Skip to content
Closed
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/readable-chat-decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Make chat questions and permission prompts expose choices and remembered-decision state to screen readers.
139 changes: 139 additions & 0 deletions packages/kilo-vscode/tests/chat-blocking-a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
1 change: 1 addition & 0 deletions packages/kilo-vscode/tests/visual-regression.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ async function disableAnimations(page: Page) {
// Permission dock config-preloaded has non-deterministic toggle rendering.
const SKIP = new Set<string>([
"agentmanager--worktree-item-busy",
"chat--question-dock-multiple-choice",
"composite-webview--permission-dock-config-preloaded",
])

Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/tests/visual-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ async function disableAnimations(page: Page) {
const SKIP = new Set<string>([
"agentmanager--worktree-item-busy",
"agentmanager--pr-badge-checks-pending",
"chat--question-dock-multiple-choice",
"composite-webview--permission-dock-config-preloaded",
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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),
)
Expand Down Expand Up @@ -185,15 +194,17 @@ export const PermissionDock: Component<{
})

return (
<div ref={root} data-component="permission-shortcuts" onKeyDown={onRoot}>
<div ref={root} data-component="permission-shortcuts" role="region" aria-labelledby={titleID} onKeyDown={onRoot}>
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="small" />
</span>
<div data-slot="permission-header-title">{title()}</div>
<div id={titleID} data-slot="permission-header-title">
{title()}
</div>
</div>
}
footer={
Expand All @@ -204,52 +215,64 @@ 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}
>
<span data-slot="permission-rules-header-chevron" data-open={expanded() ? "" : undefined}>
<Icon name="chevron-down" size="small" />
</span>
<span data-slot="permission-rules-header-title">{language.t("ui.permission.manageAutoApprove")}</span>
</button>

<div data-slot="permission-rules-collapse" data-open={expanded() ? "" : undefined}>
<div
id={rulesID}
data-slot="permission-rules-collapse"
data-open={expanded() ? "" : undefined}
inert={!expanded() || undefined}
aria-hidden={aria(!expanded())}
>
<div data-slot="permission-rules-collapse-inner">
<div data-slot="permission-rules">
<For each={rules()}>
{(rule, index) => (
<div data-slot="permission-rule-row" data-decision={decision(index())}>
<div
data-slot="permission-rule-row"
data-decision={decision(index())}
role="group"
aria-labelledby={`${id}-rule-${index()}`}
>
<div data-slot="permission-rule-actions">
<Tooltip value={approveTooltip(index())} placement="top">
<button
type="button"
data-slot="permission-rule-toggle"
data-variant="approve"
data-active={decision(index()) === "approved" ? "" : undefined}
disabled={props.responding}
onClick={() => toggleRule(index(), "approved")}
aria-label={approveTooltip(index())}
aria-label={`${language.t("ui.permission.allowAlways")}: ${ruleText(rule)}`}
aria-pressed={aria(decision(index()) === "approved")}
>
<Icon name="check-small" size="small" />
</button>
</Tooltip>
<Tooltip value={denyTooltip(index())} placement="top">
<button
type="button"
data-slot="permission-rule-toggle"
data-variant="deny"
data-active={decision(index()) === "denied" ? "" : undefined}
disabled={props.responding}
onClick={() => toggleRule(index(), "denied")}
aria-label={denyTooltip(index())}
aria-label={`${language.t("ui.permission.deny")}: ${ruleText(rule)}`}
aria-pressed={aria(decision(index()) === "denied")}
>
<Icon name="close-small" size="small" />
</button>
</Tooltip>
</div>
<code data-slot="permission-rule">
{command()
? label(rule)
: rule === "*"
? resolveLabel(props.request.toolName, language.t)
: `${resolveLabel(props.request.toolName, language.t)} ${rule}`}
<code id={`${id}-rule-${index()}`} data-slot="permission-rule">
{ruleText(rule)}
</code>
</div>
)}
Expand Down Expand Up @@ -286,23 +309,13 @@ export const PermissionDock: Component<{
<Button
variant="primary"
size="small"
onClick={() => {
const { approved, denied } = collectRules()
props.onDecide("once", approved, denied)
}}
onClick={() => submit("once")}
aria-label={`${language.t("ui.permission.run")}, ${language.t("ui.permission.allowOnce")}`}
disabled={props.responding}
>
{language.t("ui.permission.run")}
</Button>
<Button
variant="ghost"
size="small"
onClick={() => {
const { approved, denied } = collectRules()
props.onDecide("reject", approved, denied)
}}
disabled={props.responding}
>
<Button variant="ghost" size="small" onClick={() => submit("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
</div>
Expand Down
Loading
Loading