长列表、滚动与视口外动作
pnpm tutorial:dev v3-10 --locale zh源码examples/tutorials/v3-10-long-lists-scrolling-and-offscreen-actions/上一章说明了动作可以仍然存在,但在一段时间内不可用,例如弹窗打开期间。另一个更常见的“看起来不存在”来自位置:列表中的记录可能多于面板一次能显示的数量。折叠在可见范围之外的行仍然是应用的一部分;如果模型只收到当前画出来的控件,它会合理地认为这些行已经消失。
本章使用一个包含 24 条待办、放在固定高度滚动面板中的 Todo 列表。业务操作没有改变。完成、编辑和删除仍然是带有稳定 id 与 v-record 的行内动作。新的行为来自运行时如何保留并到达可见范围之外的记录。
这里有三种状态需要分开:
- 一条记录可以存在,但它的字段当前不可见;
- 一个动作可以存在,但它的目标当前不可见;
- 一个动作可以可见,却因为业务条件而不可用。
长列表机制处理的是第二种状态。它没有把三种状态混成一个“没显示”。
长列表改变了什么
假设用户说:“把最后一条待办标记为完成。”最后一条位于列表可见区域的下方。至少有三类做法:
- 只暴露当前可见的行,让模型自己不断滚动,直到目标出现;
- 要求用户把待办描述得足够准确,以便搜索;
- 让所有行都保留可引用的 id,即使当前不在可见区域内;动作命中时再滚到目标。
第一种做法让页面模型依赖滚动位置。同一个请求会因为用户事先滚到哪里而变难或变易。第二种做法在目标已经明确时仍然增加一次发现步骤。第三种做法保持协议稳定:记录是否存在与当前视口无关,可见性只影响动作如何执行。
示例包含 24 条待办,每次显示约 6 行。这个规模足以稳定复现问题,又不会把章节变成性能演示。某一轮快照可能显示 18 个可见行内动作,以及同一区域中另外 54 个存在但未显示的动作。模型仍然能拿到最后一条记录的 id:t24。
让滚动容器保持普通
列表仍然是普通的 Vue 列表。唯一的结构变化是列表自身成为滚动容器:
.tasks {
max-height: 360px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}区域声明写在这个元素上:
<ul
class="tasks"
tabindex="0"
:aria-label="copy.listRegionLabel"
v-scope="{
id: 'task_list',
label: copy.listRegionLabel,
description: copy.listRegionDescription,
role: 'list',
}"
>
<li
v-for="task in visibleTasks"
:key="task.id"
v-record="{
data: task,
fields: {
text: task.text,
status: task.done ? 'completed' : 'open',
priority: priorityLabel(task.priority),
},
}"
>
<input
type="checkbox"
:checked="task.done"
v-tool="{
id: `toggle@${task.id}`,
desc: copy.toggleTaskDesc(task.text, task.done),
kind: 'write',
}"
@change="toggleTask(task.id)"
/>
<!-- 待办文字、编辑和删除控件 -->
</li>
</ul>没有任何一行包含“为 agent 滚动”的特殊处理。行内动作和人使用的动作是同一个:一个带稳定 id 的复选框、一条记录声明,以及普通的变化处理函数。运行时从列表容器和元素的真实位置推导可见性。
v-scope 必须放在真正滚动的元素上。如果区域包在外层卡片上,而实际滚动的是内部某个 overflow-y: auto 元素,自动生成的滚动动作只会把区域带进页面视口,不会滚动内部内容。这是页面结构问题,不是模型问题。
模型实际收到什么
即使一部分行位于折叠线下方,记录和动作仍然可读。这个示例的一段上下文大致如下:
## 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)可见数量取决于浏览器布局与滚动位置。重要的是这些关系,而不是某一次渲染中的具体数字:区域报告“动作比当前显示的多”,折叠后的行内动作报告“其中有 18 条记录在视口外”。
模型还会收到一份有上限的引用 id:
Citation record ids (24 in active regions; offscreen targets are scrolled into view automatically):
t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12,
t13, t14, t15, t16, t17, t18, t19, t20, t21, t22, t23, t24这不是整页数据导出。注册表对“带字段的记录数”和“可引用 id 数”分别设有限制。字段比 id 昂贵得多;模型可以知道 t24 存在,却不必同时拿到每一行的所有字段。如果它需要未显示行的内容,可以通过滚动、搜索或其他已声明的发现路径获得。
执行器先揭示,再执行
模型调用下面这条折叠动作时,协议会把它还原成具体的行内动作:
{
"name": "toggle",
"arguments": { "record": "t24" }
}原始动作 id 是 toggle@t24。目标位于最后一行的复选框,当前在列表可见窗口之外。执行器会在一次动作调用里完成下面几步:
- 把
record还原成已注册的动作 id; - 校验参数,并走正常的权限决策;
- 判断目标不在可见区域内;
- 调用适配器的 reveal 处理,把目标滚进视野;
- 等待页面落定;
- 按滚动后的新状态重新判断可见性;
- 只有目标现在可达时,才真正点击。
当模型已经知道目标就是 t24 时,不需要先调用 pling_scroll_task_list。一次 toggle 调用就够了。额外的滚动是执行细节,不是又一个需要模型决策的工具步骤。在 Vue 适配器中,默认 reveal 处理使用 scrollIntoView,优先把目标放在视口中部;使用自定义视口的应用可以在适配器层替换导航方式,而不改变面向模型的动作。
滚动后重新判断可见性是必要的。一次滚动可能触发懒加载、替换行、关闭固定菜单,也可能根本没有移动嵌套容器。运行时不能假设滚动前的位置关系仍然成立。如果目标仍然不可见,调用会返回 ACTION_UNAVAILABLE 并给出原因,而不是报告一次成功点击。
reveal 不是搜索。它只能把已经存在的目标带到眼前,不能凭空创造应用没有渲染的行。
发现动作由区域生成
一个内容多于可见范围的区域会得到自动生成的探索动作。这个示例中的列表区域生成:
pling_scroll_task_list只有当区域位于页面视口内,并且注册表能判断该区域还有未显示的动作时,这个动作才可用。它的 run 函数按普通浏览器滚动模型,把区域容器滚动约一屏。若区域本身不在页面视口内,还可能出现 pling_reveal_task_list 之类的动作,先把区域带进页面。
这两种动作各自解决不同问题:
| 动作 | 何时使用 |
|---|---|
toggle { record: "t24" } | 模型已经知道目标记录。运行时会自动揭示那一行。 |
pling_scroll_task_list | 模型需要查看后面的行、比较可见内容,或发现一个尚不知道 id 的目标。 |
没有更多内容可揭示时,生成的滚动动作会消失。因此,滚动动作不存在表示“该区域当前没有报告隐藏内容”,而不是“这个页面不支持滚动”。
滚动、落定,再观察
真实页面中的滚动是异步的。平滑滚动、虚拟化渲染和懒加载都需要时间。发现动作或自动 reveal 之后,运行时会等待页面落定,再取下一份快照。模型的下一轮看到的是新的可见集合和新的动作投影,而不是滚动动画进行到一半时的视图。
这也是为什么“滚动成功”不能证明业务操作成功。三类证据彼此不同:
- 滚动动作证明容器移动了;
- 后续快照证明哪些行和动作进入了可见范围;
- 业务动作的返回值与
expect条件证明预期变化是否发生。
写操作仍然经过原有的确认、权限判断和结果检查。滚动不会削弱其中任何一层。
边界:字段裁剪、超长列表与虚拟列表
这套机制服务于“行已经存在于 DOM 中”的长列表。它不会让任意规模的数据变得免费。
注册表分别限制携带字段的记录数和可引用的 record id 数,以避免长列表吃满上下文。模型可能知道 t180 存在,却只看到前三十条记录的字段。需要精确处理数千行的页面,应当提供搜索、筛选或专门的取数动作,而不是让模型从被裁剪的表格里推断。
虚拟列表(virtual list)是另一种情况。它的视口外行可能根本没有 DOM 节点。只要应用没有把那一行物化出来,就没有元素可供滚动,也没有行内动作可按 id 调用。框架不能创造应用尚未渲染的目标。虚拟化应用应当提供明确的业务动作,例如 find_task 或 show_task_at,或者先通过搜索把目标行带进 DOM,再声明对应的行内动作。
折叠区、懒加载页签和分页数据也遵循同一边界。发现动作可以在已经存在的内容中移动,但不能自己完成后端分页。后者是一项业务能力,应当被声明成动作。
仍可能出现的错误
| 现象 | 常见原因 | 应当调整什么 |
|---|---|---|
模型能说出 t24,但动作始终不可用 | 没有声明行内动作,或该记录不在活跃区域。 | 在已渲染的行上同时声明 v-record 与动作,并检查区域活跃状态。 |
| 出现了滚动动作,但列表不动 | v-scope 放在外层,真正滚动的是内部容器。 | 把区域声明放到滚动元素上。 |
| 目标被揭示后立刻失败 | 行被吸顶栏或其他覆盖层挡住,或滚动目标是旧元素。 | 重新检查几何状态与应用的交互边界。 |
| 某条记录不在模型字段中 | 已到达记录的字段上限。 | 先用搜索缩小集合,或暴露可检索的状态/详情视图。 |
| 虚拟列表中的行无法被找到 | 该行还没有 DOM 节点。 | 增加把目标物化或取回的业务动作。 |
| 模型反复滚动而不执行操作 | 没有稳定目标 id,或区域无限报告更多内容。 | 给行稳定 id,并提供有边界的发现动作;避免无界无限滚动。 |
这些失败属于页面接入,不属于模型推理。更长的等待或再写一段提示词,通常修不好缺失的目标,也修不好挂错元素的区域。
验证本章示例
在仓库根目录启动夹具:
pnpm tutorial:dev v3-10打开 http://127.0.0.1:5223/。中文界面使用 pnpm tutorial:dev v3-10 --locale zh,并打开 http://127.0.0.1:5223/?lang=zh。
验证时不要只看最终回答:
- 让助手不要搜索或滚动,直接把最后一条待办标记为完成。列表应滚动到它,复选框应发生变化,过程中不需要模型额外请求一次手动滚动。
- 让助手把待办列表向上滚动一屏,然后查看更早的待办。应使用自动生成的
pling_scroll_task_list,可见集合应随之改变。这一步也会撤销第一次检查造成的自动滚动,因此两项检查可以连续执行。 - 打开调试界面,比较手动滚动前后的页面结构。区域的可见/总数应变化,而 record id 仍然存在。
- 查看折叠后的
toggle工具。说明中应指出多少行在视口外,record参数应指向页面数据中的 id。 - 运行
pnpm tutorial:check v3-10。确定性测试覆盖记录保留、工具折叠、自动 reveal、自动滚动、脚本模型路径和中文文案。
接下来沿用的是什么
长列表不需要第二套动作模型。原有的行内动作仍是入口。运行时保留记录的可引用性,在执行前揭示已知目标,等待页面落定,再重新观察。模型尚不知道目标时,区域提供有边界的发现动作。
下一章从页面能做什么转向页面可以只读地暴露什么状态。两者的边界同样重要:长列表不是把每一行都倒进提示词的理由,状态视图也不是把整个应用 store 交给模型的理由。

