From 4afe5ab7cb83407c5b4c8e0a6a208b96a640c48b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 25 May 2026 15:06:38 +0000 Subject: [PATCH] Add other answers to issue questions --- packages/shared/src/types/issue.ts | 1 + packages/shared/src/validators/issue.ts | 1 + .../issue-thread-interactions-service.test.ts | 12 ++- .../src/services/issue-thread-interactions.ts | 7 +- ui/src/components/IssueChatThread.test.tsx | 64 ++++++++++++++ .../IssueThreadInteractionCard.test.tsx | 62 ++++++++++++- .../components/IssueThreadInteractionCard.tsx | 87 +++++++++++++++++-- ui/src/lib/issue-thread-interactions.test.ts | 3 +- ui/src/lib/issue-thread-interactions.ts | 9 +- 9 files changed, 230 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 46ef3010..20d14342 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -722,6 +722,7 @@ export interface AskUserQuestionsPayload { export interface AskUserQuestionsAnswer { questionId: string; optionIds: string[]; + otherText?: string | null; } export interface AskUserQuestionsResult { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index cd532dc9..2b8740f5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -666,6 +666,7 @@ export const askUserQuestionsPayloadSchema = z.object({ export const askUserQuestionsAnswerSchema = z.object({ questionId: z.string().trim().min(1).max(120), optionIds: z.array(z.string().trim().min(1).max(120)).max(20), + otherText: multilineTextSchema.pipe(z.string().trim().max(4000)).nullable().optional(), }); export const askUserQuestionsResultSchema = z.object({ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 286bf590..f381768b 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -453,8 +453,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { companyId, }, created.id, { answers: [ - { questionId: "scope", optionIds: ["phase-1"] }, - { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + { questionId: "scope", optionIds: [], otherText: "Custom Phase 1" }, + { + questionId: "extras", + optionIds: ["docs", "tests", "docs"], + otherText: " Pair with release notes ", + }, ], summaryMarkdown: "Ship Phase 1 with tests and docs.", }, { @@ -465,8 +469,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(answered.result).toEqual({ version: 1, answers: [ - { questionId: "scope", optionIds: ["phase-1"] }, - { questionId: "extras", optionIds: ["docs", "tests"] }, + { questionId: "scope", optionIds: [], otherText: "Custom Phase 1" }, + { questionId: "extras", optionIds: ["docs", "tests"], otherText: "Pair with release notes" }, ], summaryMarkdown: "Ship Phase 1 with tests and docs.", }); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 533d3a2e..f27ec37e 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -272,15 +272,20 @@ function normalizeQuestionAnswers(args: { throw unprocessable(`Question ${answer.questionId} only allows one answer`); } + const otherText = answer.otherText?.trim() ?? ""; answerByQuestionId.set(answer.questionId, { questionId: answer.questionId, optionIds: uniqueOptionIds, + ...(otherText ? { otherText } : {}), }); } for (const question of args.questions) { const answer = answerByQuestionId.get(question.id); - if (question.required && (!answer || answer.optionIds.length === 0)) { + if ( + question.required + && (!answer || (answer.optionIds.length === 0 && !answer.otherText)) + ) { throw unprocessable(`Question ${question.id} requires an answer`); } } diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 3ad29546..4faf0aa7 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -1627,6 +1627,70 @@ describe("IssueChatThread", () => { }); }); + it("submits Other text for pending question interactions", async () => { + const root = createRoot(container); + const onSubmitInteractionAnswers = vi.fn(async () => undefined); + + await act(async () => { + root.render( + + {}} + onSubmitInteractionAnswers={onSubmitInteractionAnswers} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const otherButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Other"), + ); + expect(otherButton).toBeTruthy(); + + await act(async () => { + otherButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; + expect(textarea).toBeTruthy(); + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(textarea, "Phase 1 plus docs"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const submitButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Submit answers"), + ); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSubmitInteractionAnswers).toHaveBeenCalledWith( + expect.objectContaining({ + id: "interaction-question-1", + kind: "ask_user_questions", + }), + [{ questionId: "scope", optionIds: [], otherText: "Phase 1 plus docs" }], + ); + + act(() => { + root.unmount(); + }); + }); + it("invokes the cancel callback for pending question interactions", async () => { const root = createRoot(container); const onCancelInteraction = vi.fn(async () => undefined); diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index b289c72a..eeda1cf6 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -74,7 +74,7 @@ describe("IssueThreadInteractionCard", () => { ); const radios = [...host.querySelectorAll('[role="radio"]')]; - expect(radios).toHaveLength(2); + expect(radios).toHaveLength(3); expect(radios[0]?.getAttribute("aria-checked")).toBe("false"); act(() => { @@ -88,7 +88,65 @@ describe("IssueThreadInteractionCard", () => { expect(multiGroup?.getAttribute("aria-labelledby")).toBe( "interaction-questions-default-post-submit-summary-prompt", ); - expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3); + expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(4); + }); + + it("submits written Other answers for pending questions", async () => { + const onSubmitInteractionAnswers = vi.fn(async () => undefined); + const host = renderCard({ + interaction: pendingAskUserQuestionsInteraction, + onSubmitInteractionAnswers, + }); + + const otherButtons = Array.from(host.querySelectorAll("button")).filter((button) => + button.textContent?.includes("Other"), + ); + expect(otherButtons.length).toBeGreaterThan(0); + + await act(async () => { + otherButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null; + expect(textarea).toBeTruthy(); + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(textarea, "Keep only the root item open"); + textarea!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const summaryCheckbox = Array.from(host.querySelectorAll('[role="checkbox"]')).find((button) => + button.textContent?.includes("Inline answer pills"), + ); + await act(async () => { + summaryCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const submitButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Send answers"), + ); + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSubmitInteractionAnswers).toHaveBeenCalledWith( + expect.objectContaining({ kind: "ask_user_questions" }), + [ + { + questionId: "collapse-depth", + optionIds: [], + otherText: "Keep only the root item open", + }, + { + questionId: "post-submit-summary", + optionIds: ["answers-inline"], + }, + ], + ); }); it("only shows question cancellation when a cancel handler is wired", () => { diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index a05f0dc2..ca5fb09f 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -26,6 +26,8 @@ import { PriorityIcon } from "./PriorityIcon"; import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +const OTHER_ANSWER_ID = "__paperclip_other__"; + interface IssueThreadInteractionCardProps { interaction: IssueThreadInteraction; agentMap?: Map; @@ -662,6 +664,20 @@ function AskUserQuestionsCard({ ]), ), ); + const [draftOtherAnswers, setDraftOtherAnswers] = useState>(() => + Object.fromEntries( + (interaction.result?.answers ?? []) + .filter((answer) => answer.otherText) + .map((answer) => [answer.questionId, answer.otherText ?? ""]), + ), + ); + const [otherActiveQuestions, setOtherActiveQuestions] = useState>(() => + Object.fromEntries( + (interaction.result?.answers ?? []) + .filter((answer) => answer.otherText) + .map((answer) => [answer.questionId, true]), + ), + ); const [working, setWorking] = useState(false); const [cancelling, setCancelling] = useState(false); @@ -674,15 +690,45 @@ function AskUserQuestionsCard({ ]), ), ); + setDraftOtherAnswers( + Object.fromEntries( + (interaction.result?.answers ?? []) + .filter((answer) => answer.otherText) + .map((answer) => [answer.questionId, answer.otherText ?? ""]), + ), + ); + setOtherActiveQuestions( + Object.fromEntries( + (interaction.result?.answers ?? []) + .filter((answer) => answer.otherText) + .map((answer) => [answer.questionId, true]), + ), + ); }, [interaction.result?.answers]); const questions = interaction.payload.questions; const requiredQuestions = questions.filter((question) => question.required); const canSubmit = requiredQuestions.every( - (question) => (draftAnswers[question.id] ?? []).length > 0, + (question) => + (draftAnswers[question.id] ?? []).length > 0 + || ( + otherActiveQuestions[question.id] === true + && (draftOtherAnswers[question.id]?.trim().length ?? 0) > 0 + ), ); function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") { + if (optionId === OTHER_ANSWER_ID) { + setOtherActiveQuestions((current) => ({ + ...current, + [questionId]: !current[questionId], + })); + if (selectionMode === "single") { + setDraftAnswers((current) => ({ ...current, [questionId]: [] })); + } + return; + } + setDraftAnswers((current) => { const existing = current[questionId] ?? []; if (selectionMode === "single") { @@ -693,6 +739,9 @@ function AskUserQuestionsCard({ : [...existing, optionId]; return { ...current, [questionId]: next }; }); + if (selectionMode === "single") { + setOtherActiveQuestions((current) => ({ ...current, [questionId]: false })); + } } async function handleSubmit() { @@ -701,10 +750,16 @@ function AskUserQuestionsCard({ try { await onSubmitInteractionAnswers( interaction, - questions.map((question) => ({ - questionId: question.id, - optionIds: draftAnswers[question.id] ?? [], - })), + questions.map((question) => { + const otherText = otherActiveQuestions[question.id] === true + ? draftOtherAnswers[question.id]?.trim() ?? "" + : ""; + return { + questionId: question.id, + optionIds: draftAnswers[question.id] ?? [], + ...(otherText ? { otherText } : {}), + }; + }), ); } finally { setWorking(false); @@ -783,6 +838,28 @@ function AskUserQuestionsCard({ toggleOption(question.id, option.id, question.selectionMode)} /> ))} + + toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)} + /> + {otherActiveQuestions[question.id] ? ( +