Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions frontend/apps/web/app/apple-icon.tsx
Original file line number Diff line number Diff line change
@@ -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(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #fb923c 0%, #ea580c 55%, #7c2d12 100%)',
color: '#ffffff',
fontSize: 108,
fontWeight: 700,
letterSpacing: '-0.04em',
fontFamily: 'sans-serif',
}}
>
P
</div>
),
{ ...size },
);
}
66 changes: 56 additions & 10 deletions frontend/apps/web/app/chat/ChatClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export function ChatClient() {

const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(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<AbortController | null>(null);

const [canvasMessageId, setCanvasMessageId] = useState<string | null>(null);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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), []);
Expand Down Expand Up @@ -755,7 +768,7 @@ export function ChatClient() {
<div
aria-busy="true"
aria-label="Loading PetroBrain"
className="grid min-h-screen place-items-center bg-gradient-to-b from-white via-white to-primary-50/30 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/20"
className="grid min-h-dvh place-items-center bg-gradient-to-b from-white via-white to-primary-50/30 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/20"
>
<div className="flex flex-col items-center gap-3">
<span className="relative inline-flex h-12 w-12">
Expand All @@ -779,17 +792,46 @@ export function ChatClient() {
return (
<div
className={
// Mobile: single column (the sidebar is a drawer rendered out of flow).
// md and up: the original docked-rail grid, with an optional canvas column.
sidebarCollapsed
? canvasMessage
? 'grid min-h-screen grid-cols-[3.5rem_minmax(0,1fr)_minmax(0,1fr)] gap-0'
: 'grid min-h-screen grid-cols-[3.5rem_minmax(0,1fr)] gap-0'
? 'min-h-dvh md:grid md:grid-cols-[3.5rem_minmax(0,1fr)_minmax(0,1fr)] md:gap-0'
: 'min-h-dvh md:grid md:grid-cols-[3.5rem_minmax(0,1fr)] md:gap-0'
: canvasMessage
? 'grid min-h-screen grid-cols-[15rem_minmax(0,1fr)_minmax(0,1fr)] gap-0'
: 'grid min-h-screen grid-cols-[15rem_minmax(0,1fr)] gap-0'
? 'min-h-dvh md:grid md:grid-cols-[15rem_minmax(0,1fr)_minmax(0,1fr)] md:gap-0'
: 'min-h-dvh md:grid md:grid-cols-[15rem_minmax(0,1fr)] md:gap-0'
}
>
<ChatSidebar />
<section className="relative flex h-screen flex-col overflow-hidden bg-gradient-to-b from-white via-white to-primary-50/20 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/10">
<ChatSidebar mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
<section className="relative flex h-dvh flex-col overflow-hidden bg-gradient-to-b from-white via-white to-primary-50/20 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/10">
<header className="safe-topbar relative z-20 flex items-center gap-2 border-b border-neutral-200/60 bg-white/70 px-3 pb-2 backdrop-blur-xl md:hidden dark:border-neutral-800/60 dark:bg-neutral-900/60">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
aria-label="Open menu"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-neutral-600 transition-colors hover:bg-neutral-100 active:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
<path d="M3 6h14M3 10h14M3 14h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
<span className="min-w-0 flex-1 truncate text-center text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{activeConversation?.title || 'PetroBrain'}
</span>
<button
type="button"
onClick={() => ownerKey && newConversation(ownerKey, activeProject?.id ?? null)}
disabled={!ownerKey}
aria-label="New chat"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-neutral-600 transition-colors hover:bg-neutral-100 active:bg-neutral-200 disabled:opacity-40 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
<path d="M4 13.5V16h2.5l7-7L11 6.5l-7 7z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M12.5 5l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</header>
<div
aria-hidden
className="pointer-events-none absolute -top-32 right-[-10%] h-96 w-96 rounded-full bg-primary-200/30 blur-3xl dark:bg-primary-800/20"
Expand All @@ -799,7 +841,7 @@ export function ChatClient() {
className="pointer-events-none absolute -bottom-40 left-[-10%] h-96 w-96 rounded-full bg-primary-100/40 blur-3xl dark:bg-primary-900/20"
/>

<header className="relative z-20 flex items-center justify-between gap-4 border-b border-neutral-200/60 bg-white/60 px-7 py-3 backdrop-blur-xl dark:border-neutral-800/60 dark:bg-neutral-900/60">
<header className="relative z-20 hidden items-center justify-between gap-4 border-b border-neutral-200/60 bg-white/60 px-7 py-3 backdrop-blur-xl md:flex dark:border-neutral-800/60 dark:bg-neutral-900/60">
<div className="flex items-center gap-2">
<ModulePill />
{activeProject ? (
Expand Down Expand Up @@ -955,6 +997,10 @@ export function ChatClient() {
</div>
</section>
{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.
<CanvasPanel message={canvasMessage} onClose={closeCanvas} />
) : null}
{shareStatus.kind === 'shared' && activeConversation ? (
Expand Down Expand Up @@ -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"
>
<div className="relative w-full max-w-[38rem] overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-[0_28px_80px_-28px_rgba(15,23,42,0.55)] dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50">
<div className="relative max-h-[92dvh] w-full max-w-[38rem] overflow-y-auto rounded-t-2xl border border-slate-200 bg-white text-slate-950 shadow-[0_28px_80px_-28px_rgba(15,23,42,0.55)] sm:rounded-2xl dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50">
<div className="absolute inset-y-0 left-0 w-1.5 bg-gradient-to-b from-primary-500 via-cyan-500 to-emerald-500" />
<div className="border-b border-slate-200 bg-slate-50/90 px-6 py-5 dark:border-slate-800 dark:bg-slate-900/80">
<div className="flex items-start justify-between gap-4">
Expand Down
101 changes: 101 additions & 0 deletions frontend/apps/web/app/chat/components/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<div className="md:hidden">
<button
type="button"
aria-label="Close"
onClick={onClose}
className={clsx(
'fixed inset-0 z-40 bg-neutral-950/40 backdrop-blur-sm transition-opacity duration-200 motion-reduce:transition-none',
entered ? 'opacity-100' : 'opacity-0',
)}
/>
<div
role="dialog"
aria-modal="true"
aria-label={label}
style={entered ? { transform: `translateY(${dragY}px)` } : undefined}
className={clsx(
'fixed inset-x-0 bottom-0 top-[max(1.5rem,env(safe-area-inset-top))] z-50 flex flex-col overflow-hidden rounded-t-3xl border border-b-0 border-neutral-200/60 bg-gradient-to-b from-white via-white to-primary-50/30 shadow-[0_-12px_40px_-12px_rgba(15,23,42,0.45)] dark:border-neutral-800/60 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/20',
dragging ? '' : 'transition-transform duration-200 ease-out motion-reduce:transition-none',
entered ? 'translate-y-0' : 'translate-y-full',
)}
>
<div
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className="flex shrink-0 cursor-grab touch-none justify-center pb-1 pt-2.5 active:cursor-grabbing"
>
<span aria-hidden className="h-1.5 w-10 rounded-full bg-neutral-300 dark:bg-neutral-700" />
</div>
{children}
</div>
</div>
);
}
39 changes: 28 additions & 11 deletions frontend/apps/web/app/chat/components/CanvasPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,15 +37,7 @@ export function CanvasPanel({
const eyebrow = structured ? 'Generated document' : 'Long-form answer';
const createdAt = new Date(message.createdAt);

return (
<aside
aria-label="Canvas: expanded message"
className="relative flex h-screen min-h-0 flex-col overflow-hidden border-l border-neutral-200/60 bg-gradient-to-b from-white via-white to-primary-50/30 dark:border-neutral-800/60 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/20"
>
<div
aria-hidden
className="pointer-events-none absolute -top-32 right-[-15%] h-96 w-96 rounded-full bg-primary-200/30 blur-3xl dark:bg-primary-800/20"
/>
const header = (
<header className="relative z-10 flex shrink-0 items-center justify-between gap-3 border-b border-neutral-200/60 bg-white/70 px-5 py-3 backdrop-blur-xl dark:border-neutral-800/60 dark:bg-neutral-900/60">
<div className="flex min-w-0 flex-col">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-primary-600 dark:text-primary-400">
Expand All @@ -66,8 +59,10 @@ export function CanvasPanel({
</svg>
</button>
</header>
);

<div className="relative z-10 flex-1 overflow-y-auto px-7 py-6">
const scrollBody = (
<div className="relative z-10 flex-1 overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
{safetyToolResult ? (
<div className="mb-5">
<Banner tone="brand" title="DECISION SUPPORT ONLY">
Expand Down Expand Up @@ -135,6 +130,28 @@ export function CanvasPanel({
</details>
) : null}
</div>
</aside>
);

return (
<>
{/* Desktop: docked canvas column in the chat grid. */}
<aside
aria-label="Canvas: expanded message"
className="relative hidden h-dvh min-h-0 flex-col overflow-hidden border-l border-neutral-200/60 bg-gradient-to-b from-white via-white to-primary-50/30 md:flex dark:border-neutral-800/60 dark:from-neutral-950 dark:via-neutral-950 dark:to-primary-900/20"
>
<div
aria-hidden
className="pointer-events-none absolute -top-32 right-[-15%] h-96 w-96 rounded-full bg-primary-200/30 blur-3xl dark:bg-primary-800/20"
/>
{header}
{scrollBody}
</aside>

{/* Mobile: the same content as a full-height bottom sheet. */}
<BottomSheet onClose={onClose} label={`Canvas: ${eyebrow}`}>
{header}
{scrollBody}
</BottomSheet>
</>
);
}
Loading
Loading