From e91f78f932e82a9523ffff0fdbd871fc499706d6 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Fri, 31 Jul 2026 10:56:57 +0000 Subject: [PATCH 1/2] fix(vscode): support reviewing question answers --- .changeset/vscode-question-dialog-review.md | 5 + apps/vscode/test/question-dialog.test.tsx | 106 +++++++++++ apps/vscode/test/question-flow.test.ts | 68 +++++++ apps/vscode/tsconfig.json | 2 +- apps/vscode/vitest.config.ts | 2 +- .../src/components/QuestionDialog.tsx | 168 +++++++++++++----- .../src/components/question-flow.ts | 96 ++++++++++ apps/vscode/webview-ui/tsconfig.json | 2 +- 8 files changed, 405 insertions(+), 44 deletions(-) create mode 100644 .changeset/vscode-question-dialog-review.md create mode 100644 apps/vscode/test/question-dialog.test.tsx create mode 100644 apps/vscode/test/question-flow.test.ts create mode 100644 apps/vscode/webview-ui/src/components/question-flow.ts diff --git a/.changeset/vscode-question-dialog-review.md b/.changeset/vscode-question-dialog-review.md new file mode 100644 index 0000000000..491e04b450 --- /dev/null +++ b/.changeset/vscode-question-dialog-review.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Let VS Code users select multiple answers, revisit earlier questions, and review the final answer before submitting AskUserQuestion prompts. diff --git a/apps/vscode/test/question-dialog.test.tsx b/apps/vscode/test/question-dialog.test.tsx new file mode 100644 index 0000000000..9bc957ffcd --- /dev/null +++ b/apps/vscode/test/question-dialog.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QuestionDialog } from "../webview-ui/src/components/QuestionDialog"; + +const store = vi.hoisted(() => ({ + pendingQuestion: { + id: "question-1", + tool_call_id: "tool-1", + questions: [ + { + question: "Languages?", + options: [{ label: "Go" }, { label: "TypeScript" }], + multi_select: true, + }, + { + question: "Editor?", + options: [{ label: "VS Code" }, { label: "Vim" }], + multi_select: false, + }, + ], + }, + respondQuestion: vi.fn<(_: Record) => Promise>(), +})); + +vi.mock("@/stores", () => ({ useChatStore: () => store })); + +function button(container: HTMLElement, label: string): HTMLButtonElement { + const match = [...container.querySelectorAll("button")].find((item) => + item.textContent?.includes(label), + ); + if (!match) throw new Error(`button not found: ${label}`); + return match; +} + +async function click(element: HTMLElement): Promise { + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("QuestionDialog", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + store.respondQuestion.mockReset().mockResolvedValue(undefined); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("preserves multi and custom answers across Back and submits only explicitly", async () => { + await click(button(container, "Go")); + expect(store.respondQuestion).not.toHaveBeenCalled(); + + await click(button(container, "Custom response")); + const input = container.querySelector("input"); + if (!input) throw new Error("custom response input not found"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "Rust"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click(button(container, "Send")); + await click(button(container, "Next")); + await click(button(container, "Custom response")); + const editorInput = container.querySelector("input"); + if (!editorInput) throw new Error("editor custom response input not found"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(editorInput, "Neovim"); + editorInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click(button(container, "Send")); + expect(store.respondQuestion).not.toHaveBeenCalled(); + + await click(button(container, "Back")); + expect(container.textContent).toContain("Rust"); + await click(button(container, "Next")); + + const submit = button(container, "Submit answers"); + await act(async () => { + submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); + submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(store.respondQuestion).toHaveBeenCalledTimes(1); + expect(store.respondQuestion).toHaveBeenCalledWith({ + "Languages?": "Go, Rust", + "Editor?": "Neovim", + }); + }); +}); diff --git a/apps/vscode/test/question-flow.test.ts b/apps/vscode/test/question-flow.test.ts new file mode 100644 index 0000000000..9fa2d51107 --- /dev/null +++ b/apps/vscode/test/question-flow.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + answerQuestion, + answerQuestionWithCustom, + canAdvanceQuestion, + createQuestionFlow, + moveQuestion, + toggleQuestionOption, +} from "../webview-ui/src/components/question-flow"; + +const questions = [ + { question: "Languages?", multi_select: true }, + { question: "Editor?", multi_select: false }, +] as const; +const languageQuestion = questions[0]; +const editorQuestion = questions[1]; + +describe("question flow", () => { + it("keeps multi-select choices on the current question until the user advances", () => { + let flow = createQuestionFlow(questions); + + flow = toggleQuestionOption(flow, languageQuestion, "Go"); + flow = toggleQuestionOption(flow, languageQuestion, "TypeScript"); + + expect(flow.questionIndex).toBe(0); + expect(flow.answers).toEqual({ "Languages?": "Go, TypeScript" }); + expect(canAdvanceQuestion(flow, languageQuestion)).toBe(true); + }); + + it("can return to an earlier question without losing either answer", () => { + let flow = createQuestionFlow(questions); + flow = answerQuestion(flow, languageQuestion, "Go"); + flow = moveQuestion(flow, 1, questions.length); + flow = answerQuestion(flow, editorQuestion, "VS Code"); + + flow = moveQuestion(flow, -1, questions.length); + + expect(flow.questionIndex).toBe(0); + expect(flow.answers).toEqual({ "Languages?": "Go", "Editor?": "VS Code" }); + }); + + it("does not advance beyond the final review step", () => { + let flow = createQuestionFlow(questions); + flow = answerQuestion(flow, languageQuestion, "Go"); + flow = moveQuestion(flow, 1, questions.length); + flow = answerQuestion(flow, editorQuestion, "VS Code"); + + expect(moveQuestion(flow, 1, questions.length).questionIndex).toBe(1); + expect(flow.answers).toEqual({ "Languages?": "Go", "Editor?": "VS Code" }); + }); + + it("combines a custom response with multi-select options and restores it", () => { + let flow = createQuestionFlow(questions); + flow = toggleQuestionOption(flow, languageQuestion, "Go"); + flow = answerQuestionWithCustom(flow, languageQuestion, "Rust"); + + expect(flow.answers).toEqual({ "Languages?": "Go, Rust" }); + expect(flow.customAnswers["Languages?"]).toBe("Rust"); + expect(flow.selections["Languages?"]).toEqual(["Go"]); + }); + + it("can advance with only a custom response", () => { + const flow = answerQuestionWithCustom(createQuestionFlow(questions), editorQuestion, "Neovim"); + + expect(canAdvanceQuestion(flow, editorQuestion)).toBe(true); + expect(flow.answers).toEqual({ "Editor?": "Neovim" }); + }); +}); diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json index 2e8486a7dc..fe140be83e 100644 --- a/apps/vscode/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -15,5 +15,5 @@ } }, "include": ["src/**/*", "shared/**/*", "test/**/*"], - "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts"] + "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts", "test/question-dialog.test.tsx"] } diff --git a/apps/vscode/vitest.config.ts b/apps/vscode/vitest.config.ts index 3f0b710139..d92f57962d 100644 --- a/apps/vscode/vitest.config.ts +++ b/apps/vscode/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, }, test: { - include: ['test/**/*.test.ts'], + include: ['test/**/*.test.{ts,tsx}'], environment: 'node', }, }); diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index c4919269a9..76f41f9f23 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -1,51 +1,68 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useChatStore } from "@/stores"; import { cn } from "@/lib/utils"; +import { + answerQuestionWithCustom, + canAdvanceQuestion, + createQuestionFlow, + moveQuestion, + toggleQuestionOption, +} from "./question-flow"; export function QuestionDialog() { const { pendingQuestion, respondQuestion } = useChatStore(); const [customInput, setCustomInput] = useState(""); const [showCustom, setShowCustom] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(1); - const [questionIndex, setQuestionIndex] = useState(0); - const [answers, setAnswers] = useState>({}); + const [flow, setFlow] = useState(() => createQuestionFlow([])); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); const questions = pendingQuestion?.questions ?? []; - const question = questions[questionIndex]; + const question = questions[flow.questionIndex]; useEffect(() => { if (pendingQuestion) { setShowCustom(false); setCustomInput(""); - setSelectedIndex(1); - setQuestionIndex(0); - setAnswers({}); + setFlow(createQuestionFlow(pendingQuestion.questions)); + setSubmitting(false); + submittingRef.current = false; } }, [pendingQuestion?.id]); if (!pendingQuestion || !question) return null; - // Step through the questions one by one; submit all answers after the last. - const handleAnswer = async (answer: string) => { - const nextAnswers = { ...answers, [question.question]: answer }; - if (questionIndex + 1 < questions.length) { - setAnswers(nextAnswers); - setQuestionIndex(questionIndex + 1); - setShowCustom(false); - setCustomInput(""); - setSelectedIndex(1); - } else { - await respondQuestion(nextAnswers); - } + const handleSelect = (optionLabel: string) => { + setFlow((current) => toggleQuestionOption(current, question, optionLabel)); + }; + + const handleCustomSubmit = () => { + if (!customInput.trim()) return; + setFlow((current) => answerQuestionWithCustom(current, question, customInput.trim())); + setShowCustom(false); }; - const handleSelect = async (optionLabel: string) => { - await handleAnswer(optionLabel); + const openCustom = () => { + setCustomInput(flow.customAnswers[question.question] ?? ""); + setShowCustom(true); }; - const handleCustomSubmit = async () => { - if (!customInput.trim()) return; - await handleAnswer(customInput.trim()); + const submitAnswers = async () => { + if (submittingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + try { + await respondQuestion(flow.answers); + } finally { + submittingRef.current = false; + setSubmitting(false); + } + }; + + const move = (offset: number) => { + setFlow((current) => moveQuestion(current, offset, questions.length)); + setShowCustom(false); + setCustomInput(""); }; const options = question.options || []; @@ -56,7 +73,7 @@ export function QuestionDialog() {
{questions.length > 1 && (
- Question {questionIndex + 1} of {questions.length} + Question {flow.questionIndex + 1} of {questions.length}
)} {question.header &&
{question.header}
} @@ -66,19 +83,38 @@ export function QuestionDialog() { ))} @@ -87,9 +123,11 @@ export function QuestionDialog() { setCustomInput(e.target.value)} + onChange={(e) => { + setCustomInput(e.target.value); + }} onKeyDown={(e) => { - if (e.key === "Enter") void handleCustomSubmit(); + if (e.key === "Enter") handleCustomSubmit(); if (e.key === "Escape") setShowCustom(false); }} placeholder="Enter your response..." @@ -97,7 +135,7 @@ export function QuestionDialog() { />
) : ( + )} + +
+ + {flow.questionIndex + 1 < questions.length ? ( + + ) : ( + )}
diff --git a/apps/vscode/webview-ui/src/components/question-flow.ts b/apps/vscode/webview-ui/src/components/question-flow.ts new file mode 100644 index 0000000000..add59f8abb --- /dev/null +++ b/apps/vscode/webview-ui/src/components/question-flow.ts @@ -0,0 +1,96 @@ +export interface QuestionLike { + question: string; + multi_select?: boolean; +} + +export interface QuestionFlow { + questionIndex: number; + answers: Record; + selections: Record; + customAnswers: Record; +} + +const ANSWER_SEPARATOR = ", "; + +export function createQuestionFlow(_questions: readonly QuestionLike[]): QuestionFlow { + return { questionIndex: 0, answers: {}, selections: {}, customAnswers: {} }; +} + +export function answerQuestion( + flow: QuestionFlow, + question: QuestionLike, + answer: string, +): QuestionFlow { + return withSelections( + { ...flow, customAnswers: withoutKey(flow.customAnswers, question.question) }, + question, + [answer], + ); +} + +export function answerQuestionWithCustom( + flow: QuestionFlow, + question: QuestionLike, + answer: string, +): QuestionFlow { + const customAnswers = { ...flow.customAnswers, [question.question]: answer }; + const selections = question.multi_select ? flow.selections : withoutKey(flow.selections, question.question); + return withAnswer({ ...flow, selections, customAnswers }, question); +} + +export function toggleQuestionOption( + flow: QuestionFlow, + question: QuestionLike, + option: string, +): QuestionFlow { + if (!question.multi_select) return answerQuestion(flow, question, option); + const current = flow.selections[question.question] ?? []; + const selections = current.includes(option) + ? current.filter((item) => item !== option) + : [...current, option]; + return withSelections(flow, question, selections); +} + +export function canAdvanceQuestion(flow: QuestionFlow, question: QuestionLike): boolean { + return flow.answers[question.question] !== undefined; +} + +export function moveQuestion( + flow: QuestionFlow, + offset: number, + questionCount: number, +): QuestionFlow { + const lastIndex = Math.max(0, questionCount - 1); + return { + ...flow, + questionIndex: Math.min(lastIndex, Math.max(0, flow.questionIndex + offset)), + }; +} + +function withSelections( + flow: QuestionFlow, + question: QuestionLike, + selections: string[], +): QuestionFlow { + return withAnswer( + { ...flow, selections: { ...flow.selections, [question.question]: selections } }, + question, + ); +} + +function withAnswer(flow: QuestionFlow, question: QuestionLike): QuestionFlow { + const values = [ + ...(flow.selections[question.question] ?? []), + ...(flow.customAnswers[question.question] ? [flow.customAnswers[question.question]] : []), + ]; + const answers = { ...flow.answers }; + if (values.length === 0) delete answers[question.question]; + else answers[question.question] = values.join(ANSWER_SEPARATOR); + return { ...flow, answers }; +} + +function withoutKey(record: Record, key: string): Record { + const copy = { ...record }; + delete copy[key]; + return copy; +} diff --git a/apps/vscode/webview-ui/tsconfig.json b/apps/vscode/webview-ui/tsconfig.json index 8ed3015664..fc87d83fee 100644 --- a/apps/vscode/webview-ui/tsconfig.json +++ b/apps/vscode/webview-ui/tsconfig.json @@ -20,5 +20,5 @@ "shared/*": ["../shared/*"] } }, - "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts"] + "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts", "../test/question-dialog.test.tsx"] } From ef1693adf1c5722c087a6ca5b571e714a82e72fd Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Fri, 31 Jul 2026 13:34:59 +0000 Subject: [PATCH 2/2] fix(vscode): clear custom question answers --- apps/vscode/test/question-dialog.test.tsx | 34 +++++++++++++++++++ apps/vscode/test/question-flow.test.ts | 12 +++++++ .../src/components/QuestionDialog.tsx | 7 ++-- .../src/components/question-flow.ts | 5 ++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/vscode/test/question-dialog.test.tsx b/apps/vscode/test/question-dialog.test.tsx index 9bc957ffcd..65a926372a 100644 --- a/apps/vscode/test/question-dialog.test.tsx +++ b/apps/vscode/test/question-dialog.test.tsx @@ -103,4 +103,38 @@ describe("QuestionDialog", () => { "Editor?": "Neovim", }); }); + + it("lets users remove a custom multi-select answer before submitting", async () => { + await click(button(container, "Go")); + await click(button(container, "Custom response")); + const input = container.querySelector("input"); + if (!input) throw new Error("custom response input not found"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "Rust"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click(button(container, "Send")); + + await click(button(container, "Rust")); + const reopenedInput = container.querySelector("input"); + if (!reopenedInput) throw new Error("reopened custom response input not found"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(reopenedInput, ""); + reopenedInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + const save = button(container, "Send"); + expect(save.disabled).toBe(false); + await click(save); + + await click(button(container, "Next")); + await click(button(container, "VS Code")); + await click(button(container, "Submit answers")); + + expect(store.respondQuestion).toHaveBeenCalledWith({ + "Languages?": "Go", + "Editor?": "VS Code", + }); + }); }); diff --git a/apps/vscode/test/question-flow.test.ts b/apps/vscode/test/question-flow.test.ts index 9fa2d51107..b599c72fbf 100644 --- a/apps/vscode/test/question-flow.test.ts +++ b/apps/vscode/test/question-flow.test.ts @@ -65,4 +65,16 @@ describe("question flow", () => { expect(canAdvanceQuestion(flow, editorQuestion)).toBe(true); expect(flow.answers).toEqual({ "Editor?": "Neovim" }); }); + + it("removes a cleared custom response while preserving selected options", () => { + let flow = createQuestionFlow(questions); + flow = toggleQuestionOption(flow, languageQuestion, "Go"); + flow = answerQuestionWithCustom(flow, languageQuestion, "Rust"); + + flow = answerQuestionWithCustom(flow, languageQuestion, ""); + + expect(flow.answers).toEqual({ "Languages?": "Go" }); + expect(flow.customAnswers).toEqual({}); + expect(flow.selections["Languages?"]).toEqual(["Go"]); + }); }); diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index 76f41f9f23..a397782e19 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -37,8 +37,9 @@ export function QuestionDialog() { }; const handleCustomSubmit = () => { - if (!customInput.trim()) return; - setFlow((current) => answerQuestionWithCustom(current, question, customInput.trim())); + const normalizedInput = customInput.trim(); + if (!normalizedInput && !flow.customAnswers[question.question]) return; + setFlow((current) => answerQuestionWithCustom(current, question, normalizedInput)); setShowCustom(false); }; @@ -137,7 +138,7 @@ export function QuestionDialog() { onClick={() => { handleCustomSubmit(); }} - disabled={!customInput.trim()} + disabled={!customInput.trim() && !flow.customAnswers[question.question]} className="px-2 py-1 rounded-md text-xs bg-blue-500 text-white disabled:opacity-50 cursor-pointer" > Send diff --git a/apps/vscode/webview-ui/src/components/question-flow.ts b/apps/vscode/webview-ui/src/components/question-flow.ts index add59f8abb..e19d27cef7 100644 --- a/apps/vscode/webview-ui/src/components/question-flow.ts +++ b/apps/vscode/webview-ui/src/components/question-flow.ts @@ -33,7 +33,10 @@ export function answerQuestionWithCustom( question: QuestionLike, answer: string, ): QuestionFlow { - const customAnswers = { ...flow.customAnswers, [question.question]: answer }; + const normalizedAnswer = answer.trim(); + const customAnswers = normalizedAnswer + ? { ...flow.customAnswers, [question.question]: normalizedAnswer } + : withoutKey(flow.customAnswers, question.question); const selections = question.multi_select ? flow.selections : withoutKey(flow.selections, question.question); return withAnswer({ ...flow, selections, customAnswers }, question); }