Add other answers to issue questions

This commit is contained in:
Dotta
2026-05-25 15:06:38 +00:00
parent f360fcbbb3
commit 4afe5ab7cb
9 changed files with 230 additions and 16 deletions
+1
View File
@@ -722,6 +722,7 @@ export interface AskUserQuestionsPayload {
export interface AskUserQuestionsAnswer { export interface AskUserQuestionsAnswer {
questionId: string; questionId: string;
optionIds: string[]; optionIds: string[];
otherText?: string | null;
} }
export interface AskUserQuestionsResult { export interface AskUserQuestionsResult {
+1
View File
@@ -666,6 +666,7 @@ export const askUserQuestionsPayloadSchema = z.object({
export const askUserQuestionsAnswerSchema = z.object({ export const askUserQuestionsAnswerSchema = z.object({
questionId: z.string().trim().min(1).max(120), questionId: z.string().trim().min(1).max(120),
optionIds: z.array(z.string().trim().min(1).max(120)).max(20), 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({ export const askUserQuestionsResultSchema = z.object({
@@ -453,8 +453,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
companyId, companyId,
}, created.id, { }, created.id, {
answers: [ answers: [
{ questionId: "scope", optionIds: ["phase-1"] }, { questionId: "scope", optionIds: [], otherText: "Custom Phase 1" },
{ questionId: "extras", optionIds: ["docs", "tests", "docs"] }, {
questionId: "extras",
optionIds: ["docs", "tests", "docs"],
otherText: " Pair with release notes ",
},
], ],
summaryMarkdown: "Ship Phase 1 with tests and docs.", summaryMarkdown: "Ship Phase 1 with tests and docs.",
}, { }, {
@@ -465,8 +469,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect(answered.result).toEqual({ expect(answered.result).toEqual({
version: 1, version: 1,
answers: [ answers: [
{ questionId: "scope", optionIds: ["phase-1"] }, { questionId: "scope", optionIds: [], otherText: "Custom Phase 1" },
{ questionId: "extras", optionIds: ["docs", "tests"] }, { questionId: "extras", optionIds: ["docs", "tests"], otherText: "Pair with release notes" },
], ],
summaryMarkdown: "Ship Phase 1 with tests and docs.", summaryMarkdown: "Ship Phase 1 with tests and docs.",
}); });
@@ -272,15 +272,20 @@ function normalizeQuestionAnswers(args: {
throw unprocessable(`Question ${answer.questionId} only allows one answer`); throw unprocessable(`Question ${answer.questionId} only allows one answer`);
} }
const otherText = answer.otherText?.trim() ?? "";
answerByQuestionId.set(answer.questionId, { answerByQuestionId.set(answer.questionId, {
questionId: answer.questionId, questionId: answer.questionId,
optionIds: uniqueOptionIds, optionIds: uniqueOptionIds,
...(otherText ? { otherText } : {}),
}); });
} }
for (const question of args.questions) { for (const question of args.questions) {
const answer = answerByQuestionId.get(question.id); 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`); throw unprocessable(`Question ${question.id} requires an answer`);
} }
} }
@@ -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(
<MemoryRouter>
<IssueChatThread
comments={[]}
interactions={[createQuestionInteraction()]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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 () => { it("invokes the cancel callback for pending question interactions", async () => {
const root = createRoot(container); const root = createRoot(container);
const onCancelInteraction = vi.fn(async () => undefined); const onCancelInteraction = vi.fn(async () => undefined);
@@ -74,7 +74,7 @@ describe("IssueThreadInteractionCard", () => {
); );
const radios = [...host.querySelectorAll('[role="radio"]')]; const radios = [...host.querySelectorAll('[role="radio"]')];
expect(radios).toHaveLength(2); expect(radios).toHaveLength(3);
expect(radios[0]?.getAttribute("aria-checked")).toBe("false"); expect(radios[0]?.getAttribute("aria-checked")).toBe("false");
act(() => { act(() => {
@@ -88,7 +88,65 @@ describe("IssueThreadInteractionCard", () => {
expect(multiGroup?.getAttribute("aria-labelledby")).toBe( expect(multiGroup?.getAttribute("aria-labelledby")).toBe(
"interaction-questions-default-post-submit-summary-prompt", "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", () => { it("only shows question cancellation when a cancel handler is wired", () => {
@@ -26,6 +26,8 @@ import { PriorityIcon } from "./PriorityIcon";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const OTHER_ANSWER_ID = "__paperclip_other__";
interface IssueThreadInteractionCardProps { interface IssueThreadInteractionCardProps {
interaction: IssueThreadInteraction; interaction: IssueThreadInteraction;
agentMap?: Map<string, Agent>; agentMap?: Map<string, Agent>;
@@ -662,6 +664,20 @@ function AskUserQuestionsCard({
]), ]),
), ),
); );
const [draftOtherAnswers, setDraftOtherAnswers] = useState<Record<string, string>>(() =>
Object.fromEntries(
(interaction.result?.answers ?? [])
.filter((answer) => answer.otherText)
.map((answer) => [answer.questionId, answer.otherText ?? ""]),
),
);
const [otherActiveQuestions, setOtherActiveQuestions] = useState<Record<string, boolean>>(() =>
Object.fromEntries(
(interaction.result?.answers ?? [])
.filter((answer) => answer.otherText)
.map((answer) => [answer.questionId, true]),
),
);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [cancelling, setCancelling] = 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]); }, [interaction.result?.answers]);
const questions = interaction.payload.questions; const questions = interaction.payload.questions;
const requiredQuestions = questions.filter((question) => question.required); const requiredQuestions = questions.filter((question) => question.required);
const canSubmit = requiredQuestions.every( 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") { 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) => { setDraftAnswers((current) => {
const existing = current[questionId] ?? []; const existing = current[questionId] ?? [];
if (selectionMode === "single") { if (selectionMode === "single") {
@@ -693,6 +739,9 @@ function AskUserQuestionsCard({
: [...existing, optionId]; : [...existing, optionId];
return { ...current, [questionId]: next }; return { ...current, [questionId]: next };
}); });
if (selectionMode === "single") {
setOtherActiveQuestions((current) => ({ ...current, [questionId]: false }));
}
} }
async function handleSubmit() { async function handleSubmit() {
@@ -701,10 +750,16 @@ function AskUserQuestionsCard({
try { try {
await onSubmitInteractionAnswers( await onSubmitInteractionAnswers(
interaction, interaction,
questions.map((question) => ({ questions.map((question) => {
questionId: question.id, const otherText = otherActiveQuestions[question.id] === true
optionIds: draftAnswers[question.id] ?? [], ? draftOtherAnswers[question.id]?.trim() ?? ""
})), : "";
return {
questionId: question.id,
optionIds: draftAnswers[question.id] ?? [],
...(otherText ? { otherText } : {}),
};
}),
); );
} finally { } finally {
setWorking(false); setWorking(false);
@@ -783,6 +838,28 @@ function AskUserQuestionsCard({
toggleOption(question.id, option.id, question.selectionMode)} toggleOption(question.id, option.id, question.selectionMode)}
/> />
))} ))}
<QuestionOptionButton
id={`${interaction.id}-${question.id}-other`}
label="Other"
description="Use a written answer instead of the listed choices."
selected={otherActiveQuestions[question.id] === true}
selectionMode={question.selectionMode}
onClick={() =>
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
/>
{otherActiveQuestions[question.id] ? (
<Textarea
aria-label={`Other answer for ${question.prompt}`}
value={draftOtherAnswers[question.id] ?? ""}
onChange={(event) =>
setDraftOtherAnswers((current) => ({
...current,
[question.id]: event.target.value,
}))}
placeholder="Type your answer"
className="min-h-24 bg-background text-sm"
/>
) : null}
</div> </div>
</div> </div>
))} ))}
+2 -1
View File
@@ -141,10 +141,11 @@ describe("issue thread interaction helpers", () => {
{ {
questionId: "question-1", questionId: "question-1",
optionIds: ["option-2", "option-1"], optionIds: ["option-2", "option-1"],
otherText: "A written answer",
}, },
], ],
}); });
expect(labels).toEqual(["Option 2", "Option 1"]); expect(labels).toEqual(["Option 2", "Option 1", "Other: A written answer"]);
}); });
}); });
+6 -3
View File
@@ -132,12 +132,15 @@ export function getQuestionAnswerLabels(args: {
answers: readonly AskUserQuestionsAnswer[]; answers: readonly AskUserQuestionsAnswer[];
}) { }) {
const { question, answers } = args; const { question, answers } = args;
const selectedIds = const answer = answers.find((candidate) => candidate.questionId === question.id);
answers.find((answer) => answer.questionId === question.id)?.optionIds ?? []; const selectedIds = answer?.optionIds ?? [];
const optionLabelById = new Map( const optionLabelById = new Map(
question.options.map((option) => [option.id, option.label] as const), question.options.map((option) => [option.id, option.label] as const),
); );
return selectedIds const labels = selectedIds
.map((optionId) => optionLabelById.get(optionId)) .map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string"); .filter((label): label is string => typeof label === "string");
const otherText = answer?.otherText?.trim();
if (otherText) labels.push(`Other: ${otherText}`);
return labels;
} }