mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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  instead of empty string - Copy All Messages includes image messages as  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:
parent
44b482928b
commit
5e4ff3755c
6 changed files with 424 additions and 117 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
5
packages/vscode-ide-companion/package.nls.json
Normal file
5
packages/vscode-ide-companion/package.nls.json
Normal 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"
|
||||
}
|
||||
5
packages/vscode-ide-companion/package.nls.zh-cn.json
Normal file
5
packages/vscode-ide-companion/package.nls.zh-cn.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"qwen-code.copyMessage.title": "复制消息",
|
||||
"qwen-code.copyAllMessages.title": "复制全部消息",
|
||||
"qwen-code.copyLastReply.title": "复制最后回复"
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(``);
|
||||
} 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
|
||||
? ``
|
||||
: (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 && (
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue