diff --git a/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx b/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx index b1582dbef..8d543e24c 100644 --- a/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx +++ b/apps/fabro-web/app/components/runs-list/row-actions-menu.tsx @@ -12,9 +12,13 @@ import { canCancel, canDelete, canUnarchive, + cancellationActionLabel, + cancellationSuccessMessage, cancelRun, + deleteErrorMessage, deleteRun, denyRun, + isCancellationPendingState, mapError, retryRun, unarchiveRun, @@ -32,7 +36,8 @@ const MENU_ITEM_DANGER_CLASS = export function RowActionsMenu({ run }: { run: RunWithStatus }) { const { mutate } = useSWRConfig(); const { push } = useToast(); - const [pending, setPending] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [optimisticallyCancelled, setOptimisticallyCancelled] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [idCopied, setIdCopied] = useState(false); @@ -44,6 +49,12 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { const showUnarchive = canUnarchive(status); const showCancel = canCancel(status); const showDelete = canDelete(status); + const cancellationPending = isCancellationPendingState( + status, + run.pendingControl, + pendingAction === "cancel" || optimisticallyCancelled, + ); + const pending = pendingAction !== null || cancellationPending; const hasLifecycle = showRetry || showArchive || showUnarchive; const hasDestructive = showDeny || showCancel || showDelete; @@ -51,17 +62,25 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { async function runAction( label: LifecycleAction, action: () => Promise, - successMessage: string, + successMessage: string | ((result: T) => string), ) { if (pending) return; - setPending(true); + setPendingAction(label); try { - await action(); - push({ message: successMessage }); + const result = await action(); + if (label === "cancel") { + setOptimisticallyCancelled(true); + } + push({ + message: + typeof successMessage === "function" + ? successMessage(result) + : successMessage, + }); } catch (error) { push({ message: mapError(error, label), tone: "error" }); } finally { - setPending(false); + setPendingAction(null); mutateRunListCaches(mutate); } } @@ -81,15 +100,14 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { async function handleDeleteConfirm() { if (pending) return; - setPending(true); + setPendingAction("delete"); try { await deleteRun(run.id); push({ message: "Deleted run." }); } catch (error) { - // deleteRun throws LifecycleActionError shapes via lifecycleActionErrorFromError - push({ message: mapError(error, "archive"), tone: "error" }); + push({ message: deleteErrorMessage(error), tone: "error" }); } finally { - setPending(false); + setPendingAction(null); setDeleteDialogOpen(false); mutateRunListCaches(mutate); } @@ -208,12 +226,12 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) { )} diff --git a/apps/fabro-web/app/components/steer-bar.test.tsx b/apps/fabro-web/app/components/steer-bar.test.tsx new file mode 100644 index 000000000..33fefa556 --- /dev/null +++ b/apps/fabro-web/app/components/steer-bar.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + isInterruptDisabled, + SteerWaitingStatus, +} from "./steer-bar"; + +describe("SteerBar", () => { + test("shows durable waiting state and prevents a second interrupt", () => { + expect(isInterruptDisabled(true, false)).toBe(true); + expect(isInterruptDisabled(false, true)).toBe(true); + expect(isInterruptDisabled(false, false)).toBe(false); + + const html = renderToStaticMarkup( + createElement(SteerWaitingStatus, { waitingForSteer: true }), + ); + expect(html).toContain('role="status"'); + expect(html).toContain("Interrupted — waiting for steering"); + }); +}); diff --git a/apps/fabro-web/app/components/steer-bar.tsx b/apps/fabro-web/app/components/steer-bar.tsx index 6c169dc9a..b78a62f92 100644 --- a/apps/fabro-web/app/components/steer-bar.tsx +++ b/apps/fabro-web/app/components/steer-bar.tsx @@ -13,6 +13,7 @@ import { ErrorMessage } from "./ui"; export interface SteerBarProps { runId: string; + waitingForSteer?: boolean; ref?: Ref; } @@ -20,13 +21,38 @@ export interface SteerBarHandle { focus(): void; } -export function SteerBar({ runId, ref }: SteerBarProps) { +export function isInterruptDisabled( + waitingForSteer: boolean, + mutationPending: boolean, +): boolean { + return waitingForSteer || mutationPending; +} + +export function SteerWaitingStatus({ + waitingForSteer, +}: { + waitingForSteer: boolean; +}) { + if (!waitingForSteer) return null; + return ( +

+ Interrupted — waiting for steering +

+ ); +} + +export function SteerBar({ + runId, + waitingForSteer = false, + ref, +}: SteerBarProps) { const [text, setText] = useState(""); const [errorMessage, setErrorMessage] = useState(null); const textareaRef = useRef(null); const steer = useSteerRun(runId); const interrupt = useInterruptRun(runId); const pending = steer.isMutating || interrupt.isMutating; + const interruptDisabled = isInterruptDisabled(waitingForSteer, pending); useImperativeHandle(ref, () => ({ focus() { @@ -49,7 +75,7 @@ export function SteerBar({ runId, ref }: SteerBarProps) { } async function fireInterrupt() { - if (pending) return; + if (interruptDisabled) return; setErrorMessage(null); try { await interrupt.trigger(); @@ -91,7 +117,7 @@ export function SteerBar({ runId, ref }: SteerBarProps) {