主题
Trace Viewer Web
本课交付结果
你将交付 React <TraceViewer api>:处理 loading、empty、error、运行列表和详情选择;详情以具名 region 呈现 tool、diff、test 面板,并可独立执行 Vite 生产构建。
岗位问题
Trace 只有被人快速读懂才有价值。界面不能在空数据时白屏、请求失败时无限 loading,也不能只靠颜色区分面板。可访问名称同时改善键盘/读屏体验和自动化测试稳定性。
前置检查
前置知识快照
先通过 Query API:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 27组件只依赖注入 API,不直接读取文件或总项目,因此 Lab 可用纯 fixture 测试。
第 27 课已经把运行摘要与事件详情变成稳定 contract,Viewer 不再重复计算状态、token 或耗时。开始前要把浏览器边界说清:组件可以请求数据、维护交互状态和呈现证据,不能导入 Node 文件系统、拼 Trace 路径或重新解释原始 JSONL。依赖注入的 TraceApi 让测试用内存 fixture,让生产接 HTTP 或本地 bridge,也让权限与存储留在可信边界。
一个合格的异步页面至少有初始、加载、空、成功、失败五种状态;选中详情又有自己的空闲、加载、成功、失败与过期响应问题。把它们只塞进几个互相独立的布尔值会产生不可能组合,例如同时 loading 和 empty。教学实现保持简洁,工程化版本应使用判别联合或 reducer 显式描述状态机。
原理拆解
mermaid
stateDiagram-v2
[*] --> Loading
Loading --> Empty: listRuns 返回空
Loading --> Ready: listRuns 返回运行
Loading --> Error: listRuns 失败
Ready --> DetailLoading: 选择运行
DetailLoading --> Ready: 详情成功
DetailLoading --> Error: 详情失败
Ready --> Ready: 切换运行并忽略旧响应首次 effect 调 listRuns,维护 loading/error/runs;卸载后用 active flag 阻止过期更新。点击运行后调用 getRun。每个面板生成可读 label:工具 read_file、Diff a.ts、测试 vitest。
代码实验
bash
pnpm --dir bootcamps/coding-agent/labs/28-trace-viewer/starter test
pnpm --dir bootcamps/coding-agent/labs/28-trace-viewer/solution test
pnpm --dir bootcamps/coding-agent/labs/28-trace-viewer/solution buildStarter 应声明 实现 Trace 详情时间线;Solution 应通过 5 项 jsdom 测试并生成 dist/。
失败实现
ts
export function UnsafeViewer() {
const [runs, setRuns] = useState<RunSummary[]>();
useEffect(() => { api.listRuns().then(setRuns); }, []);
return <div>{runs?.map((run) =>
<div onClick={() => api.getRun(run.id)}>{run.id}</div>)}</div>;
}失败实现没有 loading、empty 或 error,Promise 拒绝会成为未处理异常;可点击 div 没有键盘语义和可访问名称;effect 隐式捕获全局 api,切换实例后仍读旧数据;详情请求结果甚至没有进入界面。它可能在开发者的单一路径上“能点”,却无法作为其他人可靠使用的诊断工具。
基础实现至少将 API 作为属性、在卸载后阻止更新,并使用真实按钮:
ts
useEffect(() => {
let active = true;
api.listRuns()
.then((value) => { if (active) setRuns(value); })
.catch((reason) => { if (active) setError(toMessage(reason)); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [api]);
<button type="button" onClick={() => void select(run.id)}>
{run.id} · {run.status}
</button>关键实现讲解
loading 使用 role=status,错误使用 role=alert,运行选择用真实 button,详情标题为 运行 run-1。region 的 aria-label 包含面板类型和目标,测试无需依赖 CSS class 或 DOM 层级。
可访问性不是给测试添加几个 role,而是让视觉结构、键盘操作和辅助技术共享同一语义。role=status 适合非紧急加载更新,读屏不会打断用户;role=alert 用于需要立即注意的失败。运行列表用 nav 加具名标签,项目用 button,自然获得 Tab 焦点、Enter 与 Space 激活和禁用语义。若用 div 模拟,需要重建整套交互且容易遗漏。
empty 与 error 必须互斥。空数组表示 API 成功且确实没有记录;请求失败表示不知道有没有记录。若 catch 后把 runs 设为空,界面会把离线或权限错误显示成“暂无运行”,用户可能误判 Trace 丢失。状态设计应保留数据事实与获取状态的区别,错误消息经过安全转换,不能直接渲染服务端堆栈或绝对路径。
列表请求的 active flag 解决组件卸载后的状态更新,却不完全解决选择竞争。用户先点慢的 run-a,再点快的 run-b,a 最后返回会覆盖 b。详情选择应维护 requestId 或 AbortController:每次选择递增令牌并取消前一个请求,响应只有令牌仍是最新才提交。按钮的 aria-current 或 aria-pressed 标出当前选择,详情标题也与之保持一致。
ts
const requestRef = useRef(0);
async function select(id: string) {
const request = ++requestRef.current;
setDetailState({ kind: "loading", runId: id });
try {
const value = await api.getRun(id);
if (request === requestRef.current) {
setDetailState({ kind: "ready", value });
}
} catch (error) {
if (request === requestRef.current) {
setDetailState({ kind: "error", message: toMessage(error) });
}
}
}AbortController 比只忽略响应更节省服务器与网络资源,但 TraceApi 要接受 signal,后端也要真正响应取消。无论能否取消,客户端令牌都是最后一道防线。取消不是错误,不应显示红色 alert;只有当前请求真实失败才进入详情错误状态。列表已有内容时,详情失败也不应把整个列表清空,用户还可以选择其他运行。
面板类型是信息架构。tool 回答调用了什么与结果如何,diff 回答工作区改变了什么,test 回答证据是否通过。统一使用具名 region,让用户能在长详情中快速导航。label 需要同时包含类别与目标,例如“工具 read_file”“Diff src/a.ts”“测试 vitest”;只写“详情”会让三个区域无法区分,只用颜色或图标则对读屏与灰度环境失效。
标题层级也要连续:页面 h1、选中运行 h2、面板 h3。不要根据视觉大小跳到 h4,也不要用加粗 div 冒充标题。自动化测试按 heading role 与 accessible name 查询,比按类名 .panel-title 更接近用户体验;重构 CSS 或 DOM 包装时测试仍稳定,只有语义破坏才失败。
pre 适合 Diff、命令输出和结构化日志,因为要保留空格与换行;但内容可能非常长。Viewer 应限制预览高度与字节,提供“显示更多”或下载受控 artifact,不应一次把数兆文本插进 DOM。截断提示必须可读并说明原始大小,复制按钮应默认复制当前脱敏内容。任何富文本渲染都要防止 HTML 注入,最安全是让 React 以文本节点输出。
Diff 面板需要不只靠红绿表达删除与新增。每行加 +、- 前缀,使用可读标签,颜色满足对比度;对折叠上下文提供展开按钮与文件路径。路径来自后端规范化结果,界面显示时仍应限制长度并处理双向文本控制字符。点击文件若要跳转编辑器,需要可信 URI 构造,不能把 Trace 中任意字符串直接交给系统打开。
工具面板应区分请求、成功、失败、超时和审批阻断,并显示序号、耗时与截断状态。默认折叠敏感参数,只展示工具名和安全摘要;用户展开仍只能看到 Query 已脱敏的 data。Viewer 不是脱敏边界,但要坚持纵深防御:禁止通过调试组件、错误 toast 或下载功能绕过 API 返回的安全投影。
测试面板呈现命令、退出码、通过数和失败摘要。绿色“passed”不能替代真实输出引用,红色失败要保留最小复现信息。命令可能包含环境变量,不应原样回显秘密;Query 或后端先提供经过脱敏的 argv。Viewer 可把 exitCode 与 timedOut 作为明确文本和图标,避免只有颜色。
时间线顺序来自 Query 返回的 sequence,不在组件内按 timestamp 重排。面板 key 不能只用数组 index,因为增量刷新插入事件时 React 可能复用错误节点;使用 runId、sequence 或稳定 panelId。Lab fixture 没暴露 sequence,所以组合 type、title、index 足够演示,生产 contract 应提供唯一事件标识。
列表刷新需要保留选择。若当前 run 仍存在,更新 summary 而不清空详情;若已被保留策略删除,显示明确提示并回到列表。轮询频率随页面可见性调整,浏览器后台暂停;实时流断线后指数退避。不要每秒重新下载所有详情,Query 可提供 finalSequence 或 ETag,让客户端只在事实变化时更新。
URL 深链接让排障可分享,例如查询参数携带 runId 与选中面板。读取 URL 后仍要通过 TraceApi 请求和授权,不能假定 URL 合法。切换选择使用 history API 更新地址,浏览器前进后退恢复界面。错误 URL 显示“运行不存在或无权访问”,不要泄露内部资源是否存在,具体策略由权限模型决定。
loading 体验分首次与详情两种。首次没有任何内容时显示页面级 status;切换详情时保留列表与旧详情或显示局部 skeleton,避免整页闪烁。按钮在对应请求中可以禁用并设置 aria-busy,但不要阻止用户选择别的运行。慢请求超过阈值可提示“仍在加载”,超时后提供重试而不是无限旋转。
重试必须重复安全读取,不应触发 Agent 或工具副作用。TraceApi 是只读 contract,因此可自动重试暂时网络错误;四百类输入和权限错误无需重试。重试次数、退避与最后错误对用户可见,测试用可注入计时器。不要在 render 中发请求,否则每次状态变化都会再次调用。
错误边界用于捕获渲染异常,不能替代 Promise catch。某个畸形 panel 导致渲染失败时,可以隔离该面板并报告安全摘要,列表仍可用;全局 ErrorBoundary 提供刷新入口。上报前脱敏 React component stack 与 props,绝不把整个 detail 作为监控附件。
响应式设计优先保持证据可读。窄屏时列表可变成抽屉,详情占主区域;代码和 Diff 水平滚动,不强制折行破坏结构。桌面宽屏可用左右分栏,但 DOM 顺序仍应先列表后详情,键盘焦点逻辑自然。焦点在选择成功后是否移动到详情标题要谨慎:用户连续浏览列表时自动抢焦点会打断操作,可提供明确“查看详情”行为或按产品测试决定。
大列表可虚拟化,但虚拟化不能让键盘与读屏失去上下文。先实现分页或搜索,确认性能瓶颈后再用支持可访问性的虚拟列表。列表项显示 runId、状态与开始时间,状态使用文本;相同 ID 仍由稳定 key 区分。排序由 Query 决定,前端只呈现,避免本地 locale 排序改变证据次序。
测试分为组件状态、交互竞争、可访问语义和生产构建四层。jsdom 覆盖 loading、empty、error、选择和三个具名 region;可控 Promise 测 a/b 响应倒序;axe 或同类工具扫描常见规则;真实浏览器验证焦点、滚动与深链接。最后执行 Vite build,证明不是只在测试转换器下可编译。
ts
it("keeps the latest selected run", async () => {
const a = deferred<RunDetail>();
const b = deferred<RunDetail>();
render(<TraceViewer api={apiReturning({ a, b })} />);
fireEvent.click(await screen.findByRole("button", { name: /run-a/ }));
fireEvent.click(screen.getByRole("button", { name: /run-b/ }));
b.resolve(detailB); a.resolve(detailA);
expect(await screen.findByRole("heading", { name: "运行 run-b" })).toBeTruthy();
});测试查询应优先 role 与 name,其次使用用户可见文本,最后才考虑 test id。这样测试表达“用户能找到什么”,而不是“实现用了什么类名”。同理,不应对整页 DOM 做大快照;快照容易在无意义结构变化时噪声巨大,却遗漏按钮是否可操作、错误是否被播报。
生产构建是交付的一部分。Vite 应输出 HTML 与带哈希的 JS 资产,base 与部署子路径匹配;运行时 API 地址由受控配置提供,不把文件系统路径或密钥打包进客户端。构建后可扫描产物搜索秘密种子和绝对工作区路径,再用静态服务器做一次加载与路由冒烟。
Viewer 的验收场景应覆盖一条完整调查链:打开页面听到或看到加载状态;列表成功后选择完成 run;查看工具输入摘要、Diff 与测试证据;切换到失败 run 仍保持列表;断开 API 显示可重试 alert;空存储明确显示空状态。所有动作仅通过键盘也能完成,并且浏览器控制台没有未处理拒绝。
面板数据最好使用判别联合而不是任意对象。type: tool 要求工具名、状态与安全内容,type: diff 要求路径和补丁,type: test 要求命令与结果。TypeScript 的穷尽 switch 会在新增 panel 类型时提醒实现 label 与渲染分支;若全部塞进 title/content,后续很容易把命令输出误当 HTML 或遗漏可访问名称。
对详情做本地搜索时,只索引当前已获授权且已脱敏的文本,关闭页面后不写入持久浏览器缓存。搜索结果高亮不能使用未经净化的 innerHTML,可将匹配区间拆成普通文本节点与 mark。复制、下载和分享都是新的数据出口,需要与查看详情相同或更严格的权限和审计,而不是默认开启。
浏览器缓存策略应谨慎。Trace 可能包含敏感工作内容,HTTP 响应可使用 no-store;若产品为了离线调查启用 IndexedDB,应加密、设置过期和显式清除入口,并说明设备风险。Service Worker 不应无意缓存详情接口。静态 JS 资产可以长期缓存,数据响应与它们采用不同策略。
国际化也影响可访问名称与测试。不要在组件内部拼散落字符串;把“工具”“Diff”“测试”“运行”等词集中到消息层,同时保持 label 公式稳定。日期通过明确 locale 格式化,但排序仍由 Query 提供的规范 startedAt 决定。测试断言语义角色与关键名称,不依赖完整本地化句子,可降低文案调整噪声。
状态徽章需要文本映射:completed 对应已完成,failed 对应失败,blocked 对应待授权,running 对应进行中,unknown 对应未知。未知状态不能默认显示成功色;新增后端枚举在旧前端中应安全降级并保留原值摘要。颜色令牌满足正常文本与背景对比度,图标设置为装饰或提供恰当替代文本,避免重复播报。
焦点管理要通过真实用户测试决定。首次加载完成无需自动移动焦点,否则读屏用户可能失去当前位置;提交明确选择后,可以让详情标题获得临时 tabindex 并聚焦,但连续切换时这可能增加操作成本。一个稳妥方案是保持按钮焦点,同时在礼貌 live region 宣布“已加载运行某某”,用户按快捷键跳到主内容。
慢面板渲染可按类型懒加载语法高亮器或 Diff 组件,首屏先展示纯文本证据。动态模块失败时回退 pre,而不是整页崩溃。高亮器版本与主题不能改变实际复制文本,代码行号使用 CSS 或 aria-hidden,避免读屏逐行重复无意义数字。
前端遥测只能记录页面状态和性能摘要,例如列表加载耗时、详情错误码、面板数量,不能把 Trace content、run task 或路径再次发送到第三方分析。遥测边界与 Logger 一样先做数据最小化;用户查看敏感诊断数据时,浏览器扩展和第三方脚本也应尽量减少。
内容安全策略可禁止内联脚本、限制 connect-src 到 Trace API,并禁用不必要的 frame。依赖升级后跑生产构建与安全扫描,确认 bundle 未引入远端字体、分析脚本或 source map 泄露。source map 若部署到公开路径可能包含源码,仅上传到受控错误平台或在内部环境使用。
性能预算可以设为列表首屏脚本体积、首次可交互时间、详情最大 DOM 节点和单面板预览字节。预算失败不一定阻止所有发布,但要有可见报告与例外理由。诊断工具常在系统出问题时使用,网络与机器可能已经承压,因此轻量和降级能力比动画效果更重要。
验收时使用一组真实但无秘密的 fixture,包含长路径、中文、空输出、失败测试、超长 Diff 和未知 panel。分别在窄屏、键盘、减少动画、高对比度与网络离线环境走查。自动化覆盖结构,人工检查信息密度和理解速度,两者证据合并才能说明 Viewer 真正可用。
最终评审应让没有参与开发的人在五分钟内回答:这次运行做了什么、改了哪里、测试是否通过、失败从哪一步开始。如果他必须打开开发者工具、猜颜色或阅读原始 JSON,说明 Viewer 仍只是开发者外壳。好的界面会压缩寻找证据的时间,却不隐藏证据来源与不确定性。可读、可操作、可复核三项必须同时成立。
运行与验证
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 28
pnpm --filter @coding-agent/trace-viewer test生产构建应转换 React 模块并输出 HTML/JS 资产;测试分别验证永不 resolve 的 loading、空数组、API error、选择运行和三类面板。
真实运行输出
text
✓ tests/lab.test.tsx (5 tests)
Test Files 1 passed (1)
Tests 5 passed (5)
vite v6 building for production...
✓ built in 412ms测试与构建缺一不可:前者证明状态和语义,后者证明浏览器交付链。验收记录还应包含键盘走查和慢请求竞争,因为这两类问题不一定被当前五个基础测试完全覆盖。
常见失败与排查
故障案例 1
症状:API 已返回错误,页面却一直显示加载;或先显示空状态再闪出错误。
根因:loading 只在成功分支关闭,多个布尔状态独立更新产生不可能组合。
定位:用永不完成、立即拒绝和空数组三个 Promise fixture,逐帧检查 status、alert 与空提示是否互斥。
修复:在 finally 关闭初始加载,或改用判别联合状态;错误不转换为空数组,局部详情错误不清除成功列表。
故障案例 2
症状:鼠标可打开运行,Tab 找不到项目;读屏只说“按钮”或三个“详情”。
根因:点击 div、图标无名称,region 标签没有包含类型与目标。
定位:只用键盘完成选择,并用 role/name 查询按钮、标题和三个区域;临时关闭 CSS 检查信息是否仍可区分。
修复:使用原生 button、连续标题、具名 nav 与 region,状态同时用文字表达,不把颜色当唯一通道。
故障案例 3
症状:快速点击 run-a 再点 run-b,最后标题却回到 run-a;卸载页面后出现状态更新警告。
根因:异步响应没有请求身份,旧请求完成后无条件覆盖新选择。
定位:用可控 Promise 让响应按相反顺序完成,监视最终 heading 和组件卸载后的 setState。
修复:为每次请求分配递增令牌并尽量 Abort 前项;只有当前令牌提交状态,取消不进入错误 alert。
课后作业
加入事件虚拟列表、URL 深链接、token/耗时图表和 JSON 下载;用 axe 检查可访问性并为慢详情请求添加独立 loading。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 状态 | loading/empty/error 全覆盖 | 白屏或无限加载 |
| 交互 | button 可选择运行 | click div |
| 语义 | 面板具名 region | 依赖颜色和 class |
| 交付 | 测试与生产构建均通过 | 只在 jsdom 可运行 |
总项目增量
总项目包:@coding-agent/trace-viewer
总项目路径:apps/trace-viewer/src/TraceViewer.tsx
总项目验证命令:pnpm --filter @coding-agent/trace-viewer test
Viewer 解释单次运行;下一课建立每次评测都使用全新副本的基准任务。
延伸阅读
- Accessible name computation。
- React async effect cleanup。
- Trace waterfall 与可视化信息密度。
方案对比与工程取舍
服务端渲染可让首屏和权限集中处理,但 Trace 详情高度交互且常在本地工具中使用,独立 React 应用更容易注入 API、渐进加载和测试。选择客户端不代表把存储搬进浏览器;可信后端仍负责读取、脱敏与授权,前端只处理公开 contract。
单一 loading 布尔值代码少,适合基础 Lab;判别联合或 reducer 更适合真实产品,因为它排除互相冲突的状态并自然携带 runId、错误和旧数据。随着加入刷新、分页与请求取消,应尽早迁移状态机,而不是继续堆叠五六个布尔值。
完整渲染实现简单,虚拟化提高大数据性能却增加焦点与辅助技术复杂度。先通过 Query 分页、面板折叠和字节截断降低规模,确认仍有真实卡顿再引入虚拟化。诊断工具的首要目标是证据准确可读,不能为追求炫目图表牺牲语义与可复制文本。
下一课衔接
Viewer 已能解释单次运行,但无法回答某个 Agent 版本是否普遍更好。下一课转向 Benchmark Fixtures:为每个任务加载严格清单、复制全新工作区、成功失败都清理,并用规范树哈希证明源夹具没有被上一轮评测污染。只有输入实验条件可信,后续成功率才有意义。