From 84f34f7a5e4300752784e77690703bb6d98dd660 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 17:02:12 +0200 Subject: [PATCH 1/4] fix(vscode): make chat decisions operable for screen readers --- .changeset/readable-chat-decisions.md | 5 + .../tests/chat-blocking-a11y.spec.ts | 124 ++++++++++++++++++ .../tests/visual-regression.spec.mts | 1 + .../tests/visual-regression.spec.ts | 1 + .../src/components/chat/PermissionDock.tsx | 63 +++++---- .../src/components/chat/QuestionDock.tsx | 118 +++++++++++++---- .../webview-ui/src/stories/chat.stories.tsx | 29 ++++ 7 files changed, 292 insertions(+), 49 deletions(-) create mode 100644 .changeset/readable-chat-decisions.md create mode 100644 packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts diff --git a/.changeset/readable-chat-decisions.md b/.changeset/readable-chat-decisions.md new file mode 100644 index 00000000000..abbafe3c0e3 --- /dev/null +++ b/.changeset/readable-chat-decisions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make chat questions and permission prompts expose choices and remembered-decision state to screen readers. diff --git a/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts new file mode 100644 index 00000000000..a2f34ea6dd2 --- /dev/null +++ b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts @@ -0,0 +1,124 @@ +import { expect, test } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const HOST = process.env["STORYBOOK_URL"] ?? "" + +function story(id: string) { + return `${HOST}/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}` +} + +test.describe("QuestionDock accessibility interactions", () => { + test("exposes single-choice radio state and keyboard selection", async ({ page }) => { + await page.goto(story("chat--question-dock-single"), { waitUntil: "load" }) + + const group = page.getByRole("radiogroup", { name: "Which testing framework should I use for this project?" }) + const vitest = page.getByRole("radio", { name: "Vitest" }) + const jest = page.getByRole("radio", { name: "Jest" }) + + await expect(group).toHaveAccessibleDescription("Select one answer") + await expect(vitest).toHaveAccessibleDescription("Fast, Vite-native unit testing") + await expect(vitest).toHaveAttribute("aria-checked", "false") + await expect(vitest).toBeFocused() + + await vitest.click() + await expect(vitest).toHaveAttribute("aria-checked", "true") + await vitest.press("ArrowRight") + await expect(jest).toBeFocused() + await expect(vitest).toHaveAttribute("aria-checked", "false") + await expect(jest).toHaveAttribute("aria-checked", "true") + }) + + test("does not advance a question step while navigating radio answers", async ({ page }) => { + await page.goto(story("chat--question-dock-multi"), { waitUntil: "load" }) + + const group = page.getByRole("radiogroup", { name: "Which testing framework?" }) + const vitest = page.getByRole("radio", { name: "Vitest" }) + const jest = page.getByRole("radio", { name: "Jest" }) + + await expect(vitest).toBeFocused() + await vitest.press("ArrowRight") + await expect(group).toBeVisible() + await expect(jest).toBeFocused() + await expect(jest).toHaveAttribute("aria-checked", "true") + + await jest.press("Enter") + await expect(page.getByRole("radiogroup", { name: "Should I include coverage reporting?" })).toBeVisible() + }) + + test("exposes checkbox state and labels a submitted custom answer", async ({ page }) => { + await page.goto(story("chat--question-dock-multiple-choice"), { waitUntil: "load" }) + + const group = page.getByRole("group", { name: "Which checks should I run before submitting?" }) + const typecheck = page.getByRole("checkbox", { name: "Typecheck" }) + const lint = page.getByRole("checkbox", { name: "Lint" }) + const custom = page.getByRole("checkbox", { name: "Type your own answer" }) + + await expect(group).toHaveAccessibleDescription("Select all answers that apply") + await typecheck.click() + await lint.click() + await expect(typecheck).toHaveAttribute("aria-checked", "true") + await expect(lint).toHaveAttribute("aria-checked", "true") + + await custom.click() + const input = page.getByRole("textbox", { name: "Type your own answer" }) + await expect(input).toBeFocused() + await input.fill("Compile extension") + await input.press("Enter") + await expect(custom).toHaveAttribute("aria-checked", "true") + await expect(custom).toHaveAccessibleDescription("Compile extension") + }) + + test("exposes and keyboard-operates the question disclosure", async ({ page }) => { + await page.goto(story("chat--question-dock-single"), { waitUntil: "load" }) + + const toggle = page.locator('[data-slot="question-collapse-toggle"]') + const body = page.locator('[data-slot="question-dock-body"]') + await expect(toggle).toHaveAttribute("aria-expanded", "true") + await expect(body).not.toHaveAttribute("inert", "") + + await toggle.focus() + await toggle.press("Enter") + await expect(toggle).toHaveAttribute("aria-expanded", "false") + await expect(body).toHaveAttribute("inert", "") + + await toggle.press("Enter") + await expect(toggle).toHaveAttribute("aria-expanded", "true") + await expect(body).not.toHaveAttribute("inert", "") + }) +}) + +test.describe("PermissionDock accessibility interactions", () => { + test("names permission actions and exposes remembered-rule toggle state", async ({ page }) => { + await page.goto(story("composite-webview--bash-with-permission"), { waitUntil: "load" }) + + await expect(page.getByRole("region", { name: "Permission required" })).toBeVisible() + const header = page.getByRole("button", { name: "Manage Auto-Approve Rules" }) + const rules = page.locator('[data-slot="permission-rules-collapse"]') + await expect(header).toHaveAttribute("aria-expanded", "false") + await expect(rules).toHaveAttribute("aria-hidden", "true") + + await header.focus() + await header.press("Enter") + await expect(header).toHaveAttribute("aria-expanded", "true") + await expect(rules).toHaveAttribute("aria-hidden", "false") + + const row = page.locator('[data-slot="permission-rule-row"]').first() + const allow = page.getByRole("button", { name: "Allow always: bun", exact: true }) + const deny = page.getByRole("button", { name: "Deny: bun", exact: true }) + await expect(allow).toHaveAttribute("aria-pressed", "false") + await allow.focus() + await allow.press("Enter") + await expect(allow).toHaveAttribute("aria-pressed", "true") + await expect(row).toHaveAttribute("data-decision", "approved") + + await deny.focus() + await deny.press("Space") + await expect(allow).toHaveAttribute("aria-pressed", "false") + await expect(deny).toHaveAttribute("aria-pressed", "true") + await expect(row).toHaveAttribute("data-decision", "denied") + + const once = page.getByRole("button", { name: "Allow once" }) + await expect(once).toContainText("Run") + await expect(page.getByRole("button", { name: "Deny", exact: true })).toBeVisible() + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 6877dfe7b6c..70c0b6af53f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -50,6 +50,7 @@ async function disableAnimations(page: Page) { // Permission dock config-preloaded has non-deterministic toggle rendering. const SKIP = new Set([ "agentmanager--worktree-item-busy", + "chat--question-dock-multiple-choice", "composite-webview--permission-dock-config-preloaded", ]) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 75940ae1de5..617c73b72f9 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -51,6 +51,7 @@ async function disableAnimations(page: Page) { const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--pr-badge-checks-pending", + "chat--question-dock-multiple-choice", "composite-webview--permission-dock-config-preloaded", ]) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 66f05f92503..b35cbd8ae46 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -9,7 +9,7 @@ * The command buttons (Deny / Run) control the current command. */ -import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { Component, For, Show, createEffect, createMemo, createSignal, createUniqueId, onCleanup } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { DockPrompt } from "@kilocode/kilo-ui/dock-prompt" import { Icon } from "@kilocode/kilo-ui/icon" @@ -34,6 +34,10 @@ export const PermissionDock: Component<{ const session = useSession() const language = useLanguage() const { config } = useConfig() + const id = createUniqueId() + const aria = (state: boolean) => (state ? "true" : "false") + const titleID = `${id}-title` + const rulesID = `${id}-rules` const fromChild = () => props.request.sessionID !== session.currentSessionID() // Bash sends fine-grained rules via metadata.rules; other tools use the always array. @@ -47,6 +51,11 @@ export const PermissionDock: Component<{ // Normalize IDN/Unicode hostnames to punycode ASCII to prevent homograph attacks. return normalizeUrls(cmd) } + const ruleText = (rule: string) => { + if (command()) return label(rule) || resolveLabel(props.request.toolName, language.t) + if (rule === "*") return resolveLabel(props.request.toolName, language.t) + return `${resolveLabel(props.request.toolName, language.t)} ${rule}` + } const description = createMemo(() => command() ? null : describePatterns(props.request.toolName, props.request.patterns, language.t), ) @@ -185,7 +194,7 @@ export const PermissionDock: Component<{ }) return ( -
+
-
{title()}
+
+ {title()} +
} footer={ @@ -204,7 +215,8 @@ export const PermissionDock: Component<{ data-slot="permission-rules-header" data-open={expanded() ? "" : undefined} onClick={toggleExpanded} - aria-expanded={expanded()} + aria-expanded={aria(expanded())} + aria-controls={rulesID} > @@ -212,44 +224,55 @@ export const PermissionDock: Component<{ {language.t("ui.permission.manageAutoApprove")} -
+
{(rule, index) => ( -
+
- - {command() - ? label(rule) - : rule === "*" - ? resolveLabel(props.request.toolName, language.t) - : `${resolveLabel(props.request.toolName, language.t)} ${rule}`} + + {ruleText(rule)}
)} @@ -286,10 +309,8 @@ export const PermissionDock: Component<{ @@ -343,22 +367,47 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
{/* Animated body — hidden when collapsed */} -
+
-
{questionText()}
- {language.t("ui.question.singleHint")}
}> -
{language.t("ui.question.multiHint")}
+
+ {questionText()} +
+ + {language.t("ui.question.singleHint")} +
+ } + > +
+ {language.t("ui.question.multiHint")} +
-
+
{(opt, i) => { const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false const localized = translateOption(opt) return ( @@ -385,9 +438,16 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => -
+ { setTimeout(() => { @@ -422,6 +484,8 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => }} type="text" data-slot="custom-input" + aria-labelledby={customID()} + aria-describedby={textID()} placeholder={language.t("ui.question.custom.placeholder")} value={input()} disabled={store.sending} diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index fc539eda514..503c77e7ddd 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -43,6 +43,24 @@ const singleQuestion: QuestionRequest = { tool: { messageID: "asst-msg-001", callID: "call-question-001" }, } +const multipleChoiceQuestion: QuestionRequest = { + id: "q-multiple-choice-001", + sessionID: SESSION_ID, + questions: [ + { + question: "Which checks should I run before submitting?", + header: "Choose checks", + multiple: true, + options: [ + { label: "Typecheck", description: "Validate TypeScript types" }, + { label: "Lint", description: "Find style and correctness issues" }, + { label: "Unit tests", description: "Verify component behavior" }, + ], + }, + ], + tool: { messageID: "asst-msg-001", callID: "call-question-multiple-choice-001" }, +} + const multiQuestion: QuestionRequest = { id: "q-multi-001", sessionID: SESSION_ID, @@ -189,6 +207,17 @@ export const QuestionDockMulti: Story = { ), } +export const QuestionDockMultipleChoice: Story = { + name: "QuestionDock — multiple choice interaction fixture", + render: () => ( + +
+ +
+
+ ), +} + /** Many options to verify the max-height scroll constraint */ const manyOptionsQuestion: QuestionRequest = { id: "q-many-001", From e776517c4b593e4bbe8041294c192891e0889ad2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 28 May 2026 17:16:45 +0200 Subject: [PATCH 2/4] fix(vscode): select custom question answers by keyboard --- .../tests/chat-blocking-a11y.spec.ts | 14 +++++++++++ .../src/components/chat/QuestionDock.tsx | 25 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts index a2f34ea6dd2..d859d69c75b 100644 --- a/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts +++ b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts @@ -28,6 +28,20 @@ test.describe("QuestionDock accessibility interactions", () => { await expect(jest).toHaveAttribute("aria-checked", "true") }) + test("selects the custom radio when reached with arrow navigation", async ({ page }) => { + await page.goto(story("chat--question-dock-single"), { waitUntil: "load" }) + + const bun = page.getByRole("radio", { name: "Bun test" }) + const custom = page.getByRole("radio", { name: "Type your own answer" }) + await bun.click() + await expect(bun).toHaveAttribute("aria-checked", "true") + + await bun.press("ArrowRight") + await expect(bun).toHaveAttribute("aria-checked", "false") + await expect(custom).toHaveAttribute("aria-checked", "true") + await expect(page.getByRole("textbox", { name: "Type your own answer" })).toBeFocused() + }) + test("does not advance a question step while navigating radio answers", async ({ page }) => { await page.goto(story("chat--question-dock-multi"), { waitUntil: "load" }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 539bf4e9d03..34739643968 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -66,8 +66,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const multi = createMemo(() => question()?.multiple === true) const role = createMemo(() => (multi() ? "checkbox" : "radio")) const group = createMemo(() => (multi() ? "group" : "radiogroup")) - const customPicked = createMemo(() => - (store.answers[store.tab] ?? []).some((answer) => store.kinds[store.tab]?.[answer] === "custom"), + const customPicked = createMemo( + () => + (!multi() && store.editing) || + (store.answers[store.tab] ?? []).some((answer) => store.kinds[store.tab]?.[answer] === "custom"), ) const tabIndex = (picked: boolean, index: number) => { if (multi()) return undefined @@ -188,11 +190,24 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => setStore("editing", false) } + const edit = () => { + setStore("editing", true) + if (multi()) return + + const answers = [...store.answers] + answers[store.tab] = [] + setStore("answers", answers) + const kinds = [...store.kinds] + kinds[store.tab] = {} + setStore("kinds", kinds) + syncAgent(answers, kinds) + } + const selectOption = (optIndex: number) => { if (store.sending) return if (optIndex === options().length) { - setStore("editing", true) + edit() return } @@ -225,6 +240,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const next = idx === -1 ? (delta > 0 ? 0 : items.length - 1) : (idx + delta + items.length) % items.length items[next]?.focus() if (multi()) return + if (next === options().length) { + edit() + return + } const opt = options()[next] if (opt) pick(opt.label, false, false) } From d7420a4f3dab652815676bcc48b3b5827eca30b8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 09:26:51 +0200 Subject: [PATCH 3/4] fix(vscode): preserve permission action label in name --- packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts | 3 ++- .../webview-ui/src/components/chat/PermissionDock.tsx | 10 ++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts index d859d69c75b..2b214919531 100644 --- a/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts +++ b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts @@ -131,8 +131,9 @@ test.describe("PermissionDock accessibility interactions", () => { await expect(deny).toHaveAttribute("aria-pressed", "true") await expect(row).toHaveAttribute("data-decision", "denied") - const once = page.getByRole("button", { name: "Allow once" }) + const once = page.getByRole("button", { name: "Run, Allow once", exact: true }) await expect(once).toContainText("Run") + await expect(once).toHaveAccessibleName("Run, Allow once") await expect(page.getByRole("button", { name: "Deny", exact: true })).toBeVisible() }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index b35cbd8ae46..1175135f964 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -310,18 +310,12 @@ export const PermissionDock: Component<{ variant="primary" size="small" onClick={() => submit("once")} - aria-label={language.t("ui.permission.allowOnce")} + aria-label={`${language.t("ui.permission.run")}, ${language.t("ui.permission.allowOnce")}`} disabled={props.responding} > {language.t("ui.permission.run")} -
From a66fdb0abe17c064c97c80ebf1802ab5c7f7aefb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 29 May 2026 14:48:35 +0200 Subject: [PATCH 4/4] fix(vscode): remove redundant custom answer relation --- .../webview-ui/src/components/chat/QuestionDock.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 34739643968..e6b640f6958 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -30,7 +30,6 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const textID = () => `${id}-text-${store.tab}` const hintID = () => `${id}-hint-${store.tab}` const customID = () => `${id}-custom-${store.tab}` - const formID = () => `${id}-form-${store.tab}` const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -465,7 +464,6 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => aria-checked={aria(customPicked())} aria-labelledby={customID()} aria-describedby={!store.editing ? `${customID()}-description` : undefined} - aria-controls={store.editing ? formID() : undefined} tabIndex={tabIndex(customPicked(), options().length)} disabled={store.sending} onClick={() => selectOption(options().length)} @@ -493,7 +491,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => - + { setTimeout(() => {