mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Allow cancelling question interactions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0b85f56d27
commit
326fd2f6fb
13 files changed
+394
-15
No files matched your search
@@ -160,6 +160,7 @@ export const ISSUE_THREAD_INTERACTION_STATUSES = [
|
||||
"accepted",
|
||||
"rejected",
|
||||
"answered",
|
||||
"cancelled",
|
||||
"expired",
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
@@ -414,6 +414,8 @@ export interface AskUserQuestionsAnswer {
|
||||
export interface AskUserQuestionsResult {
|
||||
version: 1;
|
||||
answers: AskUserQuestionsAnswer[];
|
||||
cancelled?: true;
|
||||
cancellationReason?: string | null;
|
||||
summaryMarkdown?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -330,6 +330,8 @@ export const askUserQuestionsAnswerSchema = z.object({
|
||||
export const askUserQuestionsResultSchema = z.object({
|
||||
version: z.literal(1),
|
||||
answers: z.array(askUserQuestionsAnswerSchema).max(20),
|
||||
cancelled: z.literal(true).optional(),
|
||||
cancellationReason: z.string().trim().max(4000).nullable().optional(),
|
||||
summaryMarkdown: z.string().max(20000).nullable().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockInteractionService = vi.hoisted(() => ({
|
||||
rejectInteraction: vi.fn(),
|
||||
rejectSuggestedTasks: vi.fn(),
|
||||
answerQuestions: vi.fn(),
|
||||
cancelQuestions: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
@@ -249,6 +250,36 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
updatedAt: "2026-04-20T12:06:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:06:00.000Z",
|
||||
});
|
||||
mockInteractionService.cancelQuestions.mockResolvedValue({
|
||||
id: "interaction-2",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "ask_user_questions",
|
||||
status: "cancelled",
|
||||
continuationPolicy: "wake_assignee",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: "comment-2",
|
||||
sourceRunId: "run-2",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "scope",
|
||||
prompt: "Scope?",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "phase-1", label: "Phase 1" }],
|
||||
}],
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
answers: [],
|
||||
cancelled: true,
|
||||
cancellationReason: null,
|
||||
summaryMarkdown: null,
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:05:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:05:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists and creates board-authored interactions", async () => {
|
||||
@@ -363,6 +394,30 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels question interactions without emitting a continuation wake", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/cancel")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("cancelled");
|
||||
expect(mockInteractionService.cancelQuestions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
|
||||
"interaction-2",
|
||||
{},
|
||||
expect.objectContaining({ userId: "local-board" }),
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.thread_interaction_cancelled",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => {
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
|
||||
@@ -427,6 +427,85 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
})).rejects.toThrow("Interaction has already been resolved");
|
||||
});
|
||||
|
||||
it("persists cancelled ask_user_questions interactions without answer data", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Cancel question answers",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Question parent",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "ask_user_questions",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "scope",
|
||||
prompt: "Choose the scope",
|
||||
selectionMode: "single",
|
||||
required: true,
|
||||
options: [
|
||||
{ id: "phase-1", label: "Phase 1" },
|
||||
{ id: "phase-2", label: "Phase 2" },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const cancelled = await interactionsSvc.cancelQuestions({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
reason: "Not needed anymore",
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
expect(cancelled.result).toEqual({
|
||||
version: 1,
|
||||
answers: [],
|
||||
cancelled: true,
|
||||
cancellationReason: "Not needed anymore",
|
||||
summaryMarkdown: null,
|
||||
});
|
||||
|
||||
await expect(interactionsSvc.answerQuestions({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, created.id, {
|
||||
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
||||
}, {
|
||||
userId: "local-board",
|
||||
})).rejects.toThrow("Interaction has already been resolved");
|
||||
});
|
||||
|
||||
it("reuses the existing interaction when the same idempotency key is submitted twice", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
||||
@@ -3335,6 +3335,50 @@ export function issueRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/issues/:id/interactions/:interactionId/cancel",
|
||||
validate(rejectIssueThreadInteractionSchema),
|
||||
async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const interactionId = req.params.interactionId as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
assertBoard(req);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const interaction = await issueThreadInteractionService(db).cancelQuestions(issue, interactionId, req.body, {
|
||||
agentId: actor.agentId,
|
||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.thread_interaction_cancelled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
interactionId: interaction.id,
|
||||
interactionKind: interaction.kind,
|
||||
interactionStatus: interaction.status,
|
||||
cancellationReason:
|
||||
interaction.kind === "ask_user_questions"
|
||||
? (interaction.result?.cancellationReason ?? null)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(interaction);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/issues/:id/comments/:commentId", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
|
||||
@@ -1265,5 +1265,60 @@ export function issueThreadInteractionService(db: Db) {
|
||||
await touchIssue(db, issue.id);
|
||||
return hydrateInteraction(updated);
|
||||
},
|
||||
|
||||
cancelQuestions: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
input: RejectIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
const data = rejectIssueThreadInteractionSchema.parse(input);
|
||||
const current = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Interaction not found");
|
||||
if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
|
||||
throw notFound("Interaction not found");
|
||||
}
|
||||
if (current.kind !== "ask_user_questions") {
|
||||
throw unprocessable("Only ask_user_questions interactions can be cancelled");
|
||||
}
|
||||
if (current.status !== "pending") {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
|
||||
const reason = data.reason?.trim() || null;
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
result: {
|
||||
version: 1,
|
||||
answers: [],
|
||||
cancelled: true,
|
||||
cancellationReason: reason,
|
||||
summaryMarkdown: null,
|
||||
},
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, interactionId),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
|
||||
await touchIssue(db, issue.id);
|
||||
return hydrateInteraction(updated);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -160,6 +160,8 @@ export const issuesApi = {
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
|
||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}),
|
||||
cancelInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/cancel`, reason ? { reason } : {}),
|
||||
respondToInteraction: (
|
||||
id: string,
|
||||
interactionId: string,
|
||||
|
||||
@@ -1357,6 +1357,49 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes the cancel callback for pending question interactions", async () => {
|
||||
const root = createRoot(container);
|
||||
const onCancelInteraction = vi.fn(async () => undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
interactions={[createQuestionInteraction()]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const cancelButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Cancel question"),
|
||||
);
|
||||
expect(cancelButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onCancelInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "interaction-question-1",
|
||||
kind: "ask_user_questions",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("folds expired request confirmations into an activity row by default", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -146,6 +146,9 @@ interface IssueChatMessageContext {
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
@@ -319,6 +322,9 @@ interface IssueChatThreadProps {
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
/**
|
||||
* Hook for the parent to refetch comments when the user explicitly asks
|
||||
@@ -1812,6 +1818,7 @@ function ExpiredRequestConfirmationActivity({
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onCancelInteraction,
|
||||
} = useContext(IssueChatCtx);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
|
||||
@@ -1890,6 +1897,7 @@ function ExpiredRequestConfirmationActivity({
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1905,6 +1913,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
@@ -1951,6 +1960,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3004,6 +3014,7 @@ export function IssueChatThread({
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
composerRef,
|
||||
onRefreshLatestComments,
|
||||
}: IssueChatThreadProps) {
|
||||
@@ -3404,6 +3415,7 @@ export function IssueChatThread({
|
||||
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
|
||||
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
|
||||
const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers);
|
||||
const stableOnCancelInteraction = useStableEvent(onCancelInteraction);
|
||||
|
||||
const chatCtx = useMemo<IssueChatMessageContext>(
|
||||
() => ({
|
||||
@@ -3423,6 +3435,7 @@ export function IssueChatThread({
|
||||
onAcceptInteraction: stableOnAcceptInteraction,
|
||||
onRejectInteraction: stableOnRejectInteraction,
|
||||
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
|
||||
onCancelInteraction: stableOnCancelInteraction,
|
||||
}),
|
||||
[
|
||||
feedbackDataSharingPreference,
|
||||
@@ -3441,6 +3454,7 @@ export function IssueChatThread({
|
||||
stableOnAcceptInteraction,
|
||||
stableOnRejectInteraction,
|
||||
stableOnSubmitInteractionAnswers,
|
||||
stableOnCancelInteraction,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ interface IssueThreadInteractionCardProps {
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function resolveActorLabel(args: {
|
||||
@@ -72,6 +75,8 @@ function statusLabel(status: IssueThreadInteraction["status"]) {
|
||||
return "Rejected";
|
||||
case "answered":
|
||||
return "Answered";
|
||||
case "cancelled":
|
||||
return "Cancelled";
|
||||
case "expired":
|
||||
return "Expired";
|
||||
case "failed":
|
||||
@@ -100,6 +105,7 @@ function statusIcon(status: IssueThreadInteraction["status"]) {
|
||||
case "answered":
|
||||
return CheckCircle2;
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
case "failed":
|
||||
return XCircle;
|
||||
case "expired":
|
||||
@@ -118,6 +124,7 @@ function statusClasses(status: IssueThreadInteraction["status"]) {
|
||||
badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100",
|
||||
};
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return {
|
||||
shell: "border-rose-400/70 bg-transparent",
|
||||
badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100",
|
||||
@@ -636,12 +643,16 @@ function QuestionOptionButton({
|
||||
function AskUserQuestionsCard({
|
||||
interaction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: {
|
||||
interaction: AskUserQuestionsInteraction;
|
||||
onSubmitInteractionAnswers?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void> | void;
|
||||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
}) {
|
||||
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
|
||||
Object.fromEntries(
|
||||
@@ -652,6 +663,7 @@ function AskUserQuestionsCard({
|
||||
),
|
||||
);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftAnswers(
|
||||
@@ -699,6 +711,16 @@ function AskUserQuestionsCard({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!onCancelInteraction) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancelInteraction(interaction);
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -765,26 +787,52 @@ function AskUserQuestionsCard({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Submit once after you finish the full form.
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!onSubmitInteractionAnswers || !canSubmit || working}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.submitLabel ?? "Submit answers"
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onCancelInteraction || working || cancelling}
|
||||
onClick={() => void handleCancel()}
|
||||
>
|
||||
{cancelling ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
"Cancel question"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!onSubmitInteractionAnswers || !canSubmit || working || cancelling}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.submitLabel ?? "Submit answers"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : interaction.status === "cancelled" ? (
|
||||
<div className="rounded-2xl border border-rose-300/60 bg-rose-50/85 p-4 text-sm leading-6 text-rose-950 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<div className="font-semibold">Question cancelled</div>
|
||||
{interaction.result?.cancellationReason ? (
|
||||
<p className="mt-1">{interaction.result.cancellationReason}</p>
|
||||
) : (
|
||||
<p className="mt-1">No answer was recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{questions.map((question) => {
|
||||
@@ -1162,6 +1210,7 @@ export function IssueThreadInteractionCard({
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: IssueThreadInteractionCardProps) {
|
||||
const StatusIcon = statusIcon(interaction.status);
|
||||
const styles = statusClasses(interaction.status);
|
||||
@@ -1247,6 +1296,7 @@ export function IssueThreadInteractionCard({
|
||||
<AskUserQuestionsCard
|
||||
interaction={interaction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
|
||||
@@ -87,6 +87,9 @@ export function buildIssueThreadInteractionSummary(
|
||||
if (interaction.status === "answered") {
|
||||
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
||||
}
|
||||
if (interaction.status === "cancelled") {
|
||||
return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`;
|
||||
}
|
||||
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
type AskUserQuestionsAnswer,
|
||||
type AskUserQuestionsInteraction,
|
||||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
@@ -622,6 +623,7 @@ type IssueDetailChatTabProps = {
|
||||
interaction: IssueThreadInteraction,
|
||||
answers: AskUserQuestionsAnswer[],
|
||||
) => Promise<void>;
|
||||
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
|
||||
};
|
||||
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
@@ -669,6 +671,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
}: IssueDetailChatTabProps) {
|
||||
const { data: activity } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
@@ -871,6 +874,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onSubmitInteractionAnswers={(interaction, answers) =>
|
||||
onSubmitInteractionAnswers(interaction, answers)
|
||||
}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
onCancelRun={runningIssueRun && onPauseWorkRun
|
||||
? async () => {
|
||||
await onPauseWorkRun(runningIssueRun.id);
|
||||
@@ -1977,6 +1981,27 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const cancelInteraction = useMutation({
|
||||
mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) =>
|
||||
issuesApi.cancelInteraction(issueId!, interaction.id),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueCollections();
|
||||
pushToast({
|
||||
title: "Question cancelled",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Cancel failed",
|
||||
body: err instanceof Error ? err.message : "Unable to cancel the question",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const addCommentAndReassign = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
@@ -2753,6 +2778,9 @@ export function IssueDetail() {
|
||||
) => {
|
||||
await answerInteraction.mutateAsync({ interaction, answers });
|
||||
}, [answerInteraction]);
|
||||
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
|
||||
await cancelInteraction.mutateAsync({ interaction });
|
||||
}, [cancelInteraction]);
|
||||
|
||||
const treePreviewAffectedIssues = useMemo(
|
||||
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
|
||||
@@ -3647,6 +3675,7 @@ export function IssueDetail() {
|
||||
onAcceptInteraction={handleAcceptInteraction}
|
||||
onRejectInteraction={handleRejectInteraction}
|
||||
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
|
||||
onCancelInteraction={handleCancelInteraction}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
Reference in new issue
Block a user