mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Fold expired confirmation interactions
This commit is contained in:
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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user