diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index b7c50f57c..05e438c57 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -28,9 +28,66 @@ "ide companion" ], "activationEvents": [ - "onStartupFinished" + "onStartupFinished", + "onView:qwenCode.chatView.panel", + "onView:qwenCode.chatView.secondary", + "onCommand:qwen-code.setChatLocation.editor", + "onCommand:qwen-code.setChatLocation.panel", + "onCommand:qwen-code.setChatLocation.secondary", + "onCommand:qwen-code.openChat" ], "contributes": { + "configuration": { + "title": "Qwen Code Companion", + "properties": { + "qwen-code.chat.location": { + "type": "string", + "default": "editor", + "enum": [ + "editor", + "panel", + "secondary" + ], + "description": "选择聊天界面放置的位置:编辑器标签页(默认)、底部面板或 Secondary Side Bar。修改后需重新打开聊天。" + } + } + }, + "viewsContainers": { + "panel": [ + { + "id": "qwenCodePanel", + "title": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'panel'" + } + ], + "secondarySidebar": [ + { + "id": "qwenCodeSecondary", + "title": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'secondary'" + } + ] + }, + "views": { + "qwenCodePanel": [ + { + "id": "qwenCode.chatView.panel", + "name": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'panel'" + } + ], + "qwenCodeSecondary": [ + { + "id": "qwenCode.chatView.secondary", + "name": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'secondary'" + } + ] + }, "languages": [ { "id": "qwen-diff-editable" @@ -63,6 +120,24 @@ { "command": "qwen-code.login", "title": "Qwen Code: Login" + }, + { + "command": "qwen-code.setChatLocation.editor", + "title": "Qwen Code: 将聊天放在编辑器标签页" + }, + { + "command": "qwen-code.setChatLocation.panel", + "title": "Qwen Code: 将聊天放在底部面板" + }, + { + "command": "qwen-code.setChatLocation.secondary", + "title": "Qwen Code: 将聊天放在 Secondary Bar" + } + ], + "submenus": [ + { + "id": "qwenCode.chatLocationMenu", + "label": "Qwen Code: 聊天位置" } ], "menus": { @@ -94,6 +169,27 @@ { "command": "qwen-code.openChat", "group": "navigation" + }, + { + "submenu": "qwenCode.chatLocationMenu", + "group": "navigation@2" + } + ], + "qwenCode.chatLocationMenu": [ + { + "command": "qwen-code.setChatLocation.editor", + "group": "1_editor", + "when": "qwenCode.chatLocation != 'editor'" + }, + { + "command": "qwen-code.setChatLocation.panel", + "group": "2_panel", + "when": "qwenCode.chatLocation != 'panel'" + }, + { + "command": "qwen-code.setChatLocation.secondary", + "group": "3_secondary", + "when": "qwenCode.chatLocation != 'secondary'" } ] }, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index e75e1bd10..7ede00a63 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -3,12 +3,17 @@ import type { DiffManager } from '../diff-manager.js'; import type { WebViewProvider } from '../webview/WebViewProvider.js'; type Logger = (message: string) => void; +export type ChatHostLocation = 'editor' | 'panel' | 'secondary'; export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; export const loginCommand = 'qwen-code.login'; +export const setChatLocationEditorCommand = 'qwen-code.setChatLocation.editor'; +export const setChatLocationPanelCommand = 'qwen-code.setChatLocation.panel'; +export const setChatLocationSecondaryCommand = + 'qwen-code.setChatLocation.secondary'; export function registerNewCommands( context: vscode.ExtensionContext, @@ -16,11 +21,19 @@ export function registerNewCommands( diffManager: DiffManager, getWebViewProviders: () => WebViewProvider[], createWebViewProvider: () => WebViewProvider, + getChatHostLocation: () => ChatHostLocation, + focusChatView: () => Promise, ): void { const disposables: vscode.Disposable[] = []; disposables.push( vscode.commands.registerCommand(openChatCommand, async () => { + const host = getChatHostLocation(); + if (host !== 'editor') { + await focusChatView(); + return; + } + const providers = getWebViewProviders(); if (providers.length > 0) { await providers[providers.length - 1].show(); @@ -58,6 +71,15 @@ export function registerNewCommands( disposables.push( vscode.commands.registerCommand(openNewChatTabCommand, async () => { + const host = getChatHostLocation(); + if (host !== 'editor') { + vscode.window.showInformationMessage( + '当前配置使用面板/Secondary Bar 承载聊天,暂不支持多标签。将为你聚焦现有聊天视图。', + ); + await focusChatView(); + return; + } + const provider = createWebViewProvider(); // Session restoration is now disabled by default, so no need to suppress it await provider.show(); @@ -76,5 +98,75 @@ export function registerNewCommands( } }), ); + + const updateChatLocation = async (location: ChatHostLocation) => { + const current = getChatHostLocation(); + if (current === location) { + log(`[Command] Chat location unchanged (${location}), focusing view`); + await focusChatView(); + return; + } + + const target: vscode.ConfigurationTarget = + vscode.workspace.workspaceFolders?.length && vscode.workspace.name + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + + try { + await vscode.workspace + .getConfiguration('qwen-code') + .update('chat.location', location, target); + log( + `[Command] Chat location set to ${location} (target=${target}; prev=${current})`, + ); + // Update context immediately so view container visibility switches without reload + await vscode.commands.executeCommand( + 'setContext', + 'qwenCode.chatLocation', + location, + ); + + // Dispose existing editor-hosted chat panels when moving to panel/secondary to avoid confusion. + if (location !== 'editor') { + for (const provider of getWebViewProviders()) { + try { + provider.getPanel()?.dispose(); + } catch (error) { + log( + `[Command] Failed to dispose chat panel during relocation: ${error}`, + ); + } + } + } + + // Small delay to ensure context has updated before focusing + await new Promise((resolve) => setTimeout(resolve, 100)); + await focusChatView(); + void vscode.window.showInformationMessage( + `聊天位置已切换为 ${location === 'editor' ? '编辑器标签页' : location === 'panel' ? '底部面板' : 'Secondary Bar'},已尝试为你打开对应位置。如未生效,请重载窗口。`, + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log(`[Command] Failed to set chat location: ${msg}`); + void vscode.window.showErrorMessage( + `切换聊天位置失败:${msg}。可在 Settings 搜索 "Qwen Code Chat Location" 手动修改。`, + ); + } + }; + + disposables.push( + vscode.commands.registerCommand(setChatLocationEditorCommand, async () => { + await updateChatLocation('editor'); + }), + vscode.commands.registerCommand(setChatLocationPanelCommand, async () => { + await updateChatLocation('panel'); + }), + vscode.commands.registerCommand( + setChatLocationSecondaryCommand, + async () => { + await updateChatLocation('secondary'); + }, + ), + ); context.subscriptions.push(...disposables); } diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index be0f669e6..69382fb3f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,13 +15,42 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './webview/WebViewProvider.js'; -import { registerNewCommands } from './commands/index.js'; +import { + ChatWebviewViewProvider, + CHAT_VIEW_ID_PANEL, + CHAT_VIEW_ID_SECONDARY, +} from './webview/ChatWebviewViewProvider.js'; +import { + registerNewCommands, + type ChatHostLocation, +} from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; export const DIFF_SCHEME = 'qwen-diff'; +const CHAT_LOCATION_SETTING_KEY = 'chat.location'; + +function getChatHostLocation(): ChatHostLocation { + const location = vscode.workspace + .getConfiguration('qwen-code') + .get(CHAT_LOCATION_SETTING_KEY, 'editor'); + + if (location === 'panel' || location === 'secondary') { + return location; + } + return 'editor'; +} + +async function setChatLocationContext(location: ChatHostLocation) { + console.log('[Extension] setChatLocationContext ->', location); + await vscode.commands.executeCommand( + 'setContext', + 'qwenCode.chatLocation', + location, + ); +} /** * IDE environments where the installation greeting is hidden. In these @@ -143,6 +172,9 @@ export async function activate(context: vscode.ExtensionContext) { }, ); + const initialChatLocation = getChatHostLocation(); + await setChatLocationContext(initialChatLocation); + // Helper function to create a new WebView provider instance const createWebViewProvider = (): WebViewProvider => { const provider = new WebViewProvider(context, context.extensionUri); @@ -150,6 +182,44 @@ export async function activate(context: vscode.ExtensionContext) { return provider; }; + // Register WebviewView hosts (panel and secondary sidebar). They are shown based on config conditions. + const chatViewProviderPanel = new ChatWebviewViewProvider( + createWebViewProvider(), + context.extensionUri, + 'panel', + ); + const chatViewProviderSecondary = new ChatWebviewViewProvider( + createWebViewProvider(), + context.extensionUri, + 'secondary', + ); + console.log( + '[Extension] Registering WebviewView providers for panel and secondary', + ); + const panelProviderDisposable = vscode.window.registerWebviewViewProvider( + CHAT_VIEW_ID_PANEL, + chatViewProviderPanel, + { + webviewOptions: { retainContextWhenHidden: true }, + }, + ); + const secondaryProviderDisposable = vscode.window.registerWebviewViewProvider( + CHAT_VIEW_ID_SECONDARY, + chatViewProviderSecondary, + { + webviewOptions: { retainContextWhenHidden: true }, + }, + ); + console.log( + '[Extension] WebviewView providers registered:', + panelProviderDisposable ? 'panel' : 'panel (missing)', + secondaryProviderDisposable ? 'secondary' : 'secondary (missing)', + ); + context.subscriptions.push( + panelProviderDisposable, + secondaryProviderDisposable, + ); + // Register WebView panel serializer for persistence across reloads context.subscriptions.push( vscode.window.registerWebviewPanelSerializer('qwenCode.chat', { @@ -187,6 +257,43 @@ export async function activate(context: vscode.ExtensionContext) { }), ); + const focusChatView = async () => { + const host = getChatHostLocation(); + if (host === 'editor') { + const providers = webViewProviders; + if (providers.length > 0) { + await providers[providers.length - 1].show(); + } else { + const provider = createWebViewProvider(); + await provider.show(); + } + return; + } + + const viewId = + host === 'secondary' ? CHAT_VIEW_ID_SECONDARY : CHAT_VIEW_ID_PANEL; + try { + // Ensure the view provider is registered and ready before focusing + await new Promise((resolve) => setTimeout(resolve, 200)); + await vscode.commands.executeCommand(`${viewId}.focus`); + + // Wait a bit more to ensure the view has initialized + await new Promise((resolve) => setTimeout(resolve, 300)); + } catch (err) { + log(`Failed to focus chat view (${viewId}): ${err}`); + // Try to ensure the view provider exists by creating new ones if needed + try { + const currentProviders = webViewProviders; + if (currentProviders.length === 0) { + createWebViewProvider(); // Create a new provider if none exist + } + await vscode.commands.executeCommand(`${viewId}.focus`); + } catch (retryErr) { + log(`Retry failed to focus chat view (${viewId}): ${retryErr}`); + } + } + }; + // Register newly added commands via commands module registerNewCommands( context, @@ -194,6 +301,8 @@ export async function activate(context: vscode.ExtensionContext) { diffManager, () => webViewProviders, createWebViewProvider, + getChatHostLocation, + focusChatView, ); context.subscriptions.push( @@ -202,6 +311,48 @@ export async function activate(context: vscode.ExtensionContext) { diffManager.cancelDiff(doc.uri); } }), + vscode.workspace.onDidChangeConfiguration(async (event) => { + if (event.affectsConfiguration('qwen-code.chat.location')) { + const newLocation = getChatHostLocation(); + log( + `[Extension] Configuration changed: chat.location -> ${newLocation}`, + ); + await setChatLocationContext(newLocation); + if (newLocation !== 'editor') { + for (const provider of webViewProviders) { + try { + provider.getPanel()?.dispose(); + } catch (err) { + log( + `[Extension] Failed to dispose chat panel after location change: ${err}`, + ); + } + } + } + + // Focus the appropriate view after config change + if (newLocation === 'panel' || newLocation === 'secondary') { + const viewId = + newLocation === 'secondary' + ? CHAT_VIEW_ID_SECONDARY + : CHAT_VIEW_ID_PANEL; + try { + // Give VS Code a moment to update the context and UI + await new Promise((resolve) => setTimeout(resolve, 300)); + await vscode.commands.executeCommand(`${viewId}.focus`); + + // Refresh the view to ensure content appears + await new Promise((resolve) => setTimeout(resolve, 200)); + // Trigger a re-render by focusing again after a delay + await vscode.commands.executeCommand(`${viewId}.focus`); + } catch (err) { + log( + `[Extension] Failed to focus chat view after location change: ${err}`, + ); + } + } + } + }), vscode.workspace.registerTextDocumentContentProvider( DIFF_SCHEME, diffContentProvider, diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts index e3b837778..13897412c 100644 --- a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -7,12 +7,23 @@ import * as vscode from 'vscode'; import { openChatCommand } from '../commands/index.js'; +function isEditorHostedChat(): boolean { + const location = vscode.workspace + .getConfiguration('qwen-code') + .get('chat.location', 'editor'); + return location === 'editor'; +} + /** * Find the editor group immediately to the left of the Qwen chat webview. * - If the chat webview group is the leftmost group, returns undefined. * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. */ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { + if (!isEditorHostedChat()) { + return undefined; + } + try { const groups = vscode.window.tabGroups.all; @@ -92,6 +103,10 @@ function waitForTabGroupsCondition( export async function ensureLeftGroupOfChatWebview(): Promise< vscode.ViewColumn | undefined > { + if (!isEditorHostedChat()) { + return undefined; + } + // First try to find an existing left neighbor const existing = findLeftGroupOfChatWebview(); if (existing !== undefined) { diff --git a/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts b/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts new file mode 100644 index 000000000..5eb906ac5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { WebViewProvider } from './WebViewProvider.js'; + +export const CHAT_VIEW_ID_PANEL = 'qwenCode.chatView.panel'; +export const CHAT_VIEW_ID_SECONDARY = 'qwenCode.chatView.secondary'; + +/** + * WebviewView host for placing the chat UI in panel/secondary sidebar. + */ +export class ChatWebviewViewProvider implements vscode.WebviewViewProvider { + constructor( + private readonly webViewProvider: WebViewProvider, + private readonly extensionUri: vscode.Uri, + private readonly hostKind: 'panel' | 'secondary', + ) {} + + async resolveWebviewView(webviewView: vscode.WebviewView): Promise { + console.log( + `[ChatWebviewViewProvider] resolveWebviewView invoked for ${webviewView.viewType} (host=${this.hostKind})`, + ); + // Determine the view ID from the webviewView + const viewId = webviewView.viewType; // This will be either 'qwenCode.chatView.panel' or 'qwenCode.chatView.secondary' + + // Ensure scripts/resources are allowed + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }; + + await this.webViewProvider.attachToView(webviewView, viewId); + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/WebViewContent.ts index 8f802c84f..f3f934890 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewContent.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewContent.ts @@ -14,20 +14,17 @@ import { escapeHtml } from './utils/webviewUtils.js'; export class WebViewContent { /** * Generate HTML content for the WebView - * @param panel WebView Panel + * @param webview VS Code Webview (panel or view) * @param extensionUri Extension URI * @returns HTML string */ - static generate( - panel: vscode.WebviewPanel, - extensionUri: vscode.Uri, - ): string { - const scriptUri = panel.webview.asWebviewUri( + static generate(webview: vscode.Webview, extensionUri: vscode.Uri): string { + const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'), ); // Convert extension URI for webview access - this allows frontend to construct resource paths - const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri); + const extensionUriForWebview = webview.asWebviewUri(extensionUri); // Escape URI for HTML to prevent potential injection attacks const safeExtensionUri = escapeHtml(extensionUriForWebview.toString()); @@ -38,7 +35,7 @@ export class WebViewContent { - + Qwen Code diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 5aa92c0fb..248d6f20c 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -21,6 +21,9 @@ export class WebViewProvider { private agentManager: QwenAgentManager; private conversationStore: ConversationStore; private disposables: vscode.Disposable[] = []; + private currentWebview: vscode.Webview | null = null; + // Track webviews by view ID for better multi-view support + private webviews: Map = new Map(); private agentInitialized = false; // Track if agent has been initialized // Track a pending permission request and its resolver so extension commands // can "simulate" user choice from the command palette (e.g. after accepting @@ -38,7 +41,7 @@ export class WebViewProvider { this.conversationStore = new ConversationStore(context); this.panelManager = new PanelManager(extensionUri, () => { // Panel dispose callback - this.disposables.forEach((d) => d.dispose()); + this.disposeWebviewDisposables(); }); this.messageHandler = new MessageHandler( this.agentManager, @@ -369,6 +372,162 @@ export class WebViewProvider { ); } + /** + * Dispose and reset all webview-scoped disposables (listeners bound per host) + */ + private disposeWebviewDisposables(): void { + for (const disposable of this.disposables) { + try { + disposable.dispose(); + } catch (_err) { + // Best-effort cleanup + } + } + this.disposables = []; + this.currentWebview = null; + } + + private setupMessageListener( + webview: vscode.Webview, + titleSetter?: (title: string) => void, + ): void { + const disposable = webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + // Allow webview to request updating the VS Code tab title + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + if (titleSetter) { + titleSetter(title || 'Qwen Code'); + } + return; + } + await this.messageHandler.route(message); + }, + ); + this.disposables.push(disposable); + } + + private setupEditorListeners(): void { + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + const selectionChangeDisposable = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + }); + this.disposables.push(selectionChangeDisposable); + } + + private sendInitialActiveEditorState(): void { + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + } + + /** + * Common binding logic for both WebviewPanel and WebviewView hosts. + */ + private async bindWebview( + webview: vscode.Webview, + options?: { titleSetter?: (title: string) => void; viewId?: string }, + ): Promise { + try { + console.log('[WebViewProvider] bindWebview start for host'); + this.disposeWebviewDisposables(); + this.currentWebview = webview; + + // Store the webview in the map if viewId is provided + if (options?.viewId) { + this.webviews.set(options.viewId, webview); + } + + webview.html = WebViewContent.generate(webview, this.extensionUri); + + this.setupMessageListener(webview, options?.titleSetter); + this.setupEditorListeners(); + this.sendInitialActiveEditorState(); + + // Attempt to restore authentication state and initialize connection + // Don't await this to prevent blocking the UI + this.attemptAuthStateRestoration().catch((error) => { + console.error('[WebViewProvider] Error in auth restoration:', error); + }); + console.log('[WebViewProvider] bindWebview completed'); + } catch (error) { + console.error('[WebViewProvider] Error in bindWebview:', error); + // Fallback to basic HTML if binding fails + webview.html = `
Initialization error: ${(error as Error).message || 'Unknown error'}
`; + throw error; // Re-throw so caller can handle it + } + } + async show(): Promise { const panel = this.panelManager.getPanel(); @@ -391,142 +550,75 @@ export class WebViewProvider { return; } - // Set up state serialization - newPanel.onDidChangeViewState(() => { - console.log( - '[WebViewProvider] Panel view state changed, triggering serialization check', - ); - }); - // Capture the Tab that corresponds to our WebviewPanel this.panelManager.captureTab(); // Auto-lock editor group when opened in new column await this.panelManager.autoLockEditorGroup(); - newPanel.webview.html = WebViewContent.generate( - newPanel, - this.extensionUri, - ); - - // 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; + await this.bindWebview(newPanel.webview, { + titleSetter: (title: string) => { + try { + newPanel.title = title || 'Qwen Code'; + } catch (_err) { + // Best-effort only } - // Allow webview to request updating the VS Code tab title - if (message.type === 'updatePanelTitle') { - const title = String( - (message.data as { title?: unknown } | undefined)?.title ?? '', - ).trim(); - const panelRef = this.panelManager.getPanel(); - if (panelRef) { - panelRef.title = title || 'Qwen Code'; - } - return; - } - await this.messageHandler.route(message); }, - null, - this.disposables, - ); + }); // Listen for view state changes (no pin/lock; just keep tab reference fresh) this.panelManager.registerViewStateChangeHandler(this.disposables); // Register panel dispose handler this.panelManager.registerDisposeHandler(this.disposables); + } - // Listen for active editor changes and notify WebView - const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( - (editor) => { - // If switching to a non-text editor (like webview), keep the last state - if (!editor) { - // Don't update - keep previous state - return; - } - - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (editor && !editor.selection.isEmpty) { - const selection = editor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - }, + /** + * Attach the chat experience to a WebviewView (panel/secondary sidebar host) + */ + async attachToView( + webviewView: vscode.WebviewView, + viewId?: string, + ): Promise { + console.log( + `[WebViewProvider] attachToView called for ${webviewView.viewType} (id=${viewId ?? 'unknown'})`, ); - this.disposables.push(editorChangeDisposable); + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }; - // Listen for text selection changes - const selectionChangeDisposable = - vscode.window.onDidChangeTextEditorSelection((event) => { - const editor = event.textEditor; - if (editor === vscode.window.activeTextEditor) { - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (!event.selections[0].isEmpty) { - const selection = event.selections[0]; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; + try { + await this.bindWebview(webviewView.webview, { + titleSetter: (title: string) => { + try { + webviewView.title = title || 'Qwen Code'; + } catch (_err) { + // Ignore title update errors } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - - // Mode callbacks are registered in constructor; no-op here - } - }); - this.disposables.push(selectionChangeDisposable); - - // Send initial active editor state to WebView - const initialEditor = vscode.window.activeTextEditor; - if (initialEditor) { - const filePath = initialEditor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - let selectionInfo = null; - if (!initialEditor.selection.isEmpty) { - const selection = initialEditor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, + }, + viewId, // Pass the viewId to bindWebview }); + } catch (error) { + console.error('[WebViewProvider] Error in attachToView:', error); + // Ensure some content is displayed even if binding fails + webviewView.webview.html = `
Error loading chat: ${error instanceof Error ? error.message : String(error)}
`; } - // Attempt to restore authentication state and initialize connection - console.log( - '[WebViewProvider] Attempting to restore auth state and connection...', + webviewView.onDidDispose( + () => { + // Remove the webview from the map on dispose + if (viewId) { + this.webviews.delete(viewId); + } + this.disposeWebviewDisposables(); + }, + null, + this.disposables, ); - await this.attemptAuthStateRestoration(); } /** @@ -878,6 +970,32 @@ export class WebViewProvider { * Send message to WebView */ private sendMessageToWebView(message: unknown): void { + // Prioritize sending to current active webview + if (this.currentWebview) { + try { + void this.currentWebview.postMessage(message); + return; + } catch (_err) { + // Fallback to panel below + } + } + + // If we have webviews stored by ID, try to send to all of them + if (this.webviews.size > 0) { + for (const [id, webview] of this.webviews) { + try { + void webview.postMessage(message); + } catch (err) { + console.error( + `[WebViewProvider] Error posting message to webview ${id}:`, + err, + ); + } + } + return; + } + + // Fallback to panel const panel = this.panelManager.getPanel(); panel?.webview.postMessage(message); } @@ -1008,30 +1126,18 @@ export class WebViewProvider { ); } - panel.webview.html = WebViewContent.generate(panel, this.extensionUri); - - // 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 === 'updatePanelTitle') { - const title = String( - (message.data as { title?: unknown } | undefined)?.title ?? '', - ).trim(); + await this.bindWebview(panel.webview, { + titleSetter: (title: string) => { + try { const panelRef = this.panelManager.getPanel(); if (panelRef) { panelRef.title = title || 'Qwen Code'; } - return; + } catch (_err) { + // Ignore } - await this.messageHandler.route(message); }, - null, - this.disposables, - ); + }); // Register view state change handler this.panelManager.registerViewStateChangeHandler(this.disposables); @@ -1039,97 +1145,10 @@ export class WebViewProvider { // Register dispose handler this.panelManager.registerDisposeHandler(this.disposables); - // Listen for active editor changes and notify WebView - const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( - (editor) => { - // If switching to a non-text editor (like webview), keep the last state - if (!editor) { - // Don't update - keep previous state - return; - } - - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (editor && !editor.selection.isEmpty) { - const selection = editor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - }, - ); - this.disposables.push(editorChangeDisposable); - - // Send initial active editor state to WebView - const initialEditor = vscode.window.activeTextEditor; - if (initialEditor) { - const filePath = initialEditor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - let selectionInfo = null; - if (!initialEditor.selection.isEmpty) { - const selection = initialEditor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - } - - // Listen for text selection changes (restore path) - const selectionChangeDisposableRestore = - vscode.window.onDidChangeTextEditorSelection((event) => { - const editor = event.textEditor; - if (editor === vscode.window.activeTextEditor) { - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (!event.selections[0].isEmpty) { - const selection = event.selections[0]; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - } - }); - this.disposables.push(selectionChangeDisposableRestore); - // Capture the tab reference on restore this.panelManager.captureTab(); console.log('[WebViewProvider] Panel restored successfully'); - - // Attempt to restore authentication state and initialize connection - console.log( - '[WebViewProvider] Attempting to restore auth state and connection after restore...', - ); - await this.attemptAuthStateRestoration(); } /** @@ -1182,7 +1201,10 @@ export class WebViewProvider { // Reload content after restore const panel = this.panelManager.getPanel(); if (panel) { - panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + panel.webview.html = WebViewContent.generate( + panel.webview, + this.extensionUri, + ); } }