feat(vscode): add native context menu copy actions for webview chat (#3477)

* feat(vscode): add native context menu copy actions for webview chat

Add three right-click context menu items to the chat message area using
VSCode's native webview/context API:
- Copy Message: copies the right-clicked message's raw markdown content
- Copy All Messages: copies the full conversation in markdown format
- Copy Last Reply: copies the last assistant response

Implementation details:
- Commands registered in package.json with webview/context menu entries
- Clipboard writes go through extension host (vscode.env.clipboard) for
  reliability in webview sandbox
- Message identification via data-msg-idx stamped after render
- Tool-call outputs supported including diff format (git diff style)
- i18n support via package.nls.json (English) and package.nls.zh-cn.json
- Menu only shown in message area (not input box or empty state)

Closes #3052

* fix(vscode): wrap tool-call content text in code blocks for copy

* fix(vscode): only wrap tool-call content in code blocks for Copy All, not single Copy Message

* fix(vscode): route copy commands to the right-clicked webview and use dynamic code fences

* fix(vscode): use childIndexMap for copy-message routing and extract shared message handling

Replace the wrapper-div approach (which broke CSS layout) with a
render-time childIndexMap that maps DOM child positions to allMessages
indices. This avoids both the useLayoutEffect index-drift bug and the
wrapper-div CSS side effects.

- Remove data-msg-idx wrapper divs; messages render directly as
  container children, preserving original [&>*] CSS layout
- Build childIndexMap during MessageList render, skipping null items
  (empty AssistantMessage, hidden tool calls via shouldShowToolCall)
- findMessageIndex walks up from click target to container's direct
  child, then maps through childIndexMap
- Filter hidden tool calls and empty content in copyAllMessages
- Extract handleCommonWebviewMessage to deduplicate routing logic
  across sidebar, editor panel, and restored panel handlers
- Clear lastContextMenuProvider on dispose to prevent memory leaks

* fix(vscode): handle image messages in copy and resolve intermittent copy failure

- Copy Message on image messages now outputs markdown format ![image](path)
  instead of empty string
- Copy All Messages includes image messages as ![image](path) instead of
  skipping them
- Copy Last Reply skips empty assistant placeholders during streaming
- Resolve intermittent copy failure by pre-resolving message index on
  right-click instead of storing a DOM element reference that can become
  stale after React re-renders
This commit is contained in:
dreamWB 2026-04-24 20:26:56 +08:00 committed by GitHub
parent 44b482928b
commit 5e4ff3755c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 424 additions and 117 deletions

View file

@ -125,6 +125,18 @@
{
"command": "qwen-code.showLogs",
"title": "Qwen Code: Show Logs"
},
{
"command": "qwen-code.copyMessage",
"title": "%qwen-code.copyMessage.title%"
},
{
"command": "qwen-code.copyAllMessages",
"title": "%qwen-code.copyAllMessages.title%"
},
{
"command": "qwen-code.copyLastReply",
"title": "%qwen-code.copyLastReply.title%"
}
],
"menus": {
@ -139,6 +151,35 @@
},
{
"command": "qwen-code.auth"
},
{
"command": "qwen-code.copyMessage",
"when": "false"
},
{
"command": "qwen-code.copyAllMessages",
"when": "false"
},
{
"command": "qwen-code.copyLastReply",
"when": "false"
}
],
"webview/context": [
{
"command": "qwen-code.copyMessage",
"when": "webviewSection == 'chat-messages'",
"group": "9_cutcopypaste@1"
},
{
"command": "qwen-code.copyAllMessages",
"when": "webviewSection == 'chat-messages'",
"group": "9_cutcopypaste@2"
},
{
"command": "qwen-code.copyLastReply",
"when": "webviewSection == 'chat-messages'",
"group": "9_cutcopypaste@3"
}
],
"editor/title": [

View file

@ -0,0 +1,5 @@
{
"qwen-code.copyMessage.title": "Copy Message",
"qwen-code.copyAllMessages.title": "Copy All Messages",
"qwen-code.copyLastReply.title": "Copy Last Reply"
}

View file

@ -0,0 +1,5 @@
{
"qwen-code.copyMessage.title": "复制消息",
"qwen-code.copyAllMessages.title": "复制全部消息",
"qwen-code.copyLastReply.title": "复制最后回复"
}

View file

@ -213,6 +213,26 @@ export async function activate(context: vscode.ExtensionContext) {
supportsSecondarySidebar,
);
// Register copy commands for webview context menu
// Only send to the first provider with an active webview (the one the user right-clicked)
const sendCopyToActive = (action: string) => {
for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ??
[]) {
if (provider.sendCopyCommand(action)) break;
}
};
context.subscriptions.push(
vscode.commands.registerCommand('qwen-code.copyMessage', () =>
sendCopyToActive('copyMessage'),
),
vscode.commands.registerCommand('qwen-code.copyAllMessages', () =>
sendCopyToActive('copyAllMessages'),
),
vscode.commands.registerCommand('qwen-code.copyLastReply', () =>
sendCopyToActive('copyLastReply'),
),
);
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
if (doc.uri.scheme === DIFF_SCHEME) {

View file

@ -26,7 +26,7 @@ import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui';
import type { TextMessage } from './hooks/message/useMessageHandling.js';
import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js';
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './utils/utils.js';
import { hasToolCallOutput, shouldShowToolCall } from './utils/utils.js';
import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
@ -77,97 +77,148 @@ interface MessageListItem {
interface MessageListProps {
allMessages: MessageListItem[];
onFileClick: (path: string) => void;
/**
* After each render, this ref is updated with an array that maps
* DOM child position allMessages index, only for items that
* actually render a DOM element (skipping nulls).
*/
childIndexMap: React.MutableRefObject<number[]>;
}
const MessageList = React.memo<MessageListProps>(
({ allMessages, onFileClick }) => {
({ allMessages, onFileClick, childIndexMap }) => {
let imageIndex = 0;
return (
<>
{allMessages.map((item, index) => {
switch (item.type) {
case 'message': {
const msg = item.data as TextMessage;
if (msg.kind === 'image' && msg.imagePath) {
imageIndex += 1;
return (
<ImageMessageRenderer
key={`message-${index}`}
msg={msg as WebViewImageMessage}
imageIndex={imageIndex}
/>
);
}
// Build child→allMessages index mapping: for each item that renders
// a non-null element, record its allMessages index. This array's
// position corresponds to the DOM child position in the container.
const mapping: number[] = [];
if (msg.role === 'thinking') {
return (
<ThinkingMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
/>
);
}
const elements = allMessages.map((item, index) => {
let child: React.ReactNode;
switch (item.type) {
case 'message': {
const msg = item.data as TextMessage;
if (msg.role === 'user') {
return (
<UserMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
fileContext={msg.fileContext}
/>
);
}
{
const content = (msg.content || '').trim();
if (
content === 'Interrupted' ||
content === 'Tool interrupted'
) {
return (
<InterruptedMessage
key={`message-${index}`}
text={content}
/>
);
}
return (
<AssistantMessage
key={`message-${index}`}
content={content}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
/>
);
}
}
case 'in-progress-tool-call':
case 'completed-tool-call': {
return (
<ToolCall
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
toolCall={item.data as ToolCallData}
/>
);
}
default:
return null;
if (msg.kind === 'image' && msg.imagePath) {
imageIndex += 1;
child = (
<ImageMessageRenderer
msg={msg as WebViewImageMessage}
imageIndex={imageIndex}
/>
);
break;
}
})}
</>
);
if (msg.role === 'thinking') {
child = (
<ThinkingMessage
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
/>
);
break;
}
if (msg.role === 'user') {
child = (
<UserMessage
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
fileContext={msg.fileContext}
/>
);
break;
}
{
const content = (msg.content || '').trim();
if (!content) {
child = null;
break;
}
if (content === 'Interrupted' || content === 'Tool interrupted') {
child = <InterruptedMessage text={content} />;
break;
}
child = (
<AssistantMessage
content={content}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
/>
);
}
break;
}
case 'in-progress-tool-call':
case 'completed-tool-call': {
const tc = item.data as ToolCallData;
if (!shouldShowToolCall(tc.kind)) {
child = null;
break;
}
child = <ToolCall toolCall={tc} />;
break;
}
default:
child = null;
}
// No wrapper div — message components render directly as children
// of the scroll container, preserving the original CSS layout.
if (child == null) return null;
mapping.push(index);
return <React.Fragment key={`msg-${index}`}>{child}</React.Fragment>;
});
// Update the mapping ref so the copy handler can use it
childIndexMap.current = mapping;
return <>{elements}</>;
},
);
MessageList.displayName = 'MessageList';
/**
* Given a click target inside the messages container, find which
* allMessages index it belongs to by walking up from the target to
* the container's direct child, then mapping through childIndexMap.
*
* NOTE: childIndexMap indices correspond to MessageList's DOM children
* which must be the first N children of the container. Elements rendered
* after MessageList (InsightProgressCard, WaitingMessage, etc.) are
* excluded from the map and will correctly return -1.
*/
function findMessageIndex(
target: Element,
container: Element,
childIndexMap: number[],
): number {
// Walk up from the click target to find the direct child of the container.
// This works for all message types regardless of whether they have
// .qwen-message class (e.g. InterruptedMessage does not).
let directChild: Element | null = target;
while (directChild && directChild.parentElement !== container) {
directChild = directChild.parentElement;
}
if (!directChild) return -1;
// Find DOM child position among container's children
const children = container.children;
for (let i = 0; i < children.length; i++) {
if (children[i] === directChild) {
return i < childIndexMap.length ? childIndexMap[i] : -1;
}
}
return -1;
}
export const App: React.FC = () => {
const vscode = useVSCode();
@ -215,6 +266,9 @@ export const App: React.FC = () => {
const [showModelSelector, setShowModelSelector] = useState(false);
const [accountInfo, setAccountInfo] = useState<AccountInfo | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
// Maps DOM child position → allMessages index. Built during render by
// MessageList, only includes items that actually produce DOM elements.
const childIndexMapRef = useRef<number[]>([]);
// Scroll container for message list; used to keep the view anchored to the latest content
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const inputFieldRef = useRef<HTMLDivElement | null>(null);
@ -970,6 +1024,167 @@ export const App: React.FC = () => {
[vscode],
);
// Build a markdown code fence that won't collide with content containing backticks
const buildFence = useCallback((content: string): string => {
const matches = (content ?? '').match(/`+/g);
const maxRun = matches ? Math.max(...matches.map((m) => m.length)) : 0;
return '`'.repeat(Math.max(3, maxRun + 1));
}, []);
// Format a tool call's content for clipboard copy
// wrapCodeBlock: true for Copy All (markdown), false for single Copy Message (plain text)
const formatToolCallForCopy = useCallback(
(tc: ToolCallData, wrapCodeBlock = false): string => {
const parts: string[] = [];
if (tc.content) {
for (const c of tc.content) {
if (c.type === 'content' && c.content?.text) {
if (wrapCodeBlock) {
const fence = buildFence(c.content.text);
parts.push(`${fence}\n${c.content.text}\n${fence}`);
} else {
parts.push(c.content.text);
}
} else if (c.type === 'diff') {
const filePath = c.path || '';
if (c.oldText) {
const oldLines = c.oldText
.split('\n')
.map((l) => `-${l}`)
.join('\n');
const newLines = (c.newText || '')
.split('\n')
.map((l) => `+${l}`)
.join('\n');
const diffContent = `--- ${filePath}\n+++ ${filePath}\n${oldLines}\n${newLines}`;
if (wrapCodeBlock) {
const fence = buildFence(diffContent);
parts.push(`${fence}diff\n${diffContent}\n${fence}`);
} else {
parts.push(diffContent);
}
} else {
if (wrapCodeBlock) {
const fence = buildFence(c.newText || '');
parts.push(
`${filePath}:\n${fence}\n${c.newText || ''}\n${fence}`,
);
} else {
parts.push(`${filePath}:\n${c.newText || ''}`);
}
}
}
}
}
return parts.join('\n\n');
},
[buildFence],
);
// Track which message was right-clicked by resolving the index immediately.
// Storing the DOM element reference would be fragile: React re-renders between
// the right-click and the async copy command (routed via extension host) can
// detach the element, causing findMessageIndex to fail intermittently.
const contextMenuMsgIdxRef = useRef<number>(-1);
useEffect(() => {
const trackTarget = (e: MouseEvent) => {
const container = messagesContainerRef.current;
if (container && e.target instanceof Element) {
contextMenuMsgIdxRef.current = findMessageIndex(
e.target,
container,
childIndexMapRef.current,
);
}
// Notify extension that this webview was right-clicked, so copy commands route here
vscode.postMessage({ type: 'contextMenuTriggered', data: {} });
};
document.addEventListener('contextmenu', trackTarget, true);
return () => document.removeEventListener('contextmenu', trackTarget, true);
}, [vscode]);
// Copy text via the extension host's clipboard API (more reliable than navigator.clipboard in webview)
const copyToClipboard = useCallback(
(text: string) => {
vscode.postMessage({ type: 'copyToClipboard', data: { text } });
},
[vscode],
);
// Handle copy commands from VSCode native context menu
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data;
if (message?.type !== 'copyCommand') return;
const { action } = message.data as { action: string };
if (action === 'copyMessage') {
const idx = contextMenuMsgIdxRef.current;
if (idx >= 0 && idx < allMessages.length) {
const item = allMessages[idx];
if (item.type === 'message') {
const msg = item.data as TextMessage;
if (msg.kind === 'image' && msg.imagePath) {
copyToClipboard(`![image](${msg.imagePath})`);
} else {
copyToClipboard(msg.content || '');
}
} else if (
item.type === 'completed-tool-call' ||
item.type === 'in-progress-tool-call'
) {
copyToClipboard(formatToolCallForCopy(item.data as ToolCallData));
}
}
} else if (action === 'copyAllMessages') {
const parts: string[] = [];
for (const item of allMessages) {
if (item.type === 'message') {
const msg = item.data as TextMessage;
const content =
msg.kind === 'image' && msg.imagePath
? `![image](${msg.imagePath})`
: (msg.content || '').trim();
if (!content) continue;
if (msg.role === 'user') {
parts.push(`**User:** ${content}`);
} else if (msg.role === 'thinking') {
parts.push(`**Thinking:** ${content}`);
} else {
parts.push(`**Qwen Code:** ${content}`);
}
} else if (
item.type === 'completed-tool-call' ||
item.type === 'in-progress-tool-call'
) {
const tc = item.data as ToolCallData;
if (!shouldShowToolCall(tc.kind)) continue;
const text = formatToolCallForCopy(tc, true);
if (text) {
parts.push(`**[Tool: ${tc.kind}]**\n\n${text}`);
}
}
}
copyToClipboard(parts.join('\n\n---\n\n'));
} else if (action === 'copyLastReply') {
for (let i = allMessages.length - 1; i >= 0; i--) {
const item = allMessages[i];
if (item.type === 'message') {
const msg = item.data as TextMessage;
if (msg.role === 'assistant' && msg.content?.trim()) {
copyToClipboard(msg.content);
return;
}
}
}
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [allMessages, copyToClipboard, formatToolCallForCopy]);
const hasContent =
messageHandling.messages.length > 0 ||
messageHandling.isStreaming ||
@ -1023,6 +1238,9 @@ export const App: React.FC = () => {
<div
ref={messagesContainerRef}
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
data-vscode-context={
hasContent ? '{"webviewSection": "chat-messages"}' : undefined
}
>
{!hasContent && !isLoading && !sessionManagement.isSwitchingSession ? (
isAuthenticated === false ? (
@ -1052,6 +1270,7 @@ export const App: React.FC = () => {
<MessageList
allMessages={allMessages}
onFileClick={handleFileClick}
childIndexMap={childIndexMapRef}
/>
{insightProgress && (

View file

@ -66,6 +66,8 @@ export class WebViewProvider {
// Track current ACP mode id to influence permission/diff behavior
private currentModeId: ApprovalModeValue | null = null;
private authState: boolean | null = null;
/** Global tracker: the provider whose webview most recently received a contextmenu event */
private static lastContextMenuProvider: WebViewProvider | null = null;
/** Cached available commands for re-sending on webview ready */
private cachedAvailableCommands: AvailableCommand[] | null = null;
/** Cached available models for re-sending on webview ready */
@ -659,18 +661,7 @@ export class WebViewProvider {
// Handle messages from WebView
webview.onDidReceiveMessage(
async (message: { type: string; data?: unknown }) => {
if (message.type === 'openDiff' && this.isAutoMode()) {
return;
}
if (message.type === 'webviewReady') {
this.handleWebviewReady();
return;
}
if (message.type === 'resolveImagePaths') {
this.handleResolveImagePaths(message.data, webview);
return;
}
if (await this.handleOpenInsightReportMessage(message)) {
if (await this.handleCommonWebviewMessage(message, webview)) {
return;
}
if (this.handleNewChatByContext(message)) {
@ -820,19 +811,7 @@ export class WebViewProvider {
// Handle messages from WebView
newPanel.webview.onDidReceiveMessage(
async (message: { type: string; data?: unknown }) => {
// Suppress UI-originated diff opens in auto/yolo mode
if (message.type === 'openDiff' && this.isAutoMode()) {
return;
}
if (message.type === 'webviewReady') {
this.handleWebviewReady();
return;
}
if (message.type === 'resolveImagePaths') {
this.handleResolveImagePaths(message.data, newPanel.webview);
return;
}
if (await this.handleOpenInsightReportMessage(message)) {
if (await this.handleCommonWebviewMessage(message, newPanel.webview)) {
return;
}
// Allow webview to request updating the VS Code tab title
@ -1665,6 +1644,18 @@ export class WebViewProvider {
return true;
}
/**
* Send a copy command to the webview (triggered by native context menu).
* The webview resolves the content and posts back a 'copyToClipboard' message.
*/
sendCopyCommand(action: string): boolean {
if (WebViewProvider.lastContextMenuProvider !== this) return false;
const webview = this.getActiveWebview();
if (!webview) return false;
webview.postMessage({ type: 'copyCommand', data: { action } });
return true;
}
/**
* Send message to WebView
*/
@ -1720,6 +1711,41 @@ export class WebViewProvider {
return this.currentModeId;
}
/**
* Handle common webview message types shared across all host contexts
* (sidebar view, editor panel, restored panel).
* Returns true if the message was handled and no further processing is needed.
*/
private async handleCommonWebviewMessage(
message: { type: string; data?: unknown },
webview: vscode.Webview,
): Promise<boolean> {
if (message.type === 'openDiff' && this.isAutoMode()) {
return true;
}
if (message.type === 'webviewReady') {
this.handleWebviewReady();
return true;
}
if (message.type === 'contextMenuTriggered') {
WebViewProvider.lastContextMenuProvider = this;
return true;
}
if (message.type === 'copyToClipboard') {
const { text } = message.data as { text: string };
await vscode.env.clipboard.writeText(text);
return true;
}
if (message.type === 'resolveImagePaths') {
this.handleResolveImagePaths(message.data, webview);
return true;
}
if (await this.handleOpenInsightReportMessage(message)) {
return true;
}
return false;
}
/** True if diffs/permissions should be auto-handled without prompting. */
isAutoMode(): boolean {
return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo';
@ -1843,19 +1869,7 @@ export class WebViewProvider {
// Handle messages from WebView (restored panel)
panel.webview.onDidReceiveMessage(
async (message: { type: string; data?: unknown }) => {
// Suppress UI-originated diff opens in auto/yolo mode
if (message.type === 'openDiff' && this.isAutoMode()) {
return;
}
if (message.type === 'webviewReady') {
this.handleWebviewReady();
return;
}
if (message.type === 'resolveImagePaths') {
this.handleResolveImagePaths(message.data, panel.webview);
return;
}
if (await this.handleOpenInsightReportMessage(message)) {
if (await this.handleCommonWebviewMessage(message, panel.webview)) {
return;
}
if (message.type === 'updatePanelTitle') {
@ -2095,6 +2109,9 @@ export class WebViewProvider {
this.pendingAskUserQuestionResolve = null;
this.pendingAskUserQuestionRequest = null;
}
if (WebViewProvider.lastContextMenuProvider === this) {
WebViewProvider.lastContextMenuProvider = null;
}
this.panelManager.dispose();
this.agentManager.disconnect();
this.disposables.forEach((d) => d.dispose());