Add other answers to issue questions
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user