import { useCallback, useState } from "react"; import { useNavigate } from "@/lib/router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Pause, Play, Plus, MoreHorizontal, Loader2, Copy, RotateCcw, Trash2, CheckCircle2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { AgentStatusBadge } from "./StatusBadge"; import { agentsApi } from "../api/agents"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import { agentRouteRef } from "../lib/utils"; import { useDialogActions } from "../context/DialogContext"; import { useToastActions } from "../context/ToastContext"; import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle, } from "../lib/duplicate-agent-payload"; import type { Agent, AgentInstructionsBundle, AgentInstructionsFileSummary, HeartbeatRun, } from "@paperclipai/shared"; export function RunButton({ onClick, disabled, label = "Run now", size = "sm", }: { onClick: () => void; disabled?: boolean; label?: string; size?: "sm" | "default"; }) { return ( ); } export function PauseResumeButton({ isPaused, onPause, onResume, disabled, size = "sm", }: { isPaused: boolean; onPause: () => void; onResume: () => void; disabled?: boolean; size?: "sm" | "default"; }) { if (isPaused) { return ( ); } return ( ); } export function ClearErrorButton({ onClick, disabled, size = "sm", }: { onClick: () => void; disabled?: boolean; size?: "sm" | "default"; }) { return ( ); } function duplicateInstructionFilePath( _bundle: AgentInstructionsBundle, summary: AgentInstructionsFileSummary, ): string | null { if (summary.deprecated || summary.virtual) return null; return summary.path; } export async function loadDuplicateInstructionsBundle( agentId: string, companyId?: string, ): Promise { const bundle = await agentsApi.instructionsBundle(agentId, companyId); const files: Record = {}; for (const summary of bundle.files) { const path = duplicateInstructionFilePath(bundle, summary); if (!path) continue; const file = await agentsApi.instructionsFile(agentId, summary.path, companyId); files[path] = file.content; } const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile) ? bundle.entryFile : Object.keys(files)[0] ?? "AGENTS.md"; return Object.keys(files).length > 0 ? { entryFile, files } : null; } /** * Shared agent action cluster used by both the agent detail header and the * agents list rows. Encapsulates the invoke / pause / resume / terminate / * duplicate / reset-session mutations so callers do not diverge in behavior. */ export function AgentActionButtons({ agent, companyId, size = "sm", assignLabel = "Assign Task", runLabel = "Run now", showStatus = true, actionsDisabled = false, workActionsDisabled = false, workActionsDisabledReason, navigateToRunOnInvoke = true, onActionError, children, className, }: { agent: Agent; companyId?: string | null; size?: "sm" | "default"; assignLabel?: string; runLabel?: string; showStatus?: boolean; actionsDisabled?: boolean; workActionsDisabled?: boolean; workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; /** * Optional inline error reporter. When provided it is used instead of a toast * for action failures (preserves the detail page's inline error banner). When * omitted, failures surface as toasts (used by the list view). */ onActionError?: (message: string | null) => void; /** Extra content rendered just before the overflow menu (e.g. live-run link). */ children?: React.ReactNode; className?: string; }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const { openNewIssue } = useDialogActions(); const { pushToast } = useToastActions(); const [moreOpen, setMoreOpen] = useState(false); const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); const isPaused = agent.status === "paused"; const isError = agent.status === "error"; const reportError = useCallback( (message: string) => { if (onActionError) { onActionError(message); } else { pushToast({ title: "Action failed", body: message, tone: "error" }); } }, [onActionError, pushToast], ); const invalidateAgent = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(canonicalAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) }); } }, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]); const agentAction = useMutation({ mutationFn: async (action: "invoke" | "pause" | "resume" | "clear_error" | "approve" | "terminate") => { switch (action) { case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined); case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined); case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined); case "clear_error": return agentsApi.clearError(agent.id, resolvedCompanyId ?? undefined); case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined); case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined); } }, onSuccess: (data, action) => { onActionError?.(null); invalidateAgent(); if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) { navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`); } }, onError: (err) => { reportError(err instanceof Error ? err.message : "Action failed"); }, }); const duplicateAgent = useMutation({ mutationFn: async () => { if (!resolvedCompanyId) { throw new Error("Agent is not ready to duplicate"); } const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId); const payload = buildDuplicateAgentPayload(agent, instructionsBundle); try { return await agentsApi.create(resolvedCompanyId, payload); } catch (error) { if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) { const hire = await agentsApi.hire(resolvedCompanyId, payload); return hire.agent; } throw error; } }, onSuccess: async (createdAgent) => { onActionError?.(null); if (resolvedCompanyId) { await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); } pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" }); navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`); }, onError: (err) => { const message = err instanceof Error ? err.message : "Failed to duplicate agent"; onActionError?.(message); pushToast({ title: "Could not duplicate agent", body: message, tone: "error" }); }, }); const handleDuplicateAgent = useCallback(() => { if (duplicateAgent.isPending) return; const nextName = duplicateAgentName(agent.name); const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`); setMoreOpen(false); if (!confirmed) return; duplicateAgent.mutate(); }, [agent.name, duplicateAgent]); const resetTaskSession = useMutation({ mutationFn: () => agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined), onSuccess: () => { onActionError?.(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) }); }, onError: (err) => { reportError(err instanceof Error ? err.message : "Failed to reset session"); }, }); const isPendingApproval = agent.status === "pending_approval"; const disabled = actionsDisabled || agentAction.isPending; const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled; const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled); const clearErrorDisabled = disabled; return (
agentAction.mutate("invoke")} disabled={assignAndRunDisabled} label={runLabel} size={size} /> {isError ? ( agentAction.mutate("clear_error")} disabled={clearErrorDisabled} size={size} /> ) : ( agentAction.mutate("pause")} onResume={() => agentAction.mutate("resume")} disabled={pauseResumeDisabled} size={size} /> )} {showStatus && ( )} {children}
); }