Skip to content

Commit 8254ed9

Browse files
author
s39-dev
committed
feat(webui): three per-turn diff endpoints and their client wrapper
Ticket 80's plan B, batch 2. The runtime already records what each turn changed — real per-file line counts, and whether that turn can still be changed — under the msg_id of the turn's last assistant message. Batch 1 put that id on the wire; nothing read it. These endpoints read it. GET /api/turn-diff POST /api/turn-diff/revert POST /api/turn-diff/revert is a real write: the engine verifies each file against the snapshot it captured and then writes or removes it. POST /api/turn-diff/reapply puts the turn back. Zero new backend. All three are thin projections over `applications.session.diff`, reached through the same catalogue host routes/plugins.js uses. The route reaches exactly that leaf: the `applications` tree also carries `session.lifecycle`, which can delete a session, so handing out the handle would hand out deletion too. A tripwire asserts the only member ever dereferenced is `session`, and that the route never mentions a gate of its own — auth, rate limiting and read-only mode are inherited from lib/gates.js. The engine's selector degrades to "this session's latest turn" when it is given no id, so a request that lost the coordinate would answer with ANOTHER turn's counts and its undo button would rewrite that turn's files. A request without `assistantMessageId` therefore never reaches the engine at all: it answers `{ok:true,turnDiff:null}`. An id the engine does not know is a different case and is passed through verbatim, so the engine — not the route — decides it, and its empty answer stays empty. A malformed sessionId is a 400. The tests assert the call count, not just the body, because only the call count catches a "helpful" default. The engine's 409 for a non-latest turn is passed through with its own sentence, because that sentence is the only thing that says why an undo did nothing. What a successful mutation does NOT do is guess at refreshing the browser. The response carries the status and the canUndo / canReapply flips; the client's re-reads are a separate slice.
1 parent d1c4f5e commit 8254ed9

13 files changed

Lines changed: 1674 additions & 42 deletions

File tree

‎docs/webui.md‎

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,53 +1532,118 @@ re-arranging the DOM.
15321532
- Neither the `data-active` forced-open rule nor a group's own folding
15331533
semantics change: a collapse intent never overrides forced open.
15341534

1535-
## The 「已编辑 N 个文件」 card (ticket 77)
1535+
## The 「已编辑 N 个文件」 card (tickets 77 + 83)
15361536

15371537
The last block of a turn is the edited-files card — the one in the
1538-
desktop screenshot `06-browser-tree-tasks-review.jpg`. It mounts in
1539-
`chat.tsx` after `MessageActions`, matching the reference's mount point
1540-
at `other-minimax-code/.../AssistantBody.tsx:340-380`. The header reuses
1541-
the existing `activity.editedFiles` key; **no new i18n key was added**.
1542-
The activity-group summary and this card are the same sentence, so they
1543-
have to stay the same sentence.
1538+
desktop screenshot `06-browser-tree-tasks-review.jpg`. Ticket 77 mounted
1539+
it once, at the end of the transcript, from the file paths the turn's
1540+
edit tools named, and recorded in this document that the desktop's line
1541+
counts and undo button were unreachable. **Both became reachable in
1542+
ticket 83**, and this section now describes what the card does with
1543+
them.
1544+
1545+
The header reuses the existing `activity.editedFiles` key for its
1546+
sentence; ticket 83 added keys only for the new affordances (undo, redo,
1547+
the failure copy). The activity-group summary and this card are the same
1548+
sentence, so they have to stay the same sentence.
15441549

15451550
### Where the data comes from
15461551

1547-
The three-tier fallback chain from ticket 61 §4.2 does not resolve at
1548-
tier 1. The card lands on tier 2, but with better semantics than tier 2
1549-
was sketched for: the source is not "the workspace's git changes" but
1550-
**the file paths the turn's edit tools actually named in the transcript**,
1551-
which is turn-scoped by construction.
1552+
Two sources, in strict order of authority, and never merged:
15521553

