Add generic issue-linked board approvals

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-06 10:36:31 -05:00
parent 6b4f3b56e4
commit 365b6d9bd8
10 changed files with 345 additions and 39 deletions

View file

@ -220,6 +220,7 @@ describe("renderCompanyImportPreview", () => {
status: null, status: null,
executionWorkspacePolicy: null, executionWorkspacePolicy: null,
workspaces: [], workspaces: [],
env: null,
metadata: null, metadata: null,
}, },
], ],
@ -432,6 +433,7 @@ describe("import selection catalog", () => {
status: null, status: null,
executionWorkspacePolicy: null, executionWorkspacePolicy: null,
workspaces: [], workspaces: [],
env: null,
metadata: null, metadata: null,
}, },
], ],

View file

@ -200,7 +200,12 @@ export const PROJECT_COLORS = [
"#3b82f6", // blue "#3b82f6", // blue
] as const; ] as const;
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const; export const APPROVAL_TYPES = [
"hire_agent",
"approve_ceo_strategy",
"budget_override_required",
"request_board_approval",
] as const;
export type ApprovalType = (typeof APPROVAL_TYPES)[number]; export type ApprovalType = (typeof APPROVAL_TYPES)[number];
export const APPROVAL_STATUSES = [ export const APPROVAL_STATUSES = [

View file

@ -57,6 +57,24 @@ function createApp() {
return app; return app;
} }
function createAgentApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "api_key",
isInstanceAdmin: false,
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("approval routes idempotent retries", () => { describe("approval routes idempotent retries", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -107,4 +125,56 @@ describe("approval routes idempotent retries", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled();
}); });
it("lets agents create generic issue-linked board approval requests", async () => {
mockApprovalService.create.mockResolvedValue({
id: "approval-1",
companyId: "company-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
payload: { title: "Approve hosting spend" },
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-04-06T00:00:00.000Z"),
updatedAt: new Date("2026-04-06T00:00:00.000Z"),
});
const res = await request(createAgentApp())
.post("/api/companies/company-1/approvals")
.send({
type: "request_board_approval",
issueIds: ["00000000-0000-0000-0000-000000000001"],
payload: { title: "Approve hosting spend" },
});
expect(res.status).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
decisionNote: null,
}),
);
expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled();
expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith(
"approval-1",
["00000000-0000-0000-0000-000000000001"],
{ agentId: "agent-1", userId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "approval.created",
}),
);
});
}); });

View file

@ -133,6 +133,37 @@ If a blocker is moved to `cancelled`, it does **not** count as resolved for bloc
When you receive one of these wake reasons, check the issue state and continue the work or mark it done. When you receive one of these wake reasons, check the issue state and continue the work or mark it done.
## Requesting Board Approval
Agents can create approval requests for arbitrary issue-linked work. Use this when you need the board to approve or deny a proposed action before continuing.
Recommended generic type:
- `request_board_approval` for open-ended approval requests like spend approval, vendor approval, launch approval, or other board decisions
Create the approval and link it to the relevant issue in one call:
```json
POST /api/companies/{companyId}/approvals
{
"type": "request_board_approval",
"requestedByAgentId": "{your-agent-id}",
"issueIds": ["{issue-id}"],
"payload": {
"title": "Approve monthly hosting spend",
"summary": "Estimated cost is $42/month for provider X.",
"recommendedAction": "Approve provider X and continue setup.",
"risks": ["Costs may increase with usage."]
}
}
```
Notes:
- `issueIds` links the approval into the issue thread/UI.
- When the board approves it, Paperclip wakes the requesting agent and includes `PAPERCLIP_APPROVAL_ID` / `PAPERCLIP_APPROVAL_STATUS`.
- Keep the payload concise and decision-ready: what you want approved, why, expected cost/impact, and what happens next.
## Project Setup Workflow (CEO/Manager Common Path) ## Project Setup Workflow (CEO/Manager Common Path)
When asked to set up a new project with workspace config (local folder and/or GitHub repo), use: When asked to set up a new project with workspace config (local folder and/or GitHub repo), use:
@ -335,6 +366,7 @@ PATCH /api/agents/{agentId}/instructions-path
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
| Release task | `POST /api/issues/:issueId/release` | | Release task | `POST /api/issues/:issueId/release` |
| List agents | `GET /api/companies/:companyId/agents` | | List agents | `GET /api/companies/:companyId/agents` |
| Create approval | `POST /api/companies/:companyId/approvals` |
| List company skills | `GET /api/companies/:companyId/skills` | | List company skills | `GET /api/companies/:companyId/skills` |
| Import company skills | `POST /api/companies/:companyId/skills/import` | | Import company skills | `POST /api/companies/:companyId/skills/import` |
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` | | Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |

View file

@ -21,19 +21,22 @@ export function ApprovalCard({
onReject, onReject,
onOpen, onOpen,
detailLink, detailLink,
isPending, isPending = false,
pendingAction = null,
}: { }: {
approval: Approval; approval: Approval;
requesterAgent: Agent | null; requesterAgent: Agent | null;
onApprove: () => void; onApprove?: () => void;
onReject: () => void; onReject?: () => void;
onOpen?: () => void; onOpen?: () => void;
detailLink?: string; detailLink?: string;
isPending: boolean; isPending?: boolean;
pendingAction?: "approve" | "reject" | null;
}) { }) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null); const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons = const showResolutionButtons =
Boolean(onApprove && onReject) &&
approval.type !== "budget_override_required" && approval.type !== "budget_override_required" &&
(approval.status === "pending" || approval.status === "revision_requested"); (approval.status === "pending" || approval.status === "revision_requested");
@ -78,7 +81,7 @@ export function ApprovalCard({
onClick={onApprove} onClick={onApprove}
disabled={isPending} disabled={isPending}
> >
Approve {pendingAction === "approve" ? "Approving..." : "Approve"}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -86,21 +89,23 @@ export function ApprovalCard({
onClick={onReject} onClick={onReject}
disabled={isPending} disabled={isPending}
> >
Reject {pendingAction === "reject" ? "Rejecting..." : "Reject"}
</Button> </Button>
</div> </div>
)} )}
<div className="mt-3"> {(detailLink || onOpen) ? (
{detailLink ? ( <div className="mt-3">
<Button variant="ghost" size="sm" className="text-xs px-0" asChild> {detailLink ? (
<Link to={detailLink}>View details</Link> <Button variant="ghost" size="sm" className="text-xs px-0" asChild>
</Button> <Link to={detailLink}>View details</Link>
) : ( </Button>
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}> ) : (
View details <Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
</Button> View details
)} </Button>
</div> )}
</div>
) : null}
</div> </div>
); );
} }

View file

@ -5,6 +5,7 @@ export const typeLabel: Record<string, string> = {
hire_agent: "Hire Agent", hire_agent: "Hire Agent",
approve_ceo_strategy: "CEO Strategy", approve_ceo_strategy: "CEO Strategy",
budget_override_required: "Budget Override", budget_override_required: "Budget Override",
request_board_approval: "Board Approval",
}; };
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */ /** 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, hire_agent: UserPlus,
approve_ceo_strategy: Lightbulb, approve_ceo_strategy: Lightbulb,
budget_override_required: ShieldAlert, budget_override_required: ShieldAlert,
request_board_approval: ShieldCheck,
}; };
export const defaultTypeIcon = ShieldCheck; export const defaultTypeIcon = ShieldCheck;

View file

@ -4,7 +4,7 @@ import { act } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommentThread } from "./CommentThread"; import { CommentThread } from "./CommentThread";
@ -33,6 +33,25 @@ vi.mock("./InlineEntitySelector", () => ({
InlineEntitySelector: () => null, 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", () => ({ vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null, PluginSlotOutlet: () => null,
})); }));
@ -144,4 +163,75 @@ describe("CommentThread", () => {
root.unmount(); 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();
});
});
}); });

View file

@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import type { import type {
Agent, Agent,
Approval,
FeedbackDataSharingPreference, FeedbackDataSharingPreference,
FeedbackVote, FeedbackVote,
FeedbackVoteValue, FeedbackVoteValue,
@ -15,7 +16,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
import { MarkdownBody } from "./MarkdownBody"; import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { StatusBadge } from "./StatusBadge"; import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
@ -50,6 +51,7 @@ interface CommentReassignment {
interface CommentThreadProps { interface CommentThreadProps {
comments: CommentWithRunMeta[]; comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[]; queuedComments?: CommentWithRunMeta[];
linkedApprovals?: Approval[];
feedbackVotes?: FeedbackVote[]; feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null; feedbackTermsUrl?: string | null;
@ -57,6 +59,12 @@ interface CommentThreadProps {
timelineEvents?: IssueTimelineEvent[]; timelineEvents?: IssueTimelineEvent[];
companyId?: string | null; companyId?: string | null;
projectId?: string | null; projectId?: string | null;
onApproveApproval?: (approvalId: string) => Promise<void>;
onRejectApproval?: (approvalId: string) => Promise<void>;
pendingApprovalAction?: {
approvalId: string;
action: "approve" | "reject";
} | null;
onVote?: ( onVote?: (
commentId: string, commentId: string,
vote: FeedbackVoteValue, vote: FeedbackVoteValue,
@ -375,6 +383,7 @@ function CommentCard({
type TimelineItem = type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { 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: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
@ -447,6 +456,9 @@ const TimelineList = memo(function TimelineList({
currentUserId, currentUserId,
companyId, companyId,
projectId, projectId,
onApproveApproval,
onRejectApproval,
pendingApprovalAction,
feedbackVoteByTargetId, feedbackVoteByTargetId,
feedbackDataSharingPreference = "prompt", feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null, feedbackTermsUrl = null,
@ -459,6 +471,12 @@ const TimelineList = memo(function TimelineList({
currentUserId?: string | null; currentUserId?: string | null;
companyId?: string | null; companyId?: string | null;
projectId?: 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>; feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null; 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") { if (item.kind === "run") {
const run = item.run; const run = item.run;
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
@ -548,6 +584,7 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({ export function CommentThread({
comments, comments,
queuedComments = [], queuedComments = [],
linkedApprovals = [],
feedbackVotes = [], feedbackVotes = [],
feedbackDataSharingPreference = "prompt", feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null, feedbackTermsUrl = null,
@ -555,6 +592,9 @@ export function CommentThread({
timelineEvents = [], timelineEvents = [],
companyId, companyId,
projectId, projectId,
onApproveApproval,
onRejectApproval,
pendingApprovalAction = null,
onVote, onVote,
onAdd, onAdd,
agentMap, agentMap,
@ -593,6 +633,12 @@ export function CommentThread({
createdAtMs: new Date(comment.createdAt).getTime(), createdAtMs: new Date(comment.createdAt).getTime(),
comment, 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) => ({ const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
kind: "event", kind: "event",
id: event.id, id: event.id,
@ -605,17 +651,18 @@ export function CommentThread({
createdAtMs: new Date(runTimestamp(run)).getTime(), createdAtMs: new Date(runTimestamp(run)).getTime(),
run, 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.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.kind === b.kind) return a.id.localeCompare(b.id); if (a.kind === b.kind) return a.id.localeCompare(b.id);
const kindOrder = { const kindOrder = {
event: 0, event: 0,
comment: 1, approval: 1,
run: 2, comment: 2,
run: 3,
} as const; } as const;
return kindOrder[a.kind] - kindOrder[b.kind]; return kindOrder[a.kind] - kindOrder[b.kind];
}); });
}, [comments, timelineEvents, linkedRuns]); }, [comments, linkedApprovals, timelineEvents, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => { const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>(); const map = new Map<string, FeedbackVoteValue>();
@ -754,6 +801,9 @@ export function CommentThread({
currentUserId={currentUserId} currentUserId={currentUserId}
companyId={companyId} companyId={companyId}
projectId={projectId} projectId={projectId}
onApproveApproval={onApproveApproval}
onRejectApproval={onRejectApproval}
pendingApprovalAction={pendingApprovalAction}
feedbackVoteByTargetId={feedbackVoteByTargetId} feedbackVoteByTargetId={feedbackVoteByTargetId}
feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackDataSharingPreference={feedbackDataSharingPreference}
onVote={onVote ? handleFeedbackVote : undefined} onVote={onVote ? handleFeedbackVote : undefined}

View file

@ -123,6 +123,9 @@ export function Approvals() {
onReject={() => rejectMutation.mutate(approval.id)} onReject={() => rejectMutation.mutate(approval.id)}
detailLink={`/approvals/${approval.id}`} detailLink={`/approvals/${approval.id}`}
isPending={approveMutation.isPending || rejectMutation.isPending} isPending={approveMutation.isPending || rejectMutation.isPending}
pendingAction={
approveMutation.isPending ? "approve" : rejectMutation.isPending ? "reject" : null
}
/> />
))} ))}
</div> </div>

View file

@ -3,6 +3,7 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { approvalsApi } from "../api/approvals";
import { activityApi } from "../api/activity"; import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
@ -37,6 +38,7 @@ import {
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { ApprovalCard } from "../components/ApprovalCard";
import { CommentThread } from "../components/CommentThread"; import { CommentThread } from "../components/CommentThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties"; import { IssueProperties } from "../components/IssueProperties";
@ -47,7 +49,6 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon"; import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginLauncherOutlet } from "@/plugins/launchers";
@ -303,6 +304,10 @@ export function IssueDetail() {
const [secondaryOpen, setSecondaryOpen] = useState({ const [secondaryOpen, setSecondaryOpen] = useState({
approvals: false, approvals: false,
}); });
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
approvalId: string;
action: "approve" | "reject";
} | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null); const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false);
@ -659,6 +664,39 @@ export function IssueDetail() {
}, },
}); });
const approvalDecision = useMutation({
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
if (action === "approve") {
return approvalsApi.approve(approvalId);
}
return approvalsApi.reject(approvalId);
},
onMutate: ({ approvalId, action }) => {
setPendingApprovalAction({ approvalId, action });
},
onSuccess: (_approval, variables) => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
if (resolvedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
}
pushToast({
title: variables.action === "approve" ? "Approval approved" : "Approval rejected",
tone: "success",
});
},
onError: (err, variables) => {
pushToast({
title: variables.action === "approve" ? "Approval failed" : "Rejection failed",
body: err instanceof Error ? err.message : "Unable to update approval",
tone: "error",
});
},
onSettled: () => {
setPendingApprovalAction(null);
},
});
const addComment = useMutation({ const addComment = useMutation({
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) => mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen, interrupt), issuesApi.addComment(issueId!, body, reopen, interrupt),
@ -1543,6 +1581,7 @@ export function IssueDetail() {
<CommentThread <CommentThread
comments={timelineComments} comments={timelineComments}
queuedComments={queuedComments} queuedComments={queuedComments}
linkedApprovals={linkedApprovals}
feedbackVotes={feedbackVotes} feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL} feedbackTermsUrl={FEEDBACK_TERMS_URL}
@ -1550,6 +1589,13 @@ export function IssueDetail() {
timelineEvents={timelineEvents} timelineEvents={timelineEvents}
companyId={issue.companyId} companyId={issue.companyId}
projectId={issue.projectId} projectId={issue.projectId}
onApproveApproval={async (approvalId) => {
await approvalDecision.mutateAsync({ approvalId, action: "approve" });
}}
onRejectApproval={async (approvalId) => {
await approvalDecision.mutateAsync({ approvalId, action: "reject" });
}}
pendingApprovalAction={pendingApprovalAction}
issueStatus={issue.status} issueStatus={issue.status}
agentMap={agentMap} agentMap={agentMap}
currentUserId={currentUserId} currentUserId={currentUserId}
@ -1703,20 +1749,21 @@ export function IssueDetail() {
<CollapsibleContent> <CollapsibleContent>
<div className="border-t border-border divide-y divide-border"> <div className="border-t border-border divide-y divide-border">
{linkedApprovals.map((approval) => ( {linkedApprovals.map((approval) => (
<Link <div key={approval.id} className="px-3 py-3">
key={approval.id} <ApprovalCard
to={`/approvals/${approval.id}`} approval={approval}
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors" requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
> onApprove={() => approvalDecision.mutate({ approvalId: approval.id, action: "approve" })}
<div className="flex items-center gap-2"> onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })}
<StatusBadge status={approval.status} /> detailLink={`/approvals/${approval.id}`}
<span className="font-medium"> isPending={pendingApprovalAction?.approvalId === approval.id}
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} pendingAction={
</span> pendingApprovalAction?.approvalId === approval.id
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span> ? pendingApprovalAction.action
</div> : null
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span> }
</Link> />
</div>
))} ))}
</div> </div>
</CollapsibleContent> </CollapsibleContent>