Compare commits

...

3 Commits

Author SHA1 Message Date
space b68eac587a Merge pull request 'Improve Mobile UI' (#4) from feature/mobile-ui into main
Deploy / Build (push) Successful in 30s
Deploy / Test & Lint (push) Successful in 43s
Deploy / Build and Push Docker Image (push) Successful in 1m40s
Reviewed-on: #4
2026-08-01 01:50:10 +02:00
space ce2d9d9f77 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>
2026-08-01 01:19:30 +02:00
space 43040b2ac8 Allowed "*" as a CORS_DOMAINS entry which overrides the cors middleware
Deploy / Build (pull_request) Successful in 26s
Deploy / Test & Lint (pull_request) Successful in 32s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
2026-08-01 00:53:56 +02:00
6 changed files with 105 additions and 11 deletions
+8 -1
View File
@@ -29,9 +29,16 @@ export const PORT = env.PORT;
export { prisma };
const CORS_DOMAINS = env.CORS_URLS.split(",").map((s) => s.trim());
let CORS_DOMAINS = env.CORS_URLS.split(",").map((s) => s.trim());
CORS_DOMAINS.push(UI_URL);
CORS_DOMAINS.push(REACT_APP_API_URL.replace(/\/+$/, ""));
// If an origin is "*", clear the list and add a star
// ? This is for PR-Previews especially, which have a dynamic origin and cannot be enumerated in advance
if (CORS_DOMAINS.includes("*")) {
CORS_DOMAINS = ["*"];
}
initCorsDomains(CORS_DOMAINS);
if (env.NODE_ENV !== "test") {
+20
View File
@@ -29,6 +29,26 @@ export const corsMiddleware = new Middleware<{}, {}>("Custom CORS", "1.0.0")
const origin = ctr.headers.get("origin");
// if the list is only a single star, allow all origins
if (CORS_DOMAINS.length === 1 && CORS_DOMAINS[0] === "*") {
if (origin) {
ctr.headers.set("Access-Control-Allow-Origin", origin);
ctr.headers.set("Vary", "Origin");
ctr.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH");
ctr.headers.set(
"Access-Control-Allow-Headers",
ctr.headers.get("access-control-request-headers") || "content-type, x-api-key",
);
ctr.headers.set("Access-Control-Allow-Credentials", "true");
}
if (ctr.url.method === "OPTIONS") {
ctr.headers.set("Access-Control-Max-Age", "86400");
ctr.headers.set("Content-Length", "0");
return end(ctr.status(ctr.$status.NO_CONTENT).print(""));
}
return;
}
if (origin && !CORS_DOMAINS.includes(origin)) {
// Agent/API traffic (no browser origin) is unaffected; only browser
// requests from disallowed origins are blocked.
+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}