1553-
| Tier | Source | Finding |
1554+
| Source | What it is | What it can prove |
15541555
|---|---|---|
1555-
| 1 — turn changeset | `SessionDiffApplication.getTurnDiff` | **Unreachable.** `local-runtime-v2` really does have it (`application/session/diff-application.ts:51`, backed by v1's `LocalTurnDiffCapability` over `SqliteLocalTurnDiffStore`, carrying per-file `additions`/`deletions` and `undo` snapshots). The blocker is addressing, not existence: the transcript is a flat array of glyph-prefixed strings carrying neither `turnId` nor `assistantMessageId`, and `findTurnDiffRecord` (`local-runtime/src/turns/diff-api.ts:209-220`) falls back to `latestForSession` when no selector is supplied — so every historical assistant message would render the same "last turn" card. Separately, `server/lib/runtime-host.js:176-178` forwards only `cliService` / `apiHost` / `controller` and drops the `application` the v2 host returns. Closing either gap means touching `local-runtime-v2`, which ticket 61 §6 risk 1 already reserves for a separate decision. |
1556-
| 2 — the turn's file paths | the transcript's `file-edit` tool blocks | **Adopted.** `collectEditedFiles` (`webapp/lib/edited-files.ts`) walks every `RenderUnit`, reads `toolPaths` off the blocks `isFileEditTool` accepts, normalises separators, de-duplicates, and keeps first-seen order. |
1557-
| 3 — nothing changed | the list is empty | **The card is not rendered at all** (A1). With no file to name there is no empty shell and no 「已编辑 0 个文件」 placeholder. |
1556+
| **The engine's record** | `GET /api/turn-diff` with the turn's `assistantMessageId` | The real per-file `+N` / `-N`, the real file list (which includes edits made through tools whose arguments name no path), and the engine's own `canUndo` / `canReapply` |
1557+
| **The transcript scan** | `collectTurnEditedFiles` (`webapp/lib/edited-files.ts`) over the turn's `RenderUnit`s | Only the file paths a `file-edit` tool named. No counts, no gates. |
1558+
1559+
The engine's list REPLACES the scan rather than merging with it. A merge
1560+
would double-count a file the two name differently, and the counts would
1561+
then sit on the wrong row. When there is no record — a session recorded
1562+
before the turn coordinate shipped, a legacy transcript read, the `exec`
1563+
transport — the scan is the whole story and the card is exactly what
1564+
ticket 77 shipped.
1565+
1566+
### The turn coordinate
1567+
1568+
The engine persists a turn's record under the msg_id of that turn's
1569+
**last assistant message** (`local-runtime-v2/.../turn-outcome.ts` reads
1570+
the last agent *message* response; a turn carries more than one id on
1571+
the wire, one per message segment). The ACP transport already delivered
1572+
that id; the server used to drop it.
1573+
1574+
- **Live**: `acp.mjs#prompt` keeps the last `agent_message_chunk`
1575+
`messageId`; `mcode-acp.js#finalize` writes `§§ turn_msg=<id>` next to
1576+
the `§§ processed_duration=Nms` marker it already wrote. The `§§`
1577+
family is the established convention for server-written per-turn
1578+
metadata — third reuse, not a new grammar.
1579+
- **Restored**: `server/lib/transcript.js`'s v2 probe now selects the
1580+
`turn_id` and `msg_id` columns the message table already carries, and
1581+
synthesises the same marker from the last assistant row of each turn.
1582+
Switching away and back gives an existing session its coordinates
1583+
without asking the engine anything.
1584+
1585+
`decodeTranscript` consumes the marker and hangs the id on the turn's
1586+
LAST assistant block. A transcript without the marker decodes exactly as
1587+
it did before — every session older than the marker has to keep
1588+
rendering, and a decoder that assumed the marker would throw on all of
1589+
them.
15581590

15591591
### What each desktop element maps to
15601592

15611593
| Desktop element | Here | Why |
15621594
|---|---|---|
15631595
| Header glyph | **Present** | The `pencil` icon from the existing `icons.tsx` set. |
1564-
| 「已编辑 N 个文件」 | **Present** | N counts **distinct files**, not edit calls — a file edited five times is still one file. |
1565-
| Green `+N` / red `-N` | **Absent** | The transcript carries no line statistics and there is no reachable turn-scoped source. Drawing one would be a fabricated number. |
1566-
| 「撤销」 (undo) | **Absent** | No turn-scoped revert endpoint exists; the engine's `rewindTurnDiff` is a side effect of rewinding history, not a per-card undo. A dead button is worse than no button. |
1567-
| 「Review」 | **Absent — the file rows replace it** | A file row opens the real preview through the existing `onOpenFile` chain (red line 4). That is the affordance this codebase can actually honour. |
1596+
| 「已编辑 N 个文件」 | **Present** | N counts **distinct files**, not edit calls. With a record it is the engine's own file count; without one it is the count of paths the scan could name. |
1597+
| Green `+N` / red `-N` | **Present, with a record** | The engine's own per-turn counts, summed in the header. **Absent without one** — a card showing `+0 -0` would say "this file did not change", which is a different and false claim. A zero side is not drawn as `+0` / `-0` either. |
1598+
| 「撤销」 (undo) | **Present, gated on `canUndo`** | Calls `POST /api/turn-diff/revert`, which rewrites real workspace files. |
1599+
| 「重做」 (redo) | **Present, gated on `canReapply`** | Calls `POST /api/turn-diff/reapply`. Appears independently of undo: a reverted turn offers redo and no undo. |
1600+
| 「Review」 | **Absent — the file rows replace it** | A file row opens the real preview through the existing `onOpenFile` chain (red line 4). |
15681601
| File row: type icon + name | **Present** | A `file` icon plus the path's trailing segment; the full path rides on `title` and `data-file-path`. |
1569-
| Per-row added/deleted | **Absent** | Same reason as the header badge. |
1602+
| Per-row added/deleted | **Present, with a record** | The engine's per-file counts, not the header total repeated. |
15701603
| Collapse (3 rows, then expand) | **Present** | A pure client-side state machine, `reduceEditedFilesCardState`; the toggle appears only past three rows. |
15711604

1605+
The two buttons are **not** greyed out when they cannot act; they are
1606+
absent. Only the latest turn diff can be changed — the engine answers
1607+
`canUndo:false` before the user clicks, and 409 `TURN_DIFF_CONFLICT`
1608+
if they click anyway — so a disabled button would promise an action the
1609+
engine has already refused. The card never re-derives "is this the last
1610+
turn?" from the transcript; the whole coordinate system exists because
1611+
that inference cannot be made safely.
1612+
1613+
### What a successful undo refreshes
1614+
1615+
A revert rewrites files the browser is already showing, so five things
1616+
move, in this order:
1617+
1618+
1. the server's session-tree cache (`invalidateSessionTree()`);
1619+
2. a `session-tree-changed` broadcast (the sidebar);
1620+
3. a `workspace-files-changed` broadcast — a new named SSE frame, no
1621+
payload, the only signal the webui has that files on disk moved;
1622+
4. on that frame, the files tree re-reads every directory it has open
1623+
and the git panel re-reads status and branches;
1624+
5. on the same frame, the open file preview re-reads through the refresh
1625+
path — scroll position preserved, a deleted file shown as a named
1626+
banner rather than a blank pane, and a **dirty draft left alone**,
1627+
because its baseline is what a later save conflict-checks against.
1628+
15721629
### Contract notes
15731630

15741631
- The collapse state is **not** persisted in `localStorage`: a reload
15751632
returns to collapsed. It is derived state, not a user preference, so
15761633
red line 3 is untouched.
1577-
- The card derives from the **full** `units` list, not the virtualised
1634+
- Cards are derived from the **full** `units` list, not the virtualised
15781635
`visibleUnits` window — otherwise scrolling past 200 units would make
1579-
it blink in and out.
1636+
them blink in and out. Each turn's card sits at **its own** turn's last
1637+
unit, so a three-turn session reads as three cards; the final turn's
1638+
card stays at the transcript tail, after the message-action row, which
1639+
is where ticket 77 put the single card and where the desktop puts it.
15801640
- With no `onOpenFile` wired, a file row degrades to plain text rather
15811641
than to a button that does nothing.
1642+
- The route exposes `applications.session.diff` and nothing else; the
1643+
`applications` tree also carries `session.lifecycle`, which can delete
1644+
a session.
1645+
- `previewState` is declared by the protocol and is always `undefined` on
1646+
this path. Nothing renders it.
15821647

15831648
## Loading states: transcript skeleton and streaming indicator (ticket U8)
15841649

‎docs/webui.zh-CN.md‎

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,44 +1103,92 @@ loading-states 相同:让 SSR 渲染测试可以脱离 `chat.tsx` 的 `@/` 别
11031103
- `data-active` 强制展开规则与活动组自身的折叠语义都不因此改变:
11041104
收起意图不会盖过强制展开。
11051105

1106-
## 「已编辑 N 个文件」汇总卡(工单 77)
1106+
## 「已编辑 N 个文件」汇总卡(工单 77 + 83)
11071107

11081108
回合的最后一块是这张卡(桌面版 `06-browser-tree-tasks-review.jpg`
1109-
里的那张),挂载点在 `chat.tsx` 的 `MessageActions` 之后——与
1110-
参照仓 `AssistantBody.tsx:340-380` 的位置一致。文案复用既有的
1111-
`activity.editedFiles` 键,**没有新增 i18n 键**:活动组摘要和这
1112-
张卡说的是同一句话,就该是同一个键。
1109+
里的那张)。工单 77 把它挂在转录尾部,数据源是编辑类工具点名的文件
1110+
路径,并在本文档里记录了桌面版的行数统计与撤销按钮「不可达」。**两者
1111+
在工单 83 变得可达**,本节现在描述卡片拿到它们之后的行为。
11131112

