Surface render crashes and make chat layout viewport-safe (#59)
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:
@@ -23,6 +23,7 @@ import Tracker from './llm-fe/components/Tracker/Tracker';
|
|||||||
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
|
import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentContext';
|
||||||
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
|
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
|
||||||
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
|
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
|
||||||
|
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
|
||||||
|
|
||||||
const ProtectedRoutes = () => {
|
const ProtectedRoutes = () => {
|
||||||
const { authenticated, loading } = useContext(AuthContext);
|
const { authenticated, loading } = useContext(AuthContext);
|
||||||
@@ -53,6 +54,7 @@ class App extends Component {
|
|||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
|
|
||||||
|
|
||||||
|
<AppErrorBoundary>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path='*' element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ class App extends Component {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</AppErrorBoundary>
|
||||||
|
|
||||||
</div>
|
</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 }>`
|
const Bubble = styled.div<{ $isUser: boolean }>`
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
border-radius: 1.2rem;
|
border-radius: 1.2rem;
|
||||||
background: ${(props) =>
|
background: ${(props) =>
|
||||||
@@ -39,6 +41,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
& code {
|
& code {
|
||||||
@@ -50,6 +53,17 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
|||||||
color: #a0c4ff;
|
color: #a0c4ff;
|
||||||
text-decoration: underline;
|
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`
|
const LoadingDot = styled.div`
|
||||||
|
|||||||
@@ -12,26 +12,36 @@ const HeaderContainer = styled.header`
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
|
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'};
|
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'};
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
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`
|
const Logo = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
|
min-width: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LogoMark = styled.img`
|
const LogoMark = styled.img`
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
`;
|
`;
|
||||||
@@ -39,7 +49,12 @@ const LogoMark = styled.img`
|
|||||||
const LogoWordmark = styled.h4`
|
const LogoWordmark = styled.h4`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
background: ${({ theme }) => `linear-gradient(135deg, ${theme.main} 0%, ${theme.focus} 100%)`};
|
background: ${({ theme }) => `linear-gradient(135deg, ${theme.main} 0%, ${theme.focus} 100%)`};
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
@@ -100,7 +115,8 @@ const MobileMenuDropdown = styled.div<{ isOpen: boolean }>`
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
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);
|
backdrop-filter: blur(10px);
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -24,16 +24,20 @@ const ParticleBackground = () => {
|
|||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let particles: Particle[] = [];
|
let particles: Particle[] = [];
|
||||||
let animationFrameId: number;
|
let animationFrameId: number | null = null;
|
||||||
|
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||||
|
|
||||||
const resizeCanvas = () => {
|
const resizeCanvas = () => {
|
||||||
canvas.width = window.innerWidth;
|
const nextWidth = Math.max(1, window.innerWidth);
|
||||||
canvas.height = window.innerHeight;
|
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 {
|
class Particle {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -73,18 +77,24 @@ const ParticleBackground = () => {
|
|||||||
|
|
||||||
const init = () => {
|
const init = () => {
|
||||||
particles = [];
|
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++) {
|
for (let i = 0; i < numberOfParticles; i++) {
|
||||||
particles.push(new Particle());
|
particles.push(new Particle());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const animate = () => {
|
const render = (advance: boolean) => {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
particles.forEach((particle) => {
|
particles.forEach((particle) => {
|
||||||
particle.update();
|
if (advance) {
|
||||||
|
particle.update();
|
||||||
|
}
|
||||||
particle.draw();
|
particle.draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,16 +121,51 @@ const ParticleBackground = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
render(true);
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
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();
|
init();
|
||||||
animate();
|
if (prefersReducedMotion) {
|
||||||
|
render(false);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', resizeCanvas);
|
window.removeEventListener('resize', resizeCanvas);
|
||||||
cancelAnimationFrame(animationFrameId);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
stop();
|
||||||
};
|
};
|
||||||
}, [theme]); // Re-run when theme changes
|
}, [theme]); // Re-run when theme changes
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
|||||||
// Styled Components
|
// Styled Components
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
/* Mobile URL bars change the viewport height; dvh keeps the input row reachable. */
|
||||||
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
@@ -32,6 +35,8 @@ const PageContainer = styled.div`
|
|||||||
|
|
||||||
const Sidebar = styled.div<{ $isOpen: boolean }>`
|
const Sidebar = styled.div<{ $isOpen: boolean }>`
|
||||||
width: 280px;
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)'};
|
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)'};
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -48,7 +53,9 @@ const Sidebar = styled.div<{ $isOpen: boolean }>`
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: min(85vw, 320px);
|
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%)'};
|
transform: ${({ $isOpen }) => $isOpen ? 'translateX(0)' : 'translateX(-100%)'};
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.95)' : 'rgba(255, 255, 255, 0.95)'};
|
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'};
|
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`
|
const MobileSidebarToggle = styled.button`
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5.5rem; /* Below header */
|
top: calc(4.75rem + env(safe-area-inset-top, 0px)); /* Below header */
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@@ -87,10 +94,7 @@ const MobileSidebarToggle = styled.button`
|
|||||||
const Overlay = styled.div<{ $isOpen: boolean }>`
|
const Overlay = styled.div<{ $isOpen: boolean }>`
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
@@ -135,22 +139,35 @@ const MobileSidebarCloseButton = styled.button`
|
|||||||
|
|
||||||
const MainContent = styled.div`
|
const MainContent = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5;
|
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`
|
const ChatArea = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
@@ -164,14 +181,22 @@ const ChatArea = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const InputArea = styled.div`
|
const InputArea = styled.div`
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)'};
|
background: ${({ theme }) => theme.darkMode ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)'};
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
border-top: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
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`
|
const StyledInputContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
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;
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};
|
background: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};
|
||||||
border-color: ${({ theme }) => theme.main};
|
border-color: ${({ theme }) => theme.main};
|
||||||
@@ -191,9 +222,11 @@ const StyledInputContainer = styled.div`
|
|||||||
|
|
||||||
const StyledInput = styled.textarea`
|
const StyledInput = styled.textarea`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
/* Below 16px iOS/Android browsers zoom the page on focus. */
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -211,6 +244,7 @@ const StyledInput = styled.textarea`
|
|||||||
const IconButton = styled.button`
|
const IconButton = styled.button`
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};
|
color: ${({ theme }) => theme.darkMode ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -234,6 +268,8 @@ const IconButton = styled.button`
|
|||||||
const StyledSelect = styled.select`
|
const StyledSelect = styled.select`
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 7rem;
|
||||||
color: ${({ theme }) => theme.colors.text};
|
color: ${({ theme }) => theme.colors.text};
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -241,6 +277,13 @@ const StyledSelect = styled.select`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
border-right: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
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 {
|
option {
|
||||||
background: ${({ theme }) => theme.colors.background || '#1a1a1a'};
|
background: ${({ theme }) => theme.colors.background || '#1a1a1a'};
|
||||||
@@ -320,9 +363,11 @@ type PromptValues = {
|
|||||||
modelName: string;
|
modelName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AlwaysScrollToBottom = (): JSX.Element => {
|
const AlwaysScrollToBottom = ({ trigger }: { trigger: string | number }): JSX.Element => {
|
||||||
const elementRef = useRef<any>();
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => elementRef.current?.scrollIntoView({ behavior: "smooth" }));
|
useEffect(() => {
|
||||||
|
elementRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
}, [trigger]);
|
||||||
return <div ref={elementRef} />;
|
return <div ref={elementRef} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -500,7 +545,7 @@ const AsyncDashboardInner = (): JSX.Element => {
|
|||||||
<Markdown>Select a conversation or start a new one.</Markdown>
|
<Markdown>Select a conversation or start a new one.</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AlwaysScrollToBottom />
|
<AlwaysScrollToBottom trigger={`${conversationDetails.length}:${stateMessage.length}`} />
|
||||||
</ChatArea>
|
</ChatArea>
|
||||||
|
|
||||||
<InputArea>
|
<InputArea>
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ html {
|
|||||||
body {
|
body {
|
||||||
/* Keyboard plugin resizes body; keep root scrollable within inset. */
|
/* Keyboard plugin resizes body; keep root scrollable within inset. */
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
/* Full-bleed page shells must never create a sideways scroll on phones. */
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user