From 4a1cb6d64bd7e2a16bdb3441101044d8c08a20d5 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Thu, 2 Oct 2025 19:21:31 +0900 Subject: [PATCH] fix(frontend): performance and layout issues (#11036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes 🏗️ ### Performance (Onboarding) 🐎 - Moved non-UI logic into `providers/onboarding/helpers.ts` to reduce provider complexity. - Memoized provider value and narrowed state updates to cut unnecessary re-renders. - Deferred non-critical effects until after mount to lower initial JS work. **Result:** faster initial render and smoother onboarding flows under load. ### Layout and overflow fixes 📐 - Replaced `w-screen` with `w-full` in platform/admin/profile layouts and marketplace wrappers to avoid 100vw scrollbar overflow. - Adjusted mobile navbar position (`right-0` instead of `-right-4`) to prevent off-viewport elements. **Result:** removed horizontal scrolling on Marketplace, Library, and Settings pages; Build remains unaffected. ### New Generic Error pages - Standardized global error handling in `app/global-error.tsx` for consistent display and user feedback. - Added platform-scoped error page(s) under `app/(platform)/error` for route-level failures with a consistent layout. - Improved retry affordances using existing `ErrorCard`. ## Checklist 📋 ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verify onboarding flows render faster and re-render less (DevTools flamegraph) - [x] Confirm no horizontal scrolling on Marketplace, Library, Settings at common widths - [x] Validate mobile navbar stays within viewport - [x] Trigger errors to confirm global and platform error pages render consistently ### For configuration changes: None --- .../src/app/(platform)/admin/layout.tsx | 2 +- .../src/app/(platform)/auth/callback/route.ts | 88 ++++----- .../frontend/src/app/(platform)/error.tsx | 55 ++++++ .../src/app/(platform)/error/helpers.ts | 67 +++++++ .../src/app/(platform)/error/page.tsx | 63 ++++++ .../frontend/src/app/(platform)/layout.tsx | 2 +- .../src/app/(platform)/login/page.tsx | 2 +- .../components/AgentPageLoading.tsx | 2 +- .../components/CreatorPageLoading.tsx | 2 +- .../MainAgentPage/MainAgentPage.tsx | 6 +- .../MainCreatorPage/MainCreatorPage.tsx | 4 +- .../MainMarketplacePage.tsx | 2 +- .../components/MainMarketplacePageLoading.tsx | 2 +- .../app/(platform)/profile/(user)/layout.tsx | 2 +- .../frontend/src/app/global-error.tsx | 22 ++- .../src/components/__legacy__/Wallet.tsx | 52 ++++- .../src/components/atoms/Avatar/Avatar.tsx | 19 +- .../components/AccountMenu/AccountMenu.tsx | 2 +- .../layout/Navbar/components/LoginButton.tsx | 1 - .../layout/Navbar/components/NavbarView.tsx | 2 +- .../src/providers/onboarding/helpers.ts | 109 +++++++++++ .../onboarding/onboarding-provider.tsx | 183 ++++++------------ .../src/services/storage/local-storage.ts | 1 + .../frontend/src/tests/pages/login.page.ts | 27 +++ 24 files changed, 515 insertions(+), 202 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/error.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/error/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/error/page.tsx create mode 100644 autogpt_platform/frontend/src/providers/onboarding/helpers.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/admin/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/admin/layout.tsx index 55893c95e1..a91b2c1ef4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/admin/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/admin/layout.tsx @@ -31,7 +31,7 @@ export default function AdminLayout({ children: React.ReactNode; }) { return ( -
+
{children}
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts index 094f01e2fe..b026416ee9 100644 --- a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts +++ b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts @@ -11,56 +11,12 @@ async function shouldShowOnboarding() { ); } -// Default redirect path - matches the home page redirect destination -const DEFAULT_REDIRECT_PATH = "/marketplace"; - -// Validate redirect URL to prevent open redirect attacks and malformed URLs -function validateRedirectUrl(url: string): string { - try { - const cleanUrl = url.trim(); - - // Check for completely invalid patterns that suggest URL corruption - if ( - cleanUrl.includes(",") || // Any comma suggests concatenated URLs - cleanUrl.includes(" ") // Spaces in URLs are problematic - ) { - console.warn( - "Detected corrupted redirect URL (likely race condition):", - cleanUrl, - ); - return DEFAULT_REDIRECT_PATH; - } - - // Only allow relative URLs that start with / - if (!cleanUrl.startsWith("/") || cleanUrl.startsWith("//")) { - console.warn("Invalid redirect URL format:", cleanUrl); - return DEFAULT_REDIRECT_PATH; - } - - // Additional safety checks - if (cleanUrl.split("/").length > 5) { - // Reasonable path depth limit - console.warn("Suspiciously deep redirect URL:", cleanUrl); - return DEFAULT_REDIRECT_PATH; - } - - // For now, allow any valid relative path (can be restricted later if needed) - return cleanUrl; - } catch (error) { - console.error("Error validating redirect URL:", error); - return DEFAULT_REDIRECT_PATH; - } -} - // Handle the callback to complete the user session login export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); - // if "next" is in param, use it as the redirect URL - const nextParam = searchParams.get("next") ?? "/"; - // Validate redirect URL to prevent open redirect attacks - let next = validateRedirectUrl(nextParam); + let next = "/marketplace"; if (code) { const supabase = await getServerSupabase(); @@ -70,7 +26,7 @@ export async function GET(request: Request) { } const { error } = await supabase.auth.exchangeCodeForSession(code); - // data.session?.refresh_token is available if you need to store it for later use + if (!error) { try { const api = new BackendAPI(); @@ -84,7 +40,45 @@ export async function GET(request: Request) { } } catch (createUserError) { console.error("Error creating user:", createUserError); - // Continue with redirect even if createUser fails + + // Handle ApiError from the backend API client + if ( + createUserError && + typeof createUserError === "object" && + "status" in createUserError + ) { + const apiError = createUserError as any; + + if (apiError.status === 401) { + // Authentication issues - token missing/invalid + return NextResponse.redirect( + `${origin}/error?message=auth-token-invalid`, + ); + } else if (apiError.status >= 500) { + // Server/database errors + return NextResponse.redirect( + `${origin}/error?message=server-error`, + ); + } else if (apiError.status === 429) { + // Rate limiting + return NextResponse.redirect( + `${origin}/error?message=rate-limited`, + ); + } + } + + // Handle network/fetch errors + if ( + createUserError instanceof TypeError && + createUserError.message.includes("fetch") + ) { + return NextResponse.redirect(`${origin}/error?message=network-error`); + } + + // Generic user creation failure + return NextResponse.redirect( + `${origin}/error?message=user-creation-failed`, + ); } const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer diff --git a/autogpt_platform/frontend/src/app/(platform)/error.tsx b/autogpt_platform/frontend/src/app/(platform)/error.tsx new file mode 100644 index 0000000000..12ff33f838 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/error.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; +import { getErrorDetails } from "./error/helpers"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; + +function ErrorPageContent() { + const searchParams = useSearchParams(); + const errorMessage = searchParams.get("message"); + + const errorDetails = getErrorDetails(errorMessage); + + function handleRetry() { + if ( + errorMessage === "user-creation-failed" || + errorMessage === "auth-failed" + ) { + window.location.href = "/login"; + } else { + window.location.href = "/marketplace"; + } + } + + return ( +
+
+ +
+
+ ); +} + +export default function ErrorPage() { + return ( + +
+ +
+
+ } + > + + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/error/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/error/helpers.ts new file mode 100644 index 0000000000..11f4214df6 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/error/helpers.ts @@ -0,0 +1,67 @@ +export function getErrorDetails(errorType: string | null) { + switch (errorType) { + case "user-creation-failed": + return { + responseError: { + message: + "Failed to create your user account in our system. This could be due to a temporary server issue or a problem with your account setup.", + }, + context: "user account creation", + }; + case "auth-token-invalid": + return { + responseError: { + message: + "Your authentication token is missing or invalid. Please try signing in again.", + }, + context: "authentication token", + }; + case "server-error": + return { + responseError: { + message: + "Our servers are experiencing issues. Please try again in a few minutes, or contact support if the problem persists.", + }, + context: "server error", + }; + case "rate-limited": + return { + responseError: { + message: + "Too many requests have been made. Please wait a moment before trying again.", + }, + context: "rate limiting", + }; + case "network-error": + return { + responseError: { + message: + "Unable to connect to our servers. Please check your internet connection and try again.", + }, + context: "network connectivity", + }; + case "auth-failed": + return { + responseError: { + message: "Authentication failed. Please try signing in again.", + }, + context: "authentication", + }; + case "session-expired": + return { + responseError: { + message: + "Your session has expired. Please sign in again to continue.", + }, + context: "session", + }; + default: + return { + responseError: { + message: + "An unexpected error occurred. Please try again or contact support if the problem persists.", + }, + context: "application", + }; + } +} diff --git a/autogpt_platform/frontend/src/app/(platform)/error/page.tsx b/autogpt_platform/frontend/src/app/(platform)/error/page.tsx new file mode 100644 index 0000000000..b7858787cf --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/error/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { getErrorDetails } from "./helpers"; + +function ErrorPageContent() { + const searchParams = useSearchParams(); + const errorMessage = searchParams.get("message"); + const errorDetails = getErrorDetails(errorMessage); + + function handleRetry() { + // Auth-related errors should redirect to login + if ( + errorMessage === "user-creation-failed" || + errorMessage === "auth-failed" || + errorMessage === "auth-token-invalid" || + errorMessage === "session-expired" + ) { + window.location.href = "/login"; + } else if (errorMessage === "rate-limited") { + // For rate limiting, wait a moment then try again + setTimeout(() => { + window.location.reload(); + }, 2000); + } else { + // For server/network errors, go to marketplace + window.location.href = "/marketplace"; + } + } + + return ( +
+
+ +
+
+ ); +} + +export default function ErrorPage() { + return ( + +
+ +
+ + } + > + +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/layout.tsx index 9a18e72beb..36ae5f56ef 100644 --- a/autogpt_platform/frontend/src/app/(platform)/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/layout.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; export default function PlatformLayout({ children }: { children: ReactNode }) { return ( -
+
{children}
diff --git a/autogpt_platform/frontend/src/app/(platform)/login/page.tsx b/autogpt_platform/frontend/src/app/(platform)/login/page.tsx index eaa5d2dd50..4770970475 100644 --- a/autogpt_platform/frontend/src/app/(platform)/login/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/login/page.tsx @@ -85,7 +85,7 @@ export default function LoginPage() { /> {/* Turnstile CAPTCHA Component */} - {isCloudEnv && !turnstile.verified ? ( + {turnstile.shouldRender ? ( { return ( -
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/CreatorPageLoading.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/CreatorPageLoading.tsx index cb700b55d5..ee972cbdfc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/CreatorPageLoading.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/CreatorPageLoading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton"; export const CreatorPageLoading = () => { return ( -
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/MainAgentPage.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/MainAgentPage.tsx index d48b18105d..b6090b79a0 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/MainAgentPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainAgentPage/MainAgentPage.tsx @@ -30,7 +30,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => { } if (hasError) { return ( -
+
{ if (!agent) { return ( -
+
{ ]; return ( -
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainCreatorPage/MainCreatorPage.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainCreatorPage/MainCreatorPage.tsx index b53e8c304d..5d90c380e7 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainCreatorPage/MainCreatorPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainCreatorPage/MainCreatorPage.tsx @@ -23,7 +23,7 @@ export const MainCreatorPage = ({ params }: MainCreatorPageProps) => { if (hasError) { return ( -
+
{ if (creator) return ( -
+
{ return ( // FRONTEND-TODO : Need better state location, need to fetch creators and agents in their respective file, Can't do it right now because these files are used in some other pages of marketplace, will fix it when encounter with those pages -
+
{featuredAgents && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePageLoading.tsx b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePageLoading.tsx index 4e296721ba..d78edb0105 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePageLoading.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/MainMarketplacePageLoading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton"; export const MainMarketplacePageLoading = () => { return ( -
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx index bd8a0a0dd8..7bd3e5db4a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx @@ -52,7 +52,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { ]; return ( -
+
{children}
diff --git a/autogpt_platform/frontend/src/app/global-error.tsx b/autogpt_platform/frontend/src/app/global-error.tsx index 9388e06e02..07b22f1c15 100644 --- a/autogpt_platform/frontend/src/app/global-error.tsx +++ b/autogpt_platform/frontend/src/app/global-error.tsx @@ -1,13 +1,15 @@ "use client"; import * as Sentry from "@sentry/nextjs"; -import NextError from "next/error"; +import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard"; import { useEffect } from "react"; export default function GlobalError({ error, + reset, }: { error: Error & { digest?: string }; + reset: () => void; }) { useEffect(() => { Sentry.captureException(error); @@ -16,11 +18,19 @@ export default function GlobalError({ return ( - {/* `NextError` is the default Next.js error page component. Its type - definition requires a `statusCode` prop. However, since the App Router - does not expose status codes for errors, we simply pass 0 to render a - generic error message. */} - +
+
+ +
+
); diff --git a/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx b/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx index 5e8af8e23e..eff78ce8d9 100644 --- a/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx +++ b/autogpt_platform/frontend/src/components/__legacy__/Wallet.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; import * as party from "party-js"; import WalletRefill from "./WalletRefill"; import { OnboardingStep } from "@/lib/autogpt-server-api"; +import { storage, Key as StorageKey } from "@/services/storage/local-storage"; export interface Task { id: OnboardingStep; @@ -164,7 +165,8 @@ export default function Wallet() { const [prevCredits, setPrevCredits] = useState(credits); const [flash, setFlash] = useState(false); - const [walletOpen, setWalletOpen] = useState(state?.walletShown || false); + const [walletOpen, setWalletOpen] = useState(false); + const [lastSeenCredits, setLastSeenCredits] = useState(null); const totalCount = useMemo(() => { return groups.reduce((acc, group) => acc + group.tasks.length, 0); @@ -193,6 +195,38 @@ export default function Wallet() { setCompletedCount(completed); }, [groups, state?.completedSteps]); + // Load last seen credits from localStorage once on mount + useEffect(() => { + const stored = storage.get(StorageKey.WALLET_LAST_SEEN_CREDITS); + if (stored !== undefined && stored !== null) { + const parsed = parseFloat(stored); + if (!Number.isNaN(parsed)) setLastSeenCredits(parsed); + else setLastSeenCredits(0); + } else { + setLastSeenCredits(0); + } + }, []); + + // Auto-open once if never shown, otherwise open only when credits increase beyond last seen + useEffect(() => { + if (typeof credits !== "number") return; + // Open once for first-time users + if (state && state.walletShown === false) { + setWalletOpen(true); + // Mark as shown so it won't reopen on every reload + updateState({ walletShown: true }); + return; + } + // Open if user gained more credits than last acknowledged + if ( + lastSeenCredits !== null && + credits > lastSeenCredits && + walletOpen === false + ) { + setWalletOpen(true); + } + }, [credits, lastSeenCredits, state?.walletShown, updateState, walletOpen]); + const onWalletOpen = useCallback(async () => { if (!state?.walletShown) { updateState({ walletShown: true }); @@ -270,7 +304,19 @@ export default function Wallet() { }, [credits, prevCredits]); return ( - + { + setWalletOpen(open); + if (!open) { + // Persist the latest acknowledged credits so we only auto-open on future gains + if (typeof credits === "number") { + storage.set(StorageKey.WALLET_LAST_SEEN_CREDITS, String(credits)); + setLastSeenCredits(credits); + } + } + }} + >
- +
diff --git a/autogpt_platform/frontend/src/components/atoms/Avatar/Avatar.tsx b/autogpt_platform/frontend/src/components/atoms/Avatar/Avatar.tsx index cf27f1c6ec..78318dca8e 100644 --- a/autogpt_platform/frontend/src/components/atoms/Avatar/Avatar.tsx +++ b/autogpt_platform/frontend/src/components/atoms/Avatar/Avatar.tsx @@ -10,6 +10,7 @@ import React, { import BoringAvatar from "boring-avatars"; import Image, { ImageProps } from "next/image"; +import { cn } from "@/lib/utils"; type AvatarContextValue = { isLoaded: boolean; @@ -44,10 +45,10 @@ export function Avatar({ return (
{children} @@ -126,7 +127,7 @@ export function AvatarImage({ {alt - +