From 9814855db780850e4a146093265f6a44336b605c Mon Sep 17 00:00:00 2001 From: infinite-vector Date: Thu, 1 Oct 2026 17:52:27 +0800 Subject: [PATCH] fix(uiux): resolve popup-select scroll churn and fallback check icon rendering --- .../settings/agent-model-picker-directory.js | 13 +++- .../uiux/generated/primitives/popup-select.js | 60 ++++++++++++------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/modules/ui-system/settings/agent-model-picker-directory.js b/modules/ui-system/settings/agent-model-picker-directory.js index 4cf7add6c..7ebf99a9e 100644 --- a/modules/ui-system/settings/agent-model-picker-directory.js +++ b/modules/ui-system/settings/agent-model-picker-directory.js @@ -60,11 +60,20 @@ export function createAgentModelPickerDirectory({ electronAPI, input }) { const inOrder = (ids, group) => ids .map(id => byId.get(id)) .filter(Boolean) - .map(option => ({ ...option, group })); + .map(option => ({ + ...option, + group, + // 保留原生 id 供 onSelect 消费,同时补充 viewKey 避免复合状态混淆 + viewKey: `${group}:${option.id}`, + })); return [ ...inOrder(hotIds, '热门模型'), ...inOrder(favoriteIds, '收藏模型'), - ...normalized.map(option => ({ ...option, group: '全部模型' })), + ...normalized.map(option => ({ + ...option, + group: '全部模型', + viewKey: `全部模型:${option.id}`, + })), ]; }; diff --git a/modules/uiux/generated/primitives/popup-select.js b/modules/uiux/generated/primitives/popup-select.js index 92f032c77..194b8623e 100644 --- a/modules/uiux/generated/primitives/popup-select.js +++ b/modules/uiux/generated/primitives/popup-select.js @@ -1,5 +1,4 @@ import { mountRiskConfirmation } from './risk-confirmation.js'; -import { mountSemanticIcon } from './semantic-icon.js'; const STYLE_ID = 'vcp-uiux-uiux-popup-select'; /** * ModelSelect renders its selected marker as a 16px inline SVG, not through @@ -429,12 +428,16 @@ export function mountPopupSelectView(host, props, scope) { let riskScope = null; let rowsScope = null; let focusActiveOption = false; + let scrollActiveIntoView = false; const moveAndFocus = (direction) => { const before = popup.getSnapshot().active; focusActiveOption = optionRole === 'menuitemradio'; + scrollActiveIntoView = true; // 键盘主动移动光标,授权执行滚入视野 popup.move(direction); - if (popup.getSnapshot().active === before) + if (popup.getSnapshot().active === before) { focusActiveOption = false; + scrollActiveIntoView = false; + } }; viewScope.listen(card, 'keydown', event => { const s = popup.getSnapshot(); @@ -474,7 +477,10 @@ export function mountPopupSelectView(host, props, scope) { default: return; // ArrowLeft/Right fall through: native caret movement. } }); - viewScope.listen(search, 'input', () => popup.setSearch(search.value)); + viewScope.listen(search, 'input', () => { + scrollActiveIntoView = true; // 输入过滤词发生变化时,将焦点首项复位到可视区域 + popup.setSearch(search.value); + }); viewScope.listen(retryButton, 'click', () => popup.retry()); viewScope.listen(document, 'pointerdown', event => { const s = popup.getSnapshot(); @@ -523,15 +529,19 @@ export function mountPopupSelectView(host, props, scope) { } row.setAttribute('role', optionRole); row.setAttribute('aria-disabled', String(disabled)); - if (optionRole === 'menuitemradio') + const isActive = index === s.active; + row.dataset.active = String(isActive); + if (optionRole === 'menuitemradio') { row.setAttribute('aria-checked', String(option.active === true)); - else - row.setAttribute('aria-selected', String(index === s.active)); - row.className = optionRole === 'menuitemradio' - ? 'vcp-uiux-popup-select-option' - : (index === s.active + row.className = isActive + ? 'vcp-uiux-popup-select-option vcp-uiux-popup-select-option-active' + : 'vcp-uiux-popup-select-option'; + } else { + row.setAttribute('aria-selected', String(isActive)); + row.className = isActive ? 'vcp-uiux-popup-select-row vcp-uiux-popup-select-row-active' - : 'vcp-uiux-popup-select-row'); + : 'vcp-uiux-popup-select-row'; + } if (disabled) row.classList.add(optionRole === 'menuitemradio' ? 'vcp-uiux-popup-select-option-disabled' : 'vcp-uiux-popup-select-row-disabled'); const copy = document.createElement('span'); @@ -554,12 +564,9 @@ export function mountPopupSelectView(host, props, scope) { const check = document.createElement('span'); check.className = optionRole === 'menuitemradio' ? 'vcp-uiux-popup-select-option-check' : 'vcp-uiux-popup-select-check'; check.setAttribute('aria-hidden', 'true'); - if (option.active === true && grouped && optionRole === 'menuitemradio') { + if (option.active === true) { mountUiuxModelSelectCheck(check); } - else if (option.active === true) { - mountSemanticIcon(check, { name: 'check', size: 16 }, nextRowsScope.child('uiux-popup-select-check')); - } row.append(check); } if (props.onFavoriteToggle !== undefined && option.favorite !== undefined) { @@ -585,15 +592,23 @@ export function mountPopupSelectView(host, props, scope) { actionRow.className = 'vcp-uiux-popup-select-action-row'; nextRowsScope.listen(row, 'click', () => { if (!disabled) void popup.select(index); }); - nextRowsScope.listen(row, 'mouseenter', () => { if (!disabled) - popup.highlight(index); }); + nextRowsScope.listen(row, 'mouseenter', () => { + if (!disabled) { + scrollActiveIntoView = false; // 严防死守:鼠标移动引发的高亮决不触发 scrollIntoView + popup.highlight(index); + } + }); actionRow.append(row, favorite); return actionRow; } nextRowsScope.listen(row, 'click', () => { if (!disabled) void popup.select(index); }); - nextRowsScope.listen(row, 'mouseenter', () => { if (!disabled) - popup.highlight(index); }); + nextRowsScope.listen(row, 'mouseenter', () => { + if (!disabled) { + scrollActiveIntoView = false; // 严防死守:鼠标移动引发的高亮决不触发 scrollIntoView + popup.highlight(index); + } + }); return row; }; if (grouped) { @@ -621,9 +636,12 @@ export function mountPopupSelectView(host, props, scope) { } else rows.forEach((option, index) => listbox.append(renderOption(option, index))); - // Focus ownership sits with the search input, so scrolling the virtual - // highlight into view is explicit here (source useEffect on `active`). - listbox.querySelector('[aria-selected="true"], [aria-checked="true"]')?.scrollIntoView?.({ block: 'nearest' }); + // 仅在明确需要滚动(如键盘上下移动或搜索词刷新)时执行,且靶点必须是真正的当前光标项 + if (scrollActiveIntoView) { + scrollActiveIntoView = false; + const targetRow = listbox.querySelector('[data-active="true"]'); + targetRow?.scrollIntoView?.({ block: 'nearest' }); + } if (focusActiveOption) { focusActiveOption = false; const row = listbox.querySelectorAll('[role="menuitemradio"]')[s.active];