Skip to content

Commit 7539265

Browse files
fengzhi09s39-dev
andauthored
feat(webui): the turn now ends with the files it edited (#121)
* feat(webui): the turn now ends with the files it edited The desktop closes a turn with an "Edited N files" card: per-file rows, green/red line counts, an undo and a Review action. Ours had a count in the activity summary and nothing else. Investigated what can back the full shape. The transcript carries the paths a file-edit tool named but no line counts and no turn coordinate — turnId and assistantMessageId appear nowhere. local-runtime-v2 does expose getTurnDiff/revertTurnDiff/reapplyTurnDiff, but the webui transcript cannot select a turn, so the lookup degrades to latestForSession, and runtime-host drops the application handle on the floor. The working-tree git diff is cross-turn and carries no counts either. So the card is built on the paths the transcript does have: naturally per-turn, no new endpoint, reusing activity.editedFiles. Eight of the twelve desktop elements ship — the line counts, the undo action and the inline per-file counts have no real backing and are absent rather than drawn dead. Review is replaced by opening the file in the preview, which is a real action and a red line. Mutation-verified 5/5 red, plus four absence assertions that fail if the line counts or the undo button come back. * docs(webui): the edited-files card, in both languages The 77 card ships eight of the twelve desktop elements; the line counts, the undo action and the inline per-file counts are documented as absent because nothing in the transcript can back them. --------- Co-authored-by: s39-dev <s39-dev@local>
1 parent f4c68e3 commit 7539265

8 files changed

Lines changed: 680 additions & 1 deletion

File tree

‎docs/webui.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,6 +1531,54 @@ re-arranging the DOM.
15311531
- Neither the `data-active` forced-open rule nor a group's own folding
15321532
semantics change: a collapse intent never overrides forced open.
15331533

1534+
## The 「已编辑 N 个文件」 card (ticket 77)
1535+
1536+
The last block of a turn is the edited-files card — the one in the
1537+
desktop screenshot `06-browser-tree-tasks-review.jpg`. It mounts in
1538+
`chat.tsx` after `MessageActions`, matching the reference's mount point
1539+
at `other-minimax-code/.../AssistantBody.tsx:340-380`. The header reuses
1540+
the existing `activity.editedFiles` key; **no new i18n key was added**.
1541+
The activity-group summary and this card are the same sentence, so they
1542+
have to stay the same sentence.
1543+
1544+
### Where the data comes from
1545+
1546+
The three-tier fallback chain from ticket 61 §4.2 does not resolve at
1547+
tier 1. The card lands on tier 2, but with better semantics than tier 2
1548+
was sketched for: the source is not "the workspace's git changes" but
1549+
**the file paths the turn's edit tools actually named in the transcript**,
1550+
which is turn-scoped by construction.
1551+
1552+
| Tier | Source | Finding |
1553+
|---|---|---|
1554+
| 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. |
1555+
| 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. |
1556+
| 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. |
1557+
1558+
### What each desktop element maps to
1559+
1560+
| Desktop element | Here | Why |
1561+
|---|---|---|
1562+
| Header glyph | **Present** | The `pencil` icon from the existing `icons.tsx` set. |
1563+
| 「已编辑 N 个文件」 | **Present** | N counts **distinct files**, not edit calls — a file edited five times is still one file. |
1564+
| 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. |
1565+
| 「撤销」 (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. |
1566+
| 「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. |
1567+
| 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`. |
1568+
| Per-row added/deleted | **Absent** | Same reason as the header badge. |
1569+
| Collapse (3 rows, then expand) | **Present** | A pure client-side state machine, `reduceEditedFilesCardState`; the toggle appears only past three rows. |
1570+
1571+
### Contract notes
1572+
1573+
- The collapse state is **not** persisted in `localStorage`: a reload
1574+
returns to collapsed. It is derived state, not a user preference, so
1575+
red line 3 is untouched.
1576+
- The card derives from the **full** `units` list, not the virtualised
1577+
`visibleUnits` window — otherwise scrolling past 200 units would make
1578+
it blink in and out.
1579+
- With no `onOpenFile` wired, a file row degrades to plain text rather
1580+
than to a button that does nothing.
1581+
15341582
## Loading states: transcript skeleton and streaming indicator (ticket U8)
15351583

15361584
The two waiting windows on the conversation surface have distinct treatments,

‎docs/webui.zh-CN.md‎

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

1105+
## 「已编辑 N 个文件」汇总卡(工单 77)
1106+
1107+
回合的最后一块是这张卡(桌面版 `06-browser-tree-tasks-review.jpg`
1108+
里的那张),挂载点在 `chat.tsx` 的 `MessageActions` 之后——与
1109+
参照仓 `AssistantBody.tsx:340-380` 的位置一致。文案复用既有的
1110+
`activity.editedFiles` 键,**没有新增 i18n 键**:活动组摘要和这
1111+
张卡说的是同一句话,就该是同一个键。
1112+
1113+
### 数据从哪来(三级降级链,审计结论)
1114+
1115+
工单 61 §4.2 定的降级链,第一级审计**没通过**。卡片落在第 2 级,
1116+
但语义比第 2 级原设想更好:数据源不是「工作区 git 变更」而是
1117+
**转录里编辑类工具真实点名的文件路径**,天然是回合级的。
1118+
1119+
| 级 | 数据源 | 审计结果 |
1120+
|---|---|---|
1121+
| 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 已声明需另行拍板。 |
1122+
| 2 回合级文件路径 | 转录的 `file-edit` 工具块 | **采用**。`collectEditedFiles`(`webapp/lib/edited-files.ts`)走遍 `RenderUnit[]`,对 `isFileEditTool` 为真的工具块取 `toolPaths`,按分隔符归一去重,保留首次出现顺序。 |
1123+
| 3 无变更 | 列表为空 | **整卡不渲染**(A1)。没有文件可点名时不画空壳、不画「已编辑 0 个文件」。 |
1124+
1125+
### 桌面版每个元素对应什么
1126+
1127+
| 桌面版元素 | 我们的对应 | 说明 |
1128+
|---|---|---|
1129+
| 头部图标 | **有** | `pencil` 图标,沿用 `icons.tsx` 既有图标集。 |
1130+
| 「已编辑 N 个文件」 | **有** | N 是**去重后的文件数**,不是编辑调用次数——同一文件改五次仍算一个文件。 |
1131+
| 绿色 `+N` / 红色 `-N` 增删统计 | **没有** | 转录不含任何行数统计,本仓也没有可达的回合级行数来源。画一个就是编数字。 |
1132+
| 「撤销」按钮 | **没有** | 没有回合级回滚端点;引擎的 `rewindTurnDiff` 是历史回退的副作用,不是按卡片撤销。不画死按钮。 |
1133+
| 「Review」按钮 | **没有(以文件行替代)** | 点文件行走既有的 `onOpenFile` 链路开文件预览(红线 4),这是本仓真能兑现的动作。 |
1134+
| 文件行:类型图标 + 文件名 | **有** | `file` 图标 + 路径末段;完整路径挂在 `title` 与 `data-file-path` 上。 |
1135+
| 文件行右侧增删数 | **没有** | 同增删统计。 |
1136+
| 折叠(先 3 行 + 展开) | **有** | 纯客户端状态机 `reduceEditedFilesCardState`,超过 3 行才出现切换。 |
1137+
1138+
### 契约要点
1139+
1140+
- 折叠状态**不进 `localStorage`**:刷新后回到折叠态。这是派生状态而非用户偏好,红线 3 不受影响。
1141+
- 卡片从**完整** `units` 派生,不是虚拟窗口 `visibleUnits`——否则超过 200 个单元后滚动会让它忽隐忽现。
1142+
- 未接 `onOpenFile` 时文件行降级为纯文本,**不退化成点了没反应的按钮**。
1143+
11051144
## 加载态:会话骨架屏与流式活动指示(工单 U8)
11061145

11071146
会话界面有两类等待,各自有明确的呈现方式,都不是一个孤零零的转圈:

‎packages/webui/webapp/components/chat.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ import { Icon } from "./icons";
1717
import { useChatVirtualization } from "./chat-virtual-list";
1818
import { ActivityPulse, isSessionActivityActive } from "./loading-states";
1919
import { ActivityGroup, TurnProcessDisclosure, assignActivityBlockKeys } from "./activity-group";
20-
import { computeTurnLayout, computeTurnStatsByUnit, summarizeTurn, type TurnStats } from "@/lib/turn-stats";
20+
import { collectEditedFiles } from "@/lib/edited-files";
21+
import { EditedFilesCard } from "./edited-files-card";
22+
import {
23+
computeTurnLayout,
24+
computeTurnStatsByUnit,
25+
summarizeTurn,
26+
type TurnStats,
27+
} from "@/lib/turn-stats";
2128
import { useSessionContext } from "@/lib/store";
2229
import { capToastReducer } from "@/lib/cap-toast";
2330
import { readScrollPosition as readPersistedScroll } from "@/lib/persist";
@@ -215,6 +222,13 @@ export function Chat({
215222
});
216223
}, []);
217224

225+
// Ticket 77 — the 「已编辑 N 个文件」 card's data. Derived from the FULL
226+
// `units` list, not the virtualized window: the card is a turn-level footer,
227+
// so it must not appear or vanish as the user scrolls the window past 200
228+
// units. `collectEditedFiles` returns only paths this turn's edit tools
229+
// actually named, and an empty list means the card is not rendered at all.
230+
const editedFiles = useMemo(() => collectEditedFiles(units), [units]);
231+
218232
// Windowed rendering: above VIRTUAL_LIST_THRESHOLD (200) units we slice the
219233
// transcript to a visible window around the user's scroll position. The hook
220234
// owns the scroll/resize listeners; `stuck` uses the 16 px threshold so the
@@ -440,6 +454,12 @@ export function Chat({
440454
/>
441455
</div>
442456
) : null}
457+
{/* Ticket 77 (G3) — the turn's last block, matching where the
458+
desktop puts the 「已编辑 N 个文件」 card. Derived from the full
459+
unit list, so it is stable under virtual scrolling. */}
460+
{editedFiles.length > 0 ? (
461+
<EditedFilesCard files={editedFiles} t={t} onOpenFile={onOpenFile} />
462+
) : null}
443463
</div>
444464
</div>
445465

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// webapp/components/edited-files-card.tsx — the 「已编辑 N 个文件」 summary card.
2+
//
3+
// Ticket 77 (G3 of ticket 61). Structure follows the reference implementation
4+
// (`other-minimax-code/.../DiffCard.tsx`): a header row and a file list, driven
5+
// by a pure state machine kept beside the component so the transitions are
6+
// testable without a DOM.
7+
//
8+
// The reference's card is fed by `getTurnDiff` / `revertTurnDiff` /
9+
// `reapplyTurnDiff` — a per-turn changeset the runtime owns. This repo has no
10+
// reachable equivalent (the audit is in
11+
// `.tickets/webui-parity/77-edited-files-card.md`), so the card renders only
12+
// what `collectEditedFiles` can prove: the paths the turn's edit tools named.
13+
// Consequences, all deliberate and none cosmetic:
14+
//
15+
// * no green/red added-deleted badges — no line counts exist to show;
16+
// * no 撤销 button — there is no per-turn revert to call, and a button that
17+
// does nothing is worse than an absent one;
18+
// * no Review button — clicking a file row opens the real file preview
19+
// instead, which is the affordance this codebase can actually honour.
20+
//
21+
// The header reuses the existing `activity.editedFiles` key rather than adding
22+
// a near-duplicate: the activity-group summary and this card are the same
23+
// sentence, so they must stay the same sentence.
24+
25+
"use client";
26+
27+
import { useState } from "react";
28+
import { Icon } from "./icons";
29+
import type { EditedFile } from "../lib/edited-files";
30+
import type { MessageKey } from "../lib/i18n";
31+
32+
/** Rows shown before the card needs the expand toggle. */
33+
export const COLLAPSED_FILE_ROWS = 3;
34+
35+
export interface EditedFilesCardState {
36+
/** Whether the file list is showing all rows rather than the first few. */
37+
readonly expanded: boolean;
38+
}
39+
40+
export type EditedFilesCardAction = { readonly type: "toggle-expanded" };
41+
42+
export function initialEditedFilesCardState(): EditedFilesCardState {
43+
return { expanded: false };
44+
}
45+
46+
export function reduceEditedFilesCardState(
47+
state: EditedFilesCardState,
48+
action: EditedFilesCardAction,
49+
): EditedFilesCardState {
50+
switch (action.type) {
51+
case "toggle-expanded":
52+
return { ...state, expanded: !state.expanded };
53+
}
54+
}
55+
56+
export function EditedFilesCard({
57+
files,
58+
t,
59+
onOpenFile,
60+
}: {
61+
readonly files: readonly EditedFile[];
62+
readonly t: (key: MessageKey) => string;
63+
readonly onOpenFile?: (path: string) => void;
64+
}) {
65+
const [state, setState] = useState<EditedFilesCardState>(initialEditedFilesCardState);
66+
// A1: with no files there is nothing truthful to say, so the card is not in
67+
// the tree at all — not an empty shell, not a 「0 个文件」 placeholder.
68+
if (files.length === 0) return null;
69+
70+
const shown = state.expanded ? files : files.slice(0, COLLAPSED_FILE_ROWS);
71+
const hidden = files.length - shown.length;
72+
const title = t("activity.editedFiles").replace("{{count}}", String(files.length));
73+
74+
return (
75+
<section
76+
data-testid="edited-files-card"
77+
data-edited-files-count={files.length}
78+
aria-label={title}
79+
className="mb-3 rounded-[10px] border border-border_default bg-bg_default_primary px-3 py-2.5"
80+
>
81+
<header className="flex items-center gap-2">
82+
<span className="text-icon_default_tertiary" data-testid="edited-files-card-icon">
83+
<Icon name="pencil" size={16} />
84+
</span>
85+
<span className="text-body-small-strong text-text_default_primary">{title}</span>
86+
</header>
87+
<ul className="mt-1.5 flex flex-col" data-testid="edited-files-card-list">
88+
{shown.map((file) => (
89+
<li
90+
key={file.path}
91+
data-testid="edited-files-card-row"
92+
data-file-path={file.path}
93+
className="flex min-h-[28px] items-center gap-2"
94+
>
95+
<span className="text-icon_default_tertiary">
96+
<Icon name="file" size={14} />
97+
</span>
98+
{onOpenFile ? (
99+
<button
100+
type="button"
101+
onClick={() => onOpenFile(file.path)}
102+
title={file.path}
103+
data-testid="edited-files-card-file"
104+
className="truncate text-left font-mono text-caption-small text-text_default_primary underline-offset-2 hover:underline"
105+
>
106+
{file.name}
107+
</button>
108+
) : (
109+
<span
110+
title={file.path}
111+
data-testid="edited-files-card-file"
112+
className="truncate font-mono text-caption-small text-text_default_primary"
113+
>
114+
{file.name}
115+
</span>
116+
)}
117+
</li>
118+
))}
119+
</ul>
120+
{hidden > 0 || state.expanded ? (
121+
<button
122+
type="button"
123+
data-testid="edited-files-card-toggle"
124+
data-expanded={state.expanded}
125+
onClick={() => setState((current) => reduceEditedFilesCardState(current, { type: "toggle-expanded" }))}
126+
className="mt-1 flex items-center gap-1 text-caption-small text-text_default_tertiary transition-colors hover:text-text_default_primary"
127+
>
128+
<Icon name={state.expanded ? "chevronUp" : "chevronDown"} size={12} />
129+
{state.expanded ? t("activity.collapse") : t("activity.expand")}
130+
</button>
131+
) : null}
132+
</section>
133+
);
134+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// webapp/lib/edited-files.ts — which files this turn's edit tools touched.
2+
//
3+
// Ticket 77 (G3 of ticket 61). The desktop's 「已编辑 N 个文件」 card is fed by
4+
// a turn-level changeset the runtime owns. This repo has no such contract, so
5+
// this module derives the one thing the transcript *does* carry truthfully: the
6+
// file paths the turn's `file-edit` tool calls named, de-duplicated.
7+
//
8+
// What this deliberately does NOT produce, and why (see
9+
// `.tickets/webui-parity/77-edited-files-card.md` for the full audit):
10+
//
11+
// * per-file added/deleted line counts — the transcript records no line
12+
// statistics, and `LocalTurnFileChangeCaptureService` (the runtime's real
13+
// changeset producer) is not reachable per turn from webui;
14+
// * an undo affordance — no per-turn revert endpoint exists.
15+
//
16+
// Guessing either would put invented numbers in front of the user, so callers
17+
// get `null` / an empty list instead and render nothing.
18+
19+
import { isFileEditTool, type RenderUnit } from "./transcript";
20+
21+
/** One file the turn's edit tools named, in the transcript's own terms. */
22+
export interface EditedFile {
23+
/** Workspace-normalised path, exactly as the decoder produced it. */
24+
readonly path: string;
25+
/** The trailing path segment — what the desktop card's file row shows. */
26+
readonly name: string;
27+
}
28+
29+
/** The turn's edited files, in first-seen order. Empty means "nothing to say". */
30+
export type EditedFiles = readonly EditedFile[];
31+
32+
/**
33+
* Distinct files written by this turn's edit tool calls, in first-seen order.
34+
*
35+
* Only `activity` units carry tool blocks; plain `block` units are prose and
36+
* are skipped. An edit tool that named no path (a `bash` heredoc, an unparsed
37+
* arg shape) contributes nothing rather than a placeholder row — the count in
38+
* the card header is a count of files we can actually name.
39+
*/
40+
export function collectEditedFiles(units: readonly RenderUnit[]): EditedFiles {
41+
const files: EditedFile[] = [];
42+
const seen = new Set<string>();
43+
for (const unit of units) {
44+
if (unit.kind !== "activity") continue;
45+
for (const block of unit.blocks) {
46+
if (block.role !== "tool" || !isFileEditTool(block.toolName)) continue;
47+
for (const path of block.toolPaths ?? []) {
48+
const key = dedupeKey(path);
49+
if (!key || seen.has(key)) continue;
50+
seen.add(key);
51+
files.push({ path, name: basenameOf(path) });
52+
}
53+
}
54+
}
55+
return files;
56+
}
57+
58+
/**
59+
* Identity for "the same file named twice".
60+
*
61+
* Separators are folded so a Windows-style `a\b.ts` and an `a/b.ts` do not read
62+
* as two files. Case is left alone on purpose: macOS and Linux are
63+
* case-sensitive, and folding case there would merge two genuinely distinct
64+
* files into one row.
65+
*/
66+
function dedupeKey(path: string): string {
67+
return path.trim().replace(/\\/g, "/");
68+
}
69+
70+
/** The trailing path segment, with both separator styles honoured. */
71+
export function basenameOf(path: string): string {
72+
const normalised = dedupeKey(path);
73+
const index = normalised.lastIndexOf("/");
74+
return index === -1 ? normalised : normalised.slice(index + 1);
75+
}

‎packages/webui/webapp/lib/transcript.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,19 @@ export function iconByName(toolName: string | undefined): SummaryIconType {
651651
return TOOL_ICON[toolName.trim().toLowerCase()] ?? "tool";
652652
}
653653

654+
/**
655+
* Whether `TOOL_CATEGORY` reads this tool name as a file edit.
656+
*
657+
* Exported so the edited-files card (ticket 77) and the activity summary cannot
658+
* drift into two different notions of "an edit": both ask this one predicate.
659+
* A name outside the table is *not* an edit, which is the safe direction — the
660+
* summary under-states what ran rather than claiming files changed when none
661+
* did.
662+
*/
663+
export function isFileEditTool(toolName: string | undefined): boolean {
664+
return TOOL_CATEGORY[(toolName ?? "tool").trim().toLowerCase()] === "file-edit";
665+
}
666+
654667
/**
655668
* Count the activity in a run of blocks (see `groupActivity`).
656669
*

0 commit comments

Comments
 (0)