1114-
### 数据从哪来(三级降级链,审计结论)
1113+
表头那句文案仍复用 `activity.editedFiles` 键;工单 83 只为新增的
1114+
能力(撤销、重做、失败文案)新增了键。活动组摘要和这张卡说的是同一
1115+
句话,就该是同一个键。
11151116

1116-
工单 61 §4.2 定的降级链,第一级审计**没通过**。卡片落在第 2 级,
1117-
但语义比第 2 级原设想更好:数据源不是「工作区 git 变更」而是
1118-
**转录里编辑类工具真实点名的文件路径**,天然是回合级的。
1117+
### 数据从哪来
11191118

1120-
| 级 | 数据源 | 审计结果 |
1119+
两个来源,权威性有严格先后,且**从不合并**:
1120+
1121+
| 来源 | 是什么 | 能证明什么 |
11211122
|---|---|---|
1122-
| 1 回合级 changeset | `SessionDiffApplication.getTurnDiff` | **不可达**。`local-runtime-v2` 确实有这套能力(`application/session/diff-application.ts:51`,由 v1 的 `LocalTurnDiffCapability` 落到 `SqliteLocalTurnDiffStore`,逐文件带 `additions`/`deletions` 与 `undo` 快照),但 webui 拿不到回合坐标:转录是字形前缀的纯字符串数组,不携带 `turnId` 也不携带 `assistantMessageId`,而 `findTurnDiffRecord`(`local-runtime/src/turns/diff-api.ts:209-220`)在没有选择器时退化成 `latestForSession`——挂在每条历史助手消息下都是同一张「最后一回合」的卡。另外 webui 侧 `server/lib/runtime-host.js:176-178` 只回传 `cliService`/`apiHost`/`controller`,把 v2 宿主返回的 `application` 丢掉了。补齐要动 `local-runtime-v2`,工单 61 第六节风险 1 已声明需另行拍板。 |
1123-
| 2 回合级文件路径 | 转录的 `file-edit` 工具块 | **采用**。`collectEditedFiles`(`webapp/lib/edited-files.ts`)走遍 `RenderUnit[]`,对 `isFileEditTool` 为真的工具块取 `toolPaths`,按分隔符归一去重,保留首次出现顺序。 |
1124-
| 3 无变更 | 列表为空 | **整卡不渲染**(A1)。没有文件可点名时不画空壳、不画「已编辑 0 个文件」。 |
1123+
| **引擎记录** | 带该回合 `assistantMessageId` 的 `GET /api/turn-diff` | 真实的逐文件 `+N` / `-N`、真实的文件清单(含参数里没写路径的工具改的文件),以及引擎自己的 `canUndo` / `canReapply` |
1124+
| **转录扫描** | `collectTurnEditedFiles`(`webapp/lib/edited-files.ts`)在该回合的 `RenderUnit[]` 上扫描 | 只有 `file-edit` 工具点名的文件路径。没有行数,没有门控 |
1125+
1126+
引擎清单**取代**扫描,而不是与之合并。合并会让两个来源命名不同的
1127+
同一文件被数两次,计数随后落在错误的行上。没有记录时——回合坐标
1128+
上线之前录的会话、legacy 转录回读、`exec` 传输——扫描就是全部,卡片
1129+
也就是工单 77 交付的那张。
1130+
1131+
### 回合坐标
1132+
1133+
引擎把一回合的记录存在该回合**最后一条助手消息**的 msg_id 下
1134+
(`local-runtime-v2/.../turn-outcome.ts` 读的是最后一条 agent
1135+
*message* 应答;线上一个回合不止一个 id,每个消息段一个)。ACP
1136+
传输早就把这个 id 送到了服务端,此前被丢掉。
1137+
1138+
- **实时路径**:`acp.mjs#prompt` 留住最后一个 `agent_message_chunk`
1139+
的 `messageId`;`mcode-acp.js#finalize` 在它本来就写的
1140+
`§§ processed_duration=Nms` 旁边写下 `§§ turn_msg=<id>`。`§§`
1141+
这一族是服务端回合元数据的既有约定——这是第三次复用,不是新语法。
1142+
- **回读路径**:`server/lib/transcript.js` 的 v2 探针现在 SELECT
1143+
消息表本来就有的 `turn_id` 与 `msg_id` 两列,用每个回合最后一条
1144+
助手行合成同一款标记。切走再切回,老会话就拿到了坐标,不必向引擎
1145+
要任何东西。
1146+
1147+
`decodeTranscript` 吞掉标记,把 id 挂在该回合**最后**一个助手块上。
1148+
没有标记的转录解码结果与之前逐块相同——早于标记的每个会话都得继续
1149+
正常渲染,而假设标记一定存在的解码器会把它们全部解崩。
11251150

11261151
### 桌面版每个元素对应什么
11271152

11281153
| 桌面版元素 | 我们的对应 | 说明 |
11291154
|---|---|---|
11301155
| 头部图标 | **有** | `pencil` 图标,沿用 `icons.tsx` 既有图标集。 |
1131-
| 「已编辑 N 个文件」 | **有** | N 是**去重后的文件数**,不是编辑调用次数——同一文件改五次仍算一个文件。 |
1132-
| 绿色 `+N` / 红色 `-N` 增删统计 | **没有** | 转录不含任何行数统计,本仓也没有可达的回合级行数来源。画一个就是编数字。 |
1133-
| 「撤销」按钮 | **没有** | 没有回合级回滚端点;引擎的 `rewindTurnDiff` 是历史回退的副作用,不是按卡片撤销。不画死按钮。 |
1134-
| 「Review」按钮 | **没有(以文件行替代)** | 点文件行走既有的 `onOpenFile` 链路开文件预览(红线 4),这是本仓真能兑现的动作。 |
1156+
| 「已编辑 N 个文件」 | **有** | N 是**去重后的文件数**。有记录时是引擎自己的文件数;没有时是扫描能点名的数量。 |
1157+
| 绿色 `+N` / 红色 `-N` | **有记录时有** | 引擎自己的逐回合计数,表头求和。**没有记录时不画**——画 `+0 -0` 等于宣称「这个文件没变」,那是另一句且为假的话。某一边为 0 时也不画 `+0` / `-0`。 |
1158+
| 「撤销」按钮 | **有,由 `canUndo` 门控** | 调 `POST /api/turn-diff/revert`,真的会改写工作区文件。 |
1159+
| 「重做」按钮 | **有,由 `canReapply` 门控** | 调 `POST /api/turn-diff/reapply`。与撤销独立出现:已撤销的回合给重做、不给撤销。 |
1160+
| 「Review」按钮 | **没有(以文件行替代)** | 点文件行走既有的 `onOpenFile` 链路开文件预览(红线 4)。 |
11351161
| 文件行:类型图标 + 文件名 | **有** | `file` 图标 + 路径末段;完整路径挂在 `title` 与 `data-file-path` 上。 |
1136-
| 文件行右侧增删数 | **没有** | 同增删统计。 |
1162+
| 文件行右侧增删数 | **有记录时有** | 是该文件自己的计数,不是把表头总数重复一遍。 |
11371163
| 折叠(先 3 行 + 展开) | **有** | 纯客户端状态机 `reduceEditedFilesCardState`,超过 3 行才出现切换。 |
11381164

1165+
两个按钮在不能动作时是**不画**,不是灰掉。只有最新回合的 diff 能改
1166+
——引擎会在用户点击之前就回 `canUndo:false`,真点了也回 409
1167+
`TURN_DIFF_CONFLICT`——所以一个禁用的按钮等于承诺一件引擎已经拒绝
1168+
的事。卡片从不从转录反推「这是不是最后一回合」;整套坐标方案存在的
1169+
理由就是这个推断做不安全。
1170+
1171+
### 一次成功撤销要刷新什么
1172+
1173+
撤销改写的是浏览器正在显示的文件,因此有五步,按此顺序:
1174+
1175+
1. 服务端的会话树缓存(`invalidateSessionTree()`);
1176+
2. 一次 `session-tree-changed` 广播(侧边栏);
1177+
3. 一次 `workspace-files-changed` 广播——新的命名 SSE 帧,无载荷,
1178+
是 webui 唯一能得知「磁盘上的文件在自己脚下变了」的信号;
1179+
4. 收到该帧后,文件树重读所有已展开的目录,git 面板重读 status 与
1180+
branches;
1181+
5. 同一帧触发,文件预览走刷新通道重读当前文件——保留滚动位置;文件
1182+
已删时给具名提示条而不是空白面板;**有未保存草稿则不动**,因为那份
1183+
草稿的基线正是后续保存要冲突校验的对象。
1184+
11391185
### 契约要点
11401186

11411187
- 折叠状态**不进 `localStorage`**:刷新后回到折叠态。这是派生状态而非用户偏好,红线 3 不受影响。
1142-
- 卡片从**完整** `units` 派生,不是虚拟窗口 `visibleUnits`——否则超过 200 个单元后滚动会让它忽隐忽现。
1188+
- 卡片从**完整** `units` 派生,不是虚拟窗口 `visibleUnits`——否则超过 200 个单元后滚动会让它们忽隐忽现。每个回合的卡挂在**该回合**最后一个单元后,所以三个回合读起来就是三张卡;最后一个回合的卡仍在转录尾部、在消息操作行之后——那是工单 77 放单张卡的位置,也是桌面版的位置。
11431189
- 未接 `onOpenFile` 时文件行降级为纯文本,**不退化成点了没反应的按钮**。
1190+
- 路由只透出 `applications.session.diff`,不多给一个成员——同一棵 `applications` 树上还有能删会话的 `session.lifecycle`。
1191+
- `previewState` 协议里有定义,这条链路上恒为 `undefined`,任何地方都不渲染它。
11441192

11451193
## 加载态:会话骨架屏与流式活动指示(工单 U8)
11461194

0 commit comments

Comments
 (0)