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] ? (
+