Add expandable check status rows and pending question text to Run cards

Replace simple CI dot indicators with rich check status data model
(CheckRun[] with per-check name, status, duration). Cards in Verify
and Merge columns show a full-width expandable status row with summary
icon and text. Clicking reveals individual check results. Pending
column cards now display the human-in-the-loop question above the
Answer Question button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-01 12:53:56 -05:00
parent ebc1d5ea6a
commit a338f8a064
3 changed files with 199 additions and 20 deletions

View file

@ -1,5 +1,13 @@
export type CiStatus = "passing" | "failing" | "pending";
export type CheckStatus = "success" | "failure" | "skipped" | "pending" | "queued";
export interface CheckRun {
name: string;
status: CheckStatus;
duration?: string;
}
export interface RunItem {
id: string;
repo: string;
@ -8,12 +16,13 @@ export interface RunItem {
number?: number;
additions?: number;
deletions?: number;
ci?: CiStatus;
checks?: CheckRun[];
elapsed?: string;
elapsedWarning?: boolean;
resources?: string;
actionDisabled?: boolean;
comments?: number;
question?: string;
}
export type ColumnStatus = "working" | "pending" | "review" | "merge";
@ -23,6 +32,12 @@ export interface RunWithStatus extends RunItem {
statusLabel: string;
}
export function deriveCiStatus(checks: CheckRun[]): CiStatus {
if (checks.some((c) => c.status === "failure")) return "failing";
if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending";
return "passing";
}
export const columns: {
id: ColumnStatus;
name: string;
@ -82,6 +97,7 @@ export const columns: {
additions: 567,
deletions: 234,
elapsed: "1h 12m",
question: "Accept or push for another round?",
},
{
id: "run-5",
@ -91,6 +107,7 @@ export const columns: {
additions: 145,
deletions: 23,
elapsed: "28m",
question: "Proceed from investigation to fix?",
},
],
},
@ -110,7 +127,15 @@ export const columns: {
number: 889,
additions: 234,
deletions: 67,
ci: "failing",
checks: [
{ name: "lint", status: "success", duration: "23s" },
{ name: "typecheck", status: "success", duration: "1m 12s" },
{ name: "unit-tests", status: "success", duration: "2m 34s" },
{ name: "integration-tests", status: "failure", duration: "4m 56s" },
{ name: "e2e / chrome", status: "failure", duration: "3m 2s" },
{ name: "build", status: "success", duration: "1m 45s" },
{ name: "coverage", status: "skipped" },
],
elapsed: "35m",
comments: 4,
},
@ -122,7 +147,13 @@ export const columns: {
number: 156,
additions: 412,
deletions: 0,
ci: "pending",
checks: [
{ name: "lint", status: "success", duration: "18s" },
{ name: "typecheck", status: "success", duration: "56s" },
{ name: "unit-tests", status: "pending" },
{ name: "integration-tests", status: "queued" },
{ name: "build", status: "pending" },
],
elapsed: "12m",
actionDisabled: true,
comments: 1,
@ -145,7 +176,20 @@ export const columns: {
number: 1249,
additions: 189,
deletions: 45,
ci: "passing",
checks: [
{ name: "lint", status: "success", duration: "21s" },
{ name: "typecheck", status: "success", duration: "1m 8s" },
{ name: "unit-tests", status: "success", duration: "3m 12s" },
{ name: "integration-tests", status: "success", duration: "5m 34s" },
{ name: "e2e / chrome", status: "success", duration: "4m 22s" },
{ name: "e2e / firefox", status: "success", duration: "4m 45s" },
{ name: "build", status: "success", duration: "2m 1s" },
{ name: "deploy-preview", status: "success", duration: "1m 33s" },
{ name: "security-scan", status: "skipped" },
{ name: "performance", status: "success", duration: "2m 18s" },
{ name: "bundle-size", status: "success", duration: "34s" },
{ name: "accessibility", status: "success", duration: "1m 12s" },
],
elapsed: "3d",
elapsedWarning: true,
comments: 7,
@ -158,7 +202,14 @@ export const columns: {
number: 430,
additions: 56,
deletions: 12,
ci: "passing",
checks: [
{ name: "lint", status: "success", duration: "15s" },
{ name: "typecheck", status: "success", duration: "48s" },
{ name: "unit-tests", status: "success", duration: "1m 56s" },
{ name: "build", status: "success", duration: "1m 22s" },
{ name: "coverage", status: "success", duration: "2m 4s" },
{ name: "bundle-size", status: "skipped" },
],
elapsed: "1h 5m",
comments: 2,
},
@ -170,7 +221,12 @@ export const columns: {
number: 76,
additions: 34,
deletions: 8,
ci: "passing",
checks: [
{ name: "lint", status: "success", duration: "12s" },
{ name: "typecheck", status: "success", duration: "34s" },
{ name: "unit-tests", status: "success", duration: "1m 15s" },
{ name: "build", status: "success", duration: "58s" },
],
elapsed: "48m",
comments: 0,
},

View file

@ -18,8 +18,8 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { columns as staticColumns, ciConfig, statusColors } from "../data/runs";
import type { CiStatus, RunItem, RunWithStatus } from "../data/runs";
import { columns as staticColumns, ciConfig, statusColors, deriveCiStatus } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus } from "../data/runs";
import type { Route } from "./+types/pipelines";
export function meta({}: Route.MetaArgs) {
@ -58,13 +58,131 @@ const iconMap = {
pr: GitPullRequestIcon,
};
function CiBadge({ status }: { status: CiStatus }) {
const config = ciConfig[status];
function CheckStatusIcon({ status }: { status: CheckStatus }) {
switch (status) {
case "success":
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3 shrink-0 text-mint" aria-hidden="true">
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
);
case "failure":
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3 shrink-0 text-coral" aria-hidden="true">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
);
case "pending":
return (
<span className="flex size-3 shrink-0 items-center justify-center">
<span className="size-2 rounded-full bg-amber" />
</span>
);
case "queued":
return (
<span className="flex size-3 shrink-0 items-center justify-center">
<span className="size-2 rounded-full border border-navy-600" />
</span>
);
case "skipped":
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3 shrink-0 text-navy-600" aria-hidden="true">
<path d="M2 7.75A.75.75 0 0 1 2.75 7h10a.75.75 0 0 1 0 1.5h-10A.75.75 0 0 1 2 7.75Z" />
</svg>
);
}
}
function SummaryStatusIcon({ status }: { status: CiStatus }) {
switch (status) {
case "passing":
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="size-4 shrink-0 text-mint" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm3.78-9.72a.75.75 0 0 0-1.06-1.06L7 8.94 5.28 7.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.06 0l4.25-4.25Z" />
</svg>
);
case "failing":
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="size-4 shrink-0 text-coral" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16ZM5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8 4.22 10.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
</svg>
);
case "pending":
return (
<svg viewBox="0 0 16 16" fill="currentColor" className="size-4 shrink-0 text-amber" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 0 0-1.5 0v3.69L5.22 10.47a.75.75 0 1 0 1.06 1.06l2.5-2.5a.75.75 0 0 0 .22-.53V4.75Z" />
</svg>
);
}
}
function summarizeChecks(checks: CheckRun[]) {
const counts = {
success: checks.filter((c) => c.status === "success").length,
failure: checks.filter((c) => c.status === "failure").length,
skipped: checks.filter((c) => c.status === "skipped").length,
pending: checks.filter((c) => c.status === "pending" || c.status === "queued").length,
};
let summary: string;
const parts: string[] = [];
if (counts.failure > 0) {
summary = `${counts.failure} failing check${counts.failure !== 1 ? "s" : ""}`;
if (counts.success > 0) parts.push(`${counts.success} success`);
if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`);
if (counts.pending > 0) parts.push(`${counts.pending} pending`);
} else if (counts.pending > 0) {
summary = `${counts.pending} check${counts.pending !== 1 ? "s" : ""} pending`;
if (counts.success > 0) parts.push(`${counts.success} success`);
if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`);
} else {
summary = "All checks passing";
if (counts.skipped > 0) {
parts.push(`${counts.skipped} skipped`);
parts.push(`${counts.success} success`);
}
}
return { summary, detail: parts.join(", ") };
}
function ChecksStatus({ checks }: { checks: CheckRun[] }) {
const [expanded, setExpanded] = useState(false);
const overallStatus = deriveCiStatus(checks);
const config = ciConfig[overallStatus];
const { summary, detail } = summarizeChecks(checks);
return (
<span className={`inline-flex items-center gap-1.5 font-mono text-xs ${config.text}`}>
<span className={`size-1.5 rounded-full ${config.dot}`} />
{config.label}
</span>
<div
className="-mx-4 mt-3 overflow-hidden border-y border-white/[0.04]"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-2 px-4 py-2 text-left transition-colors hover:bg-white/[0.03]"
>
<SummaryStatusIcon status={overallStatus} />
<span className={`min-w-0 flex-1 truncate font-mono text-xs font-medium ${config.text}`}>{summary}</span>
<ChevronDownIcon className={`size-3 shrink-0 text-navy-600 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`} />
</button>
<div className={`grid transition-[grid-template-rows] duration-200 ease-out ${expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`}>
<div className="overflow-hidden">
<div className="border-t border-white/[0.04] px-4 pb-2 pt-1.5">
{checks.map((check) => (
<div key={check.name} className="flex items-center gap-2 py-1 font-mono text-[11px]">
<CheckStatusIcon status={check.status} />
<span className={check.status === "skipped" || check.status === "queued" ? "text-navy-600" : "text-ice-300"}>{check.name}</span>
<span className="ml-auto text-navy-600">
{check.duration ?? (check.status === "skipped" ? "skipped" : check.status === "queued" ? "queued" : "")}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@ -115,7 +233,7 @@ function PrCard({
<p className="text-sm leading-snug text-ice-100">{pr.title}</p>
{(pr.additions != null || pr.resources != null || pr.ci != null || pr.elapsed != null) && (
{(pr.additions != null || pr.resources != null || pr.elapsed != null) && (
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
{pr.resources != null && (
<span className="text-ice-300">{pr.resources}</span>
@ -144,7 +262,13 @@ function PrCard({
</div>
)}
{(actions != null && actions.length > 0 || pr.ci != null) && (
{pr.checks != null && <ChecksStatus checks={pr.checks} />}
{pr.question != null && (
<p className="mt-3 truncate text-xs italic text-amber/70">{pr.question}</p>
)}
{actions != null && actions.length > 0 && (
<div className="mt-3 flex items-center gap-1.5">
{actions?.map((label) => (
<button
@ -189,7 +313,6 @@ function PrCard({
{label}
</button>
))}
{pr.ci != null && <span className="ml-auto flex items-center"><CiBadge status={pr.ci} /></span>}
</div>
)}
</Link>
@ -299,7 +422,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.ci != null && <span className={`size-1.5 rounded-full ${ciConfig[run.ci].dot}`} />}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
)}
</span>

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link } from "react-router";
import { allRunsFlat, ciConfig, columns, statusColors } from "../data/runs";
import { allRunsFlat, ciConfig, columns, deriveCiStatus, statusColors } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
const runs = allRunsFlat();
@ -49,7 +49,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.ci != null && <span className={`size-1.5 rounded-full ${ciConfig[run.ci].dot}`} />}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
)}
</span>