mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03: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
|
|
@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue