Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/vscode-question-dialog-review.md
Original file line number Diff line number Diff line change
@@ -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.
140 changes: 140 additions & 0 deletions apps/vscode/test/question-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// @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<string, string>) => Promise<void>>(),
}));

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<void> {
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(<QuestionDialog />);
});
});

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",
});
});

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",
});
});
});
80 changes: 80 additions & 0 deletions apps/vscode/test/question-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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" });
});

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"]);
});
});
2 changes: 1 addition & 1 deletion apps/vscode/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
2 changes: 1 addition & 1 deletion apps/vscode/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineConfig({
},
},
test: {
include: ['test/**/*.test.ts'],
include: ['test/**/*.test.{ts,tsx}'],
environment: 'node',
},
});
Loading