diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index bfab4d26320..920eba15c22 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -14,6 +14,7 @@ import { } from "./IssueChatThread"; import type { AskUserQuestionsInteraction, + RequestConfirmationInteraction, SuggestTasksInteraction, } from "../lib/issue-thread-interactions"; @@ -215,6 +216,39 @@ function createQuestionInteraction( }; } +function createExpiredRequestConfirmationInteraction( + overrides: Partial = {}, +): RequestConfirmationInteraction { + return { + id: "interaction-confirmation-expired", + companyId: "company-1", + issueId: "issue-1", + kind: "request_confirmation", + title: "Approve the plan", + status: "expired", + continuationPolicy: "wake_assignee_on_accept", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: "user-1", + createdAt: new Date("2026-04-06T12:04:00.000Z"), + updatedAt: new Date("2026-04-06T12:05:00.000Z"), + resolvedAt: new Date("2026-04-06T12:05:00.000Z"), + payload: { + version: 1, + prompt: "Approve the plan and let the assignee start implementation?", + acceptLabel: "Approve plan", + rejectLabel: "Request revisions", + }, + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: "comment-1", + }, + ...overrides, + }; +} + describe("IssueChatThread", () => { let container: HTMLDivElement; @@ -535,6 +569,50 @@ describe("IssueChatThread", () => { }); }); + it("folds expired request confirmations into an activity row by default", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + + {}} + currentUserId="user-1" + userLabelMap={new Map([["user-1", "Dotta"]])} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("Dotta"); + expect(container.textContent).toContain("updated this task"); + expect(container.textContent).toContain("Expired confirmation"); + expect(container.textContent).not.toContain("Approve the plan"); + + const toggleButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Expired confirmation"), + ); + expect(toggleButton).toBeTruthy(); + + await act(async () => { + toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Approve the plan"); + expect(container.textContent).toContain("Confirmation expired after comment"); + + act(() => { + root.unmount(); + }); + }); + it("renders the transcript directly from stable Paperclip messages", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index db89048b7eb..e7e2d34af9f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -53,7 +53,7 @@ import type { RequestConfirmationInteraction, SuggestTasksInteraction, } from "../lib/issue-thread-interactions"; -import { isIssueThreadInteraction } from "../lib/issue-thread-interactions"; +import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions"; import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; @@ -616,6 +616,23 @@ function initialsForName(name: string) { return name.slice(0, 2).toUpperCase(); } +function formatInteractionActorLabel(args: { + agentId?: string | null; + userId?: string | null; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; +}) { + const { agentId, userId, agentMap, currentUserId, userLabelMap } = args; + if (agentId) return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8); + if (userId) { + return userLabelMap?.get(userId) + ?? formatAssigneeUserLabel(userId, currentUserId, userLabelMap) + ?? "Board"; + } + return "System"; +} + export function resolveIssueChatHumanAuthor(args: { authorName?: string | null; authorUserId?: string | null; @@ -1741,6 +1758,106 @@ function IssueChatFeedbackButtons({ ); } +function ExpiredRequestConfirmationActivity({ + message, + anchorId, + interaction, +}: { + message: ThreadMessage; + anchorId?: string; + interaction: RequestConfirmationInteraction; +}) { + const { + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, + } = useContext(IssueChatCtx); + const [expanded, setExpanded] = useState(false); + const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId); + const actorAgentId = hasResolvedActor + ? interaction.resolvedByAgentId ?? null + : interaction.createdByAgentId ?? null; + const actorUserId = hasResolvedActor + ? interaction.resolvedByUserId ?? null + : interaction.createdByUserId ?? null; + const actorName = formatInteractionActorLabel({ + agentId: actorAgentId, + userId: actorUserId, + agentMap, + currentUserId, + userLabelMap, + }); + const actorIcon = actorAgentId ? agentMap?.get(actorAgentId)?.icon : undefined; + const isCurrentUser = Boolean(actorUserId && currentUserId && actorUserId === currentUserId); + const detailsId = anchorId ? `${anchorId}-details` : `${interaction.id}-details`; + const summary = buildIssueThreadInteractionSummary(interaction); + + const rowContent = ( +
+
+ {actorName} + updated this task + + {timeAgo(message.createdAt)} + + +
+ {expanded ? ( +

+ {summary} +

+ ) : null} +
+ ); + + return ( +
+ {isCurrentUser ? ( +
+ {rowContent} +
+ ) : ( +
+ + {actorIcon ? ( + + ) : ( + {initialsForName(actorName)} + )} + + {rowContent} +
+ )} + {expanded ? ( +
+ +
+ ) : null} +
+ ); +} + function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { const { agentMap, @@ -1773,6 +1890,16 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { : null; if (custom.kind === "interaction" && interaction) { + if (interaction.kind === "request_confirmation" && interaction.status === "expired") { + return ( + + ); + } + return (