From 77d8af6715233276175a6b11e9bd04476be7e21d Mon Sep 17 00:00:00 2001 From: ZZBuAoYe Date: Sun, 8 Mar 2026 16:29:28 +0800 Subject: [PATCH 1/3] feat(vscode): add sidebar chat view --- packages/vscode-ide-companion/assets/icon.svg | 1 + packages/vscode-ide-companion/package.json | 18 + .../src/extension.test.ts | 16 + .../vscode-ide-companion/src/extension.ts | 29 +- .../src/webview/SidebarWebviewProvider.ts | 766 ++++++++++++++++++ .../src/webview/WebViewContent.ts | 17 +- 6 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 packages/vscode-ide-companion/assets/icon.svg create mode 100644 packages/vscode-ide-companion/src/webview/SidebarWebviewProvider.ts diff --git a/packages/vscode-ide-companion/assets/icon.svg b/packages/vscode-ide-companion/assets/icon.svg new file mode 100644 index 0000000000..3c42e5a0a6 --- /dev/null +++ b/packages/vscode-ide-companion/assets/icon.svg @@ -0,0 +1 @@ +Qwen diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 79e6193df2..28b2a54d13 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -31,6 +31,24 @@ "onStartupFinished" ], "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "qwen-code-sidebar", + "title": "Qwen Code", + "icon": "assets/icon.svg" + } + ] + }, + "views": { + "qwen-code-sidebar": [ + { + "id": "qwen-code-chat", + "name": "Chat", + "type": "webview" + } + ] + }, "jsonValidation": [ { "fileMatch": "**/.qwen/settings.json", diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index ef0d5ad46a..1e48277b66 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -43,6 +43,9 @@ vi.mock('vscode', () => ({ registerWebviewPanelSerializer: vi.fn(() => ({ dispose: vi.fn(), })), + registerWebviewViewProvider: vi.fn(() => ({ + dispose: vi.fn(), + })), }, workspace: { workspaceFolders: [], @@ -134,6 +137,19 @@ describe('activate', () => { expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled(); }); + it('should register the sidebar webview provider', async () => { + await activate(context); + expect(vscode.window.registerWebviewViewProvider).toHaveBeenCalledWith( + 'qwen-code-chat', + expect.anything(), + { + webviewOptions: { + retainContextWhenHidden: true, + }, + }, + ); + }); + it('should launch the Qwen Code when the user clicks the button', async () => { const showInformationMessageMock = vi .mocked(vscode.window.showInformationMessage) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 14ff4bcaeb..dda9ac7a43 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,6 +15,7 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './webview/WebViewProvider.js'; +import { SidebarWebviewProvider } from './webview/SidebarWebviewProvider.js'; import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; @@ -36,6 +37,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs +let sidebarProvider: SidebarWebviewProvider | null = null; let log: (message: string) => void = () => {}; @@ -125,15 +127,18 @@ export async function activate(context: vscode.ExtensionContext) { ); log('Readonly file system provider registered'); + const getPermissionAwareProviders = () => + sidebarProvider ? [...webViewProviders, sidebarProvider] : webViewProviders; + const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager( log, diffContentProvider, // Delay when any chat tab has a pending permission drawer - () => webViewProviders.some((p) => p.hasPendingPermission()), + () => getPermissionAwareProviders().some((p) => p.hasPendingPermission()), // Suppress diffs when active mode is auto or yolo in any chat tab () => { - const providers = webViewProviders.filter( + const providers = getPermissionAwareProviders().filter( (p) => typeof p.shouldSuppressDiff === 'function', ); if (providers.length === 0) { @@ -150,6 +155,20 @@ export async function activate(context: vscode.ExtensionContext) { return provider; }; + sidebarProvider = new SidebarWebviewProvider(context, context.extensionUri); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + 'qwen-code-chat', + sidebarProvider, + { + webviewOptions: { + retainContextWhenHidden: true, + }, + }, + ), + sidebarProvider, + ); + // Register WebView panel serializer for persistence across reloads context.subscriptions.push( vscode.window.registerWebviewPanelSerializer('qwenCode.chat', { @@ -213,7 +232,7 @@ export async function activate(context: vscode.ExtensionContext) { } // If WebView is requesting permission, actively select an allow option (prefer once) try { - for (const provider of webViewProviders) { + for (const provider of getPermissionAwareProviders()) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('allow'); } @@ -230,7 +249,7 @@ export async function activate(context: vscode.ExtensionContext) { } // If WebView is requesting permission, actively select reject/cancel try { - for (const provider of webViewProviders) { + for (const provider of getPermissionAwareProviders()) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('cancel'); } @@ -369,6 +388,8 @@ export async function deactivate(): Promise { provider.dispose(); }); webViewProviders = []; + sidebarProvider?.dispose(); + sidebarProvider = null; } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/webview/SidebarWebviewProvider.ts b/packages/vscode-ide-companion/src/webview/SidebarWebviewProvider.ts new file mode 100644 index 0000000000..0dbf3781cc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/SidebarWebviewProvider.ts @@ -0,0 +1,766 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { + AvailableCommand, + ModelInfo, + RequestPermissionRequest, +} from '@agentclientprotocol/sdk'; +import { QwenAgentManager } from '../services/qwenAgentManager.js'; +import { ConversationStore } from '../services/conversationStore.js'; +import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; +import { MessageHandler } from './MessageHandler.js'; +import { WebViewContent } from './WebViewContent.js'; +import { getFileName } from './utils/webviewUtils.js'; +import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; + +export class SidebarWebviewProvider + implements vscode.WebviewViewProvider, vscode.Disposable +{ + private view: vscode.WebviewView | null = null; + private messageHandler: MessageHandler; + private agentManager: QwenAgentManager; + private conversationStore: ConversationStore; + private disposables: vscode.Disposable[] = []; + private agentInitialized = false; + private pendingPermissionRequest: RequestPermissionRequest | null = null; + private pendingPermissionResolve: ((optionId: string) => void) | null = null; + private currentModeId: ApprovalModeValue | null = null; + private authState: boolean | null = null; + private cachedAvailableModels: ModelInfo[] | null = null; + private cachedAvailableCommands: AvailableCommand[] | null = null; + private initializationPromise: Promise | null = null; + + constructor( + context: vscode.ExtensionContext, + private readonly extensionUri: vscode.Uri, + ) { + this.agentManager = new QwenAgentManager(); + this.conversationStore = new ConversationStore(context); + this.messageHandler = new MessageHandler( + this.agentManager, + this.conversationStore, + null, + (message) => this.sendMessageToWebView(message), + ); + + this.messageHandler.setLoginHandler(async () => { + await this.forceReLogin(); + }); + + this.agentManager.onMessage((message) => { + this.sendMessageToWebView({ + type: 'message', + data: message, + }); + }); + + this.agentManager.onStreamChunk((chunk: string) => { + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'streamChunk', + data: { chunk }, + }); + }); + + this.agentManager.onThoughtChunk((chunk: string) => { + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'thoughtChunk', + data: { chunk }, + }); + }); + + this.agentManager.onModeInfo((info) => { + try { + this.currentModeId = (info?.currentModeId || + null) as ApprovalModeValue | null; + } catch (_error) { + // Ignore invalid mode payloads. + } + this.sendMessageToWebView({ + type: 'modeInfo', + data: info || {}, + }); + }); + + this.agentManager.onModeChanged((modeId) => { + try { + this.currentModeId = modeId; + } catch (_error) { + // Ignore invalid mode payloads. + } + this.sendMessageToWebView({ + type: 'modeChanged', + data: { modeId }, + }); + }); + + this.agentManager.onUsageUpdate((stats) => { + this.sendMessageToWebView({ + type: 'usageStats', + data: stats, + }); + }); + + this.agentManager.onModelInfo((info) => { + this.sendMessageToWebView({ + type: 'modelInfo', + data: info, + }); + }); + + this.agentManager.onModelChanged((model) => { + this.sendMessageToWebView({ + type: 'modelChanged', + data: { model }, + }); + }); + + this.agentManager.onAvailableCommands((commands) => { + this.cachedAvailableCommands = commands; + this.sendMessageToWebView({ + type: 'availableCommands', + data: { commands }, + }); + }); + + this.agentManager.onAvailableModels((models) => { + this.cachedAvailableModels = models; + this.sendMessageToWebView({ + type: 'availableModels', + data: { models }, + }); + }); + + this.agentManager.onEndTurn((reason) => { + this.sendMessageToWebView({ + type: 'streamEnd', + data: { + timestamp: Date.now(), + reason: reason || 'end_turn', + }, + }); + }); + + this.agentManager.onToolCall((update) => { + const updateData = update as unknown as Record; + let messageType = updateData.sessionUpdate as string | undefined; + if (!messageType) { + messageType = + updateData.kind || updateData.title || updateData.rawInput + ? 'tool_call' + : 'tool_call_update'; + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: messageType, + ...updateData, + }, + }); + }); + + this.agentManager.onPlan((entries) => { + this.sendMessageToWebView({ + type: 'plan', + data: { entries }, + }); + }); + + this.agentManager.onPermissionRequest( + async (request: RequestPermissionRequest) => { + if (this.isAutoMode()) { + const options = request.options || []; + const pick = (substr: string) => + options.find((o) => + (o.optionId || '').toLowerCase().includes(substr), + )?.optionId; + const pickByKind = (kind: string) => + options.find((o) => (o.kind || '').toLowerCase().includes(kind)) + ?.optionId; + return ( + pick('allow_once') || + pickByKind('allow') || + pick('proceed') || + options[0]?.optionId || + 'allow_once' + ); + } + + this.sendMessageToWebView({ + type: 'permissionRequest', + data: request, + }); + + return new Promise((resolve) => { + this.pendingPermissionRequest = request; + this.pendingPermissionResolve = (optionId: string) => { + try { + resolve(optionId); + } finally { + this.pendingPermissionRequest = null; + this.pendingPermissionResolve = null; + this.sendMessageToWebView({ + type: 'permissionResolved', + data: { optionId }, + }); + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + if (!isCancel) { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + void vscode.commands.executeCommand( + 'qwen.diff.suppressBriefly', + ); + } + } + }; + + const handler = (message: PermissionResponseMessage) => { + if (message.type !== 'permissionResponse') { + return; + } + + const optionId = message.data.optionId || ''; + this.pendingPermissionResolve?.(optionId); + + const isCancel = + optionId === 'cancel' || + optionId.toLowerCase().includes('reject'); + if (!isCancel) { + void vscode.commands.executeCommand('qwen.diff.closeAll'); + void vscode.commands.executeCommand('qwen.diff.suppressBriefly'); + return; + } + + void vscode.commands.executeCommand('qwen.diff.closeAll'); + void (async () => { + try { + await this.agentManager.cancelCurrentPrompt(); + } catch (_error) { + // Ignore cancellation races. + } + + this.sendMessageToWebView({ + type: 'streamEnd', + data: { timestamp: Date.now(), reason: 'user_cancelled' }, + }); + + try { + const toolCallId = + (request.toolCall as { toolCallId?: string } | undefined) + ?.toolCallId || ''; + const title = + (request.toolCall as { title?: string } | undefined)?.title || + ''; + let kind = ((request.toolCall as { kind?: string } | undefined) + ?.kind || 'execute') as string; + if (!kind && title) { + const normalizedTitle = title.toLowerCase(); + if ( + normalizedTitle.includes('read') || + normalizedTitle.includes('cat') + ) { + kind = 'read'; + } else if ( + normalizedTitle.includes('write') || + normalizedTitle.includes('edit') + ) { + kind = 'edit'; + } else { + kind = 'execute'; + } + } + + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: 'tool_call_update', + toolCallId, + title, + kind, + status: 'failed', + rawInput: (request.toolCall as { rawInput?: unknown }) + ?.rawInput, + locations: ( + request.toolCall as { + locations?: Array<{ + path: string; + line?: number | null; + }>; + } + )?.locations, + }, + }); + } catch (_error) { + // Ignore best-effort UI updates. + } + })(); + }; + + this.messageHandler.setPermissionHandler(handler); + }); + }, + ); + } + + async resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): Promise { + this.disposeViewListeners(); + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + webviewView.webview.html = WebViewContent.generate( + webviewView, + this.extensionUri, + ); + + this.disposables.push( + webviewView.webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + if (message.type === 'webviewReady') { + this.handleWebviewReady(); + if (!this.agentInitialized) { + await this.attemptAuthStateRestoration(); + } + return; + } + if (message.type === 'updatePanelTitle') { + return; + } + if (message.type === 'openNewChatTab') { + await this.messageHandler.route({ + type: 'newQwenSession', + data: message.data, + }); + return; + } + + await this.messageHandler.route(message); + }, + ), + ); + + this.disposables.push( + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible && !this.agentInitialized) { + void this.attemptAuthStateRestoration(); + } + }), + ); + + this.registerActiveEditorListeners(); + + if (webviewView.visible) { + await this.attemptAuthStateRestoration(); + } + } + + async forceReLogin(): Promise { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + }, + async (progress) => { + progress.report({ message: 'Preparing sign-in...' }); + + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + } catch (_error) { + // Ignore disconnect failures during re-auth. + } + this.agentInitialized = false; + } + + this.authState = null; + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: null }, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + progress.report({ + message: 'Connecting to CLI and starting sign-in...', + }); + + await this.doInitializeAgentConnection({ autoAuthenticate: true }); + this.sendMessageToWebView({ + type: 'loginSuccess', + data: { message: 'Successfully logged in!' }, + }); + }, + ); + } + + hasPendingPermission(): boolean { + return !!this.pendingPermissionResolve; + } + + shouldSuppressDiff(): boolean { + return this.isAutoMode(); + } + + respondToPendingPermission( + choice: { optionId: string } | 'accept' | 'allow' | 'reject' | 'cancel', + ): void { + if (!this.pendingPermissionResolve || !this.pendingPermissionRequest) { + return; + } + + const options = this.pendingPermissionRequest.options || []; + const pickByKind = (substr: string, preferOnce = false) => { + const filtered = options.filter((o) => + (o.kind || '').toLowerCase().includes(substr.toLowerCase()), + ); + if (preferOnce) { + const once = filtered.find((o) => + (o.optionId || '').toLowerCase().includes('once'), + ); + if (once) { + return once.optionId; + } + } + return filtered[0]?.optionId; + }; + const pickByOptionId = (substr: string) => + options.find((o) => (o.optionId || '').toLowerCase().includes(substr)) + ?.optionId; + + let optionId: string | undefined; + if (typeof choice === 'object') { + optionId = choice.optionId; + } else if (choice === 'accept' || choice === 'allow') { + optionId = + pickByKind('allow', true) || + pickByOptionId('proceed_once') || + pickByKind('allow') || + pickByOptionId('proceed') || + options[0]?.optionId; + } else { + optionId = + options.find((o) => o.optionId === 'cancel')?.optionId || + pickByKind('reject') || + pickByOptionId('cancel') || + pickByOptionId('reject') || + 'cancel'; + } + + if (optionId) { + this.pendingPermissionResolve(optionId); + } + } + + dispose(): void { + this.disposeViewListeners(); + this.view = null; + this.agentManager.disconnect(); + } + + private disposeViewListeners(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } + + private registerActiveEditorListeners(): void { + const sendActiveEditorState = ( + editor: vscode.TextEditor | undefined, + selection?: vscode.Selection, + ) => { + if (!editor) { + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + const activeSelection = selection ?? editor.selection; + const selectionInfo = activeSelection.isEmpty + ? null + : { + startLine: activeSelection.start.line + 1, + endLine: activeSelection.end.line + 1, + }; + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }; + + this.disposables.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + sendActiveEditorState(editor); + }), + ); + + this.disposables.push( + vscode.window.onDidChangeTextEditorSelection((event) => { + if (event.textEditor === vscode.window.activeTextEditor) { + sendActiveEditorState(event.textEditor, event.selections[0]); + } + }), + ); + + sendActiveEditorState(vscode.window.activeTextEditor); + } + + private async attemptAuthStateRestoration(): Promise { + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = (async () => { + try { + await this.initializeAgentConnection({ autoAuthenticate: false }); + } catch (_error) { + await this.initializeEmptyConversation(); + } finally { + this.initializationPromise = null; + } + })(); + + return this.initializationPromise; + } + + private async initializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + await this.doInitializeAgentConnection(options); + } + + private async doInitializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + const bundledCliEntry = vscode.Uri.joinPath( + this.extensionUri, + 'dist', + 'qwen-cli', + 'cli.js', + ).fsPath; + + try { + const connectResult = await this.agentManager.connect( + workingDir, + bundledCliEntry, + options, + ); + this.agentInitialized = true; + + if (connectResult.requiresAuth && !options?.autoAuthenticate) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + await this.initializeEmptyConversation(); + return; + } + + if (connectResult.requiresAuth) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } + + const sessionReady = await this.loadCurrentSessionMessages(options); + if (sessionReady) { + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } + } catch (error) { + const requiresAuth = isAuthenticationRequiredError(error); + if (requiresAuth) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + await this.initializeEmptyConversation(); + return; + } + + vscode.window.showWarningMessage( + `Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`, + ); + await this.initializeEmptyConversation(); + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private async loadCurrentSessionMessages(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionReady = false; + + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + if (!this.agentManager.currentSessionId) { + if (!autoAuthenticate) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + try { + await this.agentManager.createNewSession(workingDir, { + autoAuthenticate, + }); + sessionReady = true; + } catch (sessionError) { + if ( + isAuthenticationRequiredError(sessionError) && + !autoAuthenticate + ) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + vscode.window.showWarningMessage( + `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, + ); + } + } + } + } else { + sessionReady = true; + } + + await this.initializeEmptyConversation(); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to load session messages: ${error}`, + ); + await this.initializeEmptyConversation(); + return false; + } + + return sessionReady; + } + + private async initializeEmptyConversation(): Promise { + try { + const newConversation = await this.conversationStore.createConversation(); + this.messageHandler.setCurrentConversationId(newConversation.id); + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: newConversation, + }); + } catch (_error) { + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: { id: 'temp', messages: [] }, + }); + } + } + + private handleWebviewReady(): void { + if (this.currentModeId) { + this.sendMessageToWebView({ + type: 'modeChanged', + data: { modeId: this.currentModeId }, + }); + } + + if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { + this.sendMessageToWebView({ + type: 'availableModels', + data: { models: this.cachedAvailableModels }, + }); + } + + if ( + this.cachedAvailableCommands && + this.cachedAvailableCommands.length > 0 + ) { + this.sendMessageToWebView({ + type: 'availableCommands', + data: { commands: this.cachedAvailableCommands }, + }); + } + + if (typeof this.authState === 'boolean') { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: this.authState }, + }); + return; + } + + if (this.agentInitialized) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: Boolean(this.agentManager.currentSessionId) }, + }); + } + } + + private updateAuthStateFromMessage(message: unknown): void { + if (!message || typeof message !== 'object') { + return; + } + + const typedMessage = message as { + type?: string; + data?: { authenticated?: boolean | null }; + }; + + switch (typedMessage.type) { + case 'authState': + if (typeof typedMessage.data?.authenticated === 'boolean') { + this.authState = typedMessage.data.authenticated; + } else { + this.authState = null; + } + break; + case 'agentConnected': + case 'loginSuccess': + this.authState = true; + break; + case 'agentConnectionError': + case 'loginError': + this.authState = false; + break; + default: + break; + } + } + + private isAutoMode(): boolean { + return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo'; + } + + private sendMessageToWebView(message: unknown): void { + this.updateAuthStateFromMessage(message); + void this.view?.webview.postMessage(message); + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/WebViewContent.ts index 8f802c84fe..e507ed26c8 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewContent.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewContent.ts @@ -12,22 +12,29 @@ import { escapeHtml } from './utils/webviewUtils.js'; * Responsible for generating the HTML content of the WebView */ export class WebViewContent { + private static getWebview( + host: vscode.Webview | vscode.WebviewPanel | vscode.WebviewView, + ): vscode.Webview { + return 'webview' in host ? host.webview : host; + } + /** * Generate HTML content for the WebView - * @param panel WebView Panel + * @param host WebView host * @param extensionUri Extension URI * @returns HTML string */ static generate( - panel: vscode.WebviewPanel, + host: vscode.Webview | vscode.WebviewPanel | vscode.WebviewView, extensionUri: vscode.Uri, ): string { - const scriptUri = panel.webview.asWebviewUri( + const webview = this.getWebview(host); + 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 +45,7 @@ export class WebViewContent { - + Qwen Code From 332d3316ab12dc82946875a9e2fc3489cfc9a093 Mon Sep 17 00:00:00 2001 From: ZZBuAoYe Date: Sun, 8 Mar 2026 16:45:45 +0800 Subject: [PATCH 2/3] fix(vscode): create a session for new chat tabs --- .../src/commands/index.test.ts | 65 +++++++++++++++++++ .../src/commands/index.ts | 2 +- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-ide-companion/src/commands/index.test.ts diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts new file mode 100644 index 0000000000..853d932003 --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { openNewChatTabCommand, registerNewCommands } from './index.js'; + +vi.mock('vscode', () => ({ + commands: { + registerCommand: vi.fn( + (_command: string, _handler: (...args: unknown[]) => unknown) => ({ + dispose: vi.fn(), + }), + ), + }, + workspace: { + workspaceFolders: [], + }, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + Uri: { + joinPath: vi.fn(), + }, +})); + +describe('registerNewCommands', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a fresh session when opening a new chat tab', async () => { + const fakeProvider = { + show: vi.fn().mockResolvedValue(undefined), + createNewSession: vi.fn().mockResolvedValue(undefined), + forceReLogin: vi.fn().mockResolvedValue(undefined), + }; + + registerNewCommands( + { subscriptions: [] } as unknown as vscode.ExtensionContext, + vi.fn(), + {} as never, + () => [], + () => fakeProvider as never, + ); + + const commandCall = vi + .mocked(vscode.commands.registerCommand) + .mock.calls.find(([command]) => command === openNewChatTabCommand); + + expect(commandCall).toBeDefined(); + + const handler = commandCall?.[1] as (() => Promise) | undefined; + expect(handler).toBeDefined(); + + await handler?.(); + + expect(fakeProvider.show).toHaveBeenCalledTimes(1); + expect(fakeProvider.createNewSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 5f487c6fb2..87ce76884a 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -65,8 +65,8 @@ export function registerNewCommands( disposables.push( vscode.commands.registerCommand(openNewChatTabCommand, async () => { const provider = createWebViewProvider(); - // Session restoration is now disabled by default, so no need to suppress it await provider.show(); + await provider.createNewSession(); }), ); From a356c1b0e931e3417c9ff6a0f3ea5a78146fd3d4 Mon Sep 17 00:00:00 2001 From: ZZBuAoYe Date: Sun, 8 Mar 2026 17:02:17 +0800 Subject: [PATCH 3/3] fix(vscode): support ACP items session lists --- .../src/services/qwenAgentManager.test.ts | 81 +++++++++++++++++++ .../src/services/qwenAgentManager.ts | 41 ++++++---- 2 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts new file mode 100644 index 0000000000..1adf5866b2 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + extractSessionListItems, + QwenAgentManager, +} from './qwenAgentManager.js'; + +vi.mock('vscode', () => ({ + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, +})); + +describe('extractSessionListItems', () => { + it('reads ACP session arrays from the sessions field', () => { + const items = extractSessionListItems({ + sessions: [{ sessionId: 'session-1' }], + }); + + expect(items).toEqual([{ sessionId: 'session-1' }]); + }); + + it('reads ACP session arrays from the legacy items field', () => { + const items = extractSessionListItems({ + items: [{ sessionId: 'session-2' }], + }); + + expect(items).toEqual([{ sessionId: 'session-2' }]); + }); +}); + +describe('QwenAgentManager session list compatibility', () => { + it('maps paged ACP session lists returned via items', async () => { + const manager = new QwenAgentManager(); + const listSessions = vi.fn().mockResolvedValue({ + items: [ + { + sessionId: 'session-3', + prompt: 'Fix sidebar history', + mtime: 1772114825468.5825, + cwd: 'e:\\Qwen\\qwen-code', + }, + ], + }); + + ( + manager as unknown as { + connection: { listSessions: typeof listSessions }; + } + ).connection = { listSessions }; + + const page = await manager.getSessionListPaged({ size: 20 }); + + expect(listSessions).toHaveBeenCalledWith({ size: 20 }); + expect(page).toEqual({ + sessions: [ + { + id: 'session-3', + sessionId: 'session-3', + title: 'Fix sidebar history', + name: 'Fix sidebar history', + startTime: undefined, + lastUpdated: 1772114825468.5825, + messageCount: 0, + projectHash: undefined, + filePath: undefined, + cwd: 'e:\\Qwen\\qwen-code', + }, + ], + nextCursor: undefined, + hasMore: false, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 38113dd086..af4316a2a1 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -37,6 +37,29 @@ import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; +export function extractSessionListItems( + response: unknown, +): Array> { + if (!response || typeof response !== 'object') { + return []; + } + + const payload = response as { + sessions?: unknown; + items?: unknown; + }; + + if (Array.isArray(payload.sessions)) { + return payload.sessions as Array>; + } + + if (Array.isArray(payload.items)) { + return payload.items as Array>; + } + + return []; +} + /** * Qwen Agent Manager * @@ -400,14 +423,7 @@ export class QwenAgentManager { console.log('[QwenAgentManager] ACP session list response:', response); const res: unknown = response; - let items: Array> = []; - - if (res && typeof res === 'object' && 'sessions' in res) { - const sessionsValue = (res as { sessions?: unknown }).sessions; - items = Array.isArray(sessionsValue) - ? (sessionsValue as Array>) - : []; - } + const items = extractSessionListItems(res); console.log( '[QwenAgentManager] Sessions retrieved via ACP:', @@ -501,14 +517,7 @@ export class QwenAgentManager { ...(cursor !== undefined ? { cursor } : {}), }); const res: unknown = response; - let items: Array> = []; - - if (res && typeof res === 'object' && 'sessions' in res) { - const sessionsValue = (res as { sessions?: unknown }).sessions; - items = Array.isArray(sessionsValue) - ? (sessionsValue as Array>) - : []; - } + const items = extractSessionListItems(res); const mapped = items.map((item) => ({ id: item.sessionId || item.id,