Surface render crashes and make chat layout viewport-safe (#61)
Unit Tests / test (pull_request) Successful in 12s

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.
This commit is contained in:
2026-07-29 07:37:01 -05:00
parent ea40700fd3
commit e4cfc18671
8 changed files with 337 additions and 24 deletions
+3
View File
@@ -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 {
<div className="main-container">
<AppErrorBoundary>
<Routes>
<Route path='*' element={<NotFound />} />
@@ -78,6 +80,7 @@ class App extends Component {
</Route>
</Routes>
</AppErrorBoundary>
</div>
@@ -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(
<MemoryRouter>
<AppErrorBoundary>
<div>chat loaded</div>
</AppErrorBoundary>
</MemoryRouter>,
);
expect(screen.getByText('chat loaded')).toBeInTheDocument();
});
it('shows the failure details instead of a blank screen', () => {
render(
<MemoryRouter>
<AppErrorBoundary>
<Boom />
</AppErrorBoundary>
</MemoryRouter>,
);
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();
});
});
@@ -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<BoundaryProps, BoundaryState> {
constructor(props: BoundaryProps) {
super(props);
this.state = { error: null, componentStack: null };
}
static getDerivedStateFromError(error: Error): Partial<BoundaryState> {
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 (
<div
role="alert"
style={{
minHeight: '100vh',
boxSizing: 'border-box',
padding: '1.5rem 1rem',
background: '#0f1220',
color: '#f5f6fa',
fontFamily: "'Inter', system-ui, sans-serif",
overflowWrap: 'anywhere',
}}
>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<h1 style={{ fontSize: '1.35rem', margin: '0 0 0.75rem' }}>Something broke on this screen</h1>
<p style={{ margin: '0 0 1rem', lineHeight: 1.5, color: 'rgba(245,246,250,0.75)' }}>
The rest of the app still works. Reload to try again, or copy the details below so the failure can be fixed.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1.25rem' }}>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '0.7rem 1.1rem',
borderRadius: '2rem',
border: 'none',
background: '#4c8dff',
color: '#fff',
fontWeight: 600,
cursor: 'pointer',
}}
>
Reload
</button>
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(this.details());
}}
style={{
padding: '0.7rem 1.1rem',
borderRadius: '2rem',
border: '1px solid rgba(245,246,250,0.3)',
background: 'transparent',
color: '#f5f6fa',
fontWeight: 600,
cursor: 'pointer',
}}
>
Copy details
</button>
</div>
<pre
style={{
whiteSpace: 'pre-wrap',
fontSize: '0.8rem',
lineHeight: 1.5,
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '0.75rem',
padding: '1rem',
margin: 0,
maxHeight: '50vh',
overflow: 'auto',
}}
>
{this.details()}
</pre>
</div>
</div>
);
}
}
/** Route change clears a previous crash so navigation is never permanently stuck. */
const AppErrorBoundary = ({ children }: BoundaryProps): JSX.Element => {
const location = useLocation();
return <ErrorBoundaryView key={location.pathname}>{children}</ErrorBoundaryView>;
};
export default AppErrorBoundary;
@@ -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`
@@ -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;
@@ -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) => {
if (advance) {
particle.update();
}
particle.draw();
});
@@ -111,16 +121,51 @@ const ParticleBackground = () => {
}
}
}
};
const animate = () => {
render(true);
animationFrameId = requestAnimationFrame(animate);
};
init();
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();
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
@@ -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;
@@ -242,6 +278,13 @@ const StyledSelect = styled.select`
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'};
color: ${({ theme }) => theme.colors.text};
@@ -320,9 +363,11 @@ type PromptValues = {
modelName: string;
};
const AlwaysScrollToBottom = (): JSX.Element => {
const elementRef = useRef<any>();
useEffect(() => elementRef.current?.scrollIntoView({ behavior: "smooth" }));
const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
elementRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [trigger]);
return <div ref={elementRef} />;
};
@@ -500,7 +545,7 @@ const AsyncDashboardInner = (): JSX.Element => {
<Markdown>Select a conversation or start a new one.</Markdown>
</div>
)}
<AlwaysScrollToBottom />
<AlwaysScrollToBottom trigger={`${conversationDetails.length}:${stateMessage.length}`} />
</ChatArea>
<InputArea>
@@ -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;
}