Fold expired confirmation interactions

This commit is contained in:
Dotta committed 2026-04-24 00:21:07 -05:00
1 parent 1ce7c17ad0
commit d968b38b4e
2 files changed
+206 -1

No files matched your search

@@ -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> = {},
): 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(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createExpiredRequestConfirmationInteraction()]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
currentUserId="user-1"
userLabelMap={new Map([["user-1", "Dotta"]])}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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);
+128 -1
View File
@@ -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<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | 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 = (
<div className="min-w-0 flex-1">
<div className={cn("flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs", isCurrentUser && "justify-end")}>
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(message.createdAt)}
</a>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => setExpanded((current) => !current)}
>
<ChevronDown className={cn("h-3 w-3 transition-transform", expanded && "rotate-180")} />
{expanded ? "Hide confirmation" : "Expired confirmation"}
</button>
</div>
{expanded ? (
<p className={cn("mt-1 text-xs text-muted-foreground", isCurrentUser && "text-right")}>
{summary}
</p>
) : null}
</div>
);
return (
<div id={anchorId}>
{isCurrentUser ? (
<div className="flex items-start justify-end gap-2 py-1">
{rowContent}
</div>
) : (
<div className="flex items-start gap-2.5 py-1">
<Avatar size="sm" className="mt-0.5">
{actorIcon ? (
<AvatarFallback><AgentIcon icon={actorIcon} className="h-3.5 w-3.5" /></AvatarFallback>
) : (
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
)}
</Avatar>
{rowContent}
</div>
)}
{expanded ? (
<div id={detailsId} className="mt-2">
<IssueThreadInteractionCard
interaction={interaction}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
</div>
) : null}
</div>
);
}
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 (
<ExpiredRequestConfirmationActivity
message={message}
anchorId={anchorId}
interaction={interaction}
/>
);
}
return (
<div id={anchorId}>
<div className="py-1.5">