From 71a380e9f60d144a3bf540741bae9830999a1d41 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Wed, 29 Oct 2025 20:31:15 +0200 Subject: [PATCH 1/6] feat: integrate error handling and toast notifications for improved user feedback - Added AIOperationErrorBoundary and WorkspaceErrorBoundary components for specialized error handling in AI operations and workspace interactions. - Introduced ErrorStateWithRetry component to provide users with retry options during errors. - Implemented ErrorToastIntegration to display toast notifications for errors, warnings, and info messages. - Enhanced App component to include ToastContainer and ErrorToastIntegration for global error handling. - Updated various components to support operation progress tracking and display multi-format progress during processing. - Added HelpPanel and HelpButton components for user assistance and guidance throughout the application. --- src/App.tsx | 4 + .../errors/AIOperationErrorBoundary.tsx | 183 +++++ src/components/errors/ErrorStateWithRetry.tsx | 126 ++++ .../errors/WorkspaceErrorBoundary.tsx | 122 ++++ src/components/errors/index.ts | 11 + .../feedback/ErrorToastIntegration.tsx | 88 +++ src/components/help/HelpButton.tsx | 112 +++ src/components/help/HelpPanel.tsx | 295 ++++++++ src/components/help/HelpPanelContext.tsx | 66 ++ src/components/input/TextInputPanel.tsx | 28 + src/components/layout/AppHeader.tsx | 4 + src/components/layout/AppLayout.tsx | 17 +- src/components/ui/MultiFormatProgress.tsx | 227 ++++++ src/components/ui/ProcessingState.tsx | 76 +- src/components/ui/RetryButton.tsx | 138 ++++ src/components/ui/SuccessCelebration.tsx | 222 ++++++ src/components/ui/SuccessMessage.tsx | 156 +++- src/components/ui/Toast.tsx | 196 +++++ src/components/ui/ToastContainer.tsx | 35 + src/components/ui/index.ts | 11 + src/constants/formats.ts | 3 + src/hooks/useContentExtraction.ts | 207 ++++-- src/hooks/useFormatTransform.ts | 416 ++++++++++- src/hooks/useHelpPanel.ts | 55 ++ src/hooks/useProgressThrottle.ts | 166 +++++ src/hooks/useRetryableOperation.ts | 164 +++++ src/hooks/useRewriter.ts | 160 ++++- src/hooks/useToast.ts | 75 ++ src/hooks/useTranslator.ts | 672 ++++++++++++++++++ src/lib/chrome-ai/types/progress.ts | 243 +++++++ src/lib/chrome-ai/utils/progressCalculator.ts | 327 +++++++++ src/lib/feedback/toastManager.ts | 155 ++++ src/lib/help/helpContent.ts | 512 +++++++++++++ src/pages/workspace/WorkspacePage.tsx | 86 ++- 34 files changed, 5221 insertions(+), 137 deletions(-) create mode 100644 src/components/errors/AIOperationErrorBoundary.tsx create mode 100644 src/components/errors/ErrorStateWithRetry.tsx create mode 100644 src/components/errors/WorkspaceErrorBoundary.tsx create mode 100644 src/components/errors/index.ts create mode 100644 src/components/feedback/ErrorToastIntegration.tsx create mode 100644 src/components/help/HelpButton.tsx create mode 100644 src/components/help/HelpPanel.tsx create mode 100644 src/components/help/HelpPanelContext.tsx create mode 100644 src/components/ui/MultiFormatProgress.tsx create mode 100644 src/components/ui/RetryButton.tsx create mode 100644 src/components/ui/SuccessCelebration.tsx create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/components/ui/ToastContainer.tsx create mode 100644 src/hooks/useHelpPanel.ts create mode 100644 src/hooks/useProgressThrottle.ts create mode 100644 src/hooks/useRetryableOperation.ts create mode 100644 src/hooks/useToast.ts create mode 100644 src/hooks/useTranslator.ts create mode 100644 src/lib/chrome-ai/types/progress.ts create mode 100644 src/lib/chrome-ai/utils/progressCalculator.ts create mode 100644 src/lib/feedback/toastManager.ts create mode 100644 src/lib/help/helpContent.ts diff --git a/src/App.tsx b/src/App.tsx index ba5ef24..78deb41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react' import { RouterProvider } from 'react-router-dom' import { AppErrorBoundary } from '@/components/errors/AppErrorBoundary' +import { ToastContainer } from '@/components/ui/ToastContainer' +import { ErrorToastIntegration } from '@/components/feedback/ErrorToastIntegration' import { appRouter } from '@/routes/app-router' import { loadPreferences } from '@/lib/storage/preferences' @@ -22,6 +24,8 @@ const App = () => { return ( + + ) } diff --git a/src/components/errors/AIOperationErrorBoundary.tsx b/src/components/errors/AIOperationErrorBoundary.tsx new file mode 100644 index 0000000..ee1dbdb --- /dev/null +++ b/src/components/errors/AIOperationErrorBoundary.tsx @@ -0,0 +1,183 @@ +/* eslint-disable react-refresh/only-export-components */ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { reportError } from '@/lib/errors/report-error' +import { ErrorStateWithRetry } from './ErrorStateWithRetry' +import { isRewriterError } from '@/lib/chrome-ai/errors/errorGuards' +import type { RewriterError } from '@/lib/chrome-ai/errors' + +export type AIOperationErrorBoundaryProps = { + children: ReactNode + operation?: 'rewrite' | 'translate' | 'prompt' | 'transform' | 'extract' + onRetry?: () => void + onCancel?: () => void + fallback?: ReactNode + maxRetries?: number +} + +type AIOperationErrorBoundaryState = { + error: Error | null + errorInfo: ErrorInfo | null + hasError: boolean + retryCount: number +} + +/** + * Specialized error boundary for AI operations + * Provides detailed error information and retry logic for Chrome AI errors + */ +export class AIOperationErrorBoundary extends Component< + AIOperationErrorBoundaryProps, + AIOperationErrorBoundaryState +> { + state: AIOperationErrorBoundaryState = { + error: null, + errorInfo: null, + hasError: false, + retryCount: 0, + } + + static getDerivedStateFromError(error: Error): Partial { + return { + error, + hasError: true, + } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ errorInfo }) + + // Determine error type for better categorization + let errorType = 'unknown' + let severity: 'warning' | 'error' | 'fatal' = 'error' + + if (isRewriterError(error)) { + errorType = 'rewriter' + // Check if it's a retryable error + severity = error.code === 'ABORTED' ? 'warning' : 'error' + } + + reportError({ + error, + context: { + componentStack: errorInfo.componentStack, + operation: this.props.operation || 'unknown', + errorType, + retryCount: this.state.retryCount, + }, + severity, + source: 'ai-operation-error-boundary', + }) + } + + handleRetry = () => { + const maxRetries = this.props.maxRetries ?? 3 + + if (this.state.retryCount >= maxRetries) { + // Max retries reached, don't reset error + return + } + + // Increment retry count and reset error state + this.setState((prev) => ({ + error: null, + errorInfo: null, + hasError: false, + retryCount: prev.retryCount + 1, + })) + + // Call provided retry handler + this.props.onRetry?.() + } + + handleCancel = () => { + // Reset error state and retry count + this.setState({ + error: null, + errorInfo: null, + hasError: false, + retryCount: 0, + }) + + // Call provided cancel handler + this.props.onCancel?.() + } + + getOperationName = (): string => { + switch (this.props.operation) { + case 'rewrite': + return 'Rewriting' + case 'translate': + return 'Translation' + case 'prompt': + return 'AI Prompt' + case 'transform': + return 'Format Transformation' + case 'extract': + return 'Content Extraction' + default: + return 'AI Operation' + } + } + + getHelpfulMessage = (error: Error): string => { + // Provide context-specific help based on error type + if (isRewriterError(error)) { + const rewriterErr = error as RewriterError + if (rewriterErr.code === 'INITIALIZATION_FAILED') { + return 'Failed to initialize the AI service. Check your Chrome AI settings and try again.' + } + if (rewriterErr.code === 'ABORTED') { + return 'The operation was cancelled. You can try again.' + } + } + + // Generic helpful message + return 'If this error persists, try refreshing the page or checking your Chrome AI configuration.' + } + + render() { + if (this.state.hasError && this.state.error) { + // Use custom fallback if provided + if (this.props.fallback) { + return this.props.fallback + } + + const maxRetries = this.props.maxRetries ?? 3 + const canRetry = this.state.retryCount < maxRetries + const operationName = this.getOperationName() + const helpfulMessage = this.getHelpfulMessage(this.state.error) + + // Use ErrorStateWithRetry component + return ( +
+
+ {}} + currentAttempt={this.state.retryCount} + maxAttempts={maxRetries} + title={`${operationName} Error`} + showErrorDetails={true} + /> + {!canRetry && ( +

+ Maximum retry attempts reached. {helpfulMessage} +

+ )} + {this.props.onCancel && ( + + )} +
+
+ ) + } + + return this.props.children + } +} diff --git a/src/components/errors/ErrorStateWithRetry.tsx b/src/components/errors/ErrorStateWithRetry.tsx new file mode 100644 index 0000000..c07fb53 --- /dev/null +++ b/src/components/errors/ErrorStateWithRetry.tsx @@ -0,0 +1,126 @@ +/** + * ErrorStateWithRetry Component + * + * A reusable component for displaying error states with retry functionality. + * Combines error display with RetryButton for a consistent error recovery UX. + * + * Features: + * - Error message display + * - Retry button with attempt tracking + * - Optional action buttons + * - Consistent styling + * - Accessibility support + * + * Usage: + * ```tsx + * + * ``` + */ + +import { RetryButton } from '@/components/ui/RetryButton' +import { getErrorMessage } from '@/lib/chrome-ai/errors/errorGuards' + +export interface ErrorStateWithRetryProps { + error: Error | unknown + onRetry: () => void | Promise + currentAttempt?: number + maxAttempts?: number + isRetrying?: boolean + title?: string + showErrorDetails?: boolean + className?: string + actions?: React.ReactNode +} + +// Error icon SVG +const ErrorIcon = () => ( + + + +) + +export const ErrorStateWithRetry = ({ + error, + onRetry, + currentAttempt = 0, + maxAttempts = 3, + isRetrying = false, + title = 'Something went wrong', + showErrorDetails = true, + className = '', + actions, +}: ErrorStateWithRetryProps) => { + // Get user-friendly error message + const errorMessage = getErrorMessage(error) + + // Get technical error details if available + const technicalDetails = error instanceof Error ? error.message : String(error) + + return ( +
+ {/* Error Icon */} +
+ +
+ + {/* Error Content */} +
+

