mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
feat(web): rewrite Run Files tab, surface it in navigation
Rewrites apps/fabro-web/app/routes/run-files.tsx to consume the real PaginatedRunFileList response and removes the fallbackFiles fixture and the Steer subsystem. The new component: - Loads via apiJsonOrNull, so a 404/501 (dev without the route) renders the empty state instead of the root error boundary - Branches on meta.degraded + meta.patch to render PatchDiff with a DegradedBanner whose copy reflects degraded_reason - Renders per-entry placeholders for sensitive, binary, symlink/ submodule, and truncated files with the priority order sensitive > binary > symlink/submodule > truncated -- security flags never get hidden behind a lesser placeholder - Renders one MultiFileDiff per regular entry - Uses role="region" + aria-label on each file row Also unhides the Files Changed tab in run-detail.tsx by flipping broken: true -> false. Adds missing final_patch: None to the runner RunFailed test fixtures to match the lifecycle change from Unit 2. 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:
parent
420e375134
commit
731a8cc6e0
6 changed files with 327 additions and 826 deletions
|
|
@ -11,7 +11,7 @@ import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
|||
const allTabs = [
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Stages", path: "/stages", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,544 +1,209 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
MultiFileDiff,
|
||||
type AnnotationSide,
|
||||
type DiffLineAnnotation,
|
||||
} from "@pierre/diffs/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { MultiFileDiff, PatchDiff } from "@pierre/diffs/react";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunFileList } from "@qltysh/fabro-api-client";
|
||||
import { apiJsonOrNull } from "../api";
|
||||
import type {
|
||||
FileDiff as ApiFileDiff,
|
||||
PaginatedRunFileList,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export async function loader({ request, params }: any) {
|
||||
const data = await apiJson<PaginatedRunFileList>(`/runs/${params.id}/files`, { request });
|
||||
const data = await apiJsonOrNull<PaginatedRunFileList>(
|
||||
`/runs/${params.id}/files`,
|
||||
{ request },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
const fallbackFiles = [
|
||||
{
|
||||
oldFile: {
|
||||
name: "src/commands/run.ts",
|
||||
contents: `import { parseArgs } from "node:util";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { execute } from "../executor.js";
|
||||
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";
|
||||
|
||||
interface RunOptions {
|
||||
config: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export async function run(argv: string[]) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
config: { type: "string", short: "c", default: ".fabro/project.toml" },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const opts: RunOptions = {
|
||||
config: values.config ?? ".fabro/project.toml",
|
||||
dryRun: values["dry-run"] ?? false,
|
||||
};
|
||||
|
||||
const config = await loadConfig(opts.config);
|
||||
const result = await execute(config, { dryRun: opts.dryRun });
|
||||
|
||||
if (result.success) {
|
||||
console.log("Run completed successfully.");
|
||||
} else {
|
||||
console.error("Run failed:", result.error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
newFile: {
|
||||
name: "src/commands/run.ts",
|
||||
contents: `import { parseArgs } from "node:util";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { execute } from "../executor.js";
|
||||
import { createLogger, type Logger } from "../logger.js";
|
||||
|
||||
interface RunOptions {
|
||||
config: string;
|
||||
dryRun: boolean;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export async function run(argv: string[]) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
config: { type: "string", short: "c", default: ".fabro/project.toml" },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
verbose: { type: "boolean", short: "v", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const opts: RunOptions = {
|
||||
config: values.config ?? ".fabro/project.toml",
|
||||
dryRun: values["dry-run"] ?? false,
|
||||
verbose: values.verbose ?? false,
|
||||
};
|
||||
|
||||
const logger: Logger = createLogger({ verbose: opts.verbose });
|
||||
|
||||
const config = await loadConfig(opts.config);
|
||||
logger.debug("Loaded config from %s", opts.config);
|
||||
|
||||
const result = await execute(config, { dryRun: opts.dryRun, logger });
|
||||
logger.debug("Execution finished in %dms", result.elapsed);
|
||||
|
||||
if (result.success) {
|
||||
console.log("Run completed successfully.");
|
||||
} else {
|
||||
console.error("Run failed:", result.error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
oldFile: {
|
||||
name: "src/logger.ts",
|
||||
contents: "",
|
||||
},
|
||||
newFile: {
|
||||
name: "src/logger.ts",
|
||||
contents: `export interface Logger {
|
||||
info(message: string, ...args: unknown[]): void;
|
||||
debug(message: string, ...args: unknown[]): void;
|
||||
error(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
interface LoggerOptions {
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export function createLogger({ verbose }: LoggerOptions): Logger {
|
||||
return {
|
||||
info(message, ...args) {
|
||||
console.log(message, ...args);
|
||||
},
|
||||
debug(message, ...args) {
|
||||
if (verbose) {
|
||||
console.log("[debug]", message, ...args);
|
||||
}
|
||||
},
|
||||
error(message, ...args) {
|
||||
console.error(message, ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
oldFile: {
|
||||
name: "src/executor.ts",
|
||||
contents: `import type { Config } from "./config.js";
|
||||
|
||||
interface ExecuteOptions {
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface ExecuteResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
config: Config,
|
||||
options: ExecuteOptions,
|
||||
): Promise<ExecuteResult> {
|
||||
if (options.dryRun) {
|
||||
console.log("Dry run — skipping execution.");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
for (const step of config.steps) {
|
||||
await step.run();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
newFile: {
|
||||
name: "src/executor.ts",
|
||||
contents: `import type { Config } from "./config.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
interface ExecuteOptions {
|
||||
dryRun: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
interface ExecuteResult {
|
||||
success: boolean;
|
||||
elapsed: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
config: Config,
|
||||
options: ExecuteOptions,
|
||||
): Promise<ExecuteResult> {
|
||||
const start = performance.now();
|
||||
|
||||
if (options.dryRun) {
|
||||
options.logger.info("Dry run — skipping execution.");
|
||||
return { success: true, elapsed: performance.now() - start };
|
||||
}
|
||||
|
||||
try {
|
||||
for (const step of config.steps) {
|
||||
options.logger.debug("Running step: %s", step.name);
|
||||
await step.run();
|
||||
}
|
||||
return { success: true, elapsed: performance.now() - start };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, elapsed: performance.now() - start, error: message };
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface SteerAnnotation {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
side: AnnotationSide;
|
||||
}
|
||||
|
||||
function steerKey(fileName: string, side: AnnotationSide, lineNumber: number) {
|
||||
return `${fileName}:${side}:${lineNumber}`;
|
||||
}
|
||||
|
||||
function DiffHeaderToggles({
|
||||
diffStyle,
|
||||
onDiffStyleChange,
|
||||
disableBackground,
|
||||
onDisableBackgroundChange,
|
||||
}: {
|
||||
diffStyle: "split" | "unified";
|
||||
onDiffStyleChange: (style: "split" | "unified") => void;
|
||||
disableBackground: boolean;
|
||||
onDisableBackgroundChange: (disabled: boolean) => void;
|
||||
}) {
|
||||
function DegradedBanner({ reason }: { reason?: string }) {
|
||||
const copy = banner_copy_for_reason(reason);
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDiffStyleChange(diffStyle === "split" ? "unified" : "split")}
|
||||
title={diffStyle === "split" ? "Switch to unified" : "Switch to split"}
|
||||
aria-label={diffStyle === "split" ? "Switch to unified" : "Switch to split"}
|
||||
className="p-1 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0" />
|
||||
<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1" opacity="0.3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDisableBackgroundChange(!disableBackground)}
|
||||
title={disableBackground ? "Enable background" : "Disable background"}
|
||||
aria-label={disableBackground ? "Enable background" : "Disable background"}
|
||||
className="p-1 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" width="16" height="16">
|
||||
<path d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25" opacity="0.4" />
|
||||
<path fillRule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clipRule="evenodd" />
|
||||
<path d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75" opacity="0.4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-950/20 px-4 py-3 text-sm text-amber-100">
|
||||
{copy}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffWithSteer({
|
||||
oldFile,
|
||||
newFile,
|
||||
openSteers,
|
||||
submittedSteers,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
oldFile: { name: string; contents: string };
|
||||
newFile: { name: string; contents: string };
|
||||
openSteers: Map<string, SteerAnnotation>;
|
||||
submittedSteers: Map<string, SteerAnnotation & { text: string }>;
|
||||
onSteer: (annotation: SteerAnnotation) => void;
|
||||
onSubmit: (annotation: SteerAnnotation, text: string) => void;
|
||||
onCancel: (annotation: SteerAnnotation) => void;
|
||||
}) {
|
||||
const [diffStyle, setDiffStyle] = useState<"split" | "unified">("split");
|
||||
const [disableBackground, setDisableBackground] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const hoveredRef = useRef<{ lineNumber: number; side: AnnotationSide } | null>(null);
|
||||
const leaveTimeoutRef = useRef<number>(0);
|
||||
|
||||
const showButton = useCallback((lineElement: HTMLElement, lineNumber: number, side: AnnotationSide) => {
|
||||
clearTimeout(leaveTimeoutRef.current);
|
||||
hoveredRef.current = { lineNumber, side };
|
||||
|
||||
const btn = buttonRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!btn || !container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const lineRect = lineElement.getBoundingClientRect();
|
||||
|
||||
btn.style.top = `${lineRect.top - containerRect.top}px`;
|
||||
btn.style.height = `${lineRect.height}px`;
|
||||
btn.style.display = "flex";
|
||||
}, []);
|
||||
|
||||
const hideButton = useCallback(() => {
|
||||
leaveTimeoutRef.current = window.setTimeout(() => {
|
||||
hoveredRef.current = null;
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.style.display = "none";
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
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 ref={containerRef} className="relative rounded-md overflow-hidden border border-line">
|
||||
<MultiFileDiff<SteerAnnotation & { text?: string }>
|
||||
oldFile={oldFile}
|
||||
newFile={newFile}
|
||||
options={{
|
||||
diffStyle,
|
||||
disableBackground,
|
||||
theme: theme === "dark" ? "pierre-dark" : "pierre-light",
|
||||
lineDiffType: "word",
|
||||
onLineEnter({ lineNumber, annotationSide, lineElement }) {
|
||||
showButton(lineElement, lineNumber, annotationSide);
|
||||
},
|
||||
onLineLeave() {
|
||||
hideButton();
|
||||
},
|
||||
}}
|
||||
renderHeaderMetadata={() => (
|
||||
<DiffHeaderToggles
|
||||
diffStyle={diffStyle}
|
||||
onDiffStyleChange={setDiffStyle}
|
||||
disableBackground={disableBackground}
|
||||
onDisableBackgroundChange={setDisableBackground}
|
||||
/>
|
||||
)}
|
||||
lineAnnotations={buildAnnotationsForFile(
|
||||
newFile.name,
|
||||
openSteers,
|
||||
submittedSteers,
|
||||
)}
|
||||
renderAnnotation={(annotation) => {
|
||||
const meta = annotation.metadata;
|
||||
if ("text" in meta && meta.text != null) {
|
||||
return <SubmittedSteerComment text={meta.text} />;
|
||||
}
|
||||
return (
|
||||
<SteerCommentForm
|
||||
annotation={meta}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => onCancel(meta)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
<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 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}
|
||||
/>
|
||||
<div
|
||||
ref={buttonRef}
|
||||
style={{ display: "none" }}
|
||||
className="absolute right-2 z-10 items-center justify-end"
|
||||
onMouseEnter={() => clearTimeout(leaveTimeoutRef.current)}
|
||||
onMouseLeave={() => {
|
||||
hoveredRef.current = null;
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.style.display = "none";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const h = hoveredRef.current;
|
||||
if (h) {
|
||||
onSteer({
|
||||
fileName: newFile.name,
|
||||
lineNumber: h.lineNumber,
|
||||
side: h.side,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded bg-teal-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-teal-500"
|
||||
>
|
||||
Steer
|
||||
<kbd className="rounded bg-teal-500/50 px-1 py-px font-sans text-[10px] text-teal-100">
|
||||
⌘Y
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SteerCommentForm({
|
||||
annotation,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
annotation: SteerAnnotation;
|
||||
onSubmit: (annotation: SteerAnnotation, text: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-teal-500/30 bg-panel-alt/90 p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Add steering guidance..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded border border-line bg-panel/80 px-2 py-1.5 text-sm text-fg-2 placeholder:text-fg-muted outline-none focus:border-focus"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit(annotation, text)}
|
||||
disabled={text.trim().length === 0}
|
||||
className="rounded bg-teal-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-teal-500 disabled:opacity-40 disabled:hover:bg-teal-600"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-line bg-panel/80 px-3 py-1 text-xs font-medium text-fg-3 transition-colors hover:bg-overlay"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmittedSteerComment({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-teal-500/20 bg-teal-950/30 px-3 py-2 text-sm text-fg-2">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnnotationsForFile(
|
||||
fileName: string,
|
||||
openSteers: Map<string, SteerAnnotation>,
|
||||
submittedSteers: Map<string, SteerAnnotation & { text: string }>,
|
||||
): DiffLineAnnotation<SteerAnnotation & { text?: string }>[] {
|
||||
const annotations: DiffLineAnnotation<SteerAnnotation & { text?: string }>[] = [];
|
||||
|
||||
for (const [, annotation] of openSteers) {
|
||||
if (annotation.fileName === fileName) {
|
||||
annotations.push({
|
||||
side: annotation.side,
|
||||
lineNumber: annotation.lineNumber,
|
||||
metadata: annotation,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
for (const [, annotation] of submittedSteers) {
|
||||
if (annotation.fileName === fileName) {
|
||||
annotations.push({
|
||||
side: annotation.side,
|
||||
lineNumber: annotation.lineNumber,
|
||||
metadata: annotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RunFiles({ loaderData }: any) {
|
||||
const runFiles = loaderData;
|
||||
const files = runFiles.data.length > 0
|
||||
? runFiles.data.map((f) => ({
|
||||
oldFile: { name: f.old_file.name, contents: f.old_file.contents },
|
||||
newFile: { name: f.new_file.name, contents: f.new_file.contents },
|
||||
}))
|
||||
: fallbackFiles;
|
||||
const [openSteers, setOpenSteers] = useState(
|
||||
() => new Map<string, SteerAnnotation>(),
|
||||
);
|
||||
const [submittedSteers, setSubmittedSteers] = useState(
|
||||
() => new Map<string, SteerAnnotation & { text: string }>(),
|
||||
);
|
||||
const theme = useTheme();
|
||||
const data = loaderData as PaginatedRunFileList | null;
|
||||
|
||||
function handleSteer(annotation: SteerAnnotation) {
|
||||
const key = steerKey(annotation.fileName, annotation.side, annotation.lineNumber);
|
||||
if (openSteers.has(key) || submittedSteers.has(key)) return;
|
||||
setOpenSteers((prev) => new Map(prev).set(key, annotation));
|
||||
}
|
||||
|
||||
function handleSubmit(annotation: SteerAnnotation, text: string) {
|
||||
const key = steerKey(annotation.fileName, annotation.side, annotation.lineNumber);
|
||||
setOpenSteers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
setSubmittedSteers((prev) =>
|
||||
new Map(prev).set(key, { ...annotation, text }),
|
||||
if (!data) {
|
||||
return (
|
||||
<EmptyState message="The diff for this run is not available right now." />
|
||||
);
|
||||
console.log("Steer submitted", { ...annotation, text });
|
||||
}
|
||||
|
||||
function handleCancel(annotation: SteerAnnotation) {
|
||||
const key = steerKey(annotation.fileName, annotation.side, annotation.lineNumber);
|
||||
setOpenSteers((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
const { data: files, meta } = data;
|
||||
|
||||
// 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.
|
||||
if (meta.degraded && meta.patch) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<DegradedBanner reason={meta.degraded_reason} />
|
||||
<PatchDiff
|
||||
patch={meta.patch}
|
||||
options={{
|
||||
diffStyle: "split",
|
||||
theme: theme.theme === "dark" ? "pierre-dark" : "pierre-light",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <EmptyState message="This run didn't change any files." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{files.map(({ oldFile, newFile }) => (
|
||||
<DiffWithSteer
|
||||
key={newFile.name}
|
||||
oldFile={oldFile}
|
||||
newFile={newFile}
|
||||
openSteers={openSteers}
|
||||
submittedSteers={submittedSteers}
|
||||
onSteer={handleSteer}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -676,6 +676,7 @@ mod tests {
|
|||
duration_ms: 10,
|
||||
reason: Some(StatusReason::Cancelled),
|
||||
git_commit_sha: None,
|
||||
final_patch: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Cancelled)
|
||||
);
|
||||
|
|
@ -685,6 +686,7 @@ mod tests {
|
|||
duration_ms: 10,
|
||||
reason: Some(StatusReason::Terminated),
|
||||
git_commit_sha: None,
|
||||
final_patch: None,
|
||||
})),
|
||||
Some(WorkerTitlePhase::Failed)
|
||||
);
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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-h33x4hjk.js"></script>
|
||||
<script type="module" src="/assets/entry-963f7qc4.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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue