Agent 执行流
Agent 的执行轨迹——思考、工具调用、命令输出、报错——是一类和聊天不同的列表: 它的节奏由机器决定,一秒十几行很常见,而且随时可能一次性灌进上万行。
值得试的四件事
- 点「开始执行」,日志持续追加,视口贴底跟随;
- 执行中向上滚去看某段历史——下面刷得再快也不会把视口拽走;
- 点「灌入 1 万行」,看状态行里的追加耗时;
- 点「展开全部工具调用」——几百行同时从一行变成九行,这是最容易露白的时刻。
和聊天列表的三个差别
| 聊天 | 执行流 | |
|---|---|---|
| 追加节奏 | 用户驱动,稀疏 | 机器驱动,密集且突发 |
| 单项高度 | 一条消息,一两百到六七百 px(含代码块、工具卡) | 一行,20 px 左右;展开后十倍 |
| 历史 | 往上翻要请求 | 通常已在内存里 |
行高小、数量大,意味着一屏能装几十行,estimatedSize 要给得准—— 给大了首屏会少渲染,滚动时补渲染的抖动更明显。
批量追加,而不是逐行
日志的到达是突发的(一次 SSE 事件可能带来十几行)。逐行提交会把开销全花在重复的 区间计算上,而这些计算的结果在同一帧内是等价的。按帧或按批合并,视觉上没有区别。
tail follow 就是 stickyBottom
initialPosition: 'bottom'
stickyBottom: true
hasMoreTop: false
hasMoreBottom: false关键仍然是「仅在原本贴底时才跟随」:用户往上翻去读某个报错时, 下面的日志再怎么刷都不会把他的视口拽走;他滚回底部,跟随自动恢复。
折叠:只改那一行
工具调用的详情展开后高度会翻十倍。做法和流式输出一致——改状态,让那一行重新渲染, 不需要通知组件高度变了:ResizeObserver 会量到,视口位置由待维持目标自动修正。 展开视口上方的行时,下面的内容不会被顶走。
展开状态要存在数据(或一个独立的集合)里,不能只存在 DOM 上—— 行滚出视口会被回收,滚回来时要能还原。
缩进与分隔用 padding,不用 margin
执行流通常要分两级:思考行是一个阶段的开始,阶段内的步骤缩进并用一条竖线串起来。 实现这类装饰时注意一点——外边距不计入项的测量尺寸。
项的高度由 ResizeObserver 按 border box 量出,margin 在它之外。用 margin 拉开 行间距,每一项都会少算几像素;几千行累积下来,滚动条的位置和实际内容就对不上了。 缩进、行距、分隔线一律用 padding 与 border 表达,它们都在测量范围内。
整段复制
日志尤其需要「选一段发给同事」。配上 copyText 之后,跨屏选区里那些未渲染的行 会从数据补齐,顺便可以在复制时补上行号与类型前缀——那是终端里没有、 但贴到工单里很有用的信息。
切换任务
从一个任务的日志切到另一个,和切换会话是同一件事:尺寸缓存、滚动位置都属于旧数据, reset() 之后再灌新数据、重新落到底部。
Vanilla 写法
const vl = new VirtList(
container,
{
list,
itemKey: 'id',
estimatedSize: 26,
initialPosition: 'bottom',
stickyBottom: true,
hasMoreTop: false,
hasMoreBottom: false,
copyText: (item) =>
`[${KIND_LABEL[item.kind]}] ${String(item.seq).padStart(5, ' ')} ${item.text}` +
(item.ms > 0 ? ` (${toolStatusLabel(item.status)} ${formatMs(item.ms)})` : ''),
renderItem: (item) => { /* ... */ },
},
{ scroll: (e) => { atBottom = e.atEnd; } },
);
// 批量追加
function append(lines) {
list = list.concat(lines);
vl.setList(list);
}折叠单行时直接改那一行的 DOM,不动列表:
function toggleDetail(item, rowEl) {
item.expanded = !item.expanded;
rowEl.querySelector('.detail').hidden = !item.expanded;
}需要一次改很多行(「展开全部」)时才用 refreshItems()——那是低频动作, 重建一次可见项可以接受。只改少数几行时给它传 key(refreshItems([id1, id2])), 其余项的 DOM 就能原样保留。
切换任务
vl.reset();
vl.setList(nextLines);
vl.scrollToBottom();