mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
9d59f4d8d0
commit
7c0ff8861f
3 changed files with 155 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue