Skip to content
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -22,6 +24,8 @@ const App = () => {
return (
<AppErrorBoundary>
<RouterProvider router={appRouter} />
<ToastContainer />
<ErrorToastIntegration />
</AppErrorBoundary>
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/integration/component-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
8 changes: 4 additions & 4 deletions src/components/content/ExtractedContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,21 +288,21 @@ describe('ExtractedContent', () => {

it('should render process button when onProcessWithAI provided', () => {
render(<ExtractedContent content={mockContent} onProcessWithAI={mockOnProcessWithAI} />)
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(<ExtractedContent content={mockContent} />)
expect(
screen.queryByRole('button', { name: 'Process with Chrome AI' })
screen.queryByRole('button', { name: 'Transform with Chrome AI' })
).not.toBeInTheDocument()
})

it('should call onProcessWithAI when process button clicked', async () => {
const user = userEvent.setup()
render(<ExtractedContent content={mockContent} onProcessWithAI={mockOnProcessWithAI} />)

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)
})
})
Expand Down Expand Up @@ -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', () => {
Expand Down
87 changes: 76 additions & 11 deletions src/components/content/ExtractedContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -159,15 +174,65 @@ export const ExtractedContent = ({ content, onClear, onProcessWithAI }: Extracte
)}
</div>

{/* Action button */}
{onProcessWithAI && (
<button
type="button"
onClick={onProcessWithAI}
className="rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-soft transition hover:bg-primary-700"
>
Process with Chrome AI
</button>
{/* Action buttons */}
{(onProcessWithAI || onEditAsText) && (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
{onProcessWithAI && (
<button
type="button"
onClick={onProcessWithAI}
disabled={processDisabled}
className="flex items-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-soft transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-primary-600"
title={processDisabled ? processDisabledReason : 'Transform extracted content'}
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>Transform with Chrome AI</span>
</button>
)}
{onEditAsText && (
<button
type="button"
onClick={onEditAsText}
className="flex items-center gap-2 rounded-xl border border-primary-500 bg-white px-6 py-3 text-sm font-semibold text-primary-600 transition hover:border-primary-600 hover:bg-primary-50"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span>Edit as text</span>
</button>
)}
</div>
{processDisabled && processDisabledReason && (
<p className="text-xs text-amber-700 dark:text-amber-300">
⚠️ {processDisabledReason}
</p>
)}
</div>
)}
</div>
)
Expand Down
182 changes: 182 additions & 0 deletions src/components/errors/AIOperationErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<AIOperationErrorBoundaryState> {
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 (
<div className="flex min-h-[300px] items-center justify-center p-6">
<div className="max-w-md space-y-4 text-center">
<ErrorStateWithRetry
error={this.state.error}
onRetry={canRetry ? this.handleRetry : () => {}}
currentAttempt={this.state.retryCount}
maxAttempts={maxRetries}
title={`${operationName} Error`}
showErrorDetails={true}
/>
{!canRetry && (
<p className="text-sm text-gray-600">
Maximum retry attempts reached. {helpfulMessage}
</p>
)}
{this.props.onCancel && (
<button
type="button"
onClick={this.handleCancel}
className="rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-soft transition hover:bg-gray-50"
>
Cancel Operation
</button>
)}
</div>
</div>
)
}

return this.props.children
}
}
Loading