Merge pull request #10688 from Kilo-Org/fix-vscode-a11y-model-picker

fix(vscode): make model selection accessible to screen readers
This commit is contained in:
Marius
2026-05-29 14:42:51 +02:00
committed by GitHub
11 changed files with 598 additions and 243 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make model selection in chat and settings operable with screen readers by announcing searchable options, keyboard navigation, selected values, and model-setting purpose.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4
size 30244
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838
size 1085
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838
size 1085
@@ -0,0 +1,151 @@
import { expect, test, type Page } from "@playwright/test"
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
function story(id: string) {
return `/iframe.html?id=${id}&viewMode=story&globals=${GLOBALS}`
}
async function load(page: Page, id: string) {
await page.goto(story(id), { waitUntil: "load" })
await page.waitForSelector("#storybook-root *", { state: "attached" })
}
test("model selector exposes combobox relationships and active option movement", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const listbox = page.getByRole("listbox", { name: "Review model" })
const alpha = page.getByRole("option", { name: "Alpha" })
const bravo = page.getByRole("option", { name: "Bravo" })
await expect(combobox).toBeFocused()
await expect(combobox).toHaveAttribute("aria-expanded", "true")
await expect(combobox).toHaveAttribute("aria-controls", await listbox.getAttribute("id"))
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.")
await expect(alpha.locator("button")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Add to favorites: Alpha" })).toBeVisible()
await combobox.press("ArrowDown")
await expect(combobox).toBeFocused()
await expect(combobox).toHaveAttribute("aria-activedescendant", await bravo.getAttribute("id"))
const expand = page.getByRole("button", { name: "Expand" })
const controls = await expand.getAttribute("aria-controls")
const preview = page.locator(`[id="${controls}"]`)
await expect(expand).toHaveAttribute("aria-expanded", "false")
await expect(preview).toHaveAttribute("aria-hidden", "true")
await expect(preview.locator("button, a, [tabindex]")).toHaveCount(0)
await expand.click()
const collapse = page.getByRole("button", { name: "Collapse", exact: true })
await expect(collapse).toHaveAttribute("aria-controls", controls!)
await expect(collapse).toHaveAttribute("aria-expanded", "true")
await expect(preview).toHaveAttribute("aria-hidden", "false")
await expect(preview.getByRole("button", { name: "Add to favorites" })).toBeVisible()
})
test("expanded preview waits for explicit pointer selection", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
await page.getByRole("button", { name: "Expand" }).click()
await page.getByRole("option", { name: "Bravo" }).click()
await expect(page.getByTestId("model-selector-value")).toHaveText("alpha")
await expect(page.getByRole("combobox", { name: "Review model: Alpha. Search models" })).toBeVisible()
await expect(page.locator(".model-selector-preview")).toContainText("Bravo")
await page.getByRole("button", { name: "Select: Bravo" }).click()
await expect(page.getByTestId("model-selector-value")).toHaveText("bravo")
})
test("selected favorite remains selected when its duplicate group is collapsed", async ({ page }) => {
await load(page, "shared--model-selector-selected-favorite")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const alpha = page.getByRole("option", { name: "Alpha" })
const favorites = page.getByRole("button", { name: "Collapse Favorites" })
await expect(alpha.first()).toHaveAttribute("aria-selected", "true")
await expect.poll(() => favorites.evaluate((el) => getComputedStyle(el).borderTopStyle)).toBe("solid")
await favorites.click()
await expect(alpha).toHaveCount(1)
await expect(alpha).toHaveAttribute("aria-selected", "true")
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
})
test("Enter selects the active option and Escape restores selector focus", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
await combobox.press("ArrowDown")
await combobox.press("Enter")
const trigger = page.getByRole("button", { name: "Review model: Bravo" })
await expect(page.getByTestId("model-selector-value")).toHaveText("bravo")
await expect(trigger).toBeFocused()
await trigger.click()
const reopened = page.getByRole("combobox", { name: "Review model: Bravo. Search models" })
await reopened.press("ArrowDown")
await reopened.press("Escape")
await expect(page.getByTestId("model-selector-value")).toHaveText("bravo")
await expect(trigger).toBeFocused()
})
test("no-match search announces the empty result and can choose the default option", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
await combobox.fill("no matching model")
await expect(page.locator(".model-selector-empty")).toHaveText("No model results")
const clear = page.getByRole("option", { name: "Use default model" })
await expect(combobox).toHaveAttribute("aria-activedescendant", await clear.getAttribute("id"))
await combobox.press("Enter")
await expect(page.getByTestId("model-selector-value")).toHaveText("default")
await expect(page.getByRole("button", { name: "Review model: Use default model" })).toBeFocused()
})
test("settings and mode editing expose distinct model field purposes", async ({ page }) => {
await load(page, "settings--models-accessible-labels")
await expect(page.getByRole("button", { name: "Default Model: Not set" })).toHaveAccessibleDescription(
"Primary model for conversations",
)
await expect(page.getByRole("button", { name: "Small Model: Not set" })).toHaveAccessibleDescription(
/Lightweight model/,
)
await expect(page.getByRole("button", { name: "Subagent Model: Not set" })).toHaveAccessibleDescription(
/Default model and reasoning effort/,
)
await expect(page.getByRole("button", { name: "Autocomplete model: Not set" })).toHaveAccessibleDescription(
"Select the model used for inline code completions",
)
await expect(page.getByRole("button", { name: "Model per Mode: code: Not set" })).toHaveAccessibleDescription(
/Override the default model for specific modes/,
)
await load(page, "settings--mode-edit-export")
await expect(page.getByRole("button", { name: /Model Override:/ })).toHaveAccessibleDescription(
"Override the default model for this agent",
)
})
test("chat picker Escape returns focus to the prompt", async ({ page }) => {
await load(page, "prompt-input--default-420")
await page.getByRole("button", { name: /^Select model:/ }).click()
const combobox = page.getByRole("combobox", { name: /^Select model:.*Search models$/ })
await expect(combobox).toBeFocused()
await combobox.press("Escape")
await expect(page.locator("textarea.prompt-input")).toBeFocused()
})
@@ -180,6 +180,8 @@ const ModeEditView: Component<Props> = (props) => {
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={language.t("settings.agentBehaviour.modelOverride.title")}
description={language.t("settings.agentBehaviour.modelOverride.description")}
/>
</SettingsRow>
@@ -101,6 +101,8 @@ const ModelsTab: Component = () => {
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={language.t("settings.providers.defaultModel.title")}
description={language.t("settings.providers.defaultModel.description")}
/>
</SettingsRow>
<SettingsRow
@@ -114,6 +116,8 @@ const ModelsTab: Component = () => {
allowClear
clearLabel={language.t("settings.providers.notSet")}
includeAutoSmall
label={language.t("settings.providers.smallModel.title")}
description={language.t("settings.providers.smallModel.description")}
/>
</SettingsRow>
<SettingsRow
@@ -127,6 +131,8 @@ const ModelsTab: Component = () => {
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={language.t("settings.providers.subagentModel.title")}
description={language.t("settings.providers.subagentModel.description")}
/>
<ThinkingSelectorBase
variants={subagentVariants()}
@@ -149,6 +155,8 @@ const ModelsTab: Component = () => {
favorites={false}
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={language.t("settings.autocomplete.model.title")}
description={language.t("settings.autocomplete.model.description")}
/>
</SettingsRow>
</Card>
@@ -167,6 +175,8 @@ const ModelsTab: Component = () => {
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={`${language.t("settings.providers.modeModels")}: ${agent.name}`}
description={language.t("settings.providers.modeModels.description")}
/>
</SettingsRow>
)}
@@ -7,7 +7,18 @@
* ModelSelector — thin wrapper wired to session context for chat usage.
*/
import { createSignal, createMemo, createEffect, onCleanup, For, Show, createSelector, useContext } from "solid-js"
import {
createSignal,
createMemo,
createEffect,
createUniqueId,
onCleanup,
For,
Show,
createSelector,
useContext,
untrack,
} from "solid-js"
import type { Accessor, Component } from "solid-js"
import { PopupSelector } from "./PopupSelector"
import { Button } from "@kilocode/kilo-ui/button"
@@ -98,6 +109,10 @@ export interface ModelSelectorBaseProps {
deferDismiss?: boolean
/** Render inline instead of through a portal when nested in a dialog. */
portal?: boolean
/** Accessible purpose of this model setting or selector. */
label?: string
/** Additional accessible context for this model setting. */
description?: string
}
export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
@@ -106,6 +121,12 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// Session context is optional — ModelSelectorBase is also used in Settings
// where SessionProvider may not be mounted.
const session = useContext(SessionContext)
const uid = createUniqueId()
const listID = `${uid}-models`
const previewID = `${uid}-preview`
const descriptionID = `${uid}-description`
const optionID = (key: string) => `${uid}-option-${encodeURIComponent(key)}`
const groupID = (key: string) => `${uid}-group-${encodeURIComponent(key)}`
const activeModel = () => {
const items = props.models
if (items) return items.find((m) => m.providerID === props.value?.providerID && m.id === props.value?.modelID)
@@ -312,9 +333,16 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const activeKey = (m?: EnrichedModel | null) => {
if (!m) return props.allowClear ? CLEAR_KEY : defaultKey()
const key = modelKey(m.providerID, m.id)
if (!debouncedSearch() && favoriteKeys().has(key)) return favoriteKey(m)
const favorite = favoriteKey(m)
if (!debouncedSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
return canonicalKey(m)
}
const chosen = (row: ModelRow) => {
if (row.kind === "clear") return !props.value?.providerID
if (!row.model || !isActive(row.model)) return false
return activeKey(row.model) === row.key
}
const activeOptionID = () => (rowMap().has(selectedKey()) ? optionID(selectedKey()) : undefined)
const [anchor, setAnchor] = createSignal<ScrollAnchor | null>(null)
const previewModel = createMemo(() => rowMap().get(previewKey() ?? "")?.model ?? null)
@@ -329,7 +357,11 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// which resets selection.
createEffect(() => {
rows() // track
setSelectedKey((prev) => (rowMap().has(prev) ? prev : defaultKey()))
setSelectedKey((prev) => {
if (rowMap().has(prev)) return prev
const next = untrack(() => activeKey(activeModel()))
return rowMap().has(next) ? next : defaultKey()
})
setPreActiveKey((prev) => (prev && rowMap().has(prev) ? prev : null))
setPreviewKey((prev) => (prev && rowMap().has(prev) ? prev : null))
})
@@ -395,7 +427,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// Listen for slash command trigger
const onTrigger = () => setOpen(true)
window.addEventListener("openModelPicker", onTrigger)
onCleanup(() => window.removeEventListener("openModelPicker", onTrigger))
onCleanup(() => {
window.removeEventListener("openModelPicker", onTrigger)
clearTimeout(previewTimer)
})
const onEscape = (e: KeyboardEvent) => {
if (!open() || e.key !== "Escape") return
@@ -545,247 +580,301 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
notSet: language.t("dialog.model.notSet"),
},
)
const label = () => props.label ?? language.t("dialog.model.select.title")
const controlLabel = () => `${label()}: ${triggerLabel()}`
const searchLabel = () => `${controlLabel()}. ${language.t("dialog.model.search.placeholder")}`
const describedBy = () => (props.description ? descriptionID : undefined)
return (
<PopupSelector
expanded={expanded()}
preferredWidth={350}
preferredExpandedWidth={450}
preferredHeight={300}
preferredExpandedHeight={800}
minHeight={200}
placement={props.placement ?? "top-start"}
deferDismiss={props.deferDismiss}
portal={props.portal}
open={open()}
onOpenChange={setOpen}
triggerAs={Button}
triggerProps={{
variant: "secondary",
size: "normal",
disabled: !canOpen(),
title: activeModel()?.id,
}}
trigger={
<>
<span class="model-selector-trigger-label">{triggerLabel()}</span>
<svg class="model-selector-trigger-chevron" width="10" height="10" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 5H4l4-5z" />
</svg>
</>
}
class={`model-selector-popover${expanded() ? " model-selector-popover--expanded" : ""}`}
>
{(bodyH) => {
createEffect(() => {
if (!expanded()) return
const h = bodyH()
if (h === undefined) return
const chrome = (searchWrapperRef?.offsetHeight ?? 0) + (splitterRef?.offsetHeight ?? 0)
setPreviewHeight((h - chrome) / 2)
})
return (
<div
onKeyDown={handleKeyDown}
class={`model-selector-body${expanded() ? " model-selector-body--expanded" : ""}`}
style={{ height: `${bodyH()}px` }}
ref={bodyRef}
>
<div class="model-selector-search-wrapper" ref={searchWrapperRef}>
<input
ref={searchRef}
class="model-selector-search"
type="text"
placeholder={language.t("dialog.model.search.placeholder")}
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
/>
<Tooltip
value={expanded() ? language.t("dialog.model.collapse") : language.t("dialog.model.expand")}
placement="top"
>
<IconButton
icon={expanded() ? "collapse" : "expand"}
size="small"
variant="ghost"
onClick={() => {
setExpanded((v) => {
if (v) {
setPreActiveKey(null)
setPreviewKey(null)
}
return !v
})
requestAnimationFrame(() => {
searchRef?.focus()
scrollRow(preActiveKey() ?? selectedKey(), "nearest")
})
}}
<>
<Show when={props.description}>
<span id={descriptionID} class="model-selector-assistive">
{props.description}
</span>
</Show>
<PopupSelector
expanded={expanded()}
preferredWidth={350}
preferredExpandedWidth={450}
preferredHeight={300}
preferredExpandedHeight={800}
minHeight={200}
placement={props.placement ?? "top-start"}
deferDismiss={props.deferDismiss}
portal={props.portal}
open={open()}
onOpenChange={setOpen}
triggerAs={Button}
triggerProps={{
variant: "secondary",
size: "normal",
get disabled() {
return !canOpen()
},
get title() {
return activeModel()?.id
},
get ["aria-label"]() {
return controlLabel()
},
get ["aria-describedby"]() {
return describedBy()
},
}}
trigger={
<>
<span class="model-selector-trigger-label">{triggerLabel()}</span>
<svg class="model-selector-trigger-chevron" width="10" height="10" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 5H4l4-5z" />
</svg>
</>
}
class={`model-selector-popover${expanded() ? " model-selector-popover--expanded" : ""}`}
>
{(bodyH) => {
createEffect(() => {
if (!expanded()) return
const h = bodyH()
if (h === undefined) return
const chrome = (searchWrapperRef?.offsetHeight ?? 0) + (splitterRef?.offsetHeight ?? 0)
setPreviewHeight((h - chrome) / 2)
})
return (
<div
onKeyDown={handleKeyDown}
class={`model-selector-body${expanded() ? " model-selector-body--expanded" : ""}`}
style={{ height: `${bodyH()}px` }}
ref={bodyRef}
>
<div class="model-selector-search-wrapper" ref={searchWrapperRef}>
<input
ref={searchRef}
data-autofocus
class="model-selector-search"
type="text"
role="combobox"
aria-label={searchLabel()}
aria-describedby={describedBy()}
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={open()}
aria-controls={listID}
aria-activedescendant={activeOptionID()}
placeholder={language.t("dialog.model.search.placeholder")}
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
/>
</Tooltip>
</div>
<div class="model-selector-list" role="listbox" ref={listRef}>
<Show when={rows().length === 0 && !props.allowClear}>
<div class="model-selector-empty">{language.t("dialog.model.empty")}</div>
</Show>
<Show when={props.allowClear}>
<div
class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="option"
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(CLEAR_KEY)
}}
<Tooltip
value={expanded() ? language.t("dialog.model.collapse") : language.t("dialog.model.expand")}
placement="top"
>
<span class="model-selector-item-name" style={{ "font-style": "italic", opacity: 0.7 }}>
{props.clearLabel ?? language.t("dialog.model.notSet")}
</span>
</div>
</Show>
<IconButton
icon={expanded() ? "collapse" : "expand"}
size="small"
variant="ghost"
aria-label={expanded() ? language.t("dialog.model.collapse") : language.t("dialog.model.expand")}
aria-expanded={expanded()}
aria-controls={previewID}
onClick={() => {
setExpanded((v) => {
if (v) {
setPreActiveKey(null)
setPreviewKey(null)
}
return !v
})
requestAnimationFrame(() => {
searchRef?.focus()
scrollRow(preActiveKey() ?? selectedKey(), "nearest")
})
}}
/>
</Tooltip>
</div>
<For each={groups()}>
{(group) => {
const shown = () => isGroupOpen(group.key)
return (
<>
<button
type="button"
class="model-selector-group-label"
aria-expanded={shown()}
aria-label={language.t(shown() ? "model.group.collapse" : "model.group.expand", {
group: group.label,
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.key)}
>
<svg
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
<div id={listID} class="model-selector-list" role="listbox" aria-label={label()} ref={listRef}>
<Show when={groups().length === 0}>
<div class="model-selector-empty" role="status" aria-live="polite">
{language.t("dialog.model.empty")}
</div>
</Show>
<Show when={props.allowClear}>
<div
id={optionID(CLEAR_KEY)}
class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="option"
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(CLEAR_KEY)
}}
>
<span class="model-selector-item-name" style={{ "font-style": "italic", opacity: 0.7 }}>
{props.clearLabel ?? language.t("dialog.model.notSet")}
</span>
</div>
</Show>
<For each={groups()}>
{(group) => {
const shown = () => isGroupOpen(group.key)
return (
<div class="model-selector-group" role="group" aria-labelledby={groupID(group.key)}>
<button
id={groupID(group.key)}
type="button"
class="model-selector-group-label"
aria-expanded={shown()}
aria-label={language.t(shown() ? "model.group.collapse" : "model.group.expand", {
group: group.label,
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.key)}
>
<path d="M4 6l4 5 4-5H4z" />
</svg>
<span>{group.label}</span>
<Show when={!shown() && !!debouncedSearch()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</button>
<Show when={shown()}>
<For each={group.rows}>
{(row) => {
if (!row.model) return null
const model = row.model
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const showSelectBtn = () => expanded() && preActive() && !isActive(model)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
return (
<div
ref={(el) => {
refs.set(row.key, el)
onCleanup(() => refs.delete(row.key))
}}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) && row.kind === "model" ? " active" : ""}`}
role="option"
aria-selected={isActive(model) && row.kind === "model"}
onClick={() => {
setRow(row.key)
setPreviewKey(row.key)
if (!expanded()) selectRow(row)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
}}
>
<div class="model-selector-item-left">
<span class="model-selector-item-name">
{(() => {
const full = sanitizeName(model.name)
const sep = full.indexOf(": ")
if (sep < 0) return <span class="model-selector-item-name-main">{full}</span>
return (
<>
<span class="model-selector-item-name-provider">{full.slice(0, sep)}</span>
<span class="model-selector-item-name-main">{full.slice(sep + 2)}</span>
</>
)
})()}
</span>
<Show when={isFree(model)}>
<Tag data-variant="member">{language.t("model.tag.free")}</Tag>
<svg
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 6l4 5 4-5H4z" />
</svg>
<span>{group.label}</span>
<Show when={!shown() && !!debouncedSearch()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</button>
<Show when={shown()}>
<For each={group.rows}>
{(row) => {
if (!row.model) return null
const model = row.model
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
const showSelect = () => expanded() && preActive() && !isActive(model)
const starLabel = () =>
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
return (
<div
role="presentation"
class={`model-selector-row${hovered() || preActive() ? " selected" : ""}`}
>
<div
id={optionID(row.key)}
ref={(el) => {
refs.set(row.key, el)
onCleanup(() => refs.delete(row.key))
}}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
role="option"
aria-selected={chosen(row)}
onClick={() => {
if (!expanded()) {
selectRow(row)
return
}
setRow(row.key)
setPreviewKey(row.key)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
schedulePreview(row.key)
}}
>
<div class="model-selector-item-left">
<span class="model-selector-item-name">
{(() => {
const full = sanitizeName(model.name)
const sep = full.indexOf(": ")
if (sep < 0) return <span class="model-selector-item-name-main">{full}</span>
return (
<>
<span class="model-selector-item-name-provider">
{full.slice(0, sep)}
</span>
<span class="model-selector-item-name-main">{full.slice(sep + 2)}</span>
</>
)
})()}
</span>
<Show when={isFree(model)}>
<Tag data-variant="member">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
</div>
</div>
<Show when={session && props.favorites !== false}>
<button
type="button"
class={`model-selector-star${starred() ? " model-selector-star--active" : ""}`}
aria-label={starLabel()}
aria-pressed={starred()}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model, row)
searchRef?.focus()
}}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
<Show when={showSelect()}>
<button
type="button"
class="model-selector-item-select-btn"
aria-label={`${language.t("dialog.model.select")}: ${sanitizeName(model.name)}`}
onClick={() => selectRow(row)}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
<Show when={session && props.favorites !== false}>
<button
type="button"
class={`model-selector-star${starred() ? " model-selector-star--active" : ""}`}
aria-label={
starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")
}
aria-pressed={starred()}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model, row)
}}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
</Show>
<Show when={expanded()}>
<button
class={`model-selector-item-select-btn${showSelectBtn() ? "" : " model-selector-item-select-btn--hidden"}`}
onClick={(e) => {
e.stopPropagation()
selectRow(row)
}}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
)
}}
</For>
</Show>
</>
)
}}
</For>
</div>
)
}}
</For>
</Show>
</div>
)
}}
</For>
</div>
<Show when={expanded()}>
<div class="model-selector-splitter" ref={splitterRef} onMouseDown={onSplitterMouseDown} />
</Show>
<div
class={`model-selector-preview${expanded() ? " model-selector-preview--visible" : ""}`}
style={expanded() ? { height: `${previewHeight()}px` } : {}}
>
<ModelPreview model={previewModel() ?? activeModel() ?? null} />
<Show when={expanded()}>
<div class="model-selector-splitter" ref={splitterRef} onMouseDown={onSplitterMouseDown} />
</Show>
<div
id={previewID}
aria-hidden={!expanded()}
class={`model-selector-preview${expanded() ? " model-selector-preview--visible" : ""}`}
style={expanded() ? { height: `${previewHeight()}px` } : {}}
>
<Show when={expanded()}>
<ModelPreview model={previewModel() ?? activeModel() ?? null} />
</Show>
</div>
</div>
</div>
)
}}
</PopupSelector>
)
}}
</PopupSelector>
</>
)
}
@@ -71,6 +71,17 @@ export const ModelsAutocompleteOpen: Story = {
),
}
export const ModelsAccessibleLabels: Story = {
name: "ModelsTab — accessible model labels",
render: () => (
<StoryProviders config={{} as any}>
<div style={{ "max-height": "700px", overflow: "auto" }}>
<ModelsTab />
</div>
</StoryProviders>
),
}
function OpenModelPicker(props: { children: any }) {
let ref: HTMLDivElement | undefined
onMount(() => {
@@ -3,9 +3,13 @@
* Stories for shared controls: ModelSelector.
*/
import { createSignal } from "solid-js"
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { StoryProviders } from "./StoryProviders"
import { StoryProviders, mockSessionValue } from "./StoryProviders"
import { ModelSelectorBase } from "../components/shared/ModelSelector"
import { SessionContext } from "../context/session"
import type { EnrichedModel } from "../context/provider"
import type { ModelSelection } from "../types/messages"
const meta: Meta = {
title: "Shared",
@@ -32,3 +36,58 @@ export const ModelSelectorNoProviders: Story = {
</StoryProviders>
),
}
const ACCESSIBLE_MODELS: EnrichedModel[] = [
{ id: "alpha", name: "Alpha", providerID: "kilo", providerName: "Kilo" },
{ id: "bravo", name: "Bravo", providerID: "kilo", providerName: "Kilo" },
{ id: "charlie", name: "Charlie", providerID: "kilo", providerName: "Kilo" },
]
const AccessibleModelSelector = () => {
const [value, setValue] = createSignal<ModelSelection | null>({ providerID: "kilo", modelID: "alpha" })
return (
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<ModelSelectorBase
value={value()}
models={ACCESSIBLE_MODELS}
label="Review model"
description="Choose the model used for code review tasks."
allowClear
clearLabel="Use default model"
placement="bottom-start"
onSelect={(providerID, modelID) => {
setValue(providerID && modelID ? { providerID, modelID } : null)
}}
/>
<output data-testid="model-selector-value">{value()?.modelID ?? "default"}</output>
</div>
)
}
export const ModelSelectorAccessible: Story = {
name: "ModelSelector — accessible interaction",
render: () => (
<StoryProviders>
<AccessibleModelSelector />
</StoryProviders>
),
}
export const ModelSelectorSelectedFavorite: Story = {
name: "ModelSelector — selected favorite",
render: () => {
const session = {
...mockSessionValue(),
favoriteModels: () => [{ providerID: "kilo", modelID: "alpha" }],
}
return (
<StoryProviders>
<SessionContext.Provider value={session as any}>
<AccessibleModelSelector />
</SessionContext.Provider>
</StoryProviders>
)
},
}
@@ -207,6 +207,18 @@
flex-shrink: 0;
}
.model-selector-assistive {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.model-selector-search-wrapper {
display: flex;
align-items: center;
@@ -280,7 +292,7 @@
outline-offset: -1px;
}
.model-selector-group-label:not(:first-child) {
.model-selector-group:not(:first-child) > .model-selector-group-label {
border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border));
margin-top: 4px;
padding-top: 8px;
@@ -305,10 +317,17 @@
margin-left: 2px;
}
.model-selector-row {
display: flex;
align-items: center;
}
.model-selector-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
padding: 5px 12px;
font-size: var(--kilo-font-size-12);
cursor: pointer;
@@ -399,12 +418,16 @@
color 0.1s ease;
}
.model-selector-row > .model-selector-star {
margin-right: 12px;
}
.model-selector-star [data-component="icon"] {
color: inherit;
}
.model-selector-item:hover .model-selector-star,
.model-selector-item.selected .model-selector-star,
.model-selector-row:hover .model-selector-star,
.model-selector-row.selected .model-selector-star,
.model-selector-star--active {
opacity: 1;
}
@@ -424,6 +447,7 @@
.model-selector-item-select-btn {
flex-shrink: 0;
margin-right: 12px;
padding: 2px 10px;
font-size: var(--kilo-font-size-11);
font-weight: 600;
@@ -442,11 +466,6 @@
opacity: 1;
}
.model-selector-item-select-btn--hidden {
opacity: 0;
pointer-events: none;
}
/* ============================================
Thinking Selector (uses kilo-ui Popover)
============================================ */