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

@ -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}