Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .scratch/model-selector/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 停留阈值)过渡,因主动换行有钝感被几何法替换。

### 事实依据(调研结论,勿再翻案)
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<ModelSelectorControl
controls={controls}
isDisabled={false}
onChange={() => {}}
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down
Loading