workflows page

This commit is contained in:
Ida Dittrich 2025-08-21 18:09:19 +02:00
parent 574e3e1cfa
commit 41a3b8f40e
29 changed files with 1439 additions and 1114 deletions

View file

@ -1,7 +1,7 @@
:root {
--color-bg: #F8F9FA; /* war vorher surface */
--color-surface: #EFEDE5; /* war vorher bg */
--color-text: #181818;
--color-text: #3A3A3A;
--color-primary: #C7C5B2;
--color-primary-hover: #D9D7C6;
@ -19,9 +19,17 @@
--color-secondary-red-hover: #D46872;
--color-secondary-red-disabled: #E8B7BA;
--color-gray: #181818;
--color-gray-hover: #2A2A2A;
--color-gray-disabled: #9B9B9B;
--color-gray: #6F7373;
--color-gray-hover: #565A5A;
--color-gray-disabled: #B7BBBA;
--color-medium-gray: #E0DDD3;
--color-medium-gray-hover: #D1CEC5;
--color-medium-gray-disabled: #E0DDD380;
--color-highlight-gray: #F5F3ED;
--color-highlight-gray-hover: #E6E3DC;
--color-highlight-gray-disabled: #F5F3ED80;
--font-family: "DM Sans", sans-serif;
}

View file

