Skip to content

Agent 执行流

Agent 的执行轨迹——思考、工具调用、命令输出、报错——是一类和聊天不同的列表: 它的节奏由机器决定,一秒十几行很常见,而且随时可能一次性灌进上万行。

微应用尚未挂载。

值得试的四件事

  1. 点「开始执行」,日志持续追加,视口贴底跟随;
  2. 执行中向上滚去看某段历史——下面刷得再快也不会把视口拽走;
  3. 点「灌入 1 万行」,看状态行里的追加耗时;
  4. 点「展开全部工具调用」——几百行同时从一行变成九行,这是最容易露白的时刻。

和聊天列表的三个差别

聊天执行流
追加节奏用户驱动,稀疏机器驱动,密集且突发
单项高度一条消息,一两百到六七百 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 拉开 行间距,每一项都会少算几像素;几千行累积下来,滚动条的位置和实际内容就对不上了。 缩进、行距、分隔线一律用 paddingborder 表达,它们都在测量范围内。

整段复制

日志尤其需要「选一段发给同事」。配上 copyText 之后,跨屏选区里那些未渲染的行 会从数据补齐,顺便可以在复制时补上行号与类型前缀——那是终端里没有、 但贴到工单里很有用的信息。

切换任务

从一个任务的日志切到另一个,和切换会话是同一件事:尺寸缓存、滚动位置都属于旧数据, reset() 之后再灌新数据、重新落到底部。

Vanilla 写法

js
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,不动列表:

js
function toggleDetail(item, rowEl) {
  item.expanded = !item.expanded;
  rowEl.querySelector('.detail').hidden = !item.expanded;
}

需要一次改很多行(「展开全部」)时才用 refreshItems()——那是低频动作, 重建一次可见项可以接受。只改少数几行时给它传 key(refreshItems([id1, id2])), 其余项的 DOM 就能原样保留。

切换任务

js
vl.reset();
vl.setList(nextLines);
vl.scrollToBottom();