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) => {