@ -1,24 +1,16 @@
import React from "react";
import { Prompt, WorkflowState, WorkflowActions } from "./dashboardChatAreaTypes";
import { DashboardChatProps } from "./dashboardChatAreaTypes";
import DashboardChatArea from './DashboardChatArea.tsx';
import styles from './DashboardChatAreaStyles/DashboardChat.module.css';
interface DashboardChatProps {
selectedPrompt?: Prompt | null;
workflowState: WorkflowState;
workflowActions: WorkflowActions;
}
const DashboardChat: React.FC<DashboardChatProps> = ({
selectedPrompt,
workflowState,
workflowActions
}) => {
return (
<div className={styles.dashboard_chat}>
<DashboardChatArea
selectedPrompt={selectedPrompt}
workflowState={workflowState}
workflowActions={workflowActions}
/>

View file

@ -7,7 +7,6 @@ import { DashboardChatAreaProps } from "./dashboardChatAreaTypes";
import styles from './DashboardChatAreaStyles/DashboardChat.module.css';
const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
selectedPrompt,
workflowState,
workflowActions
}) => {
@ -32,7 +31,6 @@ const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
{/* Bottom Left: Input Area */}
<div className={`${styles.quadrant} ${styles.input_quadrant}`}>
<InputArea
selectedPrompt={selectedPrompt}
workflowState={workflowState}
workflowActions={workflowActions}
attachedFiles={attachedFiles}

View file

@ -1,22 +1,8 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useFileDownload } from '../../../hooks/useWorkflows';
import { FileInfo } from './dashboardChatAreaTypes';
interface AttachedFile {
id: number;
name: string;
size: number;
type: string;
fileData?: File;
objectUrl?: string;
}
interface ConnectedFilesProps {
onFileSelect?: (file: FileInfo) => void;
selectedFile?: FileInfo | null;
attachedFiles?: AttachedFile[];
onRemoveFile?: (fileId: number) => void;
}
import { ConnectedFilesProps } from './dashboardChatAreaTypes';
import styles from './DashboardChatAreaStyles/DashboardChatConnectedFiles.module.css';
import { IoIosAttach } from 'react-icons/io';
const ConnectedFiles: React.FC<ConnectedFilesProps> = ({
onFileSelect,
@ -24,37 +10,16 @@ const ConnectedFiles: React.FC<ConnectedFilesProps> = ({
attachedFiles = [],
onRemoveFile
}) => {
const [files, setFiles] = useState<FileInfo[]>([]);
const { downloadFile, isDownloading } = useFileDownload();
const convertedFiles = attachedFiles.map(file => ({
id: file.id,
name: file.name,
mimeType: file.type,
size: file.size,
creationDate: new Date().toISOString(),
fileData: file.fileData,
objectUrl: file.objectUrl
}));
// Convert attached files to FileInfo format for compatibility with preview
const convertedAttachedFiles = attachedFiles.map(file => {
console.log('ConnectedFiles: Converting attached file:', file.name, 'Has fileData:', !!file.fileData, 'Has objectUrl:', !!file.objectUrl);
return {
id: file.id,
name: file.name,
mimeType: file.type,
size: file.size,
creationDate: new Date().toISOString(),
fileData: file.fileData,
objectUrl: file.objectUrl
};
});
// Combine attached files with workflow files
const allFiles = [...convertedAttachedFiles, ...files];
useEffect(() => {
// Could load workflow-specific files here in the future
}, []);
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('text/')) return '📝';
return '📎';
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
@ -62,142 +27,52 @@ const ConnectedFiles: React.FC<ConnectedFilesProps> = ({
return Math.round(bytes / (1024 * 1024)) + ' MB';
};
const handleFileClick = (file: any) => {
if (onFileSelect) {
console.log('ConnectedFiles: Selecting file:', file.name, 'Has fileData:', !!file.fileData, 'Has objectUrl:', !!file.objectUrl);
onFileSelect(file);
}
};
const handleDownload = async (file: FileInfo) => {
await downloadFile(file.id, file.name);
};
return (
<div style={{ padding: '16px', height: '100%', overflow: 'auto' }}>
{/* Show attached files count */}
<div className={styles.container}>
{attachedFiles.length > 0 && (
<div style={{
marginBottom: '12px',
padding: '8px',
backgroundColor: '#e3f2fd',
borderRadius: '6px',
fontSize: '12px',
color: '#1976d2',
fontWeight: '500'
}}>
📎 {attachedFiles.length} file{attachedFiles.length !== 1 ? 's' : ''} attached for workflow
<div className={styles.attachedInfo}>
<IoIosAttach className={styles.attachedInfoIcon}/> {attachedFiles.length} file{attachedFiles.length !== 1 ? 's' : ''} attached for workflow
</div>
)}
{allFiles.length === 0 ? (
<p style={{ color: 'var(--color-gray)', textAlign: 'center' }}>
{convertedFiles.length === 0 ? (
<p className={styles.emptyState}>
No files connected to this workflow
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{allFiles.map((file) => {
const isAttachedFile = attachedFiles.some(af => af.id === file.id);
return (
<div
key={file.id}
onClick={() => handleFileClick(file)}
style={{
padding: '12px',
border: `1px solid ${selectedFile?.id === file.id ? 'var(--color-secondary)' : 'var(--color-gray-disabled)'}`,
borderRadius: '8px',
cursor: 'pointer',
backgroundColor: selectedFile?.id === file.id ? 'var(--color-secondary-disabled)' : 'var(--color-bg)',
display: 'flex',
alignItems: 'center',
gap: '12px',
// Highlight attached files
...(isAttachedFile && {
borderColor: '#1976d2',
backgroundColor: '#f3f8ff'
})
}}
>
<span style={{ fontSize: '20px' }}>
{getFileIcon(file.mimeType)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontWeight: '500',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
{file.name}
{isAttachedFile && (
<span style={{
fontSize: '10px',
backgroundColor: '#1976d2',
color: 'white',
padding: '2px 6px',
borderRadius: '10px',
fontWeight: 'normal'
}}>
ATTACHED
</span>
)}
</div>
<div style={{
fontSize: '12px',
color: 'var(--color-gray)'
}}>
{file.size ? formatFileSize(file.size) : 'Unknown size'}
</div>
<div className={styles.filesList}>
{convertedFiles.map((file) => (
<div
key={file.id}
onClick={() => onFileSelect?.(file)}
className={`${styles.fileItem} ${selectedFile?.id === file.id ? styles.selected : ''} ${styles.attached}`}
>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{file.name}
</div>
<div style={{ display: 'flex', gap: '4px' }}>
{isAttachedFile && onRemoveFile && (
<button
onClick={(e) => {
e.stopPropagation();
onRemoveFile(file.id);
}}
style={{
padding: '4px 8px',
fontSize: '12px',
backgroundColor: 'transparent',
border: '1px solid #ff6b6b',
borderRadius: '4px',
cursor: 'pointer',
color: '#ff6b6b'
}}
title="Remove from attachment"
>
</button>
)}
<div className={styles.fileSize}>
{file.size ? formatFileSize(file.size) : 'Unknown size'}
</div>
</div>
<div className={styles.fileActions}>
{onRemoveFile && (
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
onRemoveFile(file.id);
}}
disabled={isDownloading}
style={{
padding: '4px 8px',
fontSize: '12px',
backgroundColor: 'transparent',
border: '1px solid var(--color-gray-disabled)',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Download file"
className={styles.removeButton}
title="Remove from attachment"
>
</button>
</div>
)}
</div>
);
})}
</div>
))}
</div>
)}
</div>

View file

@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import FileAttachmentPopup from './FileAttachmentPopup';
import { Prompt, WorkflowState, WorkflowActions, InputAreaProps, AttachedFile } from './dashboardChatAreaTypes';
import { InputAreaProps, AttachedFile } from './dashboardChatAreaTypes';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useSimplePrompts } from '../../../hooks/usePrompts';
import styles from './DashboardChatAreaStyles/DashboardChatAreaInput.module.css';
import sharedStyles from './DashboardChatAreaStyles/DashboardChat.module.css';
@ -9,13 +10,13 @@ import sharedStyles from './DashboardChatAreaStyles/DashboardChat.module.css';
const InputArea: React.FC<InputAreaProps> = ({
selectedPrompt,
workflowState,
workflowActions,
attachedFiles: externalAttachedFiles = [],
onAttachedFilesChange
}) => {
const { t } = useLanguage();
const { prompts, loading: promptsLoading } = useSimplePrompts();
const [inputValue, setInputValue] = useState('');
const [showFilePopup, setShowFilePopup] = useState(false);
const [isSending, setIsSending] = useState(false);
@ -48,12 +49,15 @@ const InputArea: React.FC<InputAreaProps> = ({
textarea.style.height = `${newHeight}px`;
};
// Get the current selected prompt from workflow state
const currentSelectedPrompt = workflowState.selectedPrompt;
// Auto-fill input when prompt is selected
useEffect(() => {
if (selectedPrompt) {
setInputValue(selectedPrompt.content);
if (currentSelectedPrompt) {
setInputValue(currentSelectedPrompt.content);
}
}, [selectedPrompt]);
}, [currentSelectedPrompt]);
// Adjust height when input value changes
useEffect(() => {
@ -118,6 +122,24 @@ const InputArea: React.FC<InputAreaProps> = ({
}
};
const handlePromptSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const promptId = e.target.value;
if (promptId === '') {
workflowActions.clearPrompt();
setInputValue('');
} else {
const prompt = prompts.find(p => p.id === promptId);
if (prompt) {
workflowActions.selectPrompt(prompt);
}
}
};
const handleClearPrompt = () => {
workflowActions.clearPrompt();
setInputValue('');
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
@ -225,12 +247,34 @@ const InputArea: React.FC<InputAreaProps> = ({
</div>
)}
{/* Show attached files count */}
{currentAttachedFiles.length > 0 && (
<div className={styles.attached_files_count}>
{currentAttachedFiles.length} {currentAttachedFiles.length !== 1 ? t('chat.input.files_attached_plural') : t('chat.input.files_attached')} {t('chat.input.files_attached_label')}
{/* Prompt selection dropdown */}
<div className={styles.prompt_selection_container}>
<div className={styles.prompt_dropdown_wrapper}>
<select
value={currentSelectedPrompt?.id || ''}
onChange={handlePromptSelect}
disabled={isSending || shouldShowStopButton || promptsLoading}
className={styles.prompt_dropdown}
>
<option value="">{promptsLoading ? t('chat.input.loading_prompts') : t('chat.input.select_prompt')}</option>
{prompts.map(prompt => (
<option key={prompt.id} value={prompt.id}>
{prompt.name}
</option>
))}
</select>
{currentSelectedPrompt && (
<button
onClick={handleClearPrompt}
disabled={isSending || shouldShowStopButton}
className={styles.clear_prompt_button}
title={t('chat.input.clear_prompt')}
>
</button>
)}
</div>
)}
</div>
<div className={styles.input_form_container}>
<div className={styles.floating_label_textarea}>
@ -251,7 +295,9 @@ const InputArea: React.FC<InputAreaProps> = ({
onBlur={() => setIsFocused(false)}
placeholder=""
disabled={isSending || shouldShowStopButton}
className={styles.message_textarea}
className={`${styles.message_textarea} ${
!isFocused && inputValue.trim().length > 0 ? styles.message_textarea_with_content : ''
}`}
rows={4}
/>
</div>
@ -295,17 +341,11 @@ const InputArea: React.FC<InputAreaProps> = ({
{workflowState.currentWorkflowId && !shouldShowStopButton && (
<button
onClick={() => workflowActions.clearWorkflow()}
className={styles.new_chat_button}
className={sharedStyles.button_primary}
>
{t('chat.input.new_chat')}
</button>
)}
{selectedPrompt && (
<span className={styles.prompt_indicator}>
{t('chat.input.using_prompt')} {selectedPrompt.name}
</span>
)}
</div>
</div>

View file

@ -100,12 +100,6 @@ const LogItem: React.FC<LogItemProps> = ({ log }) => {
Source: {log.source}
</div>
)}
{log.progress !== undefined && log.progress >= 0 && (
<div className={messageStyles.log_progress}>
Progress: {log.progress}%
</div>
)}
</div>
);
};

View file

@ -2,6 +2,8 @@ import React from "react";
import { useFileDownload } from "../../../hooks/useWorkflows";
import { Message, Document } from "./dashboardChatAreaTypes";
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
import { FaDownload, FaEye } from "react-icons/fa";
interface MessageItemProps {
message: Message;
@ -9,8 +11,7 @@ interface MessageItemProps {
onFilePreview?: (file: any) => void;
}
// Helper function to format file size
const formatFileSize = (bytes?: number): string => {
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
@ -18,144 +19,61 @@ const formatFileSize = (bytes?: number): string => {
};
// Helper function to get file icon based on type or extension
const getFileIcon = (type?: string, ext?: string): string => {
// Use extension first if available, then fall back to MIME type
const extension = ext?.toLowerCase();
const mimeType = type?.toLowerCase();
// Check extension first
if (extension) {
// Images
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) return '🖼️';
// Videos
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'].includes(extension)) return '🎥';
// Audio
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'wma'].includes(extension)) return '🎵';
// Documents
if (extension === 'pdf') return '📕';
if (['doc', 'docx'].includes(extension)) return '📘';
if (['xls', 'xlsx'].includes(extension)) return '📊';
if (['ppt', 'pptx'].includes(extension)) return '📋';
if (['txt', 'md', 'rtf'].includes(extension)) return '📝';
// Archives
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) return '📦';
// Code files
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'py', 'java', 'cpp', 'c', 'php'].includes(extension)) return '💻';
}
// Fall back to MIME type if extension didn't match
if (mimeType) {
if (mimeType.includes('image')) return '🖼️';
if (mimeType.includes('video')) return '🎥';
if (mimeType.includes('audio')) return '🎵';
if (mimeType.includes('pdf')) return '📕';
if (mimeType.includes('text')) return '📝';
if (mimeType.includes('word') || mimeType.includes('document')) return '📘';
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊';
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return '📋';
if (mimeType.includes('zip') || mimeType.includes('archive')) return '📦';
}
return '📄';
};
const MessageItem: React.FC<MessageItemProps> = ({ message, onFilePreview }) => {
const { downloadFile, isDownloading } = useFileDownload();
const handleDocumentClick = (document: Document) => {
// If there's a downloadUrl, use it; otherwise try the url
const downloadLink = document.downloadUrl || document.url;
if (downloadLink) {
// Open the document in a new tab
window.open(downloadLink, '_blank');
}
const handleDocumentClick = (doc: Document) => {
const link = doc.downloadUrl || doc.url;
if (link) window.open(link, '_blank');
};
const handlePreview = (document: Document, e: React.MouseEvent) => {
const handlePreview = (doc: Document, e: React.MouseEvent) => {
e.stopPropagation();
const fileId = doc.fileId || parseInt(doc.id || '0');
if (!fileId || isNaN(fileId)) return;
// Use fileId if available, otherwise try to use id as fallback
const fileId = document.fileId || parseInt(document.id || '0');
if (!fileId || isNaN(fileId)) {
console.error('❌ Invalid file ID for preview:', document);
return;
}
// Call the parent callback to show preview in the file preview quadrant
if (onFilePreview) {
onFilePreview({
id: fileId.toString(),
name: document.name,
mimeType: document.type || 'application/octet-stream',
size: document.size,
fileId: fileId
name: doc.name,
mimeType: doc.type || 'application/octet-stream',
size: doc.size,
fileId
});
}
};
const handleDownload = async (document: Document, e: React.MouseEvent) => {
const handleDownload = async (doc: Document, e: React.MouseEvent) => {
e.stopPropagation();
const fileId = doc.fileId || parseInt(doc.id || '0');
if (!fileId) return;
console.log(`⬇️ Download requested for:`, document);
// Use fileId if available, otherwise try to use id as fallback
const fileId = document.fileId || parseInt(document.id || '0');
if (!fileId) {
console.error('❌ No file ID for download:', document);
return;
}
// Construct filename with extension if available
const fileName = document.ext ? `${document.name}.${document.ext}` : document.name;
const fileName = doc.ext ? `${doc.name}.${doc.ext}` : doc.name;
await downloadFile(fileId, fileName);
};
// Debug: Log document check before rendering
const hasDocuments = message.documents && message.documents.length > 0;
const hasDocuments = message.documents?.length > 0;
// Format timestamp
const formatTimestamp = (timestamp?: string) => {
if (!timestamp) {
return '';
}
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
return '';
}
const formatTimestamp = (ts?: string) => {
if (!ts) return '';
const date = new Date(ts);
if (isNaN(date.getTime())) return '';
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
let formatted = '';
if (isToday) {
formatted = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
formatted = date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return formatted;
return isToday
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const isUser = message.role === 'user';
return (
<div
className={`${messageStyles.message_item} ${
message.role === 'user' ? messageStyles.user : messageStyles.assistant
}`}
>
<div className={`${messageStyles.message_item} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_header}>
{message.role === 'user' ? 'You' : message.agentName}
{isUser ? 'You' : message.agentName}
{message.timestamp && (
<span className={messageStyles.message_timestamp_inline}>
{formatTimestamp(message.timestamp)}
@ -163,68 +81,57 @@ const MessageItem: React.FC<MessageItemProps> = ({ message, onFilePreview }) =>
)}
</div>
<div className={`${messageStyles.message_bubble} ${
message.role === 'user' ? messageStyles.user : messageStyles.assistant
}`}>
<div className={`${messageStyles.message_bubble} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_content}>
{message.content || (
<span className={messageStyles.message_no_content}>
[No message content]
</span>
)}
{message.content || <span className={messageStyles.message_no_content}>[No message content]</span>}
</div>
{hasDocuments && (
<div className={`${messageStyles.message_documents} ${
message.role === 'user' ? messageStyles.user : messageStyles.assistant
}`}>
<div className={`${messageStyles.message_documents_header} ${
message.role === 'user' ? messageStyles.user : messageStyles.assistant
}`}>
📎 {message.role === 'user' ? 'Uploaded' : 'Attached'} Files ({message.documents!.length})
<div className={`${messageStyles.message_documents} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={`${messageStyles.message_documents_header} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
{isUser ? 'Uploaded' : 'Attached'} Files ({message.documents?.length || 0})
</div>
<div>
{message.documents!.map((document, docIndex) => {
return (
<div
key={document.id || docIndex}
className={messageStyles.message_document_item}
onClick={() => handleDocumentClick(document)}
title={`Click to open ${document.name}`}
>
<span className={messageStyles.message_document_icon}>
{getFileIcon(document.type, document.ext)}
</span>
<div className={messageStyles.message_document_info}>
<div className={messageStyles.message_document_name}>
{document.ext ? `${document.name}.${document.ext}` : document.name}
{message.documents!.map((doc, i) => (
<div
key={doc.id || i}
className={messageStyles.message_document_item}
onClick={() => handleDocumentClick(doc)}
title={`Click to open ${doc.name}`}
>
<div className={messageStyles.message_document_info}>
<div className={messageStyles.message_document_name}>
{doc.ext ? `${doc.name}.${doc.ext}` : doc.name}
</div>
{doc.size && (
<div className={messageStyles.message_document_size}>
{formatFileSize(doc.size)}
</div>
{document.size && (
<div className={messageStyles.message_document_size}>
{formatFileSize(document.size)}
</div>
)}
</div>
<div className={messageStyles.message_document_actions}>
<button
onClick={(e) => handlePreview(document, e)}
className={messageStyles.message_document_action}
title="Preview file"
>
👁
</button>
<button
onClick={(e) => handleDownload(document, e)}
disabled={isDownloading}
className={messageStyles.message_document_action}
title="Download file"
>
</button>
</div>
)}
</div>
);
})}
<div className={messageStyles.message_document_actions}>
<button
onClick={(e) => handlePreview(doc, e)}
className={messageStyles.message_document_action_button}
title="Preview file"
>
<div className={messageStyles.message_document_action_icon}>
<FaEye size={16} />
</div>
</button>
<button
onClick={(e) => handleDownload(doc, e)}
disabled={isDownloading}
className={messageStyles.message_document_action_button}
title="Download file"
>
<div className={messageStyles.message_document_action_icon}>
<FaDownload size={16} />
</div>
</button>
</div>
</div>
))}
</div>
</div>
)}

View file

@ -3,245 +3,194 @@ import { MessageListProps } from './dashboardChatAreaTypes';
import { useApiRequest } from '../../../hooks/useApi';
import MessageItem from './DashboardChatAreaMessageItem';
import LogItem from './DashboardChatAreaLogItem';
import {
calculateWorkflowProgress,
mergeMessagesAndLogs,
transformWorkflowMessage
} from './dashboardChatAreaProgressBar';
import { mergeMessagesAndLogs, transformWorkflowMessage } from './dashboardChatAreaProgressBar';
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
import { IoIosArrowDown, IoIosChatbubbles } from 'react-icons/io';
import { useLanguage } from '../../../contexts/LanguageContext';
const MessageList: React.FC<MessageListProps> = ({
workflowState,
onFilePreview
}) => {
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 [showProgressBar, setShowProgressBar] = React.useState(false);
const [messages, setMessages] = React.useState<any[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null);
const [isScrolledUp, setIsScrolledUp] = React.useState(false);
const lastCountRef = React.useRef(0);
const [showProgress, setShowProgress] = React.useState(false);
const [progressText, setProgressText] = React.useState('');
const [progressPercentage, setProgressPercentage] = React.useState(0);
const [maxCompletedTasks, setMaxCompletedTasks] = React.useState(0);
const [progressPercent, setProgressPercent] = React.useState(0);
const [maxTasks, setMaxTasks] = React.useState(0);
// ONE single effect to handle all progress logic
React.useEffect(() => {
const pendingMessages = workflowState.pendingMessages || [];
const pending = workflowState.pendingMessages || [];
const logs = 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);
if ((pending.length > 0 || logs.length > 0) && !showProgress) {
setShowProgress(true);
setProgressPercent(0);
setMaxTasks(0);
}
// Update text based on current state
if (logs.length === 0 && pendingMessages.length > 0) {
if (logs.length === 0 && pending.length > 0) {
setProgressText(t('chat.messages.loading_progress'));
return;
}
if (logs.length > 0) {
let totalTasks = 0;
let completedTasks = 0;
let total = 0;
let completed = 0;
logs.forEach(log => {
const message = log.message || '';
const msg = log.message || '';
const startMatch = msg.match(/Executing task (\d+)\/(\d+)/i);
if (startMatch) total = Math.max(total, parseInt(startMatch[2]));
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]));
}
const completeMatch = msg.match(/✅.*Task (\d+).*completed/i);
if (completeMatch) completed = Math.max(completed, parseInt(completeMatch[1]));
});
if (totalTasks > 0) {
const currentPercentage = Math.round((completedTasks / totalTasks) * 100);
setProgressText(`${completedTasks}/${totalTasks} ${t('chat.messages.tasks')} (${currentPercentage}%)`);
if (total > 0) {
const percent = Math.round((completed / total) * 100);
setProgressText(`${completed}/${total} ${t('chat.messages.tasks')} (${percent}%)`);
// ONLY update percentage if we completed more tasks than before
if (completedTasks > maxCompletedTasks) {
setMaxCompletedTasks(completedTasks);
setProgressPercentage(currentPercentage);
if (completed > maxTasks) {
setMaxTasks(completed);
setProgressPercent(percent);
}
} else {
setProgressText(`${t('chat.messages.analyzing_workflow')} (${logs.length} ${t('chat.messages.logs')})`);
}
}
}, [workflowState.pendingMessages, workflowState.logs, showProgressBar, maxCompletedTasks, t]);
}, [workflowState.pendingMessages, workflowState.logs, showProgress, maxTasks, t]);
// Clear progress bar when workflow changes
React.useEffect(() => {
if (!workflowState.currentWorkflowId) {
setShowProgressBar(false);
setShowProgress(false);
setProgressText('');
setProgressPercentage(0);
setMaxCompletedTasks(0);
setProgressPercent(0);
setMaxTasks(0);
}
}, [workflowState.currentWorkflowId]);
const timeline = React.useMemo(() => {
return mergeMessagesAndLogs(transformedMessages, workflowState.logs || []);
}, [transformedMessages, workflowState.logs]);
return mergeMessagesAndLogs(messages, workflowState.logs || []);
}, [messages, workflowState.logs]);
// Transform messages when workflow messages change
React.useEffect(() => {
const transformMessages = async () => {
// 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 || [];
const transform = async () => {
const pending = workflowState.pendingMessages || [];
const backend = workflowState.messages || [];
const all = [...pending, ...backend];
// 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([]);
if (all.length === 0) {
setMessages([]);
return;
}
setIsTransforming(true);
setIsLoading(true);
try {
const transformed = await Promise.all(
allMessages.map(msg => transformWorkflowMessage(msg, request))
);
setTransformedMessages(transformed);
} catch (error) {
console.error('Error transforming messages:', error);
setTransformedMessages([]);
const transformed = await Promise.all(all.map(msg => transformWorkflowMessage(msg, request)));
setMessages(transformed);
} catch {
setMessages([]);
} finally {
setIsTransforming(false);
setIsLoading(false);
}
};
transformMessages();
transform();
}, [workflowState.messages, workflowState.pendingMessages, request]);
// Scroll handling
const checkScrollPosition = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
setIsUserScrolledUp(distanceFromBottom > 100);
const checkScroll = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
setIsScrolledUp(scrollHeight - scrollTop - clientHeight > 100);
}, []);
const scrollToBottom = React.useCallback(() => {
const container = scrollContainerRef.current;
if (container) {
container.scrollTop = container.scrollHeight;
}
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, []);
// Auto-scroll on new messages
React.useEffect(() => {
const currentCount = timeline.length;
const hasNewItems = currentCount > lastMessageCountRef.current;
if (hasNewItems && !isUserScrolledUp) {
const count = timeline.length;
if (count > lastCountRef.current && !isScrolledUp) {
setTimeout(scrollToBottom, 100);
}
lastMessageCountRef.current = currentCount;
}, [timeline.length, isUserScrolledUp, scrollToBottom]);
lastCountRef.current = count;
}, [timeline.length, isScrolledUp, scrollToBottom]);
const { currentWorkflowId, isLoading, error } = workflowState;
const isEmpty = timeline.length === 0 && !isLoading && !isTransforming && !currentWorkflowId;
const { currentWorkflowId, isLoading: workflowLoading, error } = workflowState;
const isEmpty = timeline.length === 0 && !workflowLoading && !isLoading && !currentWorkflowId;
return (
<div className={messageStyles.message_list_container}>
<div
ref={scrollContainerRef}
<div
ref={scrollRef}
className={isEmpty ? messageStyles.chat_messages_empty : messageStyles.chat_messages}
onScroll={checkScrollPosition}
>
{error && (
<div className={messageStyles.message_error}>
Error: {error}
</div>
)}
onScroll={checkScroll}
>
{error && <div className={messageStyles.message_error}>Error: {error}</div>}
<div className={messageStyles.messages_container}>
{timeline.map((timelineItem, index) => {
const { type, item } = timelineItem;
if (type === 'message') {
return (
<MessageItem
key={`message-${item.id}`}
message={item}
index={index}
onFilePreview={onFilePreview}
/>
);
} else if (type === 'log') {
return (
<LogItem
key={`log-${item.id}`}
log={item}
index={index}
/>
);
}
return null;
})}
</div>
<div className={messageStyles.messages_container}>
{timeline.map((item, index) => {
if (item.type === 'message') {
return (
<MessageItem
key={`message-${item.item.id}`}
message={item.item}
index={index}
onFilePreview={onFilePreview}
/>
);
} else if (item.type === 'log') {
return (
<LogItem
key={`log-${item.item.id}`}
log={item.item}
index={index}
/>
);
}
return null;
})}
</div>
{isEmpty && (
<div className={messageStyles.message_empty_state}>
<div className={messageStyles.message_empty_state_icon}>
<IoIosChatbubbles />
<div className={messageStyles.message_empty_state}>
<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>
<h3>{t('chat.messages.no_workflow_selected')}</h3>
<p>{t('chat.messages.no_workflow_selected_description')}</p>
</div>
)}
</div>
{/* Progress Bar */}
{showProgressBar && (
)}
</div>
{showProgress && (
<div className={messageStyles.workflow_progress_container}>
<div className={messageStyles.workflow_progress_label}>
<span>{t('chat.messages.workflow_progress')}</span>
<div className={messageStyles.workflow_progress_label}>
<span>{t('chat.messages.workflow_progress')}</span>
<span>{progressText}</span>
</div>
<div className={messageStyles.workflow_progress_bar}>
<div
className={messageStyles.workflow_progress_fill}
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
<div className={messageStyles.workflow_progress_bar}>
<div
className={messageStyles.workflow_progress_fill}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<button
<button
className={`${messageStyles.scroll_to_bottom_btn} ${
(isUserScrolledUp && timeline.length > 0) ? messageStyles.visible : messageStyles.hidden
(isScrolledUp && timeline.length > 0) ? messageStyles.visible : messageStyles.hidden
}`}
onClick={scrollToBottom}
title={t('chat.messages.scroll_to_bottom_btn')}
>
<IoIosArrowDown className={messageStyles.scroll_to_bottom_btn_icon} />
</button>
onClick={scrollToBottom}
title={t('chat.messages.scroll_to_bottom_btn')}
>
<IoIosArrowDown className={messageStyles.scroll_to_bottom_btn_icon} />
</button>
</div>
);
};

View file

@ -54,6 +54,8 @@
.connected_files_quadrant {
grid-row: 2;
grid-column: 2;
max-height: 200px; /* Fixed height for the connected files area */
overflow: hidden; /* Ensure no overflow at quadrant level */
}
/* Chat Messages styles moved to DashboardChatMessages.module.css */

View file

@ -26,6 +26,73 @@
margin-bottom: 12px;
}
/* Prompt Selection Styles */
.prompt_selection_container {
margin-bottom: 12px;
}
.prompt_dropdown_wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.prompt_dropdown {
flex: 1;
padding: 10px 16px;
padding-right: 40px; /* Space for the dropdown arrow */
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.8;
transition: border-color 0.2s ease, opacity 0.2s ease;
cursor: pointer;
appearance: none; /* Remove default dropdown arrow */
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M5 7l3 3 3-3'/></svg>"); background-repeat: no-repeat;
background-position: right 16px center; /* Position the custom arrow */
background-size: 12px;
}
.prompt_dropdown:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
}
.prompt_dropdown:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clear_prompt_button {
width: 32px;
height: 32px;
border: 1px solid var(--color-primary);
border-radius: 50%;
background: var(--color-bg);
color: var(--color-gray);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s ease;
}
.clear_prompt_button:hover {
background: var(--color-bg);
color: var(--color-secondary);
border: 1px solid var(--color-secondary);
}
.clear_prompt_button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.attached_files_count {
margin-bottom: 8px;
padding: 6px 10px;
@ -114,10 +181,17 @@
opacity: 0.6;
}
.message_textarea_with_content {
opacity: 0.9;
border-color: var(--color-secondary);
background: var(--color-bg);
}
.input_actions_row {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
@ -131,10 +205,6 @@
color: var(--color-gray);
}
.prompt_indicator {
font-size: 12px;
color: var(--color-gray);
}
/* Drag and Drop Styles */
.input_area_container {

View file

@ -0,0 +1,118 @@
.container {
padding: 16px;
height: 100%;
max-height: 100%;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.attachedInfo {
margin-bottom: 12px;
padding: 8px 15px;
background-color: var(--color-secondary);
border-radius: 25px;
font-size: 14px;
color: var(--color-bg);
font-weight: 500;
flex-shrink: 0;
font-family: var(--font-family);
display: flex;
align-items: center;
gap: 4px;
}
.attachedInfoIcon {
width: 1.2rem;
height: 1.2rem;
}
.emptyState {
color: var(--color-gray);
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.filesList {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.fileItem {
font-family: var(--font-family);
font-weight: 500;
padding: 10px 15px;
border: 1px solid var(--color-primary);
border-radius: 25px;
cursor: pointer;
background-color: var(--color-bg);
display: flex;
align-items: center;
gap: 12px;
color: var(--color-text);
}
.fileInfo {
flex: 1;
min-width: 0;
}
.fileName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-family);
font-weight: 500;
color: var(--color-text);
}
.fileSize {
font-size: 12px;
color: var(--color-gray);
}
.fileActions {
display: flex;
gap: 4px;
}
.removeButton {
padding: 4px 8px;
font-size: 12px;
background-color: transparent;
border: 1px solid var(--color-secondary);
border-radius: 15px;
cursor: pointer;
color: var(--color-secondary);
transition: all 0.3s ease;
}
.removeButton:hover {
padding: 4px 8px;
font-size: 12px;
background-color: var(--color-secondary);
border: 1px solid var(--color-secondary);
border-radius: 15px;
cursor: pointer;
color: white;
}
.downloadButton {
padding: 4px 8px;
font-size: 12px;
background-color: transparent;
border: 1px solid var(--color-gray-disabled);
border-radius: 4px;
cursor: pointer;
}

View file

@ -90,7 +90,7 @@
display: flex;
flex-direction: column;
max-width: 80%;
min-width: 10%;
min-width: 0%;
margin-bottom: 4px;
position: relative;
}
@ -136,10 +136,10 @@
.message_bubble {
padding: 12px 16px;
border-radius: 18px;
border-radius: 25px;
position: relative;
word-wrap: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.1);
width: 100%;
}
@ -152,9 +152,8 @@
/* Assistant message bubble */
.message_bubble.assistant {
background-color: var(--color-surface);
background-color: var(--color-highlight-gray);
color: var(--color-text);
border: 1px solid var(--color-gray-disabled);
border-bottom-left-radius: 4px;
}
@ -189,24 +188,16 @@
.message_documents {
margin-top: 8px;
padding: 8px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
background-color: color-mix(in srgb, var(--color-primary), transparent 80%);
border-radius: 15px;
border: 1px solid var(--color-primary);
width: 100%;
box-sizing: border-box;
}
/* Document styling for user messages */
.message_documents.user {
background-color: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* Document styling for assistant messages */
.message_documents.assistant {
background-color: var(--color-bg);
border: 1px solid var(--color-gray-disabled);
}
.message_documents_header {
font-size: 12px;
@ -228,17 +219,16 @@
.message_document_item {
display: flex;
align-items: center;
gap: 8px;
gap: 9px;
padding: 6px;
border-radius: 4px;
background-color: var(--color-surface);
background-color: var(--color-highlight-gray);
margin-bottom: 4px;
cursor: pointer;
color: var(--color-text);
}
.message_document_icon {
font-size: 16px;
}
.message_document_info {
flex: 1;
@ -259,16 +249,28 @@
.message_document_actions {
display: flex;
gap: 4px;
gap: 9px;
}
.message_document_action {
padding: 4px 8px;
.message_document_action_button {
width: 33px;
height: 32px;
font-size: 12px;
background-color: transparent;
border: 1px solid var(--color-gray-disabled);
border-radius: 4px;
background-color: var(--color-secondary);
border: 1px solid var(--color-secondary);
border-radius: 25px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.message_document_action_icon {
font-size: 16px;
color: white;
}
.message_document_action:hover {
@ -386,16 +388,17 @@
100% { transform: translateX(100%); }
}
/* Workflow Progress Bar (fixed at bottom, outside scrollable area) */
.workflow_progress_container {
flex-shrink: 0;
padding: 12px 16px;
background-color: var(--color-surface);
border-top: 1px solid var(--color-gray-disabled);
border-top: 1px solid var(--color-primary);
border-bottom: 1px solid var(--color-primary);
margin-top: auto;
margin-right: 15px;
}
.workflow_progress_label {
font-family: var(--font-family);
font-size: 12px;
color: var(--color-text);
margin-bottom: 8px;
@ -457,32 +460,13 @@
/* Workflow Log Messages */
.log_container {
margin-top: 8px;
padding: 8px 12px;
border-radius: 6px;
padding: 20px;
border-radius: 25px;
font-size: 12px;
font-family: 'Courier New', monospace;
border-left: 3px solid;
background-color: rgba(0, 0, 0, 0.02);
}
.log_container.info {
border-left-color: var(--color-secondary);
background-color: rgba(59, 130, 246, 0.05);
}
.log_container.warning {
border-left-color: #f59e0b;
background-color: rgba(245, 158, 11, 0.05);
}
.log_container.error {
border-left-color: #ef4444;
background-color: rgba(239, 68, 68, 0.05);
}
.log_container.debug {
border-left-color: var(--color-gray);
background-color: rgba(107, 114, 128, 0.05);
border: 1px solid var(--color-gray-disabled);
background-color: var(--color-bg);
}
.log_header {
@ -502,15 +486,19 @@
}
.log_level.info {
background-color: var(--color-gray);
}
.log_level.success {
background-color: var(--color-secondary);
}
.log_level.warning {
background-color: #f59e0b;
background-color: var(--color-primary);
}
.log_level.error {
background-color: #ef4444;
background-color: var(--color-red);
}
.log_level.debug {

View file

@ -1,276 +1,155 @@
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();
const parseLogProgress = (msg: string) => {
const m = msg.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
};
}
const taskStart = m.match(/^Executing task (\d+)\/(\d+)$/i);
if (taskStart) return { taskNumber: parseInt(taskStart[1]), totalTasks: parseInt(taskStart[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
};
}
const actionStart = m.match(/^Task (\d+) - Starting action (\d+)\/(\d+)$/i);
if (actionStart) return { taskNumber: parseInt(actionStart[1]), actionNumber: parseInt(actionStart[2]), totalActions: parseInt(actionStart[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
};
}
const actionComplete = m.match(/^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i);
if (actionComplete) return { taskNumber: parseInt(actionComplete[1]), actionNumber: parseInt(actionComplete[2]), totalActions: parseInt(actionComplete[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
};
}
const taskComplete = m.match(/^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i);
if (taskComplete) return { taskNumber: parseInt(taskComplete[1]), totalTasks: parseInt(taskComplete[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 };
}
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> } } = {};
const taskCounts: { [key: number]: { total: number; completed: Set<number> } } = {};
// Analyze logs
logs.forEach((log) => {
logs.forEach(log => {
if (!log.message) return;
const p = parseLogProgress(log.message);
const progress = parseLogProgress(log.message);
if (p.type === 'task_start' && p.totalTasks) totalTasks = Math.max(totalTasks, p.totalTasks);
if (progress.type === 'task_start' && progress.totalTasks) {
totalTasks = Math.max(totalTasks, progress.totalTasks);
if (p.type === 'action_start' && p.taskNumber && p.totalActions) {
if (!taskCounts[p.taskNumber]) taskCounts[p.taskNumber] = { total: 0, completed: new Set() };
taskCounts[p.taskNumber].total = Math.max(taskCounts[p.taskNumber].total, p.totalActions);
}
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 (p.type === 'action_complete' && p.taskNumber && p.actionNumber && p.totalActions) {
if (!taskCounts[p.taskNumber]) taskCounts[p.taskNumber] = { total: p.totalActions, completed: new Set() };
taskCounts[p.taskNumber].completed.add(p.actionNumber);
taskCounts[p.taskNumber].total = Math.max(taskCounts[p.taskNumber].total, p.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);
}
if (p.type === 'task_complete' && p.taskNumber && p.totalTasks) {
totalTasks = Math.max(totalTasks, p.totalTasks);
if (taskCounts[p.taskNumber]) {
const task = taskCounts[p.taskNumber];
for (let i = 1; i <= task.total; i++) task.completed.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);
messages.forEach(msg => {
if (msg.role !== 'assistant' || !msg.content) return;
const match = msg.content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i);
if (match) {
const taskNum = parseInt(match[1]);
if (match[2] && match[3]) {
const actionNum = parseInt(match[2]);
const totalActions = parseInt(match[3]);
if (!taskCounts[taskNum]) taskCounts[taskNum] = { total: totalActions, completed: new Set() };
taskCounts[taskNum].completed.add(actionNum);
taskCounts[taskNum].total = Math.max(taskCounts[taskNum].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;
}
if (!taskCounts[taskNum]) taskCounts[taskNum] = { total: 1, completed: new Set() };
taskCounts[taskNum].completed.add(1);
if (taskCounts[taskNum].total === 0) taskCounts[taskNum].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++;
}
let completed = 0;
for (let i = 1; i <= totalTasks; i++) {
const task = taskCounts[i];
if (task && task.total > 0 && task.completed.size === task.total) completed++;
}
if (totalTasks > 0) {
const percentage = Math.round((completedTasks / totalTasks) * 100);
return {
current: completedTasks,
total: totalTasks,
percentage: percentage,
isLoading: false
};
}
return null;
return totalTasks > 0 ? { current: completed, total: totalTasks, percentage: Math.round((completed / totalTasks) * 100), isLoading: false } : null;
};
// Safe date parsing with fallback
export const safeParseDate = (timestamp: any, fallback: number = Date.now()): Date => {
if (!timestamp) return new Date(fallback);
export const safeParseDate = (ts: any, fallback = Date.now()): Date => {
if (!ts) 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;
let d = ts;
if (typeof ts === 'number') d = ts < 10000000000 ? ts * 1000 : ts;
else if (typeof ts === 'string' && /^\d+$/.test(ts)) {
const n = parseInt(ts);
d = n < 10000000000 ? n * 1000 : n;
}
const date = new Date(dateToTry);
const date = new Date(d);
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[] = [];
const items: 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
});
messages.forEach((msg, i) => {
const ts = safeParseDate(msg.timestamp || msg.publishedAt, Date.now() - ((messages.length + logs.length) - i) * 1000);
items.push({ type: 'message', item: msg, timestamp: ts });
});
// 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
});
logs.forEach((log, i) => {
const ts = safeParseDate(log.timestamp, Date.now() - (logs.length - i) * 1000);
items.push({ type: 'log', item: log, timestamp: ts });
});
// Sort by timestamp
combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return combined;
return items.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
};
// Transform workflow message to display message
export const transformWorkflowMessage = async (workflowMessage: any, request: any): Promise<any> => {
let documents: any[] = [];
export const transformWorkflowMessage = async (msg: any, request: any): Promise<any> => {
let docs: 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`
if (msg.documents?.length > 0) {
docs = msg.documents.map((d: any) => ({
id: d.id || d.fileId,
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
name: d.filename,
ext: d.filename.split('.').pop() || 'unknown',
type: d.mimeType,
size: d.fileSize,
downloadUrl: `/api/workflows/files/${d.fileId}/download`
}));
} else if (workflowMessage.fileIds && workflowMessage.fileIds.length > 0) {
// Legacy fileIds approach
const documentPromises = workflowMessage.fileIds.map(async (fileId: number) => {
} else if (msg.fileIds?.length > 0) {
const promises = msg.fileIds.map(async (id: number) => {
try {
const response = await request({
url: `/api/workflows/files/${fileId}/preview`,
method: 'get'
});
const res = await request({ url: `/api/workflows/files/${id}/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
id: id.toString(),
fileId: id,
name: res.name || res.fileName || `File_${id}`,
ext: res.extension || res.ext || (res.name ? res.name.split('.').pop() : 'txt'),
type: res.mimeType || res.type || 'application/octet-stream',
size: res.size || 0,
downloadUrl: res.downloadUrl || res.url
};
} catch {
return { id: id.toString(), fileId: id, name: `File_${id}`, ext: 'unknown', type: 'application/octet-stream', size: 0 };
}
});
documents = await Promise.all(documentPromises);
docs = await Promise.all(promises);
}
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
id: msg.id,
role: msg.role,
agentName: msg.role === 'user' ? 'You' : 'Assistant',
content: msg.message || msg.content || msg.text || msg.body || '',
timestamp: msg.publishedAt || msg.timestamp,
documents: docs
};
};

View file

@ -1,14 +1,19 @@
// Simplified types - everything in one place
export interface Prompt {
id: number;
id: string;
mandateId: string;
name: string;
content: string;
createdAt?: string;
isShared?: boolean;
}
export interface DashboardChatProps {
workflowState: WorkflowState;
workflowActions: WorkflowActions;
}
export interface DashboardChatAreaProps {
selectedPrompt?: Prompt | null;
workflowState: WorkflowState;
workflowActions: WorkflowActions;
}
@ -126,6 +131,7 @@ export interface WorkflowState {
logs: WorkflowLog[];
isLoading: boolean;
error: string | null;
selectedPrompt: Prompt | null;
}
export interface WorkflowActions {
@ -134,6 +140,8 @@ export interface WorkflowActions {
continueWorkflow: (prompt: string, fileIds?: number[]) => Promise<boolean>;
stopWorkflow: () => Promise<boolean>;
clearWorkflow: () => void;
selectPrompt: (prompt: Prompt | null) => void;
clearPrompt: () => void;
}
export interface FileInfo {
@ -169,21 +177,27 @@ export interface Message {
}
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 AttachedFile {
id: number;
name: string;
size: number;
type: string;
fileData?: File;
objectUrl?: string;
}
export interface ConnectedFilesProps {
onFileSelect?: (file: FileInfo) => void;
selectedFile?: FileInfo | null;
attachedFiles?: AttachedFile[];
onRemoveFile?: (fileId: number) => void;
}
export interface MessageListProps {
workflowState: WorkflowState;

View file

@ -8,6 +8,7 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
const [isPolling, setIsPolling] = useState(false);
const [pendingMessages, setPendingMessages] = useState<any[]>([]);
const [sentUserMessages, setSentUserMessages] = useState<Set<string>>(new Set()); // Track sent user messages
const [selectedPrompt, setSelectedPrompt] = useState<any | null>(null); // Selected prompt state
const pollingIntervalRef = useRef<number | null>(null);
// Hook-based data fetching
@ -177,6 +178,14 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
setSentUserMessages(new Set());
}, []);
const selectPrompt = useCallback((prompt: any | null) => {
setSelectedPrompt(prompt);
}, []);
const clearPrompt = useCallback(() => {
setSelectedPrompt(null);
}, []);
// Only clear pending messages when workflow changes to a different ID
// (not when creating a new workflow)
const previousWorkflowId = useRef(currentWorkflowId);
@ -228,7 +237,8 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
pendingMessages,
logs: logs || [],
isLoading,
error
error,
selectedPrompt
};
const actions: WorkflowActions = useMemo(() => ({
@ -236,8 +246,10 @@ export function useWorkflowManager(initialWorkflowId?: string | null): [Workflow
startNewWorkflow,
continueWorkflow,
stopWorkflow,
clearWorkflow
}), [loadWorkflow, startNewWorkflow, continueWorkflow, stopWorkflow, clearWorkflow]);
clearWorkflow,
selectPrompt,
clearPrompt
}), [loadWorkflow, startNewWorkflow, continueWorkflow, stopWorkflow, clearWorkflow, selectPrompt, clearPrompt]);
return [state, actions];
}

View file

@ -3,6 +3,7 @@ import { useLanguage } from '../../contexts/LanguageContext';
import { Popup, EditForm } from '../Popup';
import styles from './DateienTable.module.css';
import { useDateienLogic } from './dateienLogic.tsx';
import { useFileOperations, type UserFile } from '../../hooks/useFiles';
import type { DateienTableProps } from './dateienInterfaces';
export function DateienTable({ className = '' }: DateienTableProps) {
@ -19,8 +20,58 @@ export function DateienTable({ className = '' }: DateienTableProps) {
editingFile,
editFileFields,
handleSaveFile,
handleCancelEdit
handleCancelEdit,
refetch
} = useDateienLogic();
// Use file operations for delete functionality
const { handleFileDelete } = useFileOperations();
// Handle single file deletion
const handleDeleteSingle = async (file: UserFile) => {
const fileName = file.file_name || file.id;
if (window.confirm(t('files.delete.confirm', 'Are you sure you want to delete "{name}"?').replace('{name}', fileName))) {
const success = await handleFileDelete(file.id, () => {
// Optimistic update - this will be called immediately
refetch();
});
if (!success) {
console.error('Delete failed for file:', file.id);
// Refetch to restore the file in case of failure
refetch();
}
}
};
// Handle multiple file deletion
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
const fileCount = filesToDelete.length;
if (window.confirm(t('files.delete.confirmMultiple', 'Are you sure you want to delete {count} files?').replace('{count}', fileCount.toString()))) {
// Start all delete operations simultaneously
const deletePromises = filesToDelete.map(async (file) => {
try {
const success = await handleFileDelete(file.id);
return { fileId: file.id, success };
} catch (error) {
console.error('Failed to delete file:', file.id, error);
return { fileId: file.id, success: false };
}
});
// Wait for all deletions to complete
const results = await Promise.all(deletePromises);
// Check if any deletions failed
const failedDeletions = results.filter(result => !result.success);
if (failedDeletions.length > 0) {
console.error('Some file deletions failed:', failedDeletions);
}
// Refresh the file list regardless of individual failures
refetch();
}
};
// Show error state
if (error) {
@ -41,7 +92,6 @@ export function DateienTable({ className = '' }: DateienTableProps) {
<FormGenerator
data={files}
columns={columns}
title={t('files.table.title')}
loading={loading}
searchable={true}
filterable={true}
@ -49,8 +99,9 @@ export function DateienTable({ className = '' }: DateienTableProps) {
resizable={true}
pagination={true}
pageSize={10}
selectable={false}
onRowClick={undefined}
onDelete={handleDeleteSingle}
onDeleteMultiple={handleDeleteMultiple}
actions={actions}
className={styles.dateienFormGenerator}
/>

View file

@ -14,6 +14,64 @@
margin-bottom: 10px;
}
/* Integrated Delete Controls - appears inside the controls container */
.deleteControlsIntegrated {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.deleteButton,
.deleteAllButton {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
border-radius: 20px;
font-size: 14px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
color: white;
background: var(--color-secondary);
}
.deleteButton {
background: var(--color-secondary);
color: white;
}
.deleteButton:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.deleteAllButton {
background: var(--color-secondary);
color: white;
}
.deleteAllButton:hover {
background: var(--color-secondary-hover);
transform: translateY(-1px);
}
.deleteIcon {
font-size: 16px;
font-weight: bold;
}
.selectionInfo {
color: var(--color-text);
font-size: 14px;
font-family: var(--font-family);
margin-left: auto;
opacity: 0.8;
}
/* Controls Section */
.controls {
display: flex;
@ -220,7 +278,7 @@
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
max-height: 90%;
max-height: 70%;
}
.loading {
@ -253,7 +311,6 @@
user-select: none;
position: relative;
z-index: 10;
}
.th.actionsColumn {
@ -303,6 +360,7 @@
vertical-align: middle;
}
.tr {
transition: background-color 0.2s ease;
}
@ -323,36 +381,61 @@
.selectColumn {
text-align: center;
padding: 8px !important;
background: var(--color-bg);
position: relative;
}
/* Selection Column border only on body cells, not header */
tbody .selectColumn {
border-top: 1px solid var(--color-primary);
}
.selectColumn input[type="checkbox"] {
cursor: pointer;
transform: scale(1.2);
transform: scale(1.3);
width: 16px;
height: 16px;
accent-color: var(--color-secondary);
margin: 0;
padding: 0;
border: 2px solid var(--color-primary);
border-radius: 3px;
background: var(--color-bg);
position: relative;
z-index: 1;
appearance: auto;
-webkit-appearance: checkbox;
-moz-appearance: checkbox;
}
/* Actions Column - Resizable like other columns */
.selectColumn input[type="checkbox"]:checked {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
}
.selectColumn input[type="checkbox"]:hover {
border-color: var(--color-secondary);
}
.selectColumn input[type="checkbox"]:focus {
outline: 2px solid var(--color-secondary);
outline-offset: 2px;
}
/* Actions Column - Fixed width like select column */
.actionsColumn {
white-space: nowrap;
text-align: left;
text-align: center;
padding: 8px !important;
font-weight: 400;
box-sizing: border-box;
background: var(--color-bg);
position: relative;
}
/* Actions Column header */
thead .actionsColumn {
text-align: center;
padding: 8px !important;
}
/* Actions Column border only on body cells, not header */
tbody .actionsColumn {
border-top: 1px solid var(--color-primary);
text-align: center;
}
.actionButtons {
@ -469,6 +552,25 @@ tbody .actionsColumn {
/* Responsive Design */
@media (max-width: 768px) {
.deleteControlsIntegrated {
flex-direction: column;
align-items: stretch;
gap: 10px;
width: 100%;
}
.deleteButton,
.deleteAllButton {
justify-content: center;
padding: 10px 16px;
}
.selectionInfo {
text-align: center;
margin-left: 0;
margin-top: 5px;
}
.controls {
flex-direction: column;
align-items: stretch;
@ -503,7 +605,7 @@ tbody .actionsColumn {
}
.tableContainer {
max-height: 400px;
max-height: 90%px;
}
.th,
@ -560,10 +662,17 @@ tbody .actionsColumn {
.paginationButton:focus,
.searchInput:focus,
.filterInput:focus,
.filterSelect:focus {
.filterSelect:focus,
.deleteButton:focus,
.deleteAllButton:focus {
outline: none;
}
.deleteButton:focus,
.deleteAllButton:focus {
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.5);
}
/* Custom scrollbar for table container */
.tableContainer::-webkit-scrollbar {
width: 8px;

View file

@ -38,13 +38,14 @@ export interface FormGeneratorProps<T = any> {
onClick: (row: T) => void;
icon?: string | React.ReactNode | ((row: T) => React.ReactNode);
}[];
onDelete?: (row: T) => void;
onDeleteMultiple?: (rows: T[]) => void;
className?: string;
}
export function FormGenerator<T extends Record<string, any>>({
data,
columns: providedColumns,
title,
searchable = true,
filterable = true,
sortable = true,
@ -55,9 +56,11 @@ export function FormGenerator<T extends Record<string, any>>({
showPageSizeSelector = true,
onRowClick,
onRowSelect,
selectable = false,
selectable = true, // Default to true for selection functionality
loading = false,
actions = [],
onDelete,
onDeleteMultiple,
className = ''
}: FormGeneratorProps<T>) {
const { t } = useLanguage();
@ -116,17 +119,12 @@ export function FormGenerator<T extends Record<string, any>>({
useEffect(() => {
const initialWidths: Record<string, number> = {};
// Add actions column if present
if (actions.length > 0) {
initialWidths['actions'] = 120; // Default width for actions column
}
detectedColumns.forEach(col => {
// Set a default width if none specified to ensure all columns have explicit widths
initialWidths[col.key] = col.width || 150;
});
setColumnWidths(initialWidths);
}, [detectedColumns, actions]);
}, [detectedColumns]);
// Filter and search data
const filteredData = useMemo(() => {
@ -262,6 +260,34 @@ export function FormGenerator<T extends Record<string, any>>({
}
};
// Handle delete single item
const handleDeleteSingle = (row: T, index: number) => {
if (onDelete) {
onDelete(row);
// Remove from selection if it was selected
if (selectedRows.has(index)) {
const newSelected = new Set(selectedRows);
newSelected.delete(index);
setSelectedRows(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => paginatedData[i]);
onRowSelect(selectedData);
}
}
}
};
// Handle delete multiple items
const handleDeleteMultiple = () => {
if (onDeleteMultiple && selectedRows.size > 0) {
const selectedData = Array.from(selectedRows).map(i => paginatedData[i]);
onDeleteMultiple(selectedData);
// Clear selection
setSelectedRows(new Set());
onRowSelect?.([]);
}
};
// Handle page size change
const handlePageSizeChange = (newPageSize: number) => {
setCurrentPageSize(newPageSize);
@ -291,8 +317,8 @@ export function FormGenerator<T extends Record<string, any>>({
const tableContainer = tableRef.current?.parentElement;
if (tableContainer) {
const containerWidth = tableContainer.clientWidth;
const actionsColumnWidth = 0; // Actions column is now resizable like other columns
const selectColumnWidth = selectable ? 40 : 0;
const actionsColumnWidth = actions.length > 0 ? 120 : 0; // Fixed width actions column
const selectColumnWidth = selectable ? 50 : 0; // Fixed width select column
const fixedWidth = actionsColumnWidth + selectColumnWidth;
// Calculate total width of all OTHER data columns (excluding the one being resized)
@ -345,9 +371,41 @@ export function FormGenerator<T extends Record<string, any>>({
return (
<div className={`${styles.formGenerator} ${className}`}>
{(searchable || filterable) && (
{(searchable || filterable || (selectable && selectedRows.size > 0)) && (
<div className={styles.controls}>
{searchable && (
{/* Delete Controls - Show when items are selected */}
{selectable && selectedRows.size > 0 && (
<div className={styles.deleteControlsIntegrated}>
{selectedRows.size === 1 && onDelete && (
<button
onClick={() => {
const selectedIndex = Array.from(selectedRows)[0];
const selectedRow = paginatedData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex);
}}
className={styles.deleteButton}
title={t('formgen.delete.single', 'Delete selected item')}
>
<span className={styles.deleteIcon}></span>
{t('formgen.delete.single', 'Delete')}
</button>
)}
{selectedRows.size > 1 && onDeleteMultiple && (
<button
onClick={handleDeleteMultiple}
className={styles.deleteAllButton}
title={t('formgen.delete.multiple', `Delete ${selectedRows.size} selected items`)}
>
<span className={styles.deleteIcon}></span>
{t('formgen.delete.multiple', `Delete All (${selectedRows.size})`)}
</button>
)}
</div>
)}
{/* Search Controls - Hide when items are selected */}
{searchable && selectedRows.size === 0 && (
<div className={styles.searchContainer}>
<div className={styles.floatingLabelInput}>
<input
@ -523,33 +581,24 @@ export function FormGenerator<T extends Record<string, any>>({
<table ref={tableRef} className={styles.table}>
<thead>
<tr>
{actions.length > 0 && (
<th
className={`${styles.actionsColumn} ${styles.th} ${resizable ? styles.sortable : ''}`}
style={{
width: columnWidths['actions'] || 120,
minWidth: columnWidths['actions'] || 120,
maxWidth: columnWidths['actions'] || 120
}}
>
{resizable && (
<div
className={styles.resizeHandle}
onMouseDown={(e) => handleMouseDown(e, 'actions')}
/>
)}
</th>
)}
{selectable && (
<th className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<th className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}>
<input
type="checkbox"
checked={selectedRows.size === paginatedData.length && paginatedData.length > 0}
onChange={handleSelectAll}
title="Select all items"
/>
</th>
)}
{actions.length > 0 && (
<th
className={styles.actionsColumn}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
>
</th>
)}
{detectedColumns.map(column => (
<th
key={column.key}
@ -588,14 +637,21 @@ export function FormGenerator<T extends Record<string, any>>({
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, index)}
>
{actions.length > 0 && (
{selectable && (
<td className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}>
<input
type="checkbox"
checked={selectedRows.has(index)}
onChange={() => handleRowSelect(index)}
onClick={(e) => e.stopPropagation()}
title="Select this item"
/>
</td>
)}
{actions.length > 0 && (
<td
className={styles.actionsColumn}
style={{
width: columnWidths['actions'] || 120,
minWidth: columnWidths['actions'] || 120,
maxWidth: columnWidths['actions'] || 120
}}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
>
<div className={styles.actionButtons}>
{actions.map((action, actionIndex) => (
@ -618,16 +674,6 @@ export function FormGenerator<T extends Record<string, any>>({
</div>
</td>
)}
{selectable && (
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input
type="checkbox"
checked={selectedRows.has(index)}
onChange={() => handleRowSelect(index)}
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{detectedColumns.map(column => (
<td
key={column.key}

View file

@ -143,7 +143,7 @@
}
.workflowName {
color: #f8f9fa;
color: var(--color-text);
}
.status-running {

View file

@ -1,7 +1,14 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { IoIosTrash, IoIosPlay } from 'react-icons/io';
import { MdModeEdit } from 'react-icons/md';
import { FormGenerator, ColumnConfig } from '../FormGenerator/FormGenerator';
import { useWorkflows, useWorkflowOperations, Workflow } from '../../hooks/useWorkflows';
import { useLanguage } from '../../contexts/LanguageContext';
import { Popup, EditForm } from '../Popup';
import type { EditFieldConfig } from '../Popup/EditForm';
import styles from './WorkflowsTable.module.css';
interface WorkflowsTableProps {
@ -10,14 +17,41 @@ interface WorkflowsTableProps {
function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
const { workflows, loading, error, refetch } = useWorkflows();
const navigate = useNavigate();
// Debug: Log workflow data to see the actual structure
console.log('Workflows data:', workflows);
const {
stopWorkflow,
deleteWorkflow,
stoppingWorkflows,
deleteWorkflow,
updateWorkflow,
deletingWorkflows
} = useWorkflowOperations();
const { t } = useLanguage();
// Edit modal state
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
// Configure edit fields for workflow name editing
const editWorkflowFields: EditFieldConfig[] = useMemo(() => [
{
key: 'name',
label: t('workflows.field.name', 'Workflow Name'),
type: 'string',
editable: true,
required: true,
validator: (value: string) => {
if (!value || value.trim() === '') {
return t('workflows.validation.nameRequired', 'Workflow name cannot be empty');
}
if (value.length > 100) {
return t('workflows.validation.nameTooLong', 'Workflow name cannot exceed 100 characters');
}
return null;
}
}
], [t]);
// Configure columns for the workflows table
const columns: ColumnConfig[] = useMemo(() => [
{
@ -46,9 +80,9 @@ function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string | undefined, row: Workflow) => (
formatter: (value: string | undefined) => (
<span className={styles.workflowName}>
{value || row.title || t('workflows.unnamed')}
{value || t('workflows.unnamed')}
</span>
)
},
@ -139,15 +173,9 @@ function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
], [t]);
// Handle workflow actions
const handleStopWorkflow = async (workflow: Workflow) => {
const success = await stopWorkflow(workflow.id);
if (success) {
refetch(); // Refresh the workflows list
}
};
const handleDeleteWorkflow = async (workflow: Workflow) => {
const workflowName = workflow.name || workflow.title || workflow.id;
const workflowName = workflow.name || workflow.id;
if (window.confirm(t('workflows.delete.confirm').replace('{name}', workflowName))) {
const success = await deleteWorkflow(workflow.id);
if (success) {
@ -156,35 +184,126 @@ function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
}
};
// Handle single workflow deletion for bulk delete
const handleDeleteSingle = async (workflow: Workflow) => {
const workflowName = workflow.name || workflow.id;
if (window.confirm(t('workflows.delete.confirm', 'Are you sure you want to delete "{name}"?').replace('{name}', workflowName))) {
const success = await deleteWorkflow(workflow.id);
if (success) {
refetch(); // Refresh the workflows list
} else {
console.error('Delete failed for workflow:', workflow.id);
}
}
};
// Handle multiple workflow deletion
const handleDeleteMultiple = async (workflowsToDelete: Workflow[]) => {
const workflowCount = workflowsToDelete.length;
if (window.confirm(t('workflows.delete.confirmMultiple', 'Are you sure you want to delete {count} workflows?').replace('{count}', workflowCount.toString()))) {
// Start all delete operations simultaneously
const deletePromises = workflowsToDelete.map(async (workflow) => {
try {
const success = await deleteWorkflow(workflow.id);
return { workflowId: workflow.id, success };
} catch (error) {
console.error('Failed to delete workflow:', workflow.id, error);
return { workflowId: workflow.id, success: false };
}
});
// Wait for all deletions to complete
const results = await Promise.all(deletePromises);
// Check if any deletions failed
const failedDeletions = results.filter(result => !result.success);
if (failedDeletions.length > 0) {
console.error('Some workflow deletions failed:', failedDeletions);
}
// Refresh the workflow list regardless of individual failures
refetch();
}
};
// Handle edit workflow
const handleEditWorkflow = (workflow: Workflow) => {
setEditingWorkflow(workflow);
setEditModalOpen(true);
};
// Handle save workflow
const handleSaveWorkflow = async (updatedWorkflow: Workflow) => {
if (!editingWorkflow) return;
try {
// Call API to update workflow name
const result = await updateWorkflow(editingWorkflow.id, {
name: updatedWorkflow.name
});
if (result.success) {
// Close modal
setEditModalOpen(false);
setEditingWorkflow(null);
// Refresh workflow list
await refetch();
} else {
console.error('Failed to update workflow:', result.error);
// TODO: Show error message to user
}
} catch (error) {
console.error('Failed to update workflow:', error);
// TODO: Show error message to user
}
};
// Handle cancel edit
const handleCancelEdit = () => {
setEditModalOpen(false);
setEditingWorkflow(null);
};
// Handle play workflow - navigate to dashboard with workflow ID
const handlePlayWorkflow = (workflow: Workflow) => {
// Navigate to dashboard with workflow ID as URL parameter
navigate(`/dashboard?workflowId=${workflow.id}`);
};
// Configure action buttons
const actions = useMemo(() => [
{
label: t('workflows.action.stop'),
icon: (row: Workflow) => {
const isStoppingThis = stoppingWorkflows.has(row.id);
if (isStoppingThis) return '⏳';
return '⏹️';
label: t('workflows.action.play'),
icon: (_row: Workflow) => {
return <IoIosPlay />;
},
onClick: (row: Workflow) => {
if (row.status === 'running' && !stoppingWorkflows.has(row.id)) {
handleStopWorkflow(row);
}
handlePlayWorkflow(row);
}
},
{
label: t('workflows.action.edit'),
icon: (_row: Workflow) => {
return <MdModeEdit />;
},
onClick: (row: Workflow) => {
handleEditWorkflow(row);
}
},
{
label: t('workflows.action.delete'),
icon: (row: Workflow) => {
const isDeletingThis = deletingWorkflows.has(row.id);
if (isDeletingThis) return '⏳';
return '🗑️';
icon: (_row: Workflow) => {
return <IoIosTrash />;
},
onClick: (row: Workflow) => {
if (!deletingWorkflows.has(row.id)) {
handleDeleteWorkflow(row);
}
}
}
], [t, stoppingWorkflows, deletingWorkflows, handleStopWorkflow, handleDeleteWorkflow]);
},
], [t, deletingWorkflows, handleDeleteWorkflow, handleEditWorkflow, handlePlayWorkflow]);
// Show error state
if (error) {
@ -205,7 +324,6 @@ function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
<FormGenerator
data={workflows}
columns={columns}
title={t('workflows.table.title')}
searchable={true}
filterable={true}
sortable={true}
@ -214,12 +332,33 @@ function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
pageSize={10}
loading={loading}
actions={actions}
onDelete={handleDeleteSingle}
onDeleteMultiple={handleDeleteMultiple}
className={styles.workflowsFormGenerator}
onRowClick={(workflow: Workflow) => {
// TODO: Navigate to workflow detail view
console.log('Clicked workflow:', workflow);
}}
/>
{/* Edit Workflow Modal */}
<Popup
isOpen={editModalOpen}
title={t('workflows.edit.title', 'Edit Workflow')}
onClose={handleCancelEdit}
size="small"
>
{editingWorkflow && (
<EditForm
data={editingWorkflow}
fields={editWorkflowFields}
onSave={handleSaveWorkflow}
onCancel={handleCancelEdit}
saveButtonText={t('common.save', 'Save')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
)}
</Popup>
</div>
);
}

View file

@ -3,7 +3,8 @@ import { useApiRequest } from './useApi';
// Prompt interfaces
export interface Prompt {
id: number;
id: string;
mandateId: string;
name: string;
content: string;
createdAt?: string;
@ -25,6 +26,31 @@ export interface ShareRequest {
title?: string;
}
// Simple prompts list hook for dropdown
export function useSimplePrompts() {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
const fetchPrompts = async () => {
try {
const data = await request({
url: '/api/prompts',
method: 'get'
});
setPrompts(data || []);
} catch (error) {
// Error is already handled by useApiRequest
}
};
useEffect(() => {
fetchPrompts();
}, []);
return { prompts, loading, error, refetch: fetchPrompts };
}
// Prompts list hook
export function usePrompts() {
const [prompts, setPrompts] = useState<Prompt[]>([]);

View file

@ -118,6 +118,23 @@ export function useWorkflowOperations() {
}
};
const updateWorkflow = async (workflowId: string, updateData: Partial<{ name: string }>) => {
setDeleteError(null); // Reuse delete error state for update operations
try {
const updatedWorkflow = await request({
url: `/api/workflows/${workflowId}`,
method: 'put',
data: updateData
});
return { success: true, data: updatedWorkflow };
} catch (error: any) {
setDeleteError(error.message);
return { success: false, error: error.message };
}
};
return {
startingWorkflow,
stoppingWorkflows,
@ -128,6 +145,7 @@ export function useWorkflowOperations() {
startWorkflow,
stopWorkflow,
deleteWorkflow,
updateWorkflow,
isLoading
};
}

View file

@ -204,8 +204,11 @@ export default {
'chat.input.stopping': 'Wird gestoppt...',
'chat.input.drop_files_here': 'Dateien hier ablegen zum Anhängen',
'chat.input.drop_disabled': 'Datei-Ablage während Workflow deaktiviert',
'chat.input.new_chat': 'Neuer Chat',
'chat.input.new_chat': 'Chat leeren...',
'chat.input.using_prompt': 'Verwende Vorlage:',
'chat.input.select_prompt': 'Prompt auswählen...',
'chat.input.loading_prompts': 'Prompts werden geladen...',
'chat.input.clear_prompt': 'Prompt löschen',
// File Preview
'file_preview.loading': 'Vorschau wird geladen...',

View file

@ -207,6 +207,9 @@ export default {
'chat.input.drop_disabled': 'File drop disabled during workflow',
'chat.input.new_chat': 'New Chat',
'chat.input.using_prompt': 'Using prompt:',
'chat.input.select_prompt': 'Select a prompt...',
'chat.input.loading_prompts': 'Loading prompts...',
'chat.input.clear_prompt': 'Clear prompt',
// File Preview
'file_preview.loading': 'Loading preview...',

View file

@ -206,6 +206,9 @@ export default {
'chat.input.drop_disabled': 'Dépôt de fichiers désactivé pendant le workflow',
'chat.input.new_chat': 'Nouveau Chat',
'chat.input.using_prompt': 'Utilisation du modèle:',
'chat.input.select_prompt': 'Sélectionner un prompt...',
'chat.input.loading_prompts': 'Chargement des prompts...',
'chat.input.clear_prompt': 'Effacer le prompt',
// File Preview
'file_preview.loading': 'Chargement de l\'aperçu...',

View file

@ -1,37 +1,39 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { IoMdRefresh, IoMdArrowDropdown } from 'react-icons/io';
import { useSearchParams } from 'react-router-dom';
import { IoMdArrowDropdown } from 'react-icons/io';
import { useLanguage } from '../../contexts/LanguageContext';
import { useWorkflows } from '../../hooks/useWorkflows';
import { Prompt, Workflow } from '../../components/Dashboard/DashboardChat/dashboardChatAreaTypes';
import { Workflow } from '../../components/Dashboard/DashboardChat/dashboardChatAreaTypes';
import { useWorkflowManager } from '../../components/Dashboard/DashboardChat/useWorkflowManager';
import styles from './HomeStyles/Dashboard.module.css'
import sharedStyles from '../../components/PageManager/pages.module.css';
import DashboardChat from '../../components/Dashboard/DashboardChat/DashboardChat';
import { IoMdClose } from 'react-icons/io';
function Dashboard () {
function Dashboard() {
const { t } = useLanguage();
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Central workflow management
const [workflowState, workflowActions] = useWorkflowManager();
// Fetch workflows for dropdown
// Get workflow ID from URL parameters
const workflowIdFromUrl = searchParams.get('workflowId');
const [workflowState, workflowActions] = useWorkflowManager(workflowIdFromUrl);
const { workflows, loading: workflowsLoading, error: workflowsError } = useWorkflows();
const handleWorkflowSelect = useCallback((workflowId: string) => {
workflowActions.loadWorkflow(workflowId);
setIsDropdownOpen(false);
}, [workflowActions]);
// Clear the URL parameter once workflow is loaded
setSearchParams({});
}, [workflowActions, setSearchParams]);
const handleResetWorkflow = useCallback(() => {
workflowActions.clearWorkflow();
setSelectedPrompt(null);
}, [workflowActions]);
// Clear the URL parameter when resetting
setSearchParams({});
}, [workflowActions, setSearchParams]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@ -47,23 +49,18 @@ function Dashboard () {
const formatWorkflowId = (id: string) => `${id.substring(0, 8)}...`;
// Format workflow ID for display
const displayWorkflowId = workflowState.currentWorkflowId ?
`${workflowState.currentWorkflowId.substring(0, 8)}...` :
t('dashboard.log.no_workflow');
// Calculate workflow stats
const getWorkflowStats = () => {
if (!workflowState.workflow) return null;
const workflow = workflowState.workflow;
const messages = workflowState.messages; // Use messages from workflowState, not workflow.messages
const { workflow, messages } = workflowState;
const messageCount = messages?.length || 0;
const fileCount = messages?.reduce((count, msg) => {
return count + (msg.documents?.length || msg.fileIds?.length || 0);
}, 0) || 0;
const fileCount = messages?.reduce((count, msg) =>
count + (msg.documents?.length || msg.fileIds?.length || 0), 0) || 0;
// Aggregate stats from workflow stats and message stats
const totalTokens = (workflow.stats?.tokenCount || 0) +
(messages?.reduce((sum, msg) => sum + (msg.stats?.tokenCount || 0), 0) || 0);
const totalBytesSent = (workflow.stats?.bytesSent || 0) +
@ -73,7 +70,6 @@ function Dashboard () {
const totalErrors = (workflow.stats?.errorCount || 0) +
(messages?.reduce((sum, msg) => sum + (msg.stats?.errorCount || 0), 0) || 0);
// Calculate overall success rate
const successfulMessages = messages?.filter(msg => msg.success)?.length || 0;
const successRate = messageCount > 0 ? (successfulMessages / messageCount) * 100 : 100;
@ -99,9 +95,7 @@ function Dashboard () {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
@ -127,118 +121,109 @@ function Dashboard () {
}
};
const formatStatus = (status: string) => {
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
const formatStatus = (status: string) =>
status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
const stats = getWorkflowStats();
return (
<div className={sharedStyles.pageContainer}>
<div className={`${sharedStyles.pageCard} ${styles.dashboardPageCard}`}>
{/* Vertical Divider - spans from after title to bottom */}
<div className={styles.verticalDivider}></div>
<div className={sharedStyles.pageHeader}>
<h1 className={sharedStyles.pageTitle}>{t('nav.dashboard')}</h1>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>
<div className={styles.headerControls}>
{workflowState.currentWorkflowId ? (
<>
{(() => {
const stats = getWorkflowStats();
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
padding: '12px 20px',
backgroundColor: 'var(--color-gray-disabled)',
borderRadius: '12px',
fontSize: '13px',
fontWeight: '500'
}}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.workflow')}</span>
<span style={{ color: 'var(--color-text)', fontFamily: 'monospace' }}>{displayWorkflowId}</span>
<div className={styles.workflowStats}>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.workflow')}</span>
<span className={styles.statValueMonospace}>{displayWorkflowId}</span>
</div>
{stats && (
<>
<div className={styles.statDivider}></div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.status')}</span>
<span className={`${styles.statValue} ${styles.statValueBold}`} style={{ color: getStatusColor(stats.status) }}>
{formatStatus(stats.status)}
</span>
</div>
{stats && (
<>
<div style={{ width: '1px', height: '40px', backgroundColor: 'var(--color-primary)', opacity: 0.3 }}></div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.status')}</span>
<span style={{ color: getStatusColor(stats.status), fontWeight: '600' }}>{formatStatus(stats.status)}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.rounds')}</span>
<span style={{ color: 'var(--color-text)' }}>{stats.rounds}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.messages')}</span>
<span style={{ color: 'var(--color-text)' }}>{stats.messageCount}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.files')}</span>
<span style={{ color: 'var(--color-text)' }}>{stats.fileCount}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.tokens')}</span>
<span style={{ color: 'var(--color-text)' }}>{stats.tokenCount.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.data_sent')}</span>
<span style={{ color: 'var(--color-text)' }}>{formatBytes(stats.bytesSent)}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.data_received')}</span>
<span style={{ color: 'var(--color-text)' }}>{formatBytes(stats.bytesReceived)}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.success_rate')}</span>
<span style={{
color: stats.successRate >= 90 ? 'var(--color-success)' :
stats.successRate >= 70 ? 'var(--color-warning)' : 'var(--color-error)'
}}>{stats.successRate}%</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.errors')}</span>
<span style={{ color: stats.errorCount > 0 ? 'var(--color-error)' : 'var(--color-text)' }}>{stats.errorCount}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px', textTransform: 'uppercase' }}>{t('dashboard.stats.started')}</span>
<span style={{ color: 'var(--color-text)', fontSize: '12px' }}>{formatDate(stats.startedAt)}</span>
</div>
</>
)}
</div>
);
})()}
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.rounds')}</span>
<span className={styles.statValue}>{stats.rounds}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.messages')}</span>
<span className={styles.statValue}>{stats.messageCount}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.files')}</span>
<span className={styles.statValue}>{stats.fileCount}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.tokens')}</span>
<span className={styles.statValue}>{stats.tokenCount.toLocaleString()}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.data_sent')}</span>
<span className={styles.statValue}>{formatBytes(stats.bytesSent)}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.data_received')}</span>
<span className={styles.statValue}>{formatBytes(stats.bytesReceived)}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.success_rate')}</span>
<span className={styles.statValue} style={{
color: stats.successRate >= 90 ? 'var(--color-success)' :
stats.successRate >= 70 ? 'var(--color-warning)' : 'var(--color-error)'
}}>{stats.successRate}%</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.errors')}</span>
<span className={`${styles.statValue} ${stats.errorCount > 0 ? styles.statValueError : ''}`}>
{stats.errorCount}
</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>{t('dashboard.stats.started')}</span>
<span className={`${styles.statValue} ${styles.statValueSmall}`}>
{formatDate(stats.startedAt)}
</span>
</div>
</>
)}
</div>
<button
className={sharedStyles.secondaryButton}
className={sharedStyles.primaryButton}
onClick={handleResetWorkflow}
aria-label="Reset workflow"
>
<span className={sharedStyles.buttonIcon}><IoMdRefresh /></span>
Reset
<span className={sharedStyles.buttonIcon}><IoMdClose /></span>
</button>
</>
) : (
<div style={{ position: 'relative', display: 'inline-block' }} ref={dropdownRef}>
<div className={styles.dropdownContainer} ref={dropdownRef}>
<button
className={sharedStyles.secondaryButton}
className={`${sharedStyles.primaryButton} ${styles.dropdownButton}`}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
disabled={workflowsLoading}
aria-expanded={isDropdownOpen}
aria-haspopup="listbox"
style={{ minWidth: '180px', justifyContent: 'space-between' }}
>
<span>
{workflowsLoading
@ -249,49 +234,20 @@ function Dashboard () {
}
</span>
<IoMdArrowDropdown
className={sharedStyles.buttonIcon}
style={{
transform: isDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}
className={`${sharedStyles.buttonIcon} ${styles.dropdownIcon} ${
isDropdownOpen ? styles.dropdownIconOpen : ''
}`}
/>
</button>
{isDropdownOpen && !workflowsLoading && !workflowsError && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: '4px',
backgroundColor: 'var(--color-bg)',
border: '1px solid var(--color-primary)',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
zIndex: 1000,
maxHeight: '300px',
overflowY: 'auto'
}}>
<div style={{
padding: '12px 16px',
fontSize: '12px',
fontWeight: 600,
color: 'var(--color-text)',
backgroundColor: 'var(--color-gray-disabled)',
borderBottom: '1px solid var(--color-primary)',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
<div className={styles.dropdownMenu}>
<div className={styles.dropdownHeader}>
{t('dashboard.workflow_dropdown.available_workflows')}
</div>
{workflows.length === 0 ? (
<div style={{
padding: '12px 16px',
fontSize: '14px',
color: 'var(--color-text-secondary)',
fontStyle: 'italic'
}}>
<div className={styles.dropdownEmpty}>
{t('dashboard.workflow_dropdown.no_workflows')}
</div>
) : (
@ -299,44 +255,16 @@ function Dashboard () {
<button
key={workflow.id}
onClick={() => handleWorkflowSelect(workflow.id)}
style={{
width: '100%',
padding: '12px 16px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
fontFamily: 'var(--font-family)',
transition: 'background-color 0.2s ease',
borderBottom: '1px solid var(--color-gray-disabled)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-gray-disabled)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
className={styles.dropdownItem}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<span style={{
fontSize: '14px',
fontWeight: 500,
color: 'var(--color-text)'
}}>
<div className={styles.workflowInfo}>
<span className={styles.workflowName}>
{getWorkflowDisplayName(workflow)}
</span>
<span style={{
fontSize: '12px',
color: 'var(--color-text-secondary)',
fontFamily: 'monospace'
}}>
<span className={styles.workflowId}>
ID: {formatWorkflowId(workflow.id)}
</span>
<span style={{
fontSize: '12px',
color: 'var(--color-text-secondary)',
textTransform: 'capitalize'
}}>
<span className={styles.workflowStatus}>
{workflow.status}
</span>
</div>
@ -355,7 +283,6 @@ function Dashboard () {
<div className={styles.chatLogContainer}>
<div className={styles.chatArea}>
<DashboardChat
selectedPrompt={selectedPrompt}
workflowState={workflowState}
workflowActions={workflowActions}
/>

View file

@ -74,7 +74,7 @@ function Dateien() {
onClick={triggerFilePicker}
>
<span className={styles.dropZoneText}>
{isDragOver ? 'Drop files here' : 'Drop files here or click to browse'}
{isDragOver ? 'Drop files here' : 'Drop files here'}
</span>
</div>
<button

View file

@ -22,14 +22,13 @@
overflow: hidden;
}
/* Dashboard-specific styles */
.dashboardPageCard {
position: relative;
}
.verticalDivider {
position: absolute;
top: calc(86px + 23px + 1px); /* pageHeader height + gap + horizontalDivider height */
top: calc(86px + 23px + 1px);
bottom: 0;
left: calc(66.666% - 9px);
width: 1px;
@ -38,3 +37,159 @@
pointer-events: none;
}
.headerControls {
display: flex;
gap: 15px;
align-items: center;
}
.workflowStats {
display: flex;
align-items: center;
gap: 20px;
padding: 12px 20px;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
}
.statItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.statLabel {
color: var(--color-text-secondary);
font-size: 11px;
text-transform: uppercase;
}
.statValue {
color: var(--color-text);
}
.statValueMonospace {
color: var(--color-text);
font-family: monospace;
}
.statDivider {
width: 1px;
height: 40px;
background-color: var(--color-primary);
opacity: 0.3;
}
.dropdownContainer {
position: relative;
display: inline-block;
}
.dropdownButton {
min-width: 180px;
justify-content: space-between;
}
.dropdownIcon {
transition: transform 0.2s ease;
}
.dropdownIconOpen {
transform: rotate(180deg);
}
.dropdownMenu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background-color: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
max-height: 300px;
overflow-y: auto;
}
.dropdownHeader {
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dropdownEmpty {
padding: 12px 16px;
font-size: 14px;
color: var(--color-text);
font-style: italic;
}
.dropdownItem {
width: 100%;
padding: 12px 16px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
transition: 0.2s ease;
border-bottom: 1px solid var(--color-gray-disabled);
}
.dropdownItem:hover {
background-color: var(--color-secondary);
color: white;
}
.workflowInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.workflowName {
font-size: 14px;
font-weight: 500;
}
.workflowId {
font-size: 12px;
color: var(--color-text-secondary);
font-family: monospace;
}
.workflowStatus {
font-size: 12px;
color: var(--color-text-secondary);
text-transform: capitalize;
}
.statValueSuccess {
color: var(--color-success);
}
.statValueWarning {
color: var(--color-warning);
}
.statValueError {
color: var(--color-error);
}
.statValueBold {
font-weight: 600;
}
.statValueSmall {
font-size: 12px;
}

View file

@ -96,19 +96,18 @@
background: var(--color-bg);
cursor: pointer;
transition: all 0.2s ease;
min-width: 200px;
min-width: 400px;
text-align: center;
}
.dropZone:hover {
border-color: var(--color-secondary);
background: var(--color-gray-disabled);
}
.dropZoneActive {
border-color: var(--color-secondary);
background: var(--color-gray-disabled);
border-style: solid;
min-width: 400px;
}
.dropZoneText {