mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
perf(vscode): open model, mode, and variant selectors instantly
Virtualize the model catalog so opening the picker mounts only the visible window instead of the entire model list, and remove artificial open latency from the mode and variant pickers. The model selector built every catalog row up front (~6k DOM elements, ~11k transient nodes for a ~630-model catalog), blocking the main thread on element creation, style recalculation, and layout before first paint. The active-model lookup also re-scanned the list on every reactive read. The mode and variant pickers were cheap to build but felt slow because of the shared popover enter animation and a deferred focus frame. Render the catalog through a virtualizer, memoize the active-model lookup, anchor scroll restoration to virtual item offsets so favorite toggles no longer jump when the anchored row is virtualized out, disable the popover open animation for these selectors, and focus the selected option during initial mount instead of a later frame.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open the model selector instantly, even with large model catalogs.
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -44,9 +44,15 @@ export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (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<ModeSwitcherBaseProps> = (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<ModeSwitcherBaseProps> = (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())}
|
||||
>
|
||||
|
||||
@@ -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<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (props) => {
|
||||
let listRef: HTMLDivElement | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let previewTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
|
||||
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<string, HTMLElement>()
|
||||
|
||||
function onSplitterMouseDown(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
@@ -382,6 +382,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.allowClear}>
|
||||
<div
|
||||
id={optionID(CLEAR_KEY)}
|
||||
ref={(el) => {
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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="presentation">
|
||||
<div
|
||||
id={optionID(groupKey(group.key))}
|
||||
ref={(el) => {
|
||||
refs.set(groupKey(group.key), el)
|
||||
onCleanup(() => refs.delete(groupKey(group.key)))
|
||||
}}
|
||||
class={`model-selector-group-label${isSelected(groupKey(group.key)) ? " selected" : ""}${isSelected(groupKey(group.key)) && !pointer() ? " keyboard-focused" : ""}`}
|
||||
role="treeitem"
|
||||
aria-expanded={shown()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
onMouseMove={() => setPointer(true)}
|
||||
onMouseEnter={() => {
|
||||
if (pointer()) setSelectedKey(groupKey(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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<Show when={shown()}>
|
||||
<div role="group" aria-label={group.label}>
|
||||
<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="treeitem"
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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={isAuto(model)}>
|
||||
<Tooltip value={autoLabel(model)} placement="top">
|
||||
<span class="model-selector-auto-icon" aria-label={autoLabel(model)}>
|
||||
<Icon name="branch" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isFree(model) || hasByok(model) || isDataCollectedModel(model)}>
|
||||
<span class="model-selector-free-data">
|
||||
<Show when={isFree(model) && !hasByok(model)}>
|
||||
<span class="model-selector-data-badge">
|
||||
<Tag data-variant="member">{freeLabel()}</Tag>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={hasByok(model)}>
|
||||
<span class="model-selector-data-badge model-selector-data-badge--byok">
|
||||
<Tag data-variant="member">BYOK</Tag>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDataCollectedModel(model)}>
|
||||
<Tooltip value={dataLabel()} placement="top">
|
||||
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
|
||||
<Icon name="book-open-check" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</span>
|
||||
</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={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={nodes().length > 0}>
|
||||
<Virtualizer ref={setVirtualizer} data={nodes()} keepMounted={mounted()} overscan={4} itemSize={30}>
|
||||
{
|
||||
// eslint-disable-next-line complexity
|
||||
(node) => {
|
||||
if (node.kind === "group" && node.group) {
|
||||
const group = node.group
|
||||
const key = groupKey(group.key)
|
||||
const shown = () => isGroupOpen(group.key)
|
||||
return (
|
||||
<div
|
||||
id={optionID(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)
|
||||
}}
|
||||
</For>
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const row = node.row
|
||||
if (!row) return null
|
||||
if (row.kind === "clear") {
|
||||
return (
|
||||
<div
|
||||
id={optionID(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")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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)}
|
||||
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
|
||||
role="treeitem"
|
||||
aria-level={2}
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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={isAuto(model)}>
|
||||
<Tooltip value={autoLabel(model)} placement="top">
|
||||
<span class="model-selector-auto-icon" aria-label={autoLabel(model)}>
|
||||
<Icon name="branch" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={isFree(model) || hasByok(model) || isDataCollectedModel(model)}>
|
||||
<span class="model-selector-free-data">
|
||||
<Show when={isFree(model) && !hasByok(model)}>
|
||||
<span class="model-selector-data-badge">
|
||||
<Tag data-variant="member">{freeLabel()}</Tag>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={hasByok(model)}>
|
||||
<span class="model-selector-data-badge model-selector-data-badge--byok">
|
||||
<Tag data-variant="member">BYOK</Tag>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDataCollectedModel(model)}>
|
||||
<Tooltip value={dataLabel()} placement="top">
|
||||
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
|
||||
<Icon name="book-open-check" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</span>
|
||||
</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={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>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
}
|
||||
</Virtualizer>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>
|
||||
|
||||
@@ -57,6 +57,7 @@ export function PopupSelector<T extends ValidComponent = ValidComponent>(props:
|
||||
"minWidth",
|
||||
"minHeight",
|
||||
"deferDismiss",
|
||||
"class",
|
||||
"children",
|
||||
])
|
||||
|
||||
@@ -113,6 +114,7 @@ export function PopupSelector<T extends ValidComponent = ValidComponent>(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` }
|
||||
|
||||
@@ -64,13 +64,14 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (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<ThinkingSelectorBaseProps> = (props
|
||||
role="option"
|
||||
aria-selected={props.value === v}
|
||||
tabindex={focused() === i() ? 0 : -1}
|
||||
data-autofocus={focused() === i() ? "" : undefined}
|
||||
onClick={() => pick(v)}
|
||||
onFocus={() => setFocused(i())}
|
||||
>
|
||||
|
||||
@@ -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: () => (
|
||||
<StoryProviders>
|
||||
<ModelSelectorBase
|
||||
value={{ providerID: "provider-0", modelID: "model-300" }}
|
||||
models={LARGE_MODELS}
|
||||
placement="bottom-start"
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user