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 @@
+
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/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();
}),
);
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/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,
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