mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
wip(vscode-ide-companion): 实现 quick win 功能
- 将 WebView 调整到编辑器右侧 - 添加 ChatHeader 组件,实现会话下拉菜单 - 替换模态框为紧凑型下拉菜单 - 更新会话切换逻辑,显示当前标题 - 清理旧的会话选择器样式 基于 Claude Code v2.0.43 UI 分析实现。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
} from './agents/QwenAgentManager.js';
|
||||
import { ConversationStore } from './storage/ConversationStore.js';
|
||||
import type { AcpPermissionRequest } from './shared/acpTypes.js';
|
||||
import { AuthStateManager } from './auth/AuthStateManager.js';
|
||||
import { CliDetector } from './utils/CliDetector.js';
|
||||
import { AuthStateManager } from './auth/AuthStateManager.js';
|
||||
|
||||
export class WebViewProvider {
|
||||
private panel: vscode.WebviewPanel | null = null;
|
||||
// Track the Webview tab (avoid pin/lock; use for reveal/visibility bookkeeping)
|
||||
private panelTab: vscode.Tab | null = null;
|
||||
private agentManager: QwenAgentManager;
|
||||
private conversationStore: ConversationStore;
|
||||
private authStateManager: AuthStateManager;
|
||||
@@ -25,7 +27,7 @@ export class WebViewProvider {
|
||||
private currentStreamContent = ''; // Track streaming content for saving
|
||||
|
||||
constructor(
|
||||
private context: vscode.ExtensionContext,
|
||||
context: vscode.ExtensionContext,
|
||||
private extensionUri: vscode.Uri,
|
||||
) {
|
||||
this.agentManager = new QwenAgentManager();
|
||||
@@ -41,6 +43,17 @@ export class WebViewProvider {
|
||||
});
|
||||
});
|
||||
|
||||
// Setup thought chunk handler
|
||||
this.agentManager.onThoughtChunk((chunk: string) => {
|
||||
this.currentStreamContent += chunk;
|
||||
this.sendMessageToWebView({
|
||||
type: 'thoughtChunk',
|
||||
data: { chunk },
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
|
||||
// and sent via onStreamChunk callback
|
||||
this.agentManager.onToolCall((update) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'toolCall',
|
||||
@@ -51,6 +64,14 @@ export class WebViewProvider {
|
||||
});
|
||||
});
|
||||
|
||||
// Setup plan handler
|
||||
this.agentManager.onPlan((entries) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'plan',
|
||||
data: { entries },
|
||||
});
|
||||
});
|
||||
|
||||
this.agentManager.onPermissionRequest(
|
||||
async (request: AcpPermissionRequest) => {
|
||||
// Send permission request to WebView
|
||||
@@ -78,22 +99,62 @@ export class WebViewProvider {
|
||||
}
|
||||
|
||||
async show(): Promise<void> {
|
||||
// Track if we're creating a new panel in a new column
|
||||
let startedInNewColumn = false;
|
||||
|
||||
if (this.panel) {
|
||||
this.panel.reveal();
|
||||
// Reveal the existing panel via Tab API (Claude-style), fallback to panel.reveal
|
||||
this.revealPanelTab(true);
|
||||
this.capturePanelTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we're creating a new panel
|
||||
startedInNewColumn = true;
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code Chat',
|
||||
vscode.ViewColumn.One,
|
||||
{
|
||||
viewColumn: vscode.ViewColumn.Beside, // Open on right side of active editor
|
||||
preserveFocus: true, // Don't steal focus from editor
|
||||
},
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'dist')],
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Capture the Tab that corresponds to our WebviewPanel (Claude-style)
|
||||
this.capturePanelTab();
|
||||
|
||||
// Auto-lock editor group when opened in new column (Claude Code style)
|
||||
if (startedInNewColumn) {
|
||||
console.log(
|
||||
'[WebViewProvider] Auto-locking editor group for Qwen Code chat',
|
||||
);
|
||||
try {
|
||||
// Reveal panel without preserving focus to make it the active group
|
||||
// This ensures the lock command targets the correct editor group
|
||||
this.revealPanelTab(false);
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.lockEditorGroup',
|
||||
);
|
||||
console.log('[WebViewProvider] Editor group locked successfully');
|
||||
} catch (error) {
|
||||
console.warn('[WebViewProvider] Failed to lock editor group:', error);
|
||||
// Non-fatal error, continue anyway
|
||||
}
|
||||
} else {
|
||||
// For existing panel, reveal with preserving focus
|
||||
this.revealPanelTab(true);
|
||||
}
|
||||
|
||||
// Set panel icon to Qwen logo
|
||||
this.panel.iconPath = vscode.Uri.joinPath(
|
||||
this.extensionUri,
|
||||
@@ -112,6 +173,17 @@ export class WebViewProvider {
|
||||
this.disposables,
|
||||
);
|
||||
|
||||
// Listen for view state changes (no pin/lock; just keep tab reference fresh)
|
||||
this.panel.onDidChangeViewState(
|
||||
() => {
|
||||
if (this.panel && this.panel.visible) {
|
||||
this.capturePanelTab();
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
|
||||
this.panel.onDidDispose(
|
||||
() => {
|
||||
this.panel = null;
|
||||
@@ -229,45 +301,28 @@ export class WebViewProvider {
|
||||
|
||||
private async loadCurrentSessionMessages(): Promise<void> {
|
||||
try {
|
||||
// Get the current active session ID
|
||||
const currentSessionId = this.agentManager.currentSessionId;
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('[WebViewProvider] No active session, initializing empty');
|
||||
await this.initializeEmptyConversation();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[WebViewProvider] Loading messages from current session:',
|
||||
currentSessionId,
|
||||
'[WebViewProvider] Initializing with empty conversation and creating ACP session',
|
||||
);
|
||||
const messages =
|
||||
await this.agentManager.getSessionMessages(currentSessionId);
|
||||
|
||||
// Set current conversation ID to the session ID
|
||||
this.currentConversationId = currentSessionId;
|
||||
// Create a new ACP session so user can send messages immediately
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
if (messages.length > 0) {
|
||||
console.log(
|
||||
'[WebViewProvider] Loaded',
|
||||
messages.length,
|
||||
'messages from current Qwen session',
|
||||
try {
|
||||
await this.agentManager.createNewSession(workingDir);
|
||||
console.log('[WebViewProvider] ACP session created successfully');
|
||||
} catch (sessionError) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to create ACP session:',
|
||||
sessionError,
|
||||
);
|
||||
this.sendMessageToWebView({
|
||||
type: 'conversationLoaded',
|
||||
data: { id: currentSessionId, messages },
|
||||
});
|
||||
} else {
|
||||
// Session exists but has no messages - show empty conversation
|
||||
console.log(
|
||||
'[WebViewProvider] Current session has no messages, showing empty conversation',
|
||||
vscode.window.showWarningMessage(
|
||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||
);
|
||||
this.sendMessageToWebView({
|
||||
type: 'conversationLoaded',
|
||||
data: { id: currentSessionId, messages: [] },
|
||||
});
|
||||
}
|
||||
|
||||
await this.initializeEmptyConversation();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to load session messages:',
|
||||
@@ -472,6 +527,10 @@ export class WebViewProvider {
|
||||
await this.checkCliInstallation();
|
||||
break;
|
||||
|
||||
case 'cancelPrompt':
|
||||
await this.handleCancelPrompt();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[WebViewProvider] Unknown message type:', message.type);
|
||||
break;
|
||||
@@ -481,8 +540,31 @@ export class WebViewProvider {
|
||||
private async handleSendMessage(text: string): Promise<void> {
|
||||
console.log('[WebViewProvider] handleSendMessage called with:', text);
|
||||
|
||||
// Ensure we have an active conversation - create one if needed
|
||||
if (!this.currentConversationId) {
|
||||
const errorMsg = 'No active conversation. Please restart the extension.';
|
||||
console.log('[WebViewProvider] No active conversation, creating one...');
|
||||
try {
|
||||
await this.initializeEmptyConversation();
|
||||
console.log(
|
||||
'[WebViewProvider] Created conversation:',
|
||||
this.currentConversationId,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to create conversation: ${error}`;
|
||||
console.error('[WebViewProvider]', errorMsg);
|
||||
vscode.window.showErrorMessage(errorMsg);
|
||||
this.sendMessageToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Double check after creation attempt
|
||||
if (!this.currentConversationId) {
|
||||
const errorMsg =
|
||||
'Failed to create conversation. Please restart the extension.';
|
||||
console.error('[WebViewProvider]', errorMsg);
|
||||
vscode.window.showErrorMessage(errorMsg);
|
||||
this.sendMessageToWebView({
|
||||
@@ -656,6 +738,18 @@ export class WebViewProvider {
|
||||
messages.length,
|
||||
);
|
||||
|
||||
// Get session details for the header
|
||||
let sessionDetails = null;
|
||||
try {
|
||||
const allSessions = await this.agentManager.getSessionList();
|
||||
sessionDetails = allSessions.find(
|
||||
(s: { id?: string; sessionId?: string }) =>
|
||||
s.id === sessionId || s.sessionId === sessionId,
|
||||
);
|
||||
} catch (err) {
|
||||
console.log('[WebViewProvider] Could not get session details:', err);
|
||||
}
|
||||
|
||||
// Try to switch session in ACP (may fail if not supported)
|
||||
try {
|
||||
await this.agentManager.switchToSession(sessionId);
|
||||
@@ -681,10 +775,10 @@ export class WebViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Send messages to WebView
|
||||
// Send messages and session details to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages },
|
||||
data: { sessionId, messages, session: sessionDetails },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Failed to switch session:', error);
|
||||
@@ -696,6 +790,36 @@ export class WebViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel prompt request from WebView
|
||||
* Cancels the current AI response generation
|
||||
*/
|
||||
private async handleCancelPrompt(): Promise<void> {
|
||||
try {
|
||||
console.log('[WebViewProvider] Cancel prompt requested');
|
||||
|
||||
if (!this.agentManager.isConnected) {
|
||||
console.warn('[WebViewProvider] Agent not connected, cannot cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentManager.cancelCurrentPrompt();
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'promptCancelled',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
|
||||
console.log('[WebViewProvider] Prompt cancelled successfully');
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Failed to cancel prompt:', error);
|
||||
this.sendMessageToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to cancel: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessageToWebView(message: unknown): void {
|
||||
this.panel?.webview.postMessage(message);
|
||||
}
|
||||
@@ -705,16 +829,21 @@ export class WebViewProvider {
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview.js'),
|
||||
);
|
||||
|
||||
const iconUri = this.panel!.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets', 'icon.png'),
|
||||
);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src ${this.panel!.webview.cspSource}; style-src ${this.panel!.webview.cspSource} 'unsafe-inline';">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${this.panel!.webview.cspSource}; script-src ${this.panel!.webview.cspSource}; style-src ${this.panel!.webview.cspSource} 'unsafe-inline';">
|
||||
<title>Qwen Code Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>window.ICON_URI = "${iconUri}";</script>
|
||||
<script src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -731,6 +860,130 @@ export class WebViewProvider {
|
||||
this.agentManager.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the VS Code Tab that corresponds to our WebviewPanel.
|
||||
* We do not pin or lock the editor group, mirroring Claude's approach.
|
||||
* Instead, we:
|
||||
* - open beside the active editor
|
||||
* - preserve focus to keep typing in the current file
|
||||
* - keep a Tab reference for reveal/visibility bookkeeping if needed
|
||||
*/
|
||||
private capturePanelTab(): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer slightly so the tab model is updated after create/reveal
|
||||
setTimeout(() => {
|
||||
const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs);
|
||||
const match = allTabs.find((t) => {
|
||||
// Type guard for webview tab input
|
||||
const input: unknown = (t as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
const isWebview = isWebviewInput(input);
|
||||
const sameViewType = isWebview && input.viewType === 'qwenCode.chat';
|
||||
const sameLabel = t.label === this.panel!.title;
|
||||
return !!(sameViewType || sameLabel);
|
||||
});
|
||||
this.panelTab = match ?? null;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the WebView panel (optionally preserving focus)
|
||||
* We track the tab for bookkeeping, but use panel.reveal for actual reveal
|
||||
*/
|
||||
private revealPanelTab(preserveFocus: boolean = true): void {
|
||||
if (this.panel) {
|
||||
this.panel.reveal(vscode.ViewColumn.Beside, preserveFocus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an existing WebView panel (called during VSCode restart)
|
||||
* This sets up the panel with all event listeners
|
||||
*/
|
||||
restorePanel(panel: vscode.WebviewPanel): void {
|
||||
console.log('[WebViewProvider] Restoring WebView panel');
|
||||
this.panel = panel;
|
||||
|
||||
// Set panel icon to Qwen logo
|
||||
this.panel.iconPath = vscode.Uri.joinPath(
|
||||
this.extensionUri,
|
||||
'assets',
|
||||
'icon.png',
|
||||
);
|
||||
|
||||
// Set webview HTML
|
||||
this.panel.webview.html = this.getWebviewContent();
|
||||
|
||||
// Handle messages from WebView
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
async (message) => {
|
||||
await this.handleWebViewMessage(message);
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
|
||||
// Listen for view state changes (track the tab only)
|
||||
this.panel.onDidChangeViewState(
|
||||
() => {
|
||||
if (this.panel && this.panel.visible) {
|
||||
this.capturePanelTab();
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
|
||||
this.panel.onDidDispose(
|
||||
() => {
|
||||
this.panel = null;
|
||||
this.disposables.forEach((d) => d.dispose());
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
|
||||
// Track the tab reference on restore
|
||||
this.capturePanelTab();
|
||||
|
||||
console.log('[WebViewProvider] Panel restored successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state for serialization
|
||||
* This is used when VSCode restarts to restore the WebView
|
||||
*/
|
||||
getState(): {
|
||||
conversationId: string | null;
|
||||
agentInitialized: boolean;
|
||||
} {
|
||||
return {
|
||||
conversationId: this.currentConversationId,
|
||||
agentInitialized: this.agentInitialized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state after VSCode restart
|
||||
*/
|
||||
restoreState(state: {
|
||||
conversationId: string | null;
|
||||
agentInitialized: boolean;
|
||||
}): void {
|
||||
console.log('[WebViewProvider] Restoring state:', state);
|
||||
this.currentConversationId = state.conversationId;
|
||||
this.agentInitialized = state.agentInitialized;
|
||||
|
||||
// Reload content after restore
|
||||
if (this.panel) {
|
||||
this.panel.webview.html = this.getWebviewContent();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.panel?.dispose();
|
||||
this.agentManager.disconnect();
|
||||
|
||||
Reference in New Issue
Block a user