diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx index 485941dbf..930ba8f1c 100644 --- a/apps/fabro-web/app/components/ui.tsx +++ b/apps/fabro-web/app/components/ui.tsx @@ -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 ( + { + if (!pending) onCancel(); + }} + className="relative z-50" + > + + ); +} + export function Tooltip({ label, children, diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index 326fc346f..c2b8a394c 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -38,6 +38,16 @@ export async function unarchiveRun(id: string, request?: Request): Promise { + 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) { diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 55bcc6031..85b81b759 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -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()} /> + + This permanently removes {run.title} and its + durable state. This action cannot be undone. + + } + confirmLabel="Delete run" + pendingLabel="Deleting…" + pending={deletePending} + onConfirm={() => void handleConfirmDelete()} + onCancel={() => setDeleteDialogOpen(false)} + /> +
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) { )} + {canDelete && ( + + + + )} );