Turn review tab
轮次 review tab
The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user which files a turn changed and by how many lines, but not what changed. A row opened the file's current content in the Sidebar, or in a desktop application, which shows neither the turn's edit nor the state before it. Files git does not cover — ignored files, files outside the repository, and every file-tool edit in a working directory wit
English
Problem
The changed-files card tells the user which files a turn changed and by how many lines, but not what changed. A row opened the file's current content in the Sidebar, or in a desktop application, which shows neither the turn's edit nor the state before it. Files git does not cover — ignored files, files outside the repository, and every file-tool edit in a working directory without a repository — had only the hunks the file tools persist with their results: partial context, counts summed over repeated edits, and no whole-file comparison at all.
Decision
Each card row opens the turn's changes-review tab in the right Sidebar on that file, and the card's header opens it on the first file: one tab per turn, addressed by the viewed Session and the announcing event's sequence, with a file selector in its header that lists every recorded file and shows one file's turn-start and turn-end comparison at a time. The Host workspace-changes recorder serves each comparison through workspaceChanges.diff(sessionId, seq, index, signal); the deliverables plugin registers the tab type, passes the row's file index as a navigation parameter, and reads the summary and the comparisons through authenticated routes. The header's tools switch between the unified view and a side-by-side view, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop, open it in the default application; view and wrap choices are kept per tab. Rows and the header open the review with and without a desktop, so the earlier row-level native open and the header's folder open move into the tab and the card keeps one behavior.
Files git does not cover are compared the way Codex's turn diff tracker compares its apply_patch edits: from whole-file copies, not from hunks. Before a write, edit, or mutating str_replace_editor call runs, the recorder's tools/pre-execute step, which already waits for the baseline snapshot, copies the named file into the Session's temporary directory beside the snapshot objects, once per path per turn; at turn end it copies the path again. The copies are named by the SHA-1 of their bytes, so identical content is stored once, and they need no git. Paths the snapshots cover keep their git counts; every other captured path is listed from a line comparison of its two copies, which counts a repeatedly edited line once and includes a shell edit made after the file-tool edit. The persisted hunks and the argument-derived hunks are no longer read by the recorder.
Two bounds keep the copies and the comparisons small. maxFileBytes caps a copy and a snapshot blob read for a comparison; a larger file is listed with oversized, without counts, and its comparison is refused, which is what makes whole-file copies affordable. diffTimeoutMs bounds the line comparison, the same 100 ms Codex uses; past it the comparison degrades to one hunk that replaces every line, marked coarse, so a pathological file never stalls the turn's record or the tab. Both are Config fields.
The comparison is computed when asked for, on the Host, from the two content sources kept beside the served summary: a path in a snapshot tree, read with ls-tree -l and cat-file blob under the byte cap, or a copy read from disk. Snapshot sides git reported as binary and copies holding a NUL byte serve no lines. The tab renders the hunks with old and new line numbers and no syntax highlighting; the side-by-side view pairs each run of deletions with the additions that follow it row by row. A comparison the Host no longer serves, a failed read, a binary file, and an oversized file each show one line.
Content still lives only as long as the Session in this Host process, as the card decision settled; the comparison shares the card's lifetime, so a conversation reopened after a Host restart has neither.
Alternatives considered
Whole-file before and after text in the file tools' result metadata, as Codex's apply_patch returns, would put every edited file twice into the Session log. The recorder captures the file itself instead, so the log keeps carrying only the turn number.
Storing the copies in memory, as Codex does for its per-turn tracker, grows with every turn because the records live for the Session; the copies go to the Session's temporary directory beside the snapshot objects and are read only when a comparison is asked for.
Storing the copies as git blobs in the private object store would have shared one read path with the snapshots but made the file-tool path depend on git, which the working directories without a repository or without git cannot provide; plain files under the same directory keep that path git-free.
A shadow repository for working directories outside any repository remains deferred; the copies cover the file-tool edits there, which is the part a user can act on.
One tab per file, titled by the file name, was the first implementation; reviewing a turn meant one tab per row. One tab per turn with a file selector keeps the turn's changes together and lets the row still land on its file.
Syntax highlighting in the tab is deferred until the plain view proves insufficient.
Keeping rows opening the current file would have left the comparison one click further away; the current file still opens from the prose links and the Files tab.
Consequences
Every file-tool edit reads and writes its whole file once per turn on the Host, bounded by maxFileBytes, even for paths the snapshots also cover; a Session's temporary directory holds the copies until disposal. Comparison line counts for uncovered files are first-to-last, not sums, and two sides that both exceed the cap list the file as oversized rather than dropping it, since unread content is never known to be unchanged. A comparison costs at most two ls-tree and two cat-file commands plus one bounded line comparison; a comparison that timed out carries every line of both sides, up to twice maxFileBytes, and the tab draws at most 5,000 lines of it. The comparison route serves a listed file's complete text to the browser wherever the Host recorded it — ignored files, repository files above the working directory, and files outside the workspace — where the summary route served only paths and counts and the Sidebar's file previews stay under the workspace root; the user authorized those edits, so this is accepted and recorded in both READMEs.
The WorkspaceChangedFile type gains oversized, WorkspaceChanges gains diff, and two served types, WorkspaceDiffHunk and WorkspaceFileDiff, join the subsystem page. The Session log is unchanged. The changed-files card's rows are labeled as opening the file's changes in both languages, so the recorded Web scenario's golden changed; the same scenario now opens a shell-appended file's comparison from the snapshots and an ignored file's from its copies.
Focused tests cover whole-file capture classification, the timeout degradation, comparisons served from snapshot trees, renames, deleted files, oversized blobs, and copies, the comparison route, the tab's addresses, store, and states, and the card's row wiring.
中文
Problem
改动文件卡片告诉用户一轮改了哪些文件、改了多少行,但没说改了什么。点一行打开的是文件当前内容,在 Sidebar 里或在桌面应用里,既看不到本轮的编辑,也看不到编辑前的状态。git 覆盖不到的文件——被忽略的文件、仓库之外的文件,以及没有仓库的工作目录里的每一次文件工具编辑——只有文件工具随结果持久化的 hunk:上下文不完整,行数按重复编辑累加,完全没有整文件对比。
Decision
卡片的每一行在右侧 Sidebar 打开本轮的 changes-review tab 并选中该文件,卡片头部则在第一个文件上打开它:每轮一个 tab,由当前查看的 Session 和宣告事件的序号定址,头部有一个列出所有记录文件的文件选择器,一次显示一个文件在轮次开始与结束时的对比。Host 侧的 workspace-changes 记录器通过 workspaceChanges.diff(sessionId, seq, index, signal) 提供每个对比;产出物插件注册该 tab 类型,把行的文件下标作为导航参数传入,并通过经过认证的路由读取摘要和对比。头部的工具在单栏和左右视图之间切换、切换自动换行、在 Sidebar 中打开当前整个文件,有 Host 桌面时用默认应用打开它;视图与换行的选择按 tab 保留。有没有桌面,行和头部都打开 review,先前行级别的原生打开和头部的打开文件夹都移进 tab,卡片只保留一种行为。
git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 apply_patch 编辑的方式对比:用整文件副本,不用 hunk。在 write、edit 或有修改作用的 str_replace_editor 调用运行之前,记录器本来就要等待基线快照的 tools/pre-execute 步骤把所指文件复制到 Session 临时目录里快照对象旁边,每轮每个路径一次;轮次结束时再复制一次该路径。副本按其字节的 SHA-1 命名,相同内容只存一份,且不需要 git。快照覆盖到的路径保留 git 的行数;其余每个被捕获的路径由两份副本的逐行对比列出,反复编辑的行只计一次,文件工具编辑之后的 shell 改动也包含在内。记录器不再读取持久化的 hunk 和由参数推出的 hunk。
两个上限让副本和对比保持小。maxFileBytes 限制副本和为对比而读出的快照 blob;更大的文件列出时带 oversized,没有行数,其对比被拒绝,整文件副本因此负担得起。diffTimeoutMs 限制逐行对比,与 Codex 一样是 100 毫秒;超时后退化为一个替换全部行的 hunk,标记 coarse,因此病态的文件从不会拖住本轮记录或 tab。两者都是 Config 字段。
对比在被请求时在 Host 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 ls-tree -l 和 cat-file blob 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;左右视图把每一段删除与紧随其后的新增逐行配对。Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。
内容仍然只在本 Host 进程内随 Session 存活,这是卡片决定已经定下的;对比与卡片同寿命,因此 Host 重启后重新打开的对话两者都没有。
Alternatives considered
把整文件的前后文本放进文件工具的结果元数据,像 Codex 的 apply_patch 返回的那样,会把每个被编辑的文件两次写进 Session 日志。记录器改为自己捕获文件,日志继续只携带轮号。
把副本存在内存里,像 Codex 的每轮 tracker 那样,会随每一轮增长,因为记录要活到 Session 结束;副本放到 Session 临时目录里快照对象旁边,只在请求对比时读取。
把副本存为私有对象库里的 git blob 能和快照共用一条读取路径,但会让文件工具这条路径依赖 git,而没有仓库或没有 git 的工作目录给不了它;同一目录下的普通文件让这条路径与 git 无关。
给仓库之外的工作目录建影子仓库仍然推迟;副本已经覆盖那里的文件工具编辑,这是用户能采取行动的部分。
每个文件一个 tab、以文件名作标题,是第一版实现;审阅一轮意味着每行一个 tab。每轮一个 tab 加文件选择器把一轮的改动放在一起,行仍然能落到自己的文件上。
tab 里的语法高亮推迟到纯文本视图被证明不够用时再做。
保留行打开当前文件会让对比多一次点击才能到达;当前文件仍可从正文链接和文件 tab 打开。
Consequences
每次文件工具编辑在 Host 上每轮读写一次整个文件,受 maxFileBytes 限制,即使快照也覆盖该路径;Session 的临时目录保存副本直到释放。未覆盖文件的对比行数是首尾对比,不是累加;两侧都超过上限的文件列为 oversized 而不是丢掉,因为没读过的内容永远不能认定为没有改动。一次对比最多花费两次 ls-tree、两次 cat-file 和一次有界的逐行对比;超时的对比携带两侧的全部行,最多两倍 maxFileBytes,tab 最多绘制其中 5000 行。对比路由会把所列文件在 Host 上记录时的完整文本送到浏览器,包括被忽略的文件、工作目录之上的仓库文件和工作区外的文件,而摘要路由只送路径和行数,Sidebar 的文件预览也限定在工作区根目录之内;这些编辑是用户授权的,因此接受这一点,并记入两个 README。
WorkspaceChangedFile 类型新增 oversized,WorkspaceChanges 新增 diff,两个提供给客户端的类型 WorkspaceDiffHunk 和 WorkspaceFileDiff 加入子系统页面。Session 日志不变。改动文件卡片的行在两种语言里都标为打开该文件的改动,因此录制的 Web 场景的黄金文件变了;同一场景现在会从快照打开一个 shell 追加过的文件的对比,从副本打开一个被忽略文件的对比。
聚焦测试覆盖整文件捕获的分类、超时退化、从快照树提供的对比、改名、删除的文件、过大的 blob 和副本、对比路由、tab 的地址、store 与各状态,以及卡片行的接线。