feat(web): Run Files tab empty/error/loading states, refresh, a11y

Completes Units 11 and 12 of the Run Files Changed plan:

- Empty-state taxonomy: distinct copy for total_changed==0 vs no
  recoverable diff
- LoadingSkeleton on initial loader navigation (shimmer respects
  prefers-reduced-motion via motion-safe:animate-pulse)
- ErrorBoundary export handling 401/403/503/429 and generic 5xx
- Refresh button + Toolbar with freshness indicator; relative
  timestamps tick every 10s
- SSE subscription to /runs/{id}/attach with a 500ms debounce that
  revalidates on checkpoint.completed, run.completed, run.failed
- After a revalidation completes, focus returns to the Refresh button
- j/k keyboard navigation over file rows, ignoring key presses while
  a text field is focused
- md (768 px) breakpoint collapses split to unified without writing
  any persisted preference
- #file=<encoded-path> deep link scrolls + focuses the matching row
  on mount; absent file surfaces a 5s toast; patch-only mode shows
  a toast explaining the limitation
- Touch targets on the Refresh button meet WCAG 2.5.5 AAA (44x44)

Refs plan docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 17:13:17 -04:00
parent b589b581ff
commit 6050b5fa4b
No known key found for this signature in database
4 changed files with 535 additions and 180 deletions

View file

