Add other answers to issue questions
This commit is contained in:
@@ -722,6 +722,7 @@ export interface AskUserQuestionsPayload {
|
||||
export interface AskUserQuestionsAnswer {
|
||||
questionId: string;
|
||||
optionIds: string[];
|
||||
otherText?: string | null;
|
||||
}
|
||||
|
||||
export interface AskUserQuestionsResult {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
const root = createRoot(container);
|
||||
const onCancelInteraction = vi.fn(async () => undefined);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<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 [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)}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -141,10 +141,11 @@ describe("issue thread interaction helpers", () => {
|
||||
{
|
||||
questionId: "question-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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,12 +132,15 @@ export function getQuestionAnswerLabels(args: {
|
||||
answers: readonly AskUserQuestionsAnswer[];
|
||||
}) {
|
||||
const { question, answers } = args;
|
||||
const selectedIds =
|
||||
answers.find((answer) => answer.questionId === question.id)?.optionIds ?? [];
|
||||
const answer = answers.find((candidate) => candidate.questionId === question.id);
|
||||
const selectedIds = answer?.optionIds ?? [];
|
||||
const optionLabelById = new Map(
|
||||
question.options.map((option) => [option.id, option.label] as const),
|
||||
);
|
||||
return selectedIds
|
||||
const labels = selectedIds
|
||||
.map((optionId) => optionLabelById.get(optionId))
|
||||
.filter((label): label is string => typeof label === "string");
|
||||
const otherText = answer?.otherText?.trim();
|
||||
if (otherText) labels.push(`Other: ${otherText}`);
|
||||
return labels;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user