Files
paperclip/ui/src/components/AgentActionButtons.tsx
T
scotttong eaef47f4c7 Information Architecture + project/agent visual refresh (experimental) (#7543)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is the control surface for issues, projects, agents,
goals, workspaces, and operator settings.
> - The existing navigation and list surfaces make several
high-frequency workflows feel harder to scan than they should,
especially around projects and agents.
> - The product direction is to improve those surfaces without breaking
the existing route model or forcing a new IA on every operator at once.
> - This pull request now keeps the dependent IA, project identity, and
agent-list visual refresh work together while the Issue-to-Task copy
migration is split into #7651.
> - The benefit is a clearer left nav, better project identity, denser
agent/project list rows, and brand-aligned status treatment while
preserving the classic default experience behind a flag.

## Linked Issues or Issue Description

Refs #7645
Refs #7651

Internal planning/work references: PAP-53, PAP-56, PAP-58, PAP-59,
PAP-60, PAP-61, PAP-68, PAP-69, PAP-70, PAP-71, PAP-72, PAP-75, PAP-76,
PAP-80, PAP-85, PAP-86, PAP-87, PAP-88, PAP-89.

## What Changed

- Adds `enableStreamlinedLeftNavigation`, defaulting off, and gates
sidebar presentation so classic navigation remains the default.
- Adds project icon persistence, validation, portability, picker UI, and
`ProjectTile` rendering while defaulting new projects to neutral gray.
- Adds projects-list task-count and budget summary data with focused
server/shared/UI coverage.
- Refreshes agent list rows, row actions, active/recent sidebar
behavior, and status capsule/chip styling for the approved brand state
system.
- Removes the placeholder Conference room and Artifacts nav/routes from
the finalized experimental nav direction.
- Removes `pnpm-lock.yaml` and the Issue-to-Task copy migration from
this PR diff; the copy migration now lives in #7651.

## Verification

- Existing branch verification from the authored commits: UI typecheck,
targeted unit tests, and light/dark visual checks for `/agents`, agent
detail, and design-guide status states.
- Maintainer cleanup verification on `75e34e5`: `git diff --check
origin/master...HEAD` passed, the `design/` diff is empty, and the PR
diff is 61 files, below Greptile's 100-file review limit.
- `pnpm --filter @paperclipai/ui build` passed.
- `NODE_ENV=test pnpm exec vitest run
ui/src/components/Sidebar.test.tsx` passed: 1 file, 8 tests.
- CI and Greptile should rerun on the latest push.

## Risks

- Broad UI surface area: the experimental flag keeps the classic nav
default, but changed shared components such as `EntityRow`,
`ProjectTile`, and agent status badges could affect multiple pages.
- Database migration: `projects.icon` is additive and nullable, but
migration ordering and portability import/export must stay aligned.
- The Issue-to-Task copy migration is now separated into #7651, so
reviewers should evaluate this PR as IA/project/agent presentation work
only.
- Visual regressions are possible across smaller widths because the PR
intentionally changes dense list-row layouts.

## Model Used

Claude Opus 4.8 assisted the original feature commits.
Paperclip-Paperclip agents assisted some planning/design commits. Codex
/ GPT-5-class coding agent with shell, GitHub CLI, and repository access
performed this PR-readiness cleanup and split.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
2026-06-06 09:17:27 -05:00

346 lines
12 KiB
TypeScript

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,
} 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 (
<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>
);
}
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",
runLabel = "Run now",
showStatus = true,
actionsDisabled = false,
navigateToRunOnInvoke = true,
onActionError,
children,
className,
}: {
agent: Agent;
companyId?: string | null;
size?: "sm" | "default";
assignLabel?: string;
runLabel?: string;
showStatus?: boolean;
actionsDisabled?: boolean;
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 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" | "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 "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;
return (
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
<Button
variant="outline"
size={size}
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
>
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{assignLabel}</span>
</Button>
<RunButton
onClick={() => agentAction.mutate("invoke")}
disabled={disabled || isPendingApproval}
label={runLabel}
size={size}
/>
<PauseResumeButton
isPaused={isPaused}
onPause={() => agentAction.mutate("pause")}
onResume={() => agentAction.mutate("resume")}
disabled={disabled || isPendingApproval}
size={size}
/>
{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={() => {
navigator.clipboard.writeText(agent.id);
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>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
}}
>
<Trash2 className="h-3 w-3" />
Terminate
</button>
</PopoverContent>
</Popover>
</div>
);
}