长列表、滚动与视口外动作
pnpm tutorial:dev r-10 --locale zh源码examples/tutorials/r-10-long-lists-scrolling-and-offscreen-actions/上一章处理的是动作仍然存在、却因为弹窗或遮挡暂时不可用的情况。还有一种更常见的情形:动作本身没有被遮挡,只是目标位于列表的可见范围之外。
用户说“把最后一条待办标记为完成”时,最后一条可能已经在滚动区域的折叠线下方。它仍然是页面的一部分,但只拿到当前可见控件的模型会认为它不存在。如果页面因此要求模型先反复滚动,再把“滚动到了哪里”当作下一步推理的前提,同一个请求的难度就会随用户原先的滚动位置而变化。
本章的示例包含 24 条待办,列表固定高度并自行滚动。完成、编辑、删除等业务操作没有改变;改变的是页面如何报告记录的存在、模型如何引用视口外目标,以及执行器在调用动作前如何把目标带回可见范围。
先区分存在、可见与可用
长列表里至少有三件不同的事:
- 一条记录存在,但它的字段当前不在可见区域内;
- 一个动作存在,但目标元素当前不在可见区域内;
- 一个动作可见,却因为业务条件暂时不可用。
上一章主要处理第三种状态,本章处理第二种。把它们混成一个“没显示”,会让模型失去恢复所需的信息:它无法判断目标是没有声明、被业务规则禁用,还是只需要滚动。
这套机制的目标不是让所有行同时出现在提示词里,也不是让模型接管滚动。它只保证两件事:已经渲染的记录仍然有稳定身份,已知目标在执行前可以按身份被带回可见范围。
让真正滚动的元素成为区域
列表仍然是普通的 React 列表。唯一的结构变化,是 .tasks 自己成为滚动容器:
.tasks {
max-height: 360px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}区域声明挂在同一个元素上:
const taskListScope = usePlingScope<HTMLUListElement>({
id: "task_list",
label: copy.listRegionLabel,
description: copy.listRegionDescription,
role: "list",
});
return (
<ul
className="tasks"
ref={taskListScope}
tabIndex={0}
aria-label={copy.listRegionLabel}
>
{visibleTasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</ul>
);行本身不需要“为 agent 滚动”的特殊按钮。复选框、编辑和删除仍然是用户会点击的控件;usePlingRecord 声明行数据,usePlingTool 给控件稳定 id。运行时根据元素的实际位置判断它是否可见。
usePlingScope 必须挂在真正处理 overflow-y 的元素上。如果把区域放在外层卡片,而卡片内部另有一个滚动容器,生成的动作只会把外层区域带进浏览器视口,无法移动内部内容。这是页面结构问题,不能靠更长的提示词修正。
tabIndex={0} 让列表本身可以通过键盘聚焦和滚动;aria-label 给这个滚动区域一个可读名称。两者服务于人,也让页面结构与运行时报告的区域名称保持一致。
模型收到的是可引用记录,不是当前截图
可见性只影响动作如何进入当前清单,不改变记录的身份。示例中的一份快照大致如下:
## Page structure (regions)
- main "Tasks" (todo): ...; in viewport
- form "Task composer" (composer): ...; in viewport
- list "Task list" (task_list): ...; in viewport; actions 18/72; 54 more actions not shown, callable: pling_scroll_task_list
- toolbar "View controls" (view_controls): ...; in viewport
## Available actions on this page
- toggle (record) (click / write / region:Tasks / Task list)
Mark “{record}” as completed | use record to select a row
(24 rows: t1 / t2 / t3 / …; 18 rows outside the viewport, auto-scroll on call)不同窗口高度下,可见行数会变化,数字不必逐字复现。需要理解的是这几笔账彼此独立:
recordIds保留活跃列表中的可引用 id,因此t24仍然存在;records携带字段,但受数量上限约束,不等同于整张表;- 当前可见的动作进入
actions; - 位于视口外的行内动作进入
offscreenRecordActions,仍可按 id 调用; - 区域报告可见数量与总数,并给出有边界的发现动作。
正因如此,最后一条待办的复选框不必先被“证明可见”,模型才能处理它。模型知道 t24 是哪一条记录,动作模板也保留了这个目标;执行器会在实际调用时处理位置。
调用之后,先揭示再执行
模型直接选择 t24 并调用完成动作时,执行路径不是立刻点击一个看不见的按钮。它会经历以下顺序:
- 按稳定 id 解析动作与目标元素;
- 发现目标不在可见范围内,调用
reveal(),把元素滚到视口中央附近; - 等待滚动与页面更新落定;
- 重新检查元素是否可见、是否被遮挡、是否处于
inert或禁用状态; - 执行原动作,并用结果和
expect条件判断业务变化是否发生。
这一步是运行时行为,不需要业务组件保存“agent 当前想点第几行”。模型按记录 id 调用,执行器负责到达目标;页面仍然只暴露同一个复选框和同一个业务处理函数。
自动揭示只解决位置,不会自动获得权限。确认、策略、业务校验和结果验证仍然照常执行。如果目标已经被删除,或仍在另一个不可交互的区域内,调用会失败并返回原因,而不是跳过检查点击某个近似元素。
滚动是发现能力,不是业务动作
当模型还不知道具体目标时,它仍需要一个有边界的探索方式。区域进入视口并且报告还有隐藏内容时,usePlingScope 会提供两个生成动作:
pling_reveal_task_list:区域本身不在视口时,把区域带入视口;pling_scroll_task_list:区域已在视口时,在容器内部向前或向后滚动。
滚动动作接受可选的 direction:next 表示向前或向下,prev 表示向后或向上;省略时为 next。当区域已经全部可见,或区域本身不在浏览器视口内,相应动作会被标记为不可用并说明原因。
这与自动揭示解决的是两个不同问题。自动揭示针对“已经知道记录 id,但目标当前不可见”;滚动动作针对“还不知道目标,需要查看列表的其他部分”。前者不应要求模型额外发起一次滚动调用,后者也不应让模型无界地猜测页码。
滚动是否成功,不能证明业务操作是否成功。三类证据需要分开看:
- 滚动动作证明容器发生了移动;
- 后续快照证明哪些记录和动作进入了可见范围;
- 业务动作的返回值和
expect条件证明目标状态确实改变。
边界:字段裁剪、超长列表与虚拟列表
这套机制服务于“行已经在 DOM 中”的长列表,不会让任意规模的数据变得免费。
注册表默认最多列出 30 条带字段的记录,并保留最多 200 个可引用 id。模型可能知道 t180 存在,却看不到它的完整字段。需要精确处理数千行时,页面应提供搜索、筛选或专门取数的业务动作,而不是让模型从被裁剪的表里推断。
虚拟列表是另一种情况。视口外的行可能根本没有 DOM 节点;在应用物化这一行之前,没有元素可供滚动,也没有行内动作可按 id 调用。框架不能创造应用尚未渲染的目标。虚拟化页面应提供 find_task、show_task_at 一类业务动作,或者先通过搜索把目标行放进 DOM,再声明行内动作。
折叠区、懒加载页签和后端分页也遵循同一边界。发现动作只能在已经存在的内容中移动,不能自己完成一次后端请求。分页和远程检索是业务能力,应当被声明成动作。
常见错误
| 现象 | 常见原因 | 应当调整什么 |
|---|---|---|
模型能说出 t24,但动作始终不可用 | 没有声明行内动作,或该记录不在活跃区域。 | 在已渲染的行上同时声明记录和动作,并检查区域活跃状态。 |
| 出现滚动动作,但内部列表不动 | 区域挂在外层,实际滚动的是另一个元素。 | 把 usePlingScope 放到真正处理 overflow 的容器上。 |
| 目标揭示后立刻失败 | 行被吸顶栏或弹窗挡住,或等待期间元素被替换。 | 检查遮挡关系、交互边界和列表的重新渲染方式。 |
| 某条记录不在模型字段中 | 已达到携带字段的记录上限。 | 先用搜索缩小集合,或暴露可检索的状态/详情视图。 |
| 虚拟列表中的行无法被找到 | 该行还没有 DOM 节点。 | 增加把目标物化或取回的业务动作。 |
| 模型反复滚动而不执行操作 | 行没有稳定 id,或区域无限报告更多内容。 | 给行稳定身份,并让发现动作有明确边界。 |
这些失败大多属于页面接入,而不是模型推理。更长的等待或再写一段提示词,通常修不好缺失的目标,也修不好挂错元素的区域。
验证本章示例
本章示例位于 examples/tutorials/r-10-long-lists-scrolling-and-offscreen-actions。在仓库根目录启动中文界面:
pnpm tutorial:dev r-10 --locale zh打开 http://127.0.0.1:5250/?lang=zh。界面中先有 24 条待办,列表一次只能显示其中一部分。
验证时不要只看最终回答:
- 让助手不要搜索或滚动,直接把最后一条待办标记为完成。列表应滚动到它,复选框应发生变化,过程中不需要模型额外请求一次手动滚动。
- 让助手把待办列表向上滚动一屏,再查看更早的待办。应使用生成的
pling_scroll_task_list,可见集合应随之改变。 - 打开调试界面,比较滚动前后的页面结构。可见动作数量应变化,而记录 id 仍然保留。
- 查看折叠后的
toggle动作。说明中应指出视口外的行数,record参数应引用页面数据中的 id。 - 在根目录运行
pnpm tutorial:check r-10。确定性测试覆盖记录保留、动作折叠、自动揭示、生成的滚动动作、回放模型路径,以及中文界面。
接下来
长列表不需要第二套动作模型。原有行内动作仍是入口;运行时保留记录身份,在执行前揭示已知目标,等待页面落定,再重新观察。模型不知道目标时,区域提供有边界的发现动作。
下一章从“页面能做什么”转向“页面可以只读地暴露什么状态”。两者的边界同样重要:长列表不是把每一行都塞进提示词的理由,状态视图也不是把整个应用 store 交给模型的理由。

