Replies: 4 comments 1 reply
Independent confirmation on desktop Safari (macOS) — and a verified fixSame symptom, same root cause, on Capture-phase hit-testing at the pointer coordinates. The coordinates are identical on both events; only the target changes: No A The alpha.1 → alpha.2 reachability change also reproduces independently: alpha.1 has 2 Fix
Before — a null const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && (
rootRef.current?.contains(event.relatedTarget) === true
|| menuRef.current?.contains(event.relatedTarget) === true
)) return
close()
}After — the null case joins the two containment checks in one guard, matching the const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
const { relatedTarget } = event
if (relatedTarget === null
|| rootRef.current?.contains(relatedTarget) === true
|| menuRef.current?.contains(relatedTarget) === true) return
close()
}The regression test drives that exact sequence — press the row, blur with a null destination, release, click — and asserts the selection is submitted ( const row = screen.getByRole("menuitemradio", { name: /Max/ })
fireEvent.mouseDown(row)
// The pointer transfers focus out of the trigger subtree; Safari reports no
// destination for it. The card must survive so the click can still arrive.
fireEvent.blur(trigger, { relatedTarget: null })
expect(screen.getByRole("menuitemradio", { name: /Max/ })).toBeTruthy()
fireEvent.mouseUp(row)
fireEvent.click(row)
await waitFor(() => {
expect(select).toHaveBeenCalledWith({
provider: "deepseek-official",
model: "deepseek-v4-flash",
reasoningEffort: "max",
})
})It fails with the guard reverted and passes with the fix: pnpm exec vitest run --config vitest.config.ts \
packages/client/ui-model-selection/tests/model-select.client.spec.tsxThe surrounding suite is 41 passed on the same command; Other cards using the same patternYour closing question — I searched the tree for blur/focus handlers that read
Reachability for the first two depends on whether focus lands inside their card the way the picker's drill step now makes it. Probably worth checking separately rather than assuming they are live. |
|
Independent confirmation on desktop Safari with our source-built 0.1.6-alpha.2 deployment: mouse selection closed the model/effort menu before the click applied, while keyboard selection worked. We deployed the suggested focus-preservation approach on the portaled menu: onMouseDown={(event) => {
if (event.button === 0) event.preventDefault()
}}The existing blur/outside-dismissal logic stays unchanged. Regression tests reproduce the Safari-style blur ordering for both model and effort selection and pass with the fix; the GUI suite passed (6,183 tests, one skipped), as did focused model/effort browser tests. Live Safari mouse checks also passed: switched between two models and back, then High → Low → High reasoning. Those live checks were browser-automated, not a claim of manual human testing. Thanks for documenting the root cause—this resolved the issue in our deployment. |
|
Thanks for the report. |
|
Follow-up for Narrower rule with tests and a branch: press-held flag (set on |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Symptom
On 0.1.6-alpha.2, in Safari, no row in the composer's model picker can be selected with the mouse. Open the model chip, drill into
模型/Model, click any row: the card closes, the model does not change, no error, no toast, and nosession/selectModelrequest is sent. Every row is affected, not a particular provider. Chrome/Chromium is unaffected. Keyboard selection (↓into the list,Enter) works in Safari.Environment
dsh0.1.6-alpha.2 built from source,webprofile, page served over plain HTTP on a trusted host.webkit-2311), so this is engine-level rather than a Safari preference or extension.Evidence
Capture-phase listeners on
documentwhile clicking one row (WebKit):target[role=menu]pointerdownspan.modelNamemousedownspan.modelNamemouseupdivThe card is already unmounted by
mouseup, so themouseuplands on the composer underneath and noclickevent is ever generated on the row — hence no handler, no RPC, no error.Two controls, same build, same page:
element.click()from the console (no focus change):POST /api/session/selectModelis sent and the selection applies.Enteron a row (focus moves within the card): same, works.So the handler and the RPC are fine; only the real pointer sequence fails.
Root cause
packages/client/ui-model-selection/src/client/ModelSelect.tsx, the card root'sonBlur:When
relatedTargetisnullthe guard's first conjunct fails and the card closes.WebKit does not focus a
<button>on mousedown. Pressing a row therefore pulls focus off whatever row currently holds it and reportsrelatedTarget === null, soonBlurreads "focus left the card" and callsclose()duringmousedown. Chromium focuses the pressed button,relatedTargetis the row itself (insidemenuRef), the guard returns early, and the click completes.Why it appears now
The
onBlurguard is byte-identical to earlier releases. What changed is that the picker now hands focus to a row when a pane is drilled into (thepaneFocusref and the?.focus()calls added with the shared menu family's keyboard model). Before that, in WebKit nothing inside the card held focus after a mouse-opened menu, so pressing a row produced nofocusoutand the click completed. The new explicit row focus makes the pre-existingnull-relatedTargetpath reachable on every mouse selection.Any other card using the same "close on blur unless
relatedTargetis contained" shape plus real focus on its rows is exposed the same way.Suggested fix
Stop the press from moving focus, so the blur never happens. On the card element (the
menuRefdiv):preventDefault()onmousedowncancels only the focus move (and text selection); theclickstill fires.onBlurstays exactly as it is, presses on non-button parts of the card (group titles, the scroll track, status text) keep the browser default, and in Chromium/Firefox the only change is that a pressed row no longer takes focus before its click. The same pattern already exists inpackages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx, whose rows suppress this focus steal on mousedown.Why not special-case
relatedTarget === nullinonBlur? It keeps the card open, but focus has already fallen tobody.onKeyDownlives on the component root, so once focus is onbodythe card stops receiving keys: after a refused selection (settleSelectiononly toasts) or a press released off the row, Escape and the arrow keys no longer work. It would also change Chromium, where a press on a group title currently closes the card.Regression tests
falsefromfireEvent.mouseDown(default prevented) and leaves focus on the drilled row, a group-title press returnstrue, and the row's click then selects. jsdom performs no default focus move, so this pins the mechanism, not the WebKit behaviour.expected '选择模型,当前 Acme Think' to match /Acme Swift/); with it, it passes. The CI web lane installs only Chromium, which focuses pressed buttons and cannot observe this, so the scenario should skip where no WebKit build is installed.Edited 2026-09-18: the suggested fix changed from a
relatedTarget === nullguard inonBlurto preventing the focus move onmousedown; the reason is in "Why not special-case…" above.All reactions