diff --git a/apps/dashboard/src/components/ResumeRunActions.tsx b/apps/dashboard/src/components/ResumeRunActions.tsx index 3ef4f5e27..deb19843a 100644 --- a/apps/dashboard/src/components/ResumeRunActions.tsx +++ b/apps/dashboard/src/components/ResumeRunActions.tsx @@ -1,5 +1,5 @@ /** - * ResumeRunActions — header buttons for resuming an interrupted run. + * ResumeRunActions — run-detail Actions menu for resuming an interrupted run. * * Surfaces the existing CLI resume mechanics (`--resume`, `--rerun-failed`) * via the launch endpoint when the loaded run contains at least one result @@ -17,13 +17,14 @@ */ import { useNavigate } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { launchEvalRun } from '~/lib/api'; import type { EvalResult } from '~/lib/types'; import { type ResumeMode, + buildResumeActionMenuItems, buildResumeRequestBody, shouldShowResumeActions, } from './resume-run-helpers'; @@ -51,10 +52,11 @@ export function ResumeRunActions({ runStatus, }: ResumeRunActionsProps) { const navigate = useNavigate(); + const menuRef = useRef(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); - - if (!shouldShowResumeActions(results, isReadOnly, plannedTestCount, runStatus)) return null; + const [open, setOpen] = useState(false); + const showActions = shouldShowResumeActions(results, isReadOnly, plannedTestCount, runStatus); // Both actions need the run dir + the original eval file. Without those // we can't target the existing run workspace, so we render the buttons @@ -66,9 +68,36 @@ export function ResumeRunActions({ : !suiteFilter ? 'Original eval file path missing from summary.json — cannot determine what to resume' : ''; + const menuItems = buildResumeActionMenuItems({ ready, busy, disabledReason }); + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: PointerEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + } + } + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + if (!showActions) return null; async function launch(mode: ResumeMode) { if (!ready || !runDir || !suiteFilter) return; + setOpen(false); setBusy(mode); setError(null); try { @@ -89,28 +118,41 @@ export function ResumeRunActions({ } return ( -
-
- +
+
+ {open && ( +
+ {menuItems.map((item) => ( + + ))} +
+ )}
{error &&

{error}

}
diff --git a/apps/dashboard/src/components/RunEvalModal.tsx b/apps/dashboard/src/components/RunEvalModal.tsx index 6d126aeeb..00fe63dfa 100644 --- a/apps/dashboard/src/components/RunEvalModal.tsx +++ b/apps/dashboard/src/components/RunEvalModal.tsx @@ -204,7 +204,7 @@ export function RunEvalModal({ open, onClose, projectId, prefill }: RunEvalModal const canLaunch = !!(suiteFilter.trim() || testIds.length > 0); return ( - +
{/* Suite filter */}
@@ -413,7 +413,7 @@ export function RunEvalModal({ open, onClose, projectId, prefill }: RunEvalModal disabled={!canLaunch || launching} className="rounded-md bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50" > - {launching ? 'Launching…' : 'Run Now'} + {launching ? 'Launching…' : 'Start'}
diff --git a/apps/dashboard/src/components/resume-run-helpers.test.ts b/apps/dashboard/src/components/resume-run-helpers.test.ts index d2e977f0e..8dc233a64 100644 --- a/apps/dashboard/src/components/resume-run-helpers.test.ts +++ b/apps/dashboard/src/components/resume-run-helpers.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test'; import type { EvalResult } from '~/lib/types'; -import { buildResumeRequestBody, shouldShowResumeActions } from './resume-run-helpers'; +import { + buildResumeActionMenuItems, + buildResumeRequestBody, + shouldShowResumeActions, +} from './resume-run-helpers'; const ok = (testId: string): EvalResult => ({ testId, @@ -78,7 +82,7 @@ describe('buildResumeRequestBody', () => { }); }); - it('builds a rerun-failed request with rerun_failed:true (and no resume key)', () => { + it('builds a rerun-failed request with rerun_failed:true and no resume key', () => { const body = buildResumeRequestBody({ mode: 'rerun', runDir: 'runs/r1', @@ -108,3 +112,30 @@ describe('buildResumeRequestBody', () => { }); }); }); + +describe('buildResumeActionMenuItems', () => { + it('renders concise action labels in the requested order', () => { + expect( + buildResumeActionMenuItems({ + ready: true, + busy: null, + disabledReason: '', + }).map((item) => item.label), + ).toEqual(['Re-run', 'Resume']); + }); + + it('keeps unavailable actions visible but disabled with an explanation', () => { + const items = buildResumeActionMenuItems({ + ready: false, + busy: null, + disabledReason: 'Run directory unavailable', + }); + + expect(items[0]?.mode).toBe('rerun'); + expect(items[0]?.disabled).toBe(true); + expect(items[0]?.title).toBe('Run directory unavailable'); + expect(items[1]?.mode).toBe('resume'); + expect(items[1]?.disabled).toBe(true); + expect(items[1]?.title).toBe('Run directory unavailable'); + }); +}); diff --git a/apps/dashboard/src/components/resume-run-helpers.ts b/apps/dashboard/src/components/resume-run-helpers.ts index c8cd881fc..42ca0bb03 100644 --- a/apps/dashboard/src/components/resume-run-helpers.ts +++ b/apps/dashboard/src/components/resume-run-helpers.ts @@ -22,6 +22,20 @@ export interface BuildResumeRequestParams { target?: string; } +export interface BuildResumeActionMenuItemsParams { + ready: boolean; + busy: ResumeMode | null; + disabledReason: string; +} + +export interface ResumeActionMenuItem { + mode: ResumeMode; + label: string; + title: string; + disabled: boolean; + testId: string; +} + /** * Whether the resume actions should be visible. The button is shown when: * 1. At least one recorded row has `execution_status: execution_error`, OR @@ -68,3 +82,27 @@ export function buildResumeRequestBody(params: BuildResumeRequestParams): RunEva } return body; } + +export function buildResumeActionMenuItems( + params: BuildResumeActionMenuItemsParams, +): ResumeActionMenuItem[] { + const disabled = !params.ready || params.busy !== null; + return [ + { + mode: 'rerun', + label: 'Re-run', + title: params.ready + ? 'Re-run failed or missing work, keep passing results' + : params.disabledReason, + disabled, + testId: 'rerun-failed-menu-item', + }, + { + mode: 'resume', + label: 'Resume', + title: params.ready ? 'Resume missing or errored work' : params.disabledReason, + disabled, + testId: 'resume-run-menu-item', + }, + ]; +} diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index 35211cb6b..8d2265c47 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -162,7 +162,7 @@ function ProjectsDashboard() { onClick={() => setShowRunEval(true)} className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500" > - ▶ Run Eval + Start )}
diff --git a/apps/dashboard/src/routes/projects/$projectId.tsx b/apps/dashboard/src/routes/projects/$projectId.tsx index cb4fe387f..f1bc76b3b 100644 --- a/apps/dashboard/src/routes/projects/$projectId.tsx +++ b/apps/dashboard/src/routes/projects/$projectId.tsx @@ -70,7 +70,7 @@ function ProjectHomePage() { onClick={() => setShowRunEval(true)} className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500" > - ▶ Run Eval + Start )}
diff --git a/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx b/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx index 1192d4463..7a4fb30d0 100644 --- a/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx +++ b/apps/dashboard/src/routes/projects/$projectId_/runs/$runId.tsx @@ -117,7 +117,7 @@ function ProjectRunDetailPage() { onClick={() => setShowRunEval(true)} className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500" > - ▶ Run evals + Start )} diff --git a/apps/dashboard/src/routes/runs/$runId.tsx b/apps/dashboard/src/routes/runs/$runId.tsx index e9c93f695..900d656d5 100644 --- a/apps/dashboard/src/routes/runs/$runId.tsx +++ b/apps/dashboard/src/routes/runs/$runId.tsx @@ -112,7 +112,7 @@ function RunDetailPage() { onClick={() => setShowRunEval(true)} className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500" > - ▶ Run evals + Start )}