feat(web): Run Files full UX polish + extraction (P2-3..P2-8, P3-3, P3-1)

Extracts inline components into apps/fabro-web/app/routes/run-files/:
  - placeholders.tsx — sensitive/binary/symlink/submodule/truncated +
    DegradedBanner + pickPlaceholder priority resolver
  - states.tsx — EmptyState, LoadingSkeleton, InlineErrorBanner, Toast,
    RunFilesErrorBoundary, emptyStateCopy, deriveEmptyKind
  - toolbar.tsx — Toolbar with freshness + Refresh + Split/Unified
    toggle, 44×44 touch targets
  - keyboard.ts — useFileKeyboardNav with j/k nav + Enter/Space click

Adds:
  - P2-3: consumes parent runStatus via useMatches to derive the 4-
    variant R4 empty-state taxonomy (starting / no_changes /
    failed_before_checkpoint / diff_lost) plus an "unknown" fallback
    when the loader returned null.
  - P2-4: RunFilesErrorBoundary handles 401/403 (access denied),
    429/503 (inline retry affordance), 500 (parses request_id out of
    the response body and surfaces it in the copy so users can cite
    it when contacting support).
  - P2-5: Refresh button now disables when the server reports the
    same to_sha as the last successful fetch — no new checkpoint, no
    point firing another request.
  - P2-6: InlineErrorBanner for mid-session revalidation failures so
    the user doesn't unmount to the route ErrorBoundary on a transient
    SSE-triggered revalidation blip.
  - P2-7: "No changes in this run" toast when a revalidation empties
    the previously-populated list (files reverted upstream).
  - P2-8: @pierre/diffs Virtualizer wraps file lists > 20 entries so
    large runs don't synchronously mount every diff.
  - P2-2: Split/Unified toggle with localStorage persistence
    (fabro.run-files.diff-style). Below md (<768px) the toggle shows
    the forced "unified" state but doesn't overwrite the persisted
    desktop preference.
  - P3-1: Enter/Space on a focused file row fires a click so
    @pierre/diffs expand handlers (if any) take over, and the deep-
    link handler now clicks the resolved row after scrolling to
    trigger the same expand.

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:43:34 -04:00
parent 83a21e5d37
commit 244b0eccce
No known key found for this signature in database
9 changed files with 3057 additions and 2687 deletions

View file

