From 6f73d4c4d7b1a4a68c2f38827711d00f66cf8c2f Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 7 Oct 2026 13:06:59 +0800 Subject: [PATCH] fix(desktop): drop the open model row when the pointer leaves the list Hovering a row opens its options flyout and keeps the row lit. Only the panel-level mouseleave cleared that state, so a pointer moving from a row down to the Add Models row (or up to the search field) left the row above lit with its flyout still open. The panel's pointermove now ends the hover as soon as the pointer settles outside the list/flyout cluster. Leaving the panel still goes through the 300ms grace period the safe-triangle gap crossing relies on. --- .scratch/model-selector/PRD.md | 3 +- .../model-selector-control.test.tsx | 37 ++++++++++++++++++- .../model-selector/model-selector-control.tsx | 27 +++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/.scratch/model-selector/PRD.md b/.scratch/model-selector/PRD.md index eb6b7c02..0325b996 100644 --- a/.scratch/model-selector/PRD.md +++ b/.scratch/model-selector/PRD.md @@ -22,7 +22,8 @@ Issue: [#99](https://github.com/BubblePtr/PiGUI/issues/99) · 前身规格见 `. - 顶点 = 指针 ~200ms 前的轨迹点(`POINTER_TRAIL_MS`),底边 = 飞出层实时 `getBoundingClientRect()` 左边缘 ± 8px(`TRIANGLE_PAD_PX`)。 - 指针在三角形内 → 判定"正在赶往飞出层",每 100ms 重估;**离开三角形或原地停下(200ms 内位移 < 3px)→ 立即切换**,无固定延迟。 - 指针已达飞出层 → 放弃待定切换;点击行 → 绕过三角形立即提交。 -- 离开整个面板保留 300ms 关闭宽限期兜底(Radix/Floating UI 同款混合策略)。 +- **高亮与飞出层只属于指针所在之处**:指针在面板内移到列表/飞出层集群之外(搜索框、`Add Models` 行、面板内边距)→ 立即收起(2026-10-07 修正;此前 `Add Models` 悬停时上一行仍保持高亮与飞出层)。 +- 离开整个面板保留 300ms 关闭宽限期兜底(Radix/Floating UI 同款混合策略),该宽限期仍服务于"行 → 飞出层"的 28px 缝隙跨越。 - 曾用时间法(120ms 停留阈值)过渡,因主动换行有钝感被几何法替换。 ### 事实依据(调研结论,勿再翻案) diff --git a/apps/desktop/src/entities/model/model-selector/model-selector-control.test.tsx b/apps/desktop/src/entities/model/model-selector/model-selector-control.test.tsx index 2b79ac06..2cd05e6d 100644 --- a/apps/desktop/src/entities/model/model-selector/model-selector-control.test.tsx +++ b/apps/desktop/src/entities/model/model-selector/model-selector-control.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RuntimeModelControls } from "@pace/core"; @@ -131,6 +131,41 @@ describe("ModelSelectorControl visibility", () => { }); }); +describe("ModelSelectorControl hover intent", () => { + it("drops the active row when the pointer leaves the list for Add Models", async () => { + render( + {}} + onManageModels={() => {}} + />, + ); + + const { user, list } = await openSelector(); + const rows = within(list).getAllByRole("listitem"); + const lastRow = rows[rows.length - 1] as HTMLElement; + + await user.hover(lastRow); + + expect( + screen.getByRole("group", { name: "Kimi K3 options" }), + ).toBeInTheDocument(); + expect(lastRow).toHaveAttribute("aria-current", "true"); + + await user.hover(screen.getByText("Add Models")); + + // The row highlight and its flyout belong to the row under the pointer; + // the panel chrome below the list must not keep the last row lit. + await waitFor(() => + expect( + screen.queryByRole("group", { name: "Kimi K3 options" }), + ).not.toBeInTheDocument(), + ); + expect(lastRow).not.toHaveAttribute("aria-current"); + }); +}); + describe("ModelSelectorControl provider mark", () => { it("shows the provider mark next to the model name for a known provider", () => { render( diff --git a/apps/desktop/src/entities/model/model-selector/model-selector-control.tsx b/apps/desktop/src/entities/model/model-selector/model-selector-control.tsx index 492c7a06..769cafd5 100644 --- a/apps/desktop/src/entities/model/model-selector/model-selector-control.tsx +++ b/apps/desktop/src/entities/model/model-selector/model-selector-control.tsx @@ -3,8 +3,9 @@ // (vertical Reasoning levels + Fast Mode switch). Fast siblings are separate // catalog entries merged into one family row here. Hover intent uses a // safe-triangle (menu-aim): while the pointer travels toward the open flyout -// it is never stolen by rows on the way. Decision record: -// .scratch/model-selector/PRD.md +// it is never stolen by rows on the way, and the open row is dropped the +// moment the pointer settles on the panel's chrome instead of a row. +// Decision record: .scratch/model-selector/PRD.md import { useEffect, useRef, useState, type ComponentProps } from "react"; import { Button } from "@astryxdesign/core/Button"; @@ -99,6 +100,21 @@ function useModelFlyout() { clearTimer(closeTimerRef); }; + /** Ends the hover at once, without the panel-exit grace period. */ + const closeFlyoutNow = () => { + clearTimer(closeTimerRef); + clearTimer(switchTimerRef); + activeKeyRef.current = null; + setActiveKey(null); + }; + + /** + * Every pointer move inside the panel: records the trail the safe triangle + * reads, and drops the open row as soon as the pointer has settled on the + * panel's chrome (search field, Add Models row) instead of a row. Leaving + * the panel keeps closeFlyout's 300ms grace instead, which a pointer + * crossing the gap toward the flyout depends on. + */ const trackPointer = (event: React.PointerEvent) => { const trail = pointerTrailRef.current; const now = Date.now(); @@ -107,6 +123,13 @@ function useModelFlyout() { while (trail.length > 1 && now - (trail[0]?.time ?? now) > POINTER_TRAIL_MS) { trail.shift(); } + + if ( + activeKeyRef.current !== null && + !anchorRef.current?.contains(event.target as Node) + ) { + closeFlyoutNow(); + } }; const commitOpen = (key: string, row: HTMLElement | null) => {