feat(ui): make the UI feel native on mobile
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:
@@ -1,9 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import type { Change } from "../api/types";
|
||||
import { WrapIcon } from "./icons";
|
||||
|
||||
function DiffView({ content }: { content: string }) {
|
||||
function DiffView({ content, wrap }: { content: string; wrap: boolean }) {
|
||||
const lines = content.split("\n");
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-lg border border-border bg-bg p-3 text-xs leading-relaxed">
|
||||
<pre
|
||||
className={`scroll-x rounded-lg border border-border bg-bg p-2.5 text-[11px] leading-relaxed sm:p-3 sm:text-xs ${
|
||||
wrap ? "overflow-x-hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<code>
|
||||
{lines.map((line, i) => {
|
||||
let cls = "";
|
||||
@@ -13,7 +19,10 @@ function DiffView({ content }: { content: string }) {
|
||||
else if (line.startsWith("-")) cls = "diff-del";
|
||||
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
|
||||
return (
|
||||
<div key={i} className={`whitespace-pre px-1 ${cls}`}>
|
||||
<div
|
||||
key={i}
|
||||
className={`px-1 ${wrap ? "whitespace-pre-wrap break-words" : "whitespace-pre"} ${cls}`}
|
||||
>
|
||||
{line || " "}
|
||||
</div>
|
||||
);
|
||||
@@ -38,14 +47,21 @@ function percentDelta(before: unknown, after: unknown): string | null {
|
||||
return `${pct > 0 ? "+" : ""}${pct}%`;
|
||||
}
|
||||
|
||||
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
|
||||
const delta =
|
||||
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
|
||||
function BeforeAfter({
|
||||
before,
|
||||
after,
|
||||
contentType,
|
||||
}: {
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
contentType?: string | null;
|
||||
}) {
|
||||
const delta = contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
|
||||
<code className="diff-del max-w-full break-all rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
|
||||
<span className="text-faint">→</span>
|
||||
<code className="diff-add rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
|
||||
<code className="diff-add max-w-full break-all rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
|
||||
{delta && <span className="rounded bg-primary-dim/40 px-2 py-0.5 text-xs text-primary">{delta}</span>}
|
||||
{contentType && <span className="text-xs text-faint">({contentType})</span>}
|
||||
</div>
|
||||
@@ -53,34 +69,54 @@ function BeforeAfter({ before, after, contentType }: { before: unknown; after: u
|
||||
}
|
||||
|
||||
function typeChip(label: string, color: string) {
|
||||
return <span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
|
||||
return <span className={`shrink-0 rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export function ChangeCard({ change, index }: { change: Change; index: number }) {
|
||||
const [wrap, setWrap] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-mono text-faint">#{index + 1}</span>
|
||||
<div className="rounded-xl border border-border bg-surface-raised/50 p-3 sm:p-4">
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0 font-mono text-xs text-faint">#{index + 1}</span>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
{change.type === "unified_diff" && (
|
||||
<>
|
||||
{typeChip("diff", "bg-primary-dim/40 text-primary")}
|
||||
<code className="min-w-0 break-all text-sm text-text">{change.path}</code>
|
||||
</>
|
||||
)}
|
||||
{change.type === "config" && (
|
||||
<>
|
||||
{typeChip("config", "bg-accent/15 text-accent")}
|
||||
<code className="min-w-0 break-all text-sm text-text">{change.path}</code>
|
||||
</>
|
||||
)}
|
||||
{change.type === "custom" && (
|
||||
<>
|
||||
{typeChip("custom", "bg-changes/15 text-changes")}
|
||||
<span className="min-w-0 break-words text-sm text-text">{change.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wrapping beats horizontal scrolling for long lines on a phone. */}
|
||||
{change.type === "unified_diff" && (
|
||||
<>
|
||||
{typeChip("diff", "bg-primary-dim/40 text-primary")}
|
||||
<code className="break-all text-sm text-text">{change.path}</code>
|
||||
</>
|
||||
)}
|
||||
{change.type === "config" && (
|
||||
<>
|
||||
{typeChip("config", "bg-accent/15 text-accent")}
|
||||
<code className="break-all text-sm text-text">{change.path}</code>
|
||||
</>
|
||||
)}
|
||||
{change.type === "custom" && (
|
||||
<>
|
||||
{typeChip("custom", "bg-changes/15 text-changes")}
|
||||
<span className="text-sm text-text">{change.label}</span>
|
||||
</>
|
||||
<button
|
||||
onClick={() => setWrap((w) => !w)}
|
||||
aria-pressed={wrap}
|
||||
title={wrap ? "Disable line wrapping" : "Wrap long lines"}
|
||||
className={`tap-sm -mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
wrap ? "bg-primary/15 text-primary" : "text-faint hover:bg-surface-raised hover:text-muted"
|
||||
}`}
|
||||
>
|
||||
<WrapIcon className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{change.type === "unified_diff" && <DiffView content={change.content} />}
|
||||
|
||||
{change.type === "unified_diff" && <DiffView content={change.content} wrap={wrap} />}
|
||||
{change.type === "config" && (
|
||||
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user