From 967f7a6c1d5a568aecdc9346badcde008e0ad3ff Mon Sep 17 00:00:00 2001 From: abassibrahim591 Date: Tue, 23 Jun 2026 19:35:22 +0100 Subject: [PATCH] feat(web): make /chat mobile-first (Claude/ChatGPT-style) Reskin the web chat shell to be mobile-first while keeping all desktop behavior behind md: breakpoints. The chat engine and backend API are untouched. - Foundation: viewportFit cover, h-dvh containers, additive safe-area utility classes (safe-topbar/safe-composer/safe-drawer). - Shell: single column on mobile with an off-canvas drawer sidebar (scrim, Esc, scroll-lock, auto-close) and a compact mobile top bar; the docked rail/grid is preserved at md+. - Composer: home-bar-safe sticky bar, 16px input to stop iOS zoom, larger touch targets on phones. - Secondary panels: reusable BottomSheet (drag handle, swipe-to-dismiss); Canvas renders as a column on desktop and a sheet on mobile, with auto-open gated to desktop; Share dialog docks to the bottom on mobile. - Stable feel: global prefers-reduced-motion backstop. - PWA: web manifest (standalone, start_url /chat), maskable icon, generated apple-icon (force-dynamic), appleWebApp metadata, metadataBase. Build, typecheck, and 132 web tests pass. Co-Authored-By: Claude Opus 4.8 --- frontend/apps/web/app/apple-icon.tsx | 36 +++++ frontend/apps/web/app/chat/ChatClient.tsx | 66 ++++++-- .../web/app/chat/components/BottomSheet.tsx | 101 ++++++++++++ .../web/app/chat/components/CanvasPanel.tsx | 39 +++-- .../web/app/chat/components/ChatComposer.tsx | 12 +- .../web/app/chat/components/ChatSidebar.tsx | 148 ++++++++++++++++-- .../web/app/chat/components/ComposerMenu.tsx | 2 +- .../web/app/chat/components/MessageList.tsx | 2 +- frontend/apps/web/app/globals.css | 34 ++++ frontend/apps/web/app/layout.tsx | 16 ++ frontend/apps/web/app/manifest.ts | 28 ++++ frontend/apps/web/public/icon-maskable.svg | 21 +++ 12 files changed, 461 insertions(+), 44 deletions(-) create mode 100644 frontend/apps/web/app/apple-icon.tsx create mode 100644 frontend/apps/web/app/chat/components/BottomSheet.tsx create mode 100644 frontend/apps/web/app/manifest.ts create mode 100644 frontend/apps/web/public/icon-maskable.svg diff --git a/frontend/apps/web/app/apple-icon.tsx b/frontend/apps/web/app/apple-icon.tsx new file mode 100644 index 0000000..6f6ba4b --- /dev/null +++ b/frontend/apps/web/app/apple-icon.tsx @@ -0,0 +1,36 @@ +import { ImageResponse } from 'next/og'; + +// iOS ignores the web manifest icons and uses the apple-touch-icon, so we +// generate a real PNG here. Built once at compile time; rendered with satori, +// hence a flex div + brand gradient rather than the SVG droplet path. +export const size = { width: 180, height: 180 }; +export const contentType = 'image/png'; +// Render on demand rather than at build time: prerendering this route under +// the app's strict CSP/middleware tripped an "Invalid URL" in next/og. The +// icon is tiny and cached, so on-demand generation is a non-issue. +export const dynamic = 'force-dynamic'; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ P +
+ ), + { ...size }, + ); +} diff --git a/frontend/apps/web/app/chat/ChatClient.tsx b/frontend/apps/web/app/chat/ChatClient.tsx index 0d7e217..a7db469 100644 --- a/frontend/apps/web/app/chat/ChatClient.tsx +++ b/frontend/apps/web/app/chat/ChatClient.tsx @@ -157,6 +157,9 @@ export function ChatClient() { const [sending, setSending] = useState(false); const [error, setError] = useState(null); + // Mobile-only: the conversation sidebar is an off-canvas drawer. Desktop + // keeps it docked in the grid, so this flag is ignored at md and up. + const [mobileNavOpen, setMobileNavOpen] = useState(false); const abortRef = useRef(null); const [canvasMessageId, setCanvasMessageId] = useState(null); @@ -187,6 +190,13 @@ export function ChatClient() { if (canvasMessageId) return; if (lastAutoOpenedRef.current === m.id) return; if (isCanvasWorthy(m)) { + // On phones the canvas is a full-screen bottom sheet; auto-popping it + // over every long answer is intrusive. Only auto-open on desktop, + // where it is a side column. Mobile users open it from the message + // actions or the composer menu (forceCanvasNext, handled above). + if (typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches) { + return; + } lastAutoOpenedRef.current = m.id; setCanvasMessageId(m.id); } @@ -199,6 +209,9 @@ export function ChatClient() { useEffect(() => { lastAutoOpenedRef.current = null; setCanvasMessageId(null); + // Picking a conversation (or starting a new one) closes the mobile drawer + // so the user lands straight on the thread, the way Claude/ChatGPT do. + setMobileNavOpen(false); }, [activeId]); const closeCanvas = useCallback(() => setCanvasMessageId(null), []); @@ -755,7 +768,7 @@ export function ChatClient() {
@@ -779,17 +792,46 @@ export function ChatClient() { return (
- -
+ setMobileNavOpen(false)} /> +
+
+ + + {activeConversation?.title || 'PetroBrain'} + + +
-
+
{activeProject ? ( @@ -955,6 +997,10 @@ export function ChatClient() {
{canvasMessage ? ( + // CanvasPanel renders both shells itself: a docked column that takes + // the third grid cell on desktop, and a full-height bottom sheet on + // mobile. The fragment is transparent, so the desktop aside still + // lands as the grid item here. ) : null} {shareStatus.kind === 'shared' && activeConversation ? ( @@ -1031,9 +1077,9 @@ function ShareDialog({ role="dialog" aria-modal="true" aria-labelledby="share-dialog-title" - className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-8 backdrop-blur-sm" + className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/55 backdrop-blur-sm sm:items-center sm:px-4 sm:py-8" > -
+
diff --git a/frontend/apps/web/app/chat/components/BottomSheet.tsx b/frontend/apps/web/app/chat/components/BottomSheet.tsx new file mode 100644 index 0000000..d0527aa --- /dev/null +++ b/frontend/apps/web/app/chat/components/BottomSheet.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect, useRef, useState, type ReactNode, type TouchEvent } from 'react'; +import clsx from 'clsx'; + +/** + * Mobile-only bottom sheet. Slides up from the bottom edge over a tap-to- + * dismiss scrim, with a drag handle and swipe-down-to-close, the way the + * Claude/ChatGPT mobile apps present secondary surfaces. It is `md:hidden`, + * so callers can render it alongside a desktop panel and let the breakpoint + * decide which one shows. + * + * Presence is the open state: the parent mounts the sheet to open it and + * unmounts to close it. The slide-in plays on mount; close is immediate + * (the parent removes it), which keeps the state model simple and avoids a + * stuck-half-open sheet. + */ +export function BottomSheet({ + onClose, + label, + children, +}: { + onClose: () => void; + /** Accessible name for the dialog. */ + label: string; + children: ReactNode; +}) { + const [entered, setEntered] = useState(false); + const [dragY, setDragY] = useState(0); + const [dragging, setDragging] = useState(false); + const startY = useRef(null); + + useEffect(() => { + const raf = requestAnimationFrame(() => setEntered(true)); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + function onKey(e: globalThis.KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => { + cancelAnimationFrame(raf); + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', onKey); + }; + }, [onClose]); + + function onTouchStart(e: TouchEvent) { + startY.current = e.touches[0]?.clientY ?? null; + setDragging(true); + } + function onTouchMove(e: TouchEvent) { + if (startY.current === null) return; + const dy = (e.touches[0]?.clientY ?? startY.current) - startY.current; + // Only track downward drags; an upward pull does nothing. + setDragY(dy > 0 ? dy : 0); + } + function onTouchEnd() { + setDragging(false); + // A decisive pull (or a flick past the threshold) dismisses; otherwise + // the sheet springs back to its resting position. + if (dragY > 90) onClose(); + else setDragY(0); + startY.current = null; + } + + return ( +
+
+ ); +} diff --git a/frontend/apps/web/app/chat/components/CanvasPanel.tsx b/frontend/apps/web/app/chat/components/CanvasPanel.tsx index 1f54ad2..cefa2ed 100644 --- a/frontend/apps/web/app/chat/components/CanvasPanel.tsx +++ b/frontend/apps/web/app/chat/components/CanvasPanel.tsx @@ -7,6 +7,7 @@ import { Banner } from '@petrobrain/ui'; import { isStructuredToolMessage } from '@/lib/chat/canvas'; import type { AssistantMessage } from '@/lib/chat/types'; +import { BottomSheet } from './BottomSheet'; import { EvidencePanel } from './EvidencePanel'; import { Markdown } from './Markdown'; import { userSafeToolLabel } from './WorkingPanel'; @@ -36,15 +37,7 @@ export function CanvasPanel({ const eyebrow = structured ? 'Generated document' : 'Long-form answer'; const createdAt = new Date(message.createdAt); - return ( - + ); + + return ( + <> + {/* Desktop: docked canvas column in the chat grid. */} + + + {/* Mobile: the same content as a full-height bottom sheet. */} + + {header} + {scrollBody} + + ); } diff --git a/frontend/apps/web/app/chat/components/ChatComposer.tsx b/frontend/apps/web/app/chat/components/ChatComposer.tsx index 12f9b14..11efb02 100644 --- a/frontend/apps/web/app/chat/components/ChatComposer.tsx +++ b/frontend/apps/web/app/chat/components/ChatComposer.tsx @@ -542,7 +542,7 @@ export function ChatComposer({ onSubmit, disabled, sending, onStop }: ChatCompos return (
@@ -657,7 +659,7 @@ export function ChatComposer({ onSubmit, disabled, sending, onStop }: ChatCompos disabled={disabled || sending} aria-label={listening ? 'Stop voice input' : 'Start voice input'} title={listening ? 'Stop voice input' : 'Start voice input'} - className={`inline-flex h-8 w-8 items-center justify-center rounded-full border transition-all disabled:cursor-not-allowed disabled:opacity-50 ${ + className={`inline-flex h-9 w-9 items-center justify-center rounded-full border transition-all disabled:cursor-not-allowed disabled:opacity-50 sm:h-8 sm:w-8 ${ listening ? 'border-primary-300 bg-primary-50 text-primary-700 shadow-[0_0_0_3px_rgba(234,88,12,0.12)] dark:border-primary-600 dark:bg-primary-900/30 dark:text-primary-200' : 'border-neutral-200/80 bg-white text-neutral-600 hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:border-primary-600 dark:hover:bg-primary-900/30 dark:hover:text-primary-300' @@ -686,7 +688,7 @@ export function ChatComposer({ onSubmit, disabled, sending, onStop }: ChatCompos onClick={onStop} aria-label="Stop generating" title="Stop generating" - className="group relative isolate flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-b from-neutral-800 to-neutral-900 text-white shadow-[0_6px_14px_-6px_rgba(15,23,42,0.45),inset_0_1px_0_rgba(255,255,255,0.18)] transition-all hover:from-neutral-700 hover:to-neutral-800 dark:from-neutral-200 dark:to-neutral-100 dark:text-neutral-900 dark:hover:from-neutral-100 dark:hover:to-white" + className="group relative isolate flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-b from-neutral-800 to-neutral-900 text-white sm:h-9 sm:w-9 shadow-[0_6px_14px_-6px_rgba(15,23,42,0.45),inset_0_1px_0_rgba(255,255,255,0.18)] transition-all hover:from-neutral-700 hover:to-neutral-800 dark:from-neutral-200 dark:to-neutral-100 dark:text-neutral-900 dark:hover:from-neutral-100 dark:hover:to-white" > @@ -695,7 +697,7 @@ export function ChatComposer({ onSubmit, disabled, sending, onStop }: ChatCompos type="submit" disabled={!canSend} aria-label="Send" - className="group relative isolate flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-b from-primary-500 to-primary-700 text-white shadow-[0_6px_14px_-6px_rgba(234,88,12,0.55),inset_0_1px_0_rgba(255,255,255,0.28)] transition-all hover:from-primary-400 hover:to-primary-600 hover:shadow-[0_10px_24px_-8px_rgba(234,88,12,0.55)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-[0_6px_14px_-6px_rgba(234,88,12,0.55),inset_0_1px_0_rgba(255,255,255,0.28)]" + className="group relative isolate flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-b from-primary-500 to-primary-700 text-white sm:h-9 sm:w-9 shadow-[0_6px_14px_-6px_rgba(234,88,12,0.55),inset_0_1px_0_rgba(255,255,255,0.28)] transition-all hover:from-primary-400 hover:to-primary-600 hover:shadow-[0_10px_24px_-8px_rgba(234,88,12,0.55)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-[0_6px_14px_-6px_rgba(234,88,12,0.55),inset_0_1px_0_rgba(255,255,255,0.28)]" > diff --git a/frontend/apps/web/app/chat/components/ChatSidebar.tsx b/frontend/apps/web/app/chat/components/ChatSidebar.tsx index 1e409f8..38f31cb 100644 --- a/frontend/apps/web/app/chat/components/ChatSidebar.tsx +++ b/frontend/apps/web/app/chat/components/ChatSidebar.tsx @@ -1058,7 +1058,14 @@ function ActiveProjectStrip() { ); } -export function ChatSidebar() { +export function ChatSidebar({ + mobileOpen = false, + onMobileClose, +}: { + /** Mobile only: whether the off-canvas drawer is slid in. Ignored at md+. */ + mobileOpen?: boolean; + onMobileClose?: () => void; +} = {}) { const pathname = usePathname(); const principal = useChatStore((s) => s.principal); const setToken = useChatStore((s) => s.setToken); @@ -1082,10 +1089,105 @@ export function ChatSidebar() { newConversation(ownerKey, validProjectId); } - if (collapsed) return setCollapsed(false)} onNewChat={newChat} onSignOut={signOut} />; + // While the mobile drawer is open: lock body scroll and close on Esc so it + // behaves like a native sheet rather than letting the page scroll behind it. + useEffect(() => { + if (!mobileOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + function onKey(e: globalThis.KeyboardEvent) { + if (e.key === 'Escape') onMobileClose?.(); + } + document.addEventListener('keydown', onKey); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', onKey); + }; + }, [mobileOpen, onMobileClose]); return ( -
+ {collapsed ? ( + setCollapsed(false)} onNewChat={newChat} onSignOut={signOut} /> + ) : ( + setCollapsed(true)} + /> + )} +
+ + {/* Mobile: off-canvas drawer with a tap-to-dismiss scrim. */} +
+
+ + ); +} + +/** + * The expanded sidebar body, shared by the desktop docked rail and the + * mobile drawer. ``variant`` only changes the chrome: the rail shows a + * collapse control, the drawer shows a close (X) control and pads itself + * for the device safe-area. + */ +function FullSidebar({ + variant, + pathname, + principal, + newChat, + signOut, + onCollapse, + onClose, +}: { + variant: 'rail' | 'drawer'; + pathname: string | null; + principal: Principal | null; + newChat: () => void; + signOut: () => void; + onCollapse?: () => void; + onClose?: (() => void) | undefined; +}) { + return ( +
- + {variant === 'drawer' ? ( + + ) : ( + + )}