feat(web): add Delete action to archived run actions menu

Mirrors `fabro rm RUN_ID`. Surfaces a Delete item in the run detail
Actions menu for archived runs, opens a Headless UI ConfirmDialog,
calls DELETE /api/v1/runs/{id}, invalidates the boards.runs cache,
and navigates back to /runs on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-08 06:40:33 -07:00
parent 9d59f4d8d0
commit 7c0ff8861f
No known key found for this signature in database
3 changed files with 155 additions and 6 deletions

View file

@ -2,8 +2,9 @@
// exposes the primary button, secondary button, input, error message, and
// copy button so the auth and in-app surfaces can match.
import { useId, useRef, useState } from "react";
import { useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
ClipboardDocumentCheckIcon,
ClipboardIcon,
@ -16,7 +17,10 @@ export const PRIMARY_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
export const SECONDARY_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500";
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
export const DANGER_BUTTON_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg bg-coral px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-coral/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coral disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral";
export function ErrorMessage({ message }: { message: string }) {
return (
@ -64,6 +68,66 @@ export function CopyButton({
);
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
pendingLabel,
cancelLabel = "Cancel",
pending = false,
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
description: ReactNode;
confirmLabel: string;
pendingLabel?: string;
cancelLabel?: string;
pending?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<Dialog
open={open}
onClose={() => {
if (!pending) onCancel();
}}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" aria-hidden="true" />
<div className="fixed inset-0 flex items-start justify-center pt-[20vh] px-4">
<DialogPanel className="w-full max-w-md rounded-lg border border-line-strong bg-panel shadow-2xl shadow-black/40">
<div className="px-5 py-4">
<DialogTitle className="text-sm font-semibold text-fg">{title}</DialogTitle>
<div className="mt-2 text-sm text-fg-3">{description}</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className={SECONDARY_BUTTON_CLASS}
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={pending}
className={DANGER_BUTTON_CLASS}
>
{pending ? (pendingLabel ?? `${confirmLabel}…`) : confirmLabel}
</button>
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
export function Tooltip({
label,
children,

View file

@ -38,6 +38,16 @@ export async function unarchiveRun(id: string, request?: Request): Promise<RunSt
return runLifecycleAction(id, "unarchive", request);
}
export async function deleteRun(id: string, request?: Request): Promise<void> {
const response = await apiRequest(`/api/v1/runs/${encodeURIComponent(id)}`, {
init: { method: "DELETE" },
request,
});
if (response.status === 204 || response.status === 404) return;
throw await parseLifecycleActionError(response);
}
export function canCancel(status: string | null | undefined): boolean {
return !!status && CANCELABLE_STATUSES.has(status as RunStatus);
}
@ -50,10 +60,25 @@ export function canUnarchive(status: string | null | undefined): boolean {
return status === "archived";
}
export function canDelete(status: string | null | undefined): boolean {
return status === "archived";
}
export function isTerminalCancelledRun(run: RunStatusResponse): boolean {
return run.status.kind === "failed" && run.status.reason === "cancelled";
}
export function deleteErrorMessage(error: unknown): string {
if (isLifecycleActionError(error)) {
if (error.status === 409) {
return "Active runs can't be deleted.";
}
const detail = error.errors[0]?.detail?.trim();
if (detail) return detail;
}
return "Couldn't delete the run right now. Try again.";
}
export function mapError(error: unknown, action: LifecycleAction): string {
if (isLifecycleActionError(error)) {
if (error.status === 404) {

View file

@ -13,14 +13,14 @@ import {
RectangleStackIcon,
SignalIcon,
} from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useMatches } from "react-router";
import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { InterviewDock } from "../components/interview-dock";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
import { SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
import { ConfirmDialog, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
import {
isRunStatus,
mapRunSummaryToRunItem,
@ -28,6 +28,7 @@ import {
type RunSummary,
} from "../data/runs";
import { useDemoMode } from "../lib/demo-mode";
import { useSWRConfig } from "swr";
import {
useArchiveRun,
useCancelRun,
@ -38,13 +39,17 @@ import {
type PreviewMutationResult,
} from "../lib/mutations";
import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
import { queryKeys } from "../lib/query-keys";
import { useRunEvents } from "../lib/run-events";
import { useRunToasts } from "../hooks/use-run-toasts";
import { useRun, useRunQuestions } from "../lib/queries";
import {
canArchive,
canCancel,
canDelete,
canUnarchive,
deleteErrorMessage,
deleteRun,
isTerminalCancelledRun,
mapError,
type LifecycleAction,
@ -107,6 +112,7 @@ export function lifecycleActionVisibility(status: string | null | undefined) {
showPrimaryCancel: canCancel(status),
showArchive: canArchive(status),
showUnarchive: canUnarchive(status),
showDelete: canDelete(status),
};
}
@ -147,6 +153,10 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const archiveMutation = useArchiveRun(params.id);
const unarchiveMutation = useUnarchiveRun(params.id);
const interruptMutation = useInterruptRun(params.id);
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const { push, dismiss } = useToast();
const filesCount = runQuery.data?.diff_summary?.files_changed ?? null;
const tabs = allTabs
@ -217,6 +227,21 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const cancelPending = cancelMutation.isMutating;
const archivePending = archiveMutation.isMutating;
const unarchivePending = unarchiveMutation.isMutating;
const handleConfirmDelete = async () => {
setDeletePending(true);
try {
await deleteRun(params.id);
void mutate(queryKeys.boards.runs());
void mutate(queryKeys.boards.runs(true));
push({ message: "Run deleted." });
navigate("/runs");
} catch (error) {
push({ message: deleteErrorMessage(error), tone: "error" });
} finally {
setDeletePending(false);
setDeleteDialogOpen(false);
}
};
const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;
const dockClearance = hasPendingQuestions ? "18rem" : "5rem";
const rootStyle = {
@ -304,12 +329,31 @@ export default function RunDetail({ params }: { params: { id: string } }) {
canUnarchive={visibility.showUnarchive}
unarchivePending={unarchivePending}
onUnarchive={() => void unarchiveMutation.trigger()}
canDelete={visibility.showDelete}
deletePending={deletePending}
onDelete={() => setDeleteDialogOpen(true)}
canCancel={visibility.showPrimaryCancel}
cancelPending={cancelPending}
onCancel={() => void cancelMutation.trigger()}
/>
</div>
<ConfirmDialog
open={deleteDialogOpen}
title="Delete this run?"
description={
<>
This permanently removes <span className="font-mono text-fg-2">{run.title}</span> and its
durable state. This action cannot be undone.
</>
}
confirmLabel="Delete run"
pendingLabel="Deleting…"
pending={deletePending}
onConfirm={() => void handleConfirmDelete()}
onCancel={() => setDeleteDialogOpen(false)}
/>
<div
className={classNames(
"relative before:pointer-events-none before:absolute before:bottom-0 before:left-1/2 before:h-px before:w-screen before:-translate-x-1/2 before:bg-line",
@ -456,6 +500,9 @@ interface ActionsMenuProps {
canUnarchive: boolean;
unarchivePending: boolean;
onUnarchive: () => void;
canDelete: boolean;
deletePending: boolean;
onDelete: () => void;
canCancel: boolean;
cancelPending: boolean;
onCancel: () => void;
@ -468,16 +515,17 @@ function ActionsMenu(props: ActionsMenuProps) {
canPreview, previewPending, onPreview,
canArchive, archivePending, onArchive,
canUnarchive, unarchivePending, onUnarchive,
canDelete, deletePending, onDelete,
canCancel, cancelPending, onCancel,
} = props;
const hasOps =
canPreview || canSendInterrupt || canFocusSteer;
const hasLifecycle = canArchive || canUnarchive;
const hasDestructive = canCancel;
const hasDestructive = canCancel || canDelete;
const hasAny = hasOps || hasLifecycle || hasDestructive;
const anyPending =
previewPending || archivePending || unarchivePending || cancelPending || interruptPending;
previewPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending;
if (!hasAny) return null;
@ -567,6 +615,18 @@ function ActionsMenu(props: ActionsMenuProps) {
</button>
</MenuItem>
)}
{canDelete && (
<MenuItem>
<button
type="button"
onClick={onDelete}
disabled={deletePending}
className={MENU_ITEM_DANGER_CLASS}
>
{deletePending ? "Deleting…" : "Delete"}
</button>
</MenuItem>
)}
</MenuItems>
</Menu>
);