{title}

+ + {showErrorDetails && ( +
+

{errorMessage}

+ + {/* Technical details (collapsed by default) */} + {technicalDetails && technicalDetails !== errorMessage && ( +
+ + View technical details + +

+ {technicalDetails} +

+
+ )} +
+ )} +
+ + {/* Actions */} +
+ + + {actions} +
+ + {/* Helper text */} + {currentAttempt >= maxAttempts && ( +

+ If this problem persists, please try refreshing the page or contact support. +

+ )} +
+ ) +} diff --git a/src/components/errors/WorkspaceErrorBoundary.tsx b/src/components/errors/WorkspaceErrorBoundary.tsx new file mode 100644 index 0000000..9241d28 --- /dev/null +++ b/src/components/errors/WorkspaceErrorBoundary.tsx @@ -0,0 +1,122 @@ +/* eslint-disable react-refresh/only-export-components */ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { reportError } from '@/lib/errors/report-error' +import { ErrorStateWithRetry } from './ErrorStateWithRetry' + +export type WorkspaceErrorBoundaryProps = { + children: ReactNode + onRetry?: () => void + onReset?: () => void + fallback?: ReactNode +} + +type WorkspaceErrorBoundaryState = { + error: Error | null + errorInfo: ErrorInfo | null + hasError: boolean +} + +/** + * Error boundary for the workspace page + * Catches errors during content processing and provides retry functionality + */ +export class WorkspaceErrorBoundary extends Component< + WorkspaceErrorBoundaryProps, + WorkspaceErrorBoundaryState +> { + state: WorkspaceErrorBoundaryState = { + error: null, + errorInfo: null, + hasError: false, + } + + static getDerivedStateFromError(error: Error): Partial { + return { + error, + hasError: true, + } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ errorInfo }) + + reportError({ + error, + context: { + componentStack: errorInfo.componentStack, + location: 'workspace', + }, + severity: 'error', + source: 'workspace-error-boundary', + }) + } + + handleRetry = () => { + // Reset error state + this.setState({ + error: null, + errorInfo: null, + hasError: false, + }) + + // Call provided retry handler if available + this.props.onRetry?.() + } + + handleReset = () => { + // Reset error state + this.setState({ + error: null, + errorInfo: null, + hasError: false, + }) + + // Call provided reset handler if available + this.props.onReset?.() + } + + render() { + if (this.state.hasError && this.state.error) { + // Use custom fallback if provided + if (this.props.fallback) { + return this.props.fallback + } + + // Otherwise use ErrorStateWithRetry component + return ( +
+
+ +

+ An error occurred while processing your content. You can try again or reset the + workspace. +

+
+ + +
+
+
+ ) + } + + return this.props.children + } +} diff --git a/src/components/errors/index.ts b/src/components/errors/index.ts new file mode 100644 index 0000000..0f4feb2 --- /dev/null +++ b/src/components/errors/index.ts @@ -0,0 +1,11 @@ +export { AppErrorBoundary } from './AppErrorBoundary' +export type { AppErrorBoundaryProps } from './AppErrorBoundary' + +export { WorkspaceErrorBoundary } from './WorkspaceErrorBoundary' +export type { WorkspaceErrorBoundaryProps } from './WorkspaceErrorBoundary' + +export { AIOperationErrorBoundary } from './AIOperationErrorBoundary' +export type { AIOperationErrorBoundaryProps } from './AIOperationErrorBoundary' + +export { ErrorStateWithRetry } from './ErrorStateWithRetry' +export type { ErrorStateWithRetryProps } from './ErrorStateWithRetry' diff --git a/src/components/feedback/ErrorToastIntegration.tsx b/src/components/feedback/ErrorToastIntegration.tsx new file mode 100644 index 0000000..9e1c9c8 --- /dev/null +++ b/src/components/feedback/ErrorToastIntegration.tsx @@ -0,0 +1,88 @@ +/** + * ErrorToastIntegration Component + * + * Integrates the error reporting system with toast notifications. + * Subscribes to errors and automatically shows toast notifications for them. + * + * This component should be rendered once at the app level (in App.tsx). + */ + +import { showErrorToast, showInfoToast, showWarningToast } from '@/lib/feedback/toastManager' +import { subscribeToErrors, type ErrorReport } from '@/lib/errors/report-error' +import { getErrorMessage } from '@/lib/chrome-ai/errors/errorGuards' +import { useEffect } from 'react' + +/** + * Get a user-friendly error message from an error object + */ +const getUserFriendlyMessage = (error: unknown): { title: string; message?: string } => { + // Try to get error message using the existing error messages utility + const errorMessage = getErrorMessage(error) + + if (errorMessage && errorMessage !== 'An unexpected error occurred') { + return { + title: 'Operation Failed', + message: errorMessage, + } + } + + // Fallback to extracting message from error object + if (error instanceof Error) { + return { + title: error.name || 'Error', + message: error.message, + } + } + + if (typeof error === 'string') { + return { + title: 'Error', + message: error, + } + } + + return { + title: 'An unexpected error occurred', + message: 'Please try again or contact support if the problem persists.', + } +} + +export const ErrorToastIntegration = () => { + useEffect(() => { + const unsubscribe = subscribeToErrors((report: ErrorReport) => { + const { error, severity } = report + + // Get user-friendly error message + const { title, message } = getUserFriendlyMessage(error) + + // Show appropriate toast based on severity + switch (severity) { + case 'error': + showErrorToast(title, { + message, + duration: 7000, // Longer duration for errors + }) + break + + case 'warning': + showWarningToast(title, { + message, + duration: 6000, + }) + break + + case 'info': + showInfoToast(title, { + message, + duration: 5000, + }) + break + } + }) + + return unsubscribe + }, []) + + // This component doesn't render anything + return null +} diff --git a/src/components/help/HelpButton.tsx b/src/components/help/HelpButton.tsx new file mode 100644 index 0000000..443218f --- /dev/null +++ b/src/components/help/HelpButton.tsx @@ -0,0 +1,112 @@ +/** + * HelpButton Component + * + * A reusable button for opening the help panel. + * Can open the help panel to a specific topic or to the general help view. + * + * Features: + * - Multiple variants (icon, text, combo) + * - Can link to specific help topics + * - Tooltip on hover + * - Keyboard accessible + * + * Usage: + * ```tsx + * // Icon only button + * + * + * // Text button + * Get Help + * + * // Button with topic + * openHelpTopic('workspace-overview')} /> + * ``` + */ + +import type { ReactNode } from 'react' + +interface HelpButtonProps { + variant?: 'icon' | 'text' | 'combo' + size?: 'small' | 'medium' | 'large' + topicId?: string + onClick?: () => void + children?: ReactNode + className?: string + tooltipText?: string +} + +// Question mark icon for help +const HelpIcon = ({ className }: { className?: string }) => ( + + + +) + +export const HelpButton = ({ + variant = 'icon', + size = 'medium', + topicId: _topicId, + onClick, + children, + className = '', + tooltipText = 'Open help', +}: HelpButtonProps) => { + // Size classes + const sizeClasses = { + small: 'p-1.5 text-xs', + medium: 'p-2 text-sm', + large: 'p-3 text-base', + } + + // Icon sizes + const iconSizes = { + small: 'h-4 w-4', + medium: 'h-5 w-5', + large: 'h-6 w-6', + } + + const buttonContent = () => { + switch (variant) { + case 'icon': + return + + case 'text': + return {children || 'Help'} + + case 'combo': + return ( + <> + + {children || 'Help'} + + ) + + default: + return + } + } + + return ( + + ) +} diff --git a/src/components/help/HelpPanel.tsx b/src/components/help/HelpPanel.tsx new file mode 100644 index 0000000..90e2119 --- /dev/null +++ b/src/components/help/HelpPanel.tsx @@ -0,0 +1,295 @@ +/** + * HelpPanel Component + * + * A searchable help panel (side drawer) displaying help topics. + * + * Features: + * - Slide-in drawer from right side + * - Search functionality + * - Category filtering + * - Topic browsing + * - Related topics + * - Keyboard navigation + * - Responsive design + */ + +import { + getRelatedTopics, + getTopicsByCategory, + searchHelpTopics, + type HelpTopic, +} from '@/lib/help/helpContent' +import { useEffect, useState } from 'react' + +interface HelpPanelProps { + isOpen: boolean + onClose: () => void + initialTopicId?: string +} + +// Category labels +const categoryLabels: Record = { + 'getting-started': 'Getting Started', + features: 'Features', + troubleshooting: 'Troubleshooting', + privacy: 'Privacy & Data', + advanced: 'Advanced', +} + +// Icons +const SearchIcon = () => ( + + + +) + +const CloseIcon = () => ( + + + +) + +const BackIcon = () => ( + + + +) + +export const HelpPanel = ({ isOpen, onClose, initialTopicId }: HelpPanelProps) => { + const [searchQuery, setSearchQuery] = useState('') + const [selectedTopic, setSelectedTopic] = useState(null) + const [selectedCategory, setSelectedCategory] = useState('all') + + // Set initial topic if provided + useEffect(() => { + if (initialTopicId && isOpen) { + const topic = searchHelpTopics('').find((t) => t.id === initialTopicId) + if (topic) { + setSelectedTopic(topic) + } + } + }, [initialTopicId, isOpen]) + + // Reset state when panel closes + useEffect(() => { + if (!isOpen) { + setSearchQuery('') + setSelectedTopic(null) + setSelectedCategory('all') + } + }, [isOpen]) + + // Get filtered topics + const getFilteredTopics = () => { + let topics = searchHelpTopics(searchQuery) + + if (selectedCategory !== 'all') { + topics = getTopicsByCategory(selectedCategory) + if (searchQuery) { + topics = topics.filter((topic) => { + const searchableText = [topic.title, topic.description, ...topic.keywords] + .join(' ') + .toLowerCase() + return searchableText.includes(searchQuery.toLowerCase()) + }) + } + } + + return topics + } + + const filteredTopics = getFilteredTopics() + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (selectedTopic) { + setSelectedTopic(null) + } else { + onClose() + } + } + } + + if (isOpen) { + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + } + }, [isOpen, selectedTopic, onClose]) + + if (!isOpen) { + return null + } + + return ( + <> + {/* Backdrop */} + diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index ae978a0..e51606e 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -18,31 +18,31 @@ interface ToastProps { toast: ToastType } -// Toast type configurations +// Toast type configurations with high contrast support const toastConfig = { success: { - bgColor: 'bg-accessibility-success/10 dark:bg-accessibility-success/15', - borderColor: 'border-accessibility-success/40 dark:border-accessibility-success/30', - textColor: 'text-accessibility-success', - iconBg: 'bg-accessibility-success/20', + bgColor: 'bg-green-50 dark:bg-green-900/90 forced-colors:bg-[Canvas]', + borderColor: 'border-green-400 dark:border-green-500 forced-colors:border-[ButtonText]', + textColor: 'text-green-800 dark:text-green-100 forced-colors:text-[ButtonText]', + iconBg: 'bg-green-100 dark:bg-green-800 forced-colors:bg-[ButtonFace]', }, error: { - bgColor: 'bg-red-50 dark:bg-red-900/20', - borderColor: 'border-red-400/40 dark:border-red-400/30', - textColor: 'text-red-600 dark:text-red-400', - iconBg: 'bg-red-100 dark:bg-red-900/40', + bgColor: 'bg-red-50 dark:bg-red-900/90 forced-colors:bg-[Canvas]', + borderColor: 'border-red-400 dark:border-red-500 forced-colors:border-[ButtonText]', + textColor: 'text-red-800 dark:text-red-100 forced-colors:text-[ButtonText]', + iconBg: 'bg-red-100 dark:bg-red-800 forced-colors:bg-[ButtonFace]', }, warning: { - bgColor: 'bg-yellow-50 dark:bg-yellow-900/20', - borderColor: 'border-yellow-400/40 dark:border-yellow-400/30', - textColor: 'text-yellow-700 dark:text-yellow-400', - iconBg: 'bg-yellow-100 dark:bg-yellow-900/40', + bgColor: 'bg-yellow-50 dark:bg-yellow-900/90 forced-colors:bg-[Canvas]', + borderColor: 'border-yellow-400 dark:border-yellow-500 forced-colors:border-[ButtonText]', + textColor: 'text-yellow-800 dark:text-yellow-100 forced-colors:text-[ButtonText]', + iconBg: 'bg-yellow-100 dark:bg-yellow-800 forced-colors:bg-[ButtonFace]', }, info: { - bgColor: 'bg-blue-50 dark:bg-blue-900/20', - borderColor: 'border-blue-400/40 dark:border-blue-400/30', - textColor: 'text-blue-600 dark:text-blue-400', - iconBg: 'bg-blue-100 dark:bg-blue-900/40', + bgColor: 'bg-blue-50 dark:bg-blue-900/90 forced-colors:bg-[Canvas]', + borderColor: 'border-blue-400 dark:border-blue-500 forced-colors:border-[ButtonText]', + textColor: 'text-blue-800 dark:text-blue-100 forced-colors:text-[ButtonText]', + iconBg: 'bg-blue-100 dark:bg-blue-800 forced-colors:bg-[ButtonFace]', }, } @@ -134,7 +134,7 @@ export const Toast = ({ toast }: ToastProps) => { aria-live={toast.type === 'error' ? 'assertive' : 'polite'} aria-atomic="true" className={` - pointer-events-auto flex w-full max-w-md flex-col gap-2 rounded-xl border p-4 shadow-lg + pointer-events-auto flex w-full max-w-md flex-col gap-2 rounded-xl border-2 p-4 shadow-xl backdrop-blur-sm transition-all duration-200 ease-in-out ${config.bgColor} ${config.borderColor} ${isExiting ? 'translate-x-full opacity-0' : 'translate-x-0 opacity-100'} @@ -152,7 +152,7 @@ export const Toast = ({ toast }: ToastProps) => { {toast.title}

{toast.message && ( -

+

{toast.message}

)} @@ -162,7 +162,7 @@ export const Toast = ({ toast }: ToastProps) => { {toast.dismissible && ( @@ -184,9 +184,9 @@ export const Toast = ({ toast }: ToastProps) => { {/* Progress bar */} {toast.duration && toast.duration > 0 && ( -
+
diff --git a/src/constants/formats.test.ts b/src/constants/formats.test.ts index 98848db..27b8279 100644 --- a/src/constants/formats.test.ts +++ b/src/constants/formats.test.ts @@ -77,7 +77,7 @@ describe('formats constants', () => { const summary = FORMAT_OPTIONS.summary expect(summary.label).toBe('Summary') - expect(summary.supportsCombination).toBe(false) + expect(summary.supportsCombination).toBe(true) // Summary can now be combined with other formats expect(summary.preview).toContain('Summary:') }) }) @@ -244,30 +244,35 @@ describe('formats constants', () => { it('should return true for all combinable formats together', () => { expect(isValidFormatCombination(['bullets', 'paragraphs', 'steps', 'qa'])).toBe(true) }) - }) - describe('invalid combinations', () => { - it('should return false for empty array', () => { - expect(isValidFormatCombination([])).toBe(false) + it('should return true for summary with other formats', () => { + // Summary can now be combined with other formats for multi-audience content + expect(isValidFormatCombination(['summary', 'bullets'])).toBe(true) + expect(isValidFormatCombination(['summary', 'paragraphs'])).toBe(true) + expect(isValidFormatCombination(['bullets', 'summary'])).toBe(true) }) - it('should return false for summary with other formats', () => { - expect(isValidFormatCombination(['summary', 'bullets'])).toBe(false) - expect(isValidFormatCombination(['summary', 'paragraphs'])).toBe(false) - expect(isValidFormatCombination(['bullets', 'summary'])).toBe(false) + it('should return true for summary with multiple formats', () => { + expect(isValidFormatCombination(['summary', 'bullets', 'qa'])).toBe(true) + expect(isValidFormatCombination(['paragraphs', 'summary', 'steps'])).toBe(true) }) - it('should return false for summary with multiple formats', () => { - expect(isValidFormatCombination(['summary', 'bullets', 'qa'])).toBe(false) - expect(isValidFormatCombination(['paragraphs', 'summary', 'steps'])).toBe(false) + it('should return true for all formats including summary', () => { + expect(isValidFormatCombination(['bullets', 'paragraphs', 'steps', 'qa', 'summary'])).toBe(true) + }) + }) + + describe('invalid combinations', () => { + it('should return false for empty array', () => { + expect(isValidFormatCombination([])).toBe(false) }) }) describe('edge cases', () => { it('should handle duplicate formats', () => { - // Duplicates still count as multiple items + // Duplicates still count as multiple items, and summary can now be combined expect(isValidFormatCombination(['bullets', 'bullets'])).toBe(true) - expect(isValidFormatCombination(['summary', 'summary'])).toBe(false) + expect(isValidFormatCombination(['summary', 'summary'])).toBe(true) }) it('should throw for null or undefined input', () => { @@ -279,9 +284,7 @@ describe('formats constants', () => { }) it('should accept arrays with invalid elements (no validation of array contents)', () => { - // Note: Function doesn't validate array contents, only checks for: - // 1. Empty array - // 2. Multiple items with summary + // Note: Function doesn't validate array contents, only checks for empty array // @ts-expect-error Testing runtime behavior with invalid input expect(isValidFormatCombination([null])).toBe(true) // @ts-expect-error Testing runtime behavior with invalid input @@ -338,34 +341,29 @@ describe('formats constants', () => { }) describe('integration - presets and validation', () => { - it('should have study-guide preset that uses summary (special case)', () => { + it('should have study-guide preset that combines summary with other formats', () => { const studyGuide = FORMAT_PRESETS.find((p) => p.id === 'study-guide') /** - * Known Design Decision: The study-guide preset combines 'summary' + 'qa', - * which violates isValidFormatCombination rules (summary cannot be combined). + * Design Decision: The study-guide preset combines 'summary' + 'qa', + * which is now fully supported as summary can be combined with other formats. * - * Rationale: Presets are carefully curated combinations that prioritize - * user experience over strict validation rules. The study-guide preset - * provides both a summary overview AND Q&A format because that's pedagogically - * valuable for learning materials. + * Rationale: Multi-audience and multi-purpose content benefits from multiple formats. + * The study-guide preset provides both a summary overview AND Q&A format because + * that's pedagogically valuable for learning materials. * - * This is acceptable because: - * 1. Presets are defined by developers, not user input - * 2. Each preset is manually tested for quality - * 3. Validation rules apply to arbitrary user combinations - * - * TODO: Consider adding a `validatePreset()` function with relaxed rules - * or adding a `bypassesValidation: true` flag to preset definitions. + * This combination is valid for use cases like: + * - Summary for quick overview + * - Q&A for practice and deeper understanding + * - Different formats for different learning styles */ expect(studyGuide?.formats).toContain('summary') expect(studyGuide?.formats).toContain('qa') + expect(isValidFormatCombination(studyGuide!.formats)).toBe(true) }) - it('should validate non-summary presets correctly', () => { - const nonSummaryPresets = FORMAT_PRESETS.filter((p) => !p.formats.includes('summary')) - - nonSummaryPresets.forEach((preset) => { + it('should validate all presets correctly', () => { + FORMAT_PRESETS.forEach((preset) => { const isValid = isValidFormatCombination(preset.formats) expect(isValid).toBe(true) }) diff --git a/src/constants/formats.ts b/src/constants/formats.ts index 706c1cb..6a52e11 100644 --- a/src/constants/formats.ts +++ b/src/constants/formats.ts @@ -57,7 +57,7 @@ A: This is important because it helps you understand the key principles.`, description: 'Condense content into a brief summary', icon: 'document-duplicate', preview: `Summary: This concise overview captures the essential points and key takeaways from the content, providing a quick understanding of the main ideas without requiring a full read.`, - supportsCombination: false, + supportsCombination: true, }, } @@ -125,12 +125,8 @@ export const isValidFormatCombination = (formats: FormatType[]): boolean => { return false } - // Summary cannot be combined with other formats - if (formats.includes('summary') && formats.length > 1) { - return false - } - - // All other combinations are valid + // All format combinations are now valid! + // Summary can be combined with other formats for multi-audience content return true } diff --git a/src/lib/chrome-ai/services/FormatTransformService.ts b/src/lib/chrome-ai/services/FormatTransformService.ts index 74cf0cf..ce2c559 100644 --- a/src/lib/chrome-ai/services/FormatTransformService.ts +++ b/src/lib/chrome-ai/services/FormatTransformService.ts @@ -276,7 +276,7 @@ export class FormatTransformService { // Validate format combination if (!isValidFormatCombination(formats)) { throw new PromptError( - 'Invalid format combination. Summary cannot be combined with other formats, and at least one format must be selected.', + 'Invalid format combination. At least one format must be selected.', 'INVALID_INPUT' ) } @@ -306,7 +306,7 @@ export class FormatTransformService { // Validate format combination if (!isValidFormatCombination(formats)) { throw new PromptError( - 'Invalid format combination. Summary cannot be combined with other formats, and at least one format must be selected.', + 'Invalid format combination. At least one format must be selected.', 'INVALID_INPUT' ) } @@ -317,12 +317,36 @@ export class FormatTransformService { } let fullContent = '' + let chunkCount = 0 for await (const chunk of this.transformToFormatStreaming(content, format, options)) { fullContent += chunk - yield { format, content: fullContent, isComplete: false } + chunkCount++ + + console.log(`[FormatTransform] ${format} chunk ${chunkCount}: length=${chunk.length}, total=${fullContent.length}`) + + // Always split chunks for consistent streaming (more aggressive) + // Even small chunks get split for smooth progressive display + if (chunk.length > 20) { + // Split chunks into words for progressive display + const words = chunk.split(/(\s+)/) + let accumulated = fullContent.slice(0, fullContent.length - chunk.length) + + for (const word of words) { + accumulated += word + yield { format, content: accumulated, isComplete: false } + // Delay for smooth animation (adjusted for better visibility) + if (word.trim()) { + await new Promise(resolve => setTimeout(resolve, 15)) + } + } + } else { + // Very small chunk, emit as-is + yield { format, content: fullContent, isComplete: false } + } } + console.log(`[FormatTransform] ${format} complete: ${chunkCount} chunks, ${fullContent.length} chars total`) yield { format, content: fullContent, isComplete: true } } } diff --git a/src/pages/workspace/WorkspacePage.tsx b/src/pages/workspace/WorkspacePage.tsx index 473ea52..bf226e7 100644 --- a/src/pages/workspace/WorkspacePage.tsx +++ b/src/pages/workspace/WorkspacePage.tsx @@ -208,11 +208,15 @@ export const WorkspacePage = () => { ) const updateStreamingResults = useCallback((format: FormatType, content: string) => { + console.log(`[WorkspacePage] updateStreamingResults: ${format}, content length=${content.length}`) + streamingResultsRef.current = { ...streamingResultsRef.current, [format]: content, } + console.log(`[WorkspacePage] streamingResultsRef.current after update:`, Object.keys(streamingResultsRef.current), 'Lengths:', Object.fromEntries(Object.entries(streamingResultsRef.current).map(([k, v]) => [k, v?.length || 0]))) + setStreamingResults((prev) => { if (prev[format] === content) { return prev @@ -235,13 +239,13 @@ export const WorkspacePage = () => { const formats = selectedFormats.length > 0 ? selectedFormats : [] if (formats.length === 0) { - return setIsStreaming(false) - setStreamingResults({}) - setCompletedFormats(new Set()) - streamingResultsRef.current = {} - completedFormatsRef.current = new Set() - } + setStreamingResults({}) + setCompletedFormats(new Set()) + streamingResultsRef.current = {} + completedFormatsRef.current = new Set() + return + } const resultsRecord: ProcessingHistoryEntry['results'] = { [formats[0]]: result, } @@ -271,7 +275,16 @@ export const WorkspacePage = () => { results: Partial>, original: string ) => { - setProcessedResults(results) + console.log('[WorkspacePage] Multi-format complete. Results:', Object.keys(results), 'Lengths:', Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v?.length || 0]))) + console.log('[WorkspacePage] streamingResults state:', Object.keys(streamingResults), 'Lengths:', Object.fromEntries(Object.entries(streamingResults).map(([k, v]) => [k, v?.length || 0]))) + console.log('[WorkspacePage] streamingResultsRef.current:', Object.keys(streamingResultsRef.current), 'Lengths:', Object.fromEntries(Object.entries(streamingResultsRef.current).map(([k, v]) => [k, v?.length || 0]))) + + // Merge streaming results ref with final results to avoid losing any content + // Use ref instead of state because state might already be cleared + const mergedResults = { ...streamingResultsRef.current, ...results } + console.log('[WorkspacePage] Merged results:', Object.keys(mergedResults), 'Lengths:', Object.fromEntries(Object.entries(mergedResults).map(([k, v]) => [k, v?.length || 0]))) + + setProcessedResults(mergedResults) setProcessedResult(null) setProcessingError(null) setIsStreaming(false) @@ -283,9 +296,9 @@ export const WorkspacePage = () => { setMultiFormatProgressMap(new Map()) setAggregateProgress(null) - const orderedFormats = selectedFormats.filter((format) => results[format] !== undefined) + const orderedFormats = selectedFormats.filter((format) => mergedResults[format] !== undefined) const formats = - orderedFormats.length > 0 ? orderedFormats : (Object.keys(results) as FormatType[]) + orderedFormats.length > 0 ? orderedFormats : (Object.keys(mergedResults) as FormatType[]) if (formats.length === 0) { return @@ -295,14 +308,14 @@ export const WorkspacePage = () => { source: lastProcessingSource, inputText: original, formats, - results, + results: mergedResults, metadata: { readingLevel: loadPreferences().readingLevel || undefined, }, }) const primaryFormat = formats[0] - const primaryResult = (results[primaryFormat] ?? Object.values(results)[0]) ?? null + const primaryResult = (mergedResults[primaryFormat] ?? Object.values(mergedResults)[0]) ?? null setLastSuccessMeta(buildSuccessMetrics(original, primaryResult, processingStartedAt)) setProcessingStartedAt(null) completedFormatsRef.current = new Set() @@ -317,38 +330,22 @@ export const WorkspacePage = () => { const handleStreamingUpdate = useCallback( (format: FormatType, content: string, isComplete: boolean) => { + console.log(`[WorkspacePage] handleStreamingUpdate: format=${format}, isComplete=${isComplete}, content length=${content.length}`) setIsStreaming(true) updateStreamingResults(format, content) if (isComplete) { + console.log(`[WorkspacePage] Format ${format} marked complete`) setCompletedFormats((prev) => { const next = new Set(prev) next.add(format) completedFormatsRef.current = next - - const allComplete = selectedFormats.every((f) => next.has(f)) - - if (allComplete) { - const finalResults: Record = {} as Record - selectedFormats.forEach((f) => { - finalResults[f] = - f === format ? content : streamingResultsRef.current[f] ?? '' - }) - - setProcessedResults(finalResults) - setIsStreaming(false) - setStreamingResults({}) - streamingResultsRef.current = {} - completedFormatsRef.current = new Set() - - return new Set() - } - + console.log(`[WorkspacePage] Completed formats:`, Array.from(next)) return next }) } }, - [selectedFormats, updateStreamingResults] + [updateStreamingResults] ) const handleProcessingError = (error: Error) => { @@ -481,6 +478,18 @@ export const WorkspacePage = () => { clear() } + const handleUseExtractedURLContent = () => { + if (!content) return + + // Get extracted text and switch to text mode + setExtractedText(content.textContent) + setHasTextInput(content.textContent.trim().length > 0) + setTextInputKey(prev => prev + 1) // Force TextInputPanel to re-render with new text + selectInputMode('text') + setLastProcessingSource('url') + setLastSuccessMeta(null) + } + const resetWorkspaceOutputs = () => { setProcessedResult(null) setProcessedResults(null) @@ -1051,7 +1060,11 @@ export const WorkspacePage = () => { {content && (
- +
)} @@ -1187,11 +1200,22 @@ export const WorkspacePage = () => {
+ {(() => { + console.log('[WorkspacePage RENDER] hasStreamingContent:', hasStreamingContent, 'processedResults:', processedResults ? Object.keys(processedResults) : null) + return null + })()} {hasStreamingContent ? ( [f, !completedFormats.has(f)]))} + isLoading={new Map( + selectedFormats.map((f) => { + const progress = multiFormatProgressMap.get(f) + // Only show as loading if format is actively being processed + // Not if it's still pending in queue + return [f, progress?.status === 'processing'] + }) + )} onClear={handleClearResults} /> ) : processedResults ? ( From a5cd296ef4270a2426a2efe465f0b368af341af2 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 19:54:09 +0200 Subject: [PATCH 5/6] feat: enhance ExtractedContent and FormattedOutput components with new features and improved user interaction - Updated ExtractedContent to include new callbacks for editing text and disabling the process button, enhancing user control. - Introduced a progress bar with animation effects in the UI, providing visual feedback during processing. - Enhanced FormattedOutput to allow editing of original text and added a collapsible section for original text reference. - Improved testing coverage for ProgressBar and other components to ensure reliability and performance. - Updated WorkspacePage to integrate new functionalities, including handling original text and improved processing feedback. --- .../component-integration.test.tsx | 2 +- .../content/ExtractedContent.test.tsx | 8 +- src/components/content/ExtractedContent.tsx | 87 +++- .../output/FormattedOutput.test.tsx | 10 +- src/components/output/FormattedOutput.tsx | 96 +++- src/components/ui/ProgressBar.test.tsx | 8 +- src/components/ui/ProgressBar.tsx | 17 +- .../workspace/FormatQuickSelector.tsx | 67 ++- src/index.css | 45 ++ src/pages/workspace/WorkspacePage.tsx | 416 +++++++++++++++--- 10 files changed, 666 insertions(+), 90 deletions(-) diff --git a/src/__tests__/integration/component-integration.test.tsx b/src/__tests__/integration/component-integration.test.tsx index 2cc4a1f..c600f03 100644 --- a/src/__tests__/integration/component-integration.test.tsx +++ b/src/__tests__/integration/component-integration.test.tsx @@ -227,7 +227,7 @@ describe('Component Integration Tests', () => { expect(mockOnClear).toHaveBeenCalledTimes(1) // Test process - const processButton = screen.getByRole('button', { name: /process with chrome ai/i }) + const processButton = screen.getByRole('button', { name: /transform with chrome ai/i }) await user.click(processButton) expect(mockOnProcess).toHaveBeenCalledTimes(1) }) diff --git a/src/components/content/ExtractedContent.test.tsx b/src/components/content/ExtractedContent.test.tsx index 47140ad..e9dd08d 100644 --- a/src/components/content/ExtractedContent.test.tsx +++ b/src/components/content/ExtractedContent.test.tsx @@ -288,13 +288,13 @@ describe('ExtractedContent', () => { it('should render process button when onProcessWithAI provided', () => { render() - expect(screen.getByRole('button', { name: 'Process with Chrome AI' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Transform with Chrome AI' })).toBeInTheDocument() }) it('should not render process button when onProcessWithAI not provided', () => { render() expect( - screen.queryByRole('button', { name: 'Process with Chrome AI' }) + screen.queryByRole('button', { name: 'Transform with Chrome AI' }) ).not.toBeInTheDocument() }) @@ -302,7 +302,7 @@ describe('ExtractedContent', () => { const user = userEvent.setup() render() - await user.click(screen.getByRole('button', { name: 'Process with Chrome AI' })) + await user.click(screen.getByRole('button', { name: 'Transform with Chrome AI' })) expect(mockOnProcessWithAI).toHaveBeenCalledTimes(1) }) }) @@ -338,7 +338,7 @@ describe('ExtractedContent', () => { ) expect(screen.getByRole('button', { name: 'Clear content' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Process with Chrome AI' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Transform with Chrome AI' })).toBeInTheDocument() }) it('should use semantic heading elements', () => { diff --git a/src/components/content/ExtractedContent.tsx b/src/components/content/ExtractedContent.tsx index 81fb5a5..99b948d 100644 --- a/src/components/content/ExtractedContent.tsx +++ b/src/components/content/ExtractedContent.tsx @@ -13,15 +13,30 @@ export interface ExtractedContentProps { onClear?: () => void /** - * Callback when process with AI button is clicked + * Callback when process with AI button is clicked (direct processing) */ onProcessWithAI?: () => void + + /** + * Callback when edit as text button is clicked (switch to text mode) + */ + onEditAsText?: () => void + + /** + * Whether the process button should be disabled + */ + processDisabled?: boolean + + /** + * Reason why processing is disabled (shown as warning message) + */ + processDisabledReason?: string } /** * Display component for extracted article content */ -export const ExtractedContent = ({ content, onClear, onProcessWithAI }: ExtractedContentProps) => { +export const ExtractedContent = ({ content, onClear, onProcessWithAI, onEditAsText, processDisabled, processDisabledReason }: ExtractedContentProps) => { const [showImages, setShowImages] = useState(true) const { title, metadata, images, textContent } = content @@ -159,15 +174,65 @@ export const ExtractedContent = ({ content, onClear, onProcessWithAI }: Extracte )}
- {/* Action button */} - {onProcessWithAI && ( - + {/* Action buttons */} + {(onProcessWithAI || onEditAsText) && ( +
+
+ {onProcessWithAI && ( + + )} + {onEditAsText && ( + + )} +
+ {processDisabled && processDisabledReason && ( +

+ ⚠️ {processDisabledReason} +

+ )} +
)}
) diff --git a/src/components/output/FormattedOutput.test.tsx b/src/components/output/FormattedOutput.test.tsx index 66e67f6..bc83d99 100644 --- a/src/components/output/FormattedOutput.test.tsx +++ b/src/components/output/FormattedOutput.test.tsx @@ -176,13 +176,18 @@ describe('FormattedOutput', () => { }) describe('Action Buttons', () => { - it('should render expand all button when not all expanded', () => { + it('should render expand all button when not all expanded', async () => { + const user = userEvent.setup() render( ) + + const collapseButton = screen.getByLabelText('Collapse all sections') + await user.click(collapseButton) + expect(screen.getByLabelText('Expand all sections')).toBeInTheDocument() }) @@ -240,6 +245,9 @@ describe('FormattedOutput', () => { /> ) + const collapseButton = screen.getByLabelText('Collapse all sections') + await user.click(collapseButton) + const expandButton = screen.getByLabelText('Expand all sections') await user.click(expandButton) diff --git a/src/components/output/FormattedOutput.tsx b/src/components/output/FormattedOutput.tsx index a3b163c..3e893f6 100644 --- a/src/components/output/FormattedOutput.tsx +++ b/src/components/output/FormattedOutput.tsx @@ -28,6 +28,16 @@ export interface FormattedOutputProps { * Callback when clear all is clicked */ onClear?: () => void + + /** + * Callback when edit original is clicked (iterative refinement) + */ + onEditOriginal?: () => void + + /** + * Original text for comparison view + */ + originalText?: string } /** @@ -39,15 +49,16 @@ export const FormattedOutput = ({ isLoading, onCopy, onClear, + onEditOriginal, + originalText, }: FormattedOutputProps) => { - console.log('[FormattedOutput PROPS] formats:', formats, 'results keys:', Object.keys(results), 'results lengths:', Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v?.length || 0]))) - // Expand all formats by default - user selected them to see them! const [expandedFormats, setExpandedFormats] = useState>( new Set(formats) ) - console.log('[FormattedOutput STATE] expandedFormats:', Array.from(expandedFormats)) + // Original text visibility state + const [showOriginal, setShowOriginal] = useState(true) useEffect(() => { if (Object.keys(results).length > 0 && expandedFormats.size === 0) { @@ -259,6 +270,27 @@ export const FormattedOutput = ({ + {onEditOriginal && ( + + )} + {onClear && (
+ {/* Original text reference card (optional, collapsible) */} + {originalText && ( +
+ + {showOriginal && ( +
+
+
+                  {originalText}
+                
+
+
+ +

+ {originalText.length.toLocaleString()} characters +

+
+
+ )} +
+ )} + {/* Format sections */}
{formats.map((format) => { @@ -326,7 +414,7 @@ export const FormattedOutput = ({

Tip: Compare outputs

- Collapse completed formats to focus on the one you are currently refining. Copy or clear individual sections as you go. + The original text is always available above for reference. Expand or collapse formats as needed. Use "Edit & Reprocess" to refine and try again.

diff --git a/src/components/ui/ProgressBar.test.tsx b/src/components/ui/ProgressBar.test.tsx index b947fbb..36ecb25 100644 --- a/src/components/ui/ProgressBar.test.tsx +++ b/src/components/ui/ProgressBar.test.tsx @@ -133,14 +133,14 @@ describe('ProgressBar', () => { describe('Animation', () => { it('should not have animation class by default', () => { const { container } = render() - const fill = container.querySelector('.bg-synapse-600') - expect(fill).not.toHaveClass('animate-pulse-slow') + const fill = container.querySelector('.progress-bar-fill') + expect(fill).not.toHaveClass('progress-bar-fill--animated') }) it('should have animation class when animated prop is true', () => { const { container } = render() - const fill = container.querySelector('.bg-synapse-600') - expect(fill).toHaveClass('animate-pulse-slow') + const fill = container.querySelector('.progress-bar-fill') + expect(fill).toHaveClass('progress-bar-fill--animated') }) }) diff --git a/src/components/ui/ProgressBar.tsx b/src/components/ui/ProgressBar.tsx index 920994c..2705941 100644 --- a/src/components/ui/ProgressBar.tsx +++ b/src/components/ui/ProgressBar.tsx @@ -65,6 +65,19 @@ const variantClasses = { error: 'bg-accessibility-error', } +const getFillClassName = ( + variant: keyof typeof variantClasses, + animated: boolean +) => { + const classes = ['progress-bar-fill', 'h-full', variantClasses[variant]] + + if (animated) { + classes.push('progress-bar-fill--animated') + } + + return classes.join(' ') +} + export const ProgressBar = ({ value, max = 100, @@ -95,7 +108,7 @@ export const ProgressBar = ({ aria-label={label || 'Progress'} > { + const [show, setShow] = useState(false) + + return ( +
+ + {show && ( +
+
+

+ Preview: {label} +

+
+
+
+              {preview}
+            
+
+
+ )} +
+ ) +} + interface FormatQuickSelectorProps { selectedFormats: FormatType[] onChangeFormats: (formats: FormatType[]) => void @@ -194,7 +248,7 @@ export const FormatQuickSelector = ({ )} -
+

{config.description}

- {!config.supportsCombination && ( - Solo only - )} +
+ {config.preview && ( + + )} + {!config.supportsCombination && ( + Solo only + )} +
) })} diff --git a/src/index.css b/src/index.css index 4006095..bef5dd8 100644 --- a/src/index.css +++ b/src/index.css @@ -426,3 +426,48 @@ body { [data-theme='dark'] .hover\:border-neutral-700:hover { border-color: rgb(148 163 184 / 0.6) !important; } + +@layer components { + .progress-bar-fill { + @apply relative h-full; + transition: width 0.45s ease-out; + } + + .progress-bar-fill::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.45) 50%, + rgba(255, 255, 255, 0) 100% + ); + transform: translateX(-100%); + opacity: 0; + pointer-events: none; + } + + .progress-bar-fill--animated::after { + opacity: 0.35; + animation: progress-bar-sheen 1.6s ease-in-out infinite; + } +} + +@keyframes progress-bar-sheen { + 0% { + transform: translateX(-100%); + } + 60% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar-fill--animated::after { + animation: none; + } +} diff --git a/src/pages/workspace/WorkspacePage.tsx b/src/pages/workspace/WorkspacePage.tsx index bf226e7..ba896ad 100644 --- a/src/pages/workspace/WorkspacePage.tsx +++ b/src/pages/workspace/WorkspacePage.tsx @@ -49,6 +49,7 @@ export const WorkspacePage = () => { const [processingStartedAt, setProcessingStartedAt] = useState(null) const [lastSuccessMeta, setLastSuccessMeta] = useState(null) const [operationProgress, setOperationProgress] = useState(null) + const [historyExpanded, setHistoryExpanded] = useState(true) const [multiFormatProgressMap, setMultiFormatProgressMap] = useState< Map >(new Map()) @@ -118,10 +119,7 @@ export const WorkspacePage = () => { onExtractionComplete: () => { // Combine extracted text from all files setExtractedText(fileUpload.getAllExtractedText()) - showSuccess('File extracted', { - message: 'Text content successfully extracted from file', - duration: 3000, - }) + // Toast removed - inline status is sufficient }, onExtractionError: (error) => { console.error('File extraction error:', error) @@ -135,16 +133,7 @@ export const WorkspacePage = () => { // URL extraction hook const { extract, content, error: extractionError, isLoading, clear } = useContentExtraction() - // Show toasts for URL extraction - useEffect(() => { - if (content && content.textContent) { - showSuccess('Content extracted', { - message: 'Successfully extracted content from URL', - duration: 3000, - }) - } - }, [content, showSuccess]) - + // Show error toast for URL extraction (success is shown inline) useEffect(() => { if (extractionError) { showError('Extraction failed', { @@ -383,6 +372,17 @@ export const WorkspacePage = () => { completedFormatsRef.current = new Set() } + const handleEditOriginal = () => { + // Load original text back into text input for iterative refinement + if (originalText) { + setExtractedText(originalText) + setHasTextInput(true) + setTextInputKey(prev => prev + 1) // Force re-render + selectInputMode('text') + // Keep the results visible while editing (don't clear) + } + } + const handleFormatChange = (formats: FormatType[]) => { setSelectedFormats(formats) // Save to localStorage @@ -452,7 +452,7 @@ export const WorkspacePage = () => { } const handleUseExtractedText = () => { - // Get the latest extracted text and switch to text mode + // Get the latest extracted text and switch to text mode for editing const allText = fileUpload.getAllExtractedText() setExtractedText(allText) setHasTextInput(allText.trim().length > 0) @@ -462,6 +462,68 @@ export const WorkspacePage = () => { setLastSuccessMeta(null) } + const handleProcessExtractedFileContent = async () => { + const textContent = fileUpload.getAllExtractedText() + if (!textContent || textContent.trim().length === 0) { + showError('No text extracted', { + message: 'Please extract text from files first', + duration: 5000, + }) + return + } + + if (selectedFormats.length === 0) { + showError('No formats selected', { + message: 'Please select at least one output format', + duration: 5000, + }) + return + } + + setOriginalText(textContent) + handleProcessingStart() + + try { + const { FormatTransformService } = await import('@/lib/chrome-ai/services/FormatTransformService') + const service = new FormatTransformService({ + readingLevel: loadPreferences().readingLevel || undefined, + }) + + // Initialize service + await service.initialize() + + if (selectedFormats.length === 1) { + // Single format processing + const result = await service.transformToFormat(textContent, selectedFormats[0], { + readingLevel: loadPreferences().readingLevel || undefined, + }) + + handleProcessingComplete(result, textContent) + } else { + // Multi-format processing + const aggregatedResults: Partial> = {} + + for await (const update of service.transformToMultipleFormatsStreaming( + textContent, + selectedFormats, + { + readingLevel: loadPreferences().readingLevel || undefined, + } + )) { + if (update.isComplete) { + aggregatedResults[update.format] = update.content + } + handleStreamingUpdate(update.format, update.content, update.isComplete) + } + + handleMultiFormatComplete(aggregatedResults, textContent) + } + } catch (error) { + console.error('File processing error:', error) + handleProcessingError(error instanceof Error ? error : new Error('Processing failed')) + } + } + const handleLoadExample = () => { setExtractedText(DEFAULT_SAMPLE_TEXT) setHasTextInput(true) @@ -481,7 +543,7 @@ export const WorkspacePage = () => { const handleUseExtractedURLContent = () => { if (!content) return - // Get extracted text and switch to text mode + // Get extracted text and switch to text mode for editing setExtractedText(content.textContent) setHasTextInput(content.textContent.trim().length > 0) setTextInputKey(prev => prev + 1) // Force TextInputPanel to re-render with new text @@ -490,6 +552,68 @@ export const WorkspacePage = () => { setLastSuccessMeta(null) } + const handleProcessExtractedURLContent = async () => { + if (!content) { + showError('No content extracted', { + message: 'Please extract content from a URL first', + duration: 5000, + }) + return + } + + if (selectedFormats.length === 0) { + showError('No formats selected', { + message: 'Please select at least one output format', + duration: 5000, + }) + return + } + + const textContent = content.textContent + setOriginalText(textContent) + handleProcessingStart() + + try { + const { FormatTransformService } = await import('@/lib/chrome-ai/services/FormatTransformService') + const service = new FormatTransformService({ + readingLevel: loadPreferences().readingLevel || undefined, + }) + + // Initialize service + await service.initialize() + + if (selectedFormats.length === 1) { + // Single format processing + const result = await service.transformToFormat(textContent, selectedFormats[0], { + readingLevel: loadPreferences().readingLevel || undefined, + }) + + handleProcessingComplete(result, textContent) + } else { + // Multi-format processing + const aggregatedResults: Partial> = {} + + for await (const update of service.transformToMultipleFormatsStreaming( + textContent, + selectedFormats, + { + readingLevel: loadPreferences().readingLevel || undefined, + } + )) { + if (update.isComplete) { + aggregatedResults[update.format] = update.content + } + handleStreamingUpdate(update.format, update.content, update.isComplete) + } + + handleMultiFormatComplete(aggregatedResults, textContent) + } + } catch (error) { + console.error('URL processing error:', error) + handleProcessingError(error instanceof Error ? error : new Error('Processing failed')) + } + } + const resetWorkspaceOutputs = () => { setProcessedResult(null) setProcessedResults(null) @@ -699,10 +823,97 @@ export const WorkspacePage = () => { step.id )} -
+

{step.title}

{step.description}

{step.helper}

+ + {/* Actionable next step button */} + {step.status === 'current' && ( +
+ {step.id === 1 && ( + + )} + {step.id === 2 && !hasTextInput && ( +

+ ⚠️ Please add content in Step 1 first +

+ )} + {step.id === 2 && hasTextInput && selectedFormats.length === 0 && ( + + )} + {step.id === 2 && hasTextInput && selectedFormats.length > 0 && ( +

+ + + + Ready to process! +

+ )} + {step.id === 3 && !hasTextInput && ( +

+ ⚠️ Complete Steps 1 & 2 first +

+ )} + {step.id === 3 && hasTextInput && ( + + )} +
+ )}
@@ -838,6 +1049,11 @@ export const WorkspacePage = () => { + {/* Format Selector - Always visible for all input modes */} +
+ +
+ {inputMode === 'text' ? ( { />
- - - {fileUpload.files.some((f) => f.status === 'complete') && ( + {!fileUpload.files.some((f) => f.status === 'complete') ? ( + ) : ( + <> +
+ + {selectedFormats.length === 0 && ( +

+ ⚠️ Please select at least one output format above +

+ )} +
+ + + )}
)} @@ -1197,8 +1449,6 @@ export const WorkspacePage = () => { /> )} - -
{(() => { console.log('[WorkspacePage RENDER] hasStreamingContent:', hasStreamingContent, 'processedResults:', processedResults ? Object.keys(processedResults) : null) @@ -1217,9 +1467,17 @@ export const WorkspacePage = () => { }) )} onClear={handleClearResults} + onEditOriginal={handleEditOriginal} + originalText={originalText} /> ) : processedResults ? ( - + ) : processedResult && viewMode === 'comparison' && originalText ? ( ) : processedResult ? ( @@ -1280,13 +1538,53 @@ export const WorkspacePage = () => {
- + {/* History Section with Toggle */} +
+
+
+

+ Processing History +

+ {historyEntries.length > 0 && ( + + {historyEntries.length} {historyEntries.length === 1 ? 'entry' : 'entries'} + + )} +
+ +
+ + {historyExpanded && ( + + )} +
{/* Info section */}
From 4b561411a39dd7a9266ebbf8b0e17943f8c2e5b0 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 20:15:07 +0200 Subject: [PATCH 6/6] fix: improve progress tracking in FormattedOutput and enhance WorkspacePage rendering - Refactored progress calculation in FormattedOutput to accurately reflect completion and active states based on loading status and results. - Updated WorkspacePage to streamline className formatting for better readability and maintainability. - Enhanced loading state handling in WorkspacePage to ensure accurate representation of processing status across formats. --- src/components/output/FormattedOutput.tsx | 35 +++++++-- src/pages/workspace/WorkspacePage.tsx | 95 +++++++++++------------ 2 files changed, 75 insertions(+), 55 deletions(-) diff --git a/src/components/output/FormattedOutput.tsx b/src/components/output/FormattedOutput.tsx index 3e893f6..3079be2 100644 --- a/src/components/output/FormattedOutput.tsx +++ b/src/components/output/FormattedOutput.tsx @@ -133,19 +133,44 @@ export const FormattedOutput = ({ const progress = useMemo(() => { const total = formats.length - const completed = formats.filter((format) => !isLoading?.get(format)).length - const active = formats.find((format) => isLoading?.get(format)) - const isInProgress = formats.some((format) => isLoading?.get(format)) + let completed = 0 + let active: FormatType | undefined + + for (const format of formats) { + const status = isLoading?.get(format) + const hasResult = Boolean(results[format]) + + const isComplete = + status === false || + (status === undefined && !isLoading && hasResult) || + (status === undefined && hasResult) + + if (isComplete) { + completed += 1 + continue + } + + if (!active) { + if (status === true) { + active = format + } else if (!hasResult) { + active = format + } + } + } + + const hasLoadingInfo = Boolean(isLoading && isLoading.size > 0) + const isInProgress = hasLoadingInfo && completed < total const percent = total > 0 ? (completed / total) * 100 : 0 return { total, completed, - active, + active: isInProgress ? active : undefined, isInProgress, percent, } - }, [formats, isLoading]) + }, [formats, isLoading, results]) const formatCountLabel = useMemo(() => { const suffix = progress.total !== 1 ? 's' : '' diff --git a/src/pages/workspace/WorkspacePage.tsx b/src/pages/workspace/WorkspacePage.tsx index ba896ad..f93230e 100644 --- a/src/pages/workspace/WorkspacePage.tsx +++ b/src/pages/workspace/WorkspacePage.tsx @@ -790,26 +790,24 @@ export const WorkspacePage = () => { {stepItems.map((step, index) => (
{step.status === 'complete' ? ( @@ -971,11 +968,10 @@ export const WorkspacePage = () => { setLastProcessingSource('file') }} aria-pressed={inputMode === 'file'} - className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${ - inputMode === 'file' - ? 'bg-synapse-600 text-white shadow-sm' - : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' - }`} + className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${inputMode === 'file' + ? 'bg-synapse-600 text-white shadow-sm' + : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' + }`} > @@ -1002,11 +998,10 @@ export const WorkspacePage = () => { setLastProcessingSource('url') }} aria-pressed={inputMode === 'url'} - className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${ - inputMode === 'url' - ? 'bg-synapse-600 text-white shadow-sm' - : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' - }`} + className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${inputMode === 'url' + ? 'bg-synapse-600 text-white shadow-sm' + : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' + }`} > @@ -1033,11 +1028,10 @@ export const WorkspacePage = () => { setLastProcessingSource('text') }} aria-pressed={inputMode === 'writing-tools'} - className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${ - inputMode === 'writing-tools' - ? 'bg-synapse-600 text-white shadow-sm' - : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' - }`} + className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${inputMode === 'writing-tools' + ? 'bg-synapse-600 text-white shadow-sm' + : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' + }`} > @@ -1362,8 +1356,8 @@ export const WorkspacePage = () => { {inputMode === 'text' ? 'Your transformed text will appear here after processing.' : inputMode === 'writing-tools' - ? 'Writing-tool templates populate the text workspace so you can run a full transformation.' - : 'Process extracted content with Chrome AI to see results.'} + ? 'Writing-tool templates populate the text workspace so you can run a full transformation.' + : 'Process extracted content with Chrome AI to see results.'}

@@ -1373,11 +1367,10 @@ export const WorkspacePage = () => { type="button" onClick={() => setViewMode('output')} aria-pressed={viewMode === 'output'} - className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${ - viewMode === 'output' - ? 'bg-synapse-600 text-white shadow-sm' - : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' - }`} + className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${viewMode === 'output' + ? 'bg-synapse-600 text-white shadow-sm' + : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' + }`} > Single @@ -1387,13 +1380,12 @@ export const WorkspacePage = () => { disabled={!originalText} aria-pressed={viewMode === 'comparison'} title={!originalText ? 'Comparison view is only available after processing text.' : 'View side-by-side comparison.'} - className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${ - viewMode === 'comparison' - ? 'bg-synapse-600 text-white shadow-sm' - : !originalText + className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${viewMode === 'comparison' + ? 'bg-synapse-600 text-white shadow-sm' + : !originalText ? 'cursor-not-allowed text-neutral-400 opacity-50' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800' - }`} + }`} > Comparison @@ -1451,7 +1443,6 @@ export const WorkspacePage = () => {
{(() => { - console.log('[WorkspacePage RENDER] hasStreamingContent:', hasStreamingContent, 'processedResults:', processedResults ? Object.keys(processedResults) : null) return null })()} {hasStreamingContent ? ( @@ -1461,9 +1452,13 @@ export const WorkspacePage = () => { isLoading={new Map( selectedFormats.map((f) => { const progress = multiFormatProgressMap.get(f) - // Only show as loading if format is actively being processed - // Not if it's still pending in queue - return [f, progress?.status === 'processing'] + const status = progress?.status + const isComplete = + completedFormats.has(f) || + status === 'complete' || + status === 'error' + + return [f, !isComplete] }) )} onClear={handleClearResults}