0c49b132ee
Reworks the frontend around phone-first interaction patterns and makes the app installable to a home screen. Shell - Bottom tab bar (Home / Requests / Agents / Alerts / Settings) below `md`, with the unread badge moved onto the Alerts tab; the bell stays on desktop. - Account menu opens as a bottom sheet on phones, dropdown on desktop. - Safe-area insets throughout: `viewport-fit=cover` plus `env(safe-area-inset-*)` on the sticky header, tab bar and sheet footers. Sheets - New `Sheet` primitive: drag-to-dismiss bottom sheet on phones, centred dialog from `sm` up. `Modal` now delegates to it, so every dialog inherits the gesture, the scroll lock and the safe-area padding. - Portalled to `<body>` — an ancestor with a transform (the page fade-in) was otherwise becoming the containing block and displacing the fixed overlay. - Scroll lock pins `<body>` and restores position, which iOS needs; plain `overflow: hidden` still rubber-bands there. - `ConfirmSheet` replaces `window.confirm` for destructive actions. Screens - Request detail gets a sticky decision bar above the tab bar; the sidebar decision card is now desktop-only. - Requests state filter becomes a swipeable pill row instead of a select. - Agent cards show two primary actions plus an overflow sheet on phones. - Diffs and code blocks contain their horizontal overscroll so a sideways swipe no longer triggers browser back; diffs gain a line-wrap toggle. - `min-w-0` on grid tracks — items default to `min-width: auto`, so truncated meta lines were widening columns past the viewport at 320px. Touch and input - 44px minimum touch targets, `:active` press feedback, no tap highlight. - 16px inputs on coarse pointers so iOS stops zooming on focus. - autocomplete/inputmode hints so password managers and keyboards behave. - `overscroll-behavior-y: none` disables pull-to-refresh; motion respects `prefers-reduced-motion`. PWA - Manifest, generated app icons (192/512/apple-touch) and standalone display metadata, so the app installs to a home screen without browser chrome. - Backend serves `.webmanifest` as `application/manifest+json`; rjweb's type map has no entry for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useIsMobile } from "../hooks/useMediaQuery";
|
|
import { useScrollLock } from "../hooks/useScrollLock";
|
|
import { CloseIcon } from "./icons";
|
|
|
|
const EXIT_MS = 240;
|
|
|
|
/**
|
|
* Responsive overlay primitive: a drag-dismissable bottom sheet on phones, a
|
|
* centred dialog from `sm` up. Everything modal in the app renders through this
|
|
* so the dismiss gesture, scroll lock and safe-area handling stay consistent.
|
|
*/
|
|
export function Sheet({
|
|
open,
|
|
onClose,
|
|
onSubmit,
|
|
title,
|
|
children,
|
|
footer,
|
|
wide,
|
|
padded = true,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSubmit?: () => void;
|
|
title?: string;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
wide?: boolean;
|
|
padded?: boolean;
|
|
}) {
|
|
const isMobile = useIsMobile();
|
|
const [mounted, setMounted] = useState(open);
|
|
const [exiting, setExiting] = useState(false);
|
|
const [dragY, setDragY] = useState(0);
|
|
const [dragging, setDragging] = useState(false);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const dragStart = useRef<{ y: number; t: number } | null>(null);
|
|
|
|
// Keep the panel mounted through its exit transition.
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMounted(true);
|
|
setExiting(false);
|
|
setDragY(0);
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
setExiting(true);
|
|
const t = window.setTimeout(() => {
|
|
setMounted(false);
|
|
setExiting(false);
|
|
setDragY(0);
|
|
}, EXIT_MS);
|
|
return () => window.clearTimeout(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
|
|
useScrollLock(mounted);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && onSubmit) onSubmit();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, onClose, onSubmit]);
|
|
|
|
if (!mounted) return null;
|
|
|
|
// ── drag-to-dismiss (mobile, from the grabber/header only, so it never
|
|
// fights the scroll container underneath) ────────────────────────────────
|
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
if (!isMobile || e.pointerType === "mouse") return;
|
|
dragStart.current = { y: e.clientY, t: e.timeStamp };
|
|
setDragging(true);
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const onPointerMove = (e: React.PointerEvent) => {
|
|
if (!dragStart.current) return;
|
|
// Resist upward drags so the sheet feels anchored.
|
|
const dy = e.clientY - dragStart.current.y;
|
|
setDragY(dy > 0 ? dy : dy / 4);
|
|
};
|
|
|
|
const endDrag = (e: React.PointerEvent) => {
|
|
const start = dragStart.current;
|
|
dragStart.current = null;
|
|
setDragging(false);
|
|
if (!start) return;
|
|
const dy = e.clientY - start.y;
|
|
const dt = Math.max(1, e.timeStamp - start.t);
|
|
const velocity = dy / dt; // px/ms
|
|
const height = panelRef.current?.offsetHeight ?? 400;
|
|
if (dy > height * 0.28 || velocity > 0.5) onClose();
|
|
else setDragY(0);
|
|
};
|
|
|
|
const dismissProgress = Math.min(1, Math.max(0, dragY) / 320);
|
|
|
|
const panelTransform = exiting
|
|
? isMobile
|
|
? "translateY(100%)"
|
|
: "scale(0.96)"
|
|
: dragY
|
|
? `translateY(${dragY}px)`
|
|
: undefined;
|
|
|
|
// Portalled to <body>: any ancestor with a transform (page fade-ins, the
|
|
// blurred header) would otherwise become the containing block and knock the
|
|
// fixed overlay out of place.
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4" role="dialog" aria-modal="true">
|
|
<div
|
|
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
|
|
exiting ? "opacity-0" : "animate-backdrop-in"
|
|
}`}
|
|
style={{ opacity: exiting ? 0 : 1 - dismissProgress * 0.7 }}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
<div
|
|
ref={panelRef}
|
|
className={`relative z-10 flex max-h-[92dvh] w-full flex-col border border-border-strong bg-surface shadow-2xl ${
|
|
wide ? "sm:max-w-3xl" : "sm:max-w-lg"
|
|
} rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl ${
|
|
exiting
|
|
? isMobile
|
|
? "" // slides out; fading as well reads as a glitch on phones
|
|
: "opacity-0"
|
|
: isMobile
|
|
? "animate-sheet-up"
|
|
: "animate-scale-in"
|
|
}`}
|
|
style={{
|
|
transform: panelTransform,
|
|
transition: dragging ? "none" : `transform ${EXIT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity 180ms ease`,
|
|
}}
|
|
>
|
|
{/* Grab area — the whole header is draggable, like iOS sheets. */}
|
|
<div
|
|
className="shrink-0 touch-none"
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={endDrag}
|
|
onPointerCancel={endDrag}
|
|
>
|
|
<div className="flex justify-center pt-2 sm:hidden">
|
|
<span className="h-1 w-9 rounded-full bg-border-strong" />
|
|
</div>
|
|
{title && (
|
|
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5 sm:py-4">
|
|
<h2 className="min-w-0 truncate text-base font-semibold text-text sm:text-lg">{title}</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="tap-sm -mr-2 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-muted hover:bg-surface-raised hover:text-text"
|
|
aria-label="Close"
|
|
>
|
|
<CloseIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* The home-indicator inset is folded into the padding with calc so it
|
|
adds to the base spacing instead of replacing it. */}
|
|
<div
|
|
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${
|
|
padded ? "px-4 py-4 sm:px-5" : ""
|
|
} ${footer ? "" : padded ? "pb-[calc(1rem_+_var(--safe-bottom))]" : "pb-safe"}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{footer && (
|
|
<div className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border px-4 pb-[calc(0.75rem_+_var(--safe-bottom))] pt-3 sm:px-5 sm:pb-[calc(1rem_+_var(--safe-bottom))] sm:pt-4">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|