Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 21 additions & 29 deletions src/components/PermissionPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { ShieldAlert, Check, X, Send, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
import { buildAskUserQuestionResult, getAskUserQuestionKey } from "@/lib/ask-user-question";
import type { PermissionRequest, RespondPermissionFn } from "@/types";

const TOOL_LABELS: Record<string, string> = {
Expand All @@ -24,7 +25,7 @@ interface QuestionOption {
}

interface Question {
id: string;
id?: string;
question: string;
header: string;
isOther?: boolean;
Expand Down Expand Up @@ -128,16 +129,16 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
const questions = (request.toolInput.questions ?? []) as Question[];
const [selections, setSelections] = useState<Record<string, Set<string>>>(() => {
const init: Record<string, Set<string>> = {};
for (const q of questions) {
init[q.id] = new Set();
for (const [index, q] of questions.entries()) {
init[getAskUserQuestionKey(q, index)] = new Set();
}
return init;
});
const [freeText, setFreeText] = useState<Record<string, string>>({});

const toggleOption = (questionId: string, label: string, multiSelect: boolean) => {
const toggleOption = (questionKey: string, label: string, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[questionId] ?? new Set();
const current = prev[questionKey] ?? new Set();
const next = new Set(current);
if (multiSelect) {
if (next.has(label)) next.delete(label);
Expand All @@ -149,35 +150,24 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
next.add(label);
}
}
return { ...prev, [questionId]: next };
return { ...prev, [questionKey]: next };
});
setFreeText((prev) => ({ ...prev, [questionId]: "" }));
setFreeText((prev) => ({ ...prev, [questionKey]: "" }));
};

const handleSubmit = () => {
const answers: Record<string, string> = {};
const answersByQuestionId: Record<string, string[]> = {};
for (const q of questions) {
const custom = freeText[q.id]?.trim();
if (custom) {
answers[q.question] = custom;
answersByQuestionId[q.id] = [custom];
} else {
const selected = [...(selections[q.id] ?? [])];
answers[q.question] = selected.join(", ");
answersByQuestionId[q.id] = selected;
}
}
const { answers, answersByQuestionId } = buildAskUserQuestionResult(questions, selections, freeText);
onRespond("allow", {
questions: request.toolInput.questions,
answers,
answersByQuestionId,
});
};

const hasAllAnswers = questions.every((q) => {
const custom = freeText[q.id]?.trim();
const selected = selections[q.id];
const hasAllAnswers = questions.every((q, index) => {
const questionKey = getAskUserQuestionKey(q, index);
const custom = freeText[questionKey]?.trim();
const selected = selections[questionKey];
return custom || (selected && selected.size > 0);
});

Expand All @@ -186,19 +176,20 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
<div className="pointer-events-auto rounded-2xl border border-border/60 bg-background/55 shadow-lg backdrop-blur-lg">
{questions.map((q, qi) => (
<div
key={q.id}
key={q.id ?? `${qi}-${q.question}`}
className={`flex flex-col gap-3 px-4 py-3.5 ${qi > 0 ? "border-t border-border/40" : ""}`}
>
<p className="text-[13px] text-foreground">{q.question}</p>

<div className="grid grid-cols-2 gap-1.5">
{(q.options ?? []).map((opt) => {
const isSelected = selections[q.id]?.has(opt.label);
const questionKey = getAskUserQuestionKey(q, qi);
const isSelected = selections[questionKey]?.has(opt.label);
return (
<button
key={opt.label}
type="button"
onClick={() => toggleOption(q.id, opt.label, q.multiSelect)}
onClick={() => toggleOption(questionKey, opt.label, q.multiSelect)}
className={`flex flex-col items-start rounded-lg border px-3 py-2 text-start transition-colors ${
isSelected
? "border-border bg-accent text-foreground"
Expand All @@ -215,12 +206,13 @@ function AskUserQuestionPrompt({ request, onRespond }: PermissionPromptProps) {
<input
type={q.isSecret ? "password" : "text"}
placeholder="Or type your own answer..."
value={freeText[q.id] ?? ""}
value={freeText[getAskUserQuestionKey(q, qi)] ?? ""}
onChange={(e) => {
const value = e.target.value;
setFreeText((prev) => ({ ...prev, [q.id]: value }));
const questionKey = getAskUserQuestionKey(q, qi);
setFreeText((prev) => ({ ...prev, [questionKey]: value }));
if (value.trim()) {
setSelections((prev) => ({ ...prev, [q.id]: new Set() }));
setSelections((prev) => ({ ...prev, [questionKey]: new Set() }));
}
}}
onKeyDown={(e) => {
Expand Down
19 changes: 4 additions & 15 deletions src/components/tool-renderers/AskUserQuestion.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Loader2 } from "lucide-react";
import { getAskUserQuestionAnswer, getAskUserQuestionKey } from "@/lib/ask-user-question";
import type { UIMessage } from "@/types";

interface AskQuestionOption {
Expand All @@ -7,6 +8,7 @@ interface AskQuestionOption {
}

interface AskQuestionItem {
id?: string;
question: string;
header: string;
options: AskQuestionOption[];
Expand All @@ -16,18 +18,12 @@ interface AskQuestionItem {
export function AskUserQuestionContent({ message }: { message: UIMessage }) {
const questions = (message.toolInput?.questions ?? []) as AskQuestionItem[];
const hasResult = !!message.toolResult;
const answers = (() => {
const raw = message.toolResult?.answers;
if (!raw || typeof raw !== "object") return null;
return raw as Record<string, unknown>;
})();
const orderedAnswers = answers ? Object.values(answers) : [];

return (
<div className="space-y-2 text-xs">
{questions.map((q, qi) => (
<div
key={q.question}
key={getAskUserQuestionKey(q, qi)}
className={qi > 0 ? "border-t border-border/40 pt-2" : ""}
>
<span className="text-[13px] text-foreground/80 leading-snug">
Expand All @@ -47,14 +43,7 @@ export function AskUserQuestionContent({ message }: { message: UIMessage }) {
<div className="mt-1.5">
<span className="text-[11px] text-foreground/40">Answer: </span>
<span className="text-[12px] text-foreground/80">
{(() => {
const direct = answers?.[q.question];
if (typeof direct === "string" && direct.trim()) return direct;
// Fallback for edge cases where question text keys differ
const indexed = orderedAnswers[qi];
if (typeof indexed === "string" && indexed.trim()) return indexed;
return "No answer captured";
})()}
{getAskUserQuestionAnswer(q, qi, message.toolResult)}
</span>
Comment on lines 43 to 47
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Within this questions.map render, the wrapper uses key={q.question}. If two questions have the same text (or if text changes), React can reuse DOM/state incorrectly; with multi-question prompts it’s safer to key by a stable unique identifier (question id when present, otherwise the same fallback key you use for answers/selections).

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already applied in baeb516 — the renderer now uses getAskUserQuestionKey(q, qi) instead of q.question, so duplicate or changing question text will not reuse the wrong row state. I re-ran the targeted tests plus full test/build validation on the current branch before replying. Screenshot: https://github.com/user-attachments/assets/b5cffa90-81c9-45bf-8bf5-f8980d742722

</div>
)}
Expand Down
142 changes: 142 additions & 0 deletions src/lib/ask-user-question.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { buildAskUserQuestionResult, getAskUserQuestionAnswer, getAskUserQuestionKey } from "./ask-user-question";

describe("getAskUserQuestionKey", () => {
it("uses a fallback key when Claude questions do not provide ids", () => {
expect(getAskUserQuestionKey({ question: "First question?" }, 0)).toBe("question-0");
expect(getAskUserQuestionKey({ question: "Second question?" }, 1)).toBe("question-1");
});

it("prefers the provided question id when available", () => {
expect(getAskUserQuestionKey({ id: "q-1", question: "First question?" }, 0)).toBe("q-1");
});
});

describe("getAskUserQuestionAnswer", () => {
it("reads answers keyed by fallback question ids when Claude questions have no ids", () => {
const answer = getAskUserQuestionAnswer(
{ question: "Second question?" },
1,
{
answersByQuestionId: {
"question-0": ["Alpha"],
"question-1": ["Beta"],
},
},
);

expect(answer).toBe("Beta");
});

it("reads answers keyed by question id", () => {
const answer = getAskUserQuestionAnswer(
{ id: "q-1", question: "Pick one" },
0,
{
answersByQuestionId: {
"q-1": ["Option A"],
},
},
);

expect(answer).toBe("Option A");
});

it("falls back to answers keyed by question text for Claude", () => {
const answer = getAskUserQuestionAnswer(
{ question: "Second question?" },
1,
{
answers: {
"First question?": "Alpha",
"Second question?": "Beta",
},
},
);

expect(answer).toBe("Beta");
});

it("falls back to indexed answers when keys do not match", () => {
const answer = getAskUserQuestionAnswer(
{ question: "Second question?" },
1,
{
answers: {
first: "Alpha",
second: "Beta",
},
},
);

expect(answer).toBe("Beta");
});

it("supports Codex-style structured answers", () => {
const answer = getAskUserQuestionAnswer(
{ id: "q-2", question: "Choose many" },
0,
{
answersByQuestionId: {
"q-2": { answers: ["One", "Two"] },
},
},
);

expect(answer).toBe("One, Two");
});
});

describe("buildAskUserQuestionResult", () => {
it("keeps answers separate for multiple Claude questions without ids", () => {
const result = buildAskUserQuestionResult(
[
{ question: "First question?" },
{ question: "Second question?" },
],
{
"question-0": new Set(["Alpha"]),
"question-1": new Set(["Beta"]),
},
{},
);

expect(result).toEqual({
answers: {
"First question?": "Alpha",
"Second question?": "Beta",
},
answersByQuestionId: {
"question-0": ["Alpha"],
"question-1": ["Beta"],
},
});
});

it("prefers free text over selected options for the matching question only", () => {
const result = buildAskUserQuestionResult(
[
{ question: "First question?" },
{ question: "Second question?" },
],
{
"question-0": new Set(["Alpha"]),
"question-1": new Set(["Beta"]),
},
{
"question-1": "Custom answer",
},
);

expect(result).toEqual({
answers: {
"First question?": "Alpha",
"Second question?": "Custom answer",
},
answersByQuestionId: {
"question-0": ["Alpha"],
"question-1": ["Custom answer"],
},
});
});
});
Loading