Skip to content

Commit a731d68

Browse files
authored
Merge pull request #878 from 2379278408/fix/875-task-skill-wheel-scroll
修复任务技能列表滚轮失效
2 parents 8902c11 + 0c05686 commit a731d68

6 files changed

Lines changed: 454 additions & 0 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# 启动任务技能列表滚轮修复实施计划
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** 让“创建任务”和“启动 AI 任务”弹窗中的技能列表支持鼠标滚轮和触控板纵向滚动。
6+
7+
**Architecture:** 共享 `TaskSkillSelector` 接收可选 `enableWheelScrollFallback` 属性。“创建任务”和“启动 AI 任务”入口启用滚轮回退,通过回调 ref 跟踪 Portal 中稳定的 `PopoverContent` 节点。容器上的原生 `wheel` 监听器从事件目标定位活动技能列表,通过 `deltaMode` 换算后主动更新 `scrollTop`;该实现避开 PR #874 修改的 `TabsContent` 渲染结构。独立临时 worktree 组合 PR #871、PR #874 和 Issue #875,用于统一构建和预览。
8+
9+
**Tech Stack:** React 19、TypeScript、Radix UI、Node.js test runner、Vite。
10+
11+
## Global Constraints
12+
13+
- 修复范围限定为“创建任务”和“启动 AI 任务”入口。
14+
- 保持搜索、标签筛选、技能勾选及其他任务入口行为。
15+
- PR #874 和 Issue #875 按任意顺序合并时不产生文本冲突。
16+
- 纵向滚轮回退不处理纯横向手势和 `Ctrl+滚轮`
17+
- 执行 `pnpm lint``pnpm run build:online`
18+
- 完成 SaaS 预览并由用户验收后再提交代码。
19+
20+
---
21+
22+
### Task 1: 更新入口范围回归测试
23+
24+
**Files:**
25+
- Modify: `frontend/test/task-skill-selector-wheel.test.mjs`
26+
- Read: `frontend/src/components/console/task/task-skill-selector.tsx`
27+
- Read: `frontend/src/components/console/project/start-develop-task-dialog.tsx`
28+
- Read: `frontend/src/components/console/task/task-input.tsx`
29+
- Read: `frontend/src/components/console/task/create-default-task-dialog.tsx`
30+
31+
**Interfaces:**
32+
- Consumes: `TaskSkillSelectorProps` 和三个现有调用入口。
33+
- Produces: 对 `enableWheelScrollFallback?: boolean`、滚轮增量换算和两个弹窗入口启用范围的源码约束。
34+
35+
- [x] **Step 1: 编写失败测试**
36+
37+
```typescript
38+
import assert from "node:assert/strict";
39+
import { readFileSync } from "node:fs";
40+
import test from "node:test";
41+
42+
function readSource(path) {
43+
return readFileSync(new URL(path, import.meta.url), "utf8");
44+
}
45+
46+
const selector = readSource("../src/components/console/task/task-skill-selector.tsx");
47+
const startTask = readSource("../src/components/console/project/start-develop-task-dialog.tsx");
48+
const taskInput = readSource("../src/components/console/task/task-input.tsx");
49+
const defaultTask = readSource("../src/components/console/task/create-default-task-dialog.tsx");
50+
51+
function taskSkillSelectorTags(source) {
52+
return source.match(/<TaskSkillSelector\b[^>]*\/>/gs) ?? [];
53+
}
54+
55+
test("启动任务技能选择器启用滚轮回退", () => {
56+
assert.match(selector, /enableWheelScrollFallback\?: boolean/);
57+
assert.match(selector, /addEventListener\("wheel", handleSkillListWheel, \{ passive: false \}\)/);
58+
assert.match(selector, /event\.preventDefault\(\)/);
59+
assert.match(selector, /removeEventListener\("wheel", handleSkillListWheel\)/);
60+
assert.match(selector, /setPopoverContentElement/);
61+
assert.match(selector, /activeSkillList\.scrollTop \+= getWheelScrollDelta/);
62+
assert.match(selector, /ref=\{setPopoverContentElement\}/);
63+
assert.match(selector, /closest<HTMLDivElement>\(ACTIVE_SKILL_LIST_SELECTOR\)/);
64+
assert.match(selector, /event\.ctrlKey/);
65+
assert.match(selector, /Math\.abs\(event\.deltaY\) <= Math\.abs\(event\.deltaX\)/);
66+
assert.doesNotMatch(selector, /ref=\{tag === activeSkillTag/);
67+
assert.doesNotMatch(selector, /<Popover modal=/);
68+
assert.equal(
69+
taskSkillSelectorTags(startTask).some((tag) => /\benableWheelScrollFallback\b/.test(tag)),
70+
true,
71+
);
72+
});
73+
74+
test("滚轮回退按 deltaMode 换算滚动距离", () => {
75+
const getWheelScrollDelta = loadWheelScrollDelta();
76+
77+
assert.equal(getWheelScrollDelta(24, 0, 300), 24);
78+
assert.equal(getWheelScrollDelta(3, 1, 300), 48);
79+
assert.equal(getWheelScrollDelta(2, 2, 300), 600);
80+
assert.equal(getWheelScrollDelta(-2, 1, 300), -32);
81+
});
82+
83+
test("其他任务入口保持默认 Popover 行为", () => {
84+
assert.equal(
85+
taskSkillSelectorTags(defaultTask).some((tag) => /\benableWheelScrollFallback\b/.test(tag)),
86+
true,
87+
);
88+
assert.equal(
89+
taskSkillSelectorTags(taskInput).some((tag) => /\benableWheelScrollFallback\b/.test(tag)),
90+
false,
91+
);
92+
});
93+
```
94+
95+
- [x] **Step 2: 运行测试并确认失败**
96+
97+
Run: `node --test test/task-skill-selector-wheel.test.mjs`
98+
99+
Expected: 第一个测试因仍使用活动 `TabsContent` ref 而失败。
100+
101+
### Task 2: 为两个弹窗入口启用滚轮回退
102+
103+
**Files:**
104+
- Modify: `frontend/src/components/console/task/task-skill-selector.tsx:29-40,108-140,182-223,267-286`
105+
- Modify: `frontend/src/components/console/project/start-develop-task-dialog.tsx:456-466`
106+
- Modify: `frontend/src/components/console/task/create-default-task-dialog.tsx:575-585`
107+
- Test: `frontend/test/task-skill-selector-wheel.test.mjs`
108+
109+
**Interfaces:**
110+
- Consumes: 浏览器原生 `WheelEvent``WheelEvent.deltaMode` 和非 passive 事件监听器。
111+
- Produces: `TaskSkillSelectorProps.enableWheelScrollFallback?: boolean`,稳定的 Popover 容器监听器,两个弹窗入口启用该属性。
112+
113+
- [x] **Step 1: 移除 modal 方案并增加滚轮回退属性**
114+
115+
```tsx
116+
interface TaskSkillSelectorProps {
117+
enableWheelScrollFallback?: boolean
118+
open: boolean
119+
onOpenChange: (open: boolean) => void
120+
selectedSkills: string[]
121+
skills: SkillForPicker[]
122+
skillTags: string[]
123+
activeSkillTag: string
124+
onActiveSkillTagChange: (tag: string) => void
125+
onSkillChange: (skillId: string, checked: boolean) => void
126+
triggerClassName?: string
127+
labelClassName?: string
128+
}
129+
130+
export function TaskSkillSelector({
131+
enableWheelScrollFallback,
132+
open,
133+
onOpenChange,
134+
selectedSkills,
135+
skills,
136+
skillTags,
137+
activeSkillTag,
138+
onActiveSkillTagChange,
139+
onSkillChange,
140+
triggerClassName,
141+
labelClassName,
142+
}: TaskSkillSelectorProps) {
143+
const { t } = useTranslation()
144+
}
145+
```
146+
147+
- [x] **Step 2: 通过 Popover 容器上的非 passive 原生监听器主动滚动技能列表**
148+
149+
```tsx
150+
const DOM_DELTA_LINE = 1
151+
const DOM_DELTA_PAGE = 2
152+
const WHEEL_LINE_HEIGHT = 16
153+
154+
function getWheelScrollDelta(deltaY: number, deltaMode: number, pageHeight: number) {
155+
if (deltaMode === DOM_DELTA_LINE) return deltaY * WHEEL_LINE_HEIGHT
156+
if (deltaMode === DOM_DELTA_PAGE) return deltaY * pageHeight
157+
return deltaY
158+
}
159+
160+
const ACTIVE_SKILL_LIST_SELECTOR = '[role="tabpanel"][data-state="active"]'
161+
162+
const [popoverContentElement, setPopoverContentElement] = useState<HTMLDivElement | null>(null)
163+
164+
useEffect(() => {
165+
if (!enableWheelScrollFallback || !open || !popoverContentElement) return
166+
167+
const handleSkillListWheel = (event: WheelEvent) => {
168+
if (
169+
event.ctrlKey ||
170+
event.deltaY === 0 ||
171+
Math.abs(event.deltaY) <= Math.abs(event.deltaX) ||
172+
!(event.target instanceof Element)
173+
) {
174+
return
175+
}
176+
177+
const activeSkillList = event.target.closest<HTMLDivElement>(ACTIVE_SKILL_LIST_SELECTOR)
178+
if (!activeSkillList || !popoverContentElement.contains(activeSkillList)) return
179+
180+
event.preventDefault()
181+
activeSkillList.scrollTop += getWheelScrollDelta(
182+
event.deltaY,
183+
event.deltaMode,
184+
activeSkillList.clientHeight
185+
)
186+
}
187+
188+
popoverContentElement.addEventListener("wheel", handleSkillListWheel, { passive: false })
189+
190+
return () => popoverContentElement.removeEventListener("wheel", handleSkillListWheel)
191+
}, [enableWheelScrollFallback, open, popoverContentElement])
192+
193+
<PopoverContent
194+
ref={setPopoverContentElement}
195+
className="flex max-h-[min(24rem,var(--radix-popover-content-available-height))] w-[90vw] max-w-xl flex-col overflow-hidden p-2"
196+
align="start"
197+
>
198+
```
199+
200+
- [x] **Step 3: 在两个弹窗入口启用**
201+
202+
```tsx
203+
<TaskSkillSelector
204+
enableWheelScrollFallback
205+
open={skillPopoverOpen}
206+
onOpenChange={setSkillPopoverOpen}
207+
selectedSkills={selectedSkill}
208+
skills={skillList}
209+
skillTags={skillTags}
210+
activeSkillTag={activeSkillTag}
211+
onActiveSkillTagChange={setActiveSkillTag}
212+
onSkillChange={handleSkillChange}
213+
triggerClassName="w-full justify-start rounded-md"
214+
/>
215+
```
216+
217+
在 `CreateDefaultTaskDialog` 的现有 `TaskSkillSelector` 调用中同样增加 `enableWheelScrollFallback`,并保持 `TaskInput` 调用不传该属性。
218+
219+
- [x] **Step 4: 运行回归测试**
220+
221+
Run: `node --test test/task-skill-selector-wheel.test.mjs`
222+
223+
Expected: 3 tests pass。
224+
225+
- [x] **Step 5: 验证与 PR #874 的三方自动合并**
226+
227+
Run: 使用当前 `main`、PR #874 head 和包含 Issue #875 工作树补丁的临时 tree 执行 `git merge-tree`。
228+
229+
Expected: `task-skill-selector.tsx` 自动合并,不输出 conflict markers。
230+
231+
- [x] **Step 6: 运行静态检查和 online 构建**
232+
233+
Run: `pnpm lint`
234+
235+
Expected: exit code 0。
236+
237+
Run: `pnpm run build:online`
238+
239+
Expected: TypeScript 和 Vite 构建成功,exit code 0。
240+
241+
- [x] **Step 7: 构建三个变更的临时组合版本**
242+
243+
在独立临时 worktree 中以最新 `main` 为基础,依次应用 PR #871、PR #874 和当前 Issue #875 补丁。该 worktree 只用于组合验证,不改变当前功能分支、索引和工作树。
244+
245+
Run: `pnpm lint` 和 `pnpm run build:online`
246+
247+
Expected: 三个变更自动组合,静态检查和构建均以 exit code 0 完成。
248+
249+
- [x] **Step 8: 启动组合版本 SaaS 预览并人工验证**
250+
251+
Run: `TARGET=https://monkeycode-ai.com pnpm dev:online`
252+
253+
Expected: 黄色提示复制功能正常;技能搜索、空结果和关闭清理正常;“创建任务”和“启动 AI 任务”技能列表支持鼠标滚轮和触控板纵向滚动;任务首页行为保持正常。
254+
255+
- [x] **Step 9: 用户验收后提交**
256+
257+
```bash
258+
git add docs/superpowers/specs/2026-07-15-task-skill-wheel-scroll-design.md docs/superpowers/plans/2026-07-15-task-skill-wheel-scroll.md frontend/test/task-skill-selector-wheel.test.mjs frontend/src/components/console/task/task-skill-selector.tsx frontend/src/components/console/project/start-develop-task-dialog.tsx frontend/src/components/console/task/create-default-task-dialog.tsx
259+
git commit -m "修复启动任务技能列表滚轮失效"
260+
```
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# 启动任务技能列表滚轮修复设计
2+
3+
## 背景
4+
5+
项目“创建任务”和“启动 AI 任务”弹窗中的技能选择器使用 Radix Dialog 和 Popover。Dialog 以模态方式锁定页面滚动,PopoverContent 通过 Portal 挂载到 DialogContent 之外。鼠标位于技能列表时,Dialog 的滚动锁会拦截滚轮事件,导致超出可视区域的技能无法通过鼠标滚轮浏览。
6+
7+
关联 Issue:#875
8+
9+
## 范围
10+
11+
- 修复“创建任务”和“启动 AI 任务”弹窗中的技能列表滚轮行为。
12+
- 保持任务首页技能选择器的现有交互。
13+
- 保持技能搜索、标签筛选、勾选状态、关闭行为和移动端布局。
14+
15+
## 方案
16+
17+
为共享 `TaskSkillSelector` 增加可选的 `enableWheelScrollFallback` 属性。在 `CreateDefaultTaskDialog``StartDevelopTaskDialog` 中启用该属性,任务首页的 `TaskInput` 保持默认行为。回退逻辑通过回调 ref 跟踪稳定的 `PopoverContent` DOM 节点,并在该节点注册 `{ passive: false }` 的原生 `wheel` 监听器。事件到达容器后,从事件目标定位当前活动的 `TabsContent` 技能列表并主动更新其 `scrollTop`。浮层关闭、节点变化或组件卸载时清理监听器。
18+
19+
滚动距离根据浏览器 `WheelEvent.deltaMode` 换算:像素模式直接使用 `deltaY`,行模式按 16 像素换算,页模式按列表可视高度换算。处理器仅拦截来自活动技能列表的纵向滚轮事件;横向手势和带 `Ctrl` 的缩放手势保持浏览器默认处理。纵向滚动调用 `preventDefault()`,避免浏览器原生滚动和手动滚动叠加。
20+
21+
该方案保留现有非模态 Popover 的焦点、关闭和外部点击行为,同时将回退逻辑限制在受影响入口。滚轮实现不修改 `skillTags.map()``TabsContent` 的渲染结构,因此与 PR #874 的搜索过滤及空结果展示可以按任意顺序自动合并。原 `modal` 方案移除,因为外层 Dialog 的 `RemoveScroll` 监听器仍会先取消 Portal 区域的原生滚动。
22+
23+
## 交互
24+
25+
1. 用户打开“创建任务”或“启动 AI 任务”弹窗并展开技能选择器。
26+
2. 用户打开技能选择器。
27+
3. 初始“全部”分类完成 Portal 挂载后,容器监听器立即可用,无需先切换分类。
28+
4. 鼠标或触控板位于技能列表区域时,列表可以纵向滚动。
29+
5. 点击模型、主机等其他控件时,技能列表关闭且目标控件响应本次点击。
30+
6. 关闭技能选择器后,焦点行为保持现状。
31+
7. 搜索、标签切换和技能选择继续使用现有状态逻辑。
32+
33+
## 验证
34+
35+
- 在“创建任务”和“启动 AI 任务”弹窗中验证鼠标滚轮和触控板纵向滚动。
36+
- 验证初次打开并保持“全部”分类时可直接滚动。
37+
- 验证切换到“前端”等分类后仍可滚动,切回“全部”后继续可滚动。
38+
- 验证像素、行和页三种滚轮增量换算。
39+
- 验证列表打开后首次点击其他表单控件仍可直接操作。
40+
- 验证搜索、标签切换、技能勾选及关闭后搜索词清理。
41+
- 验证纯横向触控板手势和 `Ctrl+滚轮` 不触发技能列表纵向滚动。
42+
- 验证任务首页 `TaskInput` 技能选择器行为保持正常。
43+
- 基于 PR #871、PR #874 和 Issue #875 构建临时组合版本,统一验证复制、搜索和滚轮功能。
44+
- 使用三方合并检查确认 PR #874 与 Issue #875 无文本冲突。
45+
- 执行 `pnpm lint`
46+
- 执行 `pnpm run build:online`

frontend/src/components/console/project/start-develop-task-dialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ export default function StartDevelopTaskDialog({
454454
<div className="space-y-2">
455455
<Label>{t("taskWorkflow.skill.label")}</Label>
456456
<TaskSkillSelector
457+
enableWheelScrollFallback
457458
open={skillPopoverOpen}
458459
onOpenChange={setSkillPopoverOpen}
459460
selectedSkills={selectedSkill}

frontend/src/components/console/task/create-default-task-dialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ export default function CreateDefaultTaskDialog({
573573
</DropdownMenu>
574574

575575
<TaskSkillSelector
576+
enableWheelScrollFallback
576577
open={skillPopoverOpen}
577578
onOpenChange={setSkillPopoverOpen}
578579
selectedSkills={selectedSkill}

0 commit comments

Comments
 (0)