import { useEffect, useRef, useState, type CSSProperties, } from "react"; import { ArrowPathIcon, ChevronDownIcon, ChevronRightIcon, ClockIcon, FolderIcon, RectangleStackIcon, SignalIcon, } from "@heroicons/react/20/solid"; 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 { ConfirmDialog, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui"; import { isRunStatus, mapRunSummaryToRunItem, runStatusDisplay, type RunSummary, } from "../data/runs"; import { useDemoMode } from "../lib/demo-mode"; import { useSWRConfig } from "swr"; import { useArchiveRun, useCancelRun, useInterruptRun, usePreviewRun, useUnarchiveRun, type LifecycleMutationResult, 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, type LifecycleActionError, } from "../lib/run-actions"; const allTabs = [ { name: "Overview", path: "", count: null, demoOnly: false }, { name: "Stages", path: "/stages", count: null, demoOnly: false }, { name: "Files Changed", path: "/files", count: null, demoOnly: false }, { name: "Billing", path: "/billing", count: null, demoOnly: false }, ]; export const handle = { hideHeader: true }; export function focusSteerAfterMenuClose(focus: () => void) { globalThis.setTimeout(focus, 0); } export function actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive, }: { hasLifecycle: boolean; hasDestructive: boolean; }) { return { afterOperations: hasLifecycle || hasDestructive, beforeDestructive: hasLifecycle && hasDestructive, }; } const ACTIONS_TRIGGER_CLASS = `${SECONDARY_BUTTON_CLASS} disabled:cursor-not-allowed disabled:opacity-60`; const MENU_ITEM_CLASS = "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"; const MENU_ITEM_DANGER_CLASS = "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"; function classNames(...classes: Array) { return classes.filter(Boolean).join(" "); } function useTickingNow(intervalMs: number): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), intervalMs); return () => clearInterval(id); }, [intervalMs]); return now; } type RunDetailRun = ReturnType & { statusLabel: string; statusDot: string; statusText: string; }; export type RunDetailActionResult = PreviewMutationResult | LifecycleMutationResult; export interface LifecycleToastState { activeArchiveToastId: string | null; lastProcessed: Record; } type ToastApi = Pick, "push" | "dismiss">; const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = { activeArchiveToastId: null, lastProcessed: { cancel: null, archive: null, unarchive: null }, }; export function lifecycleActionVisibility(status: string | null | undefined) { return { showPrimaryCancel: canCancel(status), showArchive: canArchive(status), showUnarchive: canUnarchive(status), showDelete: canDelete(status), }; } function buildRunDetailRun(summary: RunSummary): RunDetailRun { const item = mapRunSummaryToRunItem(summary); const rawStatus = summary.status; const statusKind = rawStatus.kind; const display = isRunStatus(statusKind) ? runStatusDisplay[statusKind] : { label: statusKind, dot: "bg-fg-muted", text: "text-fg-muted" }; return { ...item, statusLabel: display.label, statusDot: display.dot, statusText: display.text, }; } export function meta({ data }: any) { const run = data?.run; return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }]; } export default function RunDetail({ params }: { params: { id: string } }) { const demoMode = useDemoMode(); const runQuery = useRun(params.id); const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null; const statusKind = runQuery.data?.status?.kind; const isBlocked = statusKind === "blocked"; const questionsQuery = useRunQuestions(params.id, isBlocked); const pendingQuestions = questionsQuery.data ?? []; const { pathname } = useLocation(); const matches = useMatches(); const basePath = `/runs/${params.id}`; const previewMutation = usePreviewRun(params.id); const cancelMutation = useCancelRun(params.id); 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 .map((tab) => tab.name === "Files Changed" ? { ...tab, count: filesCount } : tab, ) .filter((t) => !t.demoOnly || demoMode); const lifecycleToastStateRef = useRef(INITIAL_LIFECYCLE_TOAST_STATE); const steerBarRef = useRef(null); const now = useTickingNow(30_000); const fullHeight = matches.some( (m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight, ); useRunEvents(params.id); useRunToasts(params.id); useEffect(() => { if (previewMutation.data?.intent === "preview") { window.open(previewMutation.data.url, "_blank"); } }, [previewMutation.data]); useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "cancel", cancelMutation.data, lifecycleToastStateRef.current, { push, dismiss }, ); }, [cancelMutation.data, dismiss, push]); useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "archive", archiveMutation.data, lifecycleToastStateRef.current, { push, dismiss }, ); }, [archiveMutation.data, dismiss, push]); useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "unarchive", unarchiveMutation.data, lifecycleToastStateRef.current, { push, dismiss }, ); }, [dismiss, push, unarchiveMutation.data]); if (runQuery.isLoading && !run) { return
; } if (!run) { return (
); } const visibility = lifecycleActionVisibility(run.lifecycleStatus); const previewPending = previewMutation.isMutating; 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 = { "--fabro-interview-dock-clearance": dockClearance, } as CSSProperties; return (

{run.title}

{run.statusLabel} {run.elapsed && ( )} {run.lastEventAt && ( )}
{demoMode && } void interruptMutation.trigger()} canFocusSteer={statusKind === "running" && !hasPendingQuestions} onFocusSteer={() => { focusSteerAfterMenuClose(() => steerBarRef.current?.focus()); }} canPreview={!!run.sandboxId} previewPending={previewPending} onPreview={() => void previewMutation.trigger({ port: 3000, expires_in_secs: 3600, })} canArchive={visibility.showArchive} archivePending={archivePending} onArchive={() => void archiveMutation.trigger()} 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)} />
{hasPendingQuestions ? ( ) : ( )}
); } function isLifecycleActionFailure( value: RunDetailActionResult, ): value is Extract { return "ok" in value && value.ok === false; } export function handleLifecycleToastResult( intent: LifecycleAction, result: RunDetailActionResult | undefined, state: LifecycleToastState, toastApi: ToastApi, ): LifecycleToastState { if (!result || result.intent !== intent) return state; if (state.lastProcessed[intent] === result) return state; const nextState: LifecycleToastState = { ...state, lastProcessed: { ...state.lastProcessed, [intent]: result }, }; if (isLifecycleActionFailure(result)) { toastApi.push({ message: mapError(result.error, intent), tone: "error" }); return nextState; } if (intent === "cancel") { toastApi.push({ message: isTerminalCancelledRun(result.run) ? "Run cancelled." : "Cancellation requested.", }); return nextState; } if (state.activeArchiveToastId) { toastApi.dismiss(state.activeArchiveToastId); } if (intent === "archive") { return { ...nextState, activeArchiveToastId: toastApi.push({ message: "Run archived." }), }; } toastApi.push({ message: "Run restored." }); return { ...nextState, activeArchiveToastId: null }; } function ConnectMenu() { return ( Connect ); } interface ActionsMenuProps { canSendInterrupt: boolean; interruptPending: boolean; onSendInterrupt: () => void; canFocusSteer: boolean; onFocusSteer: () => void; canPreview: boolean; previewPending: boolean; onPreview: () => void; canArchive: boolean; archivePending: boolean; onArchive: () => void; canUnarchive: boolean; unarchivePending: boolean; onUnarchive: () => void; canDelete: boolean; deletePending: boolean; onDelete: () => void; canCancel: boolean; cancelPending: boolean; onCancel: () => void; } function ActionsMenu(props: ActionsMenuProps) { const { canSendInterrupt, interruptPending, onSendInterrupt, canFocusSteer, onFocusSteer, 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 || canDelete; const hasAny = hasOps || hasLifecycle || hasDestructive; const anyPending = previewPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending; const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive }); if (!hasAny) return null; return ( {anyPending && {canPreview && ( )} {separators.afterOperations && (
)} {canArchive && ( )} {canUnarchive && ( )} {separators.beforeDestructive && (
)} {canCancel && ( )} {canDelete && ( )}
); }