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..2b214919531 --- /dev/null +++ b/packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts @@ -0,0 +1,139 @@ +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("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" }) + + 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: "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/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..1175135f964 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,23 +309,13 @@ export const PermissionDock: Component<{ -
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 8a7acca62f8..e6b640f6958 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -4,7 +4,7 @@ * Uses kilo-ui's DockPrompt component for proper surface styling. */ -import { For, Show, createMemo, createEffect } from "solid-js" +import { For, Show, createMemo, createEffect, createUniqueId } from "solid-js" import type { Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@kilocode/kilo-ui/button" @@ -23,6 +23,13 @@ import { export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const session = useSession() const language = useLanguage() + const id = createUniqueId() + const aria = (state: boolean) => (state ? "true" : "false") + const bodyID = `${id}-body` + const titleID = `${id}-title` + const textID = () => `${id}-text-${store.tab}` + const hintID = () => `${id}-hint-${store.tab}` + const customID = () => `${id}-custom-${store.tab}` const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -56,11 +63,19 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) - const customPicked = createMemo(() => { - const value = input() - if (!value) return false - return store.answers[store.tab]?.includes(value) ?? false - }) + const role = createMemo(() => (multi() ? "checkbox" : "radio")) + const group = createMemo(() => (multi() ? "group" : "radiogroup")) + 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 + if (picked) return 0 + if ((store.answers[store.tab]?.length ?? 0) > 0 || store.editing) return -1 + return index === 0 ? 0 : -1 + } const total = createMemo(() => questions().length) const last = createMemo(() => store.tab >= total() - 1) @@ -131,7 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => session.selectAgent(next.agent) } - const pick = (answer: string, custom = false) => { + const pick = (answer: string, custom = false, advance = true) => { const answers = [...store.answers] answers[store.tab] = [answer] setStore("answers", answers) @@ -147,6 +162,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } syncAgent(answers, kinds) + if (!advance) return const outcome = pickOutcome({ single: single(), multi: multi(), custom }) if (outcome.kind === "advance") { @@ -173,11 +189,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 } @@ -190,8 +219,15 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => pick(opt.label) } + const step = (key: string) => { + if (key === "ArrowDown" || key === "ArrowRight") return 1 + if (key === "ArrowUp" || key === "ArrowLeft") return -1 + return 0 + } + const onKey = (e: KeyboardEvent) => { - if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return + const delta = step(e.key) + if (!delta) return if ((e.target as HTMLElement).tagName === "INPUT") return e.preventDefault() const el = e.currentTarget as HTMLElement @@ -200,15 +236,15 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => ) if (!items.length) return const idx = items.findIndex((b) => b === document.activeElement) - const next = - e.key === "ArrowDown" - ? idx === -1 - ? 0 - : (idx + 1) % items.length - : idx === -1 - ? items.length - 1 - : (idx - 1 + items.length) % items.length + 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) } const handleCustomSubmit = (e: Event) => { @@ -295,13 +331,17 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => ref={root} data-component="question-dock" data-collapsed={store.collapsed ? "true" : "false"} + role="region" + aria-labelledby={titleID} onClick={(e: MouseEvent) => e.stopPropagation()} onKeyDown={onRoot} > {/* Single unified header row — always visible */}
-
{summary()}
+
+ {summary()} +
{questionText()}
@@ -336,6 +376,8 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => data-slot="question-collapse-toggle" onClick={toggleCollapse} aria-label={store.collapsed ? "Expand" : "Collapse"} + aria-expanded={aria(!store.collapsed)} + aria-controls={bodyID} > @@ -343,22 +385,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 +456,15 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>