diff --git a/docs/acceptance/2026-10-22-diff-inline-feedback/README.md b/docs/acceptance/2026-10-22-diff-inline-feedback/README.md new file mode 100644 index 00000000..f6541ce3 --- /dev/null +++ b/docs/acceptance/2026-10-22-diff-inline-feedback/README.md @@ -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。上述结果不代表完整桌面发行验收。 diff --git a/docs/acceptance/2026-10-22-diff-inline-feedback/after-desktop.png b/docs/acceptance/2026-10-22-diff-inline-feedback/after-desktop.png new file mode 100644 index 00000000..9e727c36 Binary files /dev/null and b/docs/acceptance/2026-10-22-diff-inline-feedback/after-desktop.png differ diff --git a/docs/acceptance/2026-10-22-diff-inline-feedback/after-narrow.png b/docs/acceptance/2026-10-22-diff-inline-feedback/after-narrow.png new file mode 100644 index 00000000..87a1fb52 Binary files /dev/null and b/docs/acceptance/2026-10-22-diff-inline-feedback/after-narrow.png differ diff --git a/docs/acceptance/2026-10-22-diff-inline-feedback/before-desktop.png b/docs/acceptance/2026-10-22-diff-inline-feedback/before-desktop.png new file mode 100644 index 00000000..7f54d1c2 Binary files /dev/null and b/docs/acceptance/2026-10-22-diff-inline-feedback/before-desktop.png differ diff --git a/docs/acceptance/2026-10-22-diff-inline-feedback/before-narrow.png b/docs/acceptance/2026-10-22-diff-inline-feedback/before-narrow.png new file mode 100644 index 00000000..1b68fbc3 Binary files /dev/null and b/docs/acceptance/2026-10-22-diff-inline-feedback/before-narrow.png differ diff --git a/docs/acceptance/2026-10-22-diff-inline-feedback/observations.json b/docs/acceptance/2026-10-22-diff-inline-feedback/observations.json new file mode 100644 index 00000000..7288175e --- /dev/null +++ b/docs/acceptance/2026-10-22-diff-inline-feedback/observations.json @@ -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 +} \ No newline at end of file diff --git a/docs/acceptance/2026-10-27-inline-feedback-current/README.md b/docs/acceptance/2026-10-27-inline-feedback-current/README.md new file mode 100644 index 00000000..53c2d670 --- /dev/null +++ b/docs/acceptance/2026-10-27-inline-feedback-current/README.md @@ -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 预览及真实中文输入法;这些截图不作为完整桌面发行验收。 +切换生产包时重启了预览代理,其间出现的连接重置属于这次对比的服务切换过程。 diff --git a/docs/acceptance/2026-10-27-inline-feedback-current/after-desktop.png b/docs/acceptance/2026-10-27-inline-feedback-current/after-desktop.png new file mode 100644 index 00000000..eb056739 Binary files /dev/null and b/docs/acceptance/2026-10-27-inline-feedback-current/after-desktop.png differ diff --git a/docs/acceptance/2026-10-27-inline-feedback-current/after-narrow.png b/docs/acceptance/2026-10-27-inline-feedback-current/after-narrow.png new file mode 100644 index 00000000..4be183dd Binary files /dev/null and b/docs/acceptance/2026-10-27-inline-feedback-current/after-narrow.png differ diff --git a/docs/acceptance/2026-10-27-inline-feedback-current/before-desktop.png b/docs/acceptance/2026-10-27-inline-feedback-current/before-desktop.png new file mode 100644 index 00000000..48cd85c2 Binary files /dev/null and b/docs/acceptance/2026-10-27-inline-feedback-current/before-desktop.png differ diff --git a/docs/acceptance/2026-10-27-inline-feedback-current/before-narrow.png b/docs/acceptance/2026-10-27-inline-feedback-current/before-narrow.png new file mode 100644 index 00000000..728cb496 Binary files /dev/null and b/docs/acceptance/2026-10-27-inline-feedback-current/before-narrow.png differ diff --git a/web/src/v2/components/conversation-inspector-feedback.test.tsx b/web/src/v2/components/conversation-inspector-feedback.test.tsx index bce0b93c..e4eccbea 100644 --- a/web/src/v2/components/conversation-inspector-feedback.test.tsx +++ b/web/src/v2/components/conversation-inspector-feedback.test.tsx @@ -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(".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(); diff --git a/web/src/v2/components/conversation.tsx b/web/src/v2/components/conversation.tsx index 25126fef..aa97b1e4 100644 --- a/web/src/v2/components/conversation.tsx +++ b/web/src/v2/components/conversation.tsx @@ -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"; @@ -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" && } {reviewOpen && { setReviewOpen(false); workflowFocusTarget(reviewReturnFocus.current?.isConnected ? reviewReturnFocus.current : reviewTrigger.current)?.focus(); }}> - { if (!reviewReturnFocus.current?.isConnected) reviewReturnFocus.current = reviewTrigger.current; setReviewOpen(false); - }} returnFocusRef={reviewFocusTarget} onRequestChange={appendDraftAndReveal} />} + }} returnFocusRef={reviewFocusTarget} onRequestChange={appendDraftAndReveal} onAddComment={onDraftChange ? addReviewComment : undefined} />}
{view === "inspector" && !transcriptQuery.isLoading && div { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.v2-diff-comment span { color: var(--v2-muted); font-size: 12px; } +.v2-review-comment-status p { flex: 1 1 240px; margin: 0; font-size: 13px; } diff --git a/web/src/v2/components/review-diff.test.tsx b/web/src/v2/components/review-diff.test.tsx index 88fcedb6..782ef468 100644 --- a/web/src/v2/components/review-diff.test.tsx +++ b/web/src/v2/components/review-diff.test.tsx @@ -1,12 +1,82 @@ import type { ReactElement } from "react"; import type { RenderOptions } from "@testing-library/react"; import { LocaleProvider } from "../../lib/locale"; -import { render as renderComponent, screen, within } from "@testing-library/react"; +import { fireEvent, render as renderComponent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { parseReviewDiff, ReviewDiff } from "./review-diff"; const patch = "--- a/src/app.ts\n+++ b/src/app.ts\n@@ -7,3 +9,3 @@ result\n unchanged\n-old value\n+new value\n last\n\\ No newline at end of file"; +it("adds multiline inline feedback with the original source and restores keyboard focus without quoting", async () => { + const add = vi.fn().mockReturnValue("added"), quote = vi.fn(); + render(); + const user = userEvent.setup(); + const trigger = screen.getByRole("button", { name: "评论旧第 8 行" }); + await user.click(trigger); + const input = screen.getByRole("textbox", { name: "旧第 8 行的意见" }); + expect(input).toHaveFocus(); + expect(screen.getByRole("button", { name: "加入聊天草稿" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "评论新第 10 行" })).toBeDisabled(); + await user.type(input, "Keep the guard.{Enter}Explain why."); + expect(add).not.toHaveBeenCalled(); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(add).toHaveBeenCalledOnce(); + expect(add).toHaveBeenCalledWith(expect.stringContaining("edit identity and hashes\n旧文件路径:a/src/app.ts\n新文件路径:b/src/app.ts")); + expect(add).toHaveBeenCalledWith(expect.stringContaining("旧文件第 8 行:\n-old value")); + expect(add).toHaveBeenCalledWith(expect.stringContaining("要求:Keep the guard.\nExplain why.")); + expect(quote).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + await waitFor(() => expect(trigger).toHaveFocus()); +}); + +it("keeps a blocked comment, ignores IME submission and cancels without bubbling Escape", async () => { + const add = vi.fn().mockReturnValue("blocked"), key = vi.fn(); + render(
); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "评论新第 10 行" })); + const input = screen.getByRole("textbox"); + await user.type(input, "保留意见"); + fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, isComposing: true }); + fireEvent.keyDown(input, { key: "Escape", isComposing: true }); + fireEvent.keyDown(input, { key: "Escape", keyCode: 229 }); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue("保留意见"); + expect(add).not.toHaveBeenCalled(); + await user.keyboard("{Meta>}{Enter}{/Meta}"); + expect(screen.getByRole("alert")).toHaveTextContent("此处输入仍保留"); + expect(input).toHaveValue("保留意见"); + key.mockClear(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(key).not.toHaveBeenCalled(); +}); + +it("retains the original source when a refreshed patch replaces or removes the selected hunk", async () => { + const add = vi.fn().mockReturnValue("added"); + const { rerender } = render(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "评论新第 10 行" })); + await user.type(screen.getByRole("textbox"), "Use an explicit default"); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("仍引用开始输入时的版本"); + expect(screen.getByRole("textbox")).toHaveValue("Use an explicit default"); + await user.click(screen.getByRole("button", { name: "加入聊天草稿" })); + expect(add).toHaveBeenCalledWith(expect.stringContaining("original edit hash")); + expect(add).toHaveBeenCalledWith(expect.stringContaining("新文件第 10 行:\n+new value")); + expect(add.mock.calls[0][0]).not.toContain("new edit hash"); +}); + +it("offers English inline comments only on actual diff lines", async () => { + localStorage.setItem("prayu.locale.v1", "en-US"); + const add = vi.fn().mockReturnValue("added"); + renderComponent(); + expect(screen.getAllByRole("button", { name: /^Comment on/ })).toHaveLength(4); + await userEvent.setup().click(screen.getByRole("button", { name: "Comment on new line 10" })); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Please simplify" } }); + await userEvent.setup().click(screen.getByRole("button", { name: "Add to chat draft" })); + expect(add).toHaveBeenCalledWith(expect.stringContaining("New file, line 10:\n+new value")); +}); + it("toggles wrapping with the keyboard without changing source text, line references or excerpts", async () => { const feedback = vi.fn(); const longLine = `+\tconst endpoint = "https://example.test/${"long-path/".repeat(30)}"; `; diff --git a/web/src/v2/components/review-diff.tsx b/web/src/v2/components/review-diff.tsx index 5971c9f4..a6c3b47c 100644 --- a/web/src/v2/components/review-diff.tsx +++ b/web/src/v2/components/review-diff.tsx @@ -1,5 +1,7 @@ -import { useMemo, useState } from "react"; +import { Fragment, useId, useMemo, useRef, useState, type Ref } from "react"; import { WrapText } from "lucide-react"; +import type { ReviewCommentResult } from "../review-comment-draft"; +import "./review-diff-comment.css"; import { useLocale, type LocaleText } from "../../lib/locale"; export interface ReviewDiffLine { text: string; oldLine?: number; newLine?: number; kind: "context" | "add" | "remove" | "meta" } export interface ReviewDiffHunk { header: string; lines: ReviewDiffLine[]; oldPath?: string; newPath?: string; preamble: string } @@ -53,37 +55,93 @@ function fileContext(hunk: ReviewDiffHunk, t: LocaleText): string { return t(`旧文件路径:${hunk.oldPath ?? t("未提供", "Not provided")}\n新文件路径:${hunk.newPath ?? t("未提供", "Not provided")}`, `Old file path: ${hunk.oldPath ?? t("未提供", "Not provided")}\nNew file path: ${hunk.newPath ?? t("未提供", "Not provided")}`); } -export function DiffWrapButton({ wrapLines, onToggle }: { wrapLines: boolean; onToggle: () => void }) { +function lineContext(source: string, hunk: ReviewDiffHunk, line: ReviewDiffLine, t: LocaleText): string { + const number = line.newLine ?? line.oldLine; + return t(`${source}\n${fileContext(hunk, t)}\n差异片段:${hunk.header}\n${line.kind === "remove" ? "旧文件" : "新文件"}第 ${number} 行:\n${line.text}\n行号只对应上述差异版本,请先核对当前内容。`, `${source}\n${fileContext(hunk, t)}\nDiff hunk: ${hunk.header}\n${line.kind === "remove" ? "Old file" : "New file"}, line ${number}:\n${line.text}\nLine numbers refer only to this diff revision. Check current content first.`); +} + +interface CommentTarget { patch: string; hunk: number; line: number; context: string; label: string; text: string } + +export function DiffWrapButton({ wrapLines, onToggle, buttonRef }: { wrapLines: boolean; onToggle: () => void; buttonRef?: Ref }) { const { t } = useLocale(); - return ; } -export function ReviewDiff({ patch, source, onFeedback, wrapLines: controlledWrapLines }: { - patch: string; source: string; onFeedback?: (context: string) => void; wrapLines?: boolean; +export function ReviewDiff({ patch, source, onFeedback, onAddComment, wrapLines: controlledWrapLines }: { + patch: string; source: string; onFeedback?: (context: string) => void; + onAddComment?: (context: string) => ReviewCommentResult; wrapLines?: boolean; }) { const { t } = useLocale(); const [localWrapLines, setLocalWrapLines] = useState(false); const wrapLines = controlledWrapLines ?? localWrapLines; + const [comment, setComment] = useState(null); + const [text, setText] = useState(""); + const [blocked, setBlocked] = useState(false); + const trigger = useRef(null); + const toolbar = useRef(null); + const container = useRef(null); + const inputID = useId(); const parsed = useMemo(() => parseReviewDiff(patch), [patch]); - if (!parsed.hunks.length) return
{patch}
; - return
+ const closeComment = () => { + setComment(null); setText(""); setBlocked(false); + requestAnimationFrame(() => (trigger.current?.isConnected ? trigger.current : toolbar.current ?? container.current)?.focus()); + }; + const addComment = () => { + if (!comment || !text.trim() || !onAddComment) return; + const result = onAddComment(t(`审阅意见:\n${comment.context}\n要求:${text}`, `Review comment:\n${comment.context}\nRequest: ${text}`)); + if (result === "blocked") { setBlocked(true); return; } + closeComment(); + }; + const editor = comment &&
{ + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) { + // Let the IME cancel its candidate without the modal's native Escape handler closing review. + if (event.key === "Escape") event.stopPropagation(); + return; + } + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeComment(); } + if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); event.stopPropagation(); addComment(); + } + }}> + {comment.patch !== patch &&

{t("差异已更新;这条意见仍引用开始输入时的版本。", "The diff changed; this comment still refers to the revision you started with.")}

} + + {comment.patch !== patch &&
{comment.text}
} +