feat(web): promote Approve to dedicated buttons

Pending approval is the only action a user can take to unblock a run,
so the Approve action shouldn't be buried in the Actions dropdown.

- Run detail header: render a primary teal "Approve" button beside the
  Actions menu when approval is pending; remove the duplicate menu item.
- Board view (/runs): surface `pendingApproval` on RunItem and render
  an inline Approve button on cards in the Pending column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-25 11:57:19 -04:00
parent 47d8d6365b
commit 7cc38e2f8d
No known key found for this signature in database
3 changed files with 66 additions and 19 deletions

View file

@ -33,6 +33,7 @@ export interface RunItem {
actionDisabled?: boolean;
comments?: number;
question?: string;
pendingApproval?: boolean;
sandboxId?: string;
sandboxWorkingDirectory?: string;
sourceDirectory?: string;
@ -98,6 +99,9 @@ export function mapRunListItem(item: Run): RunItem {
elapsed: item.timing != null ? formatDurationMs(item.timing.wall_time_ms) : undefined,
resources: undefined,
question: item.current_question?.text,
pendingApproval:
item.lifecycle.status.kind === "pending"
&& item.lifecycle.approval?.state === "pending",
sandboxId: runtime?.id ?? undefined,
sandboxWorkingDirectory: runtime?.working_directory ?? undefined,
sourceDirectory: item.source_directory ?? undefined,

View file

@ -8,6 +8,7 @@ import {
} from "react";
import {
ArrowPathIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
@ -44,6 +45,7 @@ import {
PopoverHeader,
PopoverRow,
PopoverRows,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
Tooltip,
} from "../components/ui";
@ -701,6 +703,22 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</HoverCard>
)}
{approvalActionVisible && (
<button
type="button"
onClick={() => void approveMutation.trigger()}
disabled={approvePending}
className={PRIMARY_BUTTON_CLASS}
>
{approvePending ? (
<ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
{approvePending ? "Approving…" : "Approve"}
</button>
)}
<ActionsMenu
runId={params.id}
canSendInterrupt={statusKind === "running"}
@ -719,9 +737,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
canArchive={visibility.showArchive}
archivePending={archivePending}
onArchive={() => void archiveMutation.trigger()}
canApprove={approvalActionVisible}
approvePending={approvePending}
onApprove={() => void approveMutation.trigger()}
canDeny={approvalActionVisible}
denyPending={denyPending}
onDeny={() => void denyMutation.trigger()}
@ -961,9 +977,7 @@ interface ActionsMenuProps {
canArchive: boolean;
archivePending: boolean;
onArchive: () => void;
canApprove: boolean;
approvePending: boolean;
onApprove: () => void;
canDeny: boolean;
denyPending: boolean;
onDeny: () => void;
@ -988,7 +1002,7 @@ function ActionsMenu(props: ActionsMenuProps) {
canFocusSteer, onFocusSteer,
canPreview, previewPending, onPreview,
canArchive, archivePending, onArchive,
canApprove, approvePending, onApprove,
approvePending,
canDeny, denyPending, onDeny,
canRetry, retryPending, onRetry,
canUnarchive, unarchivePending, onUnarchive,
@ -998,7 +1012,7 @@ function ActionsMenu(props: ActionsMenuProps) {
const [runIdCopied, setRunIdCopied] = useState(false);
const hasLifecycle = canApprove || canRetry || canArchive || canUnarchive;
const hasLifecycle = canRetry || canArchive || canUnarchive;
const hasDestructive = canDeny || canCancel || canDelete;
const anyPending =
previewPending ||
@ -1083,18 +1097,6 @@ function ActionsMenu(props: ActionsMenuProps) {
{separators.afterOperations && (
<div className="my-1 h-px bg-line" role="separator" />
)}
{canApprove && (
<MenuItem>
<button
type="button"
onClick={onApprove}
disabled={approvePending}
className={MENU_ITEM_CLASS}
>
{approvePending ? "Approving…" : "Approve"}
</button>
</MenuItem>
)}
{canRetry && (
<MenuItem>
<button

View file

@ -33,7 +33,7 @@ import { ConfirmDialog } from "../components/ui";
import { mutateRunListCaches } from "../lib/board-cache";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
import { useAllRuns, useAuthConfig, useRunsPage, useSystemInfo } from "../lib/queries";
import { archiveRuns, canArchive, canDelete, canUnarchive, deleteRuns, unarchiveRuns } from "../lib/run-actions";
import { approveRun, archiveRuns, canArchive, canDelete, canUnarchive, deleteRuns, mapError, unarchiveRuns } from "../lib/run-actions";
import type {
BatchDeleteRunsResponse,
BatchRunLifecycleResponse,
@ -437,10 +437,51 @@ function PrCard({
)}
</div>
)}
{pr.pendingApproval && (
<div className="mt-3 flex items-center gap-1.5">
<ApproveBoardButton runId={pr.id} />
</div>
)}
</div>
);
}
function ApproveBoardButton({ runId }: { runId: string }) {
const { mutate } = useSWRConfig();
const { push } = useToast();
const [pending, setPending] = useState(false);
async function handleClick(event: React.MouseEvent) {
event.stopPropagation();
event.preventDefault();
if (pending) return;
setPending(true);
try {
await approveRun(runId);
mutateRunListCaches(mutate);
push({ message: "Run approved." });
} catch (error) {
push({ message: mapError(error, "approve"), tone: "error" });
} finally {
setPending(false);
}
}
return (
<button
type="button"
onClick={handleClick}
onPointerDown={(event) => event.stopPropagation()}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-md bg-teal-500 px-2.5 py-1 text-[11px] font-medium text-on-primary transition-colors hover:bg-teal-300 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
>
<CheckIcon className="size-3" aria-hidden="true" />
{pending ? "Approving…" : "Approve"}
</button>
);
}
function SortablePrCard({
pr,
iconColor,