Merge pull request #7545 from Kilo-Org/kirillk/picker-adjustments

fix(vscode): UX improvements for pickers, settings, and icons
This commit is contained in:
Kirill Kalishev
2026-03-25 08:56:52 -04:00
committed by GitHub
6 changed files with 208 additions and 167 deletions
@@ -60,6 +60,7 @@ const ModelsTab: Component = () => {
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
includeAutoSmall
/>
</SettingsRow>
</Card>
@@ -227,29 +227,20 @@ const Settings: Component<SettingsProps> = (props) => {
</Tabs.Content>
</Tabs>
{/* Save bar — visible when there are unsaved config changes */}
<Show when={isDirty()}>
<div
style={{
display: "flex",
"align-items": "center",
"justify-content": "flex-end",
gap: "8px",
padding: "8px 16px",
"border-top": "1px solid var(--border-weak-base)",
}}
>
<span style={{ "font-size": "12px", color: "var(--foreground-secondary)", "margin-right": "auto" }}>
{language.t("settings.saveBar.unsavedChanges")}
</span>
<Button variant="ghost" size="small" onClick={discardConfig}>
{language.t("settings.saveBar.discard")}
</Button>
<Button variant="primary" size="small" onClick={handleSave}>
{language.t("settings.saveBar.save")}
</Button>
</div>
</Show>
{/* Save bar — slides in when there are unsaved config changes */}
<div
class={`settings-save-bar${isDirty() ? " settings-save-bar--visible" : ""}`}
inert={!isDirty() || undefined}
aria-hidden={!isDirty()}
>
<span class="settings-save-bar-label">{language.t("settings.saveBar.unsavedChanges")}</span>
<Button variant="ghost" size="small" onClick={discardConfig}>
{language.t("settings.saveBar.discard")}
</Button>
<Button variant="primary" size="small" onClick={handleSave}>
{language.t("settings.saveBar.save")}
</Button>
</div>
</div>
)
}
@@ -100,7 +100,6 @@ export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
<PopupSelector
expanded={false}
placement="top-start"
preferredHeight={300}
minHeight={100}
open={open()}
onOpenChange={onOpen}
@@ -63,7 +63,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [preActiveIdx, setPreActiveIdx] = createSignal(-1)
const [previewIdx, setPreviewIdx] = createSignal(-1)
const [previewHeight, setPreviewHeight] = createSignal(320)
const [previewHeight, setPreviewHeight] = createSignal(500)
let searchRef: HTMLInputElement | undefined
let searchWrapperRef: HTMLDivElement | undefined
@@ -324,152 +324,161 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
}
class={`model-selector-popover${expanded() ? " model-selector-popover--expanded" : ""}`}
>
{(bodyH) => (
<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) {
setPreActiveIdx(-1)
setPreviewIdx(-1)
}
return !v
})
requestAnimationFrame(() => {
searchRef?.focus()
listRef?.querySelector(".model-selector-item.active")?.scrollIntoView({ block: "nearest" })
})
}}
{(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>
</div>
<div class="model-selector-list" role="listbox" ref={listRef}>
<Show when={flatFiltered().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(0) && !pointer() ? " keyboard-focused" : ""}${isSelected(0) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="option"
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedIndex(0)
}}
<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"
onClick={() => {
setExpanded((v) => {
if (v) {
setPreActiveIdx(-1)
setPreviewIdx(-1)
}
return !v
})
requestAnimationFrame(() => {
searchRef?.focus()
listRef?.querySelector(".model-selector-item.active")?.scrollIntoView({ block: "nearest" })
})
}}
/>
</Tooltip>
</div>
<For each={groups()}>
{(group) => (
<>
<div class="model-selector-group-label">{group.providerName}</div>
<For each={group.models}>
{(model) => {
const idx = () => flatIndexMap().get(model) ?? 0
const hovered = () => isSelected(idx())
const preActive = () => isPreActive(idx())
const showSelectBtn = () => expanded() && preActive() && !isActive(model)
return (
<div
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) ? " active" : ""}`}
role="option"
aria-selected={isActive(model)}
onClick={() => {
setSelectedIndex(idx())
setPreActiveIdx(idx())
setPreviewIdx(idx())
if (!expanded()) pick(model)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) pick(model)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedIndex(idx())
}}
>
<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>
<div class="model-selector-list" role="listbox" ref={listRef}>
<Show when={flatFiltered().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(0) && !pointer() ? " keyboard-focused" : ""}${isSelected(0) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="option"
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedIndex(0)
}}
>
<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) => (
<>
<div class="model-selector-group-label">{group.providerName}</div>
<For each={group.models}>
{(model) => {
const idx = () => flatIndexMap().get(model) ?? 0
const hovered = () => isSelected(idx())
const preActive = () => isPreActive(idx())
const showSelectBtn = () => expanded() && preActive() && !isActive(model)
return (
<div
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${isActive(model) ? " active" : ""}`}
role="option"
aria-selected={isActive(model)}
onClick={() => {
setSelectedIndex(idx())
setPreActiveIdx(idx())
setPreviewIdx(idx())
if (!expanded()) pick(model)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) pick(model)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedIndex(idx())
}}
>
<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>
</div>
<Show when={expanded()}>
<button
class={`model-selector-item-select-btn${showSelectBtn() ? "" : " model-selector-item-select-btn--hidden"}`}
onClick={(e) => {
e.stopPropagation()
pick(model)
}}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
<Show when={expanded()}>
<button
class={`model-selector-item-select-btn${showSelectBtn() ? "" : " model-selector-item-select-btn--hidden"}`}
onClick={(e) => {
e.stopPropagation()
pick(model)
}}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
)
}}
</For>
</>
)}
</For>
</div>
)
}}
</For>
</>
)}
</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
class={`model-selector-preview${expanded() ? " model-selector-preview--visible" : ""}`}
style={expanded() ? { height: `${previewHeight()}px` } : {}}
>
<ModelPreview model={previewModel() ?? activeModel() ?? null} />
</div>
</div>
</div>
)}
)
}}
</PopupSelector>
)
}
@@ -97,11 +97,11 @@ export function PopupSelector<T extends ValidComponent = ValidComponent>(props:
const bodyH = createMemo(() => {
const preferred = local.expanded ? local.preferredExpandedHeight : local.preferredHeight
if (preferred === undefined) return undefined
const h = panelH()
if (h === undefined) return preferred
// 26px = 2px border + 24px popover-body padding (12px top + 12px bottom)
const max = h - 26
const max = h !== undefined ? h - 26 : undefined
if (preferred === undefined) return max !== undefined ? Math.max(local.minHeight ?? 100, max) : undefined
if (max === undefined) return preferred
return Math.max(local.minHeight ?? 100, Math.min(preferred, max))
})
@@ -277,10 +277,18 @@
border-color: var(--vscode-textLink-foreground, #3794ff);
}
.feedback-button [data-component="icon"] {
--icon-base: currentColor;
}
.feedback-button:hover [data-slot="icon-svg"] {
color: var(--vscode-editor-background, #1e1e1e);
}
.feedback-button:hover [data-component="icon"] {
--icon-base: var(--vscode-editor-background, #1e1e1e);
}
/* Feedback Dialog */
@keyframes feedback-dialog-enter {
@@ -442,6 +450,10 @@
transition: background-color 0.2s;
}
.scroll-to-bottom-button [data-component="icon"] {
--icon-base: var(--vscode-button-foreground);
}
.scroll-to-bottom-button:hover {
background: var(--vscode-button-hoverBackground);
}
@@ -2797,6 +2809,35 @@ body.vscode-light
animation: enhance-spin 1s linear infinite;
}
/* Settings save bar */
.settings-save-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 16px;
border-top: 1px solid var(--border-weak-base);
overflow: hidden;
max-height: 0;
opacity: 0;
transition:
max-height 0.2s ease,
opacity 0.2s ease,
padding 0.2s ease;
}
.settings-save-bar--visible {
max-height: 52px;
opacity: 1;
padding: 8px 16px;
}
.settings-save-bar-label {
font-size: 12px;
color: var(--foreground-secondary);
margin-right: auto;
}
[data-slot="settings-row"] {
display: flex;
flex-wrap: wrap;