Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/page-controller/src/dom/dom_tree/dropdown-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it } from 'vitest'

import domTree from './index.js'

function setupSizes() {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get() {
return 100
},
})
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get() {
return 30
},
})
}

function buildDropdown(html: string) {
return domTree({
doHighlightElements: false,
viewportExpansion: -1,
interactiveBlacklist: [],
interactiveWhitelist: [],
}) as any
}

function getIndexedOptions(tree: any) {
return Object.values(tree.map)
.filter((n: any) => n.tagName === 'li' && typeof n.highlightIndex === 'number')
.sort((a: any, b: any) => a.highlightIndex - b.highlightIndex)
}

describe('dropdown option indexing (#519)', () => {
beforeEach(() => {
setupSizes()
document.body.innerHTML = ''
})

it('indexes enabled dropdown options', () => {
document.body.innerHTML = `
<div class="el-select-dropdown">
<ul class="el-select-dropdown__list">
<li class="el-select-dropdown__item" style="cursor:pointer">选项1</li>
<li class="el-select-dropdown__item" style="cursor:pointer">选项2</li>
<li class="el-select-dropdown__item" style="cursor:pointer">选项3</li>
</ul>
</div>
`
const tree = buildDropdown(document.body.innerHTML)
const indexed = getIndexedOptions(tree)
expect(indexed.length).toBe(3)
})

it('indexes disabled dropdown options so the full list is visible to the LLM', () => {
document.body.innerHTML = `
<div class="el-select-dropdown">
<ul class="el-select-dropdown__list">
<li class="el-select-dropdown__item" style="cursor:pointer">闸片产线</li>
<li class="el-select-dropdown__item is-disabled" style="cursor:not-allowed">落料车间</li>
<li class="el-select-dropdown__item" style="cursor:pointer">机加车间</li>
</ul>
</div>
`
const tree = buildDropdown(document.body.innerHTML)
const indexed = getIndexedOptions(tree)
expect(indexed.length).toBe(3)
})

it('still excludes disabled buttons outside dropdowns', () => {
document.body.innerHTML = `
<button style="cursor:not-allowed" disabled>保存</button>
`
const tree = buildDropdown(document.body.innerHTML)
const buttons = Object.values(tree.map).filter((n: any) => n.tagName === 'button')
expect(buttons.every((n: any) => n.highlightIndex === undefined)).toBe(true)
})
})
42 changes: 42 additions & 0 deletions packages/page-controller/src/dom/dom_tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,39 @@ export default (
)
}

/**
* @edit dropdown/menu option detection
* li/option elements (or role=option/menuitem/listitem) inside a dropdown or menu
* container are treated as indexable even when visually disabled (cursor: not-allowed),
* so the LLM sees the complete option list.
*/
const DROPDOWN_OPTION_ROLES = new Set([
'option',
'menuitem',
'menuitemradio',
'menuitemcheckbox',
'listitem',
])
const DROPDOWN_CONTAINER_SELECTOR = [
'[role="listbox"]',
'[role="menu"]',
'[role="menubar"]',
'select',
'.el-select-dropdown',
'.el-dropdown-menu',
'[data-toggle="dropdown"]',
].join(', ')

function isDropdownOptionElement(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return false
const tagName = element.tagName.toLowerCase()
const role = element.getAttribute('role')
if (tagName !== 'li' && tagName !== 'option' && !(role && DROPDOWN_OPTION_ROLES.has(role))) {
return false
Comment on lines +717 to +718

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not index ARIA wrapper list items

When a role="menu"/role="listbox" uses the common ARIA pattern of <li role="none"> wrappers around real role="menuitem" children, this condition still treats the wrapper li as an option because every li inside a dropdown container returns true before the normal role/cursor/disabled checks run. That gives both the non-actionable wrapper and the actual menu item numeric indexes, so the agent can choose an empty/no-op wrapper index and the simplified DOM is polluted with duplicate actions; consider excluding explicit non-option roles such as none/presentation/separator or only accepting bare lis for known dropdown item classes.

Useful? React with 👍 / 👎.

}
return Boolean(element.closest(DROPDOWN_CONTAINER_SELECTOR))
}

/**
* Checks if an element is interactive.
*
Expand All @@ -711,6 +744,15 @@ export default (
return true // Skip whitelisted elements
}

/**
* @edit dropdown options should stay indexable even when disabled,
* otherwise the LLM cannot see or select the full option list
* (e.g. Element UI / Avue selects with disabled options).
*/
if (isDropdownOptionElement(element)) {
return true
Comment on lines +752 to +753

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve disabled state for indexed options

When a dropdown item is disabled only by CSS/class, such as the Element UI case added in the new test with cursor:not-allowed/is-disabled, this early return runs before the cursor/disabled checks and the default simplified DOM does not include class or style. The prompt therefore exposes that disabled item as a normal [n]<li>... action, so the agent can keep trying an option the UI will reject instead of seeing it as unavailable; carry a disabled marker or emit the text without making it look actionable.

AGENTS.md reference: AGENTS.md:L150-L151

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

}

// Cache the tagName and style lookups
const tagName = element.tagName.toLowerCase()
const style = getCachedComputedStyle(element)
Expand Down