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 (
{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 (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
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 (
-