Add columns/list toggle on Runs page and Settings page mockup

Runs: add a view toggle (columns vs list) with collapsible
status-grouped sections in list view.

Settings: populate with grouped panels (General, Git & VCS,
Compute, Notifications, Security) containing key/value fields
with text inputs, selects, and toggles. Hide redundant header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-28 23:24:22 -05:00
parent 9b1bab7561
commit b5d8ad1494
2 changed files with 266 additions and 10 deletions

View file

@ -1,8 +1,8 @@
import { useState } from "react";
import { Link } from "react-router";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { columns, ciConfig } from "../data/runs";
import type { CiStatus, RunItem } from "../data/runs";
import { ChevronDownIcon, ChevronRightIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { columns, ciConfig, statusColors } from "../data/runs";
import type { CiStatus, RunItem, RunWithStatus } from "../data/runs";
import type { Route } from "./+types/pipelines";
export function meta({}: Route.MetaArgs) {
@ -208,11 +208,53 @@ function BoardColumn({ column }: { column: (typeof columns)[number] }) {
);
}
type ViewMode = "columns" | "list";
function RunRow({ run }: { run: RunWithStatus }) {
return (
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-white/[0.06] bg-navy-800/80 px-4 py-3 transition-all duration-200 hover:border-white/[0.12] hover:bg-navy-800" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<span className={`font-mono text-xs pr-2 ${run.elapsedWarning ? "text-amber" : "text-navy-600"}`}>
{run.elapsed}
</span>
<span className="flex items-center gap-2 min-w-0">
<span className="font-mono text-xs font-medium text-teal-500">{run.repo}</span>
<span className="truncate text-sm text-ice-100">{run.title}</span>
{run.comments != null && run.comments > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-navy-600">
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
</svg>
{run.comments}
</span>
)}
</span>
<span className="flex items-center justify-end gap-2 pr-4 font-mono text-xs tabular-nums">
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
</span>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-navy-600">
{run.number != null && (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.ci != null && <span className={`size-1.5 rounded-full ${ciConfig[run.ci].dot}`} />}
</>
)}
</span>
</Link>
);
}
const allRepos = [...new Set(columns.flatMap((col) => col.items.map((item) => item.repo)))].sort();
export default function Pipelines() {
const [query, setQuery] = useState("");
const [repoFilter, setRepoFilter] = useState("all");
const [view, setView] = useState<ViewMode>("columns");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const lowerQuery = query.toLowerCase();
const filteredColumns = columns.map((col) => ({
@ -253,12 +295,75 @@ export default function Pipelines() {
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-navy-600" />
</div>
<div className="flex rounded-md border border-white/[0.06] bg-navy-800/80">
<button
type="button"
onClick={() => setView("columns")}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "columns" ? "text-teal-500" : "text-navy-600 hover:text-ice-300"}`}
aria-label="Columns view"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
<path d="M2 4.75A.75.75 0 0 1 2.75 4h2.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V4.75ZM8.25 4a.75.75 0 0 0-.75.75v10.5c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V4.75a.75.75 0 0 0-.75-.75h-2.5ZM14 4.75a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75V4.75Z" />
</svg>
</button>
<button
type="button"
onClick={() => setView("list")}
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${view === "list" ? "text-teal-500" : "text-navy-600 hover:text-ice-300"}`}
aria-label="List view"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
<path fillRule="evenodd" d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75Zm0 5A.75.75 0 0 1 2.75 9h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 9.75Zm0 5a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
<div className="flex gap-5 overflow-x-auto pb-4">
{filteredColumns.map((col) => (
<BoardColumn key={col.id} column={col} />
))}
</div>
{view === "columns" ? (
<div className="flex gap-5 overflow-x-auto pb-4">
{filteredColumns.map((col) => (
<BoardColumn key={col.id} column={col} />
))}
</div>
) : (
<div className="space-y-4">
{filteredColumns.map((col) => {
const isCollapsed = collapsed.has(col.id);
return (
<div key={col.id}>
<button
type="button"
onClick={() => setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(col.id)) next.delete(col.id);
else next.add(col.id);
return next;
})}
className="mb-3 flex w-full items-center gap-2 text-left"
>
{isCollapsed
? <ChevronRightIcon className="size-3.5 text-navy-600" />
: <ChevronDownIcon className="size-3.5 text-navy-600" />}
<div className={`h-2.5 w-2.5 rounded-full ${col.accent}`} />
<h3 className="text-sm font-semibold tracking-wide text-ice-100">{col.name}</h3>
<span className="rounded-full bg-white/[0.06] px-2 py-0.5 font-mono text-xs text-navy-600">
{col.items.length}
</span>
</button>
{!isCollapsed && (col.items.length > 0 ? (
<div className="grid gap-2" style={{ gridTemplateColumns: "5rem 1fr 8rem auto" }}>
{col.items.map((item) => (
<RunRow key={item.id} run={{ ...item, status: col.id, statusLabel: col.name }} />
))}
</div>
) : (
<p className="py-4 text-center text-sm text-navy-600">No runs</p>
))}
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -4,6 +4,157 @@ export function meta({}: Route.MetaArgs) {
return [{ title: "Settings — Arc" }];
}
export default function Settings() {
return null;
export const handle = { hideHeader: true };
interface SettingField {
key: string;
label: string;
value: string;
type: "text" | "select" | "toggle";
options?: string[];
description?: string;
}
interface SettingGroup {
id: string;
name: string;
description: string;
fields: SettingField[];
}
const settingGroups: SettingGroup[] = [
{
id: "general",
name: "General",
description: "Core platform settings and defaults.",
fields: [
{ key: "org_name", label: "Organization name", value: "Acme Corp", type: "text" },
{ key: "default_branch", label: "Default branch", value: "main", type: "text" },
{ key: "timezone", label: "Timezone", value: "America/New_York", type: "select", options: ["America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "UTC", "Europe/London", "Europe/Berlin", "Asia/Tokyo"] },
{ key: "auto_cancel", label: "Auto-cancel superseded runs", value: "true", type: "toggle" },
],
},
{
id: "git",
name: "Git & VCS",
description: "Version control integration and repository settings.",
fields: [
{ key: "github_org", label: "GitHub organization", value: "acme-corp", type: "text" },
{ key: "clone_protocol", label: "Clone protocol", value: "SSH", type: "select", options: ["SSH", "HTTPS"] },
{ key: "auto_merge", label: "Auto-merge when checks pass", value: "false", type: "toggle" },
{ key: "delete_branch", label: "Delete branch after merge", value: "true", type: "toggle" },
{ key: "commit_signing", label: "Require commit signing", value: "false", type: "toggle" },
],
},
{
id: "compute",
name: "Compute",
description: "Resource allocation and execution environment.",
fields: [
{ key: "default_cpu", label: "Default CPU", value: "4", type: "select", options: ["2", "4", "8", "16"] },
{ key: "default_memory", label: "Default memory", value: "8 GB", type: "select", options: ["4 GB", "8 GB", "16 GB", "32 GB"] },
{ key: "max_parallel", label: "Max parallel runs", value: "10", type: "text" },
{ key: "timeout_minutes", label: "Run timeout (minutes)", value: "120", type: "text" },
{ key: "gpu_enabled", label: "GPU acceleration", value: "false", type: "toggle" },
],
},
{
id: "notifications",
name: "Notifications",
description: "Alerts and notification delivery preferences.",
fields: [
{ key: "slack_webhook", label: "Slack webhook URL", value: "https://hooks.slack.com/services/T00/B00/xxxx", type: "text" },
{ key: "notify_on_failure", label: "Notify on failure", value: "true", type: "toggle" },
{ key: "notify_on_success", label: "Notify on success", value: "false", type: "toggle" },
{ key: "notify_on_approval", label: "Notify on approval needed", value: "true", type: "toggle" },
{ key: "email_digest", label: "Daily email digest", value: "false", type: "toggle" },
],
},
{
id: "security",
name: "Security",
description: "Access control and security policies.",
fields: [
{ key: "sso_provider", label: "SSO provider", value: "Okta", type: "select", options: ["None", "Okta", "Azure AD", "Google Workspace", "OneLogin"] },
{ key: "mfa_required", label: "Require MFA", value: "true", type: "toggle" },
{ key: "session_timeout", label: "Session timeout", value: "8 hours", type: "select", options: ["1 hour", "4 hours", "8 hours", "24 hours", "7 days"] },
{ key: "audit_log", label: "Audit logging", value: "true", type: "toggle" },
{ key: "ip_allowlist", label: "IP allowlist", value: "", type: "text", description: "Comma-separated CIDRs. Leave empty to allow all." },
],
},
];
function ToggleSwitch({ enabled }: { enabled: boolean }) {
return (
<button
type="button"
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${enabled ? "bg-teal-500" : "bg-white/[0.08]"}`}
role="switch"
aria-checked={enabled}
>
<span
className={`pointer-events-none inline-block size-4 rounded-full bg-white shadow transition-transform ${enabled ? "translate-x-4" : "translate-x-0"}`}
/>
</button>
);
}
function SettingRow({ field }: { field: SettingField }) {
return (
<div className="flex items-center justify-between gap-8 py-3.5">
<div className="min-w-0">
<p className="text-sm text-ice-100">{field.label}</p>
{field.description != null && (
<p className="mt-0.5 text-xs text-navy-600">{field.description}</p>
)}
</div>
<div className="shrink-0">
{field.type === "toggle" ? (
<ToggleSwitch enabled={field.value === "true"} />
) : field.type === "select" ? (
<select
defaultValue={field.value}
className="appearance-none rounded-md border border-white/[0.06] bg-navy-800/80 py-1.5 pl-3 pr-8 text-sm text-ice-100 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
>
{field.options?.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type="text"
defaultValue={field.value}
className="w-64 rounded-md border border-white/[0.06] bg-navy-800/80 px-3 py-1.5 text-sm text-ice-100 placeholder-navy-600 outline-none transition-colors focus:border-teal-500/40 focus:ring-0"
/>
)}
</div>
</div>
);
}
function SettingsSection({ group }: { group: SettingGroup }) {
return (
<section className="rounded-lg border border-white/[0.06] bg-navy-800/50">
<div className="border-b border-white/[0.06] px-5 py-4">
<h2 className="text-sm font-semibold text-ice-100">{group.name}</h2>
<p className="mt-0.5 text-xs text-navy-600">{group.description}</p>
</div>
<div className="divide-y divide-white/[0.06] px-5">
{group.fields.map((field) => (
<SettingRow key={field.key} field={field} />
))}
</div>
</section>
);
}
export default function Settings() {
return (
<div className="space-y-6">
{settingGroups.map((group) => (
<SettingsSection key={group.id} group={group} />
))}
</div>
);
}