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-workspace-line-quote/README.md
Original file line number Diff line number Diff line change
@@ -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 原生壳及系统辅助技术未实测;窄窗口验证的是直接打开模态面板,不覆盖现有停靠面板在窗口缩窄时的切换行为。
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.
25 changes: 25 additions & 0 deletions docs/acceptance/2026-10-22-workspace-line-quote/observations.json
Original file line number Diff line number Diff line change
@@ -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": []
}
12 changes: 10 additions & 2 deletions web/src/components/workspace-explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,29 @@ 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;
initialPath?: string;
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<HTMLDivElement>(null);
const explorerRef = useRef<HTMLElement>(null);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const operationKeys = useRef(new Map<string, string>());
Expand Down Expand Up @@ -122,7 +126,7 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
</section>;
const snapshot = query.data;

return <section className="workspace-explorer" aria-label={t("工作区文件", "Workspace files")}>
return <section className="workspace-explorer" aria-label={t("工作区文件", "Workspace files")} ref={explorerRef}>
<header className="explorer-toolbar">
<button aria-label={t("打开上级目录", "Open parent directory")} className="icon-button"
disabled={path === "." || query.isFetching} onClick={() => navigate(parent)}
Expand Down Expand Up @@ -188,6 +192,10 @@ export function WorkspaceExplorer({ client, workspaceID, runID = "", initialPath
</div>;
})}
</div>}
{snapshot.kind === "file" && onQuote && proposalSource.data?.path !== snapshot.path &&
<WorkspaceFileQuote key={JSON.stringify([workspaceID, runID, snapshot.path, snapshot.provenance.content_sha256])}
rootRef={explorerRef} snapshot={snapshot} runID={runID} disabled={query.isFetching}
onQuote={onQuote} onReturnToDraft={onReturnToDraft} />}
{proposalSource.data && proposalSource.data.path === snapshot.path ?
<FileProposalEditor client={client} onClose={() => proposalSource.reset()}
runID={runID} source={proposalSource.data} /> :
Expand Down
4 changes: 4 additions & 0 deletions web/src/components/workspace-file-quote.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.workspace-file-quote { margin: 8px 0; color: var(--muted); font-size: 12px; }
.workspace-file-quote > div, .workspace-file-quote > p { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.workspace-file-quote > div { justify-content: space-between; }
.workspace-file-quote > p { margin: 8px 0; }
101 changes: 101 additions & 0 deletions web/src/components/workspace-file-quote.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { APIClient } from "../api/client";
import type { WorkspaceExplorerView } from "../api/types";
import { WorkspaceExplorer } from "./workspace-explorer";
import type { WorkspaceQuoteResult } from "./workspace-file-quote";

const content = "before\n selected start\n\n```inside the file\nselected end\nafter";
function snapshot(path = "src/example.ts", digest = "a".repeat(64)): WorkspaceExplorerView {
return { protocol_version: "workspace_explorer.v1", workspace_id: "actual-worktree", path, kind: "file", entries: [],
content, returned_bytes: content.length, total_bytes: 500, truncated: true, redaction_count: 1, root_path_exposed: false,
provenance: { version: "context_provenance.v1", source_kind: "workspace_file", source_ref: path,
content_sha256: digest, instruction_authorized: false } };
}
function setup(outcome: WorkspaceQuoteResult = "added") {
const client = { workspaceExplore: vi.fn().mockResolvedValue(snapshot()) } as unknown as APIClient;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
const quote = vi.fn((_content: string) => outcome);
const returned = vi.fn();
const component = (runID = "actual-run", path = "src/example.ts") => <QueryClientProvider client={queryClient}>
<p>Outside the file</p>
<WorkspaceExplorer client={client} workspaceID="actual-worktree" runID={runID} initialPath={path}
onQuote={quote} onReturnToDraft={returned} />
</QueryClientProvider>;
return { ...render(component()), client, queryClient, quote, returned, component };
}
function select(container: HTMLElement, first: number, firstOffset: number, last: number, lastOffset: number) {
const start = container.querySelector(`[data-line="${first}"] .explorer-line-content`)!.firstChild!;
const end = container.querySelector(`[data-line="${last}"] .explorer-line-content`)!.firstChild!;
act(() => {
const range = document.createRange(); range.setStart(start, firstOffset); range.setEnd(end, lastOffset);
const selection = document.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
});
}
afterEach(() => { document.getSelection()?.removeAllRanges(); });

it("quotes the full selected source lines with exact provenance, blank lines, safe fences, and no send", async () => {
const { container, quote, returned, client } = setup();
const user = userEvent.setup();
const button = await screen.findByRole("button", { name: "Quote selected lines" });
expect(button).toBeDisabled();
select(container, 2, 4, 5, 3);
expect(screen.getByText("Preview lines 2–5")).toBeVisible();
await user.click(button);
expect(quote).toHaveBeenCalledOnce();
const excerpt = quote.mock.calls[0][0];
for (const part of ['File excerpt: "src/example.ts"', "Preview lines 2–5", "Source workspace: actual-worktree", "Source run: actual-run",
"a".repeat(64), "truncated", "redacted", "````text\n selected start\n\n```inside the file\nselected end\n````"]) {
expect(excerpt).toContain(part);
}
expect(excerpt).not.toContain("before"); expect(excerpt).not.toContain("after");
expect(screen.getByRole("status")).toHaveTextContent("Not sent");
fireEvent.click(button); // A second click cannot duplicate an already consumed selection.
expect(quote).toHaveBeenCalledOnce();
expect(client.workspaceExplore).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Return to chat draft" }));
expect(returned).toHaveBeenCalledOnce();
});

it("excludes an end boundary at the next line's column zero and rejects selections crossing outside the source", async () => {
const { container, quote } = setup();
const button = await screen.findByRole("button", { name: "Quote selected lines" });
select(container, 2, 0, 5, 0);
fireEvent.click(button);
expect(quote.mock.calls[0][0]).toContain("Preview lines 2–4");
expect(quote.mock.calls[0][0]).not.toContain("selected end");
act(() => {
const range = document.createRange();
range.setStart(screen.getByText("Outside the file").firstChild!, 1);
range.setEnd(container.querySelector('[data-line="2"] .explorer-line-content')!.firstChild!, 3);
document.getSelection()!.addRange(range);
document.dispatchEvent(new Event("selectionchange"));
});
expect(button).toBeDisabled();
});

it.each(["file", "run", "snapshot"] as const)("discards the old range when the %s identity changes, even if the displayed text is identical", async (change) => {
const { container, component, client, queryClient, rerender, quote } = setup();
await screen.findByRole("button", { name: "Quote selected lines" });
select(container, 2, 0, 5, 3);
if (change === "file") {
vi.mocked(client.workspaceExplore).mockResolvedValue(snapshot("src/other.ts"));
rerender(component("actual-run", "src/other.ts"));
await waitFor(() => expect(client.workspaceExplore).toHaveBeenLastCalledWith("actual-worktree", "src/other.ts", expect.any(AbortSignal)));
} else if (change === "run") rerender(component("other-run"));
else act(() => { queryClient.setQueryData(["workspace", "actual-worktree", "explore", "src/example.ts"], snapshot(undefined, "b".repeat(64))); });
await waitFor(() => expect(screen.getByRole("button", { name: "Quote selected lines" })).toBeDisabled());
expect(quote).not.toHaveBeenCalled();
});

it.each(["unsaved", "blocked"] as const)("reports a %s draft without claiming persistence or silently losing the excerpt", async (outcome) => {
const { container, quote } = setup(outcome);
const button = await screen.findByRole("button", { name: "Quote selected lines" });
select(container, 2, 0, 2, 5);
fireEvent.click(button);
expect(screen.getByRole("alert")).toHaveTextContent(outcome === "unsaved" ? "local saving failed" : "Could not append");
fireEvent.click(button);
expect(quote).toHaveBeenCalledTimes(outcome === "unsaved" ? 1 : 2);
});
Loading
Loading