mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Add generic issue-linked board approvals
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6b4f3b56e4
commit
365b6d9bd8
10 changed files with 345 additions and 39 deletions
|
|
@ -21,19 +21,22 @@ export function ApprovalCard({
|
|||
onReject,
|
||||
onOpen,
|
||||
detailLink,
|
||||
isPending,
|
||||
isPending = false,
|
||||
pendingAction = null,
|
||||
}: {
|
||||
approval: Approval;
|
||||
requesterAgent: Agent | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onOpen?: () => void;
|
||||
detailLink?: string;
|
||||
isPending: boolean;
|
||||
isPending?: boolean;
|
||||
pendingAction?: "approve" | "reject" | null;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
|
||||
const showResolutionButtons =
|
||||
Boolean(onApprove && onReject) &&
|
||||
approval.type !== "budget_override_required" &&
|
||||
(approval.status === "pending" || approval.status === "revision_requested");
|
||||
|
||||
|
|
@ -78,7 +81,7 @@ export function ApprovalCard({
|
|||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
{pendingAction === "approve" ? "Approving..." : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
|
@ -86,21 +89,23 @@ export function ApprovalCard({
|
|||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
{pendingAction === "reject" ? "Rejecting..." : "Reject"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
{detailLink ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
||||
<Link to={detailLink}>View details</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(detailLink || onOpen) ? (
|
||||
<div className="mt-3">
|
||||
{detailLink ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
||||
<Link to={detailLink}>View details</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const typeLabel: Record<string, string> = {
|
|||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
budget_override_required: "Budget Override",
|
||||
request_board_approval: "Board Approval",
|
||||
};
|
||||
|
||||
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
|
||||
|
|
@ -20,6 +21,7 @@ export const typeIcon: Record<string, typeof UserPlus> = {
|
|||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
budget_override_required: ShieldAlert,
|
||||
request_board_approval: ShieldCheck,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { act } from "react";
|
|||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { Agent, Approval } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CommentThread } from "./CommentThread";
|
||||
|
||||
|
|
@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({
|
|||
InlineEntitySelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./ApprovalCard", () => ({
|
||||
ApprovalCard: ({
|
||||
approval,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
approval: Approval;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div>{approval.type}</div>
|
||||
<div>{String(approval.payload.title ?? "")}</div>
|
||||
{onApprove ? <button type="button" onClick={onApprove}>Approve</button> : null}
|
||||
{onReject ? <button type="button" onClick={onReject}>Reject</button> : null}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
}));
|
||||
|
|
@ -144,4 +163,75 @@ describe("CommentThread", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders linked approvals inline in the timeline", () => {
|
||||
const root = createRoot(container);
|
||||
const agent: Agent = {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
urlKey: "codexcoder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: "code",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
};
|
||||
const approval: Approval = {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {
|
||||
title: "Approve hosting spend",
|
||||
text: "Estimated monthly cost is $42.",
|
||||
},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-03-11T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T09:00:00.000Z"),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[]}
|
||||
linkedApprovals={[approval]}
|
||||
agentMap={new Map([["agent-1", agent]])}
|
||||
onAdd={async () => {}}
|
||||
onApproveApproval={async () => {}}
|
||||
onRejectApproval={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const approvalRow = container.querySelector("#approval-approval-1") as HTMLDivElement | null;
|
||||
expect(approvalRow).not.toBeNull();
|
||||
expect(container.textContent).toContain("request_board_approval");
|
||||
expect(container.textContent).toContain("Approve hosting spend");
|
||||
expect(container.textContent).toContain("Approve");
|
||||
expect(container.textContent).toContain("Reject");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
|
|||
import { Link, useLocation } from "react-router-dom";
|
||||
import type {
|
||||
Agent,
|
||||
Approval,
|
||||
FeedbackDataSharingPreference,
|
||||
FeedbackVote,
|
||||
FeedbackVoteValue,
|
||||
|
|
@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
|
|||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { ApprovalCard } from "./ApprovalCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
|
|
@ -50,6 +51,7 @@ interface CommentReassignment {
|
|||
interface CommentThreadProps {
|
||||
comments: CommentWithRunMeta[];
|
||||
queuedComments?: CommentWithRunMeta[];
|
||||
linkedApprovals?: Approval[];
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl?: string | null;
|
||||
|
|
@ -57,6 +59,12 @@ interface CommentThreadProps {
|
|||
timelineEvents?: IssueTimelineEvent[];
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||
pendingApprovalAction?: {
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
} | null;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
|
|
@ -375,6 +383,7 @@ function CommentCard({
|
|||
|
||||
type TimelineItem =
|
||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||
| { kind: "approval"; id: string; createdAtMs: number; approval: Approval }
|
||||
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
|
||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||
|
||||
|
|
@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({
|
|||
currentUserId,
|
||||
companyId,
|
||||
projectId,
|
||||
onApproveApproval,
|
||||
onRejectApproval,
|
||||
pendingApprovalAction,
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
|
|
@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({
|
|||
currentUserId?: string | null;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onApproveApproval?: (approvalId: string) => Promise<void>;
|
||||
onRejectApproval?: (approvalId: string) => Promise<void>;
|
||||
pendingApprovalAction?: {
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
} | null;
|
||||
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
|
||||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl?: string | null;
|
||||
|
|
@ -488,6 +506,24 @@ const TimelineList = memo(function TimelineList({
|
|||
);
|
||||
}
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approval = item.approval;
|
||||
const isPending = pendingApprovalAction?.approvalId === approval.id;
|
||||
return (
|
||||
<div id={`approval-${approval.id}`} key={`approval:${approval.id}`} className="py-1.5">
|
||||
<ApprovalCard
|
||||
approval={approval}
|
||||
requesterAgent={approval.requestedByAgentId ? agentMap?.get(approval.requestedByAgentId) ?? null : null}
|
||||
onApprove={onApproveApproval ? () => void onApproveApproval(approval.id) : undefined}
|
||||
onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined}
|
||||
detailLink={`/approvals/${approval.id}`}
|
||||
isPending={isPending}
|
||||
pendingAction={isPending ? pendingApprovalAction?.action ?? null : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "run") {
|
||||
const run = item.run;
|
||||
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
|
|
@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({
|
|||
export function CommentThread({
|
||||
comments,
|
||||
queuedComments = [],
|
||||
linkedApprovals = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
|
|
@ -555,6 +592,9 @@ export function CommentThread({
|
|||
timelineEvents = [],
|
||||
companyId,
|
||||
projectId,
|
||||
onApproveApproval,
|
||||
onRejectApproval,
|
||||
pendingApprovalAction = null,
|
||||
onVote,
|
||||
onAdd,
|
||||
agentMap,
|
||||
|
|
@ -593,6 +633,12 @@ export function CommentThread({
|
|||
createdAtMs: new Date(comment.createdAt).getTime(),
|
||||
comment,
|
||||
}));
|
||||
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
|
||||
kind: "approval",
|
||||
id: approval.id,
|
||||
createdAtMs: new Date(approval.createdAt).getTime(),
|
||||
approval,
|
||||
}));
|
||||
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
|
||||
kind: "event",
|
||||
id: event.id,
|
||||
|
|
@ -605,17 +651,18 @@ export function CommentThread({
|
|||
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
||||
run,
|
||||
}));
|
||||
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
|
||||
return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => {
|
||||
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
||||
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
||||
const kindOrder = {
|
||||
event: 0,
|
||||
comment: 1,
|
||||
run: 2,
|
||||
approval: 1,
|
||||
comment: 2,
|
||||
run: 3,
|
||||
} as const;
|
||||
return kindOrder[a.kind] - kindOrder[b.kind];
|
||||
});
|
||||
}, [comments, timelineEvents, linkedRuns]);
|
||||
}, [comments, linkedApprovals, timelineEvents, linkedRuns]);
|
||||
|
||||
const feedbackVoteByTargetId = useMemo(() => {
|
||||
const map = new Map<string, FeedbackVoteValue>();
|
||||
|
|
@ -754,6 +801,9 @@ export function CommentThread({
|
|||
currentUserId={currentUserId}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
onApproveApproval={onApproveApproval}
|
||||
onRejectApproval={onRejectApproval}
|
||||
pendingApprovalAction={pendingApprovalAction}
|
||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
onVote={onVote ? handleFeedbackVote : undefined}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue