fix(ui): un-cramp the requests filter row
Deploy / Build (pull_request) Successful in 1m15s
Deploy / Test & Lint (pull_request) Successful in 53s
Deploy / Build and Push Docker Image (pull_request) Has been skipped

The state pills and the agent select sat 10px apart, and the pill row was
sliced mid-chip at the viewport edge with nothing to say it scrolled.

- New `ScrollRow`: fades whichever edge still has content behind it, so a
  clipped chip reads as "scroll for more" instead of as a broken layout.
  Reused for the admin and connect-modal tab strips.
- Drop scroll-snap from the pill row. It clamped the resting scrollLeft to the
  container's 12px padding, so the first chip sat flush against the screen edge
  with no gutter; flick-snapping through short chips felt wrong regardless.
- Breathing room: pills to select 10px -> 14px, filters to list 16px -> 20px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 01:19:30 +02:00
parent 43040b2ac8
commit ce2d9d9f77
4 changed files with 77 additions and 10 deletions
+3 -2
View File
@@ -5,6 +5,7 @@ import type { Agent } from "../api/types";
import { Modal } from "./Modal";
import { Button, Input, Textarea } from "./ui";
import { CodeBlock } from "./CodeBlock";
import { ScrollRow } from "./ScrollRow";
// ── Create / edit form ───────────────────────────────────────────────────────────
@@ -229,7 +230,7 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
</p>
)}
<div className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-4 sm:mx-0 sm:px-0">
<ScrollRow wrapperClassName="border-b border-border" className="gap-1" bleed={false}>
{tabs.map((t) => (
<button
key={t.id}
@@ -241,7 +242,7 @@ curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${
{t.label}
</button>
))}
</div>
</ScrollRow>
{tab === "openclaw" && (
<div className="space-y-2">
+63
View File
@@ -0,0 +1,63 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
const FADE = 28; // px of taper at each overflowing edge
/**
* Horizontally scrollable row of chips/tabs. Fades whichever edge still has
* content behind it, so a clipped item reads as "scroll for more" rather than
* as a layout bug — and contains its overscroll so a sideways swipe never
* triggers the browser's back gesture.
*/
export function ScrollRow({
children,
className = "",
wrapperClassName = "",
bleed = true,
}: {
children: ReactNode;
/** Applied to the scrolling element (layout of the items). */
className?: string;
/** Applied to the positioned wrapper (borders, margins). */
wrapperClassName?: string;
/** Extend to the screen edges on phones so the row reads as scrollable. */
bleed?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [edges, setEdges] = useState({ start: false, end: false });
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
}, []);
useEffect(() => {
update();
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(update);
ro.observe(el);
for (const child of Array.from(el.children)) ro.observe(child);
return () => ro.disconnect();
}, [update]);
const mask = `linear-gradient(to right, ${
edges.start ? `transparent 0, black ${FADE}px` : "black 0"
}, ${edges.end ? `black calc(100% - ${FADE}px), transparent 100%` : "black 100%"})`;
return (
<div className={`relative ${bleed ? "-mx-3 sm:mx-0" : ""} ${wrapperClassName}`}>
<div
ref={ref}
onScroll={update}
style={{ maskImage: mask, WebkitMaskImage: mask }}
className={`no-scrollbar flex overflow-x-auto overscroll-x-contain ${
bleed ? "px-3 sm:px-0" : ""
} ${className}`}
>
{children}
</div>
</div>
);
}
+3 -2
View File
@@ -5,6 +5,7 @@ import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
import { ConfirmSheet } from "../components/ConfirmSheet";
import { ScrollRow } from "../components/ScrollRow";
import { relativeTime } from "../utils";
type Tab = "users" | "agents" | "settings" | "audit";
@@ -21,7 +22,7 @@ export function AdminPage() {
return (
<div>
<PageHeader title="Admin" subtitle="Platform administration." />
<div className="no-scrollbar -mx-3 mb-6 flex gap-1 overflow-x-auto overscroll-x-contain border-b border-border px-3 sm:mx-0 sm:px-0">
<ScrollRow wrapperClassName="mb-6 border-b border-border" className="gap-1">
{tabs.map((t) => (
<button
key={t.id}
@@ -33,7 +34,7 @@ export function AdminPage() {
{t.label}
</button>
))}
</div>
</ScrollRow>
{tab === "users" && <UsersTab />}
{tab === "agents" && <AgentsTab />}
{tab === "settings" && <SettingsTab />}
+8 -6
View File
@@ -7,6 +7,7 @@ import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui"
import { StateBadge } from "../components/StateBadge";
import { AgentAvatar } from "../components/AgentAvatar";
import { ChevronRightIcon } from "../components/icons";
import { ScrollRow } from "../components/ScrollRow";
import { relativeTime } from "../utils";
const STATES: RequestState[] = [
@@ -61,7 +62,7 @@ export function RequestsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const chip = (active: boolean) =>
`tap-sm min-h-9 shrink-0 snap-start rounded-full border px-3.5 text-sm font-medium transition ${
`tap-sm min-h-9 shrink-0 rounded-full border px-3.5 text-sm font-medium transition ${
active
? "border-primary/40 bg-primary/15 text-primary"
: "border-border-strong bg-surface text-muted hover:text-text"
@@ -71,10 +72,11 @@ export function RequestsPage() {
<div>
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
<div className="mb-4 space-y-2.5">
{/* Swipeable filter pills — bleeds to the screen edges on phones so the
row reads as scrollable. */}
<div className="no-scrollbar -mx-3 flex snap-x gap-2 overflow-x-auto overscroll-x-contain px-3 pb-0.5 sm:mx-0 sm:flex-wrap sm:px-0">
<div className="mb-5 space-y-3.5">
{/* No scroll-snap: it clamps the resting scrollLeft to the container's
padding, which eats the left gutter, and flick-snapping through
short chips feels wrong anyway. */}
<ScrollRow className="gap-2 pb-0.5 sm:flex-wrap">
<button
onClick={() => {
setStateFilter("");
@@ -96,7 +98,7 @@ export function RequestsPage() {
{s.replace("_", " ").toLowerCase()}
</button>
))}
</div>
</ScrollRow>
<select
value={agentFilter}