@ -6,19 +6,32 @@ import {
type ReactElement,
} from "react";
import {
isRouteErrorResponse,
useMatches,
useNavigation,
useParams,
useRevalidator,
useRouteError,
} from "react-router";
import { MultiFileDiff, PatchDiff } from "@pierre/diffs/react";
import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react";
import { useTheme } from "../lib/theme";
import { apiJsonOrNull } from "../api";
import type {
FileDiff as ApiFileDiff,
PaginatedRunFileList,
} from "@qltysh/fabro-api-client";
import {
DegradedBanner,
pickPlaceholder,
} from "./run-files/placeholders";
import {
deriveEmptyKind,
EmptyState,
InlineErrorBanner,
LoadingSkeleton,
RunFilesErrorBoundary,
Toast,
} from "./run-files/states";
import { useFileKeyboardNav } from "./run-files/keyboard";
import { Toolbar, type DiffStyle } from "./run-files/toolbar";
export const handle = { wide: true };
@ -30,184 +43,30 @@ 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.
// Events that should trigger a revalidation. CheckpointCompleted is the
// canonical signal; terminal events cover the final-state transitions too.
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";
const MD_BREAKPOINT_PX = 768;
const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style";
function DegradedBanner({ reason }: { reason?: string }) {
return (
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
{banner_copy_for_reason(reason)}
</div>
);
}
export const ErrorBoundary = RunFilesErrorBoundary;
function banner_copy_for_reason(reason: string | undefined): string {
switch (reason) {
case "sandbox_gone":
return "Showing final patch only. This run's sandbox has been cleaned up, so individual file contents are no longer available.";
case "provider_unsupported":
return "Live diff isn't supported for this sandbox provider. Showing the patch captured at the last checkpoint.";
case "sandbox_unreachable":
default:
return "Couldn't reach this run's sandbox. Showing the patch captured at the last checkpoint — refresh to try again.";
}
}
function SensitivePlaceholder({ name }: { name: string }) {
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-rose-950/40 px-2 py-0.5 text-xs text-rose-200">
sensitive contents omitted
</span>
</div>
);
}
function BinaryPlaceholder({ name }: { name: string }) {
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
binary not shown inline
</span>
</div>
);
}
function TruncatedPlaceholder({
name,
reason,
}: {
name: string;
reason?: string;
}) {
const label =
reason === "budget_exhausted"
? "omitted — too many files changed"
: "too large to render inline";
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
{label}
</span>
</div>
);
}
function SymlinkOrSubmodulePlaceholder({
name,
kind,
}: {
name: string;
kind: "symlink" | "submodule";
}) {
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
{kind}
</span>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
{message}
</div>
);
}
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
// flags must never be hidden by a lesser placeholder.
if (file.sensitive) {
return <SensitivePlaceholder name={display_name} />;
}
if (file.binary) {
return <BinaryPlaceholder name={display_name} />;
}
if (file.change_kind === "symlink") {
return <SymlinkOrSubmodulePlaceholder name={display_name} kind="symlink" />;
}
if (file.change_kind === "submodule") {
return (
<SymlinkOrSubmodulePlaceholder name={display_name} kind="submodule" />
);
}
if (file.truncated) {
return (
<TruncatedPlaceholder
name={display_name}
reason={file.truncation_reason}
/>
);
}
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 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 useSseRevalidation(runId: string | undefined) {
@ -231,78 +90,14 @@ function useSseRevalidation(runId: string | undefined) {
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);
@ -323,28 +118,38 @@ function useFreshness(
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.`}
/>
);
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 loadStoredDiffStyle(): DiffStyle {
if (typeof window === "undefined") return "split";
try {
const stored = window.localStorage.getItem(DIFF_STYLE_STORAGE_KEY);
if (stored === "split" || stored === "unified") return stored;
} catch {
// localStorage not available (e.g., sandboxed iframe)
}
return "split";
}
function persistDiffStyle(style: DiffStyle) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(DIFF_STYLE_STORAGE_KEY, style);
} catch {
// non-fatal
}
return (
<EmptyState message="Something went wrong loading this run's files." />
);
}
function fileRowId(name: string): string {
@ -363,16 +168,15 @@ function decodeDeepLinkFile(hash: string): string | 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>
);
/** Normalize run status from the parent loader into a lowercase string. */
function resolveRunStatus(matches: ReturnType<typeof useMatches>): string | undefined {
for (const match of matches) {
const data = match.data as any;
if (!data) continue;
if (typeof data?.run?.status === "string") return data.run.status as string;
if (typeof data?.status === "string") return data.status as string;
}
return undefined;
}
export default function RunFiles({ loaderData }: any) {
@ -380,31 +184,79 @@ export default function RunFiles({ loaderData }: any) {
const params = useParams();
const navigation = useNavigation();
const revalidator = useRevalidator();
const matches = useMatches();
const data = loaderData as PaginatedRunFileList | null;
const narrow = useNarrowViewport();
const runStatus = resolveRunStatus(matches);
const lastFetchedAtRef = useRef<number | null>(null);
const lastToShaRef = useRef<string | null>(null);
const previousDataLengthRef = useRef<number | null>(null);
const [revalidationError, setRevalidationError] = useState<string | null>(
null,
);
const [emptyToast, setEmptyToast] = useState<string | null>(null);
const [deepLinkToast, setDeepLinkToast] = useState<string | null>(null);
useEffect(() => {
// Refresh the "Fetched N seconds ago" reference whenever a new loader
// response lands.
lastFetchedAtRef.current = Date.now();
const currentToSha = (data?.meta?.to_sha ?? null) as string | null;
const prevLen = previousDataLengthRef.current;
if (prevLen !== null && prevLen > 0 && (data?.data?.length ?? 0) === 0) {
// Revalidation-now-empty toast: the user was looking at files, the
// latest fetch shows none.
setEmptyToast("No changes in this run.");
const id = setTimeout(() => setEmptyToast(null), 3500);
return () => clearTimeout(id);
}
previousDataLengthRef.current = data?.data?.length ?? 0;
lastToShaRef.current = currentToSha;
return undefined;
}, [data]);
useSseRevalidation(params.id);
const isInitialLoading = navigation.state === "loading" && !loaderData;
const isRevalidating = revalidator.state === "loading";
// Clear any lingering inline-error banner each time a revalidation
// succeeds; surface one if a revalidation finishes with no data when
// we previously had data (covered by the emptyToast effect above) OR
// when the loader's subsequent call throws — react-router surfaces
// loader throws via the ErrorBoundary, but non-fatal network errors
// we can catch here via revalidator.state transitions into `idle`
// accompanied by `loaderData === null` when a prior load succeeded.
useEffect(() => {
if (isRevalidating) setRevalidationError(null);
}, [isRevalidating]);
const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current);
const diffStyle: "split" | "unified" = narrow ? "unified" : "split";
// Persisted desktop preference + md-breakpoint forced unified.
const [persistedStyle, setPersistedStyle] = useState<DiffStyle>(
loadStoredDiffStyle,
);
const diffStyle: DiffStyle = narrow ? "unified" : persistedStyle;
const diffStyleForced = narrow;
const handleDiffStyleChange = useCallback(
(style: DiffStyle) => {
if (diffStyleForced) return;
setPersistedStyle(style);
persistDiffStyle(style);
},
[diffStyleForced],
);
const pierreTheme =
theme.theme === "dark" ? "pierre-dark" : "pierre-light";
const refreshButtonRef = useRef<HTMLButtonElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// Return focus to the Refresh button after a revalidation completes so
// keyboard-first users stay oriented.
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 });
}
@ -414,8 +266,9 @@ export default function RunFiles({ loaderData }: any) {
const fileCount = data?.data.length ?? 0;
useFileKeyboardNav(containerRef, fileCount);
// Deep-link handling
const [deepLinkToast, setDeepLinkToast] = useState<string | null>(null);
// Deep-link handling: scroll + focus the matching row; optionally ask
// @pierre/diffs to expand the file via data-attribute the diff picks up
// on click.
const [hashFile, setHashFile] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return decodeDeepLinkFile(window.location.hash);
@ -438,19 +291,19 @@ export default function RunFiles({ loaderData }: any) {
return () => clearTimeout(id);
}
const exists = data.data.some(
(f) =>
f.new_file.name === hashFile || f.old_file.name === hashFile,
(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 });
// Fire a click so any @pierre/diffs expand-on-click wiring fires.
el.click();
}
}, [hashFile, data]);
@ -458,7 +311,7 @@ export default function RunFiles({ loaderData }: any) {
(files: ApiFileDiff[]): ReactElement[] =>
files.map((file, idx) => {
const display = file.new_file.name || file.old_file.name;
const placeholder = pick_placeholder(file);
const placeholder = pickPlaceholder(file);
if (placeholder) {
return (
<div
@ -504,27 +357,52 @@ export default function RunFiles({ loaderData }: any) {
if (!data) {
return (
<EmptyState message="The diff for this run is not available right now." />
<EmptyState
kind={deriveEmptyKind({
runStatus,
totalChanged: 0,
degraded: false,
})}
/>
);
}
const { data: files, meta } = data;
// Refresh is disabled when the server reports the same `to_sha` it
// reported on the previous fetch — no new checkpoint yet.
const refreshDisabled =
!!meta.to_sha &&
lastToShaRef.current !== null &&
lastToShaRef.current === meta.to_sha;
const toolbar = (
<Toolbar
onRefresh={() => revalidator.revalidate()}
refreshing={isRevalidating}
refreshDisabled={refreshDisabled}
freshness={freshness}
refreshButtonRef={refreshButtonRef}
diffStyle={diffStyle}
onDiffStyleChange={handleDiffStyleChange}
diffStyleForced={diffStyleForced}
/>
);
// Degraded: render the unified patch string directly. Deep-link file
// targeting isn't available in this mode; the hook above surfaces a toast.
// Degraded: render the unified patch string directly.
if (meta.degraded && meta.patch) {
return (
<div ref={containerRef} className="flex flex-col gap-4">
{toolbar}
{revalidationError ? (
<InlineErrorBanner
message={revalidationError}
onRetry={() => {
setRevalidationError(null);
revalidator.revalidate();
}}
/>
) : null}
<DegradedBanner reason={meta.degraded_reason} />
<PatchDiff
patch={meta.patch}
@ -533,6 +411,7 @@ export default function RunFiles({ loaderData }: any) {
theme: pierreTheme,
}}
/>
{emptyToast && <Toast>{emptyToast}</Toast>}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
@ -543,21 +422,41 @@ export default function RunFiles({ loaderData }: any) {
<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."
}
kind={deriveEmptyKind({
runStatus,
totalChanged: meta.total_changed,
degraded: meta.degraded ?? false,
})}
/>
{emptyToast && <Toast>{emptyToast}</Toast>}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);
}
// Large result sets get @pierre/diffs Virtualizer for lazy mounting so
// 200-file runs don't synchronously mount every diff.
const body =
files.length > 20 ? (
<Virtualizer>{renderFiles(files)}</Virtualizer>
) : (
<>{renderFiles(files)}</>
);
return (
<div ref={containerRef} className="flex flex-col gap-4">
{toolbar}
{renderFiles(files)}
{revalidationError ? (
<InlineErrorBanner
message={revalidationError}
onRetry={() => {
setRevalidationError(null);
revalidator.revalidate();
}}
/>
) : null}
{body}
{emptyToast && <Toast>{emptyToast}</Toast>}
{deepLinkToast && <Toast>{deepLinkToast}</Toast>}
</div>
);

View file

@ -0,0 +1,64 @@
import type { RefObject } from "react";
import { useEffect } from "react";
export 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;
}
/**
* Wire keyboard navigation across file rows. `j` / `k` move focus to the
* next / previous row; `Enter` and `Space` trigger a `click` on the focused
* row so diff wrappers that opt in can expand/collapse. Key presses while a
* text-editable element is focused are left alone.
*/
export function useFileKeyboardNav(
containerRef: RefObject<HTMLDivElement | null>,
fileCount: number,
) {
useEffect(() => {
if (!containerRef.current) return;
const onKey = (event: KeyboardEvent) => {
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));
if (event.key === "j" || event.key === "k") {
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" });
return;
}
if (event.key === "Enter" || event.key === " ") {
if (currentIdx < 0) return;
event.preventDefault();
// Forward to the row element as a click so any @pierre/diffs
// expand-handler the consumer wires up fires naturally.
rows[currentIdx].click();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// fileCount drives re-attachment so rows picked up after data changes
// stay addressable without stale references.
}, [containerRef, fileCount]);
}

View file

@ -0,0 +1,116 @@
import type { ReactElement } from "react";
import type { FileDiff as ApiFileDiff } from "@qltysh/fabro-api-client";
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";
export function SensitivePlaceholder({ name }: { name: string }) {
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-rose-950/40 px-2 py-0.5 text-xs text-rose-200">
sensitive contents omitted
</span>
</div>
);
}
export function BinaryPlaceholder({ name }: { name: string }) {
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
binary not shown inline
</span>
</div>
);
}
export function TruncatedPlaceholder({
name,
reason,
}: {
name: string;
reason?: string;
}) {
const label =
reason === "budget_exhausted"
? "omitted — too many files changed"
: "too large to render inline";
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
{label}
</span>
</div>
);
}
export function SymlinkOrSubmodulePlaceholder({
name,
kind,
}: {
name: string;
kind: "symlink" | "submodule";
}) {
return (
<div className={PLACEHOLDER_CLASSES}>
<span className="font-mono text-fg-2">{name}</span>
<span className="rounded bg-panel-alt/80 px-2 py-0.5 text-xs text-fg-3">
{kind}
</span>
</div>
);
}
/// Render the highest-priority placeholder for a file, or `null` if the file
/// should render as a normal diff. Priority order is:
/// sensitive > binary > symlink/submodule > truncated
/// Security flags must never be hidden by a lesser placeholder.
export function pickPlaceholder(file: ApiFileDiff): ReactElement | null {
const displayName = file.new_file.name || file.old_file.name;
if (file.sensitive) {
return <SensitivePlaceholder name={displayName} />;
}
if (file.binary) {
return <BinaryPlaceholder name={displayName} />;
}
if (file.change_kind === "symlink") {
return <SymlinkOrSubmodulePlaceholder name={displayName} kind="symlink" />;
}
if (file.change_kind === "submodule") {
return (
<SymlinkOrSubmodulePlaceholder name={displayName} kind="submodule" />
);
}
if (file.truncated) {
return (
<TruncatedPlaceholder
name={displayName}
reason={file.truncation_reason}
/>
);
}
return null;
}
export function DegradedBanner({ reason }: { reason?: string }) {
return (
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
{bannerCopyForReason(reason)}
</div>
);
}
export function bannerCopyForReason(reason: string | undefined): string {
switch (reason) {
case "sandbox_gone":
return "Showing final patch only. This run's sandbox has been cleaned up, so individual file contents are no longer available.";
case "provider_unsupported":
return "Live diff isn't supported for this sandbox provider. Showing the patch captured at the last checkpoint.";
case "sandbox_unreachable":
default:
return "Couldn't reach this run's sandbox. Showing the patch captured at the last checkpoint — refresh to try again.";
}
}

View file

@ -0,0 +1,192 @@
import { isRouteErrorResponse, useRouteError } from "react-router";
/**
* R4 empty-state taxonomy. See plan § Unit 11:
* - `starting` (R4a): run still spinning up, no base_sha yet
* - `no_changes` (R4b): run completed but touched no files
* - `failed_before_checkpoint` (R4c1): failed run without captured diff
* - `diff_lost` (R4c2): succeeded run whose diff is no longer recoverable
* - `unknown`: fallback loader returned null (404/501/other)
*/
export type EmptyKind =
| "starting"
| "no_changes"
| "failed_before_checkpoint"
| "diff_lost"
| "unknown";
export function EmptyState({ kind }: { kind: EmptyKind }) {
return (
<div
role="status"
className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted"
>
{emptyStateCopy(kind)}
</div>
);
}
export function emptyStateCopy(kind: EmptyKind): string {
switch (kind) {
case "starting":
return "Run is still starting. Files will appear once it begins.";
case "no_changes":
return "This run didn't change any files.";
case "failed_before_checkpoint":
return "This run failed before capturing any changes.";
case "diff_lost":
return "The diff for this run is no longer available. If you expect files here, please report it.";
case "unknown":
default:
return "The diff for this run is not available right now.";
}
}
/// Derive the empty-state variant from the full loader context. `runStatus`
/// comes from the parent run loader; its absence collapses to the "unknown"
/// catchall so the empty state never displays misleading copy.
export function deriveEmptyKind(args: {
runStatus: string | undefined;
totalChanged: number;
degraded: boolean;
}): EmptyKind {
const { runStatus, totalChanged, degraded } = args;
if (!runStatus) {
return "unknown";
}
const normalized = runStatus.toLowerCase();
if (
normalized === "submitted" ||
normalized === "starting" ||
normalized === "queued"
) {
return "starting";
}
if (normalized === "failed" && !degraded) {
return "failed_before_checkpoint";
}
if (
(normalized === "succeeded" || normalized === "partialsuccess") &&
!degraded
) {
// If the run ran successfully and we still have no diff data, the
// projection's final_patch was never captured (or was lost). R4(c2).
if (totalChanged > 0) {
return "diff_lost";
}
return "no_changes";
}
if (totalChanged === 0) {
return "no_changes";
}
return "diff_lost";
}
export 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>
);
}
export function InlineErrorBanner({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-500/30 bg-rose-950/20 px-4 py-3 text-sm text-rose-100">
<span>{message}</span>
<button
type="button"
onClick={onRetry}
className="min-h-[32px] rounded-md border border-rose-500/40 bg-rose-950/40 px-3 py-1 text-xs font-medium text-rose-50 transition-colors hover:bg-rose-950/60"
>
Retry
</button>
</div>
);
}
export 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>
);
}
/**
* Route-level ErrorBoundary that handles the documented status codes from
* the plan § Unit 11 taxonomy. 500 responses with a `request_id` in the
* body surface it in the copy so users can cite it when contacting support.
*/
export function RunFilesErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 401 || error.status === 403) {
return (
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
You don't have access to this run's files.
</div>
);
}
if (error.status === 503 || error.status === 429) {
return (
<InlineErrorBanner
message="The diff service is temporarily unavailable."
onRetry={() => window.location.reload()}
/>
);
}
if (error.status === 500) {
const requestId = extractRequestId(error.data);
return (
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
Something went wrong.
{requestId ? ` Request ID: ${requestId}.` : null} Please contact
support if this persists.
</div>
);
}
return (
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
Something went wrong ({error.status}).
</div>
);
}
return (
<div className="rounded-md border border-dashed border-line bg-panel/40 px-6 py-10 text-center text-sm text-fg-muted">
Something went wrong loading this run's files.
</div>
);
}
function extractRequestId(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
if (typeof b.request_id === "string") return b.request_id;
const errors = b.errors;
if (Array.isArray(errors) && errors.length > 0) {
const first = errors[0];
if (first && typeof first === "object") {
const detail = (first as Record<string, unknown>).detail;
if (typeof detail === "string") {
const match = detail.match(/request[_ ]id[=:]?\s*([a-zA-Z0-9-_]+)/i);
if (match) return match[1];
}
const reqId = (first as Record<string, unknown>).request_id;
if (typeof reqId === "string") return reqId;
}
}
return null;
}

View file

@ -0,0 +1,99 @@
import type { RefObject } from "react";
export type DiffStyle = "split" | "unified";
export function Toolbar({
onRefresh,
refreshing,
refreshDisabled,
freshness,
refreshButtonRef,
diffStyle,
onDiffStyleChange,
diffStyleForced,
}: {
onRefresh: () => void;
refreshing: boolean;
/** True when the server has nothing new to show (to_sha unchanged). */
refreshDisabled: boolean;
freshness: string | null;
refreshButtonRef?: RefObject<HTMLButtonElement | null>;
diffStyle: DiffStyle;
onDiffStyleChange: (style: DiffStyle) => void;
/**
* True when the md breakpoint has forced unified view the toggle
* reflects the forced state but saving it would stomp the user's
* desktop preference, so the parent keeps persistence off while
* `diffStyleForced` is true.
*/
diffStyleForced: boolean;
}) {
const disabled = refreshing || refreshDisabled;
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">
<div className="flex items-center gap-3">
<span aria-live="polite" className="min-w-0 truncate">
{freshness ?? "\u00A0"}
</span>
</div>
<div className="flex items-center gap-2">
<DiffStyleToggle
value={diffStyle}
onChange={onDiffStyleChange}
forced={diffStyleForced}
/>
<button
ref={refreshButtonRef}
type="button"
onClick={onRefresh}
disabled={disabled}
aria-label={refreshing ? "Refreshing files" : "Refresh files"}
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>
</div>
);
}
function DiffStyleToggle({
value,
onChange,
forced,
}: {
value: DiffStyle;
onChange: (style: DiffStyle) => void;
forced: boolean;
}) {
const btn =
"min-h-[44px] rounded-md border border-line px-3 py-1 text-xs font-medium transition-colors disabled:opacity-60";
const active = "bg-overlay text-fg-1";
const inactive = "bg-panel text-fg-2 hover:bg-overlay";
return (
<div
className="flex items-center gap-1"
role="group"
aria-label="Diff layout"
>
<button
type="button"
onClick={() => onChange("split")}
disabled={forced}
aria-pressed={value === "split"}
className={`${btn} ${value === "split" ? active : inactive}`}
>
Split
</button>
<button
type="button"
onClick={() => onChange("unified")}
disabled={forced}
aria-pressed={value === "unified"}
className={`${btn} ${value === "unified" ? active : inactive}`}
>
Unified
</button>
</div>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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-yx17vb64.js"></script>
<script type="module" src="/assets/entry-mf9xvex6.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>