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/__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/errors/AIOperationErrorBoundary.tsx b/src/components/errors/AIOperationErrorBoundary.tsx new file mode 100644 index 0000000..7344dd2 --- /dev/null +++ b/src/components/errors/AIOperationErrorBoundary.tsx @@ -0,0 +1,182 @@ +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..d753f8e --- /dev/null +++ b/src/components/errors/WorkspaceErrorBoundary.tsx @@ -0,0 +1,121 @@ +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/__tests__/ErrorBoundaries.test.tsx b/src/components/errors/__tests__/ErrorBoundaries.test.tsx new file mode 100644 index 0000000..61bb246 --- /dev/null +++ b/src/components/errors/__tests__/ErrorBoundaries.test.tsx @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { WorkspaceErrorBoundary } from '../WorkspaceErrorBoundary' +import { AIOperationErrorBoundary } from '../AIOperationErrorBoundary' +import * as errorReporter from '@/lib/errors/report-error' + +// Mock error reporting +vi.mock('@/lib/errors/report-error', () => ({ + reportError: vi.fn(), +})) + +// Component that throws an error +const ThrowError = ({ message = 'Test error' }: { message?: string }) => { + throw new Error(message) +} + +// Component that works +const WorkingComponent = () =>
Working content
+ +describe('ErrorBoundaries', () => { + beforeEach(() => { + vi.clearAllMocks() + // Suppress console.error during tests (React logs errors caught by boundaries) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + describe('WorkspaceErrorBoundary', () => { + it('renders children when no error occurs', () => { + render( + + + + ) + + expect(screen.getByText('Working content')).toBeInTheDocument() + }) + + it('catches and displays errors from children', () => { + render( + + + + ) + + expect(screen.getByText('Workspace Error')).toBeInTheDocument() + expect(screen.getByText(/an error occurred while processing your content/i)).toBeInTheDocument() + }) + + it('reports errors to error reporting service', () => { + render( + + + + ) + + expect(errorReporter.reportError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ message: 'Custom error message' }), + severity: 'error', + source: 'workspace-error-boundary', + }) + ) + }) + + it('shows retry button and calls onRetry handler', async () => { + const user = userEvent.setup() + const onRetry = vi.fn() + + render( + + + + ) + + // Error is shown + expect(screen.getByText('Workspace Error')).toBeInTheDocument() + + const retryButton = screen.getByRole('button', { name: /retry/i }) + await user.click(retryButton) + + // Verify retry handler was called + expect(onRetry).toHaveBeenCalled() + }) + + it('shows reset workspace button', async () => { + const user = userEvent.setup() + const onReset = vi.fn() + + render( + + + + ) + + const resetButton = screen.getByRole('button', { name: /reset workspace/i }) + await user.click(resetButton) + + expect(onReset).toHaveBeenCalled() + }) + + it('renders custom fallback when provided', () => { + render( + Custom error UI}> + + + ) + + expect(screen.getByText('Custom error UI')).toBeInTheDocument() + expect(screen.queryByText('Workspace Error')).not.toBeInTheDocument() + }) + }) + + describe('AIOperationErrorBoundary', () => { + it('renders children when no error occurs', () => { + render( + + + + ) + + expect(screen.getByText('Working content')).toBeInTheDocument() + }) + + it('catches and displays errors with operation context', () => { + render( + + + + ) + + expect(screen.getByText(/rewriting error/i)).toBeInTheDocument() + }) + + it('provides operation-specific error context', () => { + render( + + + + ) + + expect(errorReporter.reportError).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + operation: 'translate', + }), + }) + ) + }) + + it('tracks retry attempts and enforces max retries', async () => { + const user = userEvent.setup() + const onRetry = vi.fn() + + const { rerender } = render( + + + + ) + + // First retry + const retryButton = screen.getByRole('button', { name: /retry/i }) + await user.click(retryButton) + expect(onRetry).toHaveBeenCalledTimes(1) + + // Re-render with error to simulate retry failure + rerender( + + + + ) + + // Second retry + await user.click(screen.getByRole('button', { name: /retry/i })) + expect(onRetry).toHaveBeenCalledTimes(2) + + // Re-render again + rerender( + + + + ) + + // Max retries reached + await waitFor(() => { + expect(screen.getByText(/maximum retry attempts reached/i)).toBeInTheDocument() + }) + }) + + it('shows helpful error messages based on error type', () => { + // Create a RewriterError-like object + const rewriterError = new Error('Initialization failed') + Object.assign(rewriterError, { code: 'INITIALIZATION_FAILED', name: 'RewriterError' }) + + const ThrowRewriterError = () => { + throw rewriterError + } + + render( + + + + ) + + // The helpful message is shown after retry limit + // Just verify the error is displayed with a message + expect(screen.getByText(/rewriting error/i)).toBeInTheDocument() + expect(screen.getByText(/initialization failed/i)).toBeInTheDocument() + }) + + it('shows cancel operation button when handler provided', async () => { + const user = userEvent.setup() + const onCancel = vi.fn() + + render( + + + + ) + + const cancelButton = screen.getByRole('button', { name: /cancel operation/i }) + await user.click(cancelButton) + + expect(onCancel).toHaveBeenCalled() + }) + + it('uses custom fallback when provided', () => { + render( + Custom AI error UI}> + + + ) + + expect(screen.getByText('Custom AI error UI')).toBeInTheDocument() + }) + + it('defaults operation to "AI Operation" when not specified', () => { + render( + + + + ) + + expect(screen.getByText(/ai operation error/i)).toBeInTheDocument() + }) + }) +}) 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..1b91a2c --- /dev/null +++ b/src/components/help/HelpButton.tsx @@ -0,0 +1,111 @@ +/** + * 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', + 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 */} + + {/* Original text reference card (optional, collapsible) */} + {originalText && ( +
+ + {showOriginal && ( +
+
+
+                  {originalText}
+                
+
+
+ +

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

+
+
+ )} +
+ )} + {/* Format sections */}
{formats.map((format) => { const onToggle = toggleHandlers.get(format) ?? (() => toggleFormat(format)) const onCopyFormat = copyHandlers.get(format) ?? (() => handleCopy(format)) + const content = results[format] ?? '' + const isFormatLoading = isLoading?.get(format) ?? false + const isFormatExpanded = expandedFormats.has(format) + + console.log(`[FormattedOutput RENDER FormatSection] format: ${format}, content length: ${content.length}, isLoading: ${isFormatLoading}, isExpanded: ${isFormatExpanded}`) return ( @@ -316,7 +439,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/preferences/FormatQuickSelector.tsx b/src/components/preferences/FormatQuickSelector.tsx index f47d387..d5158e5 100644 --- a/src/components/preferences/FormatQuickSelector.tsx +++ b/src/components/preferences/FormatQuickSelector.tsx @@ -148,12 +148,6 @@ export const FormatQuickSelector = ({ )} - - {selectedFormats.includes('summary') && selectedFormats.length > 1 && ( -

- Summary cannot be combined with other formats -

- )} ) } diff --git a/src/components/preferences/FormatSelector.tsx b/src/components/preferences/FormatSelector.tsx index 31de7e0..8612808 100644 --- a/src/components/preferences/FormatSelector.tsx +++ b/src/components/preferences/FormatSelector.tsx @@ -146,9 +146,7 @@ export const FormatSelector = ({

Invalid format combination

- {selectedFormats.length === 0 - ? 'Please select at least one format.' - : 'Summary cannot be combined with other formats. Please select either Summary alone or other formats without Summary.'} + Please select at least one format.

diff --git a/src/components/ui/MultiFormatProgress.tsx b/src/components/ui/MultiFormatProgress.tsx new file mode 100644 index 0000000..a0dd1cc --- /dev/null +++ b/src/components/ui/MultiFormatProgress.tsx @@ -0,0 +1,227 @@ +/** + * MultiFormatProgress Component + * + * Displays progress for multiple format transformations. + * Shows individual format progress, status, and aggregate completion. + * + * Features: + * - Individual format progress bars + * - Status indicators (pending, processing, complete, error) + * - Aggregate progress overview + * - Compact and detailed views + * - Streaming content preview + * + * Usage: + * ```tsx + * + * ``` + */ + +import type { FormatType } from '@/lib/chrome-ai/types' +import type { MultiFormatProgress as MultiFormatProgressType, AggregateProgress } from '@/lib/chrome-ai/types/progress' +import { ProgressBar } from './ProgressBar' +import { FORMAT_OPTIONS } from '@/constants/formats' + +interface MultiFormatProgressProps { + /** Map of format progress */ + formats: Map + + /** Aggregate progress (optional) */ + aggregate?: AggregateProgress | null + + /** Display variant */ + variant?: 'compact' | 'detailed' + + /** Show partial results for streaming */ + showPartialResults?: boolean + + /** Custom class name */ + className?: string +} + +const getStatusColor = (status: MultiFormatProgressType['status']): string => { + switch (status) { + case 'pending': + return 'text-gray-500 bg-gray-100 dark:bg-gray-800' + case 'processing': + return 'text-blue-600 bg-blue-50 dark:bg-blue-900/30' + case 'complete': + return 'text-green-600 bg-green-50 dark:bg-green-900/30' + case 'error': + return 'text-red-600 bg-red-50 dark:bg-red-900/30' + default: + return 'text-gray-500 bg-gray-100' + } +} + +const getStatusIcon = (status: MultiFormatProgressType['status']): string => { + switch (status) { + case 'pending': + return '○' + case 'processing': + return '◐' + case 'complete': + return '●' + case 'error': + return '✕' + default: + return '○' + } +} + +const getStatusLabel = (status: MultiFormatProgressType['status']): string => { + switch (status) { + case 'pending': + return 'Pending' + case 'processing': + return 'Processing' + case 'complete': + return 'Complete' + case 'error': + return 'Error' + default: + return 'Unknown' + } +} + +export const MultiFormatProgress = ({ + formats, + aggregate, + variant = 'compact', + showPartialResults = false, + className = '', +}: MultiFormatProgressProps) => { + const formatEntries = Array.from(formats.entries()) + + if (formatEntries.length === 0) { + return null + } + + return ( +
+ {/* Aggregate progress */} + {aggregate && ( +
+
+

+ Overall Progress +

+ + {aggregate.completedCount} of {aggregate.totalCount} formats + +
+ + {aggregate.currentFormat && ( +

+ Currently processing: {FORMAT_OPTIONS[aggregate.currentFormat]?.label || aggregate.currentFormat} +

+ )} +
+ )} + + {/* Individual format progress */} +
+ {formatEntries.map(([format, progress]) => { + const formatConfig = FORMAT_OPTIONS[format] + const statusColor = getStatusColor(progress.status) + const statusIcon = getStatusIcon(progress.status) + + if (variant === 'compact') { + return ( +
+
+ {statusIcon} +
+
+
+ + {formatConfig?.label || format} + + + {progress.percentage}% + +
+ +
+
+ ) + } + + // Detailed variant + return ( +
+
+
+
+ {statusIcon} +
+
+
+ {formatConfig?.label || format} +
+

+ {getStatusLabel(progress.status)} +

+
+
+ + {progress.percentage}% + +
+ + + + {/* Show timing if available */} + {progress.startTime && progress.endTime && ( +

+ Completed in {((progress.endTime - progress.startTime) / 1000).toFixed(1)}s +

+ )} + + {/* Show error if present */} + {progress.error && ( +

+ Error: {progress.error.message} +

+ )} + + {/* Show partial result preview if streaming and enabled */} + {showPartialResults && progress.partialResult && progress.status === 'processing' && ( +
+

+ {progress.partialResult} +

+
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/src/components/ui/ProcessingState.tsx b/src/components/ui/ProcessingState.tsx index 2992af0..7cd7695 100644 --- a/src/components/ui/ProcessingState.tsx +++ b/src/components/ui/ProcessingState.tsx @@ -1,5 +1,7 @@ import { CustomSpinner } from '@/components/ui/CustomSpinner' import { ProgressBar, type ProgressBarProps } from '@/components/ui/ProgressBar' +import type { OperationProgress } from '@/lib/chrome-ai/types/progress' +import { formatTimeRemaining } from '@/lib/chrome-ai/utils/progressCalculator' type ProcessingStatus = 'idle' | 'preparing' | 'processing' | 'success' | 'error' @@ -11,6 +13,15 @@ interface ProcessingStateProps { children?: React.ReactNode showProgress?: boolean progressProps?: Partial + + /** Detailed operation progress (optional) */ + operationProgress?: OperationProgress | null + + /** Show stage information */ + showStage?: boolean + + /** Show estimated time remaining */ + showTimeRemaining?: boolean } const statusConfig: Record = { @@ -49,33 +60,92 @@ export const ProcessingState = ({ children, showProgress = false, progressProps, + operationProgress, + showStage = false, + showTimeRemaining = false, }: ProcessingStateProps) => { const state = statusConfig[status] const showSpinner = status === 'preparing' || status === 'processing' + // Use operation progress if available + const effectiveProgress = operationProgress?.percentage ?? progress + const effectiveDescription = operationProgress?.message ?? description ?? state.helper + + // Get stage label for display + const getStageLabel = (stage: OperationProgress['stage']): string => { + switch (stage) { + case 'initializing': + return 'Initializing' + case 'processing': + return 'Processing' + case 'streaming': + return 'Streaming' + case 'finalizing': + return 'Finalizing' + case 'complete': + return 'Complete' + default: + return '' + } + } + return (
{showSpinner && } +

{title || state.headline}

- {description || state.helper} + {effectiveDescription}

+ + {/* Show stage if requested and available */} + {showStage && operationProgress?.stage && ( +
+ + {getStageLabel(operationProgress.stage)} +
+ )} + + {/* Show time remaining if requested and available */} + {showTimeRemaining && + operationProgress?.estimatedTimeRemaining !== undefined && + operationProgress.estimatedTimeRemaining > 0 && ( +

+ {formatTimeRemaining(operationProgress.estimatedTimeRemaining)} +

+ )}
{children} - {showProgress && typeof progress === 'number' && ( + {/* Show progress bar if enabled and progress is available */} + {showProgress && typeof effectiveProgress === 'number' && ( )} + + {/* Show detailed metadata if available */} + {operationProgress?.metadata && ( +
+ {typeof operationProgress.metadata.inputLength === 'number' && ( + Input: {operationProgress.metadata.inputLength} chars + )} + {typeof operationProgress.metadata.outputLength === 'number' && ( + Output: {operationProgress.metadata.outputLength} chars + )} + {typeof operationProgress.metadata.chunks === 'number' && ( + Chunks: {operationProgress.metadata.chunks} + )} +
+ )}
) } 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'} > void | Promise + currentAttempt?: number + maxAttempts?: number + isRetrying?: boolean + disabled?: boolean + variant?: 'primary' | 'secondary' + size?: 'small' | 'medium' | 'large' + className?: string +} + +// Retry icon SVG +const RetryIcon = ({ className }: { className?: string }) => ( + + + +) + +// Loading spinner icon +const SpinnerIcon = ({ className }: { className?: string }) => ( + + + + +) + +export const RetryButton = ({ + onRetry, + currentAttempt = 0, + maxAttempts = 3, + isRetrying = false, + disabled = false, + variant = 'secondary', + size = 'medium', + className = '', +}: RetryButtonProps) => { + const isMaxRetriesReached = currentAttempt >= maxAttempts + const isDisabled = disabled || isRetrying || isMaxRetriesReached + + // Size variants + const sizeClasses = { + small: 'px-3 py-1.5 text-sm', + medium: 'px-4 py-2 text-sm', + large: 'px-6 py-3 text-base', + } + + // Icon sizes + const iconSizes = { + small: 'h-4 w-4', + medium: 'h-4 w-4', + large: 'h-5 w-5', + } + + // Variant styles + const variantClasses = + variant === 'primary' + ? 'bg-synapse-primary text-white hover:bg-synapse-primary-dark disabled:bg-neutral-300 disabled:text-neutral-500' + : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700 disabled:opacity-50' + + const handleClick = async () => { + if (!isDisabled) { + await onRetry() + } + } + + return ( + + ) +} diff --git a/src/components/ui/SuccessCelebration.tsx b/src/components/ui/SuccessCelebration.tsx new file mode 100644 index 0000000..82f129a --- /dev/null +++ b/src/components/ui/SuccessCelebration.tsx @@ -0,0 +1,222 @@ +/** + * SuccessCelebration Component + * + * A celebratory success message component for significant achievements. + * Features animated checkmark, confetti-like decoration, and prominent display. + * + * Features: + * - Animated checkmark with drawing effect + * - Celebration decorations + * - Optional confetti animation + * - Customizable message and actions + * - Auto-dismiss with countdown + * + * Usage: + * ```tsx + * + * ``` + */ + +import { useState, useEffect, type ReactNode } from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +export interface CelebrationStat { + label: string + value: string + icon?: ReactNode +} + +interface SuccessCelebrationProps { + title: string + message?: string + stats?: CelebrationStat[] + primaryAction?: { + label: string + onClick: () => void + } + secondaryAction?: { + label: string + onClick: () => void + } + onDismiss?: () => void + autoDismiss?: boolean + autoDismissDelay?: number + showConfetti?: boolean +} + +// Animated checkmark SVG +const AnimatedCheckmark = () => ( + + + + +) + +export const SuccessCelebration = ({ + title, + message, + stats, + primaryAction, + secondaryAction, + onDismiss, + autoDismiss = false, + autoDismissDelay = 5000, + showConfetti = true, +}: SuccessCelebrationProps) => { + const [isVisible, setIsVisible] = useState(true) + const [countdown, setCountdown] = useState(autoDismissDelay / 1000) + + useEffect(() => { + if (autoDismiss) { + const dismissTimer = setTimeout(() => { + setIsVisible(false) + onDismiss?.() + }, autoDismissDelay) + + const countdownInterval = setInterval(() => { + setCountdown((prev) => Math.max(0, prev - 1)) + }, 1000) + + return () => { + clearTimeout(dismissTimer) + clearInterval(countdownInterval) + } + } + }, [autoDismiss, autoDismissDelay, onDismiss]) + + const handleDismiss = () => { + setIsVisible(false) + onDismiss?.() + } + + return ( + + {isVisible && ( + + {/* Decorative background */} + {showConfetti && ( +
+
+
+
+ )} + + {/* Content */} +
+ {/* Animated checkmark */} +
+ +
+ + {/* Title and message */} +
+

+ {title} +

+ {message && ( +

+ {message} +

+ )} +
+ + {/* Stats */} + {stats && stats.length > 0 && ( +
+ {stats.map((stat) => ( +
+
+ {stat.icon && {stat.icon}} + {stat.label} +
+
+ {stat.value} +
+
+ ))} +
+ )} + + {/* Actions */} +
+ {primaryAction && ( + + )} + + {secondaryAction && ( + + )} +
+ + {/* Auto-dismiss countdown */} + {autoDismiss && countdown > 0 && ( +

+ Auto-closing in {countdown}s +

+ )} + + {/* Close button */} + {onDismiss && ( + + )} +
+ + )} + + ) +} diff --git a/src/components/ui/SuccessMessage.test.tsx b/src/components/ui/SuccessMessage.test.tsx index d8a0b3c..6c750e9 100644 --- a/src/components/ui/SuccessMessage.test.tsx +++ b/src/components/ui/SuccessMessage.test.tsx @@ -14,14 +14,16 @@ describe('SuccessMessage', () => { expect(screen.getByText('Operation completed successfully')).toBeInTheDocument() }) - it('should render without icon', () => { + it('should render with default icon', () => { const { container } = render() - // When there's no icon, the icon div should not exist - // The message still has .text-accessibility-success, so we check structure + // When no custom icon is provided, the component renders a default icon const flexContainer = container.querySelector('.flex.items-start.gap-3') expect(flexContainer).toBeInTheDocument() - // Should only have 1 child (the message container) when no icon - expect(flexContainer?.children).toHaveLength(1) + // Should have 2 children: icon div + content div (no dismiss button by default) + expect(flexContainer?.children).toHaveLength(2) + // Verify default icon is present + const iconDiv = flexContainer?.querySelector('.text-accessibility-success') + expect(iconDiv).toBeInTheDocument() }) }) diff --git a/src/components/ui/SuccessMessage.tsx b/src/components/ui/SuccessMessage.tsx index 53dc276..63ed8d5 100644 --- a/src/components/ui/SuccessMessage.tsx +++ b/src/components/ui/SuccessMessage.tsx @@ -1,41 +1,147 @@ -import type { ReactNode } from 'react' +import { useState, useEffect, type ReactNode } from 'react' +import { motion, AnimatePresence } from 'framer-motion' export interface SuccessMetric { label: string value: string + trend?: 'up' | 'down' | 'neutral' + icon?: ReactNode } interface SuccessMessageProps { icon?: ReactNode message: string + description?: string metrics?: SuccessMetric[] action?: ReactNode + dismissible?: boolean + onDismiss?: () => void + autoDismiss?: boolean + autoDismissDelay?: number + variant?: 'default' | 'prominent' | 'subtle' } -export const SuccessMessage = ({ icon, message, metrics, action }: SuccessMessageProps) => { +const defaultIcon = ( + + + +) + +export const SuccessMessage = ({ + icon, + message, + description, + metrics, + action, + dismissible = false, + onDismiss, + autoDismiss = false, + autoDismissDelay = 5000, + variant = 'default', +}: SuccessMessageProps) => { + const [isVisible, setIsVisible] = useState(true) + + useEffect(() => { + if (autoDismiss) { + const timer = setTimeout(() => { + setIsVisible(false) + onDismiss?.() + }, autoDismissDelay) + + return () => clearTimeout(timer) + } + }, [autoDismiss, autoDismissDelay, onDismiss]) + + const handleDismiss = () => { + setIsVisible(false) + onDismiss?.() + } + + const variantStyles = { + default: + 'border-accessibility-success/40 bg-accessibility-success/5 dark:border-accessibility-success/30 dark:bg-accessibility-success/10', + prominent: + 'border-accessibility-success bg-accessibility-success/10 shadow-brand dark:border-accessibility-success/70 dark:bg-accessibility-success/20', + subtle: + 'border-accessibility-success/20 bg-accessibility-success/3 dark:border-accessibility-success/20 dark:bg-accessibility-success/5', + } + return ( -
-
- {icon &&
{icon}
} -
-

{message}

- {metrics && metrics.length > 0 && ( -
- {metrics.map((metric) => ( -
-
- {metric.label} -
-
- {metric.value} -
-
- ))} -
- )} -
-
- {action &&
{action}
} -
+ + {isVisible && ( + +
+ {/* Icon */} +
+ {icon || defaultIcon} +
+ + {/* Content */} +
+
+

{message}

+ {description && ( +

{description}

+ )} +
+ + {/* Metrics */} + {metrics && metrics.length > 0 && ( +
+ {metrics.map((metric) => ( +
+
+ {metric.icon && {metric.icon}} + {metric.label} +
+
+ {metric.value} + {metric.trend === 'up' && ( + + + + )} + {metric.trend === 'down' && ( + + + + )} +
+
+ ))} +
+ )} +
+ + {/* Dismiss button */} + {dismissible && ( + + )} +
+ + {/* Action */} + {action &&
{action}
} +
+ )} +
) } diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..e51606e --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,196 @@ +/** + * Toast Component + * + * Individual toast notification with: + * - Success, error, warning, and info variants + * - Auto-dismiss with progress indicator + * - Manual dismiss button + * - Optional action button + * - Smooth animations + * - Accessibility support (ARIA live regions) + */ + +import type { Toast as ToastType } from '@/lib/feedback/toastManager' +import { dismissToast } from '@/lib/feedback/toastManager' +import { useEffect, useState } from 'react' + +interface ToastProps { + toast: ToastType +} + +// Toast type configurations with high contrast support +const toastConfig = { + success: { + 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/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/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/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]', + }, +} + +// Icons as inline SVG components +const CheckCircleIcon = () => ( + + + +) + +const ErrorCircleIcon = () => ( + + + +) + +const WarningIcon = () => ( + + + +) + +const InfoIcon = () => ( + + + +) + +const CloseIcon = () => ( + + + +) + +const iconMap = { + success: CheckCircleIcon, + error: ErrorCircleIcon, + warning: WarningIcon, + info: InfoIcon, +} + +export const Toast = ({ toast }: ToastProps) => { + const [isExiting, setIsExiting] = useState(false) + const [progress, setProgress] = useState(100) + + const config = toastConfig[toast.type] + const Icon = iconMap[toast.type] + + // Handle auto-dismiss progress + useEffect(() => { + if (toast.duration && toast.duration > 0) { + const endTime = toast.createdAt + toast.duration + + const updateProgress = () => { + const now = Date.now() + const remaining = endTime - now + const total = toast.duration! + const currentProgress = Math.max(0, (remaining / total) * 100) + + setProgress(currentProgress) + + if (currentProgress > 0) { + requestAnimationFrame(updateProgress) + } + } + + requestAnimationFrame(updateProgress) + } + }, [toast.duration, toast.createdAt]) + + const handleDismiss = () => { + setIsExiting(true) + // Wait for exit animation before removing from state + setTimeout(() => { + dismissToast(toast.id) + }, 200) + } + + const handleAction = () => { + if (toast.action?.onClick) { + toast.action.onClick() + handleDismiss() + } + } + + return ( +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+

+ {toast.title} +

+ {toast.message && ( +

+ {toast.message} +

+ )} +
+ + {/* Dismiss button */} + {toast.dismissible && ( + + )} +
+ + {/* Action button */} + {toast.action && ( +
+ +
+ )} + + {/* Progress bar */} + {toast.duration && toast.duration > 0 && ( +
+
+
+ )} +
+ ) +} diff --git a/src/components/ui/ToastContainer.tsx b/src/components/ui/ToastContainer.tsx new file mode 100644 index 0000000..135e3a7 --- /dev/null +++ b/src/components/ui/ToastContainer.tsx @@ -0,0 +1,35 @@ +/** + * ToastContainer Component + * + * Container for rendering toast notifications. + * Positioned at the top-right of the viewport with proper z-index and spacing. + * + * Features: + * - Fixed positioning at top-right + * - Stacked toast layout + * - Responsive positioning + * - Proper z-index layering + * - Pointer events management (pass-through except on toasts) + */ + +import { Toast } from '@/components/ui/Toast' +import { useToast } from '@/hooks/useToast' + +export const ToastContainer = () => { + const { toasts } = useToast() + + if (toasts.length === 0) { + return null + } + + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ) +} diff --git a/src/components/ui/__tests__/ProgressComponents.test.tsx b/src/components/ui/__tests__/ProgressComponents.test.tsx new file mode 100644 index 0000000..b245c8d --- /dev/null +++ b/src/components/ui/__tests__/ProgressComponents.test.tsx @@ -0,0 +1,522 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ProcessingState } from '../ProcessingState' +import { MultiFormatProgress } from '../MultiFormatProgress' +import { ProgressBar, DetailedProgressBar, StepProgress, CircularProgress } from '../ProgressBar' +import type { OperationProgress, MultiFormatProgress as MultiFormatProgressType } from '@/lib/chrome-ai/types/progress' +import type { FormatType } from '@/constants/formats' + +describe('ProgressComponents', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('ProcessingState', () => { + it('renders idle state correctly', () => { + render() + + expect(screen.getByText('Ready when you are')).toBeInTheDocument() + expect(screen.getByText(/add content to start a new transformation run/i)).toBeInTheDocument() + }) + + it('renders preparing state with spinner', () => { + render() + + expect(screen.getByText('Preparing Chrome AI')).toBeInTheDocument() + expect(screen.getByText(/warming up on-device models/i)).toBeInTheDocument() + }) + + it('renders processing state with spinner', () => { + render() + + expect(screen.getByText('Processing your content')).toBeInTheDocument() + expect(screen.getByText(/chrome is reshaping your text/i)).toBeInTheDocument() + }) + + it('renders success state', () => { + render() + + expect(screen.getByText('Transformation complete')).toBeInTheDocument() + expect(screen.getByText(/review the formats below/i)).toBeInTheDocument() + }) + + it('renders error state', () => { + render() + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByText(/try again in a moment/i)).toBeInTheDocument() + }) + + it('shows progress bar when showProgress is true', () => { + render() + + const progressbar = screen.getByRole('progressbar') + expect(progressbar).toBeInTheDocument() + expect(progressbar).toHaveAttribute('aria-valuenow', '75') + }) + + it('displays custom title and description', () => { + render( + + ) + + expect(screen.getByText('Custom Title')).toBeInTheDocument() + expect(screen.getByText('Custom description text')).toBeInTheDocument() + }) + + it('shows stage indicator when showStage is true', () => { + const operationProgress: OperationProgress = { + stage: 'processing', + percentage: 50, + current: 50, + total: 100, + } + + render( + + ) + + expect(screen.getByText('Processing')).toBeInTheDocument() + }) + + it('shows time remaining when available', () => { + const operationProgress: OperationProgress = { + stage: 'processing', + percentage: 50, + current: 50, + total: 100, + estimatedTimeRemaining: 45000, // 45 seconds + } + + render( + + ) + + expect(screen.getByText('45s remaining')).toBeInTheDocument() + }) + + it('displays metadata when available', () => { + const operationProgress: OperationProgress = { + stage: 'processing', + percentage: 50, + current: 50, + total: 100, + metadata: { + inputLength: 1000, + outputLength: 500, + chunks: 3, + }, + } + + render( + + ) + + expect(screen.getByText('Input: 1000 chars')).toBeInTheDocument() + expect(screen.getByText('Output: 500 chars')).toBeInTheDocument() + expect(screen.getByText('Chunks: 3')).toBeInTheDocument() + }) + + it('uses operationProgress percentage over direct progress', () => { + const operationProgress: OperationProgress = { + stage: 'processing', + percentage: 75, + current: 75, + total: 100, + } + + render( + + ) + + const progressbar = screen.getByRole('progressbar') + expect(progressbar).toHaveAttribute('aria-valuenow', '75') + }) + + it('renders children correctly', () => { + render( + +
Custom child content
+
+ ) + + expect(screen.getByText('Custom child content')).toBeInTheDocument() + }) + }) + + describe('MultiFormatProgress', () => { + it('renders nothing when no formats provided', () => { + const { container } = render( + + ) + + expect(container.firstChild).toBeNull() + }) + + it('shows aggregate progress with correct counts', () => { + const formats = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + startTime: Date.now() - 2000, + endTime: Date.now(), + }, + ], + [ + 'bullets', + { + format: 'bullets', + status: 'processing', + percentage: 50, + startTime: Date.now() - 1000, + }, + ], + [ + 'summary', + { + format: 'summary', + status: 'pending', + percentage: 0, + }, + ], + ]) + + const aggregate = { + totalCount: 3, + completedCount: 1, + overallPercentage: 50, + currentFormat: 'bullets' as FormatType, + formats, + } + + render() + + expect(screen.getByText('Overall Progress')).toBeInTheDocument() + expect(screen.getByText('1 of 3 formats')).toBeInTheDocument() + expect(screen.getByText(/currently processing: bullet points/i)).toBeInTheDocument() + }) + + it('displays individual formats in compact variant', () => { + const formats = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + }, + ], + ]) + + render() + + expect(screen.getByText('Paragraphs')).toBeInTheDocument() + expect(screen.getByText('100%')).toBeInTheDocument() + }) + + it('displays individual formats in detailed variant', () => { + const formats = new Map([ + [ + 'bullets', + { + format: 'bullets', + status: 'processing', + percentage: 75, + startTime: Date.now(), + }, + ], + ]) + + render() + + expect(screen.getByText('Bullet Points')).toBeInTheDocument() + expect(screen.getByText('Processing')).toBeInTheDocument() + expect(screen.getByText('75%')).toBeInTheDocument() + }) + + it('shows correct status icons for different states', () => { + const formats = new Map([ + ['paragraphs', { format: 'paragraphs', status: 'pending', percentage: 0 }], + ['bullets', { format: 'bullets', status: 'processing', percentage: 50 }], + ['summary', { format: 'summary', status: 'complete', percentage: 100 }], + ]) + + const { container } = render() + + // Status icons: pending (○), processing (◐), complete (●) + expect(container.textContent).toContain('○') + expect(container.textContent).toContain('◐') + expect(container.textContent).toContain('●') + }) + + it('shows timing for completed formats in detailed view', () => { + const startTime = Date.now() - 2400 + const endTime = Date.now() + + const formats = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + startTime, + endTime, + }, + ], + ]) + + render() + + // Should show completion time (2.4s) + expect(screen.getByText(/completed in 2\.4s/i)).toBeInTheDocument() + }) + + it('shows error messages for failed formats', () => { + const formats = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'error', + percentage: 0, + error: new Error('Transformation failed'), + }, + ], + ]) + + render() + + expect(screen.getByText('Error: Transformation failed')).toBeInTheDocument() + }) + + it('shows partial results when enabled and streaming', () => { + const formats = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'processing', + percentage: 50, + partialResult: 'This is partial streaming content...', + }, + ], + ]) + + render( + + ) + + expect(screen.getByText('This is partial streaming content...')).toBeInTheDocument() + }) + + it('does not show partial results when not streaming', () => { + const formats = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + partialResult: 'This should not be shown', + }, + ], + ]) + + render( + + ) + + expect(screen.queryByText('This should not be shown')).not.toBeInTheDocument() + }) + }) + + describe('ProgressBar', () => { + it('renders with correct percentage', () => { + render() + + const progressbar = screen.getByRole('progressbar') + expect(progressbar).toBeInTheDocument() + expect(progressbar).toHaveAttribute('aria-valuenow', '75') + expect(progressbar).toHaveAttribute('aria-valuemin', '0') + expect(progressbar).toHaveAttribute('aria-valuemax', '100') + }) + + it('caps percentage at 100%', () => { + render() + + const progressbar = screen.getByRole('progressbar') + expect(progressbar).toHaveAttribute('aria-valuenow', '150') + }) + + it('shows label when showLabel is true', () => { + render() + + // Label shows percentage twice (label and value) + const labels = screen.getAllByText('75%') + expect(labels.length).toBe(2) + }) + + it('uses custom label when provided', () => { + render() + + expect(screen.getByText('Downloading...')).toBeInTheDocument() + expect(screen.getByText('75%')).toBeInTheDocument() + }) + + it('respects custom max value', () => { + render() + + const progressbar = screen.getByRole('progressbar') + expect(progressbar).toHaveAttribute('aria-valuenow', '50') + expect(progressbar).toHaveAttribute('aria-valuemax', '200') + }) + + it('applies correct size classes', () => { + const { container: small } = render() + const { container: medium } = render() + const { container: large } = render() + + expect(small.querySelector('.h-1')).toBeInTheDocument() + expect(medium.querySelector('.h-2')).toBeInTheDocument() + expect(large.querySelector('.h-3')).toBeInTheDocument() + }) + + it('has correct ARIA attributes', () => { + render() + + const progressbar = screen.getByRole('progressbar') + expect(progressbar).toHaveAttribute('aria-label', 'Loading data') + }) + }) + + describe('DetailedProgressBar', () => { + it('renders with current and total labels', () => { + render( + + ) + + expect(screen.getByText('50 MB')).toBeInTheDocument() + expect(screen.getByText('100 MB')).toBeInTheDocument() + expect(screen.getByText('50%')).toBeInTheDocument() + }) + + it('renders with subtitle', () => { + render( + + ) + + expect(screen.getByText('Downloading model...')).toBeInTheDocument() + expect(screen.getByText('75%')).toBeInTheDocument() + }) + }) + + describe('StepProgress', () => { + it('renders correct number of steps', () => { + const { container } = render( + + ) + + // Should have 4 step indicators + const dots = container.querySelectorAll('[class*="rounded-full"]') + expect(dots.length).toBeGreaterThanOrEqual(4) + }) + + it('shows step labels when provided', () => { + render( + + ) + + expect(screen.getByText('Initialize')).toBeInTheDocument() + expect(screen.getByText('Process')).toBeInTheDocument() + expect(screen.getByText('Complete')).toBeInTheDocument() + }) + + it('highlights current and completed steps', () => { + render( + + ) + + // Current step (1) should have primary color + const step2 = screen.getByText('Step 2') + expect(step2).toHaveClass('text-primary-600') + + // Future step (2) should be neutral + const step3 = screen.getByText('Step 3') + expect(step3).toHaveClass('text-neutral-500') + }) + }) + + describe('CircularProgress', () => { + it('renders with correct percentage', () => { + render() + + expect(screen.getByText('75%')).toBeInTheDocument() + }) + + it('uses custom label when provided', () => { + render() + + expect(screen.getByText('1/2')).toBeInTheDocument() + }) + + it('hides label when showLabel is false', () => { + render() + + expect(screen.queryByText('75%')).not.toBeInTheDocument() + }) + + it('caps percentage at 100', () => { + render() + + expect(screen.getByText('100%')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/ui/__tests__/SuccessComponents.test.tsx b/src/components/ui/__tests__/SuccessComponents.test.tsx new file mode 100644 index 0000000..c8eafe0 --- /dev/null +++ b/src/components/ui/__tests__/SuccessComponents.test.tsx @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SuccessMessage } from '../SuccessMessage' +import { SuccessCelebration } from '../SuccessCelebration' + +describe('SuccessComponents', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('SuccessMessage', () => { + it('renders with message and default icon', () => { + render() + + expect(screen.getByText('Operation successful')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('renders with custom icon', () => { + const customIcon = + + render() + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + }) + + it('displays description when provided', () => { + render( + + ) + + expect(screen.getByText('Everything completed as expected')).toBeInTheDocument() + }) + + it('renders metrics when provided', () => { + const metrics = [ + { label: 'Processed', value: '100' }, + { label: 'Time', value: '2.5s' }, + { label: 'Size', value: '1.2 MB' }, + ] + + render() + + expect(screen.getByText('Processed')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('Time')).toBeInTheDocument() + expect(screen.getByText('2.5s')).toBeInTheDocument() + expect(screen.getByText('Size')).toBeInTheDocument() + expect(screen.getByText('1.2 MB')).toBeInTheDocument() + }) + + it('shows trend indicators for metrics', () => { + const metrics = [ + { label: 'Speed', value: '10x', trend: 'up' as const }, + { label: 'Size', value: '-50%', trend: 'down' as const }, + ] + + const { container } = render() + + // Trend indicators are SVG icons + const svgs = container.querySelectorAll('svg') + expect(svgs.length).toBeGreaterThan(2) // Default icon + trend indicators + }) + + it('renders action when provided', () => { + const action = + + render() + + expect(screen.getByText('View Details')).toBeInTheDocument() + }) + + it('shows dismiss button when dismissible', async () => { + const user = userEvent.setup({ delay: null }) + const onDismiss = vi.fn() + + render( + + ) + + const dismissButton = screen.getByRole('button', { name: /dismiss success message/i }) + await user.click(dismissButton) + + expect(onDismiss).toHaveBeenCalled() + }) + + it('renders correctly with autoDismiss prop', () => { + render( + + ) + + // Verify message is displayed initially + expect(screen.getByText('Success')).toBeInTheDocument() + }) + + it('applies default variant styling', () => { + const { container } = render() + + // Default variant has specific border color + const element = container.querySelector('[role="status"]') + expect(element).toHaveClass('border-accessibility-success/40') + }) + + it('applies prominent variant styling', () => { + const { container } = render() + + const element = container.querySelector('[role="status"]') + expect(element).toHaveClass('border-accessibility-success') + }) + + it('applies subtle variant styling', () => { + const { container } = render() + + const element = container.querySelector('[role="status"]') + expect(element).toHaveClass('border-accessibility-success/20') + }) + }) + + describe('SuccessCelebration', () => { + it('renders with title and animated checkmark', () => { + render() + + expect(screen.getByText('Transformation Complete!')).toBeInTheDocument() + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('displays message when provided', () => { + render( + + ) + + expect(screen.getByText('Your content has been successfully transformed.')).toBeInTheDocument() + }) + + it('renders stats when provided', () => { + const stats = [ + { label: 'Formats generated', value: '3' }, + { label: 'Processing time', value: '2.4s' }, + ] + + render() + + // Stats labels are uppercased via CSS, use case-insensitive matching + expect(screen.getByText(/formats generated/i)).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText(/processing time/i)).toBeInTheDocument() + expect(screen.getByText('2.4s')).toBeInTheDocument() + }) + + it('shows primary action button', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + }) + + it('shows secondary action button', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'View Details' })).toBeInTheDocument() + }) + + it('shows dismiss button when onDismiss provided', () => { + render( + + ) + + expect(screen.getByRole('button', { name: /dismiss celebration/i })).toBeInTheDocument() + }) + + it('shows countdown when autoDismiss is enabled', () => { + render( + + ) + + // Initially shows 5 seconds countdown + expect(screen.getByText('Auto-closing in 5s')).toBeInTheDocument() + }) + + it('does not show countdown when autoDismiss is false', () => { + render() + + expect(screen.queryByText(/auto-closing/i)).not.toBeInTheDocument() + }) + + it('shows confetti decorations by default', () => { + const { container } = render() + + // Confetti decorations are decorative divs with blur + const decorations = container.querySelectorAll('[class*="blur"]') + expect(decorations.length).toBeGreaterThan(0) + }) + + it('hides confetti when showConfetti is false', () => { + const { container } = render( + + ) + + // No decorative blur elements + const decorations = container.querySelectorAll('[class*="blur"]') + expect(decorations.length).toBe(0) + }) + + it('renders stats with custom icons', () => { + const stats = [ + { + label: 'Score', + value: '95', + icon: , + }, + ] + + render() + + expect(screen.getByTestId('star-icon')).toBeInTheDocument() + expect(screen.getByText('95')).toBeInTheDocument() + }) + + it('has correct accessibility attributes', () => { + render() + + const alert = screen.getByRole('alert') + expect(alert).toHaveAttribute('aria-live', 'assertive') + }) + + it('renders both primary and secondary actions together', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('clears timers on unmount', () => { + vi.useFakeTimers() + const onDismiss = vi.fn() + + const { unmount } = render( + + ) + + // Unmount before timer completes + unmount() + + // Advance past the dismiss time + vi.advanceTimersByTime(6000) + + // Should not call onDismiss after unmount + expect(onDismiss).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) +}) diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 7d376bb..8693b83 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -52,3 +52,14 @@ export { CustomSpinner } from './CustomSpinner' export { ProcessingState } from './ProcessingState' export { EmptyState } from './EmptyState' export { SuccessMessage, type SuccessMetric } from './SuccessMessage' +export { SuccessCelebration, type CelebrationStat } from './SuccessCelebration' + +// Progress Components +export { MultiFormatProgress } from './MultiFormatProgress' + +// Toast Notifications +export { Toast } from './Toast' +export { ToastContainer } from './ToastContainer' + +// Retry Components +export { RetryButton } from './RetryButton' diff --git a/src/components/workspace/FormatQuickSelector.tsx b/src/components/workspace/FormatQuickSelector.tsx index 5959dde..692eaac 100644 --- a/src/components/workspace/FormatQuickSelector.tsx +++ b/src/components/workspace/FormatQuickSelector.tsx @@ -3,6 +3,60 @@ import { InlineHelp } from '@/components/common/InlineHelp' import type { FormatType } from '@/lib/chrome-ai/types' import { FORMAT_OPTIONS, FORMAT_PRESETS, getFormatLabels } from '@/constants/formats' +interface PreviewTooltipProps { + preview: string + label: string +} + +const PreviewTooltip = ({ preview, label }: PreviewTooltipProps) => { + 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/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 d2478c1..6a52e11 100644 --- a/src/constants/formats.ts +++ b/src/constants/formats.ts @@ -1,5 +1,8 @@ import type { FormatConfig, FormatPreset, FormatType } from '@/lib/chrome-ai/types' +// Re-export FormatType for convenience +export type { FormatType } from '@/lib/chrome-ai/types' + /** * Configuration for all available content restructuring formats */ @@ -54,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, }, } @@ -122,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/hooks/__tests__/hooks.test.tsx b/src/hooks/__tests__/hooks.test.tsx new file mode 100644 index 0000000..4f2174e --- /dev/null +++ b/src/hooks/__tests__/hooks.test.tsx @@ -0,0 +1,523 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useRetryableOperation } from '../useRetryableOperation' +import { useToast } from '../useToast' +import { useHelpPanel } from '../useHelpPanel' +import * as toastManager from '@/lib/feedback/toastManager' + +// Mock toast manager +vi.mock('@/lib/feedback/toastManager', () => ({ + showToast: vi.fn(() => 'toast-1'), + showSuccessToast: vi.fn(() => 'toast-2'), + showErrorToast: vi.fn(() => 'toast-3'), + showWarningToast: vi.fn(() => 'toast-4'), + showInfoToast: vi.fn(() => 'toast-5'), + dismissToast: vi.fn(), + dismissAllToasts: vi.fn(), + subscribeToToasts: vi.fn((callback) => { + // Immediately call with empty array + callback([]) + // Return unsubscribe function + return vi.fn() + }), +})) + +// Mock retry handler +vi.mock('@/lib/chrome-ai/utils/retryHandler', () => ({ + withRetry: vi.fn(async (operation, options) => { + // Simulate retry attempts + if (options?.onRetry) { + options.onRetry(0) + } + return await operation() + }), +})) + +describe('Hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllTimers() + }) + + describe('useRetryableOperation', () => { + it('initializes with idle state', () => { + const operation = vi.fn().mockResolvedValue('result') + + const { result } = renderHook(() => + useRetryableOperation({ operation }) + ) + + expect(result.current.state).toBe('idle') + expect(result.current.data).toBeNull() + expect(result.current.error).toBeNull() + expect(result.current.currentAttempt).toBe(0) + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.isError).toBe(false) + }) + + it('executes operation successfully', async () => { + const operation = vi.fn().mockResolvedValue('result') + const onSuccess = vi.fn() + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + onSuccess, + showToasts: false, + }) + ) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.state).toBe('success') + expect(result.current.data).toBe('result') + expect(result.current.error).toBeNull() + expect(result.current.isSuccess).toBe(true) + expect(result.current.isLoading).toBe(false) + expect(onSuccess).toHaveBeenCalledWith('result') + }) + + it('handles operation failure', async () => { + const error = new Error('Operation failed') + const operation = vi.fn().mockRejectedValue(error) + const onError = vi.fn() + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + onError, + showToasts: false, + }) + ) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.state).toBe('error') + expect(result.current.data).toBeNull() + expect(result.current.error).toEqual(error) + expect(result.current.isError).toBe(true) + expect(result.current.isLoading).toBe(false) + expect(onError).toHaveBeenCalledWith(error) + }) + + it('shows loading state during execution', async () => { + let resolveOperation: (value: string) => void + const operation = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveOperation = resolve + }) + ) + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + showToasts: false, + }) + ) + + // Start execution + act(() => { + result.current.execute() + }) + + // Check loading state + expect(result.current.state).toBe('loading') + expect(result.current.isLoading).toBe(true) + + // Resolve operation + await act(async () => { + resolveOperation!('result') + await Promise.resolve() + }) + + expect(result.current.isLoading).toBe(false) + }) + + it('retries operation when in error state', async () => { + const error = new Error('First attempt failed') + let callCount = 0 + const operation = vi.fn().mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.reject(error) + } + return Promise.resolve('success on retry') + }) + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + showToasts: false, + }) + ) + + // First execution fails + await act(async () => { + await result.current.execute() + }) + + expect(result.current.isError).toBe(true) + expect(result.current.error).toEqual(error) + + // Retry succeeds + await act(async () => { + await result.current.retry() + }) + + expect(result.current.isSuccess).toBe(true) + expect(result.current.data).toBe('success on retry') + expect(result.current.error).toBeNull() + }) + + it('does not retry when not in error state', async () => { + const operation = vi.fn().mockResolvedValue('result') + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + showToasts: false, + }) + ) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.isSuccess).toBe(true) + + const callCount = operation.mock.calls.length + + // Try to retry when in success state + await act(async () => { + await result.current.retry() + }) + + // Operation should not be called again + expect(operation.mock.calls.length).toBe(callCount) + }) + + it('resets state correctly', async () => { + const operation = vi.fn().mockResolvedValue('result') + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + showToasts: false, + }) + ) + + await act(async () => { + await result.current.execute() + }) + + expect(result.current.isSuccess).toBe(true) + expect(result.current.data).toBe('result') + + act(() => { + result.current.reset() + }) + + expect(result.current.state).toBe('idle') + expect(result.current.data).toBeNull() + expect(result.current.error).toBeNull() + expect(result.current.currentAttempt).toBe(0) + }) + + it('shows success toast when enabled', async () => { + const operation = vi.fn().mockResolvedValue('result') + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + showToasts: true, + successMessage: 'Operation succeeded!', + }) + ) + + await act(async () => { + await result.current.execute() + }) + + expect(toastManager.showSuccessToast).toHaveBeenCalledWith( + 'Operation succeeded!', + { duration: 4000 } + ) + }) + + it('shows error toast when enabled', async () => { + const error = new Error('Something went wrong') + const operation = vi.fn().mockRejectedValue(error) + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + showToasts: true, + errorMessage: 'Operation failed!', + }) + ) + + await act(async () => { + await result.current.execute() + }) + + expect(toastManager.showErrorToast).toHaveBeenCalledWith( + 'Operation failed!', + { + message: 'Something went wrong', + duration: 7000, + } + ) + }) + + it('tracks max attempts correctly', () => { + const operation = vi.fn().mockResolvedValue('result') + + const { result } = renderHook(() => + useRetryableOperation({ + operation, + maxAttempts: 5, + }) + ) + + expect(result.current.maxAttempts).toBe(5) + }) + }) + + describe('useToast', () => { + it('initializes with empty toasts array', () => { + const { result } = renderHook(() => useToast()) + + expect(result.current.toasts).toEqual([]) + }) + + it('subscribes to toast state on mount', () => { + renderHook(() => useToast()) + + expect(toastManager.subscribeToToasts).toHaveBeenCalled() + }) + + it('unsubscribes on unmount', () => { + const unsubscribe = vi.fn() + vi.mocked(toastManager.subscribeToToasts).mockReturnValue(unsubscribe) + + const { unmount } = renderHook(() => useToast()) + + unmount() + + expect(unsubscribe).toHaveBeenCalled() + }) + + it('shows success toast', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.showSuccess('Success!', { message: 'All done' }) + }) + + expect(toastManager.showSuccessToast).toHaveBeenCalledWith('Success!', { + message: 'All done', + }) + }) + + it('shows error toast', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.showError('Error!', { message: 'Something failed' }) + }) + + expect(toastManager.showErrorToast).toHaveBeenCalledWith('Error!', { + message: 'Something failed', + }) + }) + + it('shows warning toast', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.showWarning('Warning!', { message: 'Be careful' }) + }) + + expect(toastManager.showWarningToast).toHaveBeenCalledWith('Warning!', { + message: 'Be careful', + }) + }) + + it('shows info toast', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.showInfo('Info', { message: 'FYI' }) + }) + + expect(toastManager.showInfoToast).toHaveBeenCalledWith('Info', { + message: 'FYI', + }) + }) + + it('shows generic toast', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.showToast('Toast', { type: 'info' }) + }) + + expect(toastManager.showToast).toHaveBeenCalledWith('Toast', { type: 'info' }) + }) + + it('dismisses toast by id', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.dismiss('toast-123') + }) + + expect(toastManager.dismissToast).toHaveBeenCalledWith('toast-123') + }) + + it('dismisses all toasts', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.dismissAll() + }) + + expect(toastManager.dismissAllToasts).toHaveBeenCalled() + }) + + it('updates toasts when subscription callback is called', () => { + const mockToasts = [ + { id: '1', title: 'Toast 1', type: 'success' as const, createdAt: Date.now() }, + { id: '2', title: 'Toast 2', type: 'error' as const, createdAt: Date.now() }, + ] + + let subscriptionCallback: (toasts: typeof mockToasts) => void + + vi.mocked(toastManager.subscribeToToasts).mockImplementation((callback) => { + subscriptionCallback = callback + callback([]) // Initial call + return vi.fn() + }) + + const { result } = renderHook(() => useToast()) + + // Initially empty + expect(result.current.toasts).toEqual([]) + + // Trigger subscription update + act(() => { + subscriptionCallback!(mockToasts) + }) + + expect(result.current.toasts).toEqual(mockToasts) + }) + }) + + describe('useHelpPanel', () => { + it('initializes with closed state', () => { + const { result } = renderHook(() => useHelpPanel()) + + expect(result.current.isOpen).toBe(false) + expect(result.current.topicId).toBeUndefined() + }) + + it('opens help panel', () => { + const { result } = renderHook(() => useHelpPanel()) + + act(() => { + result.current.openHelp() + }) + + expect(result.current.isOpen).toBe(true) + expect(result.current.topicId).toBeUndefined() + }) + + it('closes help panel', () => { + const { result } = renderHook(() => useHelpPanel()) + + act(() => { + result.current.openHelp() + }) + + expect(result.current.isOpen).toBe(true) + + act(() => { + result.current.closeHelp() + }) + + expect(result.current.isOpen).toBe(false) + }) + + it('opens help panel with specific topic', () => { + const { result } = renderHook(() => useHelpPanel()) + + act(() => { + result.current.openHelpTopic('getting-started') + }) + + expect(result.current.isOpen).toBe(true) + expect(result.current.topicId).toBe('getting-started') + }) + + it('clears topic when opening without topic', () => { + const { result } = renderHook(() => useHelpPanel()) + + act(() => { + result.current.openHelpTopic('specific-topic') + }) + + expect(result.current.topicId).toBe('specific-topic') + + act(() => { + result.current.openHelp() + }) + + expect(result.current.isOpen).toBe(true) + expect(result.current.topicId).toBeUndefined() + }) + + it('maintains topic immediately after closing', () => { + const { result } = renderHook(() => useHelpPanel()) + + act(() => { + result.current.openHelpTopic('test-topic') + }) + + expect(result.current.topicId).toBe('test-topic') + expect(result.current.isOpen).toBe(true) + + act(() => { + result.current.closeHelp() + }) + + expect(result.current.isOpen).toBe(false) + // Topic is cleared after a timeout (not tested with fake timers) + }) + + it('handles multiple topic changes', () => { + const { result } = renderHook(() => useHelpPanel()) + + act(() => { + result.current.openHelpTopic('topic-1') + }) + + expect(result.current.topicId).toBe('topic-1') + + act(() => { + result.current.openHelpTopic('topic-2') + }) + + expect(result.current.topicId).toBe('topic-2') + expect(result.current.isOpen).toBe(true) + }) + }) +}) diff --git a/src/hooks/useContentExtraction.ts b/src/hooks/useContentExtraction.ts index 8842e33..bed4a28 100644 --- a/src/hooks/useContentExtraction.ts +++ b/src/hooks/useContentExtraction.ts @@ -5,12 +5,22 @@ import { isLikelyURL, } from '@/lib/content-extraction' import type { ExtractedContent, ContentExtractionOptions } from '@/lib/content-extraction' +import type { OperationProgress } from '@/lib/chrome-ai/types/progress' +import { createOperationProgress } from '@/lib/chrome-ai/utils/progressCalculator' /** * Extraction state */ export type ExtractionState = 'idle' | 'validating' | 'loading' | 'success' | 'error' +/** + * Content extraction options with progress tracking + */ +export interface ContentExtractionWithProgressOptions extends ContentExtractionOptions { + /** Callback for detailed progress updates */ + onProgress?: (progress: OperationProgress, error?: Error) => void +} + /** * Hook return type */ @@ -33,14 +43,20 @@ export interface UseContentExtractionResult { /** Whether there was an error */ isError: boolean + /** Current operation progress */ + operationProgress: OperationProgress | null + /** Extract content from URL */ - extract: (url: string, options?: ContentExtractionOptions) => Promise + extract: (url: string, options?: ContentExtractionWithProgressOptions) => Promise /** Clear current content and reset state */ clear: () => void /** Cancel ongoing extraction */ cancel: () => void + + /** Clear progress state */ + clearProgress: () => void } /** @@ -60,6 +76,7 @@ export function useContentExtraction(): UseContentExtractionResult { const [state, setState] = useState('idle') const [content, setContent] = useState(null) const [error, setError] = useState(null) + const [operationProgress, setOperationProgress] = useState(null) // Ref to track abort controller const abortControllerRef = useRef(null) @@ -67,71 +84,142 @@ export function useContentExtraction(): UseContentExtractionResult { /** * Extract content from URL */ - const extract = useCallback(async (url: string, options: ContentExtractionOptions = {}) => { - // Quick validation - if (!isLikelyURL(url)) { - setState('error') - setError( - new ContentExtractionError( + const extract = useCallback( + async (url: string, options: ContentExtractionWithProgressOptions = {}) => { + const startTime = Date.now() + const { onProgress, ...extractOptions } = options + + // Stage 1: Validation + const validatingProgress = createOperationProgress( + 'initializing', + 0, + 3, + 'Validating URL...', + { url } + ) + setOperationProgress(validatingProgress) + onProgress?.(validatingProgress) + + // Quick validation + if (!isLikelyURL(url)) { + setState('error') + const validationError = new ContentExtractionError( 'Please enter a valid URL (e.g., https://example.com)', 'INVALID_URL' ) - ) - return - } + setError(validationError) + setOperationProgress(null) + const failedProgress = createOperationProgress('complete', 0, 3, 'Validation failed') + onProgress?.(failedProgress, validationError) + return + } - // Cancel any ongoing extraction - if (abortControllerRef.current) { - abortControllerRef.current.abort() - } + // Cancel any ongoing extraction + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } - // Create new abort controller - const controller = new AbortController() - abortControllerRef.current = controller + // Create new abort controller + const controller = new AbortController() + abortControllerRef.current = controller - // Reset state - setState('loading') - setError(null) - setContent(null) + // Reset state + setState('loading') + setError(null) + setContent(null) - try { - // Extract content - const extractedContent = await extractContentFromURL(url, { - ...options, - signal: controller.signal, - }) + try { + // Stage 2: Fetching + const fetchingProgress = createOperationProgress( + 'processing', + 1, + 3, + 'Fetching content from URL...', + { startTime, url } + ) + setOperationProgress(fetchingProgress) + onProgress?.(fetchingProgress) - // Check if aborted - if (controller.signal.aborted) { - return - } + // Extract content + const extractedContent = await extractContentFromURL(url, { + ...extractOptions, + signal: controller.signal, + }) - // Update state - setState('success') - setContent(extractedContent) - setError(null) - } catch (err) { - // Check if aborted - if (controller.signal.aborted) { - return - } + // Check if aborted + if (controller.signal.aborted) { + return + } - // Handle error - const extractionError = - err instanceof ContentExtractionError - ? err - : new ContentExtractionError('Failed to extract content', 'PARSE_FAILED', err) + // Stage 3: Parsing complete + const parsingProgress = createOperationProgress( + 'finalizing', + 2, + 3, + 'Parsing extracted content...', + { + startTime, + url, + contentLength: extractedContent.textContent?.length || 0, + } + ) + setOperationProgress(parsingProgress) + onProgress?.(parsingProgress) - setState('error') - setError(extractionError) - setContent(null) - } finally { - // Clear controller if it's the current one - if (abortControllerRef.current === controller) { - abortControllerRef.current = null + // Stage 4: Complete + const completeProgress = createOperationProgress( + 'complete', + 3, + 3, + 'Content extraction complete', + { + startTime, + endTime: Date.now(), + url, + contentLength: extractedContent.textContent?.length || 0, + title: extractedContent.title, + wordCount: extractedContent.textContent?.split(/\s+/).length || 0, + } + ) + setOperationProgress(completeProgress) + onProgress?.(completeProgress) + + // Update state + setState('success') + setContent(extractedContent) + setError(null) + + // Clear progress after a delay + setTimeout(() => { + setOperationProgress(null) + }, 1000) + } catch (err) { + // Check if aborted + if (controller.signal.aborted) { + return + } + + // Handle error + const extractionError = + err instanceof ContentExtractionError + ? err + : new ContentExtractionError('Failed to extract content', 'PARSE_FAILED', err) + + setState('error') + setError(extractionError) + setContent(null) + setOperationProgress(null) + const failedProgress = createOperationProgress('complete', 0, 3, 'Extraction failed') + onProgress?.(failedProgress, extractionError) + } finally { + // Clear controller if it's the current one + if (abortControllerRef.current === controller) { + abortControllerRef.current = null + } } - } - }, []) + }, + [] + ) /** * Clear current content and reset to idle @@ -146,6 +234,7 @@ export function useContentExtraction(): UseContentExtractionResult { setState('idle') setContent(null) setError(null) + setOperationProgress(null) }, []) /** @@ -158,6 +247,14 @@ export function useContentExtraction(): UseContentExtractionResult { } setState('idle') + setOperationProgress(null) + }, []) + + /** + * Clear progress state + */ + const clearProgress = useCallback(() => { + setOperationProgress(null) }, []) // Cleanup on unmount @@ -176,8 +273,10 @@ export function useContentExtraction(): UseContentExtractionResult { isLoading: state === 'loading' || state === 'validating', isSuccess: state === 'success', isError: state === 'error', + operationProgress, extract, clear, cancel, + clearProgress, } } diff --git a/src/hooks/useFormatTransform.ts b/src/hooks/useFormatTransform.ts index 551ad7c..aa15efb 100644 --- a/src/hooks/useFormatTransform.ts +++ b/src/hooks/useFormatTransform.ts @@ -6,6 +6,17 @@ import { } from '@/lib/chrome-ai/services/FormatTransformService' import { checkPromptAvailability } from '@/lib/chrome-ai/prompt' import type { AICapabilityAvailability, FormatType } from '@/lib/chrome-ai/types' +import type { + OperationProgress, + StreamingProgress, + MultiFormatProgress, + AggregateProgress, +} from '@/lib/chrome-ai/types/progress' +import { + createOperationProgress, + calculateStreamingProgress, + aggregateMultiFormatProgress, +} from '@/lib/chrome-ai/utils/progressCalculator' const REQUEST_DEBOUNCE_MS = 250 @@ -28,8 +39,38 @@ export interface UseFormatTransformState { /** Download progress for model (when availability is 'downloadable') */ downloadProgress: { loaded: number; total: number } | null - /** Progress tracking for multi-format transformations */ + /** Progress tracking for multi-format transformations (legacy - number percentages) */ formatProgress: Map + + /** Current operation progress (for single-format operations) */ + operationProgress: OperationProgress | null + + /** Streaming progress (for streaming operations) */ + streamingProgress: StreamingProgress | null + + /** Multi-format progress (for detailed multi-format tracking) */ + multiFormatProgress: Map + + /** Aggregated progress across all formats */ + aggregateProgress: AggregateProgress | null +} + +export interface TransformWithProgressOptions extends TransformOptions { + /** Callback for detailed operation progress updates */ + onProgress?: (progress: OperationProgress, error?: Error) => void +} + +export interface TransformStreamWithProgressOptions extends TransformOptions { + /** Callback for streaming progress updates */ + onStreamingProgress?: (progress: StreamingProgress) => void +} + +export interface MultiFormatProgressOptions extends TransformOptions { + /** Callback for individual format progress updates */ + onFormatProgress?: (format: FormatType, progress: MultiFormatProgress) => void + + /** Callback for aggregate progress updates */ + onAggregateProgress?: (progress: AggregateProgress) => void } export interface UseFormatTransformActions { @@ -40,7 +81,7 @@ export interface UseFormatTransformActions { transformToFormat: ( content: string, format: FormatType, - options?: TransformOptions + options?: TransformWithProgressOptions ) => Promise /** Transform content to a single format with streaming */ @@ -48,14 +89,14 @@ export interface UseFormatTransformActions { content: string, format: FormatType, onChunk: (chunk: string) => void, - options?: TransformOptions + options?: TransformStreamWithProgressOptions ) => Promise /** Transform content to multiple formats sequentially without streaming */ transformToMultipleFormats: ( content: string, formats: FormatType[], - options?: TransformOptions + options?: MultiFormatProgressOptions ) => Promise> /** @@ -66,7 +107,7 @@ export interface UseFormatTransformActions { content: string, formats: FormatType[], onUpdate: (format: FormatType, content: string, isComplete: boolean) => void, - options?: TransformOptions + options?: MultiFormatProgressOptions ) => Promise /** Destroy the current service instance */ @@ -74,6 +115,9 @@ export interface UseFormatTransformActions { /** Clear the current error */ clearError: () => void + + /** Clear progress state */ + clearProgress: () => void } export type UseFormatTransformReturn = UseFormatTransformState & UseFormatTransformActions @@ -119,6 +163,10 @@ export const useFormatTransform = ( error: null, downloadProgress: null, formatProgress: new Map(), + operationProgress: null, + streamingProgress: null, + multiFormatProgress: new Map(), + aggregateProgress: null, }) const serviceRef = useRef(null) @@ -276,25 +324,81 @@ export const useFormatTransform = ( ) const transformToFormat = useCallback( - async (content: string, format: FormatType, options?: TransformOptions): Promise => { + async (content: string, format: FormatType, options?: TransformWithProgressOptions): Promise => { if (!serviceRef.current) { throw new Error('Format transform service not initialized. Call initialize() first.') } await ensureRequestSpacing() - setState((prev) => ({ ...prev, isTransforming: true, error: null })) + const startTime = Date.now() + const { onProgress, ...transformOptions } = options || {} + + // Initial progress + const initialProgress = createOperationProgress( + 'initializing', + 0, + 1, + `Preparing to transform to ${format}...` + ) + setState((prev) => ({ + ...prev, + isTransforming: true, + error: null, + operationProgress: initialProgress, + })) + onProgress?.(initialProgress) try { - const result = await serviceRef.current.transformToFormat(content, format, options) - setState((prev) => ({ ...prev, isTransforming: false })) + // Processing progress + const processingProgress = createOperationProgress( + 'processing', + 1, + 1, + `Transforming to ${format}...`, + { startTime, format, inputLength: content.length } + ) + setState((prev) => ({ ...prev, operationProgress: processingProgress })) + onProgress?.(processingProgress) + + const result = await serviceRef.current.transformToFormat(content, format, transformOptions) + + // Complete progress + const completeProgress = createOperationProgress( + 'complete', + 1, + 1, + `Transformation to ${format} complete`, + { + startTime, + endTime: Date.now(), + format, + inputLength: content.length, + outputLength: result.length, + } + ) + setState((prev) => ({ + ...prev, + isTransforming: false, + operationProgress: completeProgress, + })) + onProgress?.(completeProgress) + + // Clear progress after a delay + setTimeout(() => { + setState((prev) => ({ ...prev, operationProgress: null })) + }, 1000) + return result } catch (error) { setState((prev) => ({ ...prev, isTransforming: false, + operationProgress: null, error: error instanceof Error ? error : new Error('Failed to transform content'), })) + const failedProgress = createOperationProgress('complete', 0, 1, 'Transformation failed') + onProgress?.(failedProgress, error as Error) throw error } }, @@ -306,7 +410,7 @@ export const useFormatTransform = ( content: string, format: FormatType, onChunk: (chunk: string) => void, - options?: TransformOptions + options?: TransformStreamWithProgressOptions ): Promise => { if (!serviceRef.current) { throw new Error('Format transform service not initialized. Call initialize() first.') @@ -314,20 +418,71 @@ export const useFormatTransform = ( await ensureRequestSpacing() - setState((prev) => ({ ...prev, isTransforming: true, error: null })) + const startTime = Date.now() + const { onStreamingProgress, ...transformOptions } = options || {} + + setState((prev) => ({ + ...prev, + isTransforming: true, + error: null, + streamingProgress: { + bytesReceived: 0, + chunksReceived: 0, + isComplete: false, + }, + })) try { - const stream = serviceRef.current.transformToFormatStreaming(content, format, options) + const stream = serviceRef.current.transformToFormatStreaming(content, format, transformOptions) + let bytesReceived = 0 + let chunksReceived = 0 + let accumulatedText = '' for await (const chunk of stream) { + bytesReceived += chunk.length + chunksReceived += 1 + accumulatedText += chunk + + // Update streaming progress + const progress = calculateStreamingProgress( + bytesReceived, + chunksReceived, + startTime, + undefined, + accumulatedText + ) + + setState((prev) => ({ ...prev, streamingProgress: progress })) + onStreamingProgress?.(progress) + onChunk(chunk) } - setState((prev) => ({ ...prev, isTransforming: false })) + // Mark as complete + const finalProgress: StreamingProgress = { + bytesReceived, + chunksReceived, + isComplete: true, + partialContent: accumulatedText, + streamingRate: Math.round(bytesReceived / ((Date.now() - startTime) / 1000)), + } + + setState((prev) => ({ + ...prev, + isTransforming: false, + streamingProgress: finalProgress, + })) + onStreamingProgress?.(finalProgress) + + // Clear streaming progress after a delay + setTimeout(() => { + setState((prev) => ({ ...prev, streamingProgress: null })) + }, 1000) } catch (error) { setState((prev) => ({ ...prev, isTransforming: false, + streamingProgress: null, error: error instanceof Error ? error : new Error('Failed to stream transform'), })) throw error @@ -340,32 +495,153 @@ export const useFormatTransform = ( async ( content: string, formats: FormatType[], - options?: TransformOptions + options?: MultiFormatProgressOptions ): Promise> => { if (!serviceRef.current) { throw new Error('Format transform service not initialized. Call initialize() first.') } + const { onFormatProgress, onAggregateProgress, ...transformOptions } = options || {} + const startTime = Date.now() + + // Initialize multi-format progress + const initialProgress = new Map( + formats.map((format) => [ + format, + { + format, + status: 'pending', + percentage: 0, + startTime, + }, + ]) + ) + setState((prev) => ({ ...prev, isTransforming: true, error: null, formatProgress: new Map(), + multiFormatProgress: initialProgress, + aggregateProgress: aggregateMultiFormatProgress(initialProgress), })) await ensureRequestSpacing() try { - const results = await serviceRef.current.transformToMultipleFormats(content, formats, options) + const results: Record = {} - setState((prev) => ({ ...prev, isTransforming: false, formatProgress: new Map() })) - return results + // Process formats sequentially + for (let i = 0; i < formats.length; i++) { + const format = formats[i] + + // Update format to processing + setState((prev) => { + const newProgress = new Map(prev.multiFormatProgress) + newProgress.set(format, { + format, + status: 'processing', + percentage: 0, + startTime: Date.now(), + }) + const aggregate = aggregateMultiFormatProgress(newProgress) + return { + ...prev, + multiFormatProgress: newProgress, + aggregateProgress: aggregate, + } + }) + + const formatProgress = initialProgress.get(format)! + onFormatProgress?.(format, { ...formatProgress, status: 'processing' }) + + try { + // Transform this format + const result = await serviceRef.current.transformToFormat(content, format, transformOptions) + results[format] = result + + // Update format to complete + setState((prev) => { + const newProgress = new Map(prev.multiFormatProgress) + newProgress.set(format, { + format, + status: 'complete', + percentage: 100, + startTime: formatProgress.startTime!, + endTime: Date.now(), + }) + const aggregate = aggregateMultiFormatProgress(newProgress) + onAggregateProgress?.(aggregate) + return { + ...prev, + formatProgress: new Map(prev.formatProgress).set(format, 100), + multiFormatProgress: newProgress, + aggregateProgress: aggregate, + } + }) + + const completeProgress: MultiFormatProgress = { + format, + status: 'complete', + percentage: 100, + startTime: formatProgress.startTime!, + endTime: Date.now(), + } + onFormatProgress?.(format, completeProgress) + } catch (formatError) { + // Mark format as error + setState((prev) => { + const newProgress = new Map(prev.multiFormatProgress) + newProgress.set(format, { + format, + status: 'error', + percentage: 0, + error: formatError as Error, + startTime: formatProgress.startTime!, + endTime: Date.now(), + }) + const aggregate = aggregateMultiFormatProgress(newProgress) + return { + ...prev, + multiFormatProgress: newProgress, + aggregateProgress: aggregate, + } + }) + + const errorProgress: MultiFormatProgress = { + format, + status: 'error', + percentage: 0, + error: formatError as Error, + startTime: formatProgress.startTime!, + endTime: Date.now(), + } + onFormatProgress?.(format, errorProgress) + + // Continue with other formats + } + } + + // Clear progress after a delay + setTimeout(() => { + setState((prev) => ({ + ...prev, + formatProgress: new Map(), + multiFormatProgress: new Map(), + aggregateProgress: null, + })) + }, 1000) + + setState((prev) => ({ ...prev, isTransforming: false })) + return results as Record } catch (error) { setState((prev) => ({ ...prev, isTransforming: false, error: error instanceof Error ? error : new Error('Failed to transform content to multiple formats'), formatProgress: new Map(), + multiFormatProgress: new Map(), + aggregateProgress: null, })) throw error } @@ -378,42 +654,121 @@ export const useFormatTransform = ( content: string, formats: FormatType[], onUpdate: (format: FormatType, content: string, isComplete: boolean) => void, - options?: TransformOptions + options?: MultiFormatProgressOptions ): Promise => { if (!serviceRef.current) { throw new Error('Format transform service not initialized. Call initialize() first.') } + const { onFormatProgress, onAggregateProgress, ...transformOptions } = options || {} + const startTime = Date.now() + + // Initialize multi-format progress + const initialProgress = new Map( + formats.map((format) => [ + format, + { + format, + status: 'pending', + percentage: 0, + startTime, + }, + ]) + ) + setState((prev) => ({ ...prev, isTransforming: true, error: null, formatProgress: new Map(formats.map((f) => [f, 0])), + multiFormatProgress: initialProgress, + aggregateProgress: aggregateMultiFormatProgress(initialProgress), })) await ensureRequestSpacing() try { - const stream = serviceRef.current.transformToMultipleFormatsStreaming(content, formats, options) + const stream = serviceRef.current.transformToMultipleFormatsStreaming(content, formats, transformOptions) + const accumulatedContent = new Map() for await (const update of stream) { - onUpdate(update.format, update.content, update.isComplete) + // Accumulate content for each format + const currentContent = accumulatedContent.get(update.format) || '' + const newContent = currentContent + update.content + accumulatedContent.set(update.format, newContent) - // Update progress + // Update multi-format progress setState((prev) => { - const newProgress = new Map(prev.formatProgress) - newProgress.set(update.format, update.isComplete ? 100 : 50) - return { ...prev, formatProgress: newProgress } + const newProgress = new Map(prev.multiFormatProgress) + const existingProgress = newProgress.get(update.format) + + newProgress.set(update.format, { + format: update.format, + status: update.isComplete ? 'complete' : 'processing', + percentage: update.isComplete ? 100 : 50, + partialResult: newContent, + startTime: existingProgress?.startTime || Date.now(), + endTime: update.isComplete ? Date.now() : undefined, + }) + + const aggregate = aggregateMultiFormatProgress(newProgress) + + return { + ...prev, + formatProgress: new Map(prev.formatProgress).set( + update.format, + update.isComplete ? 100 : 50 + ), + multiFormatProgress: newProgress, + aggregateProgress: aggregate, + } + }) + + // Notify callbacks + const formatProgress = initialProgress.get(update.format)! + const updatedProgress: MultiFormatProgress = { + format: update.format, + status: update.isComplete ? 'complete' : 'processing', + percentage: update.isComplete ? 100 : 50, + partialResult: newContent, + startTime: formatProgress.startTime!, + endTime: update.isComplete ? Date.now() : undefined, + } + onFormatProgress?.(update.format, updatedProgress) + + // Update aggregate progress + const currentState = await new Promise((resolve) => { + setState((prev) => { + resolve(prev) + return prev + }) }) + if (currentState.aggregateProgress) { + onAggregateProgress?.(currentState.aggregateProgress) + } + + onUpdate(update.format, update.content, update.isComplete) } - setState((prev) => ({ ...prev, isTransforming: false, formatProgress: new Map() })) + // Clear progress after a delay + setTimeout(() => { + setState((prev) => ({ + ...prev, + formatProgress: new Map(), + multiFormatProgress: new Map(), + aggregateProgress: null, + })) + }, 1000) + + setState((prev) => ({ ...prev, isTransforming: false })) } catch (error) { setState((prev) => ({ ...prev, isTransforming: false, error: error instanceof Error ? error : new Error('Failed to stream multi-format transform'), formatProgress: new Map(), + multiFormatProgress: new Map(), + aggregateProgress: null, })) throw error } @@ -441,6 +796,16 @@ export const useFormatTransform = ( setState((prev) => ({ ...prev, error: null })) }, []) + const clearProgress = useCallback(() => { + setState((prev) => ({ + ...prev, + operationProgress: null, + streamingProgress: null, + multiFormatProgress: new Map(), + aggregateProgress: null, + })) + }, []) + return { ...state, initialize, @@ -450,5 +815,6 @@ export const useFormatTransform = ( transformToMultipleFormatsStreaming, destroy, clearError, + clearProgress, } } diff --git a/src/hooks/useHelpPanel.ts b/src/hooks/useHelpPanel.ts new file mode 100644 index 0000000..92402af --- /dev/null +++ b/src/hooks/useHelpPanel.ts @@ -0,0 +1,55 @@ +/** + * useHelpPanel Hook + * + * Hook for managing the help panel state. + * + * Usage: + * ```tsx + * const { isOpen, openHelp, closeHelp, openHelpTopic } = useHelpPanel() + * + * // Open help panel + * + * + * // Open specific topic + * + * ``` + */ + +import { useState, useCallback } from 'react' + +interface UseHelpPanelReturn { + isOpen: boolean + topicId: string | undefined + openHelp: () => void + closeHelp: () => void + openHelpTopic: (topicId: string) => void +} + +export const useHelpPanel = (): UseHelpPanelReturn => { + const [isOpen, setIsOpen] = useState(false) + const [topicId, setTopicId] = useState(undefined) + + const openHelp = useCallback(() => { + setIsOpen(true) + setTopicId(undefined) + }, []) + + const closeHelp = useCallback(() => { + setIsOpen(false) + // Clear topic after transition + setTimeout(() => setTopicId(undefined), 300) + }, []) + + const openHelpTopic = useCallback((id: string) => { + setTopicId(id) + setIsOpen(true) + }, []) + + return { + isOpen, + topicId, + openHelp, + closeHelp, + openHelpTopic, + } +} diff --git a/src/hooks/useProgressThrottle.ts b/src/hooks/useProgressThrottle.ts new file mode 100644 index 0000000..01307e4 --- /dev/null +++ b/src/hooks/useProgressThrottle.ts @@ -0,0 +1,166 @@ +/** + * useProgressThrottle Hook + * + * React hook for throttling progress updates to avoid excessive re-renders. + * Maintains state properly within React's lifecycle and prevents memory leaks. + * + * Usage: + * ```tsx + * const shouldUpdate = useProgressThrottle(100) // 100ms throttle + * + * // In your progress callback: + * onProgress={(progress) => { + * if (shouldUpdate(progress)) { + * setProgress(progress) + * } + * }} + * ``` + */ + +import { useCallback, useRef, useState } from 'react' +import type { OperationProgress } from '@/lib/chrome-ai/types/progress' + +export interface UseProgressThrottleOptions { + /** Throttle interval in milliseconds (default: 100) */ + throttleMs?: number + + /** Always update on stage changes (default: true) */ + updateOnStageChange?: boolean + + /** Always update when progress reaches 100% (default: true) */ + updateOnComplete?: boolean +} + +/** + * Hook for throttling progress updates + * + * @param options - Throttling options + * @returns Function to check if progress should update + */ +export function useProgressThrottle(options: UseProgressThrottleOptions = {}) { + const { + throttleMs = 100, + updateOnStageChange = true, + updateOnComplete = true, + } = options + + const lastUpdateRef = useRef(0) + const lastProgressRef = useRef(null) + + const shouldUpdate = useCallback( + (progress: OperationProgress): boolean => { + const now = Date.now() + const lastProgress = lastProgressRef.current + + // Always update on first call + if (!lastProgress) { + lastUpdateRef.current = now + lastProgressRef.current = progress + return true + } + + // Always update on stage changes + if (updateOnStageChange && progress.stage !== lastProgress.stage) { + lastUpdateRef.current = now + lastProgressRef.current = progress + return true + } + + // Always update when reaching 100% or complete stage + if ( + updateOnComplete && + (progress.percentage === 100 || progress.stage === 'complete') + ) { + lastUpdateRef.current = now + lastProgressRef.current = progress + return true + } + + // Throttle intermediate updates + if (now - lastUpdateRef.current >= throttleMs) { + lastUpdateRef.current = now + lastProgressRef.current = progress + return true + } + + // Don't update - still within throttle window + return false + }, + [throttleMs, updateOnStageChange, updateOnComplete] + ) + + return shouldUpdate +} + +/** + * Hook for throttling progress updates with automatic state management + * + * This variant automatically manages the progress state and only updates when throttle allows. + * + * @param initialProgress - Initial progress value + * @param options - Throttling options + * @returns Tuple of [current progress, setter function] + * + * Usage: + * ```tsx + * const [progress, setProgress] = useThrottledProgress(null, { throttleMs: 100 }) + * + * // In your operation: + * onProgress={(newProgress) => setProgress(newProgress)} + * ``` + */ +export function useThrottledProgress( + initialProgress: OperationProgress | null = null, + options: UseProgressThrottleOptions = {} +): [OperationProgress | null, (progress: OperationProgress) => void] { + const shouldUpdate = useProgressThrottle(options) + const [progress, setProgressState] = useState(initialProgress) + + const setProgress = useCallback( + (newProgress: OperationProgress) => { + if (shouldUpdate(newProgress)) { + setProgressState(newProgress) + } + }, + [shouldUpdate] + ) + + return [progress, setProgress] +} + +/** + * Hook for creating a throttled progress callback + * + * Wraps a progress callback with throttling logic. + * + * @param callback - Original progress callback + * @param options - Throttling options + * @returns Throttled progress callback + * + * Usage: + * ```tsx + * const handleProgress = useCallback((progress) => { + * console.log('Progress:', progress.percentage) + * }, []) + * + * const throttledProgress = useThrottledCallback(handleProgress, { throttleMs: 200 }) + * + * // Use in operation: + * rewrite(text, { onProgress: throttledProgress }) + * ``` + */ +export function useThrottledCallback( + callback: (progress: OperationProgress) => void, + options: UseProgressThrottleOptions = {} +): (progress: OperationProgress) => void { + const shouldUpdate = useProgressThrottle(options) + + return useCallback( + (progress: OperationProgress) => { + if (shouldUpdate(progress)) { + callback(progress) + } + }, + [callback, shouldUpdate] + ) +} diff --git a/src/hooks/useRetryableOperation.ts b/src/hooks/useRetryableOperation.ts new file mode 100644 index 0000000..fb3fb27 --- /dev/null +++ b/src/hooks/useRetryableOperation.ts @@ -0,0 +1,164 @@ +/** + * useRetryableOperation Hook + * + * React hook for managing retryable operations with state tracking. + * + * Features: + * - Automatic retry with exponential backoff + * - State management (idle, loading, success, error) + * - Retry attempt tracking + * - Toast notifications on retry and failure + * - Configurable retry options + * + * Usage: + * ```tsx + * const { execute, retry, state, error, currentAttempt } = useRetryableOperation({ + * operation: async () => await fetchData(), + * maxAttempts: 3, + * onSuccess: (data) => console.log('Success!', data), + * onError: (error) => console.error('Failed', error), + * }) + * ``` + */ + +import { showErrorToast, showInfoToast, showSuccessToast } from '@/lib/feedback/toastManager' +import { withRetry, type RetryOptions } from '@/lib/chrome-ai/utils/retryHandler' +import { useState, useCallback } from 'react' + +type OperationState = 'idle' | 'loading' | 'success' | 'error' + +interface UseRetryableOperationOptions { + operation: () => Promise + maxAttempts?: number + baseDelay?: number + maxDelay?: number + timeout?: number + onSuccess?: (result: T) => void + onError?: (error: Error) => void + showToasts?: boolean + successMessage?: string + errorMessage?: string +} + +interface UseRetryableOperationReturn { + execute: () => Promise + retry: () => Promise + reset: () => void + state: OperationState + data: T | null + error: Error | null + currentAttempt: number + maxAttempts: number + isLoading: boolean + isError: boolean + isSuccess: boolean +} + +export const useRetryableOperation = ({ + operation, + maxAttempts = 3, + baseDelay = 1000, + maxDelay = 10000, + timeout, + onSuccess, + onError, + showToasts = true, + successMessage = 'Operation completed successfully', + errorMessage = 'Operation failed', +}: UseRetryableOperationOptions): UseRetryableOperationReturn => { + const [state, setState] = useState('idle') + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [currentAttempt, setCurrentAttempt] = useState(0) + + const reset = useCallback(() => { + setState('idle') + setData(null) + setError(null) + setCurrentAttempt(0) + }, []) + + const executeOperation = useCallback(async () => { + setState('loading') + setError(null) + setCurrentAttempt(0) + + try { + const retryOptions: RetryOptions = { + maxAttempts, + baseDelay, + maxDelay, + timeout, + onRetry: (attempt) => { + setCurrentAttempt(attempt) + + if (showToasts) { + showInfoToast(`Retrying operation`, { + message: `Attempt ${attempt + 1} of ${maxAttempts}`, + duration: 3000, + }) + } + }, + } + + const result = await withRetry(operation, retryOptions) + + setData(result) + setState('success') + setCurrentAttempt(0) // Reset on success + + if (showToasts) { + showSuccessToast(successMessage, { + duration: 4000, + }) + } + + onSuccess?.(result) + } catch (err) { + const operationError = err instanceof Error ? err : new Error(String(err)) + + setError(operationError) + setState('error') + + if (showToasts) { + showErrorToast(errorMessage, { + message: operationError.message, + duration: 7000, + }) + } + + onError?.(operationError) + } + }, [ + operation, + maxAttempts, + baseDelay, + maxDelay, + timeout, + showToasts, + successMessage, + errorMessage, + onSuccess, + onError, + ]) + + const retry = useCallback(async () => { + if (state === 'error') { + await executeOperation() + } + }, [state, executeOperation]) + + return { + execute: executeOperation, + retry, + reset, + state, + data, + error, + currentAttempt, + maxAttempts, + isLoading: state === 'loading', + isError: state === 'error', + isSuccess: state === 'success', + } +} diff --git a/src/hooks/useRewriter.test.ts b/src/hooks/useRewriter.test.ts index fee10ae..483cfee 100644 --- a/src/hooks/useRewriter.test.ts +++ b/src/hooks/useRewriter.test.ts @@ -329,7 +329,7 @@ describe('useRewriter', () => { rewriteResult = await result.current.rewrite('Original text') }) - expect(rewriteText).toHaveBeenCalledWith(mockRewriter, 'Original text', undefined) + expect(rewriteText).toHaveBeenCalledWith(mockRewriter, 'Original text', {}) expect(rewriteResult).toBe('Rewritten text') }) diff --git a/src/hooks/useRewriter.ts b/src/hooks/useRewriter.ts index 7a39128..3f61726 100644 --- a/src/hooks/useRewriter.ts +++ b/src/hooks/useRewriter.ts @@ -12,6 +12,8 @@ import type { AIRewriterCreateOptions, AIRewriteOptions, } from '@/lib/chrome-ai/types' +import type { OperationProgress, StreamingProgress } from '@/lib/chrome-ai/types/progress' +import { createOperationProgress } from '@/lib/chrome-ai/utils/progressCalculator' export interface UseRewriterState { /** Current availability status of the Rewriter API */ @@ -31,20 +33,45 @@ export interface UseRewriterState { /** Download progress for model (when availability is 'downloadable') */ downloadProgress: { loaded: number; total: number } | null + + /** Current operation progress (for detailed tracking) */ + operationProgress: OperationProgress | null + + /** Streaming progress (for streaming operations) */ + streamingProgress: StreamingProgress | null +} + +export interface RewriteWithProgressOptions extends AIRewriteOptions { + /** Callback for detailed progress updates */ + onProgress?: (progress: OperationProgress, error?: Error) => void +} + +export interface RewriteStreamWithProgressOptions extends AIRewriteOptions { + /** Callback for streaming progress updates */ + onStreamingProgress?: (progress: StreamingProgress) => void } export interface UseRewriterActions { /** Initialize the rewriter with options */ initialize: (options?: AIRewriterCreateOptions) => Promise - /** Rewrite text without streaming */ - rewrite: (input: string, options?: AIRewriteOptions) => Promise - - /** Rewrite text with streaming, calling callback with each chunk */ + /** + * Rewrite text without streaming + * @param input - Text to rewrite + * @param options - Rewrite options (supports onProgress callback) + */ + rewrite: (input: string, options?: RewriteWithProgressOptions) => Promise + + /** + * Rewrite text with streaming, calling callback with each chunk + * @param input - Text to rewrite + * @param onChunk - Callback for each chunk + * @param options - Rewrite options (supports onStreamingProgress callback) + */ rewriteStream: ( input: string, onChunk: (chunk: string) => void, - options?: AIRewriteOptions + options?: RewriteStreamWithProgressOptions ) => Promise /** Destroy the current rewriter instance */ @@ -52,6 +79,9 @@ export interface UseRewriterActions { /** Clear the current error */ clearError: () => void + + /** Clear progress state */ + clearProgress: () => void } export type UseRewriterReturn = UseRewriterState & UseRewriterActions @@ -90,6 +120,8 @@ export const useRewriter = ( isReady: false, error: null, downloadProgress: null, + operationProgress: null, + streamingProgress: null, }) const rewriterRef = useRef(null) @@ -201,23 +233,64 @@ export const useRewriter = ( ) const rewrite = useCallback( - async (input: string, options?: AIRewriteOptions): Promise => { + async (input: string, options?: RewriteWithProgressOptions): Promise => { if (!rewriterRef.current) { throw new Error('Rewriter not initialized. Call initialize() first.') } - setState((prev) => ({ ...prev, isRewriting: true, error: null })) + const startTime = Date.now() + const { onProgress, ...rewriteOptions } = options || {} + + // Initial progress + const initialProgress = createOperationProgress('initializing', 0, 1, 'Preparing to rewrite text...') + setState((prev) => ({ + ...prev, + isRewriting: true, + error: null, + operationProgress: initialProgress, + })) + onProgress?.(initialProgress) try { - const result = await rewriteText(rewriterRef.current, input, options) - setState((prev) => ({ ...prev, isRewriting: false })) + // Processing progress + const processingProgress = createOperationProgress('processing', 1, 1, 'Rewriting text...', { + startTime, + inputLength: input.length, + }) + setState((prev) => ({ ...prev, operationProgress: processingProgress })) + onProgress?.(processingProgress) + + const result = await rewriteText(rewriterRef.current, input, rewriteOptions) + + // Complete progress + const completeProgress = createOperationProgress('complete', 1, 1, 'Rewrite complete', { + startTime, + endTime: Date.now(), + inputLength: input.length, + outputLength: result.length, + }) + setState((prev) => ({ + ...prev, + isRewriting: false, + operationProgress: completeProgress, + })) + onProgress?.(completeProgress) + + // Clear progress after a delay + setTimeout(() => { + setState((prev) => ({ ...prev, operationProgress: null })) + }, 1000) + return result } catch (error) { setState((prev) => ({ ...prev, isRewriting: false, + operationProgress: null, error: error instanceof Error ? error : new Error('Failed to rewrite text'), })) + const failedProgress = createOperationProgress('complete', 0, 1, 'Rewrite failed') + onProgress?.(failedProgress, error as Error) throw error } }, @@ -228,26 +301,78 @@ export const useRewriter = ( async ( input: string, onChunk: (chunk: string) => void, - options?: AIRewriteOptions + options?: RewriteStreamWithProgressOptions ): Promise => { if (!rewriterRef.current) { throw new Error('Rewriter not initialized. Call initialize() first.') } - setState((prev) => ({ ...prev, isRewriting: true, error: null })) + const startTime = Date.now() + const { onStreamingProgress, ...rewriteOptions } = options || {} + + setState((prev) => ({ + ...prev, + isRewriting: true, + error: null, + streamingProgress: { + bytesReceived: 0, + chunksReceived: 0, + isComplete: false, + }, + })) try { - const stream = rewriteTextStreaming(rewriterRef.current, input, options) + const stream = rewriteTextStreaming(rewriterRef.current, input, rewriteOptions) + let bytesReceived = 0 + let chunksReceived = 0 + let accumulatedText = '' for await (const chunk of stream) { + bytesReceived += chunk.length + chunksReceived += 1 + accumulatedText += chunk + + // Update streaming progress + const progress: StreamingProgress = { + bytesReceived, + chunksReceived, + isComplete: false, + partialContent: accumulatedText, + // Calculate streaming rate + streamingRate: Math.round(bytesReceived / ((Date.now() - startTime) / 1000)), + } + + setState((prev) => ({ ...prev, streamingProgress: progress })) + onStreamingProgress?.(progress) + onChunk(chunk) } - setState((prev) => ({ ...prev, isRewriting: false })) + // Mark as complete + const finalProgress: StreamingProgress = { + bytesReceived, + chunksReceived, + isComplete: true, + partialContent: accumulatedText, + streamingRate: Math.round(bytesReceived / ((Date.now() - startTime) / 1000)), + } + + setState((prev) => ({ + ...prev, + isRewriting: false, + streamingProgress: finalProgress, + })) + onStreamingProgress?.(finalProgress) + + // Clear streaming progress after a delay + setTimeout(() => { + setState((prev) => ({ ...prev, streamingProgress: null })) + }, 1000) } catch (error) { setState((prev) => ({ ...prev, isRewriting: false, + streamingProgress: null, error: error instanceof Error ? error : new Error('Failed to stream rewrite'), })) throw error @@ -272,6 +397,14 @@ export const useRewriter = ( setState((prev) => ({ ...prev, error: null })) }, []) + const clearProgress = useCallback(() => { + setState((prev) => ({ + ...prev, + operationProgress: null, + streamingProgress: null, + })) + }, []) + return { ...state, initialize, @@ -279,6 +412,7 @@ export const useRewriter = ( rewriteStream, destroy, clearError, + clearProgress, } } diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..9d86cfc --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,75 @@ +/** + * useToast Hook + * + * React hook for subscribing to toast notifications and triggering toasts. + * + * Usage: + * ```tsx + * const { toasts, showSuccess, showError } = useToast() + * + * // Show success toast + * showSuccess('File saved!', { message: 'Your changes have been saved successfully' }) + * + * // Show error toast + * showError('Upload failed', { + * message: 'Please try again', + * action: { label: 'Retry', onClick: () => retryUpload() } + * }) + * ``` + */ + +import { + dismissAllToasts, + dismissToast, + showErrorToast, + showInfoToast, + showSuccessToast, + showToast, + showWarningToast, + subscribeToToasts, + type Toast, + type ToastOptions, +} from '@/lib/feedback/toastManager' +import { useEffect, useState } from 'react' + +export interface UseToastReturn { + toasts: Toast[] + showToast: (title: string, options?: ToastOptions) => string + showSuccess: (title: string, options?: Omit) => string + showError: (title: string, options?: Omit) => string + showWarning: (title: string, options?: Omit) => string + showInfo: (title: string, options?: Omit) => string + dismiss: (id: string) => void + dismissAll: () => void +} + +/** + * Hook to manage toast notifications + * + * Subscribes to the toast state and provides convenience methods for + * showing and dismissing toasts. + */ +export const useToast = (): UseToastReturn => { + const [toasts, setToasts] = useState([]) + + useEffect(() => { + // Subscribe to toast state changes + const unsubscribe = subscribeToToasts((updatedToasts) => { + setToasts(updatedToasts) + }) + + // Cleanup subscription on unmount + return unsubscribe + }, []) + + return { + toasts, + showToast, + showSuccess: showSuccessToast, + showError: showErrorToast, + showWarning: showWarningToast, + showInfo: showInfoToast, + dismiss: dismissToast, + dismissAll: dismissAllToasts, + } +} diff --git a/src/hooks/useTranslator.ts b/src/hooks/useTranslator.ts new file mode 100644 index 0000000..fcd000e --- /dev/null +++ b/src/hooks/useTranslator.ts @@ -0,0 +1,672 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + TranslatorService, + type TranslatorServiceConfig, + type TranslateOptions, + type TranslateRequestOptions, + type TranslationResult, + type TranslationChunkEvent, +} from '@/lib/chrome-ai/services/TranslatorService' +import { checkTranslatorAvailability } from '@/lib/chrome-ai/translator' +import type { + AICapabilityAvailability, + AITranslatorLanguage, + AILanguageDetectionResult, +} from '@/lib/chrome-ai/types' +import type { + OperationProgress, + StreamingProgress, + ChunkProgress, +} from '@/lib/chrome-ai/types/progress' +import { + createOperationProgress, + calculateStreamingProgress, + calculateChunkProgress, +} from '@/lib/chrome-ai/utils/progressCalculator' + +export interface UseTranslatorState { + /** Current availability status of the Translator API */ + availability: AICapabilityAvailability | null + + /** Whether the service is being initialized */ + isInitializing: boolean + + /** Whether a translation operation is in progress */ + isTranslating: boolean + + /** Whether a language detection operation is in progress */ + isDetecting: boolean + + /** Whether the service is ready to use */ + isReady: boolean + + /** Error from the last operation, if any */ + error: Error | null + + /** Download progress for model (when availability is 'downloadable') */ + downloadProgress: { loaded: number; total: number } | null + + /** Current operation progress (for non-streaming operations) */ + operationProgress: OperationProgress | null + + /** Chunk progress (for chunked translations) */ + chunkProgress: ChunkProgress | null + + /** Streaming progress (for streaming operations) */ + streamingProgress: StreamingProgress | null + + /** Current language pair (if translator is initialized) */ + currentLanguagePair: { + source: AITranslatorLanguage + target: AITranslatorLanguage + } | null +} + +export interface TranslateWithProgressOptions extends TranslateRequestOptions { + /** Callback for detailed operation progress updates */ + onProgress?: (progress: OperationProgress, error?: Error) => void + + /** Callback for chunk progress updates */ + onChunkProgress?: (progress: ChunkProgress, error?: Error) => void +} + +export interface TranslateStreamWithProgressOptions extends TranslateOptions { + /** Callback for streaming progress updates */ + onStreamingProgress?: (progress: StreamingProgress) => void +} + +export interface UseTranslatorActions { + /** Initialize the translator service with options */ + initialize: (config?: TranslatorServiceConfig) => void + + /** Detect the language of the given text */ + detectLanguage: (text: string) => Promise + + /** + * Translate text with detailed metadata + * Returns full translation result with reading level, idioms, chunks, etc. + */ + translateDetailed: ( + text: string, + options: TranslateWithProgressOptions + ) => Promise + + /** + * Translate text (simple) + * Returns only the translated text + */ + translate: (text: string, options: TranslateWithProgressOptions) => Promise + + /** + * Translate text with streaming output + * Calls onChunk for each chunk of translated text + */ + translateStream: ( + text: string, + onChunk: (chunk: string) => void, + options: TranslateStreamWithProgressOptions + ) => Promise + + /** + * Detect language and translate in one operation + * Automatically detects source language and translates to target + */ + detectAndTranslate: ( + text: string, + targetLanguage: AITranslatorLanguage, + options?: Omit + ) => Promise + + /** Destroy the current service instance */ + destroy: () => void + + /** Clear the current error */ + clearError: () => void + + /** Clear progress state */ + clearProgress: () => void +} + +export type UseTranslatorReturn = UseTranslatorState & UseTranslatorActions + +/** + * React hook for using the TranslatorService + * + * @param autoInitialize - Whether to automatically initialize the service on mount + * @param config - Default configuration for the service + * @returns State and actions for translation + * + * @example + * ```tsx + * const { isReady, translate, detectLanguage } = useTranslator(true, { + * enableRetry: true, + * enableValidation: true + * }) + * + * // Detect language + * const detected = await detectLanguage('Hello world') + * + * // Translate + * const result = await translate('Hello world', { + * sourceLanguage: 'en', + * targetLanguage: 'es' + * }) + * + * // Translate with progress tracking + * const translated = await translate('Long text...', { + * sourceLanguage: 'en', + * targetLanguage: 'es', + * onProgress: (progress) => console.log(progress.percentage), + * onChunkProgress: (chunk) => console.log(`Chunk ${chunk.currentChunk}/${chunk.totalChunks}`) + * }) + * ``` + */ +export const useTranslator = ( + autoInitialize = false, + config?: TranslatorServiceConfig +): UseTranslatorReturn => { + const [state, setState] = useState({ + availability: null, + isInitializing: false, + isTranslating: false, + isDetecting: false, + isReady: false, + error: null, + downloadProgress: null, + operationProgress: null, + chunkProgress: null, + streamingProgress: null, + currentLanguagePair: null, + }) + + const serviceRef = useRef(null) + const abortControllerRef = useRef(null) + + // Check availability on mount + useEffect(() => { + const checkAvailability = async () => { + try { + // Check translator availability for a common language pair (en-es) + const availability = await checkTranslatorAvailability('en', 'es') + setState((prev) => ({ + ...prev, + availability, + isReady: (availability === 'available' || availability === 'readily') && prev.isInitializing === false, + })) + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error : new Error('Failed to check availability'), + })) + } + } + + checkAvailability() + }, []) + + // Auto-initialize if requested + useEffect(() => { + const isApiReady = state.availability === 'available' || state.availability === 'readily' + if (autoInitialize && isApiReady && !serviceRef.current) { + initialize(config) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoInitialize, state.availability]) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (serviceRef.current) { + serviceRef.current.destroy() + serviceRef.current = null + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + } + }, []) + + const initialize = useCallback( + (initConfig?: TranslatorServiceConfig) => { + // Clean up existing service + if (serviceRef.current) { + serviceRef.current.destroy() + serviceRef.current = null + } + + setState((prev) => ({ + ...prev, + isInitializing: true, + error: null, + downloadProgress: null, + })) + + try { + const mergedConfig: TranslatorServiceConfig = { + ...initConfig, + monitor: (progress) => { + setState((prev) => ({ + ...prev, + downloadProgress: progress, + })) + initConfig?.monitor?.(progress) + }, + } + + const service = new TranslatorService(mergedConfig) + serviceRef.current = service + + setState((prev) => ({ + ...prev, + isInitializing: false, + isReady: true, + downloadProgress: null, + })) + } catch (error) { + setState((prev) => ({ + ...prev, + isInitializing: false, + isReady: false, + error: error instanceof Error ? error : new Error('Failed to initialize translator service'), + downloadProgress: null, + })) + throw error + } + }, + [] + ) + + const detectLanguage = useCallback( + async (text: string): Promise => { + if (!serviceRef.current) { + throw new Error('Translator service not initialized. Call initialize() first.') + } + + setState((prev) => ({ ...prev, isDetecting: true, error: null })) + + try { + const results = await serviceRef.current.detectLanguage(text) + setState((prev) => ({ ...prev, isDetecting: false })) + return results + } catch (error) { + setState((prev) => ({ + ...prev, + isDetecting: false, + error: error instanceof Error ? error : new Error('Failed to detect language'), + })) + throw error + } + }, + [] + ) + + const translateDetailed = useCallback( + async ( + text: string, + options: TranslateWithProgressOptions + ): Promise => { + if (!serviceRef.current) { + throw new Error('Translator service not initialized. Call initialize() first.') + } + + const startTime = Date.now() + const { onProgress, onChunkProgress, onChunk, ...translateOptions } = options + + // Initial progress + const initialProgress = createOperationProgress( + 'initializing', + 0, + 1, + 'Preparing translation...' + ) + setState((prev) => ({ + ...prev, + isTranslating: true, + error: null, + operationProgress: initialProgress, + chunkProgress: null, + })) + onProgress?.(initialProgress) + + try { + // Processing progress + const processingProgress = createOperationProgress( + 'processing', + 1, + 1, + 'Translating...', + { startTime, inputLength: text.length } + ) + setState((prev) => ({ ...prev, operationProgress: processingProgress })) + onProgress?.(processingProgress) + + // Wrap onChunk to track chunk progress + const wrappedOnChunk = (event: TranslationChunkEvent) => { + // Calculate chunk progress + const chunkProg = calculateChunkProgress( + event.index + 1, // 1-indexed + options.chunkSize || 1, // Estimate - we don't know total chunks ahead of time + 100, // Assume chunk is complete when callback fires + event.sourceText.length + ) + + setState((prev) => ({ ...prev, chunkProgress: chunkProg })) + onChunkProgress?.(chunkProg) + + // Call original onChunk if provided + onChunk?.(event) + } + + const result = await serviceRef.current.translateDetailed(text, { + ...translateOptions, + onChunk: wrappedOnChunk, + }) + + // Update language pair + setState((prev) => ({ + ...prev, + currentLanguagePair: { + source: result.sourceLanguage, + target: result.targetLanguage, + }, + })) + + // Complete progress + const completeProgress = createOperationProgress( + 'complete', + 1, + 1, + 'Translation complete', + { + startTime, + endTime: Date.now(), + inputLength: text.length, + outputLength: result.translatedText.length, + chunks: result.chunks.length, + } + ) + setState((prev) => ({ + ...prev, + isTranslating: false, + operationProgress: completeProgress, + })) + onProgress?.(completeProgress) + + // Clear progress after a delay + setTimeout(() => { + setState((prev) => ({ + ...prev, + operationProgress: null, + chunkProgress: null, + })) + }, 1000) + + return result + } catch (error) { + setState((prev) => ({ + ...prev, + isTranslating: false, + operationProgress: null, + chunkProgress: null, + error: error instanceof Error ? error : new Error('Failed to translate'), + })) + const failedProgress = createOperationProgress('complete', 0, 1, 'Translation failed') + onProgress?.(failedProgress, error as Error) + throw error + } + }, + [] + ) + + const translate = useCallback( + async (text: string, options: TranslateWithProgressOptions): Promise => { + const result = await translateDetailed(text, options) + return result.translatedText + }, + [translateDetailed] + ) + + const translateStream = useCallback( + async ( + text: string, + onChunk: (chunk: string) => void, + options: TranslateStreamWithProgressOptions + ): Promise => { + if (!serviceRef.current) { + throw new Error('Translator service not initialized. Call initialize() first.') + } + + const startTime = Date.now() + const { onStreamingProgress, ...translateOptions } = options + + setState((prev) => ({ + ...prev, + isTranslating: true, + error: null, + streamingProgress: { + bytesReceived: 0, + chunksReceived: 0, + isComplete: false, + }, + })) + + try { + const stream = serviceRef.current.translateStream(text, translateOptions) + let bytesReceived = 0 + let chunksReceived = 0 + let accumulatedText = '' + + for await (const chunk of stream) { + bytesReceived += chunk.length + chunksReceived += 1 + accumulatedText += chunk + + // Update streaming progress + const progress = calculateStreamingProgress( + bytesReceived, + chunksReceived, + startTime, + undefined, + accumulatedText + ) + + setState((prev) => ({ ...prev, streamingProgress: progress })) + onStreamingProgress?.(progress) + + onChunk(chunk) + } + + // Mark as complete + const finalProgress: StreamingProgress = { + bytesReceived, + chunksReceived, + isComplete: true, + partialContent: accumulatedText, + streamingRate: Math.round(bytesReceived / ((Date.now() - startTime) / 1000)), + } + + setState((prev) => ({ + ...prev, + isTranslating: false, + streamingProgress: finalProgress, + })) + onStreamingProgress?.(finalProgress) + + // Clear streaming progress after a delay + setTimeout(() => { + setState((prev) => ({ ...prev, streamingProgress: null })) + }, 1000) + } catch (error) { + setState((prev) => ({ + ...prev, + isTranslating: false, + streamingProgress: null, + error: error instanceof Error ? error : new Error('Failed to stream translation'), + })) + throw error + } + }, + [] + ) + + const detectAndTranslate = useCallback( + async ( + text: string, + targetLanguage: AITranslatorLanguage, + options: Omit = {} + ): Promise => { + if (!serviceRef.current) { + throw new Error('Translator service not initialized. Call initialize() first.') + } + + const startTime = Date.now() + const { onProgress, onChunkProgress, onChunk, ...translateOptions } = options + + // Stage 1: Detecting language + const detectProgress = createOperationProgress( + 'initializing', + 0, + 2, + 'Detecting language...' + ) + setState((prev) => ({ + ...prev, + isTranslating: true, + isDetecting: true, + error: null, + operationProgress: detectProgress, + })) + onProgress?.(detectProgress) + + try { + // Wrap onChunk to track chunk progress + const wrappedOnChunk = onChunk + ? (event: TranslationChunkEvent) => { + const chunkProg = calculateChunkProgress( + event.index + 1, + options.chunkSize || 1, + 100, + event.sourceText.length + ) + setState((prev) => ({ ...prev, chunkProgress: chunkProg })) + onChunkProgress?.(chunkProg) + onChunk(event) + } + : undefined + + // Stage 2: Translating + const translateProgress = createOperationProgress( + 'processing', + 1, + 2, + 'Translating...', + { startTime } + ) + setState((prev) => ({ ...prev, isDetecting: false, operationProgress: translateProgress })) + onProgress?.(translateProgress) + + const result = await serviceRef.current.detectAndTranslate(text, targetLanguage, { + ...translateOptions, + onChunk: wrappedOnChunk, + }) + + // Update language pair + setState((prev) => ({ + ...prev, + currentLanguagePair: { + source: result.sourceLanguage, + target: result.targetLanguage, + }, + })) + + // Complete progress + const completeProgress = createOperationProgress( + 'complete', + 2, + 2, + 'Translation complete', + { + startTime, + endTime: Date.now(), + inputLength: text.length, + outputLength: result.translatedText.length, + detectedLanguage: result.detectedLanguage, + confidence: result.confidence, + } + ) + setState((prev) => ({ + ...prev, + isTranslating: false, + isDetecting: false, + operationProgress: completeProgress, + })) + onProgress?.(completeProgress) + + // Clear progress after a delay + setTimeout(() => { + setState((prev) => ({ + ...prev, + operationProgress: null, + chunkProgress: null, + })) + }, 1000) + + return result + } catch (error) { + setState((prev) => ({ + ...prev, + isTranslating: false, + isDetecting: false, + operationProgress: null, + chunkProgress: null, + error: error instanceof Error ? error : new Error('Failed to detect and translate'), + })) + const failedProgress = createOperationProgress('complete', 0, 2, 'Operation failed') + onProgress?.(failedProgress, error as Error) + throw error + } + }, + [] + ) + + const destroy = useCallback(() => { + if (serviceRef.current) { + serviceRef.current.destroy() + serviceRef.current = null + } + setState((prev) => ({ + ...prev, + isReady: false, + isTranslating: false, + isDetecting: false, + currentLanguagePair: null, + })) + }, []) + + const clearError = useCallback(() => { + setState((prev) => ({ ...prev, error: null })) + }, []) + + const clearProgress = useCallback(() => { + setState((prev) => ({ + ...prev, + operationProgress: null, + chunkProgress: null, + streamingProgress: null, + })) + }, []) + + return { + ...state, + initialize, + detectLanguage, + translateDetailed, + translate, + translateStream, + detectAndTranslate, + destroy, + clearError, + clearProgress, + } +} 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/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/lib/chrome-ai/types/progress.ts b/src/lib/chrome-ai/types/progress.ts new file mode 100644 index 0000000..98600c0 --- /dev/null +++ b/src/lib/chrome-ai/types/progress.ts @@ -0,0 +1,243 @@ +/** + * Progress Tracking Types + * + * Comprehensive types for tracking operation progress across all Chrome AI services. + * Provides consistent progress reporting for rewriting, translation, format transformation, and content extraction. + */ + +import type { FormatType } from '@/constants/formats' + +/** + * Main operation progress interface + * Used for tracking overall operation progress with stages + */ +export interface OperationProgress { + /** Current stage of the operation */ + stage: 'initializing' | 'processing' | 'streaming' | 'finalizing' | 'complete' + + /** Progress percentage (0-100) */ + percentage: number + + /** Current item being processed */ + current: number + + /** Total items to process */ + total: number + + /** Human-readable progress message */ + message?: string + + /** Additional metadata about the operation */ + metadata?: Record + + /** Estimated time remaining in milliseconds */ + estimatedTimeRemaining?: number +} + +/** + * Progress for chunked content processing + * Used when content is split into multiple chunks for processing + */ +export interface ChunkProgress { + /** Current chunk being processed (1-indexed) */ + currentChunk: number + + /** Total number of chunks */ + totalChunks: number + + /** Progress within current chunk (0-100) */ + chunkPercentage: number + + /** Overall progress across all chunks (0-100) */ + overallPercentage: number + + /** Size of current chunk in characters */ + chunkSize?: number + + /** Total content size in characters */ + totalSize?: number +} + +/** + * Progress for streaming operations + * Used when content is streamed in real-time + */ +export interface StreamingProgress { + /** Total bytes received so far */ + bytesReceived: number + + /** Estimated total bytes (if known) */ + estimatedTotal?: number + + /** Number of chunks received */ + chunksReceived: number + + /** Whether streaming is complete */ + isComplete: boolean + + /** Current streaming rate (bytes per second) */ + streamingRate?: number + + /** Content received so far (for preview) */ + partialContent?: string +} + +/** + * Progress for multi-format operations + * Used when processing multiple output formats simultaneously + */ +export interface MultiFormatProgress { + /** The format being processed */ + format: FormatType + + /** Current status of this format */ + status: 'pending' | 'processing' | 'complete' | 'error' + + /** Progress percentage for this format (0-100) */ + percentage: number + + /** Error if format processing failed */ + error?: Error + + /** Partial result (for streaming) */ + partialResult?: string + + /** Processing start time */ + startTime?: number + + /** Processing end time */ + endTime?: number +} + +/** + * Aggregated progress across multiple formats + */ +export interface AggregateProgress { + /** Overall progress percentage (0-100) */ + overallPercentage: number + + /** Number of formats completed */ + completedCount: number + + /** Total number of formats */ + totalCount: number + + /** Currently processing format */ + currentFormat?: FormatType + + /** Individual format progresses */ + formats: Map +} + +/** + * Download progress (for model downloads) + * Extends existing AIModelDownloadProgress type + */ +export interface ModelDownloadProgress { + /** Download percentage (0-100) */ + percentage: number + + /** Bytes downloaded */ + downloaded: number + + /** Total bytes to download */ + total: number + + /** Download rate (bytes per second) */ + rate?: number + + /** Estimated time remaining (milliseconds) */ + estimatedTimeRemaining?: number +} + +/** + * Callback type for progress updates + * Can optionally receive error information + */ +export type ProgressCallback = (progress: OperationProgress, error?: Error) => void + +/** + * Callback type for chunk progress updates + */ +export type ChunkProgressCallback = (progress: ChunkProgress, error?: Error) => void + +/** + * Callback type for streaming progress updates + */ +export type StreamingProgressCallback = (progress: StreamingProgress, error?: Error) => void + +/** + * Callback type for multi-format progress updates + */ +export type MultiFormatProgressCallback = (format: FormatType, progress: MultiFormatProgress) => void + +/** + * Callback type for cancellation requests + */ +export type CancelCallback = () => void + +/** + * Options for operations with progress tracking + */ +export interface ProgressOptions { + /** Callback for operation progress updates */ + onProgress?: ProgressCallback + + /** Callback for chunk progress updates */ + onChunkProgress?: ChunkProgressCallback + + /** Callback for streaming progress updates */ + onStreamingProgress?: StreamingProgressCallback + + /** Callback for stage changes */ + onStageChange?: (stage: OperationProgress['stage'], metadata?: Record) => void + + /** Callback for cancellation requests */ + onCancel?: CancelCallback + + /** AbortSignal for cancelling the operation */ + signal?: AbortSignal + + /** Whether to enable progress tracking (default: true) */ + enableProgress?: boolean + + /** Progress update throttle in milliseconds (default: 100) */ + progressThrottle?: number +} + +/** + * Stage information for multi-step operations + */ +export interface OperationStage { + /** Stage name */ + name: string + + /** Stage status */ + status: 'pending' | 'active' | 'complete' | 'error' + + /** Stage description */ + description?: string + + /** Stage duration in milliseconds (if complete) */ + duration?: number + + /** Error if stage failed */ + error?: Error +} + +/** + * Complete operation progress with stages + */ +export interface DetailedOperationProgress extends OperationProgress { + /** Individual stages of the operation */ + stages: OperationStage[] + + /** Overall operation start time */ + startTime: number + + /** Overall operation end time (if complete) */ + endTime?: number + + /** Whether operation can be cancelled */ + cancellable: boolean +} diff --git a/src/lib/chrome-ai/utils/__tests__/progressCalculator.test.ts b/src/lib/chrome-ai/utils/__tests__/progressCalculator.test.ts new file mode 100644 index 0000000..96f2e67 --- /dev/null +++ b/src/lib/chrome-ai/utils/__tests__/progressCalculator.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { + createOperationProgress, + calculateStreamingProgress, + aggregateMultiFormatProgress, + formatTimeRemaining, + formatPercentage, + addTimeEstimation, + calculateChunkProgress, +} from '../progressCalculator' +import type { FormatType } from '@/constants/formats' +import type { MultiFormatProgress } from '@/lib/chrome-ai/types/progress' + +describe('progressCalculator', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('createOperationProgress', () => { + it('creates progress with calculated percentage', () => { + const progress = createOperationProgress('processing', 50, 100, 'Processing content') + + expect(progress.percentage).toBe(50) + expect(progress.message).toBe('Processing content') + expect(progress.stage).toBe('processing') + expect(progress.current).toBe(50) + expect(progress.total).toBe(100) + }) + + it('creates progress with 0% when current is 0', () => { + const progress = createOperationProgress('initializing', 0, 100) + + expect(progress.percentage).toBe(0) + expect(progress.stage).toBe('initializing') + }) + + it('caps percentage at 100', () => { + const progress = createOperationProgress('processing', 150, 100) + + expect(progress.percentage).toBe(100) + }) + + it('includes metadata when provided', () => { + const progress = createOperationProgress('processing', 50, 100, undefined, { + inputLength: 1000, + outputLength: 500, + }) + + expect(progress.metadata?.inputLength).toBe(1000) + expect(progress.metadata?.outputLength).toBe(500) + }) + + it('handles zero total gracefully', () => { + const progress = createOperationProgress('processing', 0, 0) + + expect(progress.percentage).toBe(0) + }) + }) + + describe('calculateStreamingProgress', () => { + it('tracks bytes and chunks received', () => { + const startTime = Date.now() + vi.setSystemTime(startTime + 1000) // 1 second later + + const progress = calculateStreamingProgress( + 500, // bytes received + 5, // chunks received + startTime + ) + + expect(progress.bytesReceived).toBe(500) + expect(progress.chunksReceived).toBe(5) + expect(progress.isComplete).toBe(false) + }) + + it('calculates streaming rate when time has elapsed', () => { + const startTime = Date.now() + vi.setSystemTime(startTime + 2000) // 2 seconds later + + const progress = calculateStreamingProgress( + 1000, // bytes received + 10, // chunks + startTime + ) + + // Rate: 1000 bytes / 2 seconds = 500 bytes/sec + expect(progress.streamingRate).toBe(500) + }) + + it('handles zero elapsed time', () => { + const startTime = Date.now() + + const progress = calculateStreamingProgress(100, 1, startTime) + + expect(progress.streamingRate).toBeUndefined() + }) + + it('includes estimated total when provided', () => { + const startTime = Date.now() + + const progress = calculateStreamingProgress(500, 5, startTime, 1000) + + expect(progress.estimatedTotal).toBe(1000) + }) + + it('includes partial content when provided', () => { + const startTime = Date.now() + + const progress = calculateStreamingProgress( + 500, + 5, + startTime, + undefined, + 'Partial text...' + ) + + expect(progress.partialContent).toBe('Partial text...') + }) + }) + + describe('calculateChunkProgress', () => { + it('calculates overall progress from chunks', () => { + const progress = calculateChunkProgress(2, 4, 50) + + // Completed chunk 1 (100%), chunk 2 is 50% done + // (1 + 0.5) / 4 = 0.375 = 38% + expect(progress.overallPercentage).toBe(38) + expect(progress.currentChunk).toBe(2) + expect(progress.totalChunks).toBe(4) + expect(progress.chunkPercentage).toBe(50) + }) + + it('handles first chunk', () => { + const progress = calculateChunkProgress(1, 10, 0) + + expect(progress.overallPercentage).toBe(0) + expect(progress.currentChunk).toBe(1) + }) + + it('handles last chunk completion', () => { + const progress = calculateChunkProgress(5, 5, 100) + + expect(progress.overallPercentage).toBe(100) + }) + }) + + describe('aggregateMultiFormatProgress', () => { + it('aggregates progress from multiple formats', () => { + const formatProgress = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + startTime: Date.now() - 2000, + endTime: Date.now(), + }, + ], + [ + 'bullets', + { + format: 'bullets', + status: 'processing', + percentage: 50, + startTime: Date.now() - 1000, + }, + ], + [ + 'summary', + { + format: 'summary', + status: 'pending', + percentage: 0, + }, + ], + ]) + + const aggregate = aggregateMultiFormatProgress(formatProgress) + + expect(aggregate.totalCount).toBe(3) + expect(aggregate.completedCount).toBe(1) + // (100 + 50 + 0) / 3 = 50 + expect(aggregate.overallPercentage).toBe(50) + expect(aggregate.currentFormat).toBe('bullets') + expect(aggregate.formats).toBe(formatProgress) + }) + + it('handles all completed formats', () => { + const formatProgress = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + startTime: Date.now() - 2000, + endTime: Date.now(), + }, + ], + [ + 'bullets', + { + format: 'bullets', + status: 'complete', + percentage: 100, + startTime: Date.now() - 1000, + endTime: Date.now(), + }, + ], + ]) + + const aggregate = aggregateMultiFormatProgress(formatProgress) + + expect(aggregate.completedCount).toBe(2) + expect(aggregate.overallPercentage).toBe(100) + expect(aggregate.currentFormat).toBeUndefined() + }) + + it('handles empty format map', () => { + const aggregate = aggregateMultiFormatProgress(new Map()) + + expect(aggregate.totalCount).toBe(0) + expect(aggregate.completedCount).toBe(0) + expect(aggregate.overallPercentage).toBe(0) + }) + + it('counts errors as complete', () => { + const formatProgress = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'complete', + percentage: 100, + startTime: Date.now(), + endTime: Date.now(), + }, + ], + [ + 'bullets', + { + format: 'bullets', + status: 'error', + percentage: 0, + startTime: Date.now(), + error: new Error('Test error'), + }, + ], + ]) + + const aggregate = aggregateMultiFormatProgress(formatProgress) + + // Both complete and error count toward completedCount + expect(aggregate.completedCount).toBe(2) + // (100 + 100) / 2 = 100 (error contributes 100 to total) + expect(aggregate.overallPercentage).toBe(100) + }) + + it('identifies processing format when present', () => { + const formatProgress = new Map([ + [ + 'paragraphs', + { + format: 'paragraphs', + status: 'pending', + percentage: 0, + }, + ], + [ + 'bullets', + { + format: 'bullets', + status: 'processing', + percentage: 30, + startTime: Date.now(), + }, + ], + ]) + + const aggregate = aggregateMultiFormatProgress(formatProgress) + + expect(aggregate.currentFormat).toBe('bullets') + }) + }) + + describe('addTimeEstimation', () => { + it('estimates remaining time based on percentage', () => { + const startTime = Date.now() + vi.setSystemTime(startTime + 1000) // 1 second elapsed + + const progress = createOperationProgress('processing', 50, 100) + const withEstimate = addTimeEstimation(progress, startTime) + + // 50% done in 1s means 100% in 2s, so 1s remaining + expect(withEstimate.estimatedTimeRemaining).toBe(1000) + }) + + it('returns 0 for 100% complete', () => { + const startTime = Date.now() + const progress = createOperationProgress('complete', 100, 100) + const withEstimate = addTimeEstimation(progress, startTime) + + expect(withEstimate.estimatedTimeRemaining).toBe(0) + }) + + it('returns 0 for 0% (no data yet)', () => { + const startTime = Date.now() + const progress = createOperationProgress('initializing', 0, 100) + const withEstimate = addTimeEstimation(progress, startTime) + + expect(withEstimate.estimatedTimeRemaining).toBe(0) + }) + }) + + describe('formatTimeRemaining', () => { + it('formats seconds correctly', () => { + expect(formatTimeRemaining(45000)).toBe('45s remaining') + expect(formatTimeRemaining(5000)).toBe('5s remaining') + }) + + it('formats minutes and seconds', () => { + expect(formatTimeRemaining(125000)).toBe('2m 5s remaining') + expect(formatTimeRemaining(60000)).toBe('1m remaining') + }) + + it('formats hours and minutes', () => { + expect(formatTimeRemaining(3665000)).toBe('1h 1m remaining') + expect(formatTimeRemaining(7200000)).toBe('2h remaining') + }) + + it('handles zero as complete', () => { + expect(formatTimeRemaining(0)).toBe('Complete') + }) + + it('handles negative as unknown', () => { + expect(formatTimeRemaining(-1000)).toBe('Unknown') + }) + }) + + describe('formatPercentage', () => { + it('formats percentages (0-100 scale)', () => { + expect(formatPercentage(54)).toBe('54%') + expect(formatPercentage(0)).toBe('0%') + expect(formatPercentage(100)).toBe('100%') + }) + + it('rounds to nearest integer', () => { + expect(formatPercentage(54.6)).toBe('55%') + expect(formatPercentage(54.4)).toBe('54%') + }) + + it('caps at 100%', () => { + expect(formatPercentage(150)).toBe('100%') + }) + }) +}) diff --git a/src/lib/chrome-ai/utils/progressCalculator.ts b/src/lib/chrome-ai/utils/progressCalculator.ts new file mode 100644 index 0000000..96e7d96 --- /dev/null +++ b/src/lib/chrome-ai/utils/progressCalculator.ts @@ -0,0 +1,327 @@ +/** + * Progress Calculator Utilities + * + * Utilities for calculating progress across different operation types. + * Handles chunk-based, streaming, multi-format, and time-based progress calculations. + */ + +import type { FormatType } from '@/constants/formats' +import type { + AggregateProgress, + ChunkProgress, + MultiFormatProgress, + OperationProgress, + StreamingProgress, +} from '../types/progress' + +/** + * Calculate progress for chunk-based processing + */ +export function calculateChunkProgress( + currentChunk: number, + totalChunks: number, + currentChunkProgress = 0, // Progress within current chunk (0-100) + chunkSize?: number, + totalSize?: number +): ChunkProgress { + // Clamp values + const safeCurrent = Math.max(1, Math.min(currentChunk, totalChunks)) + const safeChunkProgress = Math.max(0, Math.min(currentChunkProgress, 100)) + + // Calculate overall progress + const completedChunks = safeCurrent - 1 + const currentChunkContribution = safeChunkProgress / 100 + const overallPercentage = Math.round( + ((completedChunks + currentChunkContribution) / totalChunks) * 100 + ) + + return { + currentChunk: safeCurrent, + totalChunks, + chunkPercentage: safeChunkProgress, + overallPercentage: Math.min(overallPercentage, 100), + chunkSize, + totalSize, + } +} + +/** + * Calculate progress for streaming operations + * + * @param bytesReceived - Total bytes received so far + * @param chunksReceived - Number of chunks received + * @param startTime - Operation start time (Date.now() timestamp in ms) + * @param estimatedTotal - Estimated total bytes (optional) + * @param partialContent - Partial content for preview (optional) + */ +export function calculateStreamingProgress( + bytesReceived: number, + chunksReceived: number, + startTime: number, + estimatedTotal?: number, + partialContent?: string +): StreamingProgress { + // Calculate elapsed time in seconds + const elapsedMs = Date.now() - startTime + const elapsedSeconds = elapsedMs / 1000 + + // Calculate streaming rate (bytes per second) + const streamingRate = elapsedSeconds > 0 && bytesReceived > 0 + ? Math.round(bytesReceived / elapsedSeconds) + : undefined + + return { + bytesReceived, + estimatedTotal, + chunksReceived, + isComplete: false, + partialContent, + streamingRate, + } +} + +/** + * Calculate aggregate progress across multiple formats + */ +export function aggregateMultiFormatProgress( + formatProgresses: Map +): AggregateProgress { + const entries = Array.from(formatProgresses.entries()) + const totalCount = entries.length + + if (totalCount === 0) { + return { + overallPercentage: 0, + completedCount: 0, + totalCount: 0, + formats: formatProgresses, + } + } + + // Count completed and calculate total progress + let completedCount = 0 + let totalPercentage = 0 + let currentFormat: FormatType | undefined + + for (const [format, progress] of entries) { + if (progress.status === 'complete') { + completedCount++ + totalPercentage += 100 + } else if (progress.status === 'processing') { + currentFormat = format + totalPercentage += progress.percentage + } else if (progress.status === 'error') { + // Count errors as complete for progress calculation + completedCount++ + totalPercentage += 100 + } + // pending contributes 0 + } + + const overallPercentage = Math.round(totalPercentage / totalCount) + + return { + overallPercentage: Math.min(overallPercentage, 100), + completedCount, + totalCount, + currentFormat, + formats: formatProgresses, + } +} + +/** + * Create an operation progress object + */ +export function createOperationProgress( + stage: OperationProgress['stage'], + current: number, + total: number, + message?: string, + metadata?: Record +): OperationProgress { + const percentage = total > 0 ? Math.round((current / total) * 100) : 0 + + return { + stage, + percentage: Math.min(percentage, 100), + current, + total, + message, + metadata, + } +} + +/** + * Update progress with time estimation + */ +export function addTimeEstimation( + progress: OperationProgress, + startTime: number, + estimateFromPercentage = true +): OperationProgress { + if (progress.percentage === 0 || progress.percentage === 100) { + return { ...progress, estimatedTimeRemaining: 0 } + } + + const elapsed = Date.now() - startTime + + if (!estimateFromPercentage) { + return progress + } + + // Estimate based on percentage completed + const estimatedTotal = (elapsed / progress.percentage) * 100 + const estimatedRemaining = estimatedTotal - elapsed + + return { + ...progress, + estimatedTimeRemaining: Math.max(0, Math.round(estimatedRemaining)), + } +} + +/** + * Merge chunk progress into operation progress + */ +export function mergeChunkProgress( + operationProgress: OperationProgress, + chunkProgress: ChunkProgress +): OperationProgress { + return { + ...operationProgress, + percentage: chunkProgress.overallPercentage, + current: chunkProgress.currentChunk, + total: chunkProgress.totalChunks, + message: `Processing chunk ${chunkProgress.currentChunk} of ${chunkProgress.totalChunks}`, + metadata: { + ...operationProgress.metadata, + chunkProgress, + }, + } +} + +/** + * Calculate progress for weighted stages + * Useful when different stages have different durations + */ +export function calculateWeightedProgress( + stages: Array<{ name: string; weight: number; progress: number }> +): number { + const totalWeight = stages.reduce((sum, stage) => sum + stage.weight, 0) + + if (totalWeight === 0) return 0 + + const weightedProgress = stages.reduce((sum, stage) => { + return sum + (stage.progress * stage.weight) + }, 0) + + return Math.round(weightedProgress / totalWeight) +} + +/** + * Throttle progress updates to avoid excessive re-renders + */ +export function createProgressThrottle(throttleMs = 100) { + let lastUpdate = 0 + let lastProgress: OperationProgress | null = null + + return function shouldUpdate(progress: OperationProgress): boolean { + const now = Date.now() + + // Always update on stage changes or completion + if ( + progress.stage === 'complete' || + progress.stage !== lastProgress?.stage || + progress.percentage === 100 + ) { + lastUpdate = now + lastProgress = progress + return true + } + + // Throttle intermediate updates + if (now - lastUpdate >= throttleMs) { + lastUpdate = now + lastProgress = progress + return true + } + + return false + } +} + +/** + * Format time remaining for display + */ +export function formatTimeRemaining(milliseconds: number): string { + if (milliseconds < 0) return 'Unknown' + if (milliseconds === 0) return 'Complete' + + const seconds = Math.ceil(milliseconds / 1000) + + if (seconds < 60) { + return `${seconds}s remaining` + } + + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s remaining` + : `${minutes}m remaining` + } + + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + + return remainingMinutes > 0 + ? `${hours}h ${remainingMinutes}m remaining` + : `${hours}h remaining` +} + +/** + * Format progress percentage for display + */ +export function formatPercentage(percentage: number): string { + return `${Math.min(Math.round(percentage), 100)}%` +} + +/** + * Format bytes for display + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` +} + +/** + * Calculate streaming rate + */ +export function calculateStreamingRate( + bytesReceived: number, + elapsedMs: number +): number { + if (elapsedMs === 0) return 0 + return Math.round((bytesReceived / elapsedMs) * 1000) // bytes per second +} + +/** + * Estimate completion time based on streaming rate + */ +export function estimateStreamingCompletion( + bytesReceived: number, + estimatedTotal: number, + streamingRate: number +): number { + if (streamingRate === 0) return 0 + + const remainingBytes = estimatedTotal - bytesReceived + const remainingSeconds = remainingBytes / streamingRate + + return Math.round(remainingSeconds * 1000) // milliseconds +} diff --git a/src/lib/feedback/toastManager.ts b/src/lib/feedback/toastManager.ts new file mode 100644 index 0000000..7ef082b --- /dev/null +++ b/src/lib/feedback/toastManager.ts @@ -0,0 +1,155 @@ +/** + * Toast Notification State Management System + * + * Provides a centralized, subscription-based state management for toast notifications. + * Uses a pub-sub pattern similar to the error reporting system for consistency. + * + * Features: + * - Auto-dismiss with configurable duration + * - Manual dismiss + * - Multiple toast types (success, error, warning, info) + * - Action buttons + * - Unique ID generation + * - Maximum toast limit + */ + +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface ToastAction { + label: string + onClick: () => void +} + +export interface Toast { + id: string + type: ToastType + title: string + message?: string + duration?: number // milliseconds, 0 = no auto-dismiss + action?: ToastAction + dismissible?: boolean + createdAt: number +} + +export interface ToastOptions { + type?: ToastType + message?: string + duration?: number + action?: ToastAction + dismissible?: boolean +} + +type ToastListener = (toasts: Toast[]) => void + +// State +const toasts: Toast[] = [] +const listeners = new Set() +const MAX_TOASTS = 5 +const DEFAULT_DURATION = 5000 // 5 seconds + +// Utility: Generate unique ID +let toastIdCounter = 0 +const generateToastId = (): string => { + return `toast-${Date.now()}-${++toastIdCounter}` +} + +// Notify all listeners of state changes +const notifyListeners = () => { + listeners.forEach((listener) => listener([...toasts])) +} + +// Subscribe to toast state changes +export const subscribeToToasts = (listener: ToastListener): (() => void) => { + listeners.add(listener) + + // Immediately notify with current state + listener([...toasts]) + + // Return unsubscribe function + return () => { + listeners.delete(listener) + } +} + +// Add a toast to the state +export const showToast = (title: string, options: ToastOptions = {}): string => { + const toast: Toast = { + id: generateToastId(), + type: options.type || 'info', + title, + message: options.message, + duration: options.duration !== undefined ? options.duration : DEFAULT_DURATION, + action: options.action, + dismissible: options.dismissible !== false, // default true + createdAt: Date.now(), + } + + // Add to beginning of array (newest first) + toasts.unshift(toast) + + // Enforce max toasts limit + if (toasts.length > MAX_TOASTS) { + toasts.splice(MAX_TOASTS) + } + + notifyListeners() + + // Auto-dismiss if duration > 0 + if (toast.duration && toast.duration > 0) { + setTimeout(() => { + dismissToast(toast.id) + }, toast.duration) + } + + return toast.id +} + +// Dismiss a specific toast +export const dismissToast = (id: string): void => { + const index = toasts.findIndex((t) => t.id === id) + + if (index !== -1) { + toasts.splice(index, 1) + notifyListeners() + } +} + +// Dismiss all toasts +export const dismissAllToasts = (): void => { + toasts.length = 0 + notifyListeners() +} + +// Convenience methods for different toast types +export const showSuccessToast = ( + title: string, + options?: Omit +): string => { + return showToast(title, { ...options, type: 'success' }) +} + +export const showErrorToast = ( + title: string, + options?: Omit +): string => { + return showToast(title, { ...options, type: 'error', duration: options?.duration ?? 7000 }) +} + +export const showWarningToast = ( + title: string, + options?: Omit +): string => { + return showToast(title, { ...options, type: 'warning', duration: options?.duration ?? 6000 }) +} + +export const showInfoToast = ( + title: string, + options?: Omit +): string => { + return showToast(title, { ...options, type: 'info' }) +} + +// Get current toasts (for testing/debugging) +export const getToasts = (): Toast[] => { + return [...toasts] +} diff --git a/src/lib/help/helpContent.ts b/src/lib/help/helpContent.ts new file mode 100644 index 0000000..61bbb74 --- /dev/null +++ b/src/lib/help/helpContent.ts @@ -0,0 +1,512 @@ +/** + * Help Content Database + * + * Comprehensive help topics covering all Synapse features. + * Used by the HelpPanel component for searchable, contextual help. + */ + +export interface HelpTopic { + id: string + category: 'getting-started' | 'features' | 'troubleshooting' | 'privacy' | 'advanced' + title: string + description: string + content: string + keywords: string[] + relatedTopics?: string[] +} + +export const helpTopics: HelpTopic[] = [ + // Getting Started + { + id: 'what-is-synapse', + category: 'getting-started', + title: 'What is Synapse?', + description: 'Introduction to Synapse and Chrome AI', + content: `Synapse is a privacy-first browser extension that uses Chrome's built-in AI to transform and analyze your content locally on your device. + +**Key Benefits:** +- **100% Local Processing**: All AI operations run on your device - no data is sent to external servers +- **Fast & Responsive**: Powered by Chrome's optimized on-device AI models +- **Privacy-First**: Your content never leaves your browser +- **No API Keys Required**: No cloud services, accounts, or subscriptions needed + +**What You Can Do:** +- Rewrite content in different tones and styles +- Translate text between languages +- Extract content from web pages +- Transform content formats +- Analyze images (coming soon)`, + keywords: ['intro', 'introduction', 'overview', 'what', 'about', 'chrome ai', 'local', 'privacy'], + relatedTopics: ['browser-requirements', 'privacy-policy'], + }, + + { + id: 'browser-requirements', + category: 'getting-started', + title: 'Browser Requirements', + description: 'System requirements and browser compatibility', + content: `Synapse requires specific Chrome features to function properly. + +**Minimum Requirements:** +- **Browser**: Chrome 127+ or Edge 127+ (Dev/Canary channels) +- **Operating System**: Windows, macOS, Linux, or ChromeOS +- **Chrome AI**: Built-in AI APIs must be enabled + +**Enabling Chrome AI:** +1. Open chrome://flags/#optimization-guide-on-device-model +2. Set to "Enabled BypassPerfRequirement" +3. Restart your browser +4. Navigate to chrome://components +5. Find "Optimization Guide On Device Model" and click "Check for update" + +**Origin Trial:** +For production Chrome/Edge, you need an Origin Trial token. Visit the Setup page in Synapse for detailed instructions.`, + keywords: ['requirements', 'browser', 'chrome', 'edge', 'compatibility', 'setup', 'install', 'enable'], + relatedTopics: ['troubleshooting-model', 'origin-trial'], + }, + + { + id: 'origin-trial', + category: 'getting-started', + title: 'Origin Trial Setup', + description: 'How to set up Origin Trial for Chrome AI', + content: `Origin Trial tokens allow you to use Chrome AI features in stable Chrome/Edge releases. + +**Setup Steps:** +1. Visit the Origin Trial Setup page in Synapse +2. Follow the instructions to register at chrome.dev/origintrials +3. Request a token for the "Built-in AI APIs" trial +4. Copy your token +5. Add it to your browser extension settings + +**Trial Duration:** +Origin Trials are temporary and expire. You'll need to renew your token periodically. + +**Alternative:** +Use Chrome Dev, Canary, or Edge Canary channels which don't require Origin Trial tokens.`, + keywords: ['origin trial', 'token', 'setup', 'stable', 'production'], + relatedTopics: ['browser-requirements'], + }, + + // Features + { + id: 'workspace-overview', + category: 'features', + title: 'Workspace Overview', + description: 'How to use the main workspace', + content: `The Workspace is where you transform and analyze content. + +**Input Methods:** +- **Text Input**: Type or paste content directly +- **File Upload**: Upload .txt, .md, .pdf, or .docx files +- **URL Extraction**: Fetch content from web pages +- **Image Upload**: Upload images for analysis (if supported) + +**Output Options:** +- View original and transformed content side-by-side +- Download transformed content +- Copy to clipboard +- View processing history + +**Processing:** +1. Add your content using any input method +2. Select transformation type (rewrite, translate, format) +3. Configure options (tone, style, target language, etc.) +4. Click "Process" to start +5. View results and refine as needed`, + keywords: ['workspace', 'how to use', 'process', 'transform', 'input', 'output'], + relatedTopics: ['rewriting-content', 'translating-content', 'content-extraction'], + }, + + { + id: 'rewriting-content', + category: 'features', + title: 'Rewriting Content', + description: 'Transform content tone and style', + content: `The Rewriter lets you change the tone and style of your content. + +**Available Tones:** +- **Formal**: Professional, polished language +- **Casual**: Conversational, relaxed style +- **Academic**: Scholarly, precise writing +- **Creative**: Expressive, imaginative language + +**Available Formats:** +- **Shorter**: Condense while keeping key points +- **Longer**: Expand with more detail + +**Best Practices:** +- Provide clear, well-structured input +- Choose appropriate tone for your audience +- Review output for accuracy +- Use retry if results aren't perfect + +**Limitations:** +- Maximum input length varies by model +- Complex formatting may be simplified +- Works best with English content`, + keywords: ['rewrite', 'tone', 'style', 'formal', 'casual', 'academic', 'creative', 'transform'], + relatedTopics: ['workspace-overview', 'writing-tools'], + }, + + { + id: 'translating-content', + category: 'features', + title: 'Translating Content', + description: 'Translate between languages', + content: `Synapse supports translation between multiple languages using Chrome's on-device translation models. + +**Supported Languages:** +Check the Workspace for the current list of supported source and target languages. + +**Translation Tips:** +- Provide complete sentences for better results +- Simple, clear source text translates better +- Context helps with ambiguous phrases +- Review translations for accuracy + +**Quality Factors:** +- Translation quality depends on language pair +- Common languages (English, Spanish, French) typically have better results +- Technical terms may not translate perfectly +- Idioms and cultural references may need adjustment`, + keywords: ['translate', 'translation', 'language', 'languages', 'multilingual'], + relatedTopics: ['workspace-overview'], + }, + + { + id: 'content-extraction', + category: 'features', + title: 'Content Extraction', + description: 'Extract content from web pages', + content: `Extract clean, readable content from web pages for processing. + +**How It Works:** +1. Enter a URL in the workspace +2. Click "Extract Content" +3. Synapse fetches and parses the page +4. Clean content is extracted (removing ads, navigation, etc.) +5. Use the extracted content for rewriting or translation + +**Supported Sites:** +- News articles +- Blog posts +- Documentation pages +- Public web content + +**Limitations:** +- Requires public URLs (no authentication) +- JavaScript-heavy sites may not work perfectly +- Some sites block automated extraction +- Paywalled content is not accessible + +**Privacy:** +Content extraction happens through a CORS proxy for compatibility. The proxy doesn't log or store your data.`, + keywords: ['extract', 'extraction', 'url', 'web page', 'fetch', 'article', 'scrape'], + relatedTopics: ['workspace-overview', 'privacy-policy'], + }, + + { + id: 'writing-tools', + category: 'features', + title: 'Writing Tools', + description: 'Quick writing enhancements', + content: `Writing Tools provide quick text transformations. + +**Available Tools:** +- **Fix Grammar**: Correct grammatical errors +- **Improve Clarity**: Make text clearer and easier to read +- **Shorten**: Reduce length while keeping meaning +- **Expand**: Add more detail and explanation +- **Simplify**: Use simpler words and shorter sentences +- **Formalize**: Make text more professional +- **Casualize**: Make text more conversational + +**Usage:** +1. Enter your text in the workspace +2. Select a writing tool +3. Review the transformed output +4. Apply or retry as needed + +**Best For:** +- Quick edits and improvements +- Email drafting +- Document preparation +- Content refinement`, + keywords: ['writing tools', 'grammar', 'clarity', 'improve', 'enhance', 'fix'], + relatedTopics: ['rewriting-content', 'workspace-overview'], + }, + + { + id: 'processing-history', + category: 'features', + title: 'Processing History', + description: 'View and manage past transformations', + content: `Synapse keeps a local history of your transformations. + +**Features:** +- View past input and output +- Re-run previous transformations +- Delete individual history items +- Clear all history + +**Privacy:** +- History is stored locally in your browser +- Never uploaded to any server +- Cleared when you clear browser data +- Can be exported or deleted at any time + +**Management:** +Access history from the Workspace or Data Management page. You can export your history as JSON for backup purposes.`, + keywords: ['history', 'past', 'previous', 'transformations', 'saved'], + relatedTopics: ['privacy-policy', 'data-management'], + }, + + // Troubleshooting + { + id: 'troubleshooting-model', + category: 'troubleshooting', + title: 'Model Download Issues', + description: 'Fix problems downloading AI models', + content: `If Chrome AI models aren't downloading: + +**Check Model Status:** +1. Visit chrome://components +2. Find "Optimization Guide On Device Model" +3. Check the version and status + +**Force Model Download:** +1. Click "Check for update" in chrome://components +2. Wait for download to complete (can take several minutes) +3. Restart your browser + +**Common Issues:** +- **No Internet**: Models download from Google's servers +- **Disk Space**: Models require several GB of storage +- **Firewall**: Corporate networks may block downloads +- **Unsupported Device**: Some older devices aren't supported + +**Still Not Working?** +1. Check browser version (need 127+) +2. Verify flags are enabled correctly +3. Try Chrome Dev/Canary if on stable Chrome +4. Check console for error messages`, + keywords: ['model', 'download', 'not working', 'failed', 'error', 'broken', 'fix'], + relatedTopics: ['browser-requirements', 'troubleshooting-errors'], + }, + + { + id: 'troubleshooting-errors', + category: 'troubleshooting', + title: 'Common Errors', + description: 'Understanding and fixing common errors', + content: `Common errors and solutions: + +**"Model not available"** +- Models haven't finished downloading +- Check chrome://components and force update +- Wait a few minutes and try again + +**"Operation timed out"** +- Large content takes longer to process +- Try smaller chunks of content +- Check browser isn't overwhelmed with other tasks + +**"Browser not supported"** +- Chrome/Edge version too old +- Chrome AI not enabled in flags +- Missing Origin Trial token (stable Chrome) + +**"Content extraction failed"** +- URL is invalid or blocked +- Site requires authentication +- Try copying content manually instead + +**Retry Mechanism:** +Synapse automatically retries failed operations. If max retries are reached, wait a moment and try again manually.`, + keywords: ['error', 'errors', 'broken', 'not working', 'failed', 'problem', 'issue', 'troubleshoot'], + relatedTopics: ['troubleshooting-model', 'browser-requirements'], + }, + + // Privacy + { + id: 'privacy-policy', + category: 'privacy', + title: 'Privacy & Data Handling', + description: 'How Synapse protects your privacy', + content: `Synapse is designed with privacy as the top priority. + +**100% Local Processing:** +- All AI operations run on your device +- Content is never sent to external servers +- No cloud APIs, no data collection + +**Data Storage:** +- Processing history stored locally in browser +- No accounts, no authentication +- No tracking or analytics +- Data cleared with browser data + +**Content Extraction:** +- Uses CORS proxy for compatibility +- Proxy doesn't log or store content +- Can be disabled in preferences + +**No Third Parties:** +- No external APIs +- No tracking scripts +- No advertisements +- Open source code (verifiable) + +**You're in Control:** +- Export your data anytime +- Delete history whenever you want +- No lock-in, no vendor dependence`, + keywords: ['privacy', 'data', 'security', 'collection', 'tracking', 'local', 'safe'], + relatedTopics: ['data-management', 'what-is-synapse'], + }, + + { + id: 'data-management', + category: 'privacy', + title: 'Data Management', + description: 'Export and delete your data', + content: `Manage your Synapse data from the Data Management page. + +**What's Stored:** +- Processing history (input, output, settings) +- User preferences (theme, default options) +- No personal information or credentials + +**Available Actions:** +- **Export Data**: Download all your data as JSON +- **Delete History**: Remove all processing history +- **Clear Preferences**: Reset to default settings +- **Delete All Data**: Complete data wipe + +**Backup & Restore:** +1. Export your data for backup +2. Keep the JSON file safe +3. Import on another device or after reinstall + +**Browser Data:** +Synapse data is stored using browser's localStorage. Clearing browser data will also clear Synapse data.`, + keywords: ['data', 'export', 'delete', 'clear', 'remove', 'backup', 'manage'], + relatedTopics: ['privacy-policy'], + }, + + // Advanced + { + id: 'keyboard-shortcuts', + category: 'advanced', + title: 'Keyboard Shortcuts', + description: 'Keyboard shortcuts for faster workflow', + content: `Use these keyboard shortcuts for faster work: + +**Workspace:** +- \`Ctrl/Cmd + Enter\`: Process content +- \`Ctrl/Cmd + K\`: Clear input +- \`Ctrl/Cmd + Shift + C\`: Copy output + +**General:** +- \`?\`: Open help panel +- \`Esc\`: Close modals/panels +- \`Ctrl/Cmd + ,\`: Open preferences + +**Navigation:** +- \`Ctrl/Cmd + 1\`: Home +- \`Ctrl/Cmd + 2\`: Workspace +- \`Ctrl/Cmd + 3\`: Preferences + +Note: Not all shortcuts may be available in your browser version.`, + keywords: ['keyboard', 'shortcuts', 'hotkeys', 'quick', 'fast'], + relatedTopics: ['workspace-overview'], + }, + + { + id: 'performance-tips', + category: 'advanced', + title: 'Performance Tips', + description: 'Optimize Synapse performance', + content: `Tips for best performance: + +**Content Size:** +- Keep inputs under 1000 words for best speed +- Break large documents into smaller chunks +- Complex content takes longer to process + +**Browser Performance:** +- Close unused tabs to free memory +- Chrome AI models use CPU/GPU resources +- Background tasks may slow processing +- Restart browser if it becomes sluggish + +**Model Optimization:** +- Models run faster after first use (warming up) +- First transformation may be slower +- Subsequent operations are quicker + +**When to Use:** +- Best on desktop/laptop computers +- May be slower on older devices +- Check chrome://system for device capabilities`, + keywords: ['performance', 'speed', 'slow', 'fast', 'optimize', 'improve'], + relatedTopics: ['troubleshooting-errors'], + }, +] + +/** + * Search help topics by query + */ +export const searchHelpTopics = (query: string): HelpTopic[] => { + const lowerQuery = query.toLowerCase().trim() + + if (!lowerQuery) { + return helpTopics + } + + return helpTopics.filter((topic) => { + const searchableText = [ + topic.title, + topic.description, + topic.content, + ...topic.keywords, + topic.category, + ] + .join(' ') + .toLowerCase() + + return searchableText.includes(lowerQuery) + }) +} + +/** + * Get help topic by ID + */ +export const getHelpTopic = (id: string): HelpTopic | undefined => { + return helpTopics.find((topic) => topic.id === id) +} + +/** + * Get topics by category + */ +export const getTopicsByCategory = ( + category: HelpTopic['category'] +): HelpTopic[] => { + return helpTopics.filter((topic) => topic.category === category) +} + +/** + * Get related topics for a given topic + */ +export const getRelatedTopics = (topicId: string): HelpTopic[] => { + const topic = getHelpTopic(topicId) + + if (!topic || !topic.relatedTopics) { + return [] + } + + return topic.relatedTopics + .map((id) => getHelpTopic(id)) + .filter((t): t is HelpTopic => t !== undefined) +} diff --git a/src/pages/workspace/WorkspacePage.tsx b/src/pages/workspace/WorkspacePage.tsx index 9ce2726..f93230e 100644 --- a/src/pages/workspace/WorkspacePage.tsx +++ b/src/pages/workspace/WorkspacePage.tsx @@ -9,14 +9,20 @@ import { FormattedOutput } from '@/components/output' import { FormatQuickSelector } from '@/components/workspace/FormatQuickSelector' import { ProcessingHistoryPanel } from '@/components/history/ProcessingHistoryPanel' import { ComparisonView } from '@/components/content/ComparisonView' -import { EmptyState, ProcessingState, SuccessMessage, type SuccessMetric } from '@/components/ui' +import { EmptyState, ProcessingState, SuccessMessage, MultiFormatProgress, type SuccessMetric } from '@/components/ui' import { useFileUpload } from '@/hooks/useFileUpload' import { useContentExtraction } from '@/hooks/useContentExtraction' import { useProcessingHistory } from '@/hooks/useProcessingHistory' +import { useToast } from '@/hooks/useToast' import { loadPreferences, persistPreferences, PreferenceKey } from '@/lib/storage/preferences' -import { getFormatLabels } from '@/constants/formats' +import { FORMAT_OPTIONS, getFormatLabels } from '@/constants/formats' import type { FormatType } from '@/lib/chrome-ai/types' import type { ProcessingHistoryEntry, ProcessingHistorySource } from '@/lib/storage/history' +import type { + OperationProgress, + MultiFormatProgress as MultiFormatProgressType, + AggregateProgress, +} from '@/lib/chrome-ai/types/progress' type InputMode = 'text' | 'file' | 'url' | 'writing-tools' type ViewMode = 'output' | 'comparison' @@ -42,6 +48,12 @@ export const WorkspacePage = () => { useState('text') 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()) + const [aggregateProgress, setAggregateProgress] = useState(null) const formatReadingLevelLabel = (level?: string | null) => { if (!level) return 'Adaptive' @@ -87,6 +99,9 @@ export const WorkspacePage = () => { clearHistory: clearHistoryEntries, getExportPayload: getHistoryExportPayload, } = useProcessingHistory() + + const { showSuccess, showError } = useToast() + // Loaded but not yet used - will be used when language selector is added to workspace const [, setTargetLanguage] = useState('en') const streamingResultsRef = useRef>>({}) @@ -104,15 +119,30 @@ export const WorkspacePage = () => { onExtractionComplete: () => { // Combine extracted text from all files setExtractedText(fileUpload.getAllExtractedText()) + // Toast removed - inline status is sufficient }, onExtractionError: (error) => { console.error('File extraction error:', error) + showError('File extraction failed', { + message: error || 'Failed to extract text from file', + duration: 7000, + }) }, }) // URL extraction hook const { extract, content, error: extractionError, isLoading, clear } = useContentExtraction() + // Show error toast for URL extraction (success is shown inline) + useEffect(() => { + if (extractionError) { + showError('Extraction failed', { + message: extractionError.message || 'Failed to extract content from URL', + duration: 7000, + }) + } + }, [extractionError, showError]) + const selectInputMode = (mode: InputMode) => { if (mode === inputMode) { return @@ -146,14 +176,36 @@ export const WorkspacePage = () => { setStreamingResults({}) setCompletedFormats(new Set()) setIsStreaming(false) + setOperationProgress(null) + setMultiFormatProgressMap(new Map()) + setAggregateProgress(null) } + const handleOperationProgress = useCallback((progress: OperationProgress | null) => { + setOperationProgress(progress) + }, []) + + const handleMultiFormatProgress = useCallback( + ( + formatProgress: Map, + aggregate: AggregateProgress | null + ) => { + setMultiFormatProgressMap(formatProgress) + setAggregateProgress(aggregate) + }, + [] + ) + 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 @@ -170,16 +222,19 @@ export const WorkspacePage = () => { setOriginalText(original) setProcessedResults(null) setProcessingError(null) + setOperationProgress(null) + setMultiFormatProgressMap(new Map()) + setAggregateProgress(null) 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, } @@ -196,13 +251,29 @@ export const WorkspacePage = () => { setLastSuccessMeta(buildSuccessMetrics(original, result, processingStartedAt)) setProcessingStartedAt(null) + + // Show success toast + const formatLabel = FORMAT_OPTIONS[formats[0]]?.label || formats[0] + showSuccess('Transformation complete', { + message: `Successfully transformed to ${formatLabel}`, + duration: 4000, + }) } const handleMultiFormatComplete = ( 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) @@ -210,10 +281,13 @@ export const WorkspacePage = () => { streamingResultsRef.current = {} setCompletedFormats(new Set()) setOriginalText(original) + setOperationProgress(null) + 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 @@ -223,53 +297,44 @@ 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() + + // Show success toast for multi-format + const count = formats.length + showSuccess('All formats complete', { + message: `Successfully generated ${count} format${count > 1 ? 's' : ''}`, + duration: 4000, + }) } 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) => { @@ -283,6 +348,15 @@ export const WorkspacePage = () => { setLastSuccessMeta(null) streamingResultsRef.current = {} completedFormatsRef.current = new Set() + setOperationProgress(null) + setMultiFormatProgressMap(new Map()) + setAggregateProgress(null) + + // Show error toast + showError('Transformation failed', { + message: error.message || 'An error occurred during processing', + duration: 7000, + }) } const handleClearResults = () => { @@ -298,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 @@ -367,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) @@ -377,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) @@ -393,6 +540,80 @@ export const WorkspacePage = () => { clear() } + const handleUseExtractedURLContent = () => { + if (!content) return + + // 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 + selectInputMode('text') + setLastProcessingSource('url') + 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) @@ -569,26 +790,24 @@ export const WorkspacePage = () => { {stepItems.map((step, index) => (
{step.status === 'complete' ? ( -
+

{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 && ( + + )} +
+ )}
@@ -632,11 +938,10 @@ export const WorkspacePage = () => { setLastProcessingSource('text') }} aria-pressed={inputMode === 'text'} - className={`flex-1 min-w-[160px] rounded-lg px-4 py-2 text-sm font-semibold transition ${ - inputMode === 'text' - ? '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 === 'text' + ? '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' + }`} > @@ -663,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' + }`} > @@ -694,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' + }`} > @@ -725,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' + }`} > @@ -741,6 +1043,11 @@ export const WorkspacePage = () => {
+ {/* Format Selector - Always visible for all input modes */} +
+ +
+ {inputMode === 'text' ? ( { selectedFormats={selectedFormats} onInputStateChange={setHasTextInput} onProcessingStart={handleProcessingStart} + onOperationProgress={handleOperationProgress} + onMultiFormatProgress={handleMultiFormatProgress} /> ) : inputMode === 'file' ? (
@@ -779,37 +1088,15 @@ export const WorkspacePage = () => { />
- - - {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 +

+ )} +
+ + + )}
@@ -1018,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 @@ -1032,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 @@ -1049,22 +1396,37 @@ export const WorkspacePage = () => { {processingError ? ( ) : shouldShowProcessingState ? ( - 0 ? processingProgress : undefined} - showProgress={selectedFormats.length > 1} - > - {selectedFormats.length > 1 && ( -

- {completedFormats.size} of {selectedFormats.length} formats finished. -

+
+ 0 ? processingProgress : undefined} + showProgress={selectedFormats.length === 1} + operationProgress={selectedFormats.length === 1 ? operationProgress : null} + showStage={selectedFormats.length === 1} + showTimeRemaining={selectedFormats.length === 1} + > + {selectedFormats.length > 1 && ( +

+ {completedFormats.size} of {selectedFormats.length} formats finished. +

+ )} +
+ + {/* Show multi-format progress when processing multiple formats */} + {selectedFormats.length > 1 && multiFormatProgressMap.size > 0 && ( + )} - +
) : null} {!processingError && !shouldShowProcessingState && lastSuccessMeta && ( @@ -1079,18 +1441,38 @@ export const WorkspacePage = () => { /> )} - -
+ {(() => { + return null + })()} {hasStreamingContent ? ( [f, !completedFormats.has(f)]))} + isLoading={new Map( + selectedFormats.map((f) => { + const progress = multiFormatProgressMap.get(f) + const status = progress?.status + const isComplete = + completedFormats.has(f) || + status === 'complete' || + status === 'error' + + return [f, !isComplete] + }) + )} onClear={handleClearResults} + onEditOriginal={handleEditOriginal} + originalText={originalText} /> ) : processedResults ? ( - + ) : processedResult && viewMode === 'comparison' && originalText ? ( ) : processedResult ? ( @@ -1151,13 +1533,53 @@ export const WorkspacePage = () => {
- + {/* History Section with Toggle */} +
+
+
+

+ Processing History +

+ {historyEntries.length > 0 && ( + + {historyEntries.length} {historyEntries.length === 1 ? 'entry' : 'entries'} + + )} +
+ +
+ + {historyExpanded && ( + + )} +
{/* Info section */}
diff --git a/src/test-utils/render.tsx b/src/test-utils/render.tsx index fe58cd6..f9aaf91 100644 --- a/src/test-utils/render.tsx +++ b/src/test-utils/render.tsx @@ -2,11 +2,16 @@ import { render as rtlRender } from '@testing-library/react' import type { RenderOptions } from '@testing-library/react' import type { ReactElement } from 'react' import { BrowserRouter } from 'react-router-dom' +import { HelpPanelProvider } from '@/components/help/HelpPanelContext' // Custom render function that wraps components with necessary providers function render(ui: ReactElement, options?: Omit) { function Wrapper({ children }: { children: React.ReactNode }) { - return {children} + return ( + + {children} + + ) } return rtlRender(ui, { wrapper: Wrapper, ...options })