diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 044d80339..d35dae11d 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -30,7 +30,6 @@ "activationEvents": [ "onStartupFinished", "onView:qwen-code.chatView.sidebar", - "onView:qwen-code.chatView.panel", "onView:qwen-code.chatView.secondary", "onCommand:qwen-code.openChat", "onCommand:qwen-code.focusChat", @@ -49,21 +48,16 @@ { "id": "qwen-code-sidebar", "title": "Qwen Code", - "icon": "assets/sidebar-icon.svg" - } - ], - "panel": [ - { - "id": "qwen-code-panel", - "title": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "qwen-code:doesNotSupportSecondarySidebar" } ], "secondarySidebar": [ { "id": "qwen-code-secondary", "title": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "!qwen-code:doesNotSupportSecondarySidebar" } ] }, @@ -72,21 +66,18 @@ { "type": "webview", "id": "qwen-code.chatView.sidebar", - "name": "Qwen Code" - } - ], - "qwen-code-panel": [ - { - "id": "qwen-code.chatView.panel", "name": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "qwen-code:doesNotSupportSecondarySidebar" } ], "qwen-code-secondary": [ { + "type": "webview", "id": "qwen-code.chatView.secondary", "name": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "!qwen-code:doesNotSupportSecondarySidebar" } ] }, @@ -125,7 +116,7 @@ }, { "command": "qwen-code.focusChat", - "title": "Qwen Code: Focus Chat Input" + "title": "Qwen Code: Focus Chat View" }, { "command": "qwen-code.newConversation", diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts index 5d69c4edd..e05f14272 100644 --- a/packages/vscode-ide-companion/src/commands/index.test.ts +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -5,62 +5,120 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as vscode from 'vscode'; -import { openNewChatTabCommand, registerNewCommands } from './index.js'; +import { + focusChatCommand, + openNewChatTabCommand, + registerNewCommands, +} from './index.js'; + +const { + registerCommand, + executeCommand, + showWarningMessage, + showInformationMessage, +} = vi.hoisted(() => ({ + registerCommand: vi.fn( + (_id: string, handler: (...args: unknown[]) => unknown) => ({ + dispose: vi.fn(), + handler, + }), + ), + executeCommand: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), +})); vi.mock('vscode', () => ({ commands: { - registerCommand: vi.fn( - (_command: string, _handler: (...args: unknown[]) => unknown) => ({ - dispose: vi.fn(), - }), - ), + registerCommand, + executeCommand, + }, + window: { + showWarningMessage, + showInformationMessage, }, workspace: { workspaceFolders: [], }, - window: { - showErrorMessage: vi.fn(), - showInformationMessage: vi.fn(), - showWarningMessage: vi.fn(), - }, Uri: { joinPath: vi.fn(), }, })); +function getRegisteredHandler(commandId: string) { + const call = registerCommand.mock.calls.find(([id]) => id === commandId); + if (!call) { + throw new Error(`Command ${commandId} was not registered`); + } + return call[1] as (...args: unknown[]) => Promise; +} + describe('registerNewCommands', () => { + const context = { subscriptions: [] as Array<{ dispose: () => void }> }; + const diffManager = { showDiff: vi.fn() }; + const log = vi.fn(); + beforeEach(() => { - vi.clearAllMocks(); + context.subscriptions = []; + registerCommand.mockClear(); + executeCommand.mockClear(); + showWarningMessage.mockClear(); + showInformationMessage.mockClear(); }); - it('creates a fresh session when opening a new chat tab', async () => { - const fakeProvider = { + it('openNewChatTab opens a new provider without creating a second session explicitly', async () => { + const provider = { 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, + context as never, + log, + diffManager as never, () => [], - () => fakeProvider as never, + () => provider as never, ); - const commandCall = vi - .mocked(vscode.commands.registerCommand) - .mock.calls.find(([command]) => command === openNewChatTabCommand); + await getRegisteredHandler(openNewChatTabCommand)(); - expect(commandCall).toBeDefined(); + expect(provider.show).toHaveBeenCalledTimes(1); + expect(provider.createNewSession).not.toHaveBeenCalled(); + }); - const handler = commandCall?.[1] as (() => Promise) | undefined; - expect(handler).toBeDefined(); + it('focusChat focuses the secondary sidebar when it is supported', async () => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + undefined, + true, + ); - await handler?.(); + await getRegisteredHandler(focusChatCommand)(); - expect(fakeProvider.show).toHaveBeenCalledTimes(1); - expect(fakeProvider.createNewSession).toHaveBeenCalledTimes(1); + expect(executeCommand).toHaveBeenCalledWith( + 'qwen-code.chatView.secondary.focus', + ); + }); + + it('focusChat falls back to the primary sidebar when secondary sidebar is unavailable', async () => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + undefined, + false, + ); + + await getRegisteredHandler(focusChatCommand)(); + + expect(executeCommand).toHaveBeenCalledWith( + 'qwen-code.chatView.sidebar.focus', + ); }); }); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 5bcf2565a..c50724090 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -7,7 +7,10 @@ import * as vscode from 'vscode'; import type { DiffManager } from '../diff-manager.js'; import type { WebViewProvider } from '../webview/providers/WebViewProvider.js'; -import { CHAT_VIEW_ID_SIDEBAR } from '../constants/viewIds.js'; +import { + CHAT_VIEW_ID_SIDEBAR, + CHAT_VIEW_ID_SECONDARY, +} from '../constants/viewIds.js'; type Logger = (message: string) => void; @@ -23,9 +26,8 @@ export const showLogsCommand = 'qwen-code.showLogs'; /** * Register all Qwen Code chat-related commands. * - * All chat positions (editor tab, sidebar, panel, secondary sidebar) are - * available simultaneously. `openChat` and `newConversation` always open an - * editor tab, while `focusChat` focuses the primary sidebar view. + * `openChat` and `newConversation` always open an editor tab, while + * `focusChat` focuses the secondary sidebar (preferred) or primary sidebar. * * @param context - VS Code extension context for subscription management * @param log - Logger function for debug output @@ -33,6 +35,7 @@ export const showLogsCommand = 'qwen-code.showLogs'; * @param getWebViewProviders - Returns all active editor-tab WebView providers * @param createWebViewProvider - Factory to create a new editor-tab WebView provider * @param outputChannel - Optional output channel for the showLogs command + * @param supportsSecondarySidebar - Whether the running VS Code supports secondary sidebar */ export function registerNewCommands( context: vscode.ExtensionContext, @@ -41,6 +44,7 @@ export function registerNewCommands( getWebViewProviders: () => WebViewProvider[], createWebViewProvider: () => WebViewProvider, outputChannel?: vscode.OutputChannel, + supportsSecondarySidebar = true, ): void { const disposables: vscode.Disposable[] = []; @@ -87,7 +91,6 @@ export function registerNewCommands( vscode.commands.registerCommand(openNewChatTabCommand, async () => { const provider = createWebViewProvider(); await provider.show(); - await provider.createNewSession(); }), ); @@ -104,10 +107,15 @@ export function registerNewCommands( }), ); - // Focus Chat: bring the primary sidebar chat view to front + // Focus Chat: bring the active chat view to front. + // Use secondary sidebar when supported; fall back to primary sidebar. disposables.push( vscode.commands.registerCommand(focusChatCommand, async () => { - await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SIDEBAR}.focus`); + if (supportsSecondarySidebar) { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SECONDARY}.focus`); + } else { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SIDEBAR}.focus`); + } }), ); diff --git a/packages/vscode-ide-companion/src/constants/viewIds.ts b/packages/vscode-ide-companion/src/constants/viewIds.ts index 140956b26..b54c6eaa1 100644 --- a/packages/vscode-ide-companion/src/constants/viewIds.ts +++ b/packages/vscode-ide-companion/src/constants/viewIds.ts @@ -5,11 +5,13 @@ */ /** - * WebviewView IDs for the three host positions where the chat UI can appear. + * WebviewView IDs for the chat UI host positions. * These IDs must match the `views` contributions declared in package.json. * - * Note: We use kebab-case prefix 'qwen-code.' for consistency with command IDs. + * Only one of sidebar / secondary is visible at runtime — controlled by the + * `qwen-code:doesNotSupportSecondarySidebar` context key in package.json. + * The secondary sidebar is preferred; the primary sidebar is a fallback for + * VS Code versions that lack secondary sidebar support. */ export const CHAT_VIEW_ID_SIDEBAR = 'qwen-code.chatView.sidebar'; -export const CHAT_VIEW_ID_PANEL = 'qwen-code.chatView.panel'; export const CHAT_VIEW_ID_SECONDARY = 'qwen-code.chatView.secondary'; diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index ea179d948..72c3d476e 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -23,6 +23,7 @@ vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', async () => { }); vi.mock('vscode', () => ({ + version: '1.94.0', window: { createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), @@ -137,20 +138,19 @@ describe('activate', () => { expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled(); }); - it('should register webview view providers for all three positions (sidebar, panel, secondary)', async () => { + it('should register webview view providers for sidebar and secondary positions', async () => { await activate(context); - // Verify registerWebviewViewProvider was called 3 times for the three view positions + // Verify registerWebviewViewProvider was called 2 times (sidebar + secondary) const registerCalls = vi.mocked(vscode.window.registerWebviewViewProvider) .mock.calls; - expect(registerCalls).toHaveLength(3); + expect(registerCalls).toHaveLength(2); // Extract view IDs from the calls const viewIds = registerCalls.map((call) => call[0]); - // Verify all three view IDs are registered with consistent naming + // Only sidebar and secondary are registered; panel view was removed expect(viewIds).toContain('qwen-code.chatView.sidebar'); - expect(viewIds).toContain('qwen-code.chatView.panel'); expect(viewIds).toContain('qwen-code.chatView.secondary'); }); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 55c3f5299..54b494024 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,12 +15,8 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './webview/providers/WebViewProvider.js'; -import { ChatWebviewViewProvider } from './webview/providers/ChatWebviewViewProvider.js'; -import { - CHAT_VIEW_ID_PANEL, - CHAT_VIEW_ID_SECONDARY, - CHAT_VIEW_ID_SIDEBAR, -} from './constants/viewIds.js'; +import { ChatProviderRegistry } from './webview/providers/ChatProviderRegistry.js'; +import { registerChatViewProviders } from './webview/providers/chatViewRegistration.js'; import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; @@ -41,7 +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 chatProviderRegistry: ChatProviderRegistry | null = null; let log: (message: string) => void = () => {}; @@ -131,17 +127,25 @@ export async function activate(context: vscode.ExtensionContext) { ); log('Readonly file system provider registered'); + chatProviderRegistry = new ChatProviderRegistry( + () => new WebViewProvider(context, context.extensionUri), + ); + 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()), - // Suppress diffs when active mode is auto or yolo in any chat tab + // Delay when any chat surface has a pending permission drawer + () => + chatProviderRegistry + ?.getPermissionAwareProviders() + .some((p) => p.hasPendingPermission()) ?? false, + // Suppress diffs when active mode is auto or yolo in any chat surface () => { - const providers = webViewProviders.filter( - (p) => typeof p.shouldSuppressDiff === 'function', - ); + const providers = + chatProviderRegistry + ?.getPermissionAwareProviders() + .filter((p) => typeof p.shouldSuppressDiff === 'function') ?? []; if (providers.length === 0) { return false; } @@ -150,30 +154,16 @@ export async function activate(context: vscode.ExtensionContext) { ); // Helper function to create a new WebView provider instance - const createWebViewProvider = (): WebViewProvider => { - const provider = new WebViewProvider(context, context.extensionUri); - webViewProviders.push(provider); - return provider; - }; + const createWebViewProvider = (): WebViewProvider => + chatProviderRegistry!.createEditorProvider(); - // Register WebviewView hosts for all positions (sidebar, panel, secondary). - // Providers are lazily instantiated — the factory is only called when VS Code - // actually opens the view, keeping startup lightweight. - const chatViewIds = [ - CHAT_VIEW_ID_SIDEBAR, - CHAT_VIEW_ID_PANEL, - CHAT_VIEW_ID_SECONDARY, - ] as const; + const createViewProvider = (): WebViewProvider => + chatProviderRegistry!.createViewProvider(); - for (const viewId of chatViewIds) { - context.subscriptions.push( - vscode.window.registerWebviewViewProvider( - viewId, - new ChatWebviewViewProvider(createWebViewProvider), - { webviewOptions: { retainContextWhenHidden: true } }, - ), - ); - } + const supportsSecondarySidebar = registerChatViewProviders({ + context, + createViewProvider, + }); // Register WebView panel serializer for persistence across reloads context.subscriptions.push( @@ -217,9 +207,10 @@ export async function activate(context: vscode.ExtensionContext) { context, log, diffManager, - () => webViewProviders, + () => chatProviderRegistry?.getEditorProviders() ?? [], createWebViewProvider, logger, + supportsSecondarySidebar, ); context.subscriptions.push( @@ -237,9 +228,10 @@ export async function activate(context: vscode.ExtensionContext) { if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.acceptDiff(docUri); } - // If WebView is requesting permission, actively select an allow option (prefer once) + // If any chat surface is requesting permission, actively select allow (prefer once) try { - for (const provider of webViewProviders) { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('allow'); } @@ -254,9 +246,10 @@ export async function activate(context: vscode.ExtensionContext) { if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.cancelDiff(docUri); } - // If WebView is requesting permission, actively select reject/cancel + // If any chat surface is requesting permission, actively select reject/cancel try { - for (const provider of webViewProviders) { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('cancel'); } @@ -390,11 +383,8 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } - // Dispose all WebView providers - webViewProviders.forEach((provider) => { - provider.dispose(); - }); - webViewProviders = []; + chatProviderRegistry?.disposeAll(); + chatProviderRegistry = 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/package.test.ts b/packages/vscode-ide-companion/src/package.test.ts new file mode 100644 index 000000000..9d7cdaef5 --- /dev/null +++ b/packages/vscode-ide-companion/src/package.test.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('package.json command metadata', () => { + it('describes focusChat as focusing the chat view', () => { + const manifest = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8'), + ) as { + contributes: { + commands: Array<{ command: string; title: string }>; + }; + }; + + const command = manifest.contributes.commands.find( + (item) => item.command === 'qwen-code.focusChat', + ); + + expect(command?.title).toBe('Qwen Code: Focus Chat View'); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts index 5de2b5c9d..440dc2b18 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -5,10 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { - extractSessionListItems, - QwenAgentManager, -} from './qwenAgentManager.js'; +import { extractSessionListItems } from './qwenAgentManager.js'; vi.mock('vscode', () => ({ window: { @@ -19,80 +16,41 @@ vi.mock('vscode', () => ({ })); describe('extractSessionListItems', () => { - it('reads ACP session arrays from the sessions field', () => { + it('returns sessions array 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', () => { + it('returns items array from the legacy "items" field', () => { const items = extractSessionListItems({ items: [{ sessionId: 'session-2' }], }); - expect(items).toEqual([{ sessionId: 'session-2' }]); }); - it('returns empty array for invalid responses', () => { - expect(extractSessionListItems(null)).toEqual([]); - expect(extractSessionListItems(undefined)).toEqual([]); - expect(extractSessionListItems('string')).toEqual([]); - expect(extractSessionListItems({})).toEqual([]); - expect(extractSessionListItems({ sessions: 'not-array' })).toEqual([]); - }); - - it('prefers sessions over items when both exist', () => { + it('prefers "sessions" over "items" when both are present', () => { const items = extractSessionListItems({ sessions: [{ sessionId: 'from-sessions' }], items: [{ sessionId: 'from-items' }], }); - expect(items).toEqual([{ sessionId: 'from-sessions' }]); }); -}); -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: '/workspace/qwen-code', - }, - ], - }); + it('returns empty array for null/undefined input', () => { + expect(extractSessionListItems(null)).toEqual([]); + expect(extractSessionListItems(undefined)).toEqual([]); + }); - ( - manager as unknown as { - connection: { listSessions: typeof listSessions }; - } - ).connection = { listSessions }; + it('returns empty array for non-object input', () => { + expect(extractSessionListItems('string')).toEqual([]); + expect(extractSessionListItems(42)).toEqual([]); + }); - 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: '/workspace/qwen-code', - }, - ], - nextCursor: undefined, - hasMore: false, - }); + it('returns empty array when neither field is an array', () => { + expect(extractSessionListItems({ sessions: 'not-array' })).toEqual([]); + expect(extractSessionListItems({ items: 123 })).toEqual([]); + expect(extractSessionListItems({})).toEqual([]); }); }); diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts new file mode 100644 index 000000000..3820538c8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ChatProviderRegistry } from './ChatProviderRegistry.js'; + +describe('ChatProviderRegistry', () => { + it('tracks editor and view providers separately while exposing a combined list', () => { + const factory = vi + .fn() + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'editor-1' }) + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'view-1' }) + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'editor-2' }); + + const registry = new ChatProviderRegistry(factory); + + const editor1 = registry.createEditorProvider(); + const view1 = registry.createViewProvider(); + const editor2 = registry.createEditorProvider(); + + expect(factory).toHaveBeenCalledTimes(3); + expect(registry.getEditorProviders()).toEqual([editor1, editor2]); + expect(registry.getPermissionAwareProviders()).toEqual([ + editor1, + editor2, + view1, + ]); + }); + + it('disposes all tracked providers and resets internal collections', () => { + const editorDispose = vi.fn(); + const viewDispose = vi.fn(); + const registry = new ChatProviderRegistry(() => ({ dispose: vi.fn() })); + + registry.createEditorProvider({ dispose: editorDispose }); + registry.createViewProvider({ dispose: viewDispose }); + + registry.disposeAll(); + + expect(editorDispose).toHaveBeenCalledTimes(1); + expect(viewDispose).toHaveBeenCalledTimes(1); + expect(registry.getEditorProviders()).toEqual([]); + expect(registry.getPermissionAwareProviders()).toEqual([]); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts new file mode 100644 index 000000000..94cacf47d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +type DisposableProvider = { + dispose(): void; +}; + +/** + * Tracks chat providers by host type while exposing a combined list for flows + * like permission handling and diff suppression. + */ +export class ChatProviderRegistry { + private editorProviders: T[] = []; + private viewProviders: T[] = []; + + constructor(private readonly createProvider: () => T) {} + + createEditorProvider(provider: T = this.createProvider()): T { + this.editorProviders.push(provider); + return provider; + } + + createViewProvider(provider: T = this.createProvider()): T { + this.viewProviders.push(provider); + return provider; + } + + getEditorProviders(): T[] { + return [...this.editorProviders]; + } + + getPermissionAwareProviders(): T[] { + return [...this.editorProviders, ...this.viewProviders]; + } + + disposeAll(): void { + for (const provider of this.getPermissionAwareProviders()) { + provider.dispose(); + } + this.editorProviders = []; + this.viewProviders = []; + } +} diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts new file mode 100644 index 000000000..a25860eb8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ChatWebviewViewProvider } from './ChatWebviewViewProvider.js'; + +vi.mock('vscode', () => ({})); + +describe('ChatWebviewViewProvider', () => { + it('lazily creates the WebViewProvider on first resolveWebviewView call', async () => { + const mockProvider = { + attachToView: vi.fn().mockResolvedValue(undefined), + }; + const factory = vi.fn(() => mockProvider); + + const viewProvider = new ChatWebviewViewProvider(factory as never); + + const mockWebviewView = { + webview: {}, + viewType: 'qwen-code.chatView.sidebar', + }; + + await viewProvider.resolveWebviewView(mockWebviewView as never); + + expect(factory).toHaveBeenCalledTimes(1); + expect(mockProvider.attachToView).toHaveBeenCalledWith( + mockWebviewView, + 'qwen-code.chatView.sidebar', + ); + }); + + it('reuses the same WebViewProvider on subsequent calls', async () => { + const mockProvider = { + attachToView: vi.fn().mockResolvedValue(undefined), + }; + const factory = vi.fn(() => mockProvider); + + const viewProvider = new ChatWebviewViewProvider(factory as never); + + const mockView1 = { webview: {}, viewType: 'sidebar' }; + const mockView2 = { webview: {}, viewType: 'sidebar' }; + + await viewProvider.resolveWebviewView(mockView1 as never); + await viewProvider.resolveWebviewView(mockView2 as never); + + // Factory should only be called once (lazy creation) + expect(factory).toHaveBeenCalledTimes(1); + // But attachToView should be called for each resolve + expect(mockProvider.attachToView).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index 86cfe2fae..a06fd1a3b 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -6,7 +6,10 @@ import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../../types/webviewMessageTypes.js'; import { MessageRouter } from '../handlers/MessageRouter.js'; /** diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts new file mode 100644 index 000000000..3c2029fe5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { WebViewContent } from './WebViewContent.js'; + +vi.mock('vscode', () => ({ + Uri: { + joinPath: vi.fn((_base: unknown, ...parts: string[]) => ({ + fsPath: `/ext/${parts.join('/')}`, + })), + }, +})); + +/** + * Helper: create a minimal mock vscode.Webview + */ +function createMockWebview() { + return { + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `https://webview/${uri.fsPath}`, + })), + cspSource: 'https://csp.source', + }; +} + +describe('WebViewContent', () => { + const fakeExtensionUri = { fsPath: '/ext' } as never; + + it('generates HTML when given a raw Webview', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(html).toContain('Qwen Code'); + expect(html).toContain(webview.cspSource); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('generates HTML when given a WebviewPanel (has .webview property)', () => { + const webview = createMockWebview(); + const panel = { webview }; + + const html = WebViewContent.generate(panel as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('generates HTML when given a WebviewView (has .webview property)', () => { + const webview = createMockWebview(); + const view = { webview, viewType: 'sidebar' }; + + const html = WebViewContent.generate(view as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('includes the script tag with the correct URI', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain('