Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/vscode-ide-companion/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions packages/vscode-ide-companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions packages/vscode-ide-companion/src/commands/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) | undefined;
expect(handler).toBeDefined();

await handler?.();

expect(fakeProvider.show).toHaveBeenCalledTimes(1);
expect(fakeProvider.createNewSession).toHaveBeenCalledTimes(1);
});
});
2 changes: 1 addition & 1 deletion packages/vscode-ide-companion/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}),
);

Expand Down
16 changes: 16 additions & 0 deletions packages/vscode-ide-companion/src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ vi.mock('vscode', () => ({
registerWebviewPanelSerializer: vi.fn(() => ({
dispose: vi.fn(),
})),
registerWebviewViewProvider: vi.fn(() => ({
dispose: vi.fn(),
})),
},
workspace: {
workspaceFolders: [],
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 25 additions & 4 deletions packages/vscode-ide-companion/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -36,6 +37,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet<IdeInfo['name']> = 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 = () => {};

Expand Down Expand Up @@ -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) {
Expand All @@ -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', {
Expand Down Expand Up @@ -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');
}
Expand All @@ -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');
}
Expand Down Expand Up @@ -369,6 +388,8 @@ export async function deactivate(): Promise<void> {
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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
41 changes: 25 additions & 16 deletions packages/vscode-ide-companion/src/services/qwenAgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';

export type { ChatMessage, PlanEntry, ToolCallUpdateData };

export function extractSessionListItems(
response: unknown,
): Array<Record<string, unknown>> {
if (!response || typeof response !== 'object') {
return [];
}

const payload = response as {
sessions?: unknown;
items?: unknown;
};

if (Array.isArray(payload.sessions)) {
return payload.sessions as Array<Record<string, unknown>>;
}

if (Array.isArray(payload.items)) {
return payload.items as Array<Record<string, unknown>>;
}

return [];
}

/**
* Qwen Agent Manager
*
Expand Down Expand Up @@ -400,14 +423,7 @@ export class QwenAgentManager {
console.log('[QwenAgentManager] ACP session list response:', response);

const res: unknown = response;
let items: Array<Record<string, unknown>> = [];

if (res && typeof res === 'object' && 'sessions' in res) {
const sessionsValue = (res as { sessions?: unknown }).sessions;
items = Array.isArray(sessionsValue)
? (sessionsValue as Array<Record<string, unknown>>)
: [];
}
const items = extractSessionListItems(res);

console.log(
'[QwenAgentManager] Sessions retrieved via ACP:',
Expand Down Expand Up @@ -501,14 +517,7 @@ export class QwenAgentManager {
...(cursor !== undefined ? { cursor } : {}),
});
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];

if (res && typeof res === 'object' && 'sessions' in res) {
const sessionsValue = (res as { sessions?: unknown }).sessions;
items = Array.isArray(sessionsValue)
? (sessionsValue as Array<Record<string, unknown>>)
: [];
}
const items = extractSessionListItems(res);

const mapped = items.map((item) => ({
id: item.sessionId || item.id,
Expand Down
Loading