mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
Return to the Agents index when termination is accepted, including while asynchronous cleanup continues, so the profile does not remain on an Agent not found error. Cover both action menus, roster refresh, failed requests, and unsaved changes during pending termination. Verified with 53 focused tests, the full UI suite (11,949 passed; one skipped), UI typecheck and build, all applicable CI checks, and Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip <noreply@paperclip.ing>
690 lines
26 KiB
TypeScript
690 lines
26 KiB
TypeScript
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
import { useNavigate } from "@/lib/router";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Pause,
|
|
Play,
|
|
Plus,
|
|
MoreHorizontal,
|
|
MessagesSquare,
|
|
Loader2,
|
|
Copy,
|
|
RotateCcw,
|
|
Trash2,
|
|
CheckCircle2,
|
|
Bug,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { AgentStatusBadge } from "./StatusBadge";
|
|
import { agentsApi } from "../api/agents";
|
|
import { ApiError } from "../api/client";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { agentRouteRef, cn } from "../lib/utils";
|
|
import { pendingAgentSetupChoice } from "../lib/agent-setup-choice";
|
|
import { copyTextToClipboard } from "../lib/clipboard";
|
|
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 (
|
|
<Button variant="outline" size={size} onClick={onClick} disabled={disabled}>
|
|
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">{label}</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export function PauseResumeButton({
|
|
isPaused,
|
|
onPause,
|
|
onResume,
|
|
disabled,
|
|
size = "sm",
|
|
}: {
|
|
isPaused: boolean;
|
|
onPause: () => void;
|
|
onResume: () => void;
|
|
disabled?: boolean;
|
|
size?: "sm" | "default";
|
|
}) {
|
|
if (isPaused) {
|
|
return (
|
|
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
|
|
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">Resume</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button variant="outline" size={size} onClick={onPause} disabled={disabled}>
|
|
<Pause className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">Pause</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export function ClearErrorButton({
|
|
onClick,
|
|
disabled,
|
|
size = "sm",
|
|
}: {
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
size?: "sm" | "default";
|
|
}) {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size={size}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="border-destructive/60 text-destructive hover:bg-destructive/10 hover:text-destructive dark:border-destructive/50"
|
|
aria-label="Clear error and return agent to idle"
|
|
>
|
|
<CheckCircle2 className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">Clear error</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
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<DuplicateInstructionsBundle | null> {
|
|
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
|
|
const files: Record<string, string> = {};
|
|
|
|
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",
|
|
showAssignIcon = true,
|
|
showChat = false,
|
|
runLabel = "Run now",
|
|
showStatus = true,
|
|
showRun = true,
|
|
actionsDisabled = false,
|
|
workActionsDisabled = false,
|
|
workActionsDisabledReason,
|
|
navigateToRunOnInvoke = true,
|
|
hasPendingNavigationChanges = false,
|
|
onBeforeNavigate,
|
|
onActionError,
|
|
onTerminateSuccess,
|
|
pauseConfirm,
|
|
hideTerminate = false,
|
|
workActionsInMenu = false,
|
|
menuItems = [],
|
|
children,
|
|
className,
|
|
}: {
|
|
agent: Agent;
|
|
companyId?: string | null;
|
|
size?: "sm" | "default";
|
|
assignLabel?: string;
|
|
showAssignIcon?: boolean;
|
|
showChat?: boolean;
|
|
runLabel?: string;
|
|
showStatus?: boolean;
|
|
showRun?: boolean;
|
|
actionsDisabled?: boolean;
|
|
workActionsDisabled?: boolean;
|
|
workActionsDisabledReason?: string;
|
|
navigateToRunOnInvoke?: boolean;
|
|
/** Whether the caller currently has an unsaved draft that navigation would discard. */
|
|
hasPendingNavigationChanges?: boolean;
|
|
/** Return false to stop an action whose success would navigate away. */
|
|
onBeforeNavigate?: () => boolean;
|
|
/**
|
|
* When set, pausing prompts a confirmation dialog first (e.g. for built-in
|
|
* agents that power a feature). Omit for the immediate-pause default.
|
|
*/
|
|
pauseConfirm?: { title: string; description: ReactNode };
|
|
/** Hide the Terminate action (e.g. built-in agents are undeletable). */
|
|
hideTerminate?: boolean;
|
|
/** Put Run now, Pause/Resume and Clear error in the overflow menu, leaving Assign task (or Finish setup, while setup is pending) as the one button. */
|
|
workActionsInMenu?: boolean;
|
|
/** Extra overflow items shown after the work actions when `workActionsInMenu` is on. */
|
|
menuItems?: ReadonlyArray<{ label: string; icon: typeof Plus; onSelect: () => void; disabled?: 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;
|
|
onTerminateSuccess?: (agent: Agent) => 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 [pauseConfirmOpen, setPauseConfirmOpen] = useState(false);
|
|
const pendingNavigationChangesRef = useRef(hasPendingNavigationChanges);
|
|
const beforeNavigateRef = useRef(onBeforeNavigate);
|
|
const agentActionStartedDirtyRef = useRef(false);
|
|
const duplicateStartedDirtyRef = useRef(false);
|
|
pendingNavigationChangesRef.current = hasPendingNavigationChanges;
|
|
beforeNavigateRef.current = onBeforeNavigate;
|
|
|
|
function confirmNavigationStart(startedDirtyRef: React.MutableRefObject<boolean>) {
|
|
startedDirtyRef.current = pendingNavigationChangesRef.current;
|
|
return beforeNavigateRef.current?.() !== false;
|
|
}
|
|
|
|
function confirmLateNavigationChanges(startedDirtyRef: React.MutableRefObject<boolean>) {
|
|
return (
|
|
!pendingNavigationChangesRef.current ||
|
|
startedDirtyRef.current ||
|
|
beforeNavigateRef.current?.() !== false
|
|
);
|
|
}
|
|
|
|
const resolvedCompanyId = companyId ?? agent.companyId;
|
|
const canonicalAgentRef = agentRouteRef(agent);
|
|
const isPaused = agent.lifecycleState ? agent.lifecycleState === "paused" : agent.status === "paused";
|
|
const isAgentError = agent.status === "error";
|
|
const needsSetup = agent.pauseReason === "setup" && agent.status !== "terminated"
|
|
&& !["terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState ?? "");
|
|
|
|
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 === "terminate") {
|
|
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
|
onTerminateSuccess?.(data as Agent);
|
|
}
|
|
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
|
|
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
|
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" });
|
|
if (!confirmLateNavigationChanges(duplicateStartedDirtyRef)) return;
|
|
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 || !confirmNavigationStart(duplicateStartedDirtyRef)) 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 || agent.status === "terminated";
|
|
const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled)
|
|
|| Boolean(agent.lifecycleState && ["pausing", "terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState));
|
|
const clearErrorDisabled = disabled;
|
|
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | null;
|
|
const runtimeDebug =
|
|
runtimeConfig && typeof runtimeConfig.debug === "object" && runtimeConfig.debug !== null
|
|
? (runtimeConfig.debug as Record<string, unknown>)
|
|
: null;
|
|
const persistentProviderTrace = runtimeDebug?.providerTrace === "raw";
|
|
|
|
const finishSetup = () => {
|
|
if (!confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
|
navigate(`/agents/new?${new URLSearchParams({
|
|
name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id,
|
|
runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"),
|
|
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
|
|
...pendingAgentSetupChoice(agent),
|
|
})}`);
|
|
};
|
|
const pauseDialog = pauseConfirm ? (
|
|
<AlertDialog open={pauseConfirmOpen} onOpenChange={setPauseConfirmOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{pauseConfirm.title}</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div>{pauseConfirm.description}</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => agentAction.mutate("pause")}>
|
|
Pause anyway
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
) : null;
|
|
|
|
if (workActionsInMenu) {
|
|
const item = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50";
|
|
const icon = "size-4 shrink-0 text-muted-foreground";
|
|
const select = (action: () => void) => () => {
|
|
setMoreOpen(false);
|
|
action();
|
|
};
|
|
return (
|
|
<div className={className ?? "flex shrink-0 items-center gap-2"}>
|
|
{persistentProviderTrace ? (
|
|
<span
|
|
className="hidden items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs font-medium text-primary lg:inline-flex"
|
|
title="Exact provider traffic will be captured for future runs and retained for up to 24 hours."
|
|
>
|
|
<Bug className="h-3.5 w-3.5" />
|
|
Raw tracing on
|
|
</span>
|
|
) : null}
|
|
{children}
|
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon" aria-label={`Open actions for ${agent.name}`}>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-52 p-1" align="end">
|
|
{needsSetup ? (
|
|
<button
|
|
type="button"
|
|
className={item}
|
|
disabled={assignAndRunDisabled}
|
|
title={workActionsDisabled ? workActionsDisabledReason : undefined}
|
|
onClick={select(() => openNewIssue({ assigneeAgentId: agent.id }))}
|
|
>
|
|
<Plus className={icon} />
|
|
{assignLabel}
|
|
</button>
|
|
) : showRun ? (
|
|
<button
|
|
type="button"
|
|
className={item}
|
|
disabled={assignAndRunDisabled}
|
|
title={workActionsDisabled ? workActionsDisabledReason : undefined}
|
|
onClick={select(() => {
|
|
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
|
agentAction.mutate("invoke");
|
|
})}
|
|
>
|
|
<Play className={icon} />
|
|
{runLabel}
|
|
</button>
|
|
) : null}
|
|
{needsSetup ? null : isAgentError ? (
|
|
<button
|
|
type="button"
|
|
className={cn(item, "text-destructive")}
|
|
disabled={clearErrorDisabled}
|
|
aria-label="Clear error and return agent to idle"
|
|
onClick={select(() => agentAction.mutate("clear_error"))}
|
|
>
|
|
<CheckCircle2 className={cn(icon, "text-destructive")} />
|
|
Clear error
|
|
</button>
|
|
) : isPaused ? (
|
|
<button type="button" className={item} disabled={pauseResumeDisabled} onClick={select(() => agentAction.mutate("resume"))}>
|
|
<Play className={icon} />
|
|
Resume
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={item}
|
|
disabled={pauseResumeDisabled}
|
|
onClick={select(() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause")))}
|
|
>
|
|
<Pause className={icon} />
|
|
Pause
|
|
</button>
|
|
)}
|
|
<div role="separator" className="-mx-1 my-1 h-px bg-border" />
|
|
{menuItems.map(({ label, icon: Icon, onSelect, disabled: itemDisabled }) => (
|
|
<button key={label} type="button" className={item} disabled={itemDisabled} onClick={select(onSelect)}>
|
|
<Icon className={icon} />
|
|
{label}
|
|
</button>
|
|
))}
|
|
<button type="button" className={item} disabled={duplicateAgent.isPending} onClick={handleDuplicateAgent}>
|
|
{duplicateAgent.isPending ? <Loader2 className={cn(icon, "animate-spin")} /> : <Copy className={icon} />}
|
|
Duplicate agent
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={item}
|
|
onClick={select(() => {
|
|
void copyTextToClipboard(agent.id).catch(() => {
|
|
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
|
|
});
|
|
})}
|
|
>
|
|
<Copy className={icon} />
|
|
Copy agent ID
|
|
</button>
|
|
<button type="button" className={item} onClick={select(() => resetTaskSession.mutate())}>
|
|
<RotateCcw className={icon} />
|
|
Reset sessions
|
|
</button>
|
|
{hideTerminate ? null : (
|
|
<>
|
|
<div role="separator" className="-mx-1 my-1 h-px bg-border" />
|
|
<button
|
|
type="button"
|
|
className={cn(item, "text-destructive")}
|
|
onClick={select(() => {
|
|
if (onTerminateSuccess && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
|
agentAction.mutate("terminate");
|
|
})}
|
|
>
|
|
<Trash2 className={cn(icon, "text-destructive")} />
|
|
Terminate
|
|
</button>
|
|
</>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
{showChat && <Button variant="outline" size="icon" aria-label={`Chat with ${agent.name}`} title="Chat with this agent"
|
|
disabled={disabled} onClick={() => { if (!onBeforeNavigate || onBeforeNavigate()) navigate(`/chats/${canonicalAgentRef}`); }}>
|
|
<MessagesSquare className="size-4" />
|
|
</Button>}
|
|
{needsSetup ? (
|
|
<Button size={size} disabled={disabled} onClick={finishSetup}>
|
|
<Play className="size-4" />
|
|
Finish setup
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size={size}
|
|
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
|
disabled={assignAndRunDisabled}
|
|
title={workActionsDisabled ? workActionsDisabledReason : undefined}
|
|
>
|
|
{showAssignIcon && <Plus className="size-4" />}
|
|
{assignLabel}
|
|
</Button>
|
|
)}
|
|
{pauseDialog}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
|
{persistentProviderTrace ? (
|
|
<span
|
|
className="hidden items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs font-medium text-primary lg:inline-flex"
|
|
title="Exact provider traffic will be captured for future runs and retained for up to 24 hours."
|
|
>
|
|
<Bug className="h-3.5 w-3.5" />
|
|
Raw tracing on
|
|
</span>
|
|
) : null}
|
|
<Button
|
|
variant="outline"
|
|
size={size}
|
|
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
|
disabled={assignAndRunDisabled}
|
|
title={workActionsDisabled ? workActionsDisabledReason : undefined}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">{assignLabel}</span>
|
|
</Button>
|
|
{needsSetup ? (
|
|
<Button variant="outline" size={size} disabled={disabled} onClick={() => {
|
|
if (!confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
|
navigate(`/agents/new?${new URLSearchParams({
|
|
name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id,
|
|
runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"),
|
|
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
|
|
...pendingAgentSetupChoice(agent),
|
|
})}`);
|
|
}}>Finish setup</Button>
|
|
) : showRun && <RunButton
|
|
onClick={() => {
|
|
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
|
agentAction.mutate("invoke");
|
|
}}
|
|
disabled={assignAndRunDisabled}
|
|
label={runLabel}
|
|
size={size}
|
|
/>}
|
|
{!needsSetup && (agent.status === "error" ? (
|
|
<ClearErrorButton
|
|
onClick={() => agentAction.mutate("clear_error")}
|
|
disabled={clearErrorDisabled}
|
|
size={size}
|
|
/>
|
|
) : (
|
|
<PauseResumeButton
|
|
isPaused={isPaused}
|
|
onPause={() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause"))}
|
|
onResume={() => agentAction.mutate("resume")}
|
|
disabled={pauseResumeDisabled}
|
|
size={size}
|
|
/>
|
|
))}
|
|
{pauseConfirm && (
|
|
<AlertDialog open={pauseConfirmOpen} onOpenChange={setPauseConfirmOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{pauseConfirm.title}</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div>{pauseConfirm.description}</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => agentAction.mutate("pause")}>
|
|
Pause anyway
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
{showStatus && (
|
|
<span className="hidden sm:inline">
|
|
<AgentStatusBadge status={agent.status} />
|
|
</span>
|
|
)}
|
|
{children}
|
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon-xs" aria-label={`Open actions for ${agent.name}`}>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-44 p-1" align="end">
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
disabled={duplicateAgent.isPending}
|
|
onClick={handleDuplicateAgent}
|
|
>
|
|
{duplicateAgent.isPending ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Copy className="h-3 w-3" />
|
|
)}
|
|
Duplicate Agent
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
void copyTextToClipboard(agent.id).catch(() => {
|
|
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
|
|
});
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
Copy Agent ID
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
resetTaskSession.mutate();
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
Reset Sessions
|
|
</button>
|
|
{!hideTerminate && (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
|
onClick={() => {
|
|
setMoreOpen(false);
|
|
if (onTerminateSuccess && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
|
agentAction.mutate("terminate");
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
Terminate
|
|
</button>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|