From 065de83ffd1e7e651e3d79a4af1a3c1e613e1e2f Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 20 Oct 2026 11:45:12 +0900 Subject: [PATCH] fix: open agent model picker above chat attachment controls --- scripts/test-agent-model-selection.cjs | 64 +++++++--- .../src/components/AgentModelSelector.tsx | 117 +++++++++++++++--- src/renderer/src/components/ChatWidgets.tsx | 2 +- 3 files changed, 153 insertions(+), 30 deletions(-) diff --git a/scripts/test-agent-model-selection.cjs b/scripts/test-agent-model-selection.cjs index 1a3b65d..98cfec2 100644 --- a/scripts/test-agent-model-selection.cjs +++ b/scripts/test-agent-model-selection.cjs @@ -9,7 +9,7 @@ function compile(path, overrides = {}) { const exports = {} const source = fs.readFileSync(require.resolve(path), 'utf8') vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, - { exports, require: name => overrides[name] ?? require(name) }) + { exports, document: { body: {} }, require: name => overrides[name] ?? require(name) }) return exports } const models = compile('../src/renderer/src/features/workspace/agentModels.ts') @@ -37,24 +37,60 @@ function elements(root, predicate) { if (!React.isValidElement(root)) return [] return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))] } -const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', { - '../i18n': { t: value => value }, '../features/workspace/agentModels': models, -}) +function selectorHarness(props) { + const state = [], refs = [] + let stateIndex = 0, refIndex = 0 + const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', { + '../i18n': { t: value => value }, '../features/workspace/agentModels': models, + 'react-dom': { createPortal: child => child }, + react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {}, + useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] }, + useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) } + } + }) + return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } } +} for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => { let selected - const tree = AgentModelSelector({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } }) - const select = elements(tree, node => node.type === 'select')[0] - assert.equal(select.props.value, 'QWEN') - assert.equal(select.props.style.colorScheme, dark ? 'dark' : 'light') - assert.deepEqual(elements(select, node => node.type === 'option').map(node => node.props.value), ['OPENAI', 'QWEN']) - select.props.onChange({ target: { value: 'OPENAI' } }) + const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } }) + const tree = h.render() + const trigger = elements(tree, node => node.type === 'button')[0] + assert.equal(trigger.props['aria-haspopup'], 'menu') + assert.equal(trigger.props['aria-expanded'], false) + assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash') + assert.equal(elements(tree, node => node.props.role === 'menu').length, 0) + assert.equal(elements(tree, node => node.type === 'select').length, 0) + trigger.props.onClick() + const menu = elements(h.render(), node => node.props.role === 'menu')[0] + assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/) + assert.match(menu.props.className, /fixed/) + const items = elements(menu, node => node.props.role === 'menuitemradio') + assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash']) + assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true]) + assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1)) + items[0].props.onClick() assert.equal(selected, 'OPENAI') - assert.match(select.props.className, /truncate/) - assert.ok(elements(AgentModelSelector({ models: options, provider: 'QWEN', dark, disabled: true, onChange() {} }), node => node.type === 'select')[0].props.disabled) - const empty = elements(AgentModelSelector({ models: [], provider: null, dark, disabled: false, onChange() {} }), node => node.type === 'select')[0] + assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0) + assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled) + const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0] assert.ok(empty.props.disabled) - assert.equal(elements(empty, node => node.type === 'option')[0].props.children, '사용 가능한 모델 없음') + assert.equal(empty.props.title, '사용 가능한 모델 없음') +}) + +test('model menu opens from arrow keys and closes when a request blocks the selector', () => { + const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} }) + const trigger = elements(h.render(), node => node.type === 'button')[0] + trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} }) + assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1) + assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0) +}) + +test('input footer places the model action before the attachment and clear controls', () => { + const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8') + const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)')) + assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}')) + assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}')) }) test('A-Copilot has a single managed label', () => { diff --git a/src/renderer/src/components/AgentModelSelector.tsx b/src/renderer/src/components/AgentModelSelector.tsx index 049a68d..f09fd00 100644 --- a/src/renderer/src/components/AgentModelSelector.tsx +++ b/src/renderer/src/components/AgentModelSelector.tsx @@ -1,4 +1,6 @@ -import { Bot, ChevronDown } from 'lucide-react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Bot, Check } from 'lucide-react' import { t } from '../i18n' import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels' @@ -10,23 +12,108 @@ export function AgentModelSelector({ models, provider, disabled, dark, onChange onChange: (provider: string) => void }): React.JSX.Element { const selected = models.find(model => model.provider === provider) + const blocked = disabled || models.length === 0 + const [open, setOpen] = useState(false) + const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 }) + const buttonRef = useRef(null) + const menuRef = useRef(null) + const menuId = useId() + const visible = open && !blocked + const close = (restoreFocus = false): void => { + setOpen(false) + if (restoreFocus) buttonRef.current?.focus({ preventScroll: true }) + } + + useEffect(() => { + if (blocked) setOpen(false) + }, [blocked]) + + useLayoutEffect(() => { + if (!visible || !buttonRef.current) return + const bounds = buttonRef.current.getBoundingClientRect() + const width = Math.min(320, window.innerWidth - 16) + setPosition({ + left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)), + bottom: window.innerHeight - bounds.top + 6, + width, + maxHeight: Math.max(0, Math.min(320, bounds.top - 14)) + }) + const items = menuRef.current?.querySelectorAll('[role="menuitemradio"]') + const index = Math.max(0, models.findIndex(model => model.provider === provider)) + items?.[index]?.focus({ preventScroll: true }) + }, [visible, models, provider]) + + useEffect(() => { + if (!visible) return + const outside = (event: Event): void => { + const target = event.target as Node + if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false) + } + const dismiss = (): void => setOpen(false) + window.addEventListener('pointerdown', outside, true) + window.addEventListener('contextmenu', outside) + window.addEventListener('scroll', outside, true) + window.addEventListener('resize', dismiss) + window.addEventListener('blur', dismiss) + return () => { + window.removeEventListener('pointerdown', outside, true) + window.removeEventListener('contextmenu', outside) + window.removeEventListener('scroll', outside, true) + window.removeEventListener('resize', dismiss) + window.removeEventListener('blur', dismiss) + } + }, [visible]) + return ( -
-
+