Files
igny8/frontend/src/components/common/ProgressModal.tsx
IGNY8 VPS (Salman) 4f7ab9c606 stlyes fixes
2025-12-29 19:52:51 +00:00

866 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useMemo } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
export interface ProgressModalProps {
isOpen: boolean;
title: string;
percentage: number; // 0-100
status: 'pending' | 'processing' | 'completed' | 'error';
message: string;
details?: {
current: number;
total: number;
completed: number;
currentItem?: string;
phase?: string;
};
onClose?: () => void;
onCancel?: () => void;
taskId?: string;
functionId?: string; // AI function ID for tracking (e.g., "ai-cluster-01")
stepLogs?: Array<{
stepNumber: number;
stepName: string;
status: string;
message: string;
timestamp?: number;
}>; // Step logs for debugging
}
// Success messages per function with counts
const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[]): string => {
const funcName = functionId?.toLowerCase() || title?.toLowerCase() || '';
// Extract counts from step logs
const extractCount = (pattern: RegExp, logs: any[]): string => {
for (const log of logs) {
const match = log.message?.match(pattern);
if (match && match[1]) return match[1];
}
return '';
};
if (funcName.includes('cluster')) {
const keywordCount = extractCount(/(\d+)\s+keyword/i, stepLogs || []);
const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []);
if (keywordCount && clusterCount) {
return `✓ Created ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} from ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''}`;
} else if (clusterCount) {
return `✓ Created ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
} else if (keywordCount) {
return `✓ Created clusters from ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''}`;
}
return '✓ Keywords clustered successfully';
}
if (funcName.includes('idea')) {
const ideaCount = extractCount(/(\d+)\s+idea/i, stepLogs || []);
const clusterCount = extractCount(/(\d+)\s+cluster/i, stepLogs || []);
if (ideaCount && clusterCount) {
return `✓ Generated ${ideaCount} content idea${ideaCount !== '1' ? 's' : ''} from ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
} else if (ideaCount) {
return `✓ Generated ${ideaCount} content idea${ideaCount !== '1' ? 's' : ''} with outlines`;
}
return '✓ Content ideas & outlines created successfully';
}
if (funcName.includes('content')) {
const taskCount = extractCount(/(\d+)\s+task/i, stepLogs || []);
const articleCount = extractCount(/(\d+)\s+article/i, stepLogs || []);
const wordCount = extractCount(/(\d+[,\d]*)\s+word/i, stepLogs || []);
if (articleCount && wordCount) {
return `${articleCount} article${articleCount !== '1' ? 's' : ''} generated (${wordCount} words total)`;
} else if (articleCount) {
return `${articleCount} article${articleCount !== '1' ? 's' : ''} generated`;
} else if (taskCount) {
return `${taskCount} article${taskCount !== '1' ? 's' : ''} generated`;
}
return '✓ Article generated successfully';
}
// Check for image generation from prompts FIRST (more specific)
if (funcName.includes('image') && funcName.includes('from')) {
// Image generation from prompts
const imageCount = extractCount(/(\d+)\s+image/i, stepLogs || []);
if (imageCount) {
return `${imageCount} image${imageCount !== '1' ? 's' : ''} generated and saved`;
}
return '✓ Images generated and saved';
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
// Image prompt generation
// Try to extract from SAVE step message first (most reliable)
const saveStepLog = stepLogs?.find(log => log.stepName === 'SAVE');
if (saveStepLog?.message) {
// Look for "Assigning X Prompts to Dedicated Slots"
const countMatch = saveStepLog.message.match(/Assigning (\d+)\s+Prompts/i);
if (countMatch) {
const totalPrompts = parseInt(countMatch[1], 10);
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
if (inArticleCount > 0) {
return `${totalPrompts} image prompts ready (1 featured + ${inArticleCount} in-article)`;
} else {
return `✓ 1 image prompt ready`;
}
}
}
// Try to extract from PREP step to get total count
const prepStepLog = stepLogs?.find(log => log.stepName === 'PREP');
if (prepStepLog?.message) {
const match = prepStepLog.message.match(/Mapping Content for (\d+)\s+Image Prompts/i);
if (match && match[1]) {
const totalPrompts = parseInt(match[1], 10);
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
if (inArticleCount > 0) {
return `${totalPrompts} image prompts ready (1 featured + ${inArticleCount} in-article)`;
} else {
return `✓ 1 image prompt ready`;
}
}
}
// Fallback: extract prompt count from any step log
const promptCount = extractCount(/(\d+)\s+prompt/i, stepLogs || []);
if (promptCount) {
const totalPrompts = parseInt(promptCount, 10);
const inArticleCount = totalPrompts > 1 ? totalPrompts - 1 : 0;
if (inArticleCount > 0) {
return `${totalPrompts} image prompts ready (1 featured + ${inArticleCount} in-article)`;
} else {
return `✓ 1 image prompt ready`;
}
}
// Default message
return '✓ Image prompts ready';
}
return '✓ Task completed successfully';
};
// Get step definitions per function - these are default labels that get replaced with dynamic counts
const getStepsForFunction = (functionId?: string, title?: string): Array<{phase: string, label: string}> => {
const funcName = functionId?.toLowerCase() || title?.toLowerCase() || '';
if (funcName.includes('cluster')) {
return [
{ phase: 'INIT', label: 'Validating keywords for clustering' },
{ phase: 'PREP', label: 'Analyzing keyword relationships' },
{ phase: 'AI_CALL', label: 'Grouping keywords by search intent' },
{ phase: 'PARSE', label: 'Organizing semantic clusters' },
{ phase: 'SAVE', label: 'Saving clusters' },
];
}
if (funcName.includes('idea')) {
return [
{ phase: 'INIT', label: 'Analyzing clusters for content opportunities' },
{ phase: 'PREP', label: 'Mapping keywords to topic briefs' },
{ phase: 'AI_CALL', label: 'Generating content ideas' },
{ phase: 'PARSE', label: 'Structuring article outlines' },
{ phase: 'SAVE', label: 'Saving content ideas with outlines' },
];
}
if (funcName.includes('content')) {
return [
{ phase: 'INIT', label: 'Preparing articles for generation' },
{ phase: 'PREP', label: 'Building content brief with target keywords' },
{ phase: 'AI_CALL', label: 'Writing articles with Igny8 Semantic AI' },
{ phase: 'PARSE', label: 'Formatting HTML content and metadata' },
{ phase: 'SAVE', label: 'Saving articles' },
];
}
// Check for image generation from prompts FIRST (more specific)
if (funcName.includes('image') && funcName.includes('from')) {
// Image generation from prompts
return [
{ phase: 'INIT', label: 'Queuing images for generation' },
{ phase: 'PREP', label: 'Preparing AI image generation' },
{ phase: 'AI_CALL', label: 'Generating images with AI' },
{ phase: 'PARSE', label: 'Processing generated images' },
{ phase: 'SAVE', label: 'Uploading images to media library' },
];
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
// Image prompt generation
return [
{ phase: 'INIT', label: 'Analyzing content for image opportunities' },
{ phase: 'PREP', label: 'Identifying image slots' },
{ phase: 'AI_CALL', label: 'Creating optimized prompts' },
{ phase: 'PARSE', label: 'Refining contextual image descriptions' },
{ phase: 'SAVE', label: 'Assigning prompts to image slots' },
];
}
// Default fallback
return [
{ phase: 'INIT', label: 'Initializing...' },
{ phase: 'PREP', label: 'Preparing...' },
{ phase: 'AI_CALL', label: 'Processing with Igny8 Semantic AI...' },
{ phase: 'PARSE', label: 'Processing results...' },
{ phase: 'SAVE', label: 'Saving results...' },
];
};
// Get current phase from step logs or percentage
const getCurrentPhase = (stepLogs: any[], percentage: number): string => {
if (stepLogs.length > 0) {
const lastStep = stepLogs[stepLogs.length - 1];
return lastStep.stepName || '';
}
// Fallback to percentage
if (percentage < 10) return 'INIT';
if (percentage < 25) return 'PREP';
if (percentage < 70) return 'AI_CALL';
if (percentage < 85) return 'PARSE';
if (percentage < 100) return 'SAVE';
return 'DONE';
};
// Check if step is completed
const isStepCompleted = (stepPhase: string, currentPhase: string, stepLogs: any[]): boolean => {
const phaseOrder = ['INIT', 'PREP', 'AI_CALL', 'PARSE', 'SAVE', 'DONE'];
const stepIndex = phaseOrder.indexOf(stepPhase);
const currentIndex = phaseOrder.indexOf(currentPhase);
// Step is completed if we've moved past it
if (currentIndex > stepIndex) return true;
// Or if we have a log entry for it with success status
return stepLogs.some(log =>
log.stepName === stepPhase && log.status === 'success'
);
};
// Check if step is in progress
const isStepInProgress = (stepPhase: string, currentPhase: string): boolean => {
return stepPhase === currentPhase;
};
export default function ProgressModal({
isOpen,
title,
percentage,
status,
message,
onClose,
onCancel,
taskId,
functionId,
stepLogs = [],
}: ProgressModalProps) {
// Track which steps are visually completed (with delay)
const [visuallyCompletedSteps, setVisuallyCompletedSteps] = React.useState<Set<string>>(new Set());
const stepCompletionTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const visuallyCompletedStepsRef = useRef<Set<string>>(new Set());
const lastProcessedStepLogsHashRef = useRef<string>('');
const lastProcessedPhaseRef = useRef<string>('');
const lastVisuallyCompletedCountRef = useRef<number>(0);
// Sync ref with state
useEffect(() => {
visuallyCompletedStepsRef.current = visuallyCompletedSteps;
}, [visuallyCompletedSteps]);
// Track count to detect when steps complete visually (without causing loops)
const visuallyCompletedCount = visuallyCompletedSteps.size;
// Memoize steps to prevent unnecessary re-renders
const steps = useMemo(() => getStepsForFunction(functionId, title), [functionId, title]);
// Memoize currentPhase to prevent unnecessary re-renders
const currentPhase = useMemo(() => getCurrentPhase(stepLogs, percentage), [stepLogs, percentage]);
// Create a stable hash of stepLogs to detect meaningful changes
const stepLogsHash = useMemo(() => {
return JSON.stringify(stepLogs.map(log => ({
stepName: log.stepName,
status: log.status,
})));
}, [stepLogs]);
// Format step message with counts and better formatting
const formatStepMessage = (stepPhase: string, stepLog: any, defaultLabel: string, allStepLogs: any[], functionId?: string, title?: string): string => {
const funcName = (functionId || title || '').toLowerCase();
const message = stepLog?.message || defaultLabel;
// Extract counts from message
const extractCount = (pattern: RegExp): string => {
const match = message.match(pattern);
return match && match[1] ? match[1] : '';
};
// Helper to extract count from all step logs
const extractCountFromLogs = (pattern: RegExp): string => {
for (const log of allStepLogs) {
const match = log.message?.match(pattern);
if (match && match[1]) return match[1];
}
return '';
};
if (funcName.includes('cluster')) {
if (stepPhase === 'INIT') {
// For INIT: Try to extract keyword count
const keywordCount = extractCount(/(\d+)\s+keyword/i) || extractCountFromLogs(/(\d+)\s+keyword/i);
if (keywordCount) {
return `Validating ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} for clustering`;
}
// Try to extract from "and X more keywords" format
const moreMatch = message.match(/(\d+)\s+more keyword/i);
if (moreMatch) {
const totalCount = parseInt(moreMatch[1], 10) + 3; // 3 shown + more
return `Validating ${totalCount} keywords for clustering`;
}
return 'Validating keywords for clustering';
} else if (stepPhase === 'PREP') {
// For PREP: Show "Analyzing keyword relationships"
return 'Analyzing keyword relationships';
} else if (stepPhase === 'AI_CALL') {
// For AI_CALL: Try to get keyword count
const keywordCount = extractCount(/(\d+)\s+keyword/i) || extractCountFromLogs(/(\d+)\s+keyword/i);
if (keywordCount) {
return `Grouping keywords by search intent (${keywordCount} keywords)`;
}
return 'Grouping keywords by search intent';
} else if (stepPhase === 'PARSE') {
// For PARSE: Show "Organizing X semantic clusters"
const clusterCount = extractCount(/(\d+)\s+cluster/i) || extractCountFromLogs(/(\d+)\s+cluster/i);
if (clusterCount) {
return `Organizing ${clusterCount} semantic cluster${clusterCount !== '1' ? 's' : ''}`;
}
return 'Organizing semantic clusters';
} else if (stepPhase === 'SAVE') {
// For SAVE: Show "Saving X clusters with Y keywords"
const clusterCount = extractCount(/(\d+)\s+cluster/i) || extractCountFromLogs(/(\d+)\s+cluster/i);
const keywordCount = extractCountFromLogs(/(\d+)\s+keyword/i);
if (clusterCount && keywordCount) {
return `Saving ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} with ${keywordCount} keywords`;
} else if (clusterCount) {
return `Saving ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
}
return 'Saving clusters';
}
} else if (funcName.includes('idea')) {
if (stepPhase === 'INIT') {
// For INIT: Try to extract cluster count
const clusterCount = extractCount(/(\d+)\s+cluster/i);
if (clusterCount) {
return `Analyzing ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''} for content opportunities`;
}
// Try to find cluster count in any step log
for (const log of allStepLogs) {
const count = log.message?.match(/(\d+)\s+cluster/i);
if (count && count[1]) {
return `Analyzing ${count[1]} cluster${count[1] !== '1' ? 's' : ''} for content opportunities`;
}
}
return 'Analyzing clusters for content opportunities';
} else if (stepPhase === 'PREP') {
// For PREP: Try to extract keyword count
const keywordCount = extractCount(/(\d+)\s+keyword/i);
if (keywordCount) {
return `Mapping ${keywordCount} keyword${keywordCount !== '1' ? 's' : ''} to topic briefs`;
}
return 'Mapping keywords to topic briefs';
} else if (stepPhase === 'AI_CALL') {
// For AI_CALL: Try to extract cluster count
const clusterCount = extractCount(/(\d+)\s+cluster/i);
if (clusterCount) {
return `Generating content ideas for ${clusterCount} cluster${clusterCount !== '1' ? 's' : ''}`;
}
// Try to find cluster count in any step log
for (const log of allStepLogs) {
const count = log.message?.match(/(\d+)\s+cluster/i);
if (count && count[1]) {
return `Generating content ideas for ${count[1]} cluster${count[1] !== '1' ? 's' : ''}`;
}
}
return 'Generating content ideas';
} else if (stepPhase === 'PARSE') {
// For PARSE: Show "Structuring X article outlines"
const ideaCount = extractCount(/(\d+)\s+idea/i);
if (ideaCount) {
return `Structuring ${ideaCount} article outline${ideaCount !== '1' ? 's' : ''}`;
}
// Try to find idea count in any step log
for (const log of allStepLogs) {
const count = log.message?.match(/(\d+)\s+idea/i);
if (count && count[1]) {
return `Structuring ${count[1]} article outline${count[1] !== '1' ? 's' : ''}`;
}
}
return 'Structuring article outlines';
} else if (stepPhase === 'SAVE') {
// For SAVE: Show "Saving X content ideas with outlines"
const ideaCount = extractCount(/(\d+)\s+idea/i);
if (ideaCount) {
return `Saving ${ideaCount} content idea${ideaCount !== '1' ? 's' : ''} with outlines`;
}
// Try to find idea count in any step log
for (const log of allStepLogs) {
const count = log.message?.match(/(\d+)\s+idea/i);
if (count && count[1]) {
return `Saving ${count[1]} content idea${count[1] !== '1' ? 's' : ''} with outlines`;
}
}
return 'Saving content ideas with outlines';
}
} else if (funcName.includes('content')) {
if (stepPhase === 'INIT') {
// Try to extract task/article count
const taskCount = extractCount(/(\d+)\s+task/i) || extractCount(/(\d+)\s+article/i);
if (taskCount) {
return `Preparing ${taskCount} article${taskCount !== '1' ? 's' : ''} for generation`;
}
return 'Preparing articles for generation';
} else if (stepPhase === 'PREP') {
// Try to extract keyword count
const keywordCount = extractCount(/(\d+)\s+keyword/i);
if (keywordCount) {
return `Building content brief with ${keywordCount} target keyword${keywordCount !== '1' ? 's' : ''}`;
}
return 'Building content brief with target keywords';
} else if (stepPhase === 'AI_CALL') {
// Try to extract count
const taskCount = extractCount(/(\d+)\s+task/i) || extractCount(/(\d+)\s+article/i);
if (taskCount) {
return `Writing ${taskCount} article${taskCount !== '1' ? 's' : ''} with Igny8 Semantic AI`;
}
return 'Writing articles with Igny8 Semantic AI';
} else if (stepPhase === 'PARSE') {
return 'Formatting HTML content and metadata';
} else if (stepPhase === 'SAVE') {
const articleCount = extractCount(/(\d+)\s+article/i);
const wordCount = extractCount(/(\d+[,\d]*)\s+word/i);
if (articleCount && wordCount) {
return `Saving ${articleCount} article${articleCount !== '1' ? 's' : ''} (${wordCount} words)`;
} else if (articleCount) {
return `Saving ${articleCount} article${articleCount !== '1' ? 's' : ''}`;
}
return 'Saving articles';
}
} else if (funcName.includes('image') && funcName.includes('from')) {
// Image generation from prompts
if (stepPhase === 'INIT') {
// Try to get image count
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
if (imageCount) {
return `Queuing ${imageCount} image${imageCount !== '1' ? 's' : ''} for generation`;
}
return 'Queuing images for generation';
} else if (stepPhase === 'PREP') {
// Extract image count from PREP step message
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
if (imageCount) {
return `Preparing AI image generation (${imageCount} images)`;
}
return 'Preparing AI image generation';
} else if (stepPhase === 'AI_CALL') {
// Extract current image number from message for "Generating image X/Y..."
const currentMatch = stepLog?.message?.match(/image (\d+)/i);
const totalCount = extractCountFromLogs(/(\d+)\s+image/i);
if (currentMatch && totalCount) {
return `Generating image ${currentMatch[1]}/${totalCount}...`;
} else if (currentMatch) {
return `Generating image ${currentMatch[1]}...`;
}
return 'Generating images with AI';
} else if (stepPhase === 'PARSE') {
// Extract image count from PARSE step
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
if (imageCount) {
return `Processing ${imageCount} generated image${imageCount !== '1' ? 's' : ''}`;
}
return 'Processing generated images';
} else if (stepPhase === 'SAVE') {
// Extract image count from SAVE step
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
if (imageCount) {
return `Uploading ${imageCount} image${imageCount !== '1' ? 's' : ''} to media library`;
}
return 'Uploading images to media library';
}
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
// Image prompt generation
if (stepPhase === 'INIT') {
// Try to get image count
const imageCount = extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
if (imageCount) {
return `Analyzing content for ${imageCount} image opportunit${imageCount !== '1' ? 'ies' : 'y'}`;
}
return 'Analyzing content for image opportunities';
} else if (stepPhase === 'PREP') {
// Extract total image count and calculate in-article count
const totalCount = extractCount(/(\d+)\s+Image Prompts/i) || extractCount(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+image/i);
if (totalCount) {
const total = parseInt(totalCount, 10);
const inArticleCount = total > 1 ? total - 1 : 0;
if (inArticleCount > 0) {
return `Identifying featured image and ${inArticleCount} in-article image slot${inArticleCount !== 1 ? 's' : ''}`;
}
return `Identifying featured image slot`;
}
return 'Identifying image slots';
} else if (stepPhase === 'AI_CALL') {
// For AI_CALL: Try to get count
const totalCount = extractCountFromLogs(/(\d+)\s+image/i) || extractCountFromLogs(/(\d+)\s+prompt/i);
if (totalCount) {
return `Creating optimized prompts for ${totalCount} image${totalCount !== '1' ? 's' : ''}`;
}
return 'Creating optimized prompts';
} else if (stepPhase === 'PARSE') {
// Extract in-article image count from PARSE step
const inArticleCount = extractCount(/(\d+)\s+In[-]article/i);
if (inArticleCount) {
return `Refining ${inArticleCount} contextual image description${inArticleCount !== '1' ? 's' : ''}`;
}
// Fallback: calculate from total
const totalCount = extractCountFromLogs(/(\d+)\s+image/i);
if (totalCount) {
const total = parseInt(totalCount, 10);
const inArticle = total > 1 ? total - 1 : 0;
if (inArticle > 0) {
return `Refining ${inArticle} contextual image description${inArticle !== 1 ? 's' : ''}`;
}
}
return 'Refining contextual image descriptions';
} else if (stepPhase === 'SAVE') {
// For SAVE: Extract prompt count from message
const promptCount = extractCount(/(\d+)\s+Prompts/i) || extractCount(/(\d+)\s+prompt/i) || extractCountFromLogs(/(\d+)\s+prompt/i);
if (promptCount) {
return `Assigning ${promptCount} prompt${promptCount !== '1' ? 's' : ''} to image slots`;
}
return 'Assigning prompts to image slots';
}
}
return message;
};
// Build checklist items with visual completion state (needed for allStepsVisuallyCompleted)
const checklistItems = useMemo(() => {
return steps.map((step) => {
const actuallyCompleted = isStepCompleted(step.phase, currentPhase, stepLogs);
const visuallyCompleted = visuallyCompletedSteps.has(step.phase);
// Don't show any step as in-progress (no blue styling)
// Steps are either completed (green) or pending (gray)
const inProgress = false;
// Get step log and format message
const stepLog = stepLogs.find(log => log.stepName === step.phase);
const stepMessage = formatStepMessage(step.phase, stepLog, step.label, stepLogs, functionId, title);
return {
label: stepMessage,
phase: step.phase,
completed: visuallyCompleted,
inProgress,
};
});
}, [steps, currentPhase, stepLogs, visuallyCompletedSteps, functionId, title]);
// Check if all steps are visually completed
const allStepsVisuallyCompleted = steps.length > 0 &&
steps.every(step => visuallyCompletedSteps.has(step.phase));
// Track step completions with 2-second delay between each step
useEffect(() => {
if (!isOpen) {
// Reset when modal closes
setVisuallyCompletedSteps(new Set());
visuallyCompletedStepsRef.current = new Set();
lastProcessedStepLogsHashRef.current = '';
lastProcessedPhaseRef.current = '';
lastVisuallyCompletedCountRef.current = 0;
stepCompletionTimersRef.current.forEach(timer => clearTimeout(timer));
stepCompletionTimersRef.current.clear();
return;
}
// Check if we need to process:
// 1. Backend progress changed (stepLogsHash or currentPhase)
// 2. A step completed visually (count increased)
const hashChanged = stepLogsHash !== lastProcessedStepLogsHashRef.current;
const phaseChanged = currentPhase !== lastProcessedPhaseRef.current;
const countChanged = visuallyCompletedCount > lastVisuallyCompletedCountRef.current;
if (!hashChanged && !phaseChanged && !countChanged) {
return; // Nothing changed, skip processing
}
// Update last processed values
lastProcessedStepLogsHashRef.current = stepLogsHash;
lastProcessedPhaseRef.current = currentPhase;
lastVisuallyCompletedCountRef.current = visuallyCompletedCount;
const phaseOrder = ['INIT', 'PREP', 'AI_CALL', 'PARSE', 'SAVE', 'DONE'];
// If status is completed, mark all steps as shouldBeCompleted
const allStepsShouldComplete = status === 'completed';
// Check each step in order
for (let index = 0; index < steps.length; index++) {
const step = steps[index];
const stepPhase = step.phase;
const stepIndex = phaseOrder.indexOf(stepPhase);
const currentIndex = phaseOrder.indexOf(currentPhase);
// Check if step should be completed:
// 1. Status is completed (all steps should complete)
// 2. We've moved past it (currentIndex > stepIndex)
// 3. We have a log entry for it with success status
const shouldBeCompleted = allStepsShouldComplete ||
currentIndex > stepIndex ||
stepLogs.some(log => log.stepName === stepPhase && log.status === 'success');
// If step should be completed but isn't visually completed yet and not already scheduled
if (shouldBeCompleted && !visuallyCompletedStepsRef.current.has(stepPhase) && !stepCompletionTimersRef.current.has(stepPhase)) {
// Check if previous step is visually completed (or if this is the first step)
const previousStep = index > 0 ? steps[index - 1] : null;
const previousStepCompleted = !previousStep || visuallyCompletedStepsRef.current.has(previousStep.phase);
// Only schedule if previous step is completed (or this is first step)
if (previousStepCompleted) {
// Calculate delay: 2 seconds after previous step visually completed (or 0 for first step)
const delay = previousStep ? 2000 : 0;
// Schedule completion
const timer = setTimeout(() => {
setVisuallyCompletedSteps(prev => {
const newSet = new Set([...prev, stepPhase]);
stepCompletionTimersRef.current.delete(stepPhase);
return newSet;
});
}, delay);
stepCompletionTimersRef.current.set(stepPhase, timer);
// Only process one step at a time - break after scheduling the first eligible step
break;
} else {
// Previous step is not completed yet, stop processing
break;
}
}
}
// Cleanup on unmount
return () => {
stepCompletionTimersRef.current.forEach(timer => clearTimeout(timer));
stepCompletionTimersRef.current.clear();
};
}, [isOpen, currentPhase, stepLogsHash, steps, status, visuallyCompletedCount]); // Added status and count to detect completion
// Don't auto-close - user must click close button
// Show success alert only when all steps are visually completed AND status is completed
const showSuccess = status === 'completed' && allStepsVisuallyCompleted;
const successMessage = getSuccessMessage(functionId, title, stepLogs);
return (
<Modal
isOpen={isOpen}
onClose={onClose || (() => {})}
className="max-w-lg"
showCloseButton={false}
>
<div className="p-6 min-h-[200px]">
{/* Header */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1 text-center">
{(() => {
const funcName = (functionId || title || '').toLowerCase();
if (funcName.includes('cluster')) {
// Try to extract keyword count from INIT step
const initStepLog = stepLogs.find(log => log.stepName === 'INIT');
if (initStepLog?.message) {
const message = initStepLog.message;
// Try to extract from "Validating keyword1, keyword2, keyword3 and 5 more keywords"
const countMatch = message.match(/(\d+)\s+more keyword/i);
if (countMatch) {
const moreCount = parseInt(countMatch[1], 10);
const shownCount = 3; // Typically shows 3 keywords
const totalCount = shownCount + moreCount;
return `Mapping ${totalCount} Keywords into Keyword Clusters`;
}
// Try to extract from "Validating X keywords"
const simpleMatch = message.match(/(\d+)\s+keyword/i);
if (simpleMatch) {
return `Mapping ${simpleMatch[1]} Keywords into Keyword Clusters`;
}
}
// Try to find keyword count in any step log
for (const log of stepLogs) {
const keywordCountMatch = log.message?.match(/(\d+)\s+keyword/i);
if (keywordCountMatch) {
return `Mapping ${keywordCountMatch[1]} Keywords into Keyword Clusters`;
}
}
} else if (funcName.includes('idea')) {
// For idea generation, use fixed heading
return 'Generating Content Ideas & Outline';
} else if (funcName.includes('image') && funcName.includes('from')) {
// For image generation from prompts
return 'Generate Images';
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
// For image prompt generation
return 'Smart Image Prompts';
} else if (funcName.includes('image')) {
// Fallback for other image functions
return 'Generate Images';
}
return title;
})()}
</h3>
{/* Subtitle for image functions */}
{(() => {
const funcName = (functionId || title || '').toLowerCase();
if (funcName.includes('image') && funcName.includes('from')) {
// Image generation from prompts
return (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">
Generating images from prompts using AI
</p>
);
} else if (funcName.includes('image') && (funcName.includes('prompt') || funcName.includes('extract'))) {
// Image prompt generation
return (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">
Powered by Igny8 Visual Intelligence
</p>
);
}
return null;
})()}
{!showSuccess && status !== 'completed' && (
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
{(() => {
const funcName = (functionId || title || '').toLowerCase();
// For image generation, show a more specific message
if (funcName.includes('image') && funcName.includes('from')) {
// Get current step message from step logs
const currentStepLog = stepLogs.find(log => log.stepName === currentPhase);
if (currentStepLog?.message) {
return currentStepLog.message;
}
// Fallback to step label
const currentStep = steps.find(s => s.phase === currentPhase);
return currentStep?.label || 'Generating images...';
}
// For other functions, use the message prop
return message;
})()}
</p>
)}
{status === 'completed' && !allStepsVisuallyCompleted && (
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">Processing...</p>
)}
{/* Spinner below heading - show when processing OR when completed but steps not all visually done */}
{(status === 'processing' || (status === 'completed' && !allStepsVisuallyCompleted)) && (
<div className="flex justify-center mt-4">
<svg className="w-8 h-8 text-brand-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
)}
</div>
{/* Success Alert (shown when all steps are visually completed) */}
{showSuccess && (
<div className="mb-6">
{/* Big centered check icon */}
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-full bg-success-600 dark:bg-success-700 flex items-center justify-center">
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
</div>
{/* Dark success alert box with centered text */}
<div className="p-5 rounded-lg bg-success-600 dark:bg-success-700 border border-success-700 dark:border-success-600">
<div className="text-base font-semibold text-white text-center whitespace-pre-line">
{successMessage}
</div>
</div>
</div>
)}
{/* Checklist-style Progress Steps - Always visible */}
<div className="mb-6 space-y-3">
{checklistItems.map((item, index) => (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all ${
item.completed
? 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-60'
}`}
>
{/* Icon - only checkmark for completed, gray circle for pending */}
<div className="flex-shrink-0">
{item.completed ? (
<svg className="w-5 h-5 text-success-600 dark:text-success-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600" />
)}
</div>
{/* Step Text */}
<span
className={`flex-1 text-sm font-medium ${
item.completed
? 'text-success-800 dark:text-success-300'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{item.label}
</span>
</div>
))}
</div>
{/* Footer */}
{showSuccess && onClose && (
<div className="flex justify-center mt-6">
<Button
variant="primary"
size="lg"
onClick={onClose}
className="bg-success-600 hover:bg-success-700 dark:bg-success-700 dark:hover:bg-success-800 text-white px-8 py-3 text-base font-semibold"
>
Close
</Button>
</div>
)}
{onCancel && !showSuccess && status !== 'error' && (
<div className="flex justify-end gap-3 mt-6">
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={status === 'processing'}
>
Cancel
</Button>
</div>
)}
{status === 'error' && onClose && (
<div className="flex justify-end gap-3 mt-6">
<Button variant="primary" size="sm" onClick={onClose}>
Dismiss
</Button>
</div>
)}
</div>
</Modal>
);
}