Skip to content

Commit 3564f54

Browse files
yuesoueclaude
andcommitted
fix(office-选中加聊天): QA 跟进 anomalyco#2-anomalyco#4 — 右键 snapshot / anchor-focus / quote 卡片
QA anomalyco#2 — host.tsx pointerdown snapshot 修行间空白右键 collapse 现象:user 多行选中后右键落行间空白 → selection 瞬间消失 + 菜单不弹。 根因:WebView2 默认 mousedown 把 caret 移到 click 位置 → selection collapse;textLayer 是绝对定位 spans,行间空白不属任何 span,user 视觉 上看到 overlay 覆盖此处 (我们 visual bbox union 整行 rect 算的) 但 DOM 上不属选区 → 右键 collapse → live getSelection 已空 → menu 不接管。 修法:onRightClickPointerDown (button=2) 在 mousedown collapse 之前 snapshot 选区 (text+rects+range+bbox+ts);handleContextMenu fallback — live 空 + snapshot < 500ms + 右键坐标落 snapshot bbox 内 → 用 snapshot 并 addRange(snapshot.range) 恢复 native selection。 QA anomalyco#3 — dom-provider.ts 视觉 bbox 改用 sel.anchor/focus 不用 nativeRects 现象:user 从 "单一" 拖到 "Claude" 4 行,overlay 扩到整页。 根因:PDF.js textLayer span 的 DOM 顺序 ≠ 视觉顺序 (复杂 PDF 标题/段落 在 PDF text stream 里乱序很常见);range.getClientRects() 沿 DOM 顺序 遍历返回 rects,把"DOM 在 anchor/focus 之间但视觉跨页"的 spans 全算 进 bbox → bboxTop=title.top / bboxBottom=section4.bottom → 算法收所有 y 在 bbox 内 spans → 整页 overlay。 修法:用 sel.anchorNode/focusNode + offset 算 caret rect,用 anchor/focus 两点的 cy 作 bboxTop/Bottom (±2px 容差),selStartX/EndX 同样用 anchor/ focus 真实坐标;fallback 罕见 detached node 时落回 nativeRects bbox。 关键洞察:anchor/focus 反映 user 真实意图,DOM range 反映解析结果 — 选区视觉用 user 意图维度,DOM 维度只在格式良序时才等价。 QA anomalyco#4 — commentOrigin: "quote" 改卡片不附二进制文件 (B 方案) 现象:user QA "复制文案,形势不好" — 选中文字塞 textarea 当 markdown blockquote 难看;A1 初版改卡片后又发现 build-request-parts 会把 PDF/ docx 当 text/plain 让 LLM read 工具读整个二进制 = 乱码 + 浪费 token。 设计决策:B 方案最优 — 加 commentOrigin "quote" 子型,build-request- parts 检测 quote 分支只 emit text part 不附 file URL (formatCommentNote 已含选中文字 preview)。不选 C 多模态原生 PDF (只解 PDF,Office 不行, 大文件爆 context);不选 D 文本抽取 (v1 infra 过大,v2 backlog)。 修法: - core: build-request-parts.ts isQuote 分支跳过 filePart 只 emit text - core: host.tsx submitToChat — 有 sourcePath (PDF/office) 时 prompt.context.add({..., commentOrigin: "quote"}),空 comment 兜底 "(see selected text)" 让 formatCommentNote 不漏 preview - 接线:file-tabs.tsx pdf-viewer wrapper 加 data-file-path={path()}; dom-provider.ts readPdfViewerFilePath 透传到 SelectionResult.sourceMeta - 类型扩展 commentOrigin 加 "quote":comment-note.ts / prompt.tsx / prompt-input.tsx / submit.ts / history.ts / pages/session.tsx - commentID=quote-{textHash}-{ts} (避免同 PDF 多次选区被 dedup) 真实 LLM payload 验证 (opencode.db part 表):user msg 只含 2 个 text part (1 空 + 1 formatCommentNote),0 个 file part;LLM reasoning 正确理解意图;input tokens 14k 无 docx 二进制乱码污染。 净改动:host.tsx +136 / dom-provider.ts +124 / 8 其他文件 ~80 行。 测试:72 单测全过 / typecheck 全过 / CDP 端到端自测 3 个 fix 全 pass。 [feat: office-选中加聊天] [bug-repro: 行间空白右键 collapse / 拖几行 overlay 扩整页 / 卡片 LLM 收 docx 二进制乱码 — 3 处场景 user 真桌面截图复现] Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ca09359 commit 3564f54

10 files changed

Lines changed: 282 additions & 30 deletions

File tree

packages/app/src/components/prompt-input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
182182
return diffs.some((diff) => diff.file === path)
183183
}
184184

185-
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
185+
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" | "quote" }) => {
186186
if (!item.commentID) return
187187

188188
const focus = { file: item.path, id: item.commentID }

packages/app/src/components/prompt-input/build-request-parts.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type ContextFile = {
1515
selection?: FileSelection
1616
comment?: string
1717
commentID?: string
18-
commentOrigin?: "review" | "file"
18+
commentOrigin?: "review" | "file" | "quote"
1919
preview?: string
2020
}
2121

@@ -135,6 +135,38 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
135135
const path = absolute(input.sessionDirectory, item.path)
136136
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
137137
const comment = item.comment?.trim()
138+
const isQuote = item.commentOrigin === "quote"
139+
140+
// FORK: quote origin(PDF/office 选区卡片)只送 user 选中的文字 + 来源路径,
141+
// 绝不附整个二进制文件。原因详 docs/features/office-选中加聊天/3-changelog.md
142+
// § QA 跟进 #4。
143+
// — origin=quote 必须有 comment(submitToChat 兜底"(see selected text)"),
144+
// 否则下面 formatCommentNote 收不到 preview 段。
145+
// [feat: office-选中加聊天] 2026-05-25
146+
if (isQuote) {
147+
if (!comment) return []
148+
return [
149+
{
150+
id: Identifier.ascending("part"),
151+
type: "text",
152+
text: formatCommentNote({
153+
path: item.path,
154+
selection: item.selection,
155+
comment,
156+
preview: item.preview,
157+
}),
158+
synthetic: true,
159+
metadata: createCommentMetadata({
160+
path: item.path,
161+
selection: item.selection,
162+
comment,
163+
preview: item.preview,
164+
origin: item.commentOrigin,
165+
}),
166+
} satisfies PromptRequestPart,
167+
]
168+
}
169+
138170
if (!comment && used.has(url)) return []
139171
used.add(url)
140172

packages/app/src/components/prompt-input/history.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export type PromptHistoryComment = {
1111
selection: SelectedLineRange
1212
comment: string
1313
time: number
14-
origin?: "review" | "file"
14+
origin?: "review" | "file" | "quote"
1515
preview?: string
1616
}
1717

packages/app/src/components/prompt-input/submit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ type CommentItem = {
197197
selection?: FileSelection
198198
comment?: string
199199
commentID?: string
200-
commentOrigin?: "review" | "file"
200+
commentOrigin?: "review" | "file" | "quote"
201201
preview?: string
202202
}
203203

packages/app/src/context/prompt.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export type FileContextItem = {
4444
selection?: FileSelection
4545
comment?: string
4646
commentID?: string
47-
commentOrigin?: "review" | "file"
47+
commentOrigin?: "review" | "file" | "quote"
4848
preview?: string
4949
}
5050

packages/app/src/pages/session.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ export default function Page() {
885885
selection: SelectedLineRange
886886
comment: string
887887
preview?: string
888-
origin?: "review" | "file"
888+
origin?: "review" | "file" | "quote"
889889
}) => {
890890
const selection = selectionFromLines(input.selection)
891891
const preview = input.preview ?? selectionPreview(input.file, selection)

packages/app/src/pages/session/file-tabs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ export function FileTabContent(props: {
658658
selection: SelectedLineRange
659659
comment: string
660660
preview?: string
661-
origin?: "review" | "file"
661+
origin?: "review" | "file" | "quote"
662662
}) => {
663663
const selection = selectionFromLines(input.selection)
664664
const preview = input.preview ?? buildPreview(input.file, selection)
@@ -1587,7 +1587,7 @@ export function FileTabContent(props: {
15871587
// 跟 message-part.css:709-710 chat 区开 user-select:text 同套路。
15881588
// [feat: office-选中加聊天] 2026-05-24 hot-fix(user 实测 textLayer 选不中复现)
15891589
return (
1590-
<div data-slot="pdf-viewer" class="flex flex-col h-full select-text">
1590+
<div data-slot="pdf-viewer" data-file-path={path() ?? ""} class="flex flex-col h-full select-text">
15911591
<div class="flex items-center justify-end gap-2 px-3 py-1 border-b border-border-base bg-surface-raised-stronger-non-alpha text-xs">
15921592
<button
15931593
type="button"

packages/app/src/utils/comment-note.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type PromptComment = {
55
selection?: FileSelection
66
comment: string
77
preview?: string
8-
origin?: "review" | "file"
8+
origin?: "review" | "file" | "quote"
99
}
1010

1111
function selection(selection: unknown) {
@@ -49,7 +49,7 @@ export function readCommentMetadata(value: unknown) {
4949
selection: selection((meta as { selection?: unknown }).selection),
5050
comment,
5151
preview: typeof preview === "string" ? preview : undefined,
52-
origin: origin === "review" || origin === "file" ? origin : undefined,
52+
origin: origin === "review" || origin === "file" || origin === "quote" ? origin : undefined,
5353
} satisfies PromptComment
5454
}
5555

packages/app/src/utils/context-menu-host/dom-provider.ts

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export class DomSelectionProvider implements SelectionProvider {
5353
const visual = this.collectVisualSpansInBbox(target, range)
5454
if (visual) {
5555
const partial = this.spansMultiplePdfPages(range)
56-
return { text: visual.text, rects: visual.rects, range, partial }
56+
const sourcePath = this.readPdfViewerFilePath(target)
57+
const sourceMeta = sourcePath ? { kind: "pdf-office", path: sourcePath } : undefined
58+
return { text: visual.text, rects: visual.rects, range, partial, sourceMeta }
5759
}
5860
// 视觉算法 fallback(罕见 — 拿不到 bbox 或无 spans)→ 走 native
5961
}
@@ -85,18 +87,63 @@ export class DomSelectionProvider implements SelectionProvider {
8587
const pdfViewer = target.closest('[data-slot="pdf-viewer"]')
8688
if (!pdfViewer) return null
8789

88-
// native selection 的视觉边界(union of all client rects)
90+
// FORK: 优先用 Selection.anchor/focus 视觉坐标定边界(user 真实 mousedown/mouseup 点),
91+
// 而非 range.getClientRects() bbox。
92+
// [feat: office-选中加聊天] 2026-05-25 user QA #N+2 反馈
93+
//
94+
// 背景:PDF.js textLayer span 的 DOM 顺序 ≠ 视觉顺序(复杂 PDF 中标题/段落乱序很常见)。
95+
// range.getClientRects() 沿 DOM 顺序遍历返回 rects,会把"DOM 在中间但视觉上跨页"的 spans
96+
// 全算进 bbox,导致 user 仅拖几行却选中"几乎整页"(实测 user 从"单一"拖到"Claude"4 行,
97+
// bbox 扩到包含 title + 4 个 section)。
98+
// 解法:用 sel.anchorNode/focusNode 算出真实拖拽起止 2 点 → 用这 2 点的视觉坐标围 bbox。
99+
// anchor/focus 是 user 实际 mousedown/mouseup 的 caret 位置,不会跨视觉边界。
100+
const sel = typeof window !== "undefined" ? window.getSelection() : null
101+
let anchorRect: DOMRect | null = null
102+
let focusRect: DOMRect | null = null
103+
if (sel && sel.anchorNode && sel.focusNode) {
104+
try {
105+
const ar = document.createRange()
106+
ar.setStart(sel.anchorNode, sel.anchorOffset)
107+
ar.setEnd(sel.anchorNode, sel.anchorOffset)
108+
anchorRect = ar.getBoundingClientRect()
109+
const fr = document.createRange()
110+
fr.setStart(sel.focusNode, sel.focusOffset)
111+
fr.setEnd(sel.focusNode, sel.focusOffset)
112+
focusRect = fr.getBoundingClientRect()
113+
} catch {
114+
// 罕见:detached node / cross-document — 落回 nativeRects bbox
115+
}
116+
}
117+
89118
const nativeRects = Array.from(range.getClientRects()).filter(
90119
(r) => r.width > 0 && r.height > 0,
91120
)
92-
if (nativeRects.length === 0) return null
93-
let bboxTop = Infinity
94-
let bboxBottom = -Infinity
95-
for (const r of nativeRects) {
96-
if (r.top < bboxTop) bboxTop = r.top
97-
if (r.bottom > bboxBottom) bboxBottom = r.bottom
121+
122+
let bboxTop: number
123+
let bboxBottom: number
124+
if (
125+
anchorRect &&
126+
focusRect &&
127+
Number.isFinite(anchorRect.top) &&
128+
Number.isFinite(focusRect.top) &&
129+
anchorRect.height > 0 &&
130+
focusRect.height > 0
131+
) {
132+
const ay = anchorRect.top + anchorRect.height / 2
133+
const fy = focusRect.top + focusRect.height / 2
134+
// 加 2px 容差吃掉 caret 微抖
135+
bboxTop = Math.min(ay, fy) - 2
136+
bboxBottom = Math.max(ay, fy) + 2
137+
} else {
138+
if (nativeRects.length === 0) return null
139+
bboxTop = Infinity
140+
bboxBottom = -Infinity
141+
for (const r of nativeRects) {
142+
if (r.top < bboxTop) bboxTop = r.top
143+
if (r.bottom > bboxBottom) bboxBottom = r.bottom
144+
}
145+
if (!Number.isFinite(bboxTop) || !Number.isFinite(bboxBottom)) return null
98146
}
99-
if (!Number.isFinite(bboxTop) || !Number.isFinite(bboxBottom)) return null
100147

101148
// 找 pdf-viewer 子树内所有 textLayer spans,过滤中心 y 在 bbox 内
102149
// 注:**不过滤 whitespace-only spans** — pdf.js textLayer 用 " " span 表词间空格
@@ -134,14 +181,46 @@ export class DomSelectionProvider implements SelectionProvider {
134181
}
135182
}
136183

137-
// 拿 native 选区的真实 x 边界,裁顶/底行(防止包含 user 没选到的 x 范围)
138-
const sortedNative = [...nativeRects].sort((a, b) => a.top - b.top || a.left - b.left)
139-
const firstNative = sortedNative[0]
140-
const lastNative = sortedNative[sortedNative.length - 1]
141-
const selStartX = firstNative.left
142-
const selEndX = lastNative.right
143-
const selStartCy = firstNative.top + firstNative.height / 2
144-
const selEndCy = lastNative.top + lastNative.height / 2
184+
// 拿 user 拖拽真实 x 边界 — 同样优先 anchor/focus,fallback 到 nativeRects
185+
let selStartX: number
186+
let selEndX: number
187+
let selStartCy: number
188+
let selEndCy: number
189+
if (
190+
anchorRect &&
191+
focusRect &&
192+
Number.isFinite(anchorRect.top) &&
193+
Number.isFinite(focusRect.top) &&
194+
anchorRect.height > 0 &&
195+
focusRect.height > 0
196+
) {
197+
const ax = anchorRect.left
198+
const ay = anchorRect.top + anchorRect.height / 2
199+
const fx = focusRect.left
200+
const fy = focusRect.top + focusRect.height / 2
201+
// 把 anchor/focus 按视觉位置归到 start/end(top→bottom,同行 left→right)
202+
const aFirst =
203+
Math.abs(ay - fy) <= 5 ? ax <= fx : ay < fy
204+
if (aFirst) {
205+
selStartX = ax
206+
selStartCy = ay
207+
selEndX = fx
208+
selEndCy = fy
209+
} else {
210+
selStartX = fx
211+
selStartCy = fy
212+
selEndX = ax
213+
selEndCy = ay
214+
}
215+
} else {
216+
const sortedNative = [...nativeRects].sort((a, b) => a.top - b.top || a.left - b.left)
217+
const firstNative = sortedNative[0]
218+
const lastNative = sortedNative[sortedNative.length - 1]
219+
selStartX = firstNative.left
220+
selEndX = lastNative.right
221+
selStartCy = firstNative.top + firstNative.height / 2
222+
selEndCy = lastNative.top + lastNative.height / 2
223+
}
145224

146225
// **关键修法**:每行合并成一个 rect 从 minLeft → maxRight 消除字间/词间所有 gaps
147226
// (user 2026-05-25 反馈每个字独立 rect 间有 1-2px 字间距和 word spacing 显白,
@@ -223,6 +302,17 @@ export class DomSelectionProvider implements SelectionProvider {
223302
return count > 1
224303
}
225304

305+
/**
306+
* 读 pdf-viewer wrapper 上的 `data-file-path`(file-tabs.tsx 设置)。
307+
* 用于把 PDF/office 选区送回 chat 时附 source path → 卡片显示文件名 + LLM 看到出处。
308+
* 找不到返回空字符串(罕见 — wrapper 缺 path 属性,fallback 走纯 text 路径)。
309+
*/
310+
private readPdfViewerFilePath(target: Element): string {
311+
const pdfViewer = target.closest<HTMLElement>('[data-slot="pdf-viewer"]')
312+
if (!pdfViewer) return ""
313+
return pdfViewer.dataset.filePath ?? ""
314+
}
315+
226316
clear(): void {
227317
if (typeof window === "undefined") return
228318
try {

0 commit comments

Comments
 (0)