mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
b668ad54c5
commit
958410f5d2
25 files changed
+1077
-216
No files matched your search
@@ -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 = '';
|
||||
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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user