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 */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+ {selectedTopic ? (
+
+ ) : (
+
+ Help & Documentation
+
+ )}
+
+
+
+
+ {/* Content */}
+
+ {selectedTopic ? (
+ /* Topic Detail View */
+
+
+
+ {categoryLabels[selectedTopic.category]}
+
+
+
+
+ {selectedTopic.title}
+
+
+
+ {selectedTopic.description}
+
+
+
+
$1').replace(/`(.*?)`/g, '$1') }}
+ />
+
+
+ {/* Related Topics */}
+ {selectedTopic.relatedTopics && selectedTopic.relatedTopics.length > 0 && (
+
+
+ Related Topics
+
+
+ {getRelatedTopics(selectedTopic.id).map((relatedTopic) => (
+
+ ))}
+
+
+ )}
+
+ ) : (
+ /* Topics List View */
+
+ {/* Search */}
+
+
+
+
+
setSearchQuery(e.target.value)}
+ placeholder="Search help topics..."
+ className="w-full rounded-lg border border-neutral-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-synapse-primary focus:outline-none focus:ring-2 focus:ring-synapse-primary/20 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
+ />
+
+
+ {/* Category Filter */}
+
+
+ {Object.entries(categoryLabels).map(([category, label]) => (
+
+ ))}
+
+
+ {/* Topics List */}
+
+ {filteredTopics.length === 0 ? (
+
+ No help topics found for "{searchQuery}"
+
+ ) : (
+ filteredTopics.map((topic) => (
+
+ ))
+ )}
+
+
+ )}
+
+
+ >
+ )
+}
diff --git a/src/components/help/HelpPanelContext.tsx b/src/components/help/HelpPanelContext.tsx
new file mode 100644
index 0000000..51d4b77
--- /dev/null
+++ b/src/components/help/HelpPanelContext.tsx
@@ -0,0 +1,67 @@
+/**
+ * HelpPanelContext
+ *
+ * Context provider for sharing help panel state across the application.
+ * Allows any component to open the help panel from anywhere in the app.
+ */
+
+/* eslint-disable react-refresh/only-export-components */
+import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
+
+interface HelpPanelContextValue {
+ isOpen: boolean
+ topicId: string | undefined
+ openHelp: () => void
+ closeHelp: () => void
+ openHelpTopic: (topicId: string) => void
+}
+
+const HelpPanelContext = createContext
(undefined)
+
+interface HelpPanelProviderProps {
+ children: ReactNode
+}
+
+export const HelpPanelProvider = ({ children }: HelpPanelProviderProps) => {
+ 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)
+ }, [])
+
+ const value: HelpPanelContextValue = {
+ isOpen,
+ topicId,
+ openHelp,
+ closeHelp,
+ openHelpTopic,
+ }
+
+ return {children}
+}
+
+/**
+ * Hook to access the help panel context
+ */
+export const useHelpPanelContext = (): HelpPanelContextValue => {
+ const context = useContext(HelpPanelContext)
+
+ if (!context) {
+ throw new Error('useHelpPanelContext must be used within HelpPanelProvider')
+ }
+
+ return context
+}
diff --git a/src/components/input/TextInputPanel.tsx b/src/components/input/TextInputPanel.tsx
index b073e1c..58ed73b 100644
--- a/src/components/input/TextInputPanel.tsx
+++ b/src/components/input/TextInputPanel.tsx
@@ -9,6 +9,11 @@ import { ExampleContentMenu } from './ExampleContentMenu'
import { loadPreferences } from '@/lib/storage/preferences'
import type { ExampleContent } from '@/constants/exampleContent'
import type { FormatType } from '@/lib/chrome-ai/types'
+import type {
+ OperationProgress,
+ MultiFormatProgress,
+ AggregateProgress,
+} from '@/lib/chrome-ai/types/progress'
interface TextInputPanelProps {
onProcessingComplete?: (result: string, originalText: string) => void
@@ -22,6 +27,11 @@ interface TextInputPanelProps {
selectedFormats?: FormatType[]
onInputStateChange?: (hasText: boolean) => void
onProcessingStart?: () => void
+ onOperationProgress?: (progress: OperationProgress | null) => void
+ onMultiFormatProgress?: (
+ formatProgress: Map,
+ aggregateProgress: AggregateProgress | null
+ ) => void
}
export const TextInputPanel = ({
@@ -33,6 +43,8 @@ export const TextInputPanel = ({
selectedFormats: propSelectedFormats,
onInputStateChange,
onProcessingStart,
+ onOperationProgress,
+ onMultiFormatProgress,
}: TextInputPanelProps) => {
const textareaRef = useRef(null)
const [showClearConfirm, setShowClearConfirm] = useState(false)
@@ -90,6 +102,22 @@ export const TextInputPanel = ({
readingLevel: loadPreferences().readingLevel || undefined,
})
+ // Pass progress updates to parent
+ useEffect(() => {
+ onOperationProgress?.(formatTransform.operationProgress)
+ }, [formatTransform.operationProgress, onOperationProgress])
+
+ useEffect(() => {
+ onMultiFormatProgress?.(
+ formatTransform.multiFormatProgress,
+ formatTransform.aggregateProgress
+ )
+ }, [
+ formatTransform.multiFormatProgress,
+ formatTransform.aggregateProgress,
+ onMultiFormatProgress,
+ ])
+
// Handle paste event
useEffect(() => {
const textarea = textareaRef.current
@@ -210,8 +238,12 @@ export const TextInputPanel = ({
text,
selectedFormats,
(format, content, isComplete) => {
+ console.log('[TextInputPanel] Streaming callback:', format, 'isComplete:', isComplete, 'content length:', content.length)
if (isComplete) {
aggregatedResults[format] = content
+ console.log('[TextInputPanel] ✅ Format SAVED to aggregatedResults:', format, 'Content length:', content.length)
+ } else {
+ console.log('[TextInputPanel] ⏳ Format streaming (not saved yet):', format)
}
// Pass streaming updates to parent
onStreamingUpdate?.(format, content, isComplete)
@@ -222,6 +254,7 @@ export const TextInputPanel = ({
)
// Success
+ console.log('[TextInputPanel] All formats complete. Results:', Object.keys(aggregatedResults), 'Lengths:', Object.fromEntries(Object.entries(aggregatedResults).map(([k, v]) => [k, v.length])))
setStatus('success')
onMultiFormatComplete?.(aggregatedResults, originalTextSnapshot)
}
diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx
index f516a85..69d9125 100644
--- a/src/components/layout/AppHeader.tsx
+++ b/src/components/layout/AppHeader.tsx
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { NavLink } from 'react-router-dom'
import { Logo } from '@/components/common/Logo'
import { ThemeToggle } from '@/components/common/ThemeToggle'
+import { HelpButton } from '@/components/help/HelpButton'
+import { useHelpPanelContext } from '@/components/help/HelpPanelContext'
import { primaryNavigation, secondaryNavigation, type NavItem } from '@/config/navigation'
const desktopNavClassName = ({ isActive }: { isActive: boolean }) =>
@@ -54,6 +56,7 @@ const mobileNavLinkClass = ({ isActive }: { isActive: boolean }) =>
export const AppHeader = () => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
+ const { openHelp } = useHelpPanelContext()
const menuTriggerRef = useRef(null)
const mobileMenuRef = useRef(null)
@@ -232,6 +235,7 @@ export const AppHeader = () => {
+
{
+const AppLayoutContent = () => {
+ const { isOpen, topicId, closeHelp } = useHelpPanelContext()
+
return (
@@ -20,6 +24,17 @@ export const AppLayout = () => {
+
+ {/* Help Panel */}
+
)
}
+
+export const AppLayout = () => {
+ return (
+
+
+
+ )
+}
diff --git a/src/components/output/FormatSection.tsx b/src/components/output/FormatSection.tsx
index 0cbbd4b..4b8c87a 100644
--- a/src/components/output/FormatSection.tsx
+++ b/src/components/output/FormatSection.tsx
@@ -58,9 +58,13 @@ const FormatSectionComponent = ({
onToggle,
onCopy,
}: FormatSectionProps) => {
+ console.log(`[FormatSection ${format}] RENDER - content length: ${content?.length || 0}, isLoading: ${isLoading}, isExpanded: ${isExpanded}`)
+
const [copySuccess, setCopySuccess] = useState(false)
const formatConfig = useMemo(() => getFormatConfig(format), [format])
+ console.log(`[FormatSection ${format}] formatConfig:`, formatConfig ? 'found' : 'NOT FOUND')
+
const handleCopy = useCallback(async () => {
onCopy()
await navigator.clipboard.writeText(content)
@@ -106,7 +110,7 @@ const FormatSectionComponent = ({
{formatConfig.description}
- {/* Loading indicator or chevron */}
+ {/* Loading indicator, waiting indicator, or chevron */}
{isLoading ? (
Processing...
+ ) : !content ? (
+
) : (
)}
-
- {!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' ? (
@@ -602,10 +821,97 @@ export const WorkspacePage = () => {
step.id
)}
-
+
{step.title}
{step.description}
{step.helper}
+
+ {/* Actionable next step button */}
+ {step.status === 'current' && (
+
+ {step.id === 1 && (
+
+ )}
+ {step.id === 2 && !hasTextInput && (
+
+ ⚠️ Please add content in Step 1 first
+
+ )}
+ {step.id === 2 && hasTextInput && selectedFormats.length === 0 && (
+
+ )}
+ {step.id === 2 && hasTextInput && selectedFormats.length > 0 && (
+
+
+
+
+ Ready to process!
+
+ )}
+ {step.id === 3 && !hasTextInput && (
+
+ ⚠️ Complete Steps 1 & 2 first
+
+ )}
+ {step.id === 3 && hasTextInput && (
+
+ )}
+
+ )}
@@ -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 })