@ -1,4 +1,17 @@
import type { ReactElement } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactElement,
} from "react";
import {
isRouteErrorResponse,
useNavigation,
useParams,
useRevalidator,
useRouteError,
} from "react-router";
import { MultiFileDiff, PatchDiff } from "@pierre/diffs/react";
import { useTheme } from "../lib/theme";
import { apiJsonOrNull } from "../api";
@ -17,14 +30,22 @@ export async function loader({ request, params }: any) {
return data;
}
// Events that can change the diff. CheckpointCompleted is the canonical
// signal; the others cover terminal state transitions that also merit a
// refresh.
const REFRESH_EVENTS = new Set([
"checkpoint.completed",
"run.completed",
"run.failed",
]);
const PLACEHOLDER_CLASSES =
"flex items-center justify-between rounded-md border border-line bg-panel/60 px-4 py-3 text-sm text-fg-muted";
function DegradedBanner({ reason }: { reason?: string }) {
const copy = banner_copy_for_reason(reason);
return (
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
{copy}
{banner_copy_for_reason(reason)}
</div>
);
}
@ -109,6 +130,43 @@ function EmptyState({ message }: { message: string }) {
);
}
function LoadingSkeleton() {
return (
<div className="flex flex-col gap-3" aria-label="Loading files">
<div className="h-8 rounded-md bg-panel/60 motion-safe:animate-pulse" />
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
<div className="h-32 rounded-md bg-panel/60 motion-safe:animate-pulse" />
</div>
);
}
function Toolbar({
onRefresh,
refreshing,
freshness,
refreshButtonRef,
}: {
onRefresh: () => void;
refreshing: boolean;
freshness: string | null;
refreshButtonRef?: React.Ref<HTMLButtonElement>;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-md border border-line bg-panel/40 px-3 py-2 text-xs text-fg-muted">
<span aria-live="polite">{freshness ?? "\u00A0"}</span>
<button
ref={refreshButtonRef}
type="button"
onClick={onRefresh}
disabled={refreshing}
className="min-h-[44px] min-w-[44px] rounded-md border border-line bg-panel px-3 py-1 text-xs font-medium text-fg-2 transition-colors hover:bg-overlay disabled:opacity-60"
>
{refreshing ? "Refreshing…" : "Refresh"}
</button>
</div>
);
}
function pick_placeholder(file: ApiFileDiff): ReactElement | null {
const display_name = file.new_file.name || file.old_file.name;
// Priority: sensitive > binary > symlink/submodule > truncated. Security
@ -138,9 +196,311 @@ function pick_placeholder(file: ApiFileDiff): ReactElement | null {
return null;
}
function formatRelative(iso: string | null, now: number): string {
if (!iso) return "";
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const diff = Math.max(0, Math.floor((now - then) / 1000));
if (diff < 5) return "just now";
if (diff < 60) return `${diff}s ago`;
const m = Math.floor(diff / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function useSseRevalidation(runId: string | undefined) {
const revalidator = useRevalidator();
useEffect(() => {
if (!runId) return;
const source = new EventSource(`/api/v1/runs/${runId}/attach?since_seq=1`);
let debounce: ReturnType<typeof setTimeout> | undefined;
source.onmessage = (msg) => {
try {
const payload = JSON.parse(msg.data);
if (REFRESH_EVENTS.has(payload.event)) {
clearTimeout(debounce);
debounce = setTimeout(() => revalidator.revalidate(), 500);
}
} catch {
// ignore malformed payloads
}
};
return () => {
clearTimeout(debounce);
source.close();
};
// revalidator is stable across renders per react-router; omitting it
// keeps the effect from reattaching on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId]);
}
/// Tailwind `md` breakpoint. Below this, split diffs collapse to unified
/// without updating any persisted preference.
const MD_BREAKPOINT_PX = 768;
function useNarrowViewport(): boolean {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`);
const apply = () => setNarrow(mql.matches);
apply();
mql.addEventListener("change", apply);
return () => mql.removeEventListener("change", apply);
}, []);
return narrow;
}
function isEditableElement(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
return (el as HTMLElement).isContentEditable === true;
}
function useFileKeyboardNav(
containerRef: React.RefObject<HTMLDivElement | null>,
fileCount: number,
) {
useEffect(() => {
if (!containerRef.current) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "j" && event.key !== "k") return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditableElement(document.activeElement)) return;
const container = containerRef.current;
if (!container) return;
const rows = Array.from(
container.querySelectorAll<HTMLElement>('[data-run-file-row="true"]'),
);
if (rows.length === 0) return;
const active = document.activeElement as HTMLElement | null;
const currentIdx = rows.findIndex((row) => row.contains(active));
let nextIdx: number;
if (currentIdx < 0) {
nextIdx = 0;
} else {
nextIdx = event.key === "j" ? currentIdx + 1 : currentIdx - 1;
}
if (nextIdx < 0 || nextIdx >= rows.length) return;
event.preventDefault();
const target = rows[nextIdx];
target.focus({ preventScroll: false });
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// fileCount drives re-attachment so rows picked up after data changes are
// addressable without ghost references.
}, [containerRef, fileCount]);
}
function useFreshness(
meta: PaginatedRunFileList["meta"] | null,
lastFetchedAt: number | null,
): string | null {
// Tick every 10 seconds so relative timestamps stay current.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, []);
if (!meta) return null;
const now = Date.now();
const captured = meta.to_sha_committed_at
? `Captured ${formatRelative(meta.to_sha_committed_at, now)}`
: null;
const fetched = lastFetchedAt
? `Fetched ${formatRelative(new Date(lastFetchedAt).toISOString(), now)}`
: null;
if (meta.degraded && captured && fetched) {
return `${captured} · ${fetched}`;
}
return captured ?? fetched ?? null;
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 401 || error.status === 403) {
return (
<EmptyState message="You don't have access to this run's files." />
);
}
if (error.status === 503 || error.status === 429) {
return (
<EmptyState message="The diff service is temporarily unavailable. Please retry in a moment." />
);
}
return (
<EmptyState
message={`Something went wrong (${error.status}). Please contact support if this persists.`}
/>
);
}
return (
<EmptyState message="Something went wrong loading this run's files." />
);
}
function fileRowId(name: string): string {
return `run-file:${name}`;
}
function decodeDeepLinkFile(hash: string): string | null {
if (!hash) return null;
const withoutHash = hash.startsWith("#") ? hash.slice(1) : hash;
const prefix = "file=";
if (!withoutHash.startsWith(prefix)) return null;
try {
return decodeURIComponent(withoutHash.slice(prefix.length));
} catch {
return null;
}
}
function Toast({ children }: { children: React.ReactNode }) {
return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed bottom-6 right-6 z-50 rounded-md border border-line bg-panel/95 px-3 py-2 text-xs text-fg-2 shadow-lg"
>
{children}
</div>
);
}
export default function RunFiles({ loaderData }: any) {
const theme = useTheme();
const params = useParams();
const navigation = useNavigation();
const revalidator = useRevalidator();
const data = loaderData as PaginatedRunFileList | null;
const narrow = useNarrowViewport();
const lastFetchedAtRef = useRef<number | null>(null);
useEffect(() => {
// Refresh the "Fetched N seconds ago" reference whenever a new loader
// response lands.
lastFetchedAtRef.current = Date.now();
}, [data]);
useSseRevalidation(params.id);
const isInitialLoading = navigation.state === "loading" && !loaderData;
const isRevalidating = revalidator.state === "loading";
const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current);
const diffStyle: "split" | "unified" = narrow ? "unified" : "split";
const pierreTheme =
theme.theme === "dark" ? "pierre-dark" : "pierre-light";
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const refreshingPrev = useRef(false);
useEffect(() => {
// When a revalidation completes, return focus to the Refresh button so
// keyboard-first users stay oriented.
if (refreshingPrev.current && !isRevalidating) {
refreshButtonRef.current?.focus({ preventScroll: true });
}
refreshingPrev.current = isRevalidating;
}, [isRevalidating]);
const fileCount = data?.data.length ?? 0;
useFileKeyboardNav(containerRef, fileCount);
// Deep-link handling
const [deepLinkToast, setDeepLinkToast] = useState<string | null>(null);
const [hashFile, setHashFile] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return decodeDeepLinkFile(window.location.hash);
});
useEffect(() => {
if (typeof window === "undefined") return;
const onHashChange = () =>
setHashFile(decodeDeepLinkFile(window.location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
useEffect(() => {
if (!hashFile || !data) return;
if (data.meta.degraded && data.meta.patch) {
setDeepLinkToast(
"File-level navigation isn't available in the patch-only view.",
);
const id = setTimeout(() => setDeepLinkToast(null), 5000);
return () => clearTimeout(id);
}
const exists = data.data.some(
(f) =>
f.new_file.name === hashFile || f.old_file.name === hashFile,
);
if (!exists) {
setDeepLinkToast(`File ${hashFile} is not in this run.`);
const id = setTimeout(() => setDeepLinkToast(null), 5000);
return () => clearTimeout(id);
}
// Scroll the matching row into view and focus it.
const el = document.getElementById(fileRowId(hashFile));
if (el) {
el.scrollIntoView({ block: "start", behavior: "smooth" });
el.focus({ preventScroll: true });
}
}, [hashFile, data]);
const renderFiles = useCallback(
(files: ApiFileDiff[]): ReactElement[] =>
files.map((file, idx) => {
const display = file.new_file.name || file.old_file.name;
const placeholder = pick_placeholder(file);
if (placeholder) {
return (
<div
key={`${display}-${idx}`}
id={fileRowId(display)}
tabIndex={-1}
data-run-file-row="true"
role="region"
aria-label={`${file.change_kind ?? "changed"}: ${display}`}
className="focus:outline-2 focus:outline-focus focus:outline-offset-2 rounded-md"
>
{placeholder}
</div>
);
}
return (
<div
key={`${file.new_file.name}-${idx}`}
id={fileRowId(display)}
tabIndex={-1}
data-run-file-row="true"
role="region"
aria-label={`${file.change_kind ?? "modified"}: ${file.new_file.name}`}
className="focus:outline-2 focus:outline-focus focus:outline-offset-2 rounded-md"
>
<MultiFileDiff
oldFile={file.old_file}
newFile={file.new_file}
options={{
diffStyle,
theme: pierreTheme,
}}
/>
</div>
);
}),
[diffStyle, pierreTheme],
);
if (isInitialLoading) {
return <LoadingSkeleton />;
}
if (!data) {
return (
@ -150,60 +510,55 @@ export default function RunFiles({ loaderData }: any) {
const { data: files, meta } = data;
const toolbar = (
<Toolbar
onRefresh={() => revalidator.revalidate()}
refreshing={isRevalidating}
freshness={freshness}
refreshButtonRef={refreshButtonRef}
/>
);
// Degraded: render the unified patch string directly. Deep-link file
// targeting isn't available in this mode; Unit 12 will add a transient
// toast pointing that out.
// targeting isn't available in this mode; the hook above surfaces a toast.
if (meta.degraded && meta.patch) {
return (
<div className="flex flex-col gap-4">
<div ref={containerRef} className="flex flex-col gap-4">
{toolbar}
<DegradedBanner reason={meta.degraded_reason} />
<PatchDiff
patch={meta.patch}
options={{
diffStyle: "split",
theme: theme.theme === "dark" ? "pierre-dark" : "pierre-light",
diffStyle,
theme: pierreTheme,
}}
/>
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}
if (files.length === 0) {
return <EmptyState message="This run didn't change any files." />;
return (
<div ref={containerRef} className="flex flex-col gap-4">
{toolbar}
<EmptyState
message={
meta.total_changed === 0
? "This run didn't change any files."
: "No recoverable diff is available for this run."
}
/>
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}
return (
<div className="flex flex-col gap-4">
{files.map((file, idx) => {
const placeholder = pick_placeholder(file);
if (placeholder) {
return (
<div
key={`${file.new_file.name || file.old_file.name}-${idx}`}
role="region"
aria-label={`${file.change_kind ?? "changed"}: ${file.new_file.name || file.old_file.name}`}
>
{placeholder}
</div>
);
}
return (
<div
key={`${file.new_file.name}-${idx}`}
role="region"
aria-label={`${file.change_kind ?? "modified"}: ${file.new_file.name}`}
>
<MultiFileDiff
oldFile={file.old_file}
newFile={file.new_file}
options={{
diffStyle: "split",
theme: theme.theme === "dark" ? "pierre-dark" : "pierre-light",
}}
/>
</div>
);
})}
<div ref={containerRef} className="flex flex-col gap-4">
{toolbar}
{renderFiles(files)}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-963f7qc4.js"></script>
<script type="module" src="/assets/entry-yx17vb64.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>