mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4
|
||||
size 30244
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838
|
||||
size 1085
|
||||
+3
@@ -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)
|
||||
============================================ */
|
||||
|
||||
Reference in New Issue
Block a user