diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx index dea0c53b..da3d4f12 100644 --- a/frontend/src/components/common/ErrorBoundary.tsx +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -70,7 +70,14 @@ export function ErrorBoundary({ children, fallback }: Props) { if (fallback) return fallback as React.ReactElement return }} - showDialog + beforeCapture={(scope, error) => { + // Don't report chunk load errors to Sentry — they're deploy artifacts, not bugs + if (error && isChunkLoadError(error as Error)) { + scope.setLevel('info') + scope.setTag('chunk_load_error', 'true') + } + }} + showDialog={false} > {children} diff --git a/frontend/src/components/dashboard/StartSessionInput.tsx b/frontend/src/components/dashboard/StartSessionInput.tsx index dbb04622..c306073d 100644 --- a/frontend/src/components/dashboard/StartSessionInput.tsx +++ b/frontend/src/components/dashboard/StartSessionInput.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import { Send, Paperclip, Terminal, Loader2, X, RotateCcw, ImagePlus } from 'lucide-react' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' +import { toast } from '@/lib/toast' import type { PendingUpload } from '@/types/upload' const SUGGESTIONS = [ @@ -44,6 +45,12 @@ export function StartSessionInput() { if (logContent.trim()) { state.logs = logContent.trim() } + const completedUploadIds = pendingUploads + .filter((u) => u.status === 'done' && u.result?.id) + .map((u) => u.result!.id) + if (completedUploadIds.length > 0) { + state.uploadIds = completedUploadIds + } navigate('/assistant', { state }) } @@ -81,12 +88,16 @@ export function StartSessionInput() { ) }) .catch((err) => { - const errorMsg = err?.response?.status === 503 + const is503 = err?.response?.status === 503 + const errorMsg = is503 ? 'File uploads not available' : err?.message || 'Upload failed' - setPendingUploads((prev) => - prev.map((u) => u.id === upload.id ? { ...u, status: 'error' as const, error: errorMsg } : u) - ) + if (is503) { + toast.warning('Image attachments are not available yet — describe the issue in text instead') + } else { + toast.error(`Upload failed: ${errorMsg}`) + } + setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) }) }) }, []) diff --git a/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx b/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx index a30bf833..4532a3b4 100644 --- a/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx +++ b/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { Send, Paperclip, Terminal, Loader2, X, RotateCcw, ImagePlus } from 'lucide-react' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' +import { toast } from '@/lib/toast' import type { StepResponseRequest } from '@/types/ai-session' import type { PendingUpload } from '@/types/upload' @@ -81,12 +82,13 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing ) }) .catch((err) => { - const errorMsg = err?.response?.status === 503 - ? 'File uploads not available' - : err?.message || 'Upload failed' - setPendingUploads((prev) => - prev.map((u) => u.id === upload.id ? { ...u, status: 'error' as const, error: errorMsg } : u) - ) + const is503 = err?.response?.status === 503 + if (is503) { + toast.warning('Image attachments are not available yet — describe the issue in text instead') + } else { + toast.error(`Upload failed: ${err?.message || 'Unknown error'}`) + } + setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) }) }) }, []) diff --git a/frontend/src/components/tree-editor/TreeEditorLayout.tsx b/frontend/src/components/tree-editor/TreeEditorLayout.tsx index c2e81d7b..f4baf96c 100644 --- a/frontend/src/components/tree-editor/TreeEditorLayout.tsx +++ b/frontend/src/components/tree-editor/TreeEditorLayout.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazyWithRetry' import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel' import { FlowCanvas } from './FlowCanvas' import { NodeEditorPanel } from './NodeEditorPanel' @@ -8,7 +9,7 @@ import { cn } from '@/lib/utils' import { Spinner } from '@/components/common/Spinner' // Lazy load CodeModeEditor (Monaco is ~2MB) -const CodeModeEditor = lazy(() => +const CodeModeEditor = lazyWithRetry(() => import('./code-mode/CodeModeEditor').then(m => ({ default: m.CodeModeEditor })) ) diff --git a/frontend/src/lib/lazyWithRetry.ts b/frontend/src/lib/lazyWithRetry.ts new file mode 100644 index 00000000..037209b7 --- /dev/null +++ b/frontend/src/lib/lazyWithRetry.ts @@ -0,0 +1,34 @@ +import { lazy } from 'react' + +/** + * Wraps React.lazy with retry logic for stale chunk errors after deployments. + * On first failure, reloads the page once to get fresh asset manifest. + */ +export function lazyWithRetry>( + importFn: () => Promise<{ default: T }>, +) { + return lazy(() => + importFn().catch((error: Error) => { + const isChunkError = + error.message.includes('dynamically imported module') || + error.message.includes('Loading chunk') || + error.message.includes('importing a module script failed') || + error.message.includes('loading css chunk') + + if (!isChunkError) throw error + + // Only auto-reload once per 10 seconds to prevent loops + const key = 'rf_lazy_chunk_reload' + const lastReload = sessionStorage.getItem(key) + const now = Date.now() + + if (!lastReload || now - Number(lastReload) > 10_000) { + sessionStorage.setItem(key, String(now)) + window.location.reload() + } + + // If we already reloaded recently and it still fails, let it propagate + throw error + }), + ) +} diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index a1f69b3c..cb0a7723 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -305,8 +305,13 @@ export default function AssistantChatPage() { setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u)) }) .catch((err) => { - const errorMsg = err?.response?.status === 503 ? 'File uploads not available' : err?.message || 'Upload failed' - setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'error' as const, error: errorMsg } : u)) + const is503 = err?.response?.status === 503 + if (is503) { + toast.warning('Image attachments are not available yet — describe the issue in text instead') + } else { + toast.error(`Upload failed: ${err?.message || 'Unknown error'}`) + } + setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) }) }) }, []) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 22ef345d..57d14ee4 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,10 +1,11 @@ import { createBrowserRouter } from 'react-router-dom' import * as Sentry from '@sentry/react' -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' import { AppLayout, ProtectedRoute } from '@/components/layout' import { RouteError } from '@/components/common/RouteError' import { ErrorBoundary } from '@/components/common/ErrorBoundary' import { PageLoader } from '@/components/common/PageLoader' +import { lazyWithRetry } from '@/lib/lazyWithRetry' const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter) import { @@ -13,73 +14,73 @@ import { } from '@/pages' // Public pages -const LandingPage = lazy(() => import('@/pages/LandingPage')) -const PublicTemplatesPage = lazy(() => import('@/pages/PublicTemplatesPage')) -const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage')) -const SurveyPage = lazy(() => import('@/pages/SurveyPage')) -const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage')) -const PrivacyPage = lazy(() => import('@/pages/PrivacyPage')) -const TermsPage = lazy(() => import('@/pages/TermsPage')) +const LandingPage = lazyWithRetry(() => import('@/pages/LandingPage')) +const PublicTemplatesPage = lazyWithRetry(() => import('@/pages/PublicTemplatesPage')) +const SharedSessionPage = lazyWithRetry(() => import('@/pages/SharedSessionPage')) +const SurveyPage = lazyWithRetry(() => import('@/pages/SurveyPage')) +const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPage')) +const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage')) +const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage')) // Standalone auth pages -const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage')) -const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage')) -const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')) -const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')) +const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) +const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage')) +const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage')) +const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage')) // Lazy load heavy pages for code splitting -const QuickStartPage = lazy(() => import('@/pages/QuickStartPage')) -const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage')) -const MyTreesPage = lazy(() => import('@/pages/MyTreesPage')) -const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage')) -const TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage')) -const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage')) -const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage')) -const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage')) -const BatchStatusPage = lazy(() => import('@/pages/BatchStatusPage')) -const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage')) -const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage')) -const MySharesPage = lazy(() => import('@/pages/MySharesPage')) -const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage')) -const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage')) -const FeedbackPage = lazy(() => import('@/pages/FeedbackPage')) -const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage')) -const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage')) -const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage')) -const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage')) -const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage')) -const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage')) -const EscalationQueuePage = lazy(() => import('@/pages/EscalationQueuePage')) -const ReviewQueuePage = lazy(() => import('@/pages/ReviewQueuePage')) -const FlowPilotAnalyticsPage = lazy(() => import('@/pages/FlowPilotAnalyticsPage')) -const ScriptBuilderPage = lazy(() => import('@/pages/ScriptBuilderPage')) -const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage')) -const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage')) -const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage')) -const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage')) +const QuickStartPage = lazyWithRetry(() => import('@/pages/QuickStartPage')) +const TreeLibraryPage = lazyWithRetry(() => import('@/pages/TreeLibraryPage')) +const MyTreesPage = lazyWithRetry(() => import('@/pages/MyTreesPage')) +const TreeNavigationPage = lazyWithRetry(() => import('@/pages/TreeNavigationPage')) +const TreeEditorPage = lazyWithRetry(() => import('@/pages/TreeEditorPage')) +const ProceduralEditorPage = lazyWithRetry(() => import('@/pages/ProceduralEditorPage')) +const ProceduralNavigationPage = lazyWithRetry(() => import('@/pages/ProceduralNavigationPage')) +const MaintenanceFlowDetailPage = lazyWithRetry(() => import('@/pages/MaintenanceFlowDetailPage')) +const BatchStatusPage = lazyWithRetry(() => import('@/pages/BatchStatusPage')) +const SessionHistoryPage = lazyWithRetry(() => import('@/pages/SessionHistoryPage')) +const SessionDetailPage = lazyWithRetry(() => import('@/pages/SessionDetailPage')) +const MySharesPage = lazyWithRetry(() => import('@/pages/MySharesPage')) +const TeamAnalyticsPage = lazyWithRetry(() => import('@/pages/TeamAnalyticsPage')) +const MyAnalyticsPage = lazyWithRetry(() => import('@/pages/MyAnalyticsPage')) +const FeedbackPage = lazyWithRetry(() => import('@/pages/FeedbackPage')) +const StepLibraryPage = lazyWithRetry(() => import('@/pages/StepLibraryPage')) +const ScriptLibraryPage = lazyWithRetry(() => import('@/pages/ScriptLibraryPage')) +const ScriptManagePage = lazyWithRetry(() => import('@/pages/ScriptManagePage')) +const AssistantChatPage = lazyWithRetry(() => import('@/pages/AssistantChatPage')) +const FlowAssistPage = lazyWithRetry(() => import('@/pages/FlowAssistPage')) +const FlowPilotSessionPage = lazyWithRetry(() => import('@/pages/FlowPilotSessionPage')) +const EscalationQueuePage = lazyWithRetry(() => import('@/pages/EscalationQueuePage')) +const ReviewQueuePage = lazyWithRetry(() => import('@/pages/ReviewQueuePage')) +const FlowPilotAnalyticsPage = lazyWithRetry(() => import('@/pages/FlowPilotAnalyticsPage')) +const ScriptBuilderPage = lazyWithRetry(() => import('@/pages/ScriptBuilderPage')) +const KBAcceleratorPage = lazyWithRetry(() => import('@/pages/KBAcceleratorPage')) +const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage')) +const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage')) +const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage')) // Admin pages -const AdminLayout = lazy(() => import('@/components/admin/AdminLayout')) -const AdminDashboardPage = lazy(() => import('@/pages/admin/DashboardPage')) -const AdminUsersPage = lazy(() => import('@/pages/admin/UsersPage')) -const AdminUserDetailPage = lazy(() => import('@/pages/admin/UserDetailPage')) -const AdminInviteCodesPage = lazy(() => import('@/pages/admin/InviteCodesPage')) -const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AuditLogsPage')) -const AdminPlanLimitsPage = lazy(() => import('@/pages/admin/PlanLimitsPage')) -const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage')) -const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage')) -const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage')) -const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage')) -const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage')) -const AdminGalleryManagementPage = lazy(() => import('@/pages/admin/GalleryManagementPage')) +const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout')) +const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage')) +const AdminUsersPage = lazyWithRetry(() => import('@/pages/admin/UsersPage')) +const AdminUserDetailPage = lazyWithRetry(() => import('@/pages/admin/UserDetailPage')) +const AdminInviteCodesPage = lazyWithRetry(() => import('@/pages/admin/InviteCodesPage')) +const AdminAuditLogsPage = lazyWithRetry(() => import('@/pages/admin/AuditLogsPage')) +const AdminPlanLimitsPage = lazyWithRetry(() => import('@/pages/admin/PlanLimitsPage')) +const AdminFeatureFlagsPage = lazyWithRetry(() => import('@/pages/admin/FeatureFlagsPage')) +const AdminSettingsPage = lazyWithRetry(() => import('@/pages/admin/SettingsPage')) +const AdminGlobalCategoriesPage = lazyWithRetry(() => import('@/pages/admin/GlobalCategoriesPage')) +const AdminSurveyInvitesPage = lazyWithRetry(() => import('@/pages/admin/SurveyInvitesPage')) +const AdminSurveyResponsesPage = lazyWithRetry(() => import('@/pages/admin/SurveyResponsesPage')) +const AdminGalleryManagementPage = lazyWithRetry(() => import('@/pages/admin/GalleryManagementPage')) // Account pages -const AccountLayout = lazy(() => import('@/components/account/AccountLayout')) -const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPage')) -const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage')) -const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage')) -const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage')) -const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage')) -const BrandingSettingsPage = lazy(() => import('@/pages/account/BrandingSettingsPage')) +const AccountLayout = lazyWithRetry(() => import('@/components/account/AccountLayout')) +const ProfileSettingsPage = lazyWithRetry(() => import('@/pages/account/ProfileSettingsPage')) +const TeamCategoriesPage = lazyWithRetry(() => import('@/pages/account/TeamCategoriesPage')) +const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsPage')) +const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage')) +const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage')) +const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage')) /** Wraps a lazy-loaded page with Suspense + ErrorBoundary */ function page(Component: React.LazyExoticComponent) {