From cd1ba04e3014ed3b8098687070865199286589d7 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Wed, 29 Jul 2026 07:34:24 -0500 Subject: [PATCH] Surface render crashes and make chat layout viewport-safe (#61) The app had no error boundary, so any throw inside the dashboard unmounted the whole tree and left a blank white page with no way to read the cause on a phone. Add a route-scoped boundary that shows the failure details, and fix the chat shell to fit small viewports: dynamic viewport height, shrinkable input row, safe-area padding, and wrapping message bubbles. Also pause the particle canvas while the tab is hidden and cap its particle count so phones are not driving a needless animation loop. --- llm-fe/src/App.tsx | 3 + .../AppErrorBoundary.test.tsx | 47 +++++++ .../AppErrorBoundary/AppErrorBoundary.tsx | 133 ++++++++++++++++++ .../ConversationDetailCard.tsx | 14 ++ .../src/llm-fe/components/Header2/Header2.tsx | 18 ++- .../ParticleBackground/ParticleBackground.tsx | 67 +++++++-- .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 69 +++++++-- llm-fe/src/llm-fe/platform/nativeSafeArea.css | 10 ++ 8 files changed, 337 insertions(+), 24 deletions(-) create mode 100644 llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.test.tsx create mode 100644 llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.tsx diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index 884c2a5..7424497 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -23,6 +23,7 @@ import Tracker from './llm-fe/components/Tracker/Tracker'; import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext'; import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner'; import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession'; +import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary'; const ProtectedRoutes = () => { const { authenticated, loading } = useContext(AuthContext); @@ -53,6 +54,7 @@ class App extends Component {
+ } /> @@ -78,6 +80,7 @@ class App extends Component { +
diff --git a/llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.test.tsx b/llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.test.tsx new file mode 100644 index 0000000..b9ce551 --- /dev/null +++ b/llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import AppErrorBoundary from './AppErrorBoundary'; + +const Boom = (): JSX.Element => { + throw new Error('dashboard exploded'); +}; + +describe('AppErrorBoundary', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('renders children when nothing throws', () => { + render( + + +
chat loaded
+
+
, + ); + + expect(screen.getByText('chat loaded')).toBeInTheDocument(); + }); + + it('shows the failure details instead of a blank screen', () => { + render( + + + + + , + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/Something broke on this screen/i)).toBeInTheDocument(); + expect(screen.getByText(/dashboard exploded/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument(); + }); +}); diff --git a/llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.tsx b/llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.tsx new file mode 100644 index 0000000..6ae3679 --- /dev/null +++ b/llm-fe/src/llm-fe/components/AppErrorBoundary/AppErrorBoundary.tsx @@ -0,0 +1,133 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; + +type BoundaryProps = { + children: ReactNode; +}; + +type BoundaryState = { + error: Error | null; + componentStack: string | null; +}; + +/** + * Without a boundary any render/effect throw unmounts the whole tree, which reads + * as a blank white page on mobile with no way to see the cause on the device. + */ +class ErrorBoundaryView extends Component { + constructor(props: BoundaryProps) { + super(props); + this.state = { error: null, componentStack: null }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('App crashed', error, errorInfo.componentStack); + this.setState({ componentStack: errorInfo.componentStack ?? null }); + } + + private details(): string { + const { error, componentStack } = this.state; + return [ + `Page: ${window.location.href}`, + `Agent: ${navigator.userAgent}`, + `Error: ${error?.name ?? 'Error'}: ${error?.message ?? 'unknown'}`, + error?.stack ? `Stack:\n${error.stack}` : '', + componentStack ? `Components:${componentStack}` : '', + ] + .filter(Boolean) + .join('\n\n'); + } + + render(): ReactNode { + const { error } = this.state; + if (!error) { + return this.props.children; + } + + return ( +
+
+

Something broke on this screen

+

+ The rest of the app still works. Reload to try again, or copy the details below so the failure can be fixed. +

+ +
+ + +
+ +
+            {this.details()}
+          
+
+
+ ); + } +} + +/** Route change clears a previous crash so navigation is never permanently stuck. */ +const AppErrorBoundary = ({ children }: BoundaryProps): JSX.Element => { + const location = useLocation(); + return {children}; +}; + +export default AppErrorBoundary; diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index 2f14782..4da4092 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -17,6 +17,8 @@ const MessageContainer = styled.div<{ $isUser: boolean }>` const Bubble = styled.div<{ $isUser: boolean }>` max-width: 80%; + min-width: 0; + overflow-wrap: anywhere; padding: 1rem 1.5rem; border-radius: 1.2rem; background: ${(props) => @@ -39,6 +41,7 @@ const Bubble = styled.div<{ $isUser: boolean }>` padding: 1rem; border-radius: 0.5rem; overflow-x: auto; + max-width: 100%; } & code { @@ -50,6 +53,17 @@ const Bubble = styled.div<{ $isUser: boolean }>` color: #a0c4ff; text-decoration: underline; } + + & img, + & table { + max-width: 100%; + } + + @media (max-width: 768px) { + max-width: 92%; + padding: 0.75rem 1rem; + font-size: 0.95rem; + } `; const LoadingDot = styled.div` diff --git a/llm-fe/src/llm-fe/components/Header2/Header2.tsx b/llm-fe/src/llm-fe/components/Header2/Header2.tsx index 1b48b26..0bea3df 100644 --- a/llm-fe/src/llm-fe/components/Header2/Header2.tsx +++ b/llm-fe/src/llm-fe/components/Header2/Header2.tsx @@ -12,26 +12,36 @@ const HeaderContainer = styled.header` top: 0; left: 0; width: 100%; + box-sizing: border-box; padding: 1rem 2rem; + padding-top: calc(1rem + env(safe-area-inset-top, 0px)); display: flex; justify-content: space-between; align-items: center; + gap: 1rem; z-index: 100; background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'}; backdrop-filter: blur(5px); border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; + + @media (max-width: 768px) { + padding: 0.75rem 1rem; + padding-top: calc(0.75rem + env(safe-area-inset-top, 0px)); + } `; const Logo = styled.div` display: flex; align-items: center; gap: 0.65rem; + min-width: 0; cursor: pointer; `; const LogoMark = styled.img` width: 2rem; height: 2rem; + flex-shrink: 0; display: block; border-radius: 50%; `; @@ -39,7 +49,12 @@ const LogoMark = styled.img` const LogoWordmark = styled.h4` margin: 0; font-size: 1.5rem; + white-space: nowrap; font-weight: 700; + + @media (max-width: 768px) { + font-size: 1.25rem; + } color: ${({ theme }) => theme.colors.text}; background: ${({ theme }) => `linear-gradient(135deg, ${theme.main} 0%, ${theme.focus} 100%)`}; -webkit-background-clip: text; @@ -100,7 +115,8 @@ const MobileMenuDropdown = styled.div<{ isOpen: boolean }>` top: 100%; left: 0; width: 100%; - background: ${({ theme }) => theme.colors.cardBackground}; + box-sizing: border-box; + background: ${({ theme }) => theme.darkMode ? 'rgba(10, 12, 22, 0.97)' : 'rgba(255, 255, 255, 0.97)'}; backdrop-filter: blur(10px); border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder}; display: flex; diff --git a/llm-fe/src/llm-fe/components/ParticleBackground/ParticleBackground.tsx b/llm-fe/src/llm-fe/components/ParticleBackground/ParticleBackground.tsx index 6c2ba3e..e4e6922 100644 --- a/llm-fe/src/llm-fe/components/ParticleBackground/ParticleBackground.tsx +++ b/llm-fe/src/llm-fe/components/ParticleBackground/ParticleBackground.tsx @@ -24,16 +24,20 @@ const ParticleBackground = () => { if (!ctx) return; let particles: Particle[] = []; - let animationFrameId: number; + let animationFrameId: number | null = null; + const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; const resizeCanvas = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + const nextWidth = Math.max(1, window.innerWidth); + const nextHeight = Math.max(1, window.innerHeight); + if (canvas.width === nextWidth && canvas.height === nextHeight) { + return; + } + canvas.width = nextWidth; + canvas.height = nextHeight; + init(); }; - window.addEventListener('resize', resizeCanvas); - resizeCanvas(); - class Particle { x: number; y: number; @@ -73,18 +77,24 @@ const ParticleBackground = () => { const init = () => { particles = []; - const numberOfParticles = Math.floor((canvas.width * canvas.height) / 15000); + // Cap the count so the O(n^2) link pass stays cheap on phones. + const numberOfParticles = Math.min( + Math.floor((canvas.width * canvas.height) / 15000), + 90, + ); for (let i = 0; i < numberOfParticles; i++) { particles.push(new Particle()); } }; - const animate = () => { + const render = (advance: boolean) => { if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); particles.forEach((particle) => { - particle.update(); + if (advance) { + particle.update(); + } particle.draw(); }); @@ -111,16 +121,51 @@ const ParticleBackground = () => { } } } + }; + const animate = () => { + render(true); animationFrameId = requestAnimationFrame(animate); }; + const stop = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }; + + const start = () => { + if (animationFrameId !== null || prefersReducedMotion) { + return; + } + animate(); + }; + + // A backgrounded tab keeps burning battery otherwise, which matters most on phones. + const handleVisibilityChange = () => { + if (document.hidden) { + stop(); + } else { + start(); + } + }; + + resizeCanvas(); init(); - animate(); + if (prefersReducedMotion) { + render(false); + } else { + start(); + } + + window.addEventListener('resize', resizeCanvas); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('resize', resizeCanvas); - cancelAnimationFrame(animationFrameId); + document.removeEventListener('visibilitychange', handleVisibilityChange); + stop(); }; }, [theme]); // Re-run when theme changes diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index 7c845fe..1375940 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -21,8 +21,11 @@ import { AnalyticsEvents, trackEvent } from "../../utils/analytics"; // Styled Components const PageContainer = styled.div` position: relative; - width: 100vw; + width: 100%; + max-width: 100%; height: 100vh; + /* Mobile URL bars change the viewport height; dvh keeps the input row reachable. */ + height: 100dvh; overflow: hidden; display: flex; color: ${({ theme }) => theme.colors.text}; @@ -32,6 +35,8 @@ const PageContainer = styled.div` const Sidebar = styled.div<{ $isOpen: boolean }>` width: 280px; + flex-shrink: 0; + box-sizing: border-box; height: 100%; background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)'}; backdrop-filter: blur(10px); @@ -48,7 +53,9 @@ const Sidebar = styled.div<{ $isOpen: boolean }>` top: 0; left: 0; width: min(85vw, 320px); - height: 100vh; + height: 100dvh; + padding-top: calc(4.5rem + env(safe-area-inset-top, 0px)); + padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); transform: ${({ $isOpen }) => $isOpen ? 'translateX(0)' : 'translateX(-100%)'}; background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.95)' : 'rgba(255, 255, 255, 0.95)'}; box-shadow: ${({ $isOpen }) => $isOpen ? '0 0 20px rgba(0,0,0,0.5)' : 'none'}; @@ -59,7 +66,7 @@ const Sidebar = styled.div<{ $isOpen: boolean }>` const MobileSidebarToggle = styled.button` display: none; position: absolute; - top: 5.5rem; /* Below header */ + top: calc(4.75rem + env(safe-area-inset-top, 0px)); /* Below header */ left: 1rem; z-index: 15; padding: 0.5rem 1rem; @@ -87,10 +94,7 @@ const MobileSidebarToggle = styled.button` const Overlay = styled.div<{ $isOpen: boolean }>` display: none; position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; + inset: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(2px); z-index: 110; @@ -135,22 +139,35 @@ const MobileSidebarCloseButton = styled.button` const MainContent = styled.div` flex: 1; + min-width: 0; height: 100%; display: flex; flex-direction: column; position: relative; z-index: 5; - padding-top: 4rem; // Account for header + padding-top: calc(4rem + env(safe-area-inset-top, 0px)); // Account for header + + @media (max-width: 768px) { + padding-top: calc(3.75rem + env(safe-area-inset-top, 0px)); + } `; const ChatArea = styled.div` flex: 1; + min-height: 0; overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; padding: 2rem; display: flex; flex-direction: column; scroll-behavior: smooth; + @media (max-width: 768px) { + padding: 1rem 0.85rem; + } + &::-webkit-scrollbar { width: 8px; } @@ -164,14 +181,22 @@ const ChatArea = styled.div` `; const InputArea = styled.div` + flex-shrink: 0; + box-sizing: border-box; padding: 1.5rem 2rem; background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)'}; backdrop-filter: blur(5px); border-top: 1px solid ${({ theme }) => theme.colors.cardBorder}; + + @media (max-width: 768px) { + padding: 0.75rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom, 0px)); + } `; const StyledInputContainer = styled.div` position: relative; + box-sizing: border-box; + width: 100%; max-width: 900px; margin: 0 auto; background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'}; @@ -180,8 +205,14 @@ const StyledInputContainer = styled.div` padding: 0.5rem 1rem; display: flex; align-items: center; + gap: 0.25rem; transition: all 0.3s ease; + @media (max-width: 768px) { + padding: 0.35rem 0.5rem; + border-radius: 1.25rem; + } + &:focus-within { background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; border-color: ${({ theme }) => theme.main}; @@ -191,9 +222,11 @@ const StyledInputContainer = styled.div` const StyledInput = styled.textarea` flex: 1; + min-width: 0; background: transparent; border: none; color: ${({ theme }) => theme.colors.text}; + /* Below 16px iOS/Android browsers zoom the page on focus. */ font-size: 1rem; padding: 0.8rem; outline: none; @@ -211,6 +244,7 @@ const StyledInput = styled.textarea` const IconButton = styled.button` background: transparent; border: none; + flex-shrink: 0; color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}; cursor: pointer; padding: 0.5rem; @@ -234,6 +268,8 @@ const IconButton = styled.button` const StyledSelect = styled.select` background: transparent; border: none; + flex-shrink: 0; + max-width: 7rem; color: ${({ theme }) => theme.colors.text}; font-size: 0.9rem; padding: 0.5rem; @@ -241,6 +277,13 @@ const StyledSelect = styled.select` cursor: pointer; margin-right: 0.5rem; border-right: 1px solid ${({ theme }) => theme.colors.cardBorder}; + + @media (max-width: 768px) { + max-width: 5.5rem; + font-size: 0.8rem; + padding: 0.4rem 0.2rem; + margin-right: 0.25rem; + } option { background: ${({ theme }) => theme.colors.background || '#1a1a1a'}; @@ -320,9 +363,11 @@ type PromptValues = { modelName: string; }; -const AlwaysScrollToBottom = (): JSX.Element => { - const elementRef = useRef(); - useEffect(() => elementRef.current?.scrollIntoView({ behavior: "smooth" })); +const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => { + const elementRef = useRef(null); + useEffect(() => { + elementRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [trigger]); return
; }; @@ -500,7 +545,7 @@ const AsyncDashboardInner = (): JSX.Element => { Select a conversation or start a new one.
)} - + diff --git a/llm-fe/src/llm-fe/platform/nativeSafeArea.css b/llm-fe/src/llm-fe/platform/nativeSafeArea.css index cf3696b..2dbcf33 100644 --- a/llm-fe/src/llm-fe/platform/nativeSafeArea.css +++ b/llm-fe/src/llm-fe/platform/nativeSafeArea.css @@ -7,8 +7,18 @@ html { body { /* Keyboard plugin resizes body; keep root scrollable within inset. */ min-height: 100%; + /* Full-bleed page shells must never create a sideways scroll on phones. */ + max-width: 100%; + overflow-x: hidden; } #root { min-height: 100%; + max-width: 100%; +} + +*, +*::before, +*::after { + box-sizing: border-box; }