diff --git a/.changeset/open-model-selector-faster.md b/.changeset/open-model-selector-faster.md new file mode 100644 index 0000000000..789dabdc2a --- /dev/null +++ b/.changeset/open-model-selector-faster.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open the model selector instantly, even with large model catalogs. diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 527380a880..45a7a339fd 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -183,6 +183,24 @@ test("selected favorite remains selected when its duplicate group is collapsed", await expect(combobox).toHaveAttribute("aria-activedescendant", await favorites.getAttribute("id")) }) +test("large catalogs keep the rendered tree bounded and navigate to distant models", async ({ page }) => { + await load(page, "shared--model-selector-large-catalog") + + await page.getByRole("button", { name: "Select model: Provider 0 / Model 300" }).click() + const combobox = page.getByRole("combobox", { name: "Select model: Provider 0 / Model 300. Search models" }) + const tree = page.getByRole("tree", { name: "Select model" }) + + // The window mounts before we measure it, yet stays far smaller than the catalog. + await expect.poll(() => tree.getByRole("treeitem").count()).toBeGreaterThan(0) + await expect.poll(() => tree.getByRole("treeitem").count()).toBeLessThan(50) + + // Reaching a distant model scrolls it into the mounted window and activates it. + await combobox.fill("Model 599") + const last = page.getByRole("treeitem", { name: "Model 599" }) + await expect(last).toBeVisible() + await expect(combobox).toHaveAttribute("aria-activedescendant", await last.getAttribute("id")) +}) + test("Enter selects the active option and Escape restores selector focus", async ({ page }) => { await load(page, "shared--model-selector-accessible") @@ -252,6 +270,35 @@ test("settings and mode editing expose distinct model field purposes", async ({ ) }) +test("mode picker focuses the selected mode as it opens", async ({ page }) => { + await load(page, "prompt-input--default-420") + + await page.getByRole("button", { name: "Code", exact: true }).click() + await expect(page.locator(".mode-switcher-item.selected")).toBeFocused() +}) + +test("variant picker focuses the selected effort as it opens", async ({ page }) => { + await load(page, "prompt-input--with-thinking-420") + + await page.getByRole("button", { name: "Medium", exact: true }).click() + await expect(page.locator(".thinking-selector-item.selected")).toBeFocused() +}) + +test("slash mode picker Escape returns focus to the prompt", async ({ page }) => { + await load(page, "prompt-input--default-420") + + const prompt = page.locator("textarea.prompt-input") + await prompt.evaluate((el) => el.setAttribute("aria-disabled", "false")) + await prompt.fill("/agents") + await prompt.press("Enter") + + const selected = page.locator(".mode-switcher-item.selected") + await expect(selected).toBeFocused() + await selected.press("Escape") + + await expect(prompt).toBeFocused() +}) + test("chat picker Escape returns focus to the prompt", async ({ page }) => { await load(page, "prompt-input--default-420") diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx index 6e237dbdda..d078192110 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx @@ -44,9 +44,15 @@ export const ModeSwitcherBase: Component = (props) => { const [focused, setFocused] = createSignal(-1) const language = useLanguage() let listRef: HTMLDivElement | undefined + // True while the picker was opened by the slash command rather than a click, + // so dismissal returns focus to the prompt like the model/variant pickers. + let slash = false // Listen for slash command trigger - const onTrigger = () => setOpen(true) + const onTrigger = () => { + slash = true + openSelected() + } window.addEventListener("openModePicker", onTrigger) onCleanup(() => window.removeEventListener("openModePicker", onTrigger)) @@ -65,11 +71,23 @@ export const ModeSwitcherBase: Component = (props) => { items[clamped]?.focus() } + function openSelected() { + const idx = props.agents.findIndex((a) => a.name === props.value) + setFocused(idx >= 0 ? idx : 0) + setOpen(true) + } + function onOpen(val: boolean) { - setOpen(val) if (val) { - const idx = props.agents.findIndex((a) => a.name === props.value) - requestAnimationFrame(() => focusItem(idx >= 0 ? idx : 0)) + // A click on the trigger opens without the slash flag. + slash = false + openSelected() + return + } + setOpen(false) + if (slash) { + slash = false + requestAnimationFrame(() => window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } }))) } } @@ -135,6 +153,7 @@ export const ModeSwitcherBase: Component = (props) => { role="option" aria-selected={agent.name === props.value} tabindex={focused() === i() ? 0 : -1} + data-autofocus={focused() === i() ? "" : undefined} onClick={() => pick(agent.name)} onFocus={() => setFocused(i())} > diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 1e44e6115c..73e1b684bc 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -13,13 +13,13 @@ import { createEffect, createUniqueId, onCleanup, - For, Show, createSelector, useContext, untrack, } from "solid-js" import type { Accessor, Component } from "solid-js" +import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { PopupSelector } from "./PopupSelector" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -94,7 +94,9 @@ interface ModelNode { interface ScrollAnchor { key: string - top: number | undefined + // Virtual content offset of the anchored row before the list mutates. + // Works even when the row is scrolled out of the mounted window. + offset: number | undefined scroll: number } @@ -144,11 +146,11 @@ export const ModelSelectorBase: Component = (props) => { const previewID = `${uid}-preview` const descriptionID = `${uid}-description` const optionID = (key: string) => `${uid}-option-${encodeURIComponent(key)}` - const activeModel = () => { + const activeModel = createMemo(() => { const items = props.models if (items) return items.find((m) => m.providerID === props.value?.providerID && m.id === props.value?.modelID) return findModel(props.value) - } + }) const [open, setOpen] = createSignal(false) const [expanded, setExpanded] = createSignal(true) @@ -175,10 +177,8 @@ export const ModelSelectorBase: Component = (props) => { let listRef: HTMLDivElement | undefined let bodyRef: HTMLDivElement | undefined let previewTimer: ReturnType | undefined + const [virtualizer, setVirtualizer] = createSignal() const [pointer, setPointer] = createSignal(true) - // Ref map: row key → DOM element. Populated by each row's ref callback, - // avoids DOM queries for scroll anchoring and scrollIntoView. - const refs = new Map() function onSplitterMouseDown(e: MouseEvent) { e.preventDefault() @@ -382,6 +382,13 @@ export const ModelSelectorBase: Component = (props) => { const nodeMap = createMemo(() => new Map(nodes().map((node) => [node.key, node] as const))) const nodeIndex = createMemo(() => new Map(nodes().map((node, i) => [node.key, i] as const))) const rowMap = createMemo(() => new Map(rows().map((row) => [row.key, row] as const))) + const mounted = createMemo(() => { + const map = nodeIndex() + const indexes = [selectedKey(), preActiveKey(), previewKey()] + .map((key) => (key ? map.get(key) : undefined)) + .filter((idx): idx is number => idx !== undefined) + return [...new Set(indexes)] + }) const canonicalKey = (m: EnrichedModel) => rowKey("model", m.providerID, m.id) const favoriteKey = (m: EnrichedModel) => rowKey("favorite", m.providerID, m.id) const defaultKey = () => nodes()[0]?.key ?? CLEAR_KEY @@ -421,18 +428,17 @@ export const ModelSelectorBase: Component = (props) => { createEffect(() => { const saved = anchor() - rows() - if (!saved || !listRef) return + nodes() + if (!saved) return requestAnimationFrame(() => { - if (!listRef) { - setAnchor(null) - return - } - const el = refs.get(saved.key) - if (el && saved.top !== undefined) { - listRef.scrollTop += el.getBoundingClientRect().top - saved.top + const handle = virtualizer() + const idx = nodeIndex().get(saved.key) + if (handle && idx !== undefined && saved.offset !== undefined) { + // Keep the anchored row pinned to the same viewport position after + // rows above it appear or disappear, even while it is virtualized out. + handle.scrollTo(handle.getItemOffset(idx) - (saved.offset - saved.scroll)) } else { - listRef.scrollTop = saved.scroll + handle?.scrollTo(saved.scroll) } setAnchor(null) }) @@ -543,7 +549,11 @@ export const ModelSelectorBase: Component = (props) => { } function scrollRow(key: string | null | undefined, block: ScrollLogicalPosition = "nearest") { - if (key) refs.get(key)?.scrollIntoView({ block }) + if (!key) return + const idx = nodeIndex().get(key) + if (idx === undefined) return + const align = block === "center" || block === "start" || block === "end" ? block : "nearest" + virtualizer()?.scrollToIndex(idx, { align }) } function activate(key: string) { @@ -609,10 +619,10 @@ export const ModelSelectorBase: Component = (props) => { // disappear — anchor to the canonical provider row instead so the // scroll restore finds an element that still exists after rerender. const key = row.kind === "favorite" ? canon : row.key - const el = refs.get(key) + const idx = nodeIndex().get(key) setAnchor({ key, - top: el?.getBoundingClientRect().top, + offset: idx !== undefined ? virtualizer()?.getItemOffset(idx) : undefined, scroll: listRef?.scrollTop ?? 0, }) if (row.kind === "favorite") { @@ -837,198 +847,186 @@ export const ModelSelectorBase: Component = (props) => { - -
{ - refs.set(CLEAR_KEY, el) - onCleanup(() => refs.delete(CLEAR_KEY)) - }} - class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`} - role="treeitem" - aria-selected={!props.value?.providerID} - onClick={() => pickClear()} - onMouseMove={() => { - setPointer(true) - }} - onMouseEnter={() => { - if (pointer()) setSelectedKey(CLEAR_KEY) - }} - > - - {props.clearLabel ?? language.t("dialog.model.notSet")} - -
-
- - - {(group) => { - const shown = () => isGroupOpen(group.key) - return ( - diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/PopupSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/PopupSelector.tsx index 541c3f81db..349a853211 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/PopupSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/PopupSelector.tsx @@ -57,6 +57,7 @@ export function PopupSelector(props: "minWidth", "minHeight", "deferDismiss", + "class", "children", ]) @@ -113,6 +114,7 @@ export function PopupSelector(props: overflowPadding={local.padding ?? 8} deferDismiss={local.deferDismiss} {...(rest as PopoverProps)} + class={`popup-selector${local.class ? ` ${local.class}` : ""}`} style={ popoverW().width !== undefined ? { width: `${popoverW().width}px`, "max-width": `${popoverW().max}px` } diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index a6f05f0691..45f9b827ae 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -64,13 +64,14 @@ export const ThinkingSelectorBase: Component = (props } function onOpen(val: boolean) { - setOpen(val) if (val) { const items = rows() const idx = items.findIndex((v) => v === props.value) - requestAnimationFrame(() => focusItem(idx >= 0 ? idx : 0)) + setFocused(idx >= 0 ? idx : 0) + setOpen(true) return } + setOpen(false) refocus() } @@ -166,6 +167,7 @@ export const ThinkingSelectorBase: Component = (props role="option" aria-selected={props.value === v} tabindex={focused() === i() ? 0 : -1} + data-autofocus={focused() === i() ? "" : undefined} onClick={() => pick(v)} onFocus={() => setFocused(i())} > diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index ca1c3abe40..bf3a16f7dc 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -108,3 +108,28 @@ export const ModelSelectorSelectedFavorite: Story = { ) }, } + +const LARGE_MODELS: EnrichedModel[] = Array.from({ length: 600 }, (_, i) => { + const id = String(i).padStart(3, "0") + const provider = `provider-${i % 12}` + return { + id: `model-${id}`, + name: `Model ${id}`, + providerID: provider, + providerName: `Provider ${i % 12}`, + } +}) + +export const ModelSelectorLargeCatalog: Story = { + name: "ModelSelector — large catalog", + render: () => ( + + {}} + /> + + ), +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css index e16816425e..dcd5988b84 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/model-selector.css +++ b/packages/kilo-vscode/webview-ui/src/styles/model-selector.css @@ -2,6 +2,11 @@ Model Selector (uses kilo-ui Popover) ============================================ */ +.popup-selector[data-expanded], +.popup-selector[data-closed] { + animation: none; +} + .model-selector-popover { transition: width 0.2s ease, @@ -383,7 +388,7 @@ outline-offset: -1px; } -.model-selector-group:not(:first-child) > .model-selector-group-label { +.model-selector-group-label--divided { border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border)); margin-top: 4px; padding-top: 8px;