diff --git a/docs/acceptance/2026-10-22-workspace-line-quote/README.md b/docs/acceptance/2026-10-22-workspace-line-quote/README.md new file mode 100644 index 00000000..e14b80e4 --- /dev/null +++ b/docs/acceptance/2026-10-22-workspace-line-quote/README.md @@ -0,0 +1,43 @@ +# 工作区源码选区引用验收 + +## 真实缺口与实现选择 + +UC 的工作区预览原来只能引用整份文件或附加证据。用户想就几行代码提问时,需要复制文字、回到聊天,再手工补路径和位置。待审 #442/#448/#463 分别处理搜索定位、Markdown 预览和搜索重试,#465 处理文件路径补全,#471 处理 Diff 审阅意见,均未提供源码选区引用。 + +- [Claude Code 的 VS Code 扩展](https://code.claude.com/docs/en/vs-code#reference-files-and-folders)自动提供选区上下文,并支持用 Option/Alt+K 插入带路径、行号的引用。 +- [Codex IDE 扩展](https://learn.chatgpt.com/docs/ide/commands)提供 `chatgpt.addToThread`,把选中的文字范围加入当前聊天上下文。 + +这些证据来自两者的编辑器扩展,不表示 Claude Desktop 的文件面板提供相同的选区操作。本次把相同的用户需求放在 UC 已有的文件预览里:使用浏览器原生选区,明确引用完整的选中行,加入可编辑草稿,再由用户发送。不引入编辑器集成、自动上下文读取、额外存储或 API。 + +引用包含文件路径、预览行范围、实际来源 workspace/Run、显示快照 SHA-256 和代码。Go 已经对预览内容进行脱敏和截断;哈希对应显示快照,且多行脱敏可能改变行号,因此始终标为“预览第 N–M 行”,不声称它是磁盘原文件的精确行号。 + +## PR 关系 + +基于 #471 的 `2ce3c07702d033d29765c9c68211083e368a25d3`,复用它的草稿追加和保存失败处理。Go、OpenAPI、DTO、草稿协议均不变。工作区预览只新增可选回调,不改变原有整文件引用、证据附加和编辑提案接口。 + +对当时 41 个待审 PR 分别进行 `git merge-tree --write-tree` 检查,全部无文本冲突;与 main 试合并也通过。额外按 #442、#448、#463、#437、#454、#458、#465、#440、#461、#467、#469、#470 顺序合成临时集成提交,验证源码/Markdown 切换后的旧选区不能继续引用,同时运行相关测试和类型检查。此结果不表示所有待审 PR 彼此兼容。 + +## 实际页面验证 + +使用 `workbenchjourney` 启动真实 Go HTTP API 与临时 SQLite。通过 API 导入临时 Git 工作区、建立 Thread/Run,再在 UI 操作。前后版本使用同一后端数据;仅切换静态 UI 包,没有 mock HTTP 响应,也未调用付费模型。 + +1. 基线中打开 `settings.ts`,确认没有“引用选中行”。 +2. 更新后在 1280×900 Chromium 中,用鼠标拖选实际源码第 4–8 行并引用;面板保持打开,原草稿保留。 +3. 反向选择第 1–2 行,聚焦按钮后按 Enter,第二段来源与内容仍准确。 +4. 关闭面板后在 390×844 窄窗口重新打开,以模态面板再添加一段,确认控件不超出视口。 +5. 返回聊天,确认焦点恢复;重新连接后,三段引用及原草稿仍在。 +6. 选择代码后导航至父目录再打开文件,确认旧选区失效。整段 UI 流程无 API 写入和未捕获浏览器错误。 + +原始观察值见 [observations.json](observations.json)。 + +| 基线 | 更新后 | +| --- | --- | +| ![原有文件预览](before-desktop.png) | ![选中源码并显示引用入口](after-desktop.png) | + +![窄窗口中加入草稿后的状态](after-narrow.png) + +## 自动验证与边界 + +- 相关测试覆盖完整行和空行、下一行零偏移边界、跨容器选择、文件/Run/快照切换、保存失败和冲突、连续追加、实际工作区归属,以及 conversation/inspector 返回草稿。 +- `npm run build`、`npm run check:api`、`go test -count=1 -tags desktop ./web ./internal/webui` 通过。 +- 本次页面验证为 Linux Chromium 154.0.8037.57。Windows/macOS 原生壳及系统辅助技术未实测;窄窗口验证的是直接打开模态面板,不覆盖现有停靠面板在窗口缩窄时的切换行为。 diff --git a/docs/acceptance/2026-10-22-workspace-line-quote/after-desktop.png b/docs/acceptance/2026-10-22-workspace-line-quote/after-desktop.png new file mode 100644 index 00000000..403daa3c Binary files /dev/null and b/docs/acceptance/2026-10-22-workspace-line-quote/after-desktop.png differ diff --git a/docs/acceptance/2026-10-22-workspace-line-quote/after-narrow.png b/docs/acceptance/2026-10-22-workspace-line-quote/after-narrow.png new file mode 100644 index 00000000..76686099 Binary files /dev/null and b/docs/acceptance/2026-10-22-workspace-line-quote/after-narrow.png differ diff --git a/docs/acceptance/2026-10-22-workspace-line-quote/before-desktop.png b/docs/acceptance/2026-10-22-workspace-line-quote/before-desktop.png new file mode 100644 index 00000000..4561c6a9 Binary files /dev/null and b/docs/acceptance/2026-10-22-workspace-line-quote/before-desktop.png differ diff --git a/docs/acceptance/2026-10-22-workspace-line-quote/observations.json b/docs/acceptance/2026-10-22-workspace-line-quote/observations.json new file mode 100644 index 00000000..ebb08c9e --- /dev/null +++ b/docs/acceptance/2026-10-22-workspace-line-quote/observations.json @@ -0,0 +1,25 @@ +{ + "base_commit": "2ce3c07702d033d29765c9c68211083e368a25d3", + "browser": "154.0.8037.57", + "platform": "Linux Chromium", + "same_persisted_fixture": true, + "thread_id": "thread-run-20261022041942-843e91dede57", + "run_id": "run-20261022041942-843e91dede57", + "workspace_id": "ws-import-60a04e86f5b6494720b6ec47", + "source": "export const retries = 3;\nexport const timeoutMs = 1000;\n\nexport async function loadSettings(url: string) {\n const response = await fetch(url);\n if (!response.ok) {\n throw new Error(`Request failed: ${response.status}`);\n }\n return response.json();\n}", + "displayed_sha256": "7377af2feb8a91569876bf86c7405e334569581721b6847989676ddb7b9ad291", + "native_mouse_selection": "4–8", + "reverse_selection": "1–2", + "keyboard_quote": true, + "excerpts_in_reloaded_draft": 3, + "returned_to_draft": true, + "navigation_discarded_selection": true, + "narrow": { + "viewport": 390, + "pageWidth": 390, + "left": 19, + "right": 372 + }, + "api_writes": [], + "uncaught_browser_errors": [] +} \ No newline at end of file diff --git a/web/src/components/workspace-explorer.tsx b/web/src/components/workspace-explorer.tsx index 767aab9f..deb7ba3e 100644 --- a/web/src/components/workspace-explorer.tsx +++ b/web/src/components/workspace-explorer.tsx @@ -6,12 +6,13 @@ import type { WorkspaceExplorerView, WorkspaceSearchView } from "../api/types"; import { formatBytes } from "../lib/format"; import { useLocale } from "../lib/locale"; import { EmptyState, ErrorState, LoadingState, StatusBadge } from "./common"; +import { WorkspaceFileQuote, type WorkspaceQuoteResult } from "./workspace-file-quote"; import { FileProposalEditor } from "./file-proposal-editor"; import { useFileEditLeave } from "./file-edit-drafts"; import { SafeMarkdown } from "./safe-markdown"; import "./workspace-explorer.css"; -export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath = ".", initialLine, onSelectReference, onPathChange }: { +export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath = ".", initialLine, onSelectReference, onPathChange, onQuote, onReturnToDraft }: { client: APIClient; workspaceID: string; runID?: string; @@ -19,12 +20,15 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath initialLine?: number; onSelectReference?: (file: WorkspaceExplorerView) => void; onPathChange?: (path: string) => void; + onQuote?: (content: string) => WorkspaceQuoteResult; + onReturnToDraft?: () => void; }) { const { t } = useLocale(); const leave = useFileEditLeave(); const [location, setLocation] = useState({ path: initialPath, line: initialLine, view: "source" as "source" | "preview" }); const { path, line: highlightLine } = location; const highlightedLineRef = useRef(null); + const explorerRef = useRef(null); const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const operationKeys = useRef(new Map()); @@ -122,7 +126,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath ; const snapshot = query.data; - return
+ return
+ + {result &&

+ {result === "added" ? t("已加入聊天草稿,尚未发送。", "Added to the chat draft. Not sent.") + : result === "unsaved" ? t("已加入草稿,但本地保存失败,请返回聊天保留内容。", "Added to the draft, but local saving failed. Return to chat to preserve the content.") + : t("草稿暂时无法追加,请返回聊天处理草稿冲突或保存错误后重试。", "Could not append to the draft. Return to chat to resolve its conflict or saving error, then retry.")} + {onReturnToDraft && } +

} +
; +} diff --git a/web/src/v2/components/conversation-inspector-feedback.test.tsx b/web/src/v2/components/conversation-inspector-feedback.test.tsx index e4eccbea..f106b23d 100644 --- a/web/src/v2/components/conversation-inspector-feedback.test.tsx +++ b/web/src/v2/components/conversation-inspector-feedback.test.tsx @@ -125,6 +125,43 @@ it.each(["inspector", "conversation"] as const)("collects two inline comments in expectNoWrites(); }); +it.each(["inspector", "conversation"] as const)("quotes the viewed worktree's source into the %s draft and returns without sending", async (view) => { + const { container, client, expectNoWrites } = setup(view); + client.fileEditChangeSet = vi.fn().mockResolvedValue({ workspace_id: "isolated-worktree" }); + client.workspaceExplore = vi.fn().mockResolvedValue({ protocol_version: "workspace_explorer.v1", workspace_id: "isolated-worktree", + path: "src/example.ts", kind: "file", entries: [], content: "first line\n selected source\nlast line", returned_bytes: 41, + total_bytes: 41, truncated: false, redaction_count: 0, root_path_exposed: false, + provenance: { version: "context_provenance.v1", source_kind: "workspace_file", source_ref: "src/example.ts", + content_sha256: "c".repeat(64), instruction_authorized: false } }); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "工作区文件" })); + const dialog = await screen.findByRole("dialog", { name: "工作区文件" }); + await within(dialog).findByText("selected source"); + for (const line of [2, 3]) { + act(() => { + const range = document.createRange(); + range.selectNodeContents(dialog.querySelector(`[data-line="${line}"] .explorer-line-content`)!.firstChild!); + document.getSelection()!.removeAllRanges(); + document.getSelection()!.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + }); + await user.click(within(dialog).getByRole("button", { name: "引用选中行" })); + expect(dialog).toBeVisible(); + expect(within(within(dialog).getByRole("region", { name: "引用文件片段" })).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", "来源工作区:isolated-worktree", "来源执行:run-thread-a", "c".repeat(64), + "预览第 2–2 行", "预览第 3–3 行", " selected source", "last line"]) expect(editor.value).toContain(part); + expect(editor.value).not.toContain("workspace-source"); + expect(client.workspaceExplore).toHaveBeenCalledWith("isolated-worktree", ".", expect.any(AbortSignal)); + await user.click(within(dialog).getByRole("button", { name: "返回聊天草稿" })); + await waitFor(() => expect(editor).toHaveFocus()); + expect(editor).toBeVisible(); + expect(dialog).not.toBeInTheDocument(); + 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 aa97b1e4..ad9bd2dd 100644 --- a/web/src/v2/components/conversation.tsx +++ b/web/src/v2/components/conversation.tsx @@ -98,6 +98,8 @@ export function V2FileDrawer({ initialPath = ".", initialLine, onPathChange, + onQuote, + onReturnToDraft, onClose: close, returnFocusRef, docked = false, @@ -109,6 +111,8 @@ export function V2FileDrawer({ initialPath?: string; initialLine?: number; onPathChange?: (path: string) => void; + onQuote?: (content: string) => ReviewCommentResult; + onReturnToDraft?: () => void; onClose: () => void; returnFocusRef: RefObject; docked?: boolean; @@ -206,6 +210,8 @@ export function V2FileDrawer({ initialPath={initialPath} initialLine={initialLine} onPathChange={onPathChange} + onQuote={onQuote} + onReturnToDraft={onReturnToDraft} /> )} @@ -973,11 +979,13 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana }; const companionDocked = view === "conversation" && !compactPaneOpen; const companion = <> - {activePane === "files" && setFileDrawerState((current) => ({ ...current, path, line: undefined }))} + onQuote={onDraftChange ? (content) => addReviewComment(`${t("任务:", "Thread: ")}${threadID}\n${content}`) : undefined} + onReturnToDraft={() => { setActivePane(null); appendDraftAndReveal(""); }} onClose={() => { setActivePane(null); if (fileReturnFocus.current?.isConnected) fileReturnFocus.current.focus();