feat(web-shell): add Markdown copy and refine reply actions (#15345)

* feat(web-shell): add Markdown copy and keep latest reply actions visible

* feat(web-shell): refine reply action tooltips and icons

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-26 14:18:51 +08:00
1 parent b668ad54c5
commit 958410f5d2
25 files changed
+1077 -216

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
# Copy Markdown from the assistant footer
[English](web-shell-copy-markdown.md) | [简体中文](web-shell-copy-markdown.zh-CN.md)
## Problem and scope
The assistant footer has a generic Copy button that already copies the original
Markdown. Users need two distinct entries: Copy content for the rendered plain
text and Copy Markdown for the complete original source. Preserve clipboard
fallback, streaming gates, and exported transcripts. The latest completed turn
keeps its footer actions visible.
## Solution and decisions
Add a Markdown M↓ icon immediately after Copy in `AssistantMessage.tsx`, with
its own `assistant.copyMarkdownSource` translation key so the footer label does
not collide with the selection menu's `assistant.copyMarkdown`. Label
the first action Copy content / 复制内容 with its own footer-specific translation
key. It extracts the entire rendered body through the text-only entry point of
the existing selection-copy serializer using a local Range, without changing
the user's selection. This
preserves paragraph/code line breaks, math source and image descriptions while
excluding controls and Markdown formatting. The second action copies the exact
original `content`. Both use the existing clipboard helper, including HTTP
fallback and failure reporting. Each button uses its own
`useCopiedFlash` state, so only the clicked button reports success. Reuse the
existing footer styling and a small inline SVG beside the existing footer icons;
no new primitive,
dependency, public option, or daemon route is needed.
## Validation and acceptance
Update `AssistantMessage.test.tsx` to check distinct rendered-text and exact
source-Markdown payloads, partial-selection preservation,
independent feedback, localization, timer cleanup, fallback, and visibility.
Add a focused browser test for adjacent buttons, real clipboard contents,
keyboard activation, and a screenshot using the existing mock daemon. This
validates UI and clipboard behavior, not a live model or Git backend.
Run build, typecheck, focused unit tests and browser E2E. Clipboard failures
must retain the current warning behavior without showing success. No open
questions remain.
## Latest turn visibility
Keep the built-in footer actions of the latest completed assistant turn visible
without hover. Compare its existing final-turn id to the latest turn that
produced a final assistant answer, pass the visibility flag through MessageItem
(including its memo comparison), and apply a local CSS class. Older turns retain hover/focus visibility; starting
a new user turn clears the previous footer's persistent visibility. Existing
streaming, background-work and document-mode gates remain authoritative. Verify
multiple turns, hover on older turns and transition to the next prompt in the
browser and focused MessageList tests.
## Footer tooltips and new-chat label
Use the shared Tooltip provider, trigger and content for all five icon actions
(Copy content, Copy Markdown source, satisfied, not satisfied and new chat). Remove native title
attributes, retain accessible names and route tooltip portals through the shared
Web Shell portal root. Reuse native button children so Radix refs remain valid
on React 18 and 19. The new-chat action uses the Lucide Split icon and is labeled
“New chat from here” / “从此处新建对话”; its request and pending behavior are
unchanged. Render Split at 13px with a 2.4 stroke and a clockwise 90-degree
rotation to match the neighboring icons. Verify keyboard focus, pointer hover, localization, portal placement
and the existing copy/branch interactions.
The timestamp retains hover/focus-only visibility even on the latest reply.
Touch devices keep the existing always-visible timestamp behavior.
TooltipContent accepts `variant="black"` for black background, white text and
a matching arrow. Omitting the variant preserves the existing theme-derived
colors. These five footer tooltips opt in; the override is local to the content
and does not change other tooltips or the host theme. Verify default/black ref
forwarding plus real browser text, background and arrow colors.
The black variant uses the same opaque dark gray for the body ring and arrow
outline, with a zero arrow offset. This keeps its edge visible in dark themes
and avoids a contrasting seam where the arrow meets the body. The default
variant retains its existing ring, outlined arrow and offsets.
Verify a high-DPI close-up as well as the functional color assertions.
@@ -0,0 +1,63 @@
# 在助手正文底部复制 Markdown
[English](web-shell-copy-markdown.md) | [简体中文](web-shell-copy-markdown.zh-CN.md)
## 问题与范围
助手正文底部已有通用复制按钮,实际复制的是原始 Markdown。用户需要在它
旁边增加明确的 Markdown 入口,并区分两种格式:复制内容输出渲染正文的纯文本,
复制 Markdown 输出完整原始源码。保留剪贴板降级、流式输出限制和导出的会话
文档行为。最后一轮已完成回复的底部操作图标保持显示。
## 方案与决策
在 `AssistantMessage.tsx` 的复制按钮后紧接增加 Markdown M↓ 图标,使用独立的
`assistant.copyMarkdownSource` 作为本地化提示和无障碍名称,避免与选区菜单的
`assistant.copyMarkdown` 重名。第一个操作使用独立的
底部文案键,标为 Copy content / 复制内容,通过局部 Range 复用现有选区复制
序列化逻辑的纯文本入口,提取整段渲染正文,不改变用户选区。保留段落和代码换行、公式源码
及图片描述,排除控件文字和 Markdown 格式标记。第二个操作复制精确的原始
`content`。两者均通过现有剪贴板工具写入,保留 HTTP 降级和失败报告。每个按钮各用一份
`useCopiedFlash` 状态,仅被点击的按钮显示成功。复用现有底部样式,在已有底部图标
旁定义一个小型内联 SVG,不增加基础组件、依赖、公共选项或 daemon 路由。
## 验证与验收
更新 `AssistantMessage.test.tsx`,检查渲染纯文本与原始 Markdown 的不同输出、
用户局部选区保留、独立成功反馈、
本地化、定时器清理、降级和显示条件。增加聚焦的浏览器测试,使用现有模拟
daemon 验证相邻按钮、真实剪贴板内容、键盘操作并截图。此验证覆盖 UI 和
剪贴板行为,不代表真实模型或 Git 后端验证。
执行构建、类型检查、相关单元测试和浏览器 E2E。剪贴板失败必须沿用现有
警告行为,不显示成功。无待决问题。
## 最后一轮的显示
最后一轮已完成的助手回复,其内置底部操作图标无需悬停即可显示。将已有的
最终回复轮次标识与最近一个产出最终助手回答的轮次比较,通过 MessageItem
传递显示标志(包括其 memo 比较),并应用局部 CSS 类。历史轮次保留悬停和焦点显示;
发送新的用户消息后,取消上一轮底部操作栏的常显。流式输出、后台任务和文档
模式已有的显示限制仍然生效。在浏览器和聚焦的 MessageList 测试中验证多个
轮次、历史轮次悬停和下一条用户消息的切换。
## 底部 Tooltip 与新对话文案
五个图标操作(复制内容、复制 Markdown 源文、满意、不满意和新建对话)统一使用共享
Tooltip 的 provider、trigger 和 content,移除原生 title 属性,保留无障碍
名称,提示浮层通过共享组件进入 Web Shell portal root。复用原生 button
作为子节点,确保 Radix ref 在 React 18 和 19 下正常。新对话操作使用 Lucide
Split 图标,文案为 “New chat from here” / “从此处新建对话”,请求和等待中
的行为保持不变。Split 使用 13px 尺寸、2.4 线宽并顺时针旋转 90 度,与相邻图标
保持协调。验证键盘聚焦、鼠标悬停、本地化、portal 位置和现有复制及
新建对话交互。
时间戳在最新回复中也仅于悬停或聚焦时显示。触屏设备保留原有时间戳常显行为。
TooltipContent 支持传入 `variant="black"`,显示黑底白字并同步箭头颜色。省略该
属性时保留现有主题色。这五个底部提示启用黑色版本;颜色覆盖仅在提示内容内
生效,不影响其他 Tooltip 或宿主主题。验证默认/黑色两种模式的 ref 传递,
以及真实浏览器中的文字、背景和箭头颜色。
黑色版本的主体外圈和箭头描边使用同一种不透明深灰色,箭头偏移设为零,
使深色主题下也能看清边缘,并避免箭头与主体衔接时出现异色接缝。默认版本
保留原有外圈、箭头描边和偏移。除颜色功能断言外,还需检查高 DPI 局部截图。
@@ -11,6 +11,7 @@ import {
} from '../customization';
import type { ACPToolCall, Message } from '../adapters/types';
import { summaryRunId } from './summaryRunId';
import messageStyles from './messages/AssistantMessage.module.css';
import timestampStyles from './MessageTimestamp.module.css';
import { TranscriptRenderModeProvider } from '../transcriptRenderMode';
@@ -68,10 +69,12 @@ vi.mock('./messages/AssistantMessage', async () => {
content,
customFooterInfo,
onBranchSession,
footerAlwaysVisible,
}: {
content: string;
customFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
onBranchSession?: () => void | Promise<void>;
footerAlwaysVisible?: boolean;
}) => {
if (content.includes('__BOOM__')) throw new Error('assistant boom');
const { renderAssistantTurnFooter } = useWebShellCustomization();
@@ -83,6 +86,12 @@ vi.mock('./messages/AssistantMessage', async () => {
{ 'data-testid': 'assistant-ok' },
content,
customFooter,
React.createElement('div', {
'data-testid': 'assistant-footer-visibility',
className: footerAlwaysVisible
? messageStyles.messageFooterVisible
: undefined,
}),
onBranchSession
? React.createElement(
'button',
@@ -482,6 +491,36 @@ describe('MessageItem assistant turn footer', () => {
).toBe('u2');
});
it('updates assistant footer visibility when only that flag changes', () => {
const message = assistantMsg('1', 'hello');
const tree = (assistantFooterAlwaysVisible: boolean) => (
<I18nProvider language="en">
<MessageItem
message={message}
showAssistantActions
assistantFooterAlwaysVisible={assistantFooterAlwaysVisible}
/>
</I18nProvider>
);
const { root, container } = renderWithRoot(tree(true));
const footer = container.querySelector(
'[data-testid="assistant-footer-visibility"]',
)!;
expect(footer.classList.contains(messageStyles.messageFooterVisible)).toBe(
true,
);
act(() => root.render(tree(false)));
const released = container.querySelector(
'[data-testid="assistant-footer-visibility"]',
)!;
expect(released).not.toBeNull();
expect(
released.classList.contains(messageStyles.messageFooterVisible),
).toBe(false);
});
it('degrades a crashing custom footer renderer to an inline notice', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const renderAssistantTurnFooter = vi.fn(() => {
@@ -82,6 +82,7 @@ interface MessageItemProps {
onBranchSession?: (branchRecordId?: string) => void | Promise<void>;
branchRecordId?: string;
showAssistantActions?: boolean;
assistantFooterAlwaysVisible?: boolean;
showAssistantBranch?: boolean;
/** Turn id marks are keyed by; unset hides the marks for this message. */
assistantFeedbackTurnId?: string;
@@ -122,6 +123,7 @@ export const MessageItem = memo(function MessageItem({
onBranchSession,
branchRecordId,
showAssistantActions = false,
assistantFooterAlwaysVisible = false,
showAssistantBranch = false,
assistantFeedbackTurnId,
assistantFeedbackPromptId,
@@ -231,6 +233,7 @@ export const MessageItem = memo(function MessageItem({
timestamp={message.timestamp}
onBranchSession={boundBranchSession}
showFooterActions={showAssistantActions}
footerAlwaysVisible={assistantFooterAlwaysVisible}
showBranchAction={showAssistantBranch}
showAssistantFeedback={assistantFeedbackPromptId !== undefined}
assistantFeedbackRating={assistantFeedbackRating}
@@ -500,6 +503,8 @@ function areMessageItemPropsEqual(
if (prev.onBranchSession !== next.onBranchSession) return false;
if (prev.branchRecordId !== next.branchRecordId) return false;
if (prev.showAssistantActions !== next.showAssistantActions) return false;
if (prev.assistantFooterAlwaysVisible !== next.assistantFooterAlwaysVisible)
return false;
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
if (prev.assistantFeedbackTurnId !== next.assistantFeedbackTurnId)
return false;
@@ -47,6 +47,7 @@ vi.mock('./MessageItem', async () => {
MessageItem: ({
message,
showAssistantActions,
assistantFooterAlwaysVisible,
showAssistantBranch,
onBranchSession,
branchRecordId,
@@ -59,6 +60,7 @@ vi.mock('./MessageItem', async () => {
message: Message;
assistantFeedbackPromptId?: string;
showAssistantActions?: boolean;
assistantFooterAlwaysVisible?: boolean;
showAssistantBranch?: boolean;
onBranchSession?: (branchRecordId?: string) => void | Promise<void>;
branchRecordId?: string;
@@ -79,6 +81,7 @@ vi.mock('./MessageItem', async () => {
{
'data-testid': `msg-${message.id}`,
'data-assistant-actions': String(Boolean(showAssistantActions)),
'data-footer-visible': String(Boolean(assistantFooterAlwaysVisible)),
'data-feedback-prompt': assistantFeedbackPromptId,
'data-locate-flashing': isLocateFlashing ? 'true' : undefined,
'data-send-failed': sendFailed ? 'true' : undefined,
@@ -4563,6 +4566,31 @@ describe('MessageList — turn collapse (DOM)', () => {
).toBe('u1:a1:answer');
});
it('keeps only the newest completed turn footer visible and releases it on the next prompt', () => {
const messages = [
userMsg('u1'),
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
];
const c = mount(messages);
c.querySelector<HTMLElement>('[data-web-shell-message-list]')!.scrollTo =
vi.fn();
const visible = (id: string) =>
c
.querySelector(`[data-testid="msg-${id}"]`)
?.getAttribute('data-footer-visible');
expect(visible('a1')).toBe('false');
expect(visible('a2')).toBe('true');
rerenderMessages(c, [...messages, userShellMsg('shell')]);
expect(visible('a2')).toBe('true');
rerenderMessages(c, [...messages, userMsg('u3')], { isResponding: true });
expect(visible('a2')).toBe('false');
rerenderMessages(c, [...messages, userMsg('u3'), asstMsg('a3')]);
expect(visible('a2')).toBe('false');
expect(visible('a3')).toBe('true');
});
it('maps each completed turn footer to its own turn id', () => {
const renderAssistantTurnFooter = vi.fn(({ turnId, message }) => (
<span data-testid={`assistant-turn-footer-${message.id}`}>
@@ -3723,6 +3723,13 @@ export const MessageList = memo(
isResponding,
latestTurnAwaitsAgentSummary,
]);
const latestFinalAssistantTurnId = useMemo(() => {
let latest: string | undefined;
for (const turnId of finalAssistantTurnIdByAssistantId.values()) {
latest = turnId;
}
return latest;
}, [finalAssistantTurnIdByAssistantId]);
// ── Per-turn collapse ────────────────────────────────────────────────
// Completed turns fold down to their prompt + final answer (toggle on the
@@ -6001,6 +6008,11 @@ export const MessageList = memo(
(agentReply ||
finalAssistantTurnIdByAssistantId.has(displayItem.message.id))
}
assistantFooterAlwaysVisible={
!isResponding &&
finalAssistantTurnId !== undefined &&
finalAssistantTurnId === latestFinalAssistantTurnId
}
showAssistantBranch={
displayItem.message.role === 'assistant' &&
!agentReply &&
@@ -6084,6 +6096,7 @@ export const MessageList = memo(
visibleItems,
flashTarget,
finalAssistantTurnIdByAssistantId,
latestFinalAssistantTurnId,
assistantFeedbackEnabled,
assistantFeedbackHeadById,
assistantFeedbackRatings,
@@ -67,6 +67,7 @@
transition: opacity 0.12s ease;
}
.messageFooterVisible,
.message:hover .messageFooter,
.message:focus-within .messageFooter {
opacity: 1;
@@ -98,16 +99,19 @@
height: 16px;
}
/* A thumb glyph fills its view box far more than the built-in copy and branch
icons do, so it takes a smaller box and a proportionally thicker stroke: at
13px the 2.4 stroke paints the same ~1.3px as their 16px default, so the
three read as one row instead of the thumb looking heavier. */
.feedbackButton svg {
/* Lucide glyphs fill more of their view box than the built-in copy icons.
At 13px, a 2.4 stroke matches their ~1.3px visible stroke. */
.feedbackButton svg,
.branchButton svg {
width: 13px;
height: 13px;
stroke-width: 2.4;
}
.branchButton svg {
transform: rotate(90deg);
}
/* The mark reads as state: blue means satisfied, red means not.
The hover and focus variants are listed so the colour survives `.copyButton`'s
own hover rule, which has a higher specificity: the row only appears on
@@ -128,6 +132,13 @@
.footerTime {
font-size: 11px;
line-height: 1.4;
opacity: 0;
transition: opacity 0.12s ease;
}
.message:hover .footerTime,
.message:focus-within .footerTime {
opacity: 1;
}
.prefix {
@@ -469,7 +480,8 @@
}
@media (hover: none) {
.messageFooter {
.messageFooter,
.footerTime {
opacity: 1;
}
@@ -2,9 +2,12 @@
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import TurndownService from 'turndown';
import { WebShellCustomizationProvider } from '../../customization';
import { I18nProvider } from '../../i18n';
import { getTranslator, I18nProvider } from '../../i18n';
import { getSelectedReplyContent } from '../../utils/selectionClipboard';
import { WebShellPortalRootContext } from '../../portalRoot';
import styles from './AssistantMessage.module.css';
import {
TranscriptDocumentExpandedProvider,
TranscriptRenderModeProvider,
@@ -515,6 +518,61 @@ describe('AssistantMessage streaming markdown', () => {
});
});
describe('AssistantMessage footer tooltips', () => {
it.each([
[
'en',
[
'Copy content',
'Copy Markdown source',
'Satisfied',
'Not satisfied',
'New chat from here',
],
],
[
'zh-CN',
['复制内容', '复制 Markdown 源文', '满意', '不满意', '从此处新建对话'],
],
] as const)(
'shows localized tooltips in the Web Shell portal on keyboard focus in %s',
async (language, labels) => {
const portal = document.createElement('div');
document.body.appendChild(portal);
try {
const container = render(
<WebShellPortalRootContext.Provider value={portal}>
<AssistantMessage
content="answer"
showFooterActions
showAssistantFeedback
showBranchAction
onBranchSession={vi.fn()}
/>
</WebShellPortalRootContext.Provider>,
language,
);
expect(container.querySelector('.lucide-split')).not.toBeNull();
for (const label of labels) {
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
)!;
expect(button).not.toBeNull();
expect(button.hasAttribute('title')).toBe(false);
await act(async () => button.focus());
const tooltip = portal.querySelector('[role="tooltip"]')!;
expect(tooltip?.textContent).toBe(label);
expect(button.getAttribute('aria-describedby')).toBe(tooltip.id);
expect(container.querySelector('[role="tooltip"]')).toBeNull();
await act(async () => button.blur());
}
} finally {
portal.remove();
}
},
);
});
describe('AssistantMessage branch action', () => {
it('disables the selected action while the branch request is pending', async () => {
let resolveBranch!: () => void;
@@ -533,7 +591,7 @@ describe('AssistantMessage branch action', () => {
/>,
);
const button = container.querySelector<HTMLButtonElement>(
'button[title="Branch"]',
'button[aria-label="New chat from here"]',
);
act(() => button?.click());
@@ -560,7 +618,7 @@ describe('AssistantMessage branch action', () => {
/>,
);
const button = container.querySelector<HTMLButtonElement>(
'button[title="Branch"]',
'button[aria-label="New chat from here"]',
);
await act(async () => {
@@ -616,89 +674,284 @@ describe('AssistantMessage markdown tables', () => {
});
});
describe('AssistantMessage copy reset timer', () => {
it('leaves no pending reset timer behind on unmount', async () => {
vi.useFakeTimers();
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
describe('AssistantMessage Markdown copy', () => {
it.each([
['en', 'Copy content', 'Copy Markdown source'],
['zh-CN', '复制内容', '复制 Markdown 源文'],
] as const)(
'copies rendered content and source Markdown with independent feedback in %s',
async (language, copyLabel, markdownLabel) => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
try {
const content =
'## Heading\n\n**Bold** [link](https://example.com)\n\n- Item\n\n```ts\nconst x = 1;\n```';
const container = render(
<AssistantMessage content={content} showFooterActions />,
language,
);
const copy = container.querySelector<HTMLButtonElement>(
`button[aria-label="${copyLabel}"]`,
)!;
const markdown = container.querySelector<HTMLButtonElement>(
`button[aria-label="${markdownLabel}"]`,
)!;
expect(copy.nextElementSibling).toBe(markdown);
expect(markdown.hasAttribute('title')).toBe(false);
expect(markdown.getAttribute('aria-label')).not.toBe(
getTranslator(language)('assistant.copyMarkdown'),
);
await act(async () => markdown.click());
expect(writeText).toHaveBeenCalledWith(content);
expect(markdown.querySelector('rect')).toBeNull();
expect(copy.querySelector('rect')).not.toBeNull();
act(() => vi.advanceTimersByTime(2000));
expect(markdown.querySelector('rect')).not.toBeNull();
await act(async () => copy.click());
expect(writeText).toHaveBeenLastCalledWith(
'Heading\nBold link\nItem\nconst x = 1;',
);
expect(markdown.querySelector('rect')).not.toBeNull();
expect(copy.querySelector('rect')).toBeNull();
} finally {
vi.unstubAllGlobals();
}
},
);
it('copies the whole rendered reply without replacing a partial selection', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const selection = window.getSelection()!;
try {
const container = render(
<AssistantMessage content="copy me" showFooterActions />,
<AssistantMessage content="Before **bold** after." showFooterActions />,
);
const range = document.createRange();
range.selectNodeContents(container.querySelector('strong')!);
selection.removeAllRanges();
selection.addRange(range);
const button = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy content"]',
)!;
await act(async () => button.click());
expect(writeText).toHaveBeenCalledWith('Before bold after.');
expect(selection.toString()).toBe('bold');
expect(selection.getRangeAt(0)).toBe(range);
} finally {
selection.removeAllRanges();
vi.unstubAllGlobals();
}
});
it.each([
{ content: '', showFooterActions: true },
{ content: 'answer', showFooterActions: false },
{ content: 'answer', showFooterActions: true, isStreaming: true },
])('honors footer visibility: %j', (props) => {
const container = render(<AssistantMessage {...props} />);
expect(
container.querySelector('button[aria-label="Copy Markdown source"]'),
).toBeNull();
});
it('omits the Markdown control from exported documents', () => {
const container = render(
<AssistantMessage content="answer" showFooterActions />,
'en',
'document',
);
expect(
container.querySelector('button[aria-label="Copy Markdown source"]'),
).toBeNull();
});
it('falls back to the raw source when the reply renders no plain text', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
try {
const content = '![](https://example.com/a.png)';
const container = render(
<AssistantMessage content={content} showFooterActions />,
);
const button = container.querySelector<HTMLButtonElement>(
'button[title="Copy"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
// The 2s reset is pending; unmounting must clear it, or it fires
// after the file's environment is torn down and the unit suites'
// unhandled-error gate turns an all-green run red.
expect(vi.getTimerCount()).toBeGreaterThan(0);
const { root, container: mountedContainer } = mounted.pop()!;
act(() => root.unmount());
mountedContainer.remove();
expect(vi.getTimerCount()).toBe(0);
'button[aria-label="Copy content"]',
)!;
await act(async () => button.click());
expect(writeText).toHaveBeenCalledWith(content);
expect(writeText).not.toHaveBeenCalledWith('');
} finally {
if (descriptor) {
Object.defineProperty(navigator, 'clipboard', descriptor);
}
vi.unstubAllGlobals();
}
});
it('copies rendered text without running the Markdown converter', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const turndown = vi.spyOn(TurndownService.prototype, 'turndown');
try {
const container = render(
<AssistantMessage content="Before **bold** after." showFooterActions />,
);
const button = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy content"]',
)!;
await act(async () => button.click());
expect(turndown).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith('Before bold after.');
} finally {
vi.unstubAllGlobals();
}
});
it('marks the footer persistently visible only when asked', () => {
const shown = render(
<AssistantMessage
content="answer"
showFooterActions
footerAlwaysVisible
/>,
);
expect(
shown
.querySelector(`.${styles.messageFooter}`)
?.classList.contains(styles.messageFooterVisible),
).toBe(true);
const resting = render(
<AssistantMessage content="answer" showFooterActions />,
);
expect(
resting
.querySelector(`.${styles.messageFooter}`)
?.classList.contains(styles.messageFooterVisible),
).toBe(false);
});
it('reports clipboard failure without a success flash', async () => {
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
});
const originalExecCommand = document.execCommand;
document.execCommand = vi.fn().mockReturnValue(false);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const container = render(
<AssistantMessage content="answer" showFooterActions />,
);
const markdown = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy Markdown source"]',
)!;
await act(async () => markdown.click());
expect(warn).toHaveBeenCalledWith(
'[web-shell] clipboard write failed:',
expect.any(Error),
);
expect(markdown.querySelector('rect')).not.toBeNull();
} finally {
document.execCommand = originalExecCommand;
vi.unstubAllGlobals();
}
});
});
describe('AssistantMessage copy without the async Clipboard API (issue #9485)', () => {
it('falls back to execCommand and still shows the copied state', async () => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
let copiedViaFallback: string | null = null;
const originalExecCommand = document.execCommand;
const execCommandMock = vi.fn().mockImplementation((command: string) => {
if (command === 'copy') {
copiedViaFallback = document.querySelector('textarea')?.value ?? null;
}
return true;
});
document.execCommand = execCommandMock;
try {
const container = render(
<AssistantMessage content="copy me" showFooterActions />,
describe('AssistantMessage copy reset timer', () => {
it.each(['Copy content', 'Copy Markdown source'])(
'cleans up the %s timer on unmount',
async (label) => {
vi.useFakeTimers();
const descriptor = Object.getOwnPropertyDescriptor(
navigator,
'clipboard',
);
const button = container.querySelector<HTMLButtonElement>(
'button[title="Copy"]',
);
expect(button).not.toBeNull();
await act(async () => {
button?.click();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
expect(execCommandMock).toHaveBeenCalledWith('copy');
expect(copiedViaFallback).toBe('copy me');
// The button should flip to the check icon even without the async API.
expect(
button?.querySelector('path[d="m3.5 8.3 3 3L12.8 5"]'),
).not.toBeNull();
} finally {
document.execCommand = originalExecCommand;
if (descriptor) {
Object.defineProperty(navigator, 'clipboard', descriptor);
try {
const container = render(
<AssistantMessage content="copy me" showFooterActions />,
);
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
await act(async () => {
button?.click();
await Promise.resolve();
});
// The 2s reset is pending; unmounting must clear it, or it fires
// after the file's environment is torn down and the unit suites'
// unhandled-error gate turns an all-green run red.
expect(vi.getTimerCount()).toBeGreaterThan(0);
const { root, container: mountedContainer } = mounted.pop()!;
act(() => root.unmount());
mountedContainer.remove();
expect(vi.getTimerCount()).toBe(0);
} finally {
if (descriptor) {
Object.defineProperty(navigator, 'clipboard', descriptor);
}
}
}
});
},
);
});
describe('AssistantMessage copy without the async Clipboard API (issue #9485)', () => {
it.each(['Copy content', 'Copy Markdown source'])(
'falls back to execCommand for %s',
async (label) => {
const descriptor = Object.getOwnPropertyDescriptor(
navigator,
'clipboard',
);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
let copiedViaFallback: string | null = null;
const originalExecCommand = document.execCommand;
const execCommandMock = vi.fn().mockImplementation((command: string) => {
if (command === 'copy') {
copiedViaFallback = document.querySelector('textarea')?.value ?? null;
}
return true;
});
document.execCommand = execCommandMock;
try {
const container = render(
<AssistantMessage content="**copy me**" showFooterActions />,
);
const button = container.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
expect(button).not.toBeNull();
await act(async () => {
button?.click();
});
expect(execCommandMock).toHaveBeenCalledWith('copy');
expect(copiedViaFallback).toBe(
label === 'Copy content' ? 'copy me' : '**copy me**',
);
// The button should flip to the check icon even without the async API.
expect(
button?.querySelector('path[d="m3.5 8.3 3 3L12.8 5"]'),
).not.toBeNull();
} finally {
document.execCommand = originalExecCommand;
if (descriptor) {
Object.defineProperty(navigator, 'clipboard', descriptor);
}
}
},
);
});
describe('AssistantMessage satisfied / not-satisfied marks', () => {
const find = (container: HTMLElement, title: string) =>
container.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
container.querySelector<HTMLButtonElement>(`button[aria-label="${title}"]`);
it('offers no marks unless the host opts in', () => {
const container = render(
@@ -890,8 +1143,12 @@ describe('AssistantMessage agent replies', () => {
/>,
);
expect(container.querySelector('button[aria-label="Copy"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Branch"]')).toBeNull();
expect(
container.querySelector('button[aria-label="Copy content"]'),
).not.toBeNull();
expect(
container.querySelector('button[aria-label="New chat from here"]'),
).toBeNull();
expect(container.querySelector('button[aria-pressed]')).toBeNull();
expect(container.textContent).toContain('Failed');
});
@@ -7,7 +7,12 @@ import {
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { LightbulbIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import {
LightbulbIcon,
SplitIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from 'lucide-react';
import { Markdown } from './Markdown';
import { SelectionCopyMenu } from './SelectionCopyMenu';
import { TurnSources } from '../sources/TurnSources';
@@ -28,6 +33,7 @@ import {
writeClipboardText,
} from '../../utils/clipboard';
import { useCopiedFlash } from '../../hooks/useCopiedFlash';
import { getSelectedReplyText } from '../../utils/selectionClipboard';
import type {
DaemonSessionGenerationEvent,
QwenAgentMessageMeta,
@@ -38,6 +44,12 @@ import { SquadTag } from './squad-tag';
import { AgentMessageDetails, isBlankAgentText } from './agent-message-details';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
import flashStyles from '../MessageLocateFlash.module.css';
import styles from './AssistantMessage.module.css';
@@ -50,6 +62,7 @@ interface AssistantMessageProps {
timestamp?: number;
onBranchSession?: () => void | Promise<void>;
showFooterActions?: boolean;
footerAlwaysVisible?: boolean;
showBranchAction?: boolean;
/** Satisfied / not-satisfied marks are only offered when this is set. */
showAssistantFeedback?: boolean;
@@ -71,6 +84,7 @@ export const AssistantMessage = memo(function AssistantMessage({
timestamp,
onBranchSession,
showFooterActions = false,
footerAlwaysVisible = false,
showBranchAction = false,
showAssistantFeedback = false,
assistantFeedbackRating,
@@ -84,7 +98,9 @@ export const AssistantMessage = memo(function AssistantMessage({
const renderMode = useTranscriptRenderMode();
const documentMode = renderMode === 'document';
const { renderAssistantTurnFooter } = useWebShellCustomization();
const contentRef = useRef<HTMLDivElement>(null);
const [copied, flashCopied] = useCopiedFlash();
const [markdownCopied, flashMarkdownCopied] = useCopiedFlash();
const [branchPending, setBranchPending] = useState(false);
// An agent reply of only whitespace or invisible characters (a leader's
// U+200B, recorded before the daemon classified it) shows no bubble text;
@@ -117,13 +133,23 @@ export const AssistantMessage = memo(function AssistantMessage({
setBranchPending(false);
}
}, [branchPending, onBranchSession]);
const handleCopy = useCallback(() => {
void writeClipboardText(content)
.then(() => {
flashCopied();
})
.catch(warnClipboardWriteFailure);
}, [content, flashCopied]);
const handleCopy = useCallback(
(format: 'text' | 'markdown') => {
let value = content;
if (format === 'text') {
const body = contentRef.current;
if (!body) return;
const range = body.ownerDocument.createRange();
range.selectNodeContents(body);
const extracted = getSelectedReplyText(body, range);
value = extracted && extracted.trim() ? extracted : content;
}
void writeClipboardText(value)
.then(format === 'text' ? flashCopied : flashMarkdownCopied)
.catch(warnClipboardWriteFailure);
},
[content, flashCopied, flashMarkdownCopied],
);
// Clicking the lit icon clears the mark; clicking the other one switches it.
const handleFeedback = useCallback(
(
@@ -204,6 +230,7 @@ export const AssistantMessage = memo(function AssistantMessage({
)}
{content && !blankAgentReply && (
<div
ref={contentRef}
className={`${styles.content}${
isLocateFlashing ? ` ${flashStyles.flash}` : ''
}`}
@@ -228,63 +255,108 @@ export const AssistantMessage = memo(function AssistantMessage({
<div className={styles.customFooter}>{customFooter}</div>
)}
{showFooter && (
<div className={styles.messageFooter}>
{showFooterActions && (
<button
type="button"
className={styles.copyButton}
title={t('assistant.copy')}
aria-label={t('assistant.copy')}
onClick={handleCopy}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
)}
{showFooterActions && showAssistantFeedback && (
<>
<button
type="button"
className={feedbackButtonClass('up')}
title={t('assistant.satisfied')}
aria-label={t('assistant.satisfied')}
aria-pressed={assistantFeedbackRating === 'up'}
onClick={(event) => handleFeedback('up', event)}
>
<ThumbsUpIcon />
</button>
<button
type="button"
className={feedbackButtonClass('down')}
title={t('assistant.dissatisfied')}
aria-label={t('assistant.dissatisfied')}
aria-pressed={assistantFeedbackRating === 'down'}
onClick={(event) => handleFeedback('down', event)}
>
<ThumbsDownIcon />
</button>
</>
)}
{showFooterActions && showBranchAction && onBranchSession && (
<button
type="button"
className={styles.copyButton}
title={t('assistant.branch')}
aria-label={t('assistant.branch')}
disabled={branchPending}
onClick={() => void handleBranch()}
>
<BranchIcon />
</button>
)}
{turnSources?.length ? (
<TurnSources sources={turnSources} onOpen={onSourceOpen} />
) : null}
{showFooterActions && timestamp !== undefined && (
<span className={styles.footerTime} aria-hidden="true">
{formatTimestamp(timestamp)}
</span>
)}
</div>
<TooltipProvider delayDuration={300} disableHoverableContent>
<div
className={`${styles.messageFooter}${footerAlwaysVisible ? ` ${styles.messageFooterVisible}` : ''}`}
>
{showFooterActions && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={styles.copyButton}
aria-label={t('assistant.copyContent')}
onClick={() => handleCopy('text')}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</TooltipTrigger>
<TooltipContent side="top" variant="black">
{t('assistant.copyContent')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={styles.copyButton}
aria-label={t('assistant.copyMarkdownSource')}
onClick={() => handleCopy('markdown')}
>
{markdownCopied ? <CheckIcon /> : <MarkdownIcon />}
</button>
</TooltipTrigger>
<TooltipContent side="top" variant="black">
{t('assistant.copyMarkdownSource')}
</TooltipContent>
</Tooltip>
</>
)}
{showFooterActions && showAssistantFeedback && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={feedbackButtonClass('up')}
aria-label={t('assistant.satisfied')}
aria-pressed={assistantFeedbackRating === 'up'}
onClick={(event) => handleFeedback('up', event)}
>
<ThumbsUpIcon />
</button>
</TooltipTrigger>
<TooltipContent side="top" variant="black">
{t('assistant.satisfied')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={feedbackButtonClass('down')}
aria-label={t('assistant.dissatisfied')}
aria-pressed={assistantFeedbackRating === 'down'}
onClick={(event) => handleFeedback('down', event)}
>
<ThumbsDownIcon />
</button>
</TooltipTrigger>
<TooltipContent side="top" variant="black">
{t('assistant.dissatisfied')}
</TooltipContent>
</Tooltip>
</>
)}
{showFooterActions && showBranchAction && onBranchSession && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`${styles.copyButton} ${styles.branchButton}`}
aria-label={t('assistant.branch')}
disabled={branchPending}
onClick={() => void handleBranch()}
>
<SplitIcon aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" variant="black">
{t('assistant.branch')}
</TooltipContent>
</Tooltip>
)}
{turnSources?.length ? (
<TurnSources sources={turnSources} onOpen={onSourceOpen} />
) : null}
{showFooterActions && timestamp !== undefined && (
<span className={styles.footerTime} aria-hidden="true">
{formatTimestamp(timestamp)}
</span>
)}
</div>
</TooltipProvider>
)}
</div>
);
@@ -315,6 +387,23 @@ function CopyIcon() {
);
}
function MarkdownIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="1" y="3" width="14" height="10" rx="1.2" />
<path d="M3.5 10V6l2 2 2-2v4M11 6v4m-1.5-1.5L11 10l1.5-1.5" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
@@ -330,32 +419,6 @@ function CheckIcon() {
);
}
function BranchIcon() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path
d="M5 3.5v5.2c0 2.1 1.7 3.8 3.8 3.8H11"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path
d="M5 8.2h3.2c1.5 0 2.8-1.2 2.8-2.8V4"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<circle cx="5" cy="3.5" r="1.5" fill="currentColor" />
<circle cx="11" cy="4" r="1.5" fill="currentColor" />
<circle cx="11" cy="12.5" r="1.5" fill="currentColor" />
</svg>
);
}
interface ThinkingMessageProps {
content: string;
author?: DaemonMessageAuthor;
@@ -732,7 +732,7 @@ describe('Host inline footnote icon selection', () => {
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
render(<AssistantMessage content={resourceReport} showFooterActions />);
click(container.querySelector('[aria-label="Copy"]'));
click(container.querySelector('[aria-label="Copy Markdown source"]'));
await act(async () => {});
expect(writeText).toHaveBeenCalledWith(resourceReport);
const resolver = vi.fn<WebShellFootnoteIconResolver>();
@@ -201,7 +201,7 @@ it('shows real source entries on a report without footnotes and reuses its open
const sources = container.querySelector(selector)!;
expect(sources.textContent).toBe('2 sources');
expect(sources.parentElement).toBe(
container.querySelector('[aria-label="Copy"]')?.parentElement,
container.querySelector('[aria-label="Copy content"]')?.parentElement,
);
click(sources);
const list = document.querySelector(popup)!;
@@ -142,27 +142,42 @@ describe('React 18 ref compatibility', () => {
container.remove();
});
it('forwards a TooltipContent ref to its DOM element', () => {
const ref = React.createRef<HTMLDivElement>();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
it.each([undefined, 'black'] as const)(
'forwards a TooltipContent ref with variant=%s',
(variant) => {
const ref = React.createRef<HTMLDivElement>();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(
<TooltipProvider>
<Tooltip open>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent ref={ref}>Content</TooltipContent>
</Tooltip>
</TooltipProvider>,
),
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
act(() =>
root.render(
<TooltipProvider>
<Tooltip open>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent ref={ref} variant={variant}>
Content
</TooltipContent>
</Tooltip>
</TooltipProvider>,
),
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current?.hasAttribute('variant')).toBe(false);
expect(ref.current?.classList.contains('[--popover:#000]')).toBe(
variant === 'black',
);
expect(ref.current?.classList.contains('ring-border')).toBe(
variant === 'black',
);
expect(
ref.current?.querySelectorAll('[data-slot="tooltip-arrow"] path'),
).toHaveLength(2);
act(() => root.unmount());
container.remove();
});
act(() => root.unmount());
container.remove();
},
);
it('forwards slotted PopoverAnchor and portaled content refs to their DOM elements', () => {
const anchorRef =
@@ -38,9 +38,11 @@ const TooltipTrigger = React.forwardRef<
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentProps<typeof TooltipPrimitive.Content>
React.ComponentProps<typeof TooltipPrimitive.Content> & {
variant?: 'default' | 'black';
}
>(function TooltipContent(
{ className, sideOffset = 0, children, ...props },
{ className, sideOffset = 0, variant = 'default', children, ...props },
ref,
) {
const portalRoot = useWebShellPortalRoot();
@@ -52,6 +54,8 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
'relative z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10 [--floating-arrow-offset:-1px] data-[side=top]:[--floating-arrow-offset:0px] has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
variant === 'black' &&
'ring-border [--popover:#000] [--popover-foreground:#fff] [--border:#404040] [--floating-arrow-offset:0px]',
className,
)}
{...props}
@@ -0,0 +1,187 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
async function hoverAction(page: Page, button: Locator) {
await page.mouse.move(0, 0, { steps: 12 });
await expect(page.getByRole('tooltip')).toHaveCount(0);
const box = (await button.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
steps: 12,
});
}
const plainContent =
'Markdown example\nCopy formatted text and a link.\nFirst item\nSecond item\nconst answer = 42;';
const content =
'## Markdown example\n\nCopy **formatted text** and [a link](https://example.com).\n\n- First item\n- Second item\n\n```ts\nconst answer = 42;\n```';
for (const language of ['en', 'zh-CN'] as const) {
for (const theme of ['light', 'dark'] as const) {
test(`assistant footer copies complete Markdown beside Copy (${language}, ${theme})${language === 'en' && theme === 'light' ? ' @smoke' : ''}`, async ({
page,
context,
}, info) => {
const copyLabel = language === 'en' ? 'Copy content' : '复制内容';
const markdownLabel =
language === 'en' ? 'Copy Markdown source' : '复制 Markdown 源文';
const newChatLabel =
language === 'en' ? 'New chat from here' : '从此处新建对话';
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Earlier question.', { id: 1 }),
assistantTextEvent('Earlier answer.', { id: 2 }),
turnCompleteEvent('earlier', { id: 3 }),
userTextEvent('Show a Markdown example.', { id: 4 }),
assistantTextEvent(content, {
id: 5,
branchRecordId: '22222222-2222-4222-8222-222222222222',
}),
turnCompleteEvent('copy-markdown', { id: 6 }),
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(info.project.use.baseURL),
});
await page.goto(
`/session/${scenario.sessionId}?theme=${theme}&lang=${language}`,
);
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: 'Markdown example' }),
});
await expect(row).toBeVisible();
await page.mouse.move(0, 0, { steps: 12 });
const markdown = row.getByRole('button', {
name: markdownLabel,
exact: true,
});
const copy = markdown
.locator('..')
.getByRole('button', { name: copyLabel, exact: true });
await expect(markdown).toBeVisible();
await expect(markdown.locator('..')).toHaveCSS('opacity', '1');
const timestamp = markdown
.locator('..')
.locator(':scope > span[aria-hidden="true"]');
await expect(timestamp).toHaveCSS('opacity', '0');
await row.hover();
await expect(timestamp).toHaveCSS('opacity', '1');
await page.mouse.move(0, 0, { steps: 12 });
await expect(timestamp).toHaveCSS('opacity', '0');
const olderMarkdown = page
.locator('[data-web-shell-message-row]')
.filter({ hasText: 'Earlier answer.' })
.getByRole('button', { name: markdownLabel, exact: true });
await expect(olderMarkdown.locator('..')).toHaveCSS('opacity', '0');
await olderMarkdown.hover();
await expect(olderMarkdown.locator('..')).toHaveCSS('opacity', '1');
const olderCopy = olderMarkdown
.locator('..')
.getByRole('button', { name: copyLabel, exact: true });
await hoverAction(page, olderCopy);
const tooltipBubble = page.locator('[data-slot="tooltip-content"]');
await expect(tooltipBubble).toBeVisible();
const bubbleBox = (await tooltipBubble.boundingBox())!;
await page.mouse.move(
bubbleBox.x + bubbleBox.width / 2,
bubbleBox.y + bubbleBox.height / 2,
{ steps: 6 },
);
await expect(tooltipBubble).toHaveCount(0);
await page.mouse.move(0, 0, { steps: 12 });
await expect(olderMarkdown.locator('..')).toHaveCSS('opacity', '0');
expect(
await copy.evaluate((button) =>
button.nextElementSibling?.getAttribute('aria-label'),
),
).toBe(markdownLabel);
const newChat = row.getByRole('button', {
name: newChatLabel,
exact: true,
});
const icon = newChat.locator('svg');
await expect(icon).toHaveCSS('width', '13px');
await expect(icon).toHaveCSS('height', '13px');
await expect(icon).toHaveCSS('transform', 'matrix(0, 1, -1, 0, 0, 0)');
await expect(icon).toHaveCSS('stroke-width', '2.4px');
for (const [button, label] of [
[copy, copyLabel],
[markdown, markdownLabel],
[newChat, newChatLabel],
] as const) {
await expect(button).not.toHaveAttribute('title');
await hoverAction(page, button);
await expect(page.getByRole('tooltip')).toHaveText(label);
const content = page.locator('[data-slot="tooltip-content"]');
await expect(content).toHaveCSS('background-color', 'rgb(0, 0, 0)');
await expect(content).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(
content.locator('[data-slot="tooltip-arrow"] path').first(),
).toHaveCSS('fill', 'rgb(0, 0, 0)');
await expect(content).toHaveClass(/\bring-border\b/);
await expect(content).toHaveCSS('box-shadow', /rgb\(64, 64, 64\)/);
await expect(
content.locator('[data-slot="tooltip-arrow"] path'),
).toHaveCount(2);
await expect(
content.locator('[data-slot="tooltip-arrow"] path').last(),
).toHaveCSS('stroke', 'rgb(64, 64, 64)');
await button.press('Escape');
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
await hoverAction(page, newChat);
await expect(page.getByRole('tooltip')).toHaveText(newChatLabel);
await page.screenshot({
path: info.outputPath('copy-markdown.png'),
fullPage: true,
animations: 'disabled',
});
await page.mouse.move(0, 0, { steps: 12 });
await markdown.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(content);
await expect(markdown.locator('rect')).toHaveCount(0);
await expect(copy.locator('rect')).toHaveCount(1);
await expect(markdown.locator('rect')).toHaveCount(1);
await page.evaluate(() => navigator.clipboard.writeText('SENTINEL'));
await markdown.focus();
await markdown.press('Enter');
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(content);
await page.evaluate(() => navigator.clipboard.writeText('SENTINEL'));
await copy.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(plainContent);
await page.evaluate(() => navigator.clipboard.writeText('SENTINEL'));
await copy.focus();
await copy.press('Enter');
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(plainContent);
await page
.locator('[data-web-shell-composer-editor] .cm-content')
.focus();
await page.mouse.move(0, 0, { steps: 12 });
await daemon.sendEvent(userTextEvent('Next question.', { id: 7 }));
await expect(markdown.locator('..')).toHaveCSS('opacity', '0');
});
}
}
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
test('keeps the footer timestamp visible without hover on touch devices @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Earlier question.', { id: 1 }),
assistantTextEvent('Earlier answer.', { id: 2 }),
turnCompleteEvent('earlier', { id: 3 }),
userTextEvent('Latest question.', { id: 4 }),
assistantTextEvent('Latest answer.', { id: 5 }),
turnCompleteEvent('latest', { id: 6 }),
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${scenario.sessionId}?lang=en`);
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({ hasText: 'Latest answer.' });
const copy = row.getByRole('button', { name: 'Copy content', exact: true });
await expect(copy).toBeVisible();
const timestamp = copy
.locator('..')
.locator(':scope > span[aria-hidden="true"]');
await expect(timestamp).toHaveCSS('opacity', '1');
});
@@ -573,7 +573,7 @@ test('source locator demo delegates the card link to its host panel', async ({
const footerTrigger = page.locator(footerSelectorForDemo);
await expect(footerTrigger).toHaveText('4 个来源');
const assistantFooter = footerTrigger.locator('..');
await expect(assistantFooter).toHaveCSS('opacity', '0');
await expect(assistantFooter).toHaveCSS('opacity', '1');
await expect(page.locator('[data-footnotes]')).toHaveCount(0);
const footerMetrics = await footerTrigger.evaluate((element) => {
const footer = element.parentElement!;
@@ -581,7 +581,7 @@ test('source locator demo delegates the card link to its host panel', async ({
'[aria-hidden="true"]',
)!;
const copyIcon = footer.querySelector<SVGElement>(
'button[aria-label="复制"] svg',
'button[aria-label="复制内容"] svg',
)!;
const time = footer.querySelector<HTMLElement>(
':scope > span[aria-hidden="true"]',
@@ -609,7 +609,7 @@ test('source locator demo delegates the card link to its host panel', async ({
expect(footerMetrics.textCenterDelta).toBeLessThan(0.1);
const painted = await assistantFooter.evaluate(async (footer) => {
const copy = footer.querySelector<SVGElement>(
'button[aria-label="复制"] svg',
'button[aria-label="复制内容"] svg',
)!;
const citation = footer.querySelector<HTMLElement>(
'[data-web-shell-turn-sources-trigger] > span',
@@ -69,7 +69,7 @@ async function dragBoldSubstring(
await page.mouse.up();
}
test('@smoke partial mouse selection offers plain text and Markdown copying without changing whole-reply copy', async ({
test('@smoke partial mouse selection offers plain text and Markdown copying and whole-reply content copying', async ({
page,
context,
}, testInfo) => {
@@ -115,10 +115,12 @@ test('@smoke partial mouse selection offers plain text and Markdown copying with
name: 'Copy plain text',
exact: true,
});
const markdown = page.getByRole('button', {
name: 'Copy Markdown',
exact: true,
});
const markdown = page
.locator('[data-slot="popover-content"]')
.getByRole('button', {
name: 'Copy Markdown',
exact: true,
});
for (const [button, expected] of [
[plain, 'bold'],
@@ -250,8 +252,10 @@ test('@smoke partial mouse selection offers plain text and Markdown copying with
);
await row.hover();
await row.getByRole('button', { name: 'Copy', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(reply);
await row.getByRole('button', { name: 'Copy content', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
'Selection copying\nSelect bold words in this reply.\nThe result is x^2 + 1 units.\nBefore diagram after.',
);
});
test('@smoke copies through the real clipboard fallback from a ShadowRoot menu', async ({
@@ -286,10 +290,12 @@ test('@smoke copies through the real clipboard fallback from a ShadowRoot menu',
});
await expect(row).toBeVisible();
await dragBoldSubstring(page, row.locator('p'));
const button = page.getByRole('button', {
name: 'Copy Markdown',
exact: true,
});
const button = page
.locator('[data-slot="popover-content"]')
.getByRole('button', {
name: 'Copy Markdown',
exact: true,
});
await expect(button).toBeVisible();
const clipboard = await page.evaluateHandle(() => navigator.clipboard);
try {
@@ -171,7 +171,7 @@ test('branches from an earlier completed Assistant response and resumes the fork
.filter({ hasText: 'First completed answer' });
await firstAnswerRow.hover();
await firstAnswerRow
.getByRole('button', { name: 'Branch', exact: true })
.getByRole('button', { name: 'New chat from here', exact: true })
.click();
await expect.poll(() => daemon.branchRequests().length).toBe(1);
@@ -222,7 +222,10 @@ test('branches from an earlier completed Assistant response and resumes the fork
.filter({ hasText: 'First completed answer' });
await restoredFirstAnswer.hover();
await expect(
restoredFirstAnswer.getByRole('button', { name: 'Branch', exact: true }),
restoredFirstAnswer.getByRole('button', {
name: 'New chat from here',
exact: true,
}),
).toBeVisible();
});
@@ -127,7 +127,7 @@ test('turn sources count explicit reuse independently of footnotes and open the
const { errors } = await openSources(page, info);
const button = page.locator(footer).last();
await page.mouse.move(0, 0);
await expect(button.locator('..')).toHaveCSS('opacity', '0');
await expect(button.locator('..')).toHaveCSS('opacity', '1');
await button.hover();
await expect(button.locator('..')).toHaveCSS('opacity', '1');
const list = page.locator(popup);
@@ -75,7 +75,7 @@ function unmount(): void {
}
const button = (title: string) =>
container.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
container.querySelector<HTMLButtonElement>(`button[aria-label="${title}"]`);
const isLit = (title: string) =>
button(title)?.getAttribute('aria-pressed') === 'true';
+6 -4
View File
@@ -1012,11 +1012,12 @@ const EN: Messages = {
'approval.option.allowAlwaysUser': 'Always allow for this user',
'approval.option.allowAlwaysServer': 'Always allow for this server',
'approval.option.allowAlwaysTool': 'Always allow for this tool',
'assistant.branch': 'Branch',
'assistant.copy': 'Copy',
'assistant.branch': 'New chat from here',
'assistant.copyContent': 'Copy content',
'assistant.selectionCopy': 'Copy selection',
'assistant.copyPlainText': 'Copy plain text',
'assistant.copyMarkdown': 'Copy Markdown',
'assistant.copyMarkdownSource': 'Copy Markdown source',
'assistant.dissatisfied': 'Not satisfied',
'assistant.satisfied': 'Satisfied',
// A workspace agent's reply in a chat session. These render in exported
@@ -5571,11 +5572,12 @@ const ZH: Messages = {
'approval.option.allowAlwaysUser': '对此用户始终允许',
'approval.option.allowAlwaysServer': '对此服务始终允许',
'approval.option.allowAlwaysTool': '对此工具始终允许',
'assistant.branch': '分叉',
'assistant.copy': '复制',
'assistant.branch': '从此处新建对话',
'assistant.copyContent': '复制内容',
'assistant.selectionCopy': '复制所选内容',
'assistant.copyPlainText': '复制纯文本',
'assistant.copyMarkdown': '复制 Markdown',
'assistant.copyMarkdownSource': '复制 Markdown 源文',
'assistant.dissatisfied': '不满意',
'assistant.satisfied': '满意',
'agentMessage.status.failed': '失败',
@@ -4,6 +4,7 @@ import katex from 'katex';
import {
getReplySelectionRange,
getSelectedReplyContent,
getSelectedReplyText,
} from './selectionClipboard';
const bodies: HTMLElement[] = [];
@@ -250,6 +251,22 @@ describe('selected reply clipboard content', () => {
});
});
describe('selected reply plain text', () => {
it('returns rendered text without converting Markdown', () => {
const body = fixture('<p>Before <strong>bold</strong> after.</p>');
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyText(body, range)).toBe('Before bold after.');
});
it('returns null when the selection has no copyable text', () => {
const body = fixture('<p><img src="https://example.com/a.png" alt=""></p>');
const range = document.createRange();
range.selectNodeContents(body);
expect(getSelectedReplyText(body, range)).toBeNull();
});
});
describe('nested selection boundaries', () => {
it.each([
[
@@ -0,0 +1,3 @@
export function getSelectedReplyText(body: HTMLElement): string {
return (body.textContent ?? '').replace(/^\n+|\n+$/g, '');
}
@@ -251,14 +251,31 @@ markdown.addRule('code', {
},
});
export function getSelectedReplyContent(body: HTMLElement, range: Range) {
function cloneReplyContent(
body: HTMLElement,
range: Range,
): HTMLElement | null {
if (
!body.contains(range.startContainer) ||
!body.contains(range.endContainer)
) {
return null;
}
const selected = cloneSelected(body, range) as HTMLElement | null;
return cloneSelected(body, range) as HTMLElement | null;
}
export function getSelectedReplyText(
body: HTMLElement,
range: Range,
): string | null {
const selected = cloneReplyContent(body, range);
if (!selected) return null;
const text = plainText(selected).replace(/^\n+|\n+$/g, '');
return text.trim() ? text : null;
}
export function getSelectedReplyContent(body: HTMLElement, range: Range) {
const selected = cloneReplyContent(body, range);
if (!selected) return null;
const text = plainText(selected).replace(/^\n+|\n+$/g, '');
const formatted = markdown.turndown(selected);
+4
View File
@@ -208,6 +208,10 @@ const TRANSCRIPT_STUBS = new Map(
'./client/components/messages/SelectionCopyMenu.tsx',
'./client/components/messages/SelectionCopyMenu.transcript-stub.tsx',
],
[
'./client/utils/selectionClipboard.ts',
'./client/utils/selectionClipboard.transcript-stub.ts',
],
[
'./client/components/messages/workflow-agent-details.ts',
'./client/components/messages/workflow-agent-details.transcript-stub.ts',