feat(ui): make the UI feel native on mobile
Deploy / Build (pull_request) Successful in 49s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
Deploy / Test & Lint (pull_request) Successful in 34s

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>
This commit is contained in:
2026-08-01 00:43:48 +02:00
parent 5ddb56ef97
commit 0c49b132ee
32 changed files with 1892 additions and 567 deletions
+188
View File
@@ -0,0 +1,188 @@
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,
);
}