feat(web-shell): support copying selected reply content (#13833)

* feat(web-shell): support copying selected reply content

* fix(web-shell): preserve selected reply data and menu lifecycle

* fix(web-shell): keep selection copying faithful to visible content

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-12 09:43:56 +00:00
1 parent aaf97b869d
commit f3f5e005a3
21 files changed
+2247 -21

No files matched your search

@@ -0,0 +1,41 @@
# Web Shell selected reply copying
[English](2026-10-10-web-shell-selection-copy.md) | [简体中文](2026-10-10-web-shell-selection-copy.zh-CN.md)
## Problem and scope
Reply copy currently copies the entire Markdown source. Users reading a long reply need to copy only selected content, either as visible text or Markdown. Native browser copying offers no explicit choice and may include code and table controls.
This change adds a selection popover to a single assistant reply body in interactive mode. It does not change whole-reply copying, native keyboard copying, user messages, thinking blocks, sub-agent result panels, workspace-agent run panels, document exports, or mobile selection menus. It writes text, not HTML rich text.
## Proposed behavior
After a mouse selection finishes inside one reply, show two actions near the selection: **Copy plain text** / **Copy Markdown** (Chinese: **复制纯文本** / **复制 Markdown**). Preserve selection while interacting with the popover. Close on empty or external selection, content changes, scrolling, window resizing, async reply size changes, Escape, outside interaction, and successful copying. Failed clipboard writes keep the menu available for retry.
Both endpoints must belong to the same reply body. Releasing the mouse outside the body still opens the menu when the drag started inside it and the range stays within it. Cross-message and disjoint multi-range selections keep native browser copying. Streaming replies do not offer the menu until settled. Keyboard selection retains native copying and does not open this menu. Touch selection retains the native browser menu.
Drags starting in an advanced table retain its existing rectangular cell selection, toolbar actions, and native copy shortcut. They do not open the reply selection menu. Native text selections spanning a table from surrounding reply text can still be copied by the reply menu. Diagram pan/zoom and control interactions do not open this menu. Their gesture marker is separate from copy exclusions, so dragging from a code header into code can still open the menu.
## Implementation and decisions
Use the existing scoped Radix popover, virtual selection anchor, portal container, and clipboard fallback helper. A small component owns selection lifecycle, snapshotting the selected text before menu focus can change it. Track mouse gesture ownership in the document capture phase using the composed event path, so outside releases work and portal menu clicks are not treated as new selections. Feature-detect ShadowRoot selection APIs and reject ranges outside the body.
Clone only intersecting DOM nodes, trimming boundary text while retaining ancestors. This preserves formatting when selecting part of a heading, bold span, list item, or code block. Explicitly exclude code headers and table controls, operation columns, hidden filler cells, and expanded row detail panels. Omit list markers for ancestors whose own content is not selected, while retaining selected nested items. Convert the cleaned semantic subtree to plain text with boundaries before and after blocks, and to Markdown with the Turndown version already used by this repository, declared directly by Web Shell. Add GFM table, tight/loose task-list checkbox state, and strikethrough rules; retain selected empty-cell columns and original ordinals across blank list items and explicit item values (including footnotes). A boundary-touching row contributes no fabricated empty row; fully selected empty cells remain. Task state comes only from its selected task block, never from a separate note. Nested subsets retain a line break before following siblings. Subtrees marked hidden are excluded before traversal, while visible chart data remains copyable without truncation notices. Markdown normalizes syntax rather than reproducing the exact original spelling. No source-offset mapping is introduced: rendered transformations and visible sorted/filtered tables must match what the user selected.
Preserve code indentation and language, close code fences safely, escape table pipes, and pad partial table rows into a valid Markdown table. A selection without table headers gets empty headers; never add unselected data. Custom renderers without standard semantic HTML and rendered diagrams can only expose the semantics present in their DOM; reproducing their original source is outside this change.
Keep selected images as Markdown images and use their alternative descriptions in plain text. Image-only selections without alternative descriptions and horizontal rules still offer Markdown copying; disable the plain-text action when that projection is empty. A fully selected KaTeX expression uses its existing TeX annotation once: plain text contains the expression source, and Markdown wraps it with inline or display math delimiters. Partial expressions copy only selected visible text, without adding unselected source. Clipboard fallback focuses the chosen menu action and stays inside its Radix layer, including ShadowRoot layers and host dialogs outside a ShadowRoot; failures restore the original selection for retry.
## Affected files
Web Shell assistant rendering, code/table copy exclusion attributes, a selection menu component and conversion utility, bilingual UI strings, focused unit and browser tests, package dependencies and lockfile. The transcript build substitutes a plain body wrapper for the interactive selection component, so the menu and Turndown are absent from the exported document entry. No daemon or core changes.
## Validation and acceptance
Dry-run against the global qwen Web Shell, then run the local built Web Shell with fixed mock-daemon replies and real browser clipboard writes. Validate mouse partial selection, both clipboard results, partial bold/link/heading/list/code/table selections, nested-list subsets, compact DOM block boundaries, keyboard exclusion, menu repositioning after reselection, real ShadowRoot and host-dialog clipboard fallback, transcript bundle size, cross-paragraph boundaries, repeated text, UI exclusion, dismissal and clipboard failure, whole-reply copy, Chinese labels, and scoped portal rendering. Check React 18-compatible ref paths, build, typecheck, focused tests, and review the full diff twice.
The selected content alone must be copied, Markdown must remain parseable with selected formatting, pure text must retain useful line breaks, and standard copying must continue working. Screenshots are evidence of the mocked frontend only, not real model or Git operations.
## Open questions
None. The requested “formatted content (markdown)” is interpreted as Markdown text; HTML rich-text copying is a separate feature.
@@ -0,0 +1,41 @@
# Web Shell 回复选区复制
[English](2026-10-10-web-shell-selection-copy.md) | [简体中文](2026-10-10-web-shell-selection-copy.zh-CN.md)
## 问题与范围
回复复制目前复制整条 Markdown 原文。阅读长回复时,用户需要只复制选中内容,并明确选择可见纯文本或 Markdown。浏览器原生复制没有此选择,也可能夹带代码和表格操作控件。
本次为交互模式下单条 assistant 回复正文增加选区浮层。不改变整条回复复制、原生快捷键复制、用户消息、思考块、子代理结果面板、工作区代理运行面板、文档导出或移动端选区菜单。写入的是文本,不是 HTML 富文本。
## 预期行为
在单条回复内用鼠标完成选择后,在选区附近显示 **复制纯文本** / **复制 Markdown**(英文:**Copy plain text** / **Copy Markdown**)。操作浮层时保留原选区。选区清空或移到外部、正文变化、滚动、窗口调整、异步内容导致正文尺寸变化、Escape、点击外部和复制成功时关闭。剪贴板写入失败时保留菜单供重试。
选区两端必须属于同一正文。从正文内开始拖选、选区仍在正文内时,即使在正文外松开鼠标也显示菜单。跨消息及多段不连续选区沿用浏览器原生复制行为。流式回复在结束前不显示菜单。键盘选区保留原生复制行为,不打开此菜单。触摸选择保留浏览器原生菜单。
从高级表格内开始的拖选继续使用原有矩形单元格选区、工具栏动作和原生复制快捷键,不弹出正文划词菜单。从表格周围正文开始并跨过表格的原生文本选区仍可由正文菜单复制。图表平移、缩放和控件操作不打开此菜单。手势归属标记与复制内容排除标记分离,因此从代码头部拖选到代码仍可打开菜单。
## 实现与决策
复用已封装的 Radix popover、虚拟选区锚点、作用域 portal 容器和剪贴板降级助手。小组件负责选区生命周期,并在菜单聚焦改变选择前保存复制内容。在 document 捕获阶段通过事件的组合路径跟踪鼠标手势归属,支持正文外松开,也避免把 portal 菜单点击视为新选区。对 ShadowRoot 选区 API 做能力检测,拒绝正文外的 Range。
只克隆与选区相交的 DOM 节点,裁剪边界文本并保留祖先结构,使标题、粗体、列表或代码块内的局部选择仍带对应格式。明确排除代码头部、表格工具栏、操作列、隐藏占位单元格和展开行详情面板。祖先列表项自身内容未被选中时,省略其列表标记,保留选中的嵌套子项。清理后的语义树通过块前后的边界转为纯文本,通过仓库已有版本的 Turndown 转为 Markdown,并在 Web Shell 中直接声明依赖;补充 GFM 表格、紧凑与宽松任务列表勾选状态和删除线规则;保留选中空单元格的列位置、跨空列表项后的原始序号以及显式列表项序号(包括脚注)。仅触及边界的表格行不生成虚构空行,完整选中的空单元格仍保留。任务状态只来自选中的任务块,不转移到独立备注。嵌套子集与后续兄弟项之间保留换行。在遍历前排除标记为隐藏的子树,同时保留可见图表数据,去除截断提示。Markdown 规范化语法,不承诺原文语法逐字一致。不引入源码偏移映射:渲染转换、排序和过滤后的可见表格应忠于用户实际选区。
保留代码缩进和语言,安全闭合代码围栏,转义表格竖线,将局部表格行补齐为有效 Markdown 表格。未选中表头时生成空表头,绝不补入未选中的数据。缺少标准语义 HTML 的自定义渲染器和已渲染图表只能提供 DOM 中存在的语义,还原其原始源码不在本次范围。
选中图片时,Markdown 保留图片,纯文本使用图片的替代描述。仅含无替代描述图片或水平分隔线的选区仍可复制 Markdown,纯文本为空时禁用对应操作。完整选中的 KaTeX 公式只提取一次已有 TeX annotation:纯文本包含表达式源码,Markdown 补上行内或块级公式分隔符。局部公式只复制选中的可见文本,不补入未选中的源码。剪贴板降级操作先聚焦所选菜单动作,并保持在其 Radix 层内,包含 ShadowRoot 内的层以及 ShadowRoot 外的宿主弹窗;失败时恢复原选区供重试。
## 影响文件
Web Shell assistant 渲染、代码/表格复制排除属性、选区菜单组件和转换工具、中英文界面文案、针对性单元与浏览器测试、包依赖和锁文件。transcript 构建使用普通正文容器替换交互式选区组件,因此导出文档入口不包含菜单与 Turndown。不涉及 daemon 或 core。
## 验证与验收
先用全局 qwen Web Shell 验证基线,再用本地构建的 Web Shell、固定模拟 daemon 回复和真实浏览器剪贴板验证。覆盖鼠标局部选区、两种复制结果、局部粗体/链接/标题/列表/代码/表格、嵌套列表子集、紧凑 DOM 块边界、键盘不触发菜单、重新选区后的菜单定位、ShadowRoot 与宿主弹窗内的真实降级复制、transcript 包体积、跨段落、重复文本、界面控件排除、关闭行为和剪贴板失败、整条回复复制、中文文案及作用域 portal 渲染。检查 React 18 兼容的 ref 路径,执行构建、类型检查、相关测试,并完整审阅两遍 diff。
只复制选中内容;Markdown 可正确解析并保留所选格式;纯文本保留合理换行;原有复制继续可用。截图仅证明模拟前端行为,不证明真实模型或 Git 操作。
## 未决问题
无。用户要求的“带格式的内容(markdown)”解释为 Markdown 文本;HTML 富文本复制是另一项功能。
@@ -418,6 +418,15 @@ describe('build artifact — transcript entry (#11031)', () => {
expect(js.length).toBeLessThan(1_325_000);
});
it('keeps selection copying and its converter out of the transcript entry', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toContain('data-selection-copy-math');
expect(js).not.toContain('selectionCopyListWrapper');
});
it('carries no Live Voice strings and looks none up', () => {
// vite.lib.config.ts swaps client/live/messages.ts for an empty stub in
// this build, which keeps ~200 entries out of every exported document. It
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebShellCustomizationProvider } from '../../customization';
import { I18nProvider } from '../../i18n';
import { getSelectedReplyContent } from '../../utils/selectionClipboard';
import {
TranscriptDocumentExpandedProvider,
TranscriptRenderModeProvider,
@@ -1066,3 +1067,102 @@ describe('AssistantMessage squad replies', () => {
expect(container.textContent).not.toContain('collab.');
});
});
describe('AssistantMessage selection copy gate', () => {
it.each([
['interactive', false, true],
['interactive', true, false],
['readonly', false, false],
['document', false, false],
] as const)(
'mode=%s streaming=%s opens=%s',
async (mode, streaming, opens) => {
const container = render(
<AssistantMessage
content="Select **words** here."
isStreaming={streaming}
/>,
'en',
mode,
);
const text = container.querySelector('strong')!.firstChild!;
const range = document.createRange();
range.selectNodeContents(text);
for (const type of ['pointerdown', 'pointerup']) {
if (type === 'pointerup') {
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
}
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
await act(async () => text.parentElement!.dispatchEvent(event));
}
expect(!!document.querySelector('[data-slot="popover-content"]')).toBe(
opens,
);
window.getSelection()!.removeAllRanges();
},
);
});
describe('rendered code selection semantics', () => {
it('preserves real loose task states without transferring them to notes', () => {
const container = render(
<AssistantMessage
content={
'- [x] Done\n\n Completed note.\n\n- [ ] Pending\n\n Pending note.'
}
/>,
);
const range = document.createRange();
range.selectNodeContents(container);
const value = getSelectedReplyContent(container, range)!;
expect(value.markdown).toMatch(/-\s+\[x\][^\S\n]+Done/);
expect(value.markdown).toMatch(/-\s+\[ \][^\S\n]+Pending/);
const items = container.querySelectorAll('li.task-list-item');
expect(items).toHaveLength(2);
for (const item of items) {
const note = item.querySelectorAll('p')[1];
expect(note).toBeDefined();
range.selectNodeContents(note);
expect(getSelectedReplyContent(container, range)!.markdown).not.toMatch(
/\[[x ]\]/,
);
}
});
it('preserves the actual code language and omits its rendered header', async () => {
const container = render(
<AssistantMessage content={'```typescript\nconst answer = 42;\n```'} />,
);
await act(async () => {
await vi.waitFor(() =>
expect(container.querySelector('pre')).not.toBeNull(),
);
});
const range = document.createRange();
range.selectNodeContents(container);
const value = getSelectedReplyContent(container, range)!;
expect(value.text).toBe('const answer = 42;');
expect(value.markdown).toBe('```typescript\nconst answer = 42;\n```');
expect(
container.querySelector(
'[data-selection-code-language="typescript"] pre',
),
).not.toBeNull();
});
it('omits the actual Mermaid loading placeholder from copied prose', () => {
vi.useFakeTimers();
const container = render(
<AssistantMessage
content={'Before\n\n```mermaid\ngraph TD; A-->B\n```\n\nAfter'}
/>,
);
expect(container.textContent).toContain('Rendering diagram');
const range = document.createRange();
range.selectNodeContents(container);
expect(getSelectedReplyContent(container, range)).toEqual({
text: 'Before\nAfter',
markdown: 'Before\n\nAfter',
});
});
});
@@ -9,6 +9,7 @@ import {
} from 'react';
import { LightbulbIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { Markdown } from './Markdown';
import { SelectionCopyMenu } from './SelectionCopyMenu';
import { TurnSources } from '../sources/TurnSources';
import {
useWebShellCustomization,
@@ -80,7 +81,8 @@ export const AssistantMessage = memo(function AssistantMessage({
onSourceOpen,
}: AssistantMessageProps) {
const { t } = useI18n();
const documentMode = useTranscriptRenderMode() === 'document';
const renderMode = useTranscriptRenderMode();
const documentMode = renderMode === 'document';
const { renderAssistantTurnFooter } = useWebShellCustomization();
const [copied, flashCopied] = useCopiedFlash();
const [branchPending, setBranchPending] = useState(false);
@@ -206,13 +208,17 @@ export const AssistantMessage = memo(function AssistantMessage({
isLocateFlashing ? ` ${flashStyles.flash}` : ''
}`}
>
<div className={styles.contentBody}>
<SelectionCopyMenu
className={styles.contentBody}
content={content}
enabled={renderMode === 'interactive' && !isStreaming}
>
<Markdown
content={content}
source="assistant"
isStreaming={isStreaming}
/>
</div>
</SelectionCopyMenu>
</div>
)}
{agentMessage?.kind === 'agent_message' && (
@@ -7,6 +7,7 @@ import { WebShellPortalRootContext } from '../../portalRoot';
import { InteractionBlockContext } from '../../interactionBlockContext';
import { immediateClipboardWrite } from '../../test/reactHarness';
import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
import { getSelectedReplyContent } from '../../utils/selectionClipboard';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@@ -3701,3 +3702,89 @@ describe('EnhancedMarkdownTable', () => {
expect(container.textContent).not.toContain('升序排序');
});
});
describe('rendered table selection semantics', () => {
it('preserves renderer-padded empty cells in copied rows', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Name</th>
<th>Value</th>
<th>Extra</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>Alpha</td>
<td></td>
<td>42</td>
</tr>
<tr>
<td>Beta</td>
</tr>
</tbody>,
]);
const range = document.createRange();
range.selectNodeContents(container);
expect(getSelectedReplyContent(container, range)).toEqual({
text: 'Name\tValue\tExtra\nAlpha\t\t42\nBeta\t\t',
markdown:
'| Name | Value | Extra |\n| --- | --- | --- |\n| Alpha | | 42 |\n| Beta | | |',
});
});
it('marks the real table interaction surface and omits toolbar/action chrome', () => {
const container = renderTable();
const range = document.createRange();
range.selectNodeContents(container);
expect(
container.querySelector('[data-selection-copy-table] table'),
).not.toBeNull();
expect(getSelectedReplyContent(container, range)).toEqual({
text: 'Team\tScore\nAlpha\t10\nBeta\t2\nGamma\t30',
markdown:
'| Team | Score |\n| --- | --- |\n| Alpha | 10 |\n| Beta | 2 |\n| Gamma | 30 |',
});
expect(
container
.querySelector('thead th')
?.hasAttribute('data-selection-copy-ignore'),
).toBe(true);
expect(
container
.querySelector('tbody td')
?.hasAttribute('data-selection-copy-ignore'),
).toBe(true);
});
it('omits an actual empty-table status from both copy projections', () => {
const container = renderTableContent(
<thead>
<tr>
<th>Name</th>
</tr>
</thead>,
);
expect(container.textContent).toContain('This table has no data.');
const before = document.createElement('p');
before.textContent = 'Before';
container.prepend(before);
const after = document.createElement('p');
after.textContent = 'After';
container.append(after);
const range = document.createRange();
range.selectNodeContents(container);
const value = getSelectedReplyContent(container, range)!;
expect(value.text).toBe('Before\nName\nAfter');
expect(value.markdown).toBe('Before\n\n| Name |\n| --- |\n\nAfter');
});
it('omits expanded row details while keeping the visible table once', () => {
const container = renderTable();
click(button(container, 'View details for row 1'));
expect(container.textContent).toContain('Row details');
const range = document.createRange();
range.selectNodeContents(container);
const value = getSelectedReplyContent(container, range)!;
expect(value.text).toBe('Team\tScore\nAlpha\t10\nBeta\t2\nGamma\t30');
expect(value.markdown).not.toContain('Row details');
expect(value.markdown.match(/Alpha/g)).toHaveLength(1);
});
});
@@ -2619,12 +2619,13 @@ export function EnhancedTable({
const tableContent = (
<div
ref={setShell}
data-selection-copy-table
className={`${styles.tableShell} ${densityClassName(density)} ${
freezeFirstColumn ? styles.hasFrozenColumn : ''
} ${isDragging ? styles.dragging : ''} ${fullscreen ? styles.fullscreenTable : ''}`}
>
<TooltipProvider delayDuration={300}>
<div className={styles.toolbar}>
<div className={styles.toolbar} data-selection-copy-ignore>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -2845,6 +2846,7 @@ export function EnhancedTable({
<thead>
<tr>
<th
data-selection-copy-ignore
className={`${styles.headerCell} ${styles.actionHeaderCell} ${
freezeFirstColumn ? styles.stickyActionHeaderCell : ''
}`}
@@ -2991,6 +2993,7 @@ export function EnhancedTable({
<Fragment key={row.key}>
<tr>
<td
data-selection-copy-ignore
className={`${styles.cell} ${styles.actionCell} ${
freezeFirstColumn ? styles.stickyActionCell : ''
}`}
@@ -3068,6 +3071,7 @@ export function EnhancedTable({
{detailOpen && (
<tr id={detailId} className={styles.detailRow}>
<td
data-selection-copy-ignore
className={styles.detailCell}
colSpan={
orderedVisibleColumnIndexes.length +
@@ -3115,7 +3119,7 @@ export function EnhancedTable({
</tbody>
</table>
{visibleRows.length === 0 && (
<div className={styles.emptyState}>
<div className={styles.emptyState} data-selection-copy-ignore>
{table.rows.length === 0
? t('markdownTable.empty')
: t('markdownTable.emptyFiltered')}
@@ -5,6 +5,7 @@ import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
import { getSelectedReplyContent } from '../../utils/selectionClipboard';
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
@@ -324,3 +325,36 @@ describe('Markdown mermaid zoom', () => {
expect(viewport.style.height).toBe('232px');
});
});
describe('Markdown Mermaid selection copying', () => {
it.each(['code', 'error'])(
'preserves the mermaid language in %s view',
async (viewMode) => {
if (viewMode === 'error')
mermaidMock.render.mockRejectedValueOnce(new Error('invalid diagram'));
const view = mountMermaid('interactive');
await startMermaidRender();
if (viewMode === 'code') {
const button = [...view.container.querySelectorAll('button')].find(
(button) => button.textContent === 'mermaid.viewCode',
)!;
act(() => button.click());
}
expect(view.container.querySelector('pre')).not.toBeNull();
const range = document.createRange();
range.selectNodeContents(view.container);
expect(getSelectedReplyContent(view.container, range)).toEqual({
text: 'graph TD\nA --> B',
markdown: '```mermaid\ngraph TD\nA --> B\n```',
});
},
);
it('marks the rendered pan surface separately from projection exclusion', async () => {
const view = mountMermaid('interactive');
await startMermaidRender();
const viewport =
view.container.querySelector('svg')!.parentElement!.parentElement!;
expect(viewport.hasAttribute('data-selection-copy-ignore')).toBe(true);
expect(viewport.hasAttribute('data-selection-copy-gesture')).toBe(true);
});
});
@@ -478,8 +478,8 @@ function MermaidBlock({ code }: { code: string }) {
if (error) {
return (
<div className={styles.codeBlock}>
<div className={styles.codeBlockHeader}>
<div className={styles.codeBlock} data-selection-code-language="mermaid">
<div className={styles.codeBlockHeader} data-selection-copy-ignore>
<span className={styles.codeBlockLang}>
{t('mermaid.errorLabel')}
</span>
@@ -492,8 +492,8 @@ function MermaidBlock({ code }: { code: string }) {
}
return (
<div className={styles.codeBlock}>
<div className={styles.codeBlockHeader}>
<div className={styles.codeBlock} data-selection-code-language="mermaid">
<div className={styles.codeBlockHeader} data-selection-copy-ignore>
<span className={styles.codeBlockLang}>{t('mermaid.label')}</span>
<span className={styles.mermaidActions}>
{viewMode === 'diagram' && (
@@ -546,6 +546,8 @@ function MermaidBlock({ code }: { code: string }) {
) : !svg ? (
<div
className={`${styles.mermaidBlock} ${styles.mermaidLoading} ${styles.mermaidInline}`}
data-selection-copy-ignore
data-selection-copy-gesture
>
<span>{t('mermaid.rendering')}</span>
</div>
@@ -555,6 +557,8 @@ function MermaidBlock({ code }: { code: string }) {
canPan && isDragging ? styles.mermaidDragging : ''
}`}
ref={viewportRef}
data-selection-copy-ignore
data-selection-copy-gesture
style={diagramBox ? { height: diagramBox.canvasHeight } : undefined}
onMouseDown={handleMouseDown}
onDoubleClick={resetZoomAndPan}
@@ -676,8 +680,8 @@ function CodeBlock({
}
return (
<div className={styles.codeBlock}>
<div className={styles.codeBlockHeader}>
<div className={styles.codeBlock} data-selection-code-language={lang}>
<div className={styles.codeBlockHeader} data-selection-copy-ignore>
<span className={styles.codeBlockLang}>{label}</span>
<button className={styles.codeBlockCopy} onClick={handleCopy}>
{copied ? t('code.copied') : t('code.copy')}
@@ -0,0 +1,466 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SelectionCopyMenu } from './SelectionCopyMenu';
import { SelectionCopyMenu as TranscriptSelectionCopyMenu } from './SelectionCopyMenu.transcript-stub';
import { I18nProvider } from '../../i18n';
import { WebShellPortalRootContext } from '../../portalRoot';
let root: Root;
let container: HTMLDivElement;
let portal: HTMLDivElement;
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
portal?.remove();
window.getSelection()?.removeAllRanges();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function mount(
enabled = true,
content = 'reply',
language: 'en' | 'zh-CN' = 'en',
) {
if (!container) container = document.createElement('div');
if (!portal) portal = document.createElement('div');
document.body.append(container, portal);
root = createRoot(container);
const render = (value: string) =>
act(() =>
root.render(
<I18nProvider language={language}>
<WebShellPortalRootContext.Provider value={portal}>
<SelectionCopyMenu
className="reply-body"
content={value}
enabled={enabled}
>
<p>
Before <strong>bold text</strong> after
</p>
</SelectionCopyMenu>
</WebShellPortalRootContext.Provider>
</I18nProvider>,
),
);
render(content);
return render;
}
async function choose(
pointerType = 'mouse',
releaseOutside = false,
startOutside = false,
) {
const pointer = (type: string) => {
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: pointerType });
return event;
};
await act(async () =>
(startOutside
? document.body
: container.querySelector('strong')!
).dispatchEvent(pointer('pointerdown')),
);
const text = container.querySelector('strong')!.firstChild!;
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 4);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
await act(async () =>
(releaseOutside
? document.body
: container.querySelector('strong')!
).dispatchEvent(pointer('pointerup')),
);
}
describe('SelectionCopyMenu', () => {
it('opens when dragging from the reply and releasing outside its body', async () => {
mount();
await choose('mouse', true);
expect(window.getSelection()?.toString()).toBe('bold');
expect(
portal.querySelector('[data-slot="popover-content"]'),
).not.toBeNull();
});
it('keeps handling a valid selection released inside the reply', async () => {
mount();
await choose('mouse', false, true);
expect(
portal.querySelector('[data-slot="popover-content"]'),
).not.toBeNull();
});
it('does not reopen a dismissed menu after an unrelated outside release', async () => {
mount();
await choose();
act(() => document.dispatchEvent(new Event('scroll')));
for (const type of ['pointerdown', 'pointerup']) {
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
await act(async () => document.body.dispatchEvent(event));
}
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('does not open after a cancelled pointer selection', async () => {
mount();
await choose();
act(() => document.dispatchEvent(new Event('scroll')));
for (const type of ['pointerdown', 'pointercancel', 'pointerup']) {
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
await act(async () =>
(type === 'pointerdown'
? container.querySelector('strong')!
: document.body
).dispatchEvent(event),
);
}
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('leaves drags starting in advanced tables to their own selection behavior', async () => {
mount();
await choose();
act(() => document.dispatchEvent(new Event('scroll')));
const table = document.createElement('div');
table.setAttribute('data-selection-copy-table', '');
container.querySelector('.reply-body')!.append(table);
for (const type of ['pointerdown', 'pointerup']) {
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
await act(async () =>
(type === 'pointerdown'
? table
: container.querySelector('strong')!
).dispatchEvent(event),
);
}
expect(window.getSelection()?.toString()).toBe('bold');
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
await act(async () =>
table.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Shift', bubbles: true }),
),
);
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('uses the scoped popover portal, keeps selection and copies partial Markdown', async () => {
const write = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
mount();
await choose();
expect(
portal.querySelector('[data-slot="popover-content"]'),
).not.toBeNull();
expect(window.getSelection()?.toString()).toBe('bold');
const button = [...portal.querySelectorAll('button')].find(
(item) => item.textContent === 'Copy Markdown',
)!;
await act(async () => button.click());
expect(write).toHaveBeenCalledWith('**bold**');
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('uses clear Chinese labels and copies plain text', async () => {
const write = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
mount(true, 'reply', 'zh-CN');
await choose();
const buttons = [...portal.querySelectorAll('button')];
expect(buttons.map((item) => item.textContent)).toEqual([
'复制纯文本',
'复制 Markdown',
]);
await act(async () => buttons[0].click());
expect(write).toHaveBeenCalledWith('bold');
});
it.each(['touch', 'pen'])(
'leaves %s selection to the native menu',
async (type) => {
mount();
await choose(type);
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
},
);
it('does not open when disabled for streaming or exported replies', async () => {
mount(false);
await choose();
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('closes on scroll and when content changes', async () => {
const render = mount();
await choose();
act(() => document.dispatchEvent(new Event('scroll')));
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
await choose();
render('updated');
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('closes when selection is cleared', async () => {
mount();
await choose();
act(() => {
window.getSelection()!.removeAllRanges();
document.dispatchEvent(new Event('selectionchange'));
});
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('keeps the menu available if clipboard writing fails', async () => {
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(
new Error('denied'),
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
mount();
await choose();
await act(async () =>
(portal.querySelector('button') as HTMLButtonElement).click(),
);
expect(warn).toHaveBeenCalled();
expect(
portal.querySelector('[data-slot="popover-content"]'),
).not.toBeNull();
});
it('keeps the menu and restores selection after a real fallback failure', async () => {
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(
new Error('denied'),
);
vi.spyOn(console, 'warn').mockImplementation(() => {});
const original = document.execCommand;
let copiedInsideMenu = false;
document.execCommand = vi.fn(() => {
const textarea = document.querySelector('textarea')!;
copiedInsideMenu = portal.contains(textarea);
textarea.focus();
window.getSelection()!.removeAllRanges();
document.dispatchEvent(new Event('selectionchange'));
return false;
});
try {
mount();
await choose();
await act(async () =>
(portal.querySelector('button') as HTMLButtonElement).click(),
);
expect(copiedInsideMenu).toBe(true);
expect(
portal.querySelector('[data-slot="popover-content"]'),
).not.toBeNull();
expect(window.getSelection()?.toString()).toBe('bold');
} finally {
document.execCommand = original;
}
});
it('leaves keyboard selections to native copying', async () => {
mount();
const text = container.querySelector('strong')!.firstChild!;
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 4);
window.getSelection()!.addRange(range);
for (const target of [
document.body,
container.querySelector('.reply-body')!,
]) {
await act(async () =>
target.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Shift', bubbles: true }),
),
);
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
}
});
it('keeps the transcript body wrapper and class without interactive controls', () => {
mount();
act(() =>
root.render(
<TranscriptSelectionCopyMenu
className="export-body"
content="reply"
enabled={false}
>
<pre>export code</pre>
</TranscriptSelectionCopyMenu>,
),
);
expect(container.querySelector('div.export-body > pre')?.textContent).toBe(
'export code',
);
expect(container.querySelector('button')).toBeNull();
});
it('closes on viewport resize and changed nonempty selection', async () => {
mount();
await choose();
act(() => window.dispatchEvent(new Event('resize')));
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
await choose();
act(() => {
const range = document.createRange();
range.selectNodeContents(container.querySelector('p')!);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
});
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it.each([1, 2])(
'ignores mouse button %s without computing its path',
async (button) => {
mount();
await choose();
act(() => window.dispatchEvent(new Event('resize')));
for (const type of ['pointerdown', 'pointerup']) {
const event = new MouseEvent(type, { bubbles: true, button });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
const path = vi.spyOn(event, 'composedPath');
await act(async () =>
container.querySelector('strong')!.dispatchEvent(event),
);
expect(path).not.toHaveBeenCalled();
}
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
},
);
it.each([false, true])(
'restores composer focus after a successful copy (shadow=%s)',
async (shadow) => {
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
mount();
const composer = document.createElement('textarea');
const host = document.createElement('div');
container.append(host);
const parent = shadow ? host.attachShadow({ mode: 'open' }) : host;
parent.append(composer);
composer.focus();
await choose();
await act(async () =>
(portal.querySelector('button') as HTMLButtonElement).click(),
);
expect(document.activeElement).toBe(shadow ? host : composer);
if (shadow) expect((parent as ShadowRoot).activeElement).toBe(composer);
},
);
it('leaves ignored diagram interactions alone with an existing selection', async () => {
mount();
await choose();
act(() => window.dispatchEvent(new Event('resize')));
const diagram = document.createElement('div');
diagram.setAttribute('data-selection-copy-ignore', '');
diagram.setAttribute('data-selection-copy-gesture', '');
container.querySelector('.reply-body')!.append(diagram);
for (const type of ['pointerdown', 'pointerup']) {
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
await act(async () => diagram.dispatchEvent(event));
}
expect(window.getSelection()?.toString()).toBe('bold');
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it.each([
[800, 100],
[400, 500],
])(
'dismisses after reply reflow to %s x %s but ignores the initial observer callback',
async (width, height) => {
let resized: () => void = () => {};
let bodyObserver: { disconnect: ReturnType<typeof vi.fn> } | undefined;
vi.stubGlobal(
'ResizeObserver',
class {
constructor(private callback: () => void) {}
observe(target: Element) {
if (target.matches('.reply-body')) {
resized = this.callback;
bodyObserver = { disconnect: this.disconnect };
}
}
disconnect = vi.fn();
},
);
mount();
const body = container.querySelector('.reply-body')!;
const rect = vi
.spyOn(body, 'getBoundingClientRect')
.mockReturnValue(new DOMRect(0, 0, 400, 100));
await choose();
act(() => resized());
expect(
portal.querySelector('[data-slot="popover-content"]'),
).not.toBeNull();
expect(bodyObserver).toBeDefined();
rect.mockReturnValue(new DOMRect(0, 0, width, height));
act(() => resized());
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
expect(bodyObserver!.disconnect).toHaveBeenCalledTimes(1);
},
);
it.each(['', ' '])(
'offers Markdown without blank plain-text copy for an image (prefix=%j)',
async (prefix) => {
mount();
const paragraph = container.querySelector('p')!;
paragraph.innerHTML = `${prefix}<img src="https://example.com/image.png" alt="">`;
for (const type of ['pointerdown', 'pointerup']) {
if (type === 'pointerup') {
const range = document.createRange();
range.selectNodeContents(paragraph);
window.getSelection()!.addRange(range);
}
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
await act(async () => paragraph.dispatchEvent(event));
}
const buttons = portal.querySelectorAll('button');
expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false);
},
);
it('closes when only the selection end changes', async () => {
mount();
await choose();
act(() => {
const text = container.querySelector('strong')!.firstChild!;
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 9);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
});
expect(portal.querySelector('[data-slot="popover-content"]')).toBeNull();
});
it('opens for code selected from an excluded header', async () => {
const write = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
mount();
const body = container.querySelector('.reply-body')!;
body.querySelector('p')!.innerHTML =
'<div data-selection-code-language="python"><div data-selection-copy-ignore><span>python</span></div><pre><code>answer = 42</code></pre></div>';
for (const type of ['pointerdown', 'pointerup']) {
const event = new MouseEvent(type, { bubbles: true, button: 0 });
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
if (type === 'pointerup') {
const range = document.createRange();
range.selectNodeContents(body.querySelector('code')!);
window.getSelection()!.addRange(range);
}
await act(async () =>
body
.querySelector(type === 'pointerdown' ? 'span' : 'code')!
.dispatchEvent(event),
);
}
const button = [...portal.querySelectorAll('button')].find(
(item) => item.textContent === 'Copy Markdown',
)!;
expect(button).toBeDefined();
await act(async () => button.click());
expect(write).toHaveBeenCalledWith('```python\nanswer = 42\n```');
});
});
@@ -0,0 +1,8 @@
import type { SelectionCopyMenuProps } from './SelectionCopyMenu';
export function SelectionCopyMenu({
children,
className,
}: SelectionCopyMenuProps) {
return <div className={className}>{children}</div>;
}
@@ -0,0 +1,207 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { CopyIcon, FileCodeIcon } from 'lucide-react';
import { Button } from '../ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover';
import { useI18n } from '../../i18n';
import {
writeClipboardText,
warnClipboardWriteFailure,
} from '../../utils/clipboard';
import {
getReplySelectionRange,
getSelectedReplyContent,
} from '../../utils/selectionClipboard';
export interface SelectionCopyMenuProps {
children: ReactNode;
className: string;
content: string;
enabled: boolean;
}
export function SelectionCopyMenu({
children,
className,
content,
enabled,
}: SelectionCopyMenuProps) {
const { t } = useI18n();
const bodyRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState<{
text: string;
markdown: string;
range: Range;
} | null>(null);
const anchor = useRef({ getBoundingClientRect: () => new DOMRect() });
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => setSelected(null), [content, enabled]);
useEffect(() => {
if (!selected) return;
const body = bodyRef.current!;
const document = body.ownerDocument;
const close = () => setSelected(null);
const onSelectionChange = () => {
const menu = menuRef.current;
const root = menu?.getRootNode() as Document | ShadowRoot | undefined;
if (menu?.contains(root?.activeElement ?? null)) return;
const range = getReplySelectionRange(body);
if (
!range ||
range.compareBoundaryPoints(Range.START_TO_START, selected.range) !==
0 ||
range.compareBoundaryPoints(Range.END_TO_END, selected.range) !== 0
)
close();
};
const rect = body.getBoundingClientRect();
const observer = new ResizeObserver(() => {
const next = body.getBoundingClientRect();
if (next.width !== rect.width || next.height !== rect.height) close();
});
observer.observe(body);
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('scroll', close, true);
document.defaultView?.addEventListener('resize', close);
return () => {
observer.disconnect();
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('scroll', close, true);
document.defaultView?.removeEventListener('resize', close);
};
}, [selected]);
const showSelection = useCallback(() => {
const body = bodyRef.current;
const range = enabled && body ? getReplySelectionRange(body) : null;
const value = range && body ? getSelectedReplyContent(body, range) : null;
if (!range || !value) {
setSelected(null);
return;
}
const rect = range.getBoundingClientRect();
anchor.current = { getBoundingClientRect: () => rect };
setSelected({ ...value, range: range.cloneRange() });
}, [enabled]);
useEffect(() => {
if (!enabled) return;
const body = bodyRef.current!;
const document = body.ownerDocument;
let selecting = false;
let selectingControl = false;
const onPointerDown = (event: PointerEvent) => {
selecting = false;
selectingControl = false;
if (event.pointerType !== 'mouse' || event.button !== 0) return;
const path = event.composedPath();
selectingControl = path.some(
(node) =>
node instanceof Element &&
(node.hasAttribute('data-selection-copy-table') ||
node.hasAttribute('data-selection-copy-gesture')),
);
selecting = path.includes(body) && !selectingControl;
};
const onPointerUp = (event: PointerEvent) => {
if (event.pointerType !== 'mouse' || event.button !== 0) return;
if (selectingControl) {
selectingControl = false;
setSelected(null);
return;
}
const belongsToReply = selecting || event.composedPath().includes(body);
selecting = false;
if (belongsToReply) showSelection();
};
const cancel = () => {
selecting = false;
selectingControl = false;
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', cancel, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('pointercancel', cancel, true);
};
}, [enabled, showSelection]);
const copy = (value: string, button: HTMLButtonElement) => {
let previousFocus = button.ownerDocument.activeElement;
while (previousFocus?.shadowRoot?.activeElement)
previousFocus = previousFocus.shadowRoot.activeElement;
button.focus({ preventScroll: true });
void writeClipboardText(value)
.then(() => {
setSelected(null);
if (
previousFocus instanceof HTMLElement &&
!menuRef.current?.contains(previousFocus)
)
previousFocus.focus({ preventScroll: true });
})
.catch((error: unknown) => {
const body = bodyRef.current;
if (body && menuRef.current && selected) {
const root = body.getRootNode() as Document | ShadowRoot;
const selection =
(root as Document).getSelection?.() ??
body.ownerDocument.getSelection();
selection?.removeAllRanges();
selection?.addRange(selected.range);
}
warnClipboardWriteFailure(error);
});
};
return (
<div ref={bodyRef} className={className}>
{children}
<Popover
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<PopoverAnchor virtualRef={anchor} />
{selected && (
<PopoverContent
ref={menuRef}
className="w-auto gap-0.5 p-1"
side="top"
aria-label={t('assistant.selectionCopy')}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onPointerDown={(event) => {
if (event.pointerType === 'mouse') event.preventDefault();
}}
>
<Button
variant="ghost"
className="justify-start font-normal"
disabled={!selected.text.trim()}
onClick={(event) => copy(selected.text, event.currentTarget)}
>
<CopyIcon strokeWidth={1.5} />
{t('assistant.copyPlainText')}
</Button>
<Button
variant="ghost"
className="justify-start font-normal"
onClick={(event) => copy(selected.markdown, event.currentTarget)}
>
<FileCodeIcon strokeWidth={1.5} />
{t('assistant.copyMarkdown')}
</Button>
</PopoverContent>
)}
</Popover>
</div>
);
}
@@ -0,0 +1,317 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
const reply =
'## Selection copying\n\nSelect **bold words** in this reply.\n\nThe result is $x^2 + 1$ units.\n\nBefore ![diagram](/e2e/selection-copy.png) after.';
async function dragParagraph(page: Page, paragraph: Locator) {
await page.evaluate(() => window.getSelection()?.removeAllRanges());
const start = await paragraph.evaluate((element) => {
const range = document.createRange();
range.setStart(element.firstChild!, 0);
range.setEnd(element.firstChild!, 1);
const rect = range.getBoundingClientRect();
const last = element.lastChild!;
range.setStart(last, last.textContent!.length - 1);
range.setEnd(last, last.textContent!.length);
const end = range.getBoundingClientRect();
return {
x: rect.left + 1,
y: rect.top + rect.height / 2,
endX: end.right - 1,
endY: end.top + end.height / 2,
};
});
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.endX, start.endY, { steps: 12 });
await page.mouse.up();
}
async function dragBoldSubstring(
page: Page,
paragraph: Locator,
releaseOutside = false,
) {
await page.evaluate(() => window.getSelection()?.removeAllRanges());
const points = await paragraph.locator('strong').evaluate((element) => {
const text = element.firstChild!;
const first = document.createRange();
first.setStart(text, 0);
first.setEnd(text, 1);
const last = document.createRange();
last.setStart(text, 3);
last.setEnd(text, 4);
const start = first.getBoundingClientRect();
const end = last.getBoundingClientRect();
return {
x1: start.left + 1,
y1: start.top + start.height / 2,
x2: end.right - 1,
y2: end.top + end.height / 2,
};
});
await page.mouse.move(points.x1, points.y1);
await page.mouse.down();
const paragraphBox = await paragraph.boundingBox();
await page.mouse.move(
releaseOutside ? paragraphBox!.x + paragraphBox!.width + 8 : points.x2,
points.y2,
{ steps: 12 },
);
await page.mouse.up();
}
test('@smoke partial mouse selection offers plain text and Markdown copying without changing whole-reply copy', async ({
page,
context,
}, testInfo) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.route('**/e2e/selection-copy.png', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
}),
);
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Show formatted text.', { id: 1 }),
assistantTextEvent(reply, { id: 2 }),
turnCompleteEvent('selection-copy', { id: 3 }),
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${scenario.sessionId}?theme=light&lang=en`);
await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: scenario.sessionId,
replayedCount: scenario.events.length,
}),
);
const heading = page.getByRole('heading', { name: 'Selection copying' });
await expect(heading).toBeVisible();
const row = page
.locator('[data-web-shell-message-row]')
.filter({ has: heading });
await expect
.poll(() =>
row
.locator('img')
.evaluate((image) => (image as HTMLImageElement).naturalWidth),
)
.toBe(1);
const paragraph = row.locator('p').filter({ hasText: 'Select' });
const plain = page.getByRole('button', {
name: 'Copy plain text',
exact: true,
});
const markdown = page.getByRole('button', {
name: 'Copy Markdown',
exact: true,
});
for (const [button, expected] of [
[plain, 'bold'],
[markdown, '**bold**'],
] as const) {
await dragBoldSubstring(page, paragraph);
await expect(plain).toBeVisible();
await expect(markdown).toBeVisible();
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(
'bold',
);
await button.evaluate(async (element) => {
const menu = element.closest('[data-slot="popover-content"]')!;
await Promise.all(
menu
.getAnimations({ subtree: true })
.map((animation) => animation.finished),
);
});
const buttonBox = await button.boundingBox();
await page.mouse.move(
buttonBox!.x + buttonBox!.width / 2,
buttonBox!.y + buttonBox!.height / 2,
);
await page.mouse.down();
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(
'bold',
);
await page.mouse.up();
await expect(plain).toHaveCount(0);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
expected,
);
}
for (const [button, expected] of [
[plain, 'bold words in this reply.'],
[markdown, '**bold words** in this reply.'],
] as const) {
await dragBoldSubstring(page, paragraph, true);
await expect(plain).toBeVisible();
await expect(markdown).toBeVisible();
await button.click();
await expect(plain).toHaveCount(0);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
expected,
);
}
await dragBoldSubstring(page, paragraph);
await expect(markdown).toBeVisible();
const firstMenuBox = await markdown.boundingBox();
await dragParagraph(
page,
row.locator('p').filter({ hasText: 'The result is' }),
);
await expect(markdown).toBeVisible();
await expect
.poll(async () =>
Math.abs((await markdown.boundingBox())!.y - firstMenuBox!.y),
)
.toBeGreaterThan(10);
await page.keyboard.press('Escape');
await dragBoldSubstring(page, paragraph);
await expect(plain).toBeVisible();
await page.keyboard.press('Escape');
await page.keyboard.press('Shift+ArrowRight');
await expect(plain).toHaveCount(0);
await dragBoldSubstring(page, paragraph);
await expect(plain).toBeVisible();
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await expect(plain).toHaveCount(0);
for (const [label, text, formatted] of [
[
'The result is',
'The result is x^2 + 1 units.',
'The result is $x^2 + 1$ units.',
],
[
'Before',
'Before diagram after.',
'Before ![diagram](/e2e/selection-copy.png) after.',
],
]) {
for (const [button, expected] of [
[plain, text],
[markdown, formatted],
] as const) {
await dragParagraph(page, row.locator('p').filter({ hasText: label }));
await expect(button).toBeVisible();
await button.click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
expected,
);
}
}
await dragBoldSubstring(page, paragraph);
await expect(plain).toBeVisible();
const clipboardState = await page.evaluateHandle(() => ({
write: navigator.clipboard.writeText,
exec: document.execCommand,
}));
try {
await page.evaluate(() => {
navigator.clipboard.writeText = async () => {
throw new Error('denied');
};
document.execCommand = () => false;
});
await plain.click();
await expect(plain).toBeVisible();
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(
'bold',
);
} finally {
await clipboardState.evaluate((state) => {
navigator.clipboard.writeText = state.write;
document.execCommand = state.exec;
});
await clipboardState.dispose();
}
await plain.click();
await expect(plain).toHaveCount(0);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
'bold',
);
await row.hover();
await row.getByRole('button', { name: 'Copy', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(reply);
});
test('@smoke copies through the real clipboard fallback from a ShadowRoot menu', async ({
page,
context,
}, testInfo) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Check fallback copying.', { id: 1 }),
assistantTextEvent('## Shadow copy\n\nSelect **bold words** here.', {
id: 2,
}),
turnCompleteEvent('shadow-selection-copy', { id: 3 }),
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(
`/e2e/table-fullscreen-shadow.html?sessionId=${scenario.sessionId}`,
);
await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: scenario.sessionId,
replayedCount: scenario.events.length,
}),
);
const row = page.locator('[data-web-shell-message-row]').filter({
has: page.getByRole('heading', { name: 'Shadow copy' }),
});
await expect(row).toBeVisible();
await dragBoldSubstring(page, row.locator('p'));
const button = page.getByRole('button', {
name: 'Copy Markdown',
exact: true,
});
await expect(button).toBeVisible();
const clipboard = await page.evaluateHandle(() => navigator.clipboard);
try {
await clipboard.evaluate((value) => value.writeText('SENTINEL'));
await page.evaluate(() =>
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
}),
);
await button.click();
await expect(button).toHaveCount(0);
expect(await clipboard.evaluate((value) => value.readText())).toBe(
'**bold**',
);
} finally {
await clipboard.evaluate((value) =>
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value,
}),
);
await clipboard.dispose();
}
});
+6
View File
@@ -833,6 +833,9 @@ const EN: Messages = {
'approval.option.allowAlwaysTool': 'Always allow for this tool',
'assistant.branch': 'Branch',
'assistant.copy': 'Copy',
'assistant.selectionCopy': 'Copy selection',
'assistant.copyPlainText': 'Copy plain text',
'assistant.copyMarkdown': 'Copy Markdown',
'assistant.dissatisfied': 'Not satisfied',
'assistant.satisfied': 'Satisfied',
// A workspace agent's reply in a chat session. These render in exported
@@ -5158,6 +5161,9 @@ const ZH: Messages = {
'approval.option.allowAlwaysTool': '对此工具始终允许',
'assistant.branch': '分叉',
'assistant.copy': '复制',
'assistant.selectionCopy': '复制所选内容',
'assistant.copyPlainText': '复制纯文本',
'assistant.copyMarkdown': '复制 Markdown',
'assistant.dissatisfied': '不满意',
'assistant.satisfied': '满意',
'agentMessage.status.failed': '失败',
@@ -121,6 +121,33 @@ describe('writeClipboardText (issue #9485)', () => {
expect(document.activeElement).toBe(composer);
});
it('keeps the fallback inside a focused ShadowRoot Radix layer', async () => {
delete (navigator as { clipboard?: unknown }).clipboard;
const host = document.createElement('div');
document.body.append(host);
const shadow = host.attachShadow({ mode: 'open' });
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
const button = document.createElement('button');
dialog.append(button);
shadow.append(dialog);
button.focus();
let parent: HTMLElement | null = null;
originalExecCommand = document.execCommand;
document.execCommand = vi.fn(() => {
const textarea = shadow.querySelector('textarea');
parent = textarea?.parentElement ?? null;
copiedValue =
textarea?.value.slice(textarea.selectionStart, textarea.selectionEnd) ??
null;
return !!textarea;
});
await writeClipboardText('shadow copy');
expect(parent).toBe(dialog);
expect(copiedValue).toBe('shadow copy');
expect(shadow.activeElement).toBe(button);
expect(shadow.querySelector('textarea')).toBeNull();
});
it('cleans up the temporary textarea after a fallback copy', async () => {
delete (navigator as { clipboard?: unknown }).clipboard;
@@ -158,4 +185,36 @@ describe('writeClipboardText (issue #9485)', () => {
/secure context/i,
);
});
it('finds a host dialog across nested ShadowRoots and restores the inner button', async () => {
delete (navigator as { clipboard?: unknown }).clipboard;
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
const host = document.createElement('div');
dialog.append(host);
document.body.append(dialog);
const outer = host.attachShadow({ mode: 'open' });
const innerHost = document.createElement('div');
outer.append(innerHost);
const inner = innerHost.attachShadow({ mode: 'open' });
const button = document.createElement('button');
inner.append(button);
button.focus();
let parent: HTMLElement | null = null;
originalExecCommand = document.execCommand;
document.execCommand = vi.fn(() => {
const textarea = document.querySelector('textarea');
textarea?.focus();
expect(document.activeElement).toBe(textarea);
parent = textarea?.parentElement ?? null;
copiedValue =
textarea?.value.slice(textarea.selectionStart, textarea.selectionEnd) ??
null;
return !!textarea;
});
await writeClipboardText('host dialog copy');
expect(parent).toBe(dialog);
expect(copiedValue).toBe('host dialog copy');
expect(inner.activeElement).toBe(button);
expect(document.querySelector('textarea')).toBeNull();
});
});
+12 -4
View File
@@ -68,12 +68,20 @@ function copyViaExecCommand(text: string): boolean {
// dialog or popover: a body-appended textarea sits outside the FocusScope /
// DismissableLayer, so the scope refocuses the button during select() and
// execCommand copies nothing, or the focusin dismisses a popover mid-copy.
const layerContainer =
document.activeElement?.closest(
let previousFocus = document.activeElement;
while (previousFocus?.shadowRoot?.activeElement)
previousFocus = previousFocus.shadowRoot.activeElement;
let ancestor = previousFocus;
let layer: Element | null = null;
while (ancestor && !layer) {
layer = ancestor.closest(
'[role="dialog"], [data-radix-popper-content-wrapper]',
) ?? document.body;
);
const root = ancestor.getRootNode();
ancestor = root instanceof ShadowRoot ? root.host : null;
}
const layerContainer = layer ?? document.body;
layerContainer.appendChild(textarea);
const previousFocus = document.activeElement;
try {
textarea.select();
@@ -0,0 +1,549 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import katex from 'katex';
import {
getReplySelectionRange,
getSelectedReplyContent,
} from './selectionClipboard';
const bodies: HTMLElement[] = [];
afterEach(() => {
for (const body of bodies.splice(0)) body.remove();
vi.restoreAllMocks();
window.getSelection()?.removeAllRanges();
});
function fixture(html: string) {
const body = document.createElement('div');
body.innerHTML = html;
document.body.append(body);
bodies.push(body);
return body;
}
function select(
body: HTMLElement,
start: Node,
startOffset: number,
end = start,
endOffset = start.textContent!.length,
) {
const range = document.createRange();
range.setStart(start, startOffset);
range.setEnd(end, endOffset);
return getSelectedReplyContent(body, range)!;
}
describe('selected reply clipboard content', () => {
it('keeps images in selected Markdown and their descriptions in plain text', () => {
const body = fixture(
'<p>Before <img src="https://example.com/diagram.png" alt="diagram"> after.</p><ul><li><img src="https://example.com/item.png" alt="item"></li></ul>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)).toEqual({
text: 'Before diagram after.\nitem',
markdown:
'Before ![diagram](https://example.com/diagram.png) after.\n\n- ![item](https://example.com/item.png)',
});
});
it.each([false, true])(
'copies one formula source for displayMode=%s',
(displayMode) => {
const formula = katex.renderToString('x^2 + 1', { displayMode });
const body = fixture(
displayMode
? `<p>Before</p>${formula}<p>After</p>`
: `<p>Before ${formula} After</p>`,
);
const range = document.createRange();
range.selectNodeContents(body);
const result = getSelectedReplyContent(body, range)!;
expect(result.text).toBe(
displayMode ? 'Before\nx^2 + 1\nAfter' : 'Before x^2 + 1 After',
);
expect(result.markdown).toBe(
displayMode
? 'Before\n\n$$\nx^2 + 1\n$$\n\nAfter'
: 'Before $x^2 + 1$ After',
);
},
);
it('copies only the selected visible part of a formula', () => {
const body = fixture(katex.renderToString('x^2 + 1'));
const text = body.querySelector('.katex-html .mathnormal')!.firstChild!;
expect(select(body, text, 0, text, 1)).toEqual({
text: 'x',
markdown: 'x',
});
});
it('keeps partial bold ancestors and excludes adjacent repeated text', () => {
const body = fixture('<p>Same <strong>Same bold text</strong> Same</p>');
expect(
select(body, body.querySelector('strong')!.firstChild!, 5, undefined, 9),
).toEqual({ text: 'bold', markdown: '**bold**' });
});
it('keeps partial heading, link and strikethrough formatting', () => {
const body = fixture(
'<h2>Heading</h2><p><a href="https://example.com">linked text</a> <del>removed</del></p>',
);
expect(
select(body, body.querySelector('h2')!.firstChild!, 0, undefined, 4),
).toEqual({ text: 'Head', markdown: '## Head' });
expect(
select(body, body.querySelector('a')!.firstChild!, 2, undefined, 6)
.markdown,
).toBe('[nked](https://example.com)');
expect(
select(body, body.querySelector('del')!.firstChild!, 0).markdown,
).toBe('~~removed~~');
});
it('does not escape numbered heading punctuation, including partial text', () => {
const body = fixture('<h2>2. Skills <code>/feat-dev</code></h2>');
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.markdown).toBe(
'## 2. Skills `/feat-dev`',
);
expect(
select(body, body.querySelector('h2')!.firstChild!, 0, undefined, 5)
.markdown,
).toBe('## 2. Sk');
});
it('keeps necessary paragraph and literal backslash escapes', () => {
const body = fixture('<h3>2\\. Literal slash</h3><p>2. Ordinary text</p>');
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.markdown).toBe(
'### 2\\\\. Literal slash\n\n2\\. Ordinary text',
);
});
it('retains paragraph boundaries and list/quote structure', () => {
const body = fixture(
'<p>First paragraph</p><p>Second paragraph</p><blockquote><ol start="3"><li>Third</li><li>Fourth</li></ol></blockquote>',
);
const result = select(
body,
body.querySelector('p')!.firstChild!,
6,
body.querySelectorAll('li')[1].firstChild!,
3,
);
expect(result.text).toBe('paragraph\nSecond paragraph\nThird\nFou');
expect(result.markdown).toContain('paragraph\n\nSecond paragraph');
expect(result.markdown).toMatch(/> 3\.\s+Third/);
expect(result.markdown).toMatch(/> 4\.\s+Fou/);
expect(result.markdown).not.toContain('Fourth');
});
it('keeps the original number when selecting a later ordered-list item', () => {
const body = fixture(
'<ol start="3"><li>Third</li><li>Fourth</li><li>Fifth</li></ol>',
);
const text = body.querySelectorAll('li')[1].firstChild!;
expect(select(body, text, 0, text, 4).markdown).toMatch(/^4\.\s+Four$/);
});
it('ignores an empty boundary at the end of the preceding list item', () => {
const body = fixture('<ol start="3"><li>Third</li><li>Fourth</li></ol>');
const items = body.querySelectorAll('li');
const result = select(
body,
items[0].firstChild!,
5,
items[1].firstChild!,
6,
);
expect(result.markdown).toMatch(/^4\.\s+Fourth$/);
});
it('closes fences around a subset of code without copying surrounding lines', () => {
const body = fixture(
'<div data-selection-code-language="python"><pre><code>first\n selected\nlast</code></pre></div>',
);
const text = body.querySelector('code')!.firstChild!;
expect(select(body, text, 6, text, 16)).toEqual({
text: ' selected',
markdown: '```python\n selected\n```',
});
});
it('preserves code indentation/language and uses a safe fence for highlighted code', () => {
const body = fixture(
'<div data-selection-code-language="typescript"><div data-selection-copy-ignore>typescript<button>Copy</button></div><pre><code><span> one</span>\n```\n two\n</code></pre></div>',
);
const range = document.createRange();
range.selectNodeContents(body);
const result = getSelectedReplyContent(body, range)!;
expect(result.text).toBe(' one\n```\n two');
expect(result.markdown).toBe('````typescript\n one\n```\n two\n````');
expect(result.markdown).not.toContain('Copy');
});
it('copies selected table columns with empty unselected headers and escaped pipes', () => {
const body = fixture(
'<div data-selection-copy-ignore>2 rows Copy table</div><table><thead><tr><th data-selection-copy-ignore>Actions</th><th>Name</th><th>Value</th></tr></thead><tbody><tr><td data-selection-copy-ignore>+</td><td>Alpha</td><td>A|B</td></tr><tr><td data-selection-copy-ignore>+</td><td>Beta</td><td>C</td></tr></tbody></table>',
);
const cells = body.querySelectorAll(
'tbody td:not([data-selection-copy-ignore])',
);
const result = select(
body,
cells[1].firstChild!,
0,
cells[2].firstChild!,
4,
);
expect(result.markdown).toBe(
'| | |\n| --- | --- |\n| | A\\|B |\n| Beta | |',
);
expect(result.text).toBe('A|B\nBeta');
expect(result.markdown).not.toMatch(/Name|Value|Alpha|Copy|Actions/);
});
it('copies only visible table rows and omits controls', () => {
const body = fixture(
'<div data-selection-copy-ignore>2 rows</div><table><tr><th data-selection-copy-ignore>Actions</th><th>Name<button>Filter</button></th><th>Value</th><th aria-hidden="true"></th></tr><tr><td data-selection-copy-ignore>+</td><td>Alpha</td><td>42</td><td aria-hidden="true"></td></tr><tr><td data-selection-copy-ignore colspan="3">Hidden details</td></tr></table>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)).toEqual({
text: 'Name\tValue\nAlpha\t42',
markdown: '| Name | Value |\n| --- | --- |\n| Alpha | 42 |',
});
});
it('does not turn renderer wrappers and inter-block whitespace into data', () => {
const body = fixture(
'<ol>\n<li>First</li>\n<li>Second</li>\n</ol>\n<table><tr><td><div><div>Alpha</div></div></td><td><div>42</div></td></tr></table>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.text).toBe(
'First\nSecond\nAlpha\t42',
);
});
it('preserves completed and incomplete tasks when selecting their visible text', () => {
const body = fixture(
'<ul><li><input type="checkbox" checked disabled> Done</li><li><input type="checkbox" disabled> Pending</li></ul>',
);
const items = body.querySelectorAll('li');
const result = select(body, items[0].lastChild!, 1, items[1].lastChild!, 8);
expect(result.text).toBe('Done\n Pending');
expect(result.markdown).toMatch(/-\s+\[x\]\s+Done/);
expect(result.markdown).toMatch(/-\s+\[ \]\s+Pending/);
expect(select(body, items[1].lastChild!, 1, undefined, 5).markdown).toMatch(
/-\s+\[ \]\s+Pend/,
);
});
it('does not add task markers for empty selection boundaries', () => {
const body = fixture(
'<ul><li><input type="checkbox" checked disabled> Done</li><li><input type="checkbox" disabled> Pending</li><li><input type="checkbox" disabled> Later</li></ul>',
);
const items = body.querySelectorAll('li');
for (const item of items) (item.lastChild as Text).splitText(1);
const result = select(body, items[0].lastChild!, 4, items[2].lastChild!, 0);
expect(result.text).toBe(' Pending');
expect(result.markdown).toMatch(/^-\s+\[ \]\s+Pending$/);
});
it('rejects outside and empty ranges', () => {
const body = fixture('<p>Inside</p>');
const outside = fixture('Outside');
const range = document.createRange();
range.selectNodeContents(outside);
expect(getSelectedReplyContent(body, range)).toBeNull();
window.getSelection()!.addRange(range);
expect(getReplySelectionRange(body)).toBeNull();
window.getSelection()!.removeAllRanges();
expect(getReplySelectionRange(body)).toBeNull();
});
});
describe('nested selection boundaries', () => {
it.each([
[
'<ol start="4"><li>Parent<ol><li>Sub one</li><li>Sub two</li></ol></li></ol>',
'ol ol',
'1. Sub one\n2. Sub two',
],
[
'<ul><li>Parent<ul><li>Child one</li></ul></li></ul>',
'ul ul',
'- Child one',
],
[
'<ul><li><input type="checkbox" disabled checked>Parent<ul><li>Child one</li></ul></li></ul>',
'ul ul',
'- Child one',
],
])('omits unselected ancestor markers in %s', (html, selector, expected) => {
const body = fixture(html);
const range = document.createRange();
range.selectNodeContents(body.querySelector(selector)!);
expect(getSelectedReplyContent(body, range)!.markdown).toBe(expected);
});
it('retains selected parent text and nested list structure', () => {
const body = fixture(
'<ul><li>Parent item<ul><li>Child one</li><li>Child two</li></ul></li></ul>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)).toEqual({
text: 'Parent item\nChild one\nChild two',
markdown: '- Parent item\n - Child one\n - Child two',
});
});
it('separates inline content from following cell blocks', () => {
const body = fixture(
'<table><tbody><tr><td>42<div>sub</div></td></tr></tbody></table>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.text).toBe('42 sub');
});
});
describe('review regressions', () => {
it.each(['checked', ''])('omits an unselected task state (%s)', (checked) => {
const body = fixture(
`<ul><li><p><input type="checkbox" ${checked} disabled> Task</p><p>Note</p></li></ul>`,
);
const note = body.querySelectorAll('p')[1].firstChild!;
const result = select(body, note, 0);
expect(result.text).toBe('Note');
expect(result.markdown).not.toMatch(/\[[x ]\]/);
});
it('omits task state when a drag starts at the end of its task line', () => {
const body = fixture(
'<ul><li><p><input type="checkbox" checked disabled> Task</p><p>Note</p></li></ul>',
);
const paragraphs = body.querySelectorAll('p');
const result = select(
body,
paragraphs[0].lastChild!,
5,
paragraphs[1].firstChild!,
4,
);
expect(result.text).toBe('Note');
expect(result.markdown).not.toMatch(/\[[x ]\]/);
});
it.each(['2', '0', '-2'])(
'honors explicit list value %s and subsequent ordinals',
(value) => {
const body = fixture(
`<ol><li value="${value}">First</li><li>Next</li></ol>`,
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.markdown).toBe(
`${value}. First\n${Number(value) + 1}. Next`,
);
},
);
it.each([
[
'<ul><li>Parent<ul><li>Child</li></ul></li><li>Next</li></ul>',
'- Child\n- Next',
],
[
'<ol><li>Parent<ol><li>Child</li></ol></li><li>Next</li></ol>',
'1. Child\n2. Next',
],
[
'<ul><li>A<ul><li>B<ul><li>Child</li></ul></li></ul></li><li>Next</li></ul>',
'- Child\n- Next',
],
])(
'separates nested subsets from following siblings: %s',
(html, markdown) => {
const body = fixture(html);
const items = body.querySelectorAll('li');
const child = [...items].find((item) => item.textContent === 'Child')!;
const next = items[items.length - 1];
expect(select(body, child.firstChild!, 0, next.firstChild!, 4)).toEqual({
text: 'Child\nNext',
markdown,
});
},
);
it('retains ordered-list continuation indentation', () => {
const body = fixture(
'<ol start="3"><li>Parent<ol><li>Sub one</li><li>Sub two</li></ol></li></ol>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.markdown).toBe(
'3. Parent\n 1. Sub one\n 2. Sub two',
);
});
it('rejects a content-free selection touching a table boundary', () => {
const body = fixture('<p>Before</p><table><tr><td>Cell</td></tr></table>');
expect(
select(
body,
body.querySelector('p')!.firstChild!,
6,
body.querySelector('td')!.firstChild!,
0,
),
).toBeNull();
});
it('omits a body row touched only at its start boundary', () => {
const body = fixture(
'<p>Para</p><table><tr><th>A</th><th>B</th></tr><tr><td>Cell</td><td>Data</td></tr></table>',
);
expect(
select(
body,
body.querySelector('p')!.firstChild!,
0,
body.querySelector('td')!.firstChild!,
0,
),
).toEqual({
text: 'Para\nA\tB',
markdown: 'Para\n\n| A | B |\n| --- | --- |',
});
});
it('omits a preceding row touched only at its end boundary', () => {
const body = fixture(
'<table><tr><td>First</td></tr><tr><td>Next</td></tr></table><p>After</p>',
);
expect(
select(
body,
body.querySelector('td')!.firstChild!,
5,
body.querySelector('p')!.firstChild!,
5,
),
).toEqual({
text: 'Next\nAfter',
markdown: '| |\n| --- |\n| Next |\n\nAfter',
});
});
it('rejects whitespace-only projections', () => {
const body = fixture('<p>Hello world</p>');
expect(
select(body, body.querySelector('p')!.firstChild!, 5, undefined, 6),
).toBeNull();
});
it('keeps loose task state on the same line as its selected text', () => {
const body = fixture(
'<ul><li><p><input type="checkbox" checked disabled> Done</p></li><li><p><input type="checkbox" disabled> Pending</p></li></ul>',
);
const paragraphs = body.querySelectorAll('p');
const result = select(
body,
paragraphs[0].lastChild!,
1,
paragraphs[1].lastChild!,
8,
);
expect(result.markdown).toMatch(/-\s+\[x\][^\S\n]+Done/);
expect(result.markdown).toMatch(/-\s+\[ \][^\S\n]+Pending/);
});
it.each([
['<td>Alpha</td><td></td><td>42</td>', 'Alpha\t\t42'],
['<td></td><td>42</td><td></td>', '\t42\t'],
])('retains selected empty table fields in %s', (cells, expected) => {
const body = fixture(
`<table><tr><th>Name</th><th>Value</th><th>Extra</th></tr><tr>${cells}</tr></table>`,
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.text).toBe(
`Name\tValue\tExtra\n${expected}`,
);
});
it('keeps original ordinals across blank ordered-list items', () => {
const body = fixture(
'<ol start="3"><li>Third</li><li></li><li>Fifth</li></ol>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.markdown).toMatch(
/^5\.\s+Fifth$/m,
);
});
it.each([
[
'<img src="https://example.com/diagram.png" alt="">',
'![](https://example.com/diagram.png)',
],
['<hr>', '* * *'],
])('keeps Markdown-only selections: %s', (html, expected) => {
const body = fixture(html);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)).toEqual({
text: '',
markdown: expected,
});
});
it.each([
'<svg><text>Node A</text></svg>',
'<span data-markdown-chart-loading="true">Rendering chart</span>',
'<div class="markdown-chart-error">Chart render failed.</div>',
'<a data-footnote-backref href="#internal">↩</a>',
'<div class="markdown-chart-data-view" hidden><div class="markdown-chart-data-notice">Showing 2 of 900 rows.</div><table><tr><th>region</th></tr><tr><td>EMEA</td></tr></table></div>',
'<div hidden>No data</div>',
])('excludes renderer chrome: %s', (html) => {
const body = fixture(`<p>Before</p>${html}<p>After</p>`);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)).toEqual({
text: 'Before\nAfter',
markdown: 'Before\n\nAfter',
});
});
it('retains hard line breaks', () => {
const body = fixture('<p>one<br>two</p>');
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)!.text).toBe('one\ntwo');
});
it('keeps visible chart data without the truncation notice', () => {
const body = fixture(
'<div class="markdown-chart-data-view"><div class="markdown-chart-data-notice">Showing 2 of 900 rows.</div><table><tr><th>region</th></tr><tr><td>EMEA</td></tr></table></div>',
);
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyContent(body, range)).toEqual({
text: 'region\nEMEA',
markdown: '| region |\n| --- |\n| EMEA |',
});
});
it('rejects multiple disjoint ranges without affecting native selection', () => {
const body = fixture('<p>First</p><p>Second</p>');
const ranges = [...body.querySelectorAll('p')].map((p) => {
const range = document.createRange();
range.selectNodeContents(p);
return range;
});
vi.spyOn(document, 'getSelection').mockReturnValue({
isCollapsed: false,
rangeCount: 2,
getRangeAt: (index: number) => ranges[index],
} as Selection);
expect(getReplySelectionRange(body)).toBeNull();
});
it('uses composed ranges for a reply inside a ShadowRoot', () => {
const host = fixture('');
const shadow = host.attachShadow({ mode: 'open' });
const body = document.createElement('div');
body.textContent = 'Shadow reply';
shadow.append(body);
const outer = document.createRange();
outer.selectNode(host);
const composed = {
startContainer: body.firstChild!,
startOffset: 0,
endContainer: body.firstChild!,
endOffset: 6,
};
const composedRanges = vi.fn(() => [composed]);
vi.spyOn(document, 'getSelection').mockReturnValue({
isCollapsed: false,
rangeCount: 1,
getRangeAt: () => outer,
getComposedRanges: composedRanges,
} as unknown as Selection);
expect(getReplySelectionRange(body)?.toString()).toBe('Shadow');
expect(composedRanges).toHaveBeenCalledWith({ shadowRoots: [shadow] });
});
});
@@ -0,0 +1,267 @@
import TurndownService from 'turndown';
const IGNORED =
'[data-selection-copy-ignore], [hidden], .markdown-chart-data-notice, [data-footnote-backref], [data-markdown-chart-loading], .markdown-chart-error, [aria-hidden="true"]:not(.katex-html), .katex-mathml, button, select, input, textarea, svg, style, script';
const BLOCKS = /^(P|H[1-6]|DIV|SECTION|BLOCKQUOTE|UL|OL|LI|PRE|TR)$/;
export function getReplySelectionRange(body: HTMLElement): Range | null {
const root = body.getRootNode() as Document | ShadowRoot;
const selection =
(root as Document).getSelection?.() ?? body.ownerDocument.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount !== 1)
return null;
let range = selection.getRangeAt(0);
if (root instanceof ShadowRoot) {
const composed = (
selection as Selection & {
getComposedRanges?: (options: {
shadowRoots: ShadowRoot[];
}) => StaticRange[];
}
).getComposedRanges?.({ shadowRoots: [root] })[0];
if (composed) {
range = body.ownerDocument.createRange();
range.setStart(composed.startContainer, composed.startOffset);
range.setEnd(composed.endContainer, composed.endOffset);
}
}
return !range.collapsed &&
body.contains(range.startContainer) &&
body.contains(range.endContainer)
? range
: null;
}
function cloneSelected(node: Node, range: Range): Node | null {
if (!range.intersectsNode(node)) return null;
if (node instanceof Element && node.matches(IGNORED)) return null;
if (node instanceof Element && node.matches('.katex')) {
const source = node.querySelector(
'annotation[encoding="application/x-tex"]',
)?.textContent;
const visible = node.querySelector('.katex-html');
if (source && visible) {
const walker = node.ownerDocument.createTreeWalker(
visible,
NodeFilter.SHOW_TEXT,
);
const first = walker.nextNode();
let last = first;
while (walker.nextNode()) last = walker.currentNode;
if (
first &&
last &&
range.comparePoint(first, 0) === 0 &&
range.comparePoint(last, last.textContent!.length) === 0
) {
const math = node.ownerDocument.createElement('span');
math.dataset.selectionCopyMath = node.closest('.katex-display')
? 'display'
: 'inline';
math.textContent = source;
return math;
}
return cloneSelected(visible, range);
}
}
if (node.nodeType === Node.TEXT_NODE) {
const start = node === range.startContainer ? range.startOffset : 0;
const end =
node === range.endContainer ? range.endOffset : node.textContent!.length;
return end > start
? node.ownerDocument!.createTextNode(node.textContent!.slice(start, end))
: null;
}
const clone = node.cloneNode(false);
const checkbox =
node instanceof HTMLLIElement
? node.querySelector<HTMLInputElement>(
':scope > input[type="checkbox"][disabled], :scope > p > input[type="checkbox"][disabled]:first-child',
)
: null;
let taskHost: Node | null = checkbox?.parentElement === node ? clone : null;
let ordinal = node instanceof HTMLOListElement ? node.start : 0;
for (const child of node.childNodes) {
const selected = cloneSelected(child, range);
if (child === checkbox?.parentElement) taskHost = selected;
if (clone instanceof HTMLOListElement && child instanceof HTMLLIElement) {
if (child.hasAttribute('value')) ordinal = child.value;
if (selected instanceof HTMLLIElement)
selected.dataset.selectionCopyOrdinal = String(ordinal);
ordinal++;
}
if (selected) clone.appendChild(selected);
}
if (clone instanceof HTMLLIElement) {
if (!clone.textContent?.trim() && !clone.querySelector('img')) return null;
const onlyNestedLists = Array.from(clone.childNodes).every(
(child) =>
child.nodeName === 'UL' ||
child.nodeName === 'OL' ||
(child.nodeType === Node.TEXT_NODE && !child.textContent?.trim()),
);
if (onlyNestedLists) clone.dataset.selectionCopyListWrapper = '';
if (checkbox && taskHost && !onlyNestedLists) {
const marker = clone.ownerDocument.createElement('span');
marker.dataset.selectionCopyTask = checkbox.checked ? '[x] ' : '[ ] ';
marker.textContent = marker.dataset.selectionCopyTask;
taskHost.insertBefore(marker, taskHost.firstChild);
}
}
if (
clone instanceof HTMLTableRowElement &&
!clone.textContent?.trim() &&
!clone.querySelector('img') &&
!(
range.comparePoint(node, 0) === 0 &&
range.comparePoint(node, node.childNodes.length) === 0
)
)
return null;
if (clone instanceof HTMLTableCellElement) {
clone.dataset.copyColumn = String((node as HTMLTableCellElement).cellIndex);
}
return clone.hasChildNodes() ||
(node instanceof Element && ['BR', 'HR', 'IMG'].includes(node.tagName)) ||
node instanceof HTMLTableCellElement
? clone
: null;
}
function plainText(node: Node): string {
if (node instanceof Element && node.hasAttribute('data-selection-copy-task'))
return '';
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? '';
return !text.trim() &&
/^(DIV|UL|OL|TABLE|THEAD|TBODY|TR)$/.test(node.parentNode?.nodeName ?? '')
? ''
: text;
}
const tag = node.nodeName;
if (
node instanceof Element &&
node.getAttribute('data-selection-copy-math') === 'display'
)
return `${node.textContent}\n`;
if (node instanceof HTMLImageElement) return node.alt;
if (tag === 'BR') return '\n';
if (tag === 'PRE') return `${node.textContent}\n`;
const children = Array.from(node.childNodes).reduce((text, child) => {
const value = plainText(child);
const startsBlock =
BLOCKS.test(child.nodeName) ||
(child instanceof Element &&
child.getAttribute('data-selection-copy-math') === 'display');
const separator =
value && startsBlock && text && !text.endsWith('\n') ? '\n' : '';
return text + separator + value;
}, '');
if (tag === 'TD' || tag === 'TH')
return `${children.replace(/^\n+|\n+$/g, '').replace(/\n+/g, ' ')}\t`;
if (tag === 'TR') return `${children.replace(/\t$/, '')}\n`;
return BLOCKS.test(tag) ? `${children.replace(/\n+$/, '')}\n` : children;
}
const markdown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
});
markdown.addRule('orderedListItem', {
filter: (node) => node.hasAttribute('data-selection-copy-ordinal'),
replacement: (content, node) => {
const prefix = `${node.dataset.selectionCopyOrdinal}. `;
const trailing = content.endsWith('\n') ? '\n' : '';
const indented = (content.replace(/^\n+|\n+$/g, '') + trailing).replace(
/\n/g,
`\n${' '.repeat(prefix.length)}`,
);
return prefix + indented + (node.nextSibling ? '\n' : '');
},
});
markdown.addRule('unselectedListAncestor', {
filter: (node) => node.hasAttribute('data-selection-copy-list-wrapper'),
replacement: (content, node) =>
content + (node.nextSibling && !content.endsWith('\n') ? '\n' : ''),
});
markdown.addRule('math', {
filter: (node) => node.hasAttribute('data-selection-copy-math'),
replacement: (_content, node) =>
node.dataset.selectionCopyMath === 'display'
? `\n\n$$\n${node.textContent}\n$$\n\n`
: `$${node.textContent}$`,
});
markdown.addRule('heading', {
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
replacement: (content, node) => {
const prefix = '#'.repeat(Number(node.nodeName[1]));
return `\n\n${prefix} ${content.replace(/^(\d+)\\\.(?= )/, '$1.')}\n\n`;
},
});
markdown.addRule('taskCheckbox', {
filter: (node) => node.hasAttribute('data-selection-copy-task'),
replacement: (_content, node) => node.dataset.selectionCopyTask ?? '',
});
markdown.addRule('strikethrough', {
filter: ['del', 's'],
replacement: (content) => `~~${content}~~`,
});
markdown.addRule('table', {
filter: 'table',
replacement: (_content, node) => {
const rows = Array.from((node as HTMLTableElement).rows);
const columns = [
...new Set(
rows.flatMap((row) =>
Array.from(row.cells, (cell) => Number(cell.dataset.copyColumn)),
),
),
].sort((a, b) => a - b);
const line = (row?: HTMLTableRowElement) =>
`| ${columns
.map((column) => {
const cell =
row &&
Array.from(row.cells).find(
(item) => Number(item.dataset.copyColumn) === column,
);
return cell
? markdown.turndown(cell).replace(/\n+/g, ' ').replace(/\|/g, '\\|')
: '';
})
.join(' | ')} |`;
const hasHeader = rows[0]?.querySelector('th');
return `\n\n${line(hasHeader ? rows.shift() : undefined)}\n| ${columns.map(() => '---').join(' | ')} |\n${rows.map((row) => line(row)).join('\n')}\n\n`;
},
});
markdown.addRule('code', {
filter: 'pre',
replacement: (_content, node) => {
const code = node.textContent ?? '';
let longest = 2;
for (const match of code.matchAll(/`+/g))
longest = Math.max(longest, match[0].length);
const fence = '`'.repeat(longest + 1);
const language =
node
.closest('[data-selection-code-language]')
?.getAttribute('data-selection-code-language') ?? '';
return `\n\n${fence}${language}\n${code.replace(/\n$/, '')}\n${fence}\n\n`;
},
});
export function getSelectedReplyContent(body: HTMLElement, range: Range) {
if (
!body.contains(range.startContainer) ||
!body.contains(range.endContainer)
) {
return null;
}
const selected = cloneSelected(body, range) as HTMLElement | null;
if (!selected) return null;
const text = plainText(selected).replace(/^\n+|\n+$/g, '');
const formatted = markdown.turndown(selected);
if (!text.trim() && !formatted.trim()) return null;
return { text, markdown: formatted };
}
+2
View File
@@ -82,6 +82,7 @@
"remark-math": "^6.0.0",
"shiki": "^1.29.2",
"tailwind-merge": "^3.6.0",
"turndown": "^7.2.4",
"vaul": "^1.1.2"
},
"peerDependencies": {
@@ -98,6 +99,7 @@
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.0.0",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^26.1.0",
"openapi-typescript": "^7.13.0",
+11 -6
View File
@@ -153,13 +153,14 @@ function injectCssModules(): Plugin {
// The transcript renderer is inlined into every `/export html` document, under
// a byte budget that web-templates enforces at build time. Strings for
// surfaces the read-only transcript can never render have no business there:
// the Live Voice dialog and setup card alone are ~200 entries. Matching on the
// the Live Voice dialog and setup card alone are ~200 entries. The selection
// menu is also interactive-only; keep its converter out of the entry. Matching on the
// resolved id (not the specifier) keeps this working however the module is
// imported. Vite's ids use forward slashes on every platform while
// `path.resolve` returns backslashes on Windows, so both sides go through
// `normalizePath`: compared raw, the stub would never apply there and the
// Windows build would blow the budget this exists to protect.
const TRANSCRIPT_DEAD_MESSAGES = new Map(
const TRANSCRIPT_STUBS = new Map(
[
['./client/live/messages.ts', './client/live/messages.transcript-stub.ts'],
[
@@ -182,15 +183,19 @@ const TRANSCRIPT_DEAD_MESSAGES = new Map(
'./client/settings/messages.ts',
'./client/settings/messages.transcript-stub.ts',
],
[
'./client/components/messages/SelectionCopyMenu.tsx',
'./client/components/messages/SelectionCopyMenu.transcript-stub.tsx',
],
].map(([module, stub]) => [
normalizePath(resolve(__dirname, module)),
normalizePath(resolve(__dirname, stub)),
]),
);
function stubTranscriptDeadMessages(): Plugin {
function stubTranscriptModules(): Plugin {
return {
name: 'web-shell-stub-transcript-dead-messages',
name: 'web-shell-stub-transcript-modules',
enforce: 'pre',
async resolveId(source, importer, options) {
const resolved = await this.resolve(source, importer, {
@@ -198,7 +203,7 @@ function stubTranscriptDeadMessages(): Plugin {
skipSelf: true,
});
return resolved
? (TRANSCRIPT_DEAD_MESSAGES.get(normalizePath(resolved.id)) ?? null)
? (TRANSCRIPT_STUBS.get(normalizePath(resolved.id)) ?? null)
: null;
},
};
@@ -206,7 +211,7 @@ function stubTranscriptDeadMessages(): Plugin {
export default defineConfig(({ mode }) => ({
plugins: [
...(mode === 'transcript' ? [stubTranscriptDeadMessages()] : []),
...(mode === 'transcript' ? [stubTranscriptModules()] : []),
react(),
tailwindcss(),
injectCssModules(),
+6
View File
@@ -1591,6 +1591,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
turndown:
specifier: ^7.2.4
version: 7.2.4
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -1619,6 +1622,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.0
version: 19.2.3(@types/[email protected])
'@types/turndown':
specifier: ^5.0.6
version: 5.0.6
'@vitejs/plugin-react':
specifier: ^4.2.0
version: 4.7.0([email protected])([email protected](@types/[email protected])([email protected]))