Surface render crashes and make chat layout viewport-safe (#61) (#63)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 11s
Deploy Beta / deploy-beta (push) Successful in 3m5s

## Summary
- Closes #61
- Replaces #62, which was opened from a branch that still carried the drawer commit already merged in #60 and therefore conflicted. Same change, cherry-picked cleanly onto current `master`.
- **Makes the mobile white screen diagnosable.** The app had **no error boundary anywhere**, so any throw in the dashboard unmounted the whole tree and left an empty `#root`. That matches the reported behavior exactly: brief flash, white page, other routes fine on a fresh load, blank again when navigating back to `/`. `AppErrorBoundary` renders the error name, message, stack, URL and user agent with Reload / Copy details buttons, so the real exception can be read and copied **from the phone**. It is keyed on the route path, so navigating away clears a crash.
- **Responsive layout fixes** for the clipping visible in mobile emulation: `100vw` → `100%`, `100dvh` for the chat shell so mobile URL bars can't push the input row off screen, `min-width: 0` / `flex-shrink` on the input row so attach, model select, textarea and send compress instead of overflowing, `env(safe-area-inset-*)` padding on header, drawer and input area, and message bubbles that wrap long tokens (`overflow-wrap: anywhere`, `max-width` on `pre`/`img`/`table`).
- **Perf:** particle canvas pauses while the tab is hidden, honors `prefers-reduced-motion`, caps particle count for the O(n²) link pass, and re-seeds only on real size changes. Chat auto-scroll now fires on message change rather than on every render.

## Test plan
- [x] `npm run test:ci` — 25 suites / 112 tests pass (adds `AppErrorBoundary` coverage for the pass-through and crash paths)
- [x] `npm run build` — clean
- [ ] Phone browser: sign in and land on `/` — chat renders, or the error card shows the real exception (paste into #61)
- [ ] Phone browser: no horizontal scroll; attach, model select, textarea and send all visible
- [ ] Phone browser: input row still reachable with the keyboard open
- [ ] Long code block / unbroken string stays inside the bubble
- [ ] Desktop: sidebar, chat and input unchanged; background animation still runs
- [ ] Background the tab and return — animation resumes

## Notes
The crash is not reproducible in desktop mobile emulation, so this ships the boundary as the diagnostic path instead of guessing at the throwing call site. Once the error card appears on the phone, the stack should point straight at the cause. Worth landing before the Capacitor Android wrap (#20), since the same failure mode is much harder to inspect inside a WebView.Reviewed-on: #63
This commit was merged in pull request #63.
This commit is contained in:
2026-07-29 05:43:16 -07:00
parent ea40700fd3
commit f22441371b
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) => {
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
@@ -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<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;
}