Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/acceptance/2026-10-22-diff-inline-feedback/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Diff 行内意见加入聊天草稿

> 历史记录:以下截图基于 2026-10-22 的旧 #445 布局。同步 #445 紧凑工具栏后的
> [2026-10-27 真实前后截图与验证](../2026-10-27-inline-feedback-current/README.md)
> 已更新,#471 当前目标分支为 main。

本次补的是多处代码审阅时反复退出、重新定位的问题。基线 `ed87d1dbdf3bc04c7935ca9c7b4fabfb67524e0e`(PR #445)已有文件、片段和行引用,但点击后立即关闭审阅窗口;没有在行旁输入意见并继续审阅的入口。核对当前 40 个待审 PR 后,没有发现已覆盖此流程的实现。

## 竞品依据与选择

对照的是二者公开文档描述的实际产品流程,不推断未公开的内部存储或组件实现。核对日期:2026-10-22。

| 产品 | 公开流程 | 本次取舍 |
| --- | --- | --- |
| [Claude Code Desktop](https://code.claude.com/docs/en/desktop#review-changes-with-diff-view) | 在 Diff 行旁输入意见,可连续添加多条,再统一提交给 Claude | 采用就地输入和连续审阅;UC 的 Enter 保留多行输入,Ctrl/⌘+Enter 将当前意见加入草稿 |
| [Codex app](https://learn.chatgpt.com/docs/code-review#inline-comments-for-feedback) | 通过行旁的 + 添加意见,完成后发送聊天消息让 Codex 处理 | 采用显式回到聊天检查并发送;添加意见本身不执行任务 |
| Universal-Code 本次实现 | 行旁 + → 写意见 → 加入现有聊天草稿 → 继续看其它行 → 返回草稿发送 | 复用现有草稿文档、附件和冲突处理;不增加评论数据库、后端接口或新的模型调用 |

每条意见携带原始来源、旧/新路径、差异片段、原始行文本和该版本的行号。输入时冻结这些来源;刷新后即使差异消失,也不会把意见重新绑定到另一行。原有引用操作和长行换行继续可用。意见加入草稿后可以编辑或删除;尚未点击“加入聊天草稿”的行内输入仅保留在当前审阅视图。

## 依赖与兼容

本 PR 叠加 #445,目标分支为 `Qiyuanqiii/review-diff-line-wrap`。`onAddComment` 是可选回调,原 `onFeedback` 与服务端合同不变。其它待审 PR 的依赖关系不变。特别核对 #437、#454、#458、#461、#465、#467、#469、#470 的对话、快捷键与草稿交互;#447/#451 使用另一套仓库 Diff 组件。

## 真实浏览器验证

使用仓库现有 `cmd/workbenchjourney`、真实 Go API 和 SQLite,在临时 Git 项目中通过 workspace import、thread、lifecycle、file-edit-proposal API 创建 `settings.ts` 提案。没有替换浏览器 API 响应。一个本机静态代理依次提供基线与候选生产包,并将 API 请求转发至同一个 Go 进程;两组截图使用同一持久化编辑记录、原始/提案哈希和 Diff。供应商使用现有离线 journey provider,审阅过程未请求模型推理。

| 视口 | 变更前 | 变更后 |
| --- | --- | --- |
| 1280 × 900 | [仅可引用](before-desktop.png) | [就地输入意见](after-desktop.png) |
| 390 × 844 | [窄屏 Diff](before-narrow.png) | [继续添加第二条意见](after-narrow.png) |

[浏览器观测记录](observations.json) 保留浏览器版本、实际 Diff、布局尺寸及操作结果:

- 两条意见连续加入同一草稿,原有空白和正文保留;审阅窗口保持打开。
- 每条意见包含正确的源编辑 ID、新文件行号和原始代码;返回后可检查完整草稿,刷新重连后仍保留两条意见。
- Ctrl+Enter 与 Meta+Enter 添加意见;Escape 只关闭当前行内输入;返回草稿聚焦编辑入口,窄屏折叠入口可展开继续编辑。
- 窄屏编辑器位于视口内,开启换行后 Diff 无横向溢出。审阅操作期间无 API 写请求,无未捕获浏览器错误。

通过:6 个相关测试文件的 68 项测试、`npm run build`(包含 TypeScript 检查)、`npm run check:api`、`go test -count=1 -tags desktop ./web ./internal/webui`。测试覆盖旧引用行为、Git 预览指纹、差异刷新、中文/英文、键盘与输入法事件、草稿冲突、保存失败、附件保留和跨任务隔离。

本次真实浏览器为 Linux Chromium。Windows/macOS 原生壳、真实中文输入法及 macOS 硬件键盘未实测;Meta 与组合输入仅验证合成事件。Git 预览由组件测试覆盖,journey harness 未启用任务 Git API。上述结果不代表完整桌面发行验收。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions docs/acceptance/2026-10-22-diff-inline-feedback/observations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"base_commit": "ed87d1dbdf3bc04c7935ca9c7b4fabfb67524e0e",
"browser": "154.0.8037.57",
"platform": "Linux Chromium",
"same_persisted_fixture": true,
"thread_id": "thread-run-20261022031942-ee1059310b39",
"edit_id": "edit-20261022031942-fa4f5fd41508",
"patch": "--- a/settings.ts\n+++ b/settings.ts\n@@ -1,3 +1,3 @@\n-export const attempts = 3;\n-export const timeout = 1000;\n-export const endpoint = \"https://example.test/api\";\n+export const attempts = 0;\n+export const timeout = 0;\n+export const endpoint = \"https://example.test/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/\";\n",
"raw_diff_lines": [
"-export const attempts = 3;",
"-export const timeout = 1000;",
"-export const endpoint = \"https://example.test/api\";",
"+export const attempts = 0;",
"+export const timeout = 0;",
"+export const endpoint = \"https://example.test/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/long-path/\";"
],
"narrow": {
"viewport": 390,
"document": 390,
"x": 39,
"width": 313,
"diffWidth": 337,
"diffScroll": 337
},
"comments_in_reloaded_draft": 2,
"api_writes_during_ui_flow": [],
"uncaught_browser_errors": [],
"escape_kept_review_open": true,
"synthetic_composing_escape_retained_input": true,
"return_draft_focused": true
}
46 changes: 46 additions & 0 deletions docs/acceptance/2026-10-27-inline-feedback-current/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# #471:同步 #445 后的真实页面对比

2026-10-27 在 Windows / Microsoft Edge 156 中重新拍摄。此次对比替代 PR 正文里
2026-10-22 的旧截图;旧记录保留为历史验收资料。

- 变更前:`02ff2bcf4703e948e53c5eced24bec00bc2ea986`,已合入 #445 的 main。
- 变更后:`803ac30f2c835e74c660cbbc9e03d1cb1a5eef1b`,#471 同步 #445 后的代码。
- #471 当前目标分支为 `main`。两边都有 #445 的文件名旁紧凑换行按钮;
#471 在此基础上增加行旁评论与“返回聊天草稿”,没有恢复旧版独立换行工具栏。

## 截图

| 视口 | 变更前:#445 已合入 | 变更后:#471 行内评论 |
| --- | --- | --- |
| 1280 × 900 | ![桌面变更前](before-desktop.png) | ![桌面变更后](after-desktop.png) |
| 480 × 900 | ![窄屏变更前](before-narrow.png) | ![窄屏变更后](after-narrow.png) |

两组都开启长行换行,展示同一个 `settings.ts` 提案。桌面审阅正文滚动位置为
377 px,窄屏为 562 px;同一视口下文件工具栏的位置一致。变更后截图的评论
仍在行内输入框内,点击“加入聊天草稿”或 Ctrl+Enter 后才加入草稿。

## 实际运行方式

分别从以上两个提交执行 `npm run build`,使用各自的生产前端包。后端从
`803ac30f` 以 `go build -tags workbenchjourney ./cmd/workbenchjourney` 构建,
运行真实 Go API、SQLite 和一次性项目;通过工作区导入、创建任务、启动 Run、
创建文件提案 API 准备数据。Vite preview 在同一个地址依次提供两个生产包并
转发到同一个后端进程,提案和原始文件始终相同。没有替换接口响应或单独渲染
组件。使用仓库离线 journey provider,没有调用外部模型。

本次验证:

- 两个版本的 TypeScript 检查和生产构建通过;保留已有 Vite 大包警告。
- 480 px 下,来回切换换行,尚未加入草稿的评论内容不丢失,Diff 原始代码文本不变。
- 评论输入框横向范围为 x=39 到 442 px,页面实际宽度与文档宽度均为 480 px。
截图中输入框和加入草稿按钮均可见、可操作。
- 第一条意见通过 Ctrl+Enter 加入,第二条通过按钮加入;添加后审阅面板保持打开。
- 返回聊天时保留原有草稿和两条意见,分别绑定新文件第 2、3 行及同一个正确的编辑记录;
焦点回到草稿。重新加载并连接后,这份草稿仍在。
- 同一代码实现此前合流时的 `review-diff`、`task-overview`、`review-comment-draft`
三个测试文件共 26 项通过;修改后的 `task-overview` 6 项随后单独复跑通过。
本次仅追加截图与记录,没有变更应用代码。

这是 Windows Edge 浏览器中的生产页面验收。此轮没有运行打包后的原生桌面窗口,
也没有重测 Git 预览及真实中文输入法;这些截图不作为完整桌面发行验收。
切换生产包时重启了预览代理,其间出现的连接重置属于这次对比的服务切换过程。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions web/src/v2/components/conversation-inspector-feedback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,43 @@ it.each(["inspector", "conversation"] as const)("appends exact review context an
expectNoWrites();
});

it.each(["inspector", "conversation"] as const)("collects two inline comments in the %s draft while keeping review open, then returns without sending", async (view) => {
const { container, expectNoWrites } = setup(view);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "审阅改动" }));
const dialog = await screen.findByRole("dialog", { name: "审阅任务改动" });
await user.click(await within(dialog).findByText("查看这次编辑的差异"));
await user.click(within(dialog).getByRole("button", { name: "评论旧第 1 行" }));
const composing = within(dialog).getByRole("textbox");
fireEvent.change(composing, { target: { value: "正在输入的中文意见" } });
fireEvent.keyDown(composing, { key: "Escape", isComposing: true });
expect(dialog).toBeVisible();
expect(composing).toHaveValue("正在输入的中文意见");
await user.keyboard("{Escape}");
expect(dialog).toBeVisible();
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
for (const [side, message] of [["旧", "保留旧逻辑的边界检查"], ["新", "增加空输入处理"]]) {
await user.click(within(dialog).getByRole("button", { name: `评论${side}第 1 行` }));
await user.type(within(dialog).getByRole("textbox"), message);
await user.keyboard("{Control>}{Enter}{/Control}");
expect(dialog).toBeVisible();
expect(within(dialog).getByRole("status")).toHaveTextContent("尚未发送");
}
const editor = container.querySelector<HTMLTextAreaElement>(".v2-shared-composer textarea")!;
expect(editor.value.startsWith(`${originalDraft}\n\n`)).toBe(true);
for (const part of ["保留旧逻辑的边界检查", "增加空输入处理", "任务:thread-a", "编辑记录:edit-original", "b".repeat(64)]) expect(editor.value).toContain(part);
expect(editor.value.match(/审阅意见:/gu)).toHaveLength(2);
expectNoWrites();
await user.click(within(dialog).getByRole("button", { name: "返回聊天草稿" }));
await waitFor(() => expect(editor).toHaveFocus());
expect(editor).toBeVisible();
expect(dialog).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "切到另一个任务" }));
await screen.findByText("Task thread-b");
expect(container.querySelector(".v2-shared-composer textarea")).toHaveValue("另一个任务的草稿");
expectNoWrites();
});

it.each(["inspector", "conversation"] as const)("reveals the %s draft when requesting an application startup, without starting or sending", async (view) => {
const { container, client, expectNoWrites } = setup(view);
const user = userEvent.setup();
Expand Down
14 changes: 12 additions & 2 deletions web/src/v2/components/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { useV2ThreadSubmissions, useV2ThreadTurn, V2SubmissionError, V2Recovered
import { inspectV2SteeringRequest, inspectV2TurnRequest } from "../recovery-api";
import { useV2RecoveryStore } from "../recovery-storage";
import { settleRecoveryTurn } from "../recovery-session";
import { appendReviewComment, type ReviewCommentResult } from "../review-comment-draft";
import { assertV2DraftVersion, useV2DraftDocument } from "../draft-context";
import type { V2DraftVersion } from "../draft-version";
import { V2DraftConflict } from "./draft-conflict";
Expand Down Expand Up @@ -961,6 +962,15 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
setReviewOpen(false); setPreviewOpen(false); setContextOpen(false);
setComposerFocusRequest({ threadID });
};
const addReviewComment = (content: string): ReviewCommentResult => {
if (managedDraft) return appendReviewComment(managedDraft, content);
const current = composeTarget.current;
if (!current.onDraftChange) return "blocked";
const next = current.draft ? `${current.draft}\n\n${content}` : content;
current.onDraftChange(next, current.draft);
composeTarget.current = { ...current, draft: next };
return "added";
};
const companionDocked = view === "conversation" && !compactPaneOpen;
const companion = <>
{activePane === "files" && <V2FileDrawer client={client} threadID={threadID}
Expand Down Expand Up @@ -1077,14 +1087,14 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
</V2LazySurface>}
{reviewOpen && <V2LazySurface loadingText={t("正在加载改动审阅…", "Loading change review…")} errorLabel={t("改动审阅", "Change review")} resetKey={threadID}
onDismiss={() => { setReviewOpen(false); workflowFocusTarget(reviewReturnFocus.current?.isConnected ? reviewReturnFocus.current : reviewTrigger.current)?.focus(); }}>
<V2TaskReview client={client} detail={detail} working={working}
<V2TaskReview key={threadID} client={client} detail={detail} working={working}
onOpenWorktree={onOpenWorktree}
initialFileTarget={reviewFileTarget}
initialToolTarget={reviewToolTarget} toolMemoryRef={reviewMemoryRef}
onClose={() => {
if (!reviewReturnFocus.current?.isConnected) reviewReturnFocus.current = reviewTrigger.current;
setReviewOpen(false);
}} returnFocusRef={reviewFocusTarget} onRequestChange={appendDraftAndReveal} /></V2LazySurface>}
}} returnFocusRef={reviewFocusTarget} onRequestChange={appendDraftAndReveal} onAddComment={onDraftChange ? addReviewComment : undefined} /></V2LazySurface>}
<div className="v2-conversation-stage">
<div className="v2-conversation-main-column">
{view === "inspector" && !transcriptQuery.isLoading && <V2LazySurface loadingText={t("正在加载 Inspector…", "Loading Inspector…")}
Expand Down
7 changes: 7 additions & 0 deletions web/src/v2/components/review-diff-comment.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading