mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
fix(vscode): optimize model selector search and auto-jump to active match (#12810)
* fix(vscode): optimize model selector search and auto-jump to active match Remove the 250 ms search debounce so the model list updates on every keystroke. Add a latest-search-only animation-frame scroll that keeps the active match visible when filtering from a deeply scrolled list, and track pointer coordinates so rows moving beneath a stationary pointer no longer steal selection while real hover still transfers immediately. * fix(vscode): stabilize model selector search navigation * chore: update kilo-vscode visual regression baselines * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update model search results instantly and keep the active match visible while typing.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
|
||||
size 52887
|
||||
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
|
||||
size 52895
|
||||
|
||||
@@ -193,6 +193,18 @@ test("large catalogs keep the rendered tree bounded and navigate to distant mode
|
||||
// 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)
|
||||
await expect(page.getByRole("treeitem", { name: "Model 300" })).toBeVisible()
|
||||
|
||||
// Searching from deep in the catalog scrolls the first active match into view.
|
||||
await tree.getByRole("treeitem").last().hover()
|
||||
await tree.evaluate((el) => el.scrollTo({ top: el.scrollHeight }))
|
||||
await combobox.pressSequentially("Model 5")
|
||||
const first = page.getByRole("treeitem", { name: "Model 500" })
|
||||
await expect(first).toBeVisible()
|
||||
await expect(combobox).toHaveAttribute("aria-activedescendant", await first.getAttribute("id"))
|
||||
const hovered = page.getByRole("treeitem", { name: "Model 501" })
|
||||
await hovered.hover()
|
||||
await expect(combobox).toHaveAttribute("aria-activedescendant", await hovered.getAttribute("id"))
|
||||
|
||||
// Reaching a distant model scrolls it into the mounted window and activates it.
|
||||
await combobox.fill("Model 599")
|
||||
|
||||
@@ -159,7 +159,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
const expanded = vscode.getModelSelectorExpanded
|
||||
const setExpanded = vscode.setModelSelectorExpanded
|
||||
const [search, setSearch] = createSignal("")
|
||||
const [debouncedSearch, setDebouncedSearch] = createSignal("")
|
||||
const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY)
|
||||
const [browsing, setBrowsing] = createSignal(false)
|
||||
const [navigating, setNavigating] = createSignal(false)
|
||||
@@ -181,6 +180,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
let listRef: HTMLDivElement | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let previewTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let pointerX: number | undefined
|
||||
let pointerY: number | undefined
|
||||
let previousSearch: string | undefined
|
||||
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
|
||||
const [pointer, setPointer] = createSignal(true)
|
||||
|
||||
@@ -223,16 +226,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
const hasProviders = () => visibleModels().length > 0
|
||||
const canOpen = () => hasProviders() || ((props.allowClear ?? false) && !!props.value)
|
||||
|
||||
// Debounce search input to avoid re-filtering on every keystroke
|
||||
createEffect(() => {
|
||||
const q = search()
|
||||
const t = setTimeout(() => setDebouncedSearch(q), 250)
|
||||
onCleanup(() => clearTimeout(t))
|
||||
})
|
||||
|
||||
// Flat filtered list for keyboard navigation
|
||||
const filtered = createMemo(() => {
|
||||
const q = debouncedSearch().trim()
|
||||
const q = search().trim()
|
||||
if (!q) {
|
||||
return visibleModels()
|
||||
}
|
||||
@@ -251,7 +247,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
|
||||
const favoriteModels = createMemo(() => {
|
||||
if (props.favorites === false) return []
|
||||
if (!session || debouncedSearch()) return []
|
||||
if (!session || search()) return []
|
||||
const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m]))
|
||||
const list = session
|
||||
.favoriteModels()
|
||||
@@ -400,7 +396,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
if (!m) return props.allowClear ? CLEAR_KEY : defaultKey()
|
||||
const key = modelKey(m.providerID, m.id)
|
||||
const favorite = favoriteKey(m)
|
||||
if (!debouncedSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
|
||||
if (!search() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
|
||||
return canonicalKey(m)
|
||||
}
|
||||
const chosen = (row: ModelRow) => {
|
||||
@@ -453,7 +449,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
// which would cause star/unstar to reset selection mid-interaction.
|
||||
// Falls back to defaultKey when the active model is filtered out.
|
||||
createEffect(() => {
|
||||
const query = search()
|
||||
const list = filtered()
|
||||
const searchChanged = query !== previousSearch
|
||||
previousSearch = query
|
||||
untrack(() => {
|
||||
const active = activeModel()
|
||||
const canon = active ? canonicalKey(active) : null
|
||||
@@ -468,10 +467,16 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
? CLEAR_KEY
|
||||
: defaultKey()
|
||||
setSelectedKey(next)
|
||||
setBrowsing(!!debouncedSearch() && nodeMap().has(next))
|
||||
setBrowsing(!!search() && nodeMap().has(next))
|
||||
setNavigating(false)
|
||||
setPreActiveKey(next)
|
||||
setPreviewKey(next)
|
||||
if (!open() || !searchChanged) return
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
scrollRow(next, "nearest")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -500,8 +505,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
setBrowsing(false)
|
||||
setNavigating(false)
|
||||
setSearch("")
|
||||
setDebouncedSearch("")
|
||||
clearTimeout(previewTimer)
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
scrollFrame = undefined
|
||||
})
|
||||
|
||||
// Register before the popover mounts so programmatic slash-command opens
|
||||
@@ -519,6 +525,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
window.removeEventListener("openModelPicker", onTrigger)
|
||||
window.removeEventListener("keydown", onEscape, true)
|
||||
clearTimeout(previewTimer)
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
})
|
||||
|
||||
function pick(model: EnrichedModel) {
|
||||
@@ -552,6 +559,20 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
previewTimer = setTimeout(() => setPreviewKey(key), 200)
|
||||
}
|
||||
|
||||
function pointerMove(e: MouseEvent) {
|
||||
const target = e.target
|
||||
if (!(target instanceof Element)) return
|
||||
const item = target.closest<HTMLElement>('[role="treeitem"][data-key]')
|
||||
const key = item?.dataset.key
|
||||
if (!key) return
|
||||
const moved = pointerX !== undefined && pointerY !== undefined && (e.clientX !== pointerX || e.clientY !== pointerY)
|
||||
pointerX = e.clientX
|
||||
pointerY = e.clientY
|
||||
if (!moved) return
|
||||
setPointer(true)
|
||||
setSelectedKey(key)
|
||||
}
|
||||
|
||||
function scrollRow(key: string | null | undefined, block: ScrollLogicalPosition = "nearest") {
|
||||
if (!key) return
|
||||
const idx = nodeIndex().get(key)
|
||||
@@ -808,6 +829,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
value={search()}
|
||||
onInput={(e) => {
|
||||
setPointer(false)
|
||||
setBrowsing(false)
|
||||
setNavigating(false)
|
||||
setSearch(e.currentTarget.value)
|
||||
@@ -846,7 +868,14 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div id={listID} class="model-selector-list" role="tree" aria-label={label()} ref={listRef}>
|
||||
<div
|
||||
id={listID}
|
||||
class="model-selector-list"
|
||||
role="tree"
|
||||
aria-label={label()}
|
||||
ref={listRef}
|
||||
onMouseMove={pointerMove}
|
||||
>
|
||||
<Show when={groups().length === 0}>
|
||||
<div class="model-selector-empty" role="status" aria-live="polite">
|
||||
{language.t("dialog.model.empty")}
|
||||
@@ -871,16 +900,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
id={optionID(key)}
|
||||
data-key={key}
|
||||
class={`model-selector-group-label${props.allowClear || group.key !== groups()[0]?.key ? " model-selector-group-label--divided" : ""}${isSelected(key) ? " selected" : ""}${isSelected(key) && !pointer() ? " keyboard-focused" : ""}`}
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
aria-expanded={shown()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
onMouseMove={() => setPointer(true)}
|
||||
onMouseEnter={() => {
|
||||
if (pointer()) setSelectedKey(key)
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
|
||||
@@ -893,7 +919,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
<path d="M4 6l4 5 4-5H4z" />
|
||||
</svg>
|
||||
<span>{group.label}</span>
|
||||
<Show when={!shown() && !!debouncedSearch()}>
|
||||
<Show when={!shown() && !!search()}>
|
||||
<span class="model-selector-group-match-dot" aria-hidden="true" />
|
||||
</Show>
|
||||
</div>
|
||||
@@ -906,15 +932,12 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
id={optionID(CLEAR_KEY)}
|
||||
data-key={CLEAR_KEY}
|
||||
class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
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")}
|
||||
@@ -939,6 +962,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
>
|
||||
<div
|
||||
id={optionID(row.key)}
|
||||
data-key={row.key}
|
||||
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
|
||||
role="treeitem"
|
||||
aria-level={2}
|
||||
@@ -955,10 +979,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user