diff --git a/packages/vscode-ide-companion/src/cli/cliDetector.ts b/packages/vscode-ide-companion/src/cli/cliDetector.ts index 875c2858..c1de868a 100644 --- a/packages/vscode-ide-companion/src/cli/cliDetector.ts +++ b/packages/vscode-ide-companion/src/cli/cliDetector.ts @@ -24,6 +24,125 @@ export class CliDetector { private static lastCheckTime: number = 0; private static readonly CACHE_DURATION_MS = 30000; // 30 seconds + /** + * Lightweight check if the Qwen Code CLI is installed + * This version only checks for CLI existence without getting version info for faster performance + * @param forceRefresh - Force a new check, ignoring cache + * @returns Detection result with installation status and path + */ + static async detectQwenCliLightweight( + forceRefresh = false, + ): Promise { + const now = Date.now(); + + // Return cached result if available and not expired + if ( + !forceRefresh && + this.cachedResult && + now - this.lastCheckTime < this.CACHE_DURATION_MS + ) { + console.log('[CliDetector] Returning cached result'); + return this.cachedResult; + } + + console.log( + '[CliDetector] Starting lightweight CLI detection, current PATH:', + process.env.PATH, + ); + + try { + const isWindows = process.platform === 'win32'; + const whichCommand = isWindows ? 'where' : 'which'; + + // Check if qwen command exists + try { + // Use simpler detection without NVM for speed + const detectionCommand = isWindows + ? `${whichCommand} qwen` + : `${whichCommand} qwen`; + + console.log( + '[CliDetector] Detecting CLI with lightweight command:', + detectionCommand, + ); + + const { stdout } = await execAsync(detectionCommand, { + timeout: 3000, // Reduced timeout for faster detection + shell: isWindows ? undefined : '/bin/bash', + }); + + // The output may contain multiple lines + // We want the first line which should be the actual path + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.trim()); + const cliPath = lines[0]; // Just take the first path + + console.log('[CliDetector] Found CLI at:', cliPath); + + this.cachedResult = { + isInstalled: true, + cliPath, + // Version is not retrieved in lightweight detection + }; + this.lastCheckTime = now; + return this.cachedResult; + } catch (detectionError) { + console.log('[CliDetector] CLI not found, error:', detectionError); + // CLI not found + let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`; + + // Provide specific guidance for permission errors + if (detectionError instanceof Error) { + const errorMessage = detectionError.message; + if ( + errorMessage.includes('EACCES') || + errorMessage.includes('Permission denied') + ) { + error += `\n\nThis may be due to permission issues. Possible solutions: + \n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest + \n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code + \n3. Use nvm for Node.js version management to avoid permission issues + \n4. Check your PATH environment variable includes npm's global bin directory`; + } + } + + this.cachedResult = { + isInstalled: false, + error, + }; + this.lastCheckTime = now; + return this.cachedResult; + } + } catch (error) { + console.log('[CliDetector] General detection error:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + + let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`; + + // Provide specific guidance for permission errors + if ( + errorMessage.includes('EACCES') || + errorMessage.includes('Permission denied') + ) { + userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions: + \n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest + \n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code + \n3. Use nvm for Node.js version management to avoid permission issues + \n4. Check your PATH environment variable includes npm's global bin directory`; + } + + this.cachedResult = { + isInstalled: false, + error: userFriendlyError, + }; + this.lastCheckTime = now; + return this.cachedResult; + } + } + /** * Checks if the Qwen Code CLI is installed * @param forceRefresh - Force a new check, ignoring cache diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index f4c95948..f999c602 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -42,7 +42,9 @@ export class AcpConnection { onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string; }> = () => Promise.resolve({ optionId: 'allow' }); - onEndTurn: () => void = () => {}; + onEndTurn: (reason?: string) => void = (reason?: string | undefined) => { + console.log('[ACP] onEndTurn__________ reason:', reason || 'unknown'); + }; // Called after successful initialize() with the initialize result onInitialized: (init: unknown) => void = () => {}; diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts index db7802ce..e07cedfb 100644 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -110,13 +110,30 @@ export class AcpMessageHandler { // JSON.stringify(message.result).substring(0, 200), message.result, ); - if ( + + console.log( + '[ACP] Response for message.result:', + message.result, message.result && - typeof message.result === 'object' && - 'stopReason' in message.result && - message.result.stopReason === 'end_turn' - ) { - callbacks.onEndTurn(); + typeof message.result === 'object' && + 'stopReason' in message.result, + + !!callbacks.onEndTurn, + ); + + if (message.result && typeof message.result === 'object') { + const stopReasonValue = + (message.result as { stopReason?: unknown }).stopReason ?? + (message.result as { stop_reason?: unknown }).stop_reason; + if (typeof stopReasonValue === 'string') { + callbacks.onEndTurn(stopReasonValue); + } else if ( + 'stopReason' in message.result || + 'stop_reason' in message.result + ) { + // stop_reason present but not a string (e.g., null) -> still emit + callbacks.onEndTurn(); + } } resolve(message.result); } else if ('error' in message) { diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 5ddd5612..3c701331 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -17,11 +17,18 @@ import type { ToolCallUpdateData, QwenAgentCallbacks, } from '../types/chatTypes.js'; -import { QwenConnectionHandler } from '../services/qwenConnectionHandler.js'; +import { + QwenConnectionHandler, + type QwenConnectionResult, +} from '../services/qwenConnectionHandler.js'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { CliContextManager } from '../cli/cliContextManager.js'; import { authMethod } from '../types/acpTypes.js'; -import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js'; +import { + MIN_CLI_VERSION_FOR_SESSION_METHODS, + type CliVersionInfo, +} from '../cli/cliVersionManager.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; @@ -30,6 +37,13 @@ export type { ChatMessage, PlanEntry, ToolCallUpdateData }; * * Coordinates various modules and provides unified interface */ +interface AgentConnectOptions { + autoAuthenticate?: boolean; +} +interface AgentSessionOptions { + autoAuthenticate?: boolean; +} + export class QwenAgentManager { private connection: AcpConnection; private sessionReader: QwenSessionReader; @@ -119,10 +133,10 @@ export class QwenAgentManager { return { optionId: 'allow_once' }; }; - this.connection.onEndTurn = () => { + this.connection.onEndTurn = (reason?: string) => { try { if (this.callbacks.onEndTurn) { - this.callbacks.onEndTurn(); + this.callbacks.onEndTurn(reason); } else if (this.callbacks.onStreamChunk) { // Fallback: send a zero-length chunk then rely on streamEnd elsewhere this.callbacks.onStreamChunk(''); @@ -136,6 +150,36 @@ export class QwenAgentManager { this.connection.onInitialized = (init: unknown) => { try { const obj = (init || {}) as Record; + + // Extract version information from initialize response + const serverVersion = + obj['version'] || obj['serverVersion'] || obj['cliVersion']; + if (serverVersion) { + console.log( + '[QwenAgentManager] Server version from initialize response:', + serverVersion, + ); + + // Update CLI context with version info from server + const cliContextManager = CliContextManager.getInstance(); + + // Create version info directly without async call + const versionInfo: CliVersionInfo = { + version: String(serverVersion), + isSupported: true, // Assume supported for now + features: { + supportsSessionList: true, + supportsSessionLoad: true, + }, + detectionResult: { + isInstalled: true, + version: String(serverVersion), + }, + }; + + cliContextManager.setCurrentVersionInfo(versionInfo); + } + const modes = obj['modes'] as | { currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; @@ -164,13 +208,18 @@ export class QwenAgentManager { * @param workingDir - Working directory * @param cliPath - CLI path (optional, if provided will override the path in configuration) */ - async connect(workingDir: string, _cliPath?: string): Promise { + async connect( + workingDir: string, + _cliPath?: string, + options?: AgentConnectOptions, + ): Promise { this.currentWorkingDir = workingDir; - await this.connectionHandler.connect( + return this.connectionHandler.connect( this.connection, this.sessionReader, workingDir, _cliPath, + options, ); } @@ -1170,7 +1219,11 @@ export class QwenAgentManager { * @param workingDir - Working directory * @returns Newly created session ID */ - async createNewSession(workingDir: string): Promise { + async createNewSession( + workingDir: string, + options?: AgentSessionOptions, + ): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; // Reuse existing session if present if (this.connection.currentSessionId) { return this.connection.currentSessionId; @@ -1188,12 +1241,15 @@ export class QwenAgentManager { try { await this.connection.newSession(workingDir); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const requiresAuth = - msg.includes('Authentication required') || - msg.includes('(code: -32000)'); + const requiresAuth = isAuthenticationRequiredError(err); if (requiresAuth) { + if (!autoAuthenticate) { + console.warn( + '[QwenAgentManager] session/new requires authentication but auto-auth is disabled. Deferring until user logs in.', + ); + throw err; + } console.warn( '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', ); @@ -1310,9 +1366,10 @@ export class QwenAgentManager { /** * Register end-of-turn callback * - * @param callback - Called when ACP stopReason === 'end_turn' + * @param callback - Called when ACP stopReason is reported */ - onEndTurn(callback: () => void): void { + onEndTurn(callback: (reason?: string) => void): void { + console.log('[QwenAgentManager] onEndTurn__________ callback:', callback); this.callbacks.onEndTurn = callback; this.sessionUpdateHandler.updateCallbacks(this.callbacks); } diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 6a74cd56..e35ce64c 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -10,15 +10,17 @@ * Handles Qwen Agent connection establishment, authentication, and session creation */ -import * as vscode from 'vscode'; +// import * as vscode from 'vscode'; import type { AcpConnection } from './acpConnection.js'; import type { QwenSessionReader } from '../services/qwenSessionReader.js'; -import { - CliVersionManager, - MIN_CLI_VERSION_FOR_SESSION_METHODS, -} from '../cli/cliVersionManager.js'; -import { CliContextManager } from '../cli/cliContextManager.js'; +import { CliDetector } from '../cli/cliDetector.js'; import { authMethod } from '../types/acpTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; + +export interface QwenConnectionResult { + sessionCreated: boolean; + requiresAuth: boolean; +} /** * Qwen Connection Handler class @@ -38,36 +40,39 @@ export class QwenConnectionHandler { sessionReader: QwenSessionReader, workingDir: string, cliPath?: string, - ): Promise { + options?: { + autoAuthenticate?: boolean; + }, + ): Promise { const connectId = Date.now(); console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`); + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionCreated = false; + let requiresAuth = false; - // Check CLI version and features - const cliVersionManager = CliVersionManager.getInstance(); - const versionInfo = await cliVersionManager.detectCliVersion(); - console.log('[QwenAgentManager] CLI version info:', versionInfo); - - // Store CLI context - const cliContextManager = CliContextManager.getInstance(); - cliContextManager.setCurrentVersionInfo(versionInfo); - - // Show warning if CLI version is below minimum requirement - if (!versionInfo.isSupported) { - // Wait to determine release version number - vscode.window.showWarningMessage( - `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, - ); + // Lightweight check if CLI exists (without version info for faster performance) + const detectionResult = await CliDetector.detectQwenCliLightweight( + /* forceRefresh */ true, + ); + if (!detectionResult.isInstalled) { + throw new Error(detectionResult.error || 'Qwen CLI not found'); } + console.log('[QwenAgentManager] CLI detected at:', detectionResult.cliPath); - const config = vscode.workspace.getConfiguration('qwenCode'); - // Use the provided CLI path if available, otherwise use the configured path - const effectiveCliPath = - cliPath || config.get('qwen.cliPath', 'qwen'); + // TODO: @yiliang114. closed temporarily + // Show warning if CLI version is below minimum requirement + // if (!versionInfo.isSupported) { + // // Wait to determine release version number + // vscode.window.showWarningMessage( + // `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, + // ); + // } // Build extra CLI arguments (only essential parameters) const extraArgs: string[] = []; - await connection.connect(effectiveCliPath, workingDir, extraArgs); + // TODO: + await connection.connect(cliPath!, workingDir, extraArgs); // Try to restore existing session or create new session // Note: Auto-restore on connect is disabled to avoid surprising loads @@ -85,18 +90,40 @@ export class QwenConnectionHandler { console.log( '[QwenAgentManager] Creating new session (letting CLI handle authentication)...', ); - await this.newSessionWithRetry(connection, workingDir, 3, authMethod); + await this.newSessionWithRetry( + connection, + workingDir, + 3, + authMethod, + autoAuthenticate, + ); console.log('[QwenAgentManager] New session created successfully'); + sessionCreated = true; } catch (sessionError) { - console.log(`\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`); - console.log(`[QwenAgentManager] Error details:`, sessionError); - throw sessionError; + const needsAuth = + autoAuthenticate === false && + isAuthenticationRequiredError(sessionError); + if (needsAuth) { + requiresAuth = true; + console.log( + '[QwenAgentManager] Session creation requires authentication; waiting for user-triggered login.', + ); + } else { + console.log( + `\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`, + ); + console.log(`[QwenAgentManager] Error details:`, sessionError); + throw sessionError; + } } + } else { + sessionCreated = true; } console.log(`\n========================================`); console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); console.log(`========================================\n`); + return { sessionCreated, requiresAuth }; } /** @@ -111,6 +138,7 @@ export class QwenConnectionHandler { workingDir: string, maxRetries: number, authMethod: string, + autoAuthenticate: boolean, ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -130,10 +158,14 @@ export class QwenConnectionHandler { // If Qwen reports that authentication is required, try to // authenticate on-the-fly once and retry without waiting. - const requiresAuth = - errorMessage.includes('Authentication required') || - errorMessage.includes('(code: -32000)'); + const requiresAuth = isAuthenticationRequiredError(error); if (requiresAuth) { + if (!autoAuthenticate) { + console.log( + '[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.', + ); + throw error; + } console.log( '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', ); diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 90ebbb87..57ec8504 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -34,7 +34,7 @@ export interface QwenAgentCallbacks { onToolCall?: (update: ToolCallUpdateData) => void; onPlan?: (entries: PlanEntry[]) => void; onPermissionRequest?: (request: AcpPermissionRequest) => Promise; - onEndTurn?: () => void; + onEndTurn?: (reason?: string) => void; onModeInfo?: (info: { currentModeId?: ApprovalModeValue; availableModes?: Array<{ diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts index b49bd027..258ea798 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -19,7 +19,7 @@ export interface AcpConnectionCallbacks { onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string; }>; - onEndTurn: () => void; + onEndTurn: (reason?: string) => void; } export interface AcpConnectionState { diff --git a/packages/vscode-ide-companion/src/utils/authErrors.ts b/packages/vscode-ide-companion/src/utils/authErrors.ts new file mode 100644 index 00000000..4b7de266 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const AUTH_ERROR_PATTERNS = [ + 'Authentication required', + '(code: -32000)', + 'Unauthorized', + 'Invalid token', + 'Session expired', +]; + +export const isAuthenticationRequiredError = (error: unknown): boolean => { + if (!error) { + return false; + } + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error); + return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +}; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 4bdf6622..d083740c 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -29,6 +29,7 @@ import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js'; import { EmptyState } from './components/layout/EmptyState.js'; +import { OnboardingPage } from './components/layout/OnboardingPage.js'; import { type CompletionItem } from '../types/completionItemTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { ChatHeader } from './components/layout/ChatHeader.js'; @@ -67,6 +68,7 @@ export const App: React.FC = () => { toolCall: PermissionToolCall; } | null>(null); const [planEntries, setPlanEntries] = useState([]); + const [isAuthenticated, setIsAuthenticated] = useState(null); const messagesEndRef = useRef( null, ) as React.RefObject; @@ -176,6 +178,7 @@ export const App: React.FC = () => { vscode, inputFieldRef, isStreaming: messageHandling.isStreaming, + isWaitingForResponse: messageHandling.isWaitingForResponse, }); // Handle cancel/stop from the input bar @@ -218,6 +221,7 @@ export const App: React.FC = () => { inputFieldRef, setInputText, setEditMode, + setIsAuthenticated, }); // Auto-scroll handling: keep the view pinned to bottom when new content arrives, @@ -662,26 +666,37 @@ export const App: React.FC = () => {
{!hasContent ? ( - + isAuthenticated === false ? ( + { + vscode.postMessage({ type: 'login', data: {} }); + messageHandling.setWaitingForResponse( + 'Logging in to Qwen Code...', + ); + }} + /> + ) : isAuthenticated === null ? ( + + ) : ( + + ) ) : ( <> {/* Render all messages and tool calls */} {renderMessages()} - {/* Flow-in persistent slot: keeps a small constant height so toggling */} - {/* the waiting message doesn't change list height to zero. When */} - {/* active, render the waiting message inline (not fixed). */} -
- {messageHandling.isWaitingForResponse && - messageHandling.loadingMessage && ( + + {/* Waiting message positioned fixed above the input form to avoid layout shifts */} + {messageHandling.isWaitingForResponse && + messageHandling.loadingMessage && ( +
- )} -
- +
+ )}
)} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index b4da60ab..34e6d24f 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -15,6 +15,7 @@ import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; import { type ApprovalModeValue } from '../types/acpTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; export class WebViewProvider { private panelManager: PanelManager; @@ -119,12 +120,16 @@ export class WebViewProvider { }); }); - // Setup end-turn handler from ACP stopReason=end_turn - this.agentManager.onEndTurn(() => { + // Setup end-turn handler from ACP stopReason notifications + this.agentManager.onEndTurn((reason) => { + console.log(' ============== ', reason); // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere this.sendMessageToWebView({ type: 'streamEnd', - data: { timestamp: Date.now(), reason: 'end_turn' }, + data: { + timestamp: Date.now(), + reason: reason || 'end_turn', + }, }); }); @@ -520,10 +525,10 @@ export class WebViewProvider { private async attemptAuthStateRestoration(): Promise { try { console.log( - '[WebViewProvider] Attempting connection (CLI handle authentication)...', + '[WebViewProvider] Attempting connection (without auto-auth)...', ); - //always attempt connection and let CLI handle authentication - await this.initializeAgentConnection(); + // Attempt a lightweight connection to detect prior auth without forcing login + await this.initializeAgentConnection({ autoAuthenticate: false }); } catch (error) { console.error( '[WebViewProvider] Error in attemptAuthStateRestoration:', @@ -537,14 +542,19 @@ export class WebViewProvider { * Initialize agent connection and session * Can be called from show() or via /login command */ - async initializeAgentConnection(): Promise { - return this.doInitializeAgentConnection(); + async initializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + return this.doInitializeAgentConnection(options); } /** * Internal: perform actual connection/initialization (no auth locking). */ - private async doInitializeAgentConnection(): Promise { + private async doInitializeAgentConnection(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; const run = async () => { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); @@ -553,7 +563,9 @@ export class WebViewProvider { '[WebViewProvider] Starting initialization, workingDir:', workingDir, ); - console.log('[WebViewProvider] Using CLI-managed authentication'); + console.log( + `[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`, + ); // Check if CLI is installed before attempting to connect const cliDetection = await CliDetector.detectQwenCli(); @@ -583,18 +595,34 @@ export class WebViewProvider { console.log('[WebViewProvider] Connecting to agent...'); // Pass the detected CLI path to ensure we use the correct installation - await this.agentManager.connect(workingDir, cliDetection.cliPath); + const connectResult = await this.agentManager.connect( + workingDir, + cliDetection.cliPath, + options, + ); console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; + if (connectResult.requiresAuth) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } // Load messages from the current Qwen session - await this.loadCurrentSessionMessages(); + const sessionReady = await this.loadCurrentSessionMessages(options); - // Notify webview that agent is connected - this.sendMessageToWebView({ - type: 'agentConnected', - data: {}, - }); + if (sessionReady) { + // Notify webview that agent is connected + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } else { + console.log( + '[WebViewProvider] Session creation deferred until user logs in.', + ); + } } catch (_error) { console.error('[WebViewProvider] Agent connection error:', _error); vscode.window.showWarningMessage( @@ -654,7 +682,7 @@ export class WebViewProvider { }); // Reinitialize connection (will trigger fresh authentication) - await this.doInitializeAgentConnection(); + await this.doInitializeAgentConnection({ autoAuthenticate: true }); console.log( '[WebViewProvider] Force re-login completed successfully', ); @@ -737,7 +765,11 @@ export class WebViewProvider { * Load messages from current Qwen session * Skips session restoration and creates a new session directly */ - private async loadCurrentSessionMessages(): Promise { + private async loadCurrentSessionMessages(options?: { + autoAuthenticate?: boolean; + }): Promise { + const autoAuthenticate = options?.autoAuthenticate ?? true; + let sessionReady = false; try { console.log( '[WebViewProvider] Initializing with new session (skipping restoration)', @@ -748,22 +780,47 @@ export class WebViewProvider { // avoid creating another session if connect() already created one. if (!this.agentManager.currentSessionId) { - try { - await this.agentManager.createNewSession(workingDir); - console.log('[WebViewProvider] ACP session created successfully'); - } catch (sessionError) { - console.error( - '[WebViewProvider] Failed to create ACP session:', - sessionError, - ); - vscode.window.showWarningMessage( - `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, + if (!autoAuthenticate) { + console.log( + '[WebViewProvider] Skipping ACP session creation until user logs in.', ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + try { + await this.agentManager.createNewSession(workingDir, { + autoAuthenticate, + }); + console.log('[WebViewProvider] ACP session created successfully'); + sessionReady = true; + } catch (sessionError) { + const requiresAuth = isAuthenticationRequiredError(sessionError); + if (requiresAuth && !autoAuthenticate) { + console.log( + '[WebViewProvider] ACP session requires authentication; waiting for explicit login.', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } else { + console.error( + '[WebViewProvider] Failed to create ACP session:', + sessionError, + ); + vscode.window.showWarningMessage( + `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, + ); + } + } } } else { console.log( '[WebViewProvider] Existing ACP session detected, skipping new session creation', ); + sessionReady = true; } await this.initializeEmptyConversation(); @@ -776,7 +833,10 @@ export class WebViewProvider { `Failed to load session messages: ${_error}`, ); await this.initializeEmptyConversation(); + return false; } + + return sessionReady; } /** diff --git a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx index 081352b8..f1b15c4c 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -7,10 +7,24 @@ import type React from 'react'; import { generateIconUrl } from '../../utils/resourceUrl.js'; -export const EmptyState: React.FC = () => { +interface EmptyStateProps { + isAuthenticated?: boolean; + loadingMessage?: string; +} + +export const EmptyState: React.FC = ({ + isAuthenticated = false, + loadingMessage, +}) => { // Generate icon URL using the utility function const iconUri = generateIconUrl('icon.png'); + const description = loadingMessage + ? 'Preparing Qwen Code…' + : isAuthenticated + ? 'What would you like to do? Ask about this codebase or we can start writing code.' + : 'Welcome! Please log in to start using Qwen Code.'; + return (
@@ -23,9 +37,14 @@ export const EmptyState: React.FC = () => { />
- What to do first? Ask about this codebase or we can start writing - code. + {description}
+ {loadingMessage && ( +
+ + {loadingMessage} +
+ )}
diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index fe86ea99..997c3f9a 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -113,6 +113,7 @@ export const InputForm: React.FC = ({ onCompletionClose, }) => { const editModeInfo = getEditModeInfo(editMode); + const composerDisabled = isStreaming || isWaitingForResponse; const handleKeyDown = (e: React.KeyboardEvent) => { // ESC should cancel the current interaction (stop generation) @@ -144,7 +145,7 @@ export const InputForm: React.FC = ({ return (
@@ -171,7 +172,7 @@ export const InputForm: React.FC = ({
= ({ data-placeholder="Ask Qwen Code …" // Use a data flag so CSS can show placeholder even if the browser // inserts an invisible
into contentEditable (so :empty no longer matches) - data-empty={inputText.trim().length === 0 ? 'true' : 'false'} + data-empty={ + inputText.replace(/\u200B/g, '').trim().length === 0 + ? 'true' + : 'false' + } onInput={(e) => { + if (composerDisabled) { + return; + } const target = e.target as HTMLDivElement; - onInputChange(target.textContent || ''); + // Filter out zero-width space that we use to maintain height + const text = target.textContent?.replace(/\u200B/g, '') || ''; + onInputChange(text); }} onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} @@ -280,7 +290,7 @@ export const InputForm: React.FC = ({ diff --git a/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx b/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx new file mode 100644 index 00000000..945f86a4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx @@ -0,0 +1,81 @@ +import type React from 'react'; +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface OnboardingPageProps { + onLogin: () => void; +} + +export const OnboardingPage: React.FC = ({ onLogin }) => { + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+
+
+ Qwen Code Logo +
+ + + +
+
+ +
+

+ Welcome to Qwen Code +

+

+ Qwen Code helps you understand, navigate, and transform your + codebase with AI assistance. +

+
+ + {/*
+
+

Get Started

+
    +
  • +
    + Understand complex codebases faster +
  • +
  • +
    + Navigate with AI-powered suggestions +
  • +
  • +
    + Transform code with confidence +
  • +
+
+ +
*/} + + + + {/*
+

+ By logging in, you agree to the Terms of Service and Privacy + Policy. +

+
*/} +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts index 9f67bcc8..a91594c0 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -14,6 +14,7 @@ interface UseMessageSubmitProps { setInputText: (text: string) => void; inputFieldRef: React.RefObject; isStreaming: boolean; + isWaitingForResponse: boolean; // When true, do NOT auto-attach the active editor file/selection to context skipAutoActiveContext?: boolean; @@ -40,6 +41,7 @@ export const useMessageSubmit = ({ setInputText, inputFieldRef, isStreaming, + isWaitingForResponse, skipAutoActiveContext = false, fileContext, messageHandling, @@ -48,7 +50,7 @@ export const useMessageSubmit = ({ (e: React.FormEvent) => { e.preventDefault(); - if (!inputText.trim() || isStreaming) { + if (!inputText.trim() || isStreaming || isWaitingForResponse) { return; } @@ -56,7 +58,10 @@ export const useMessageSubmit = ({ if (inputText.trim() === '/login') { setInputText(''); if (inputFieldRef.current) { - inputFieldRef.current.textContent = ''; + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); } vscode.postMessage({ type: 'login', @@ -142,7 +147,10 @@ export const useMessageSubmit = ({ setInputText(''); if (inputFieldRef.current) { - inputFieldRef.current.textContent = ''; + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); } fileContext.clearFileReferences(); }, @@ -154,6 +162,7 @@ export const useMessageSubmit = ({ vscode, fileContext, skipAutoActiveContext, + isWaitingForResponse, messageHandling, ], ); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 7a3f7e06..a227e470 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -109,6 +109,8 @@ interface UseWebViewMessagesProps { setInputText: (text: string) => void; // Edit mode setter (maps ACP modes to UI modes) setEditMode?: (mode: ApprovalModeValue) => void; + // Authentication state setter + setIsAuthenticated?: (authenticated: boolean | null) => void; } /** @@ -126,6 +128,7 @@ export const useWebViewMessages = ({ inputFieldRef, setInputText, setEditMode, + setIsAuthenticated, }: UseWebViewMessagesProps) => { // VS Code API for posting messages back to the extension host const vscode = useVSCode(); @@ -141,6 +144,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + setIsAuthenticated, }); // Track last "Updated Plan" snapshot toolcall to support merge/dedupe @@ -185,6 +189,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + setIsAuthenticated, }; }); @@ -216,6 +221,7 @@ export const useWebViewMessages = ({ } break; } + case 'loginSuccess': { // Clear loading state and show a short assistant notice handlers.messageHandling.clearWaitingForResponse(); @@ -224,12 +230,16 @@ export const useWebViewMessages = ({ content: 'Successfully logged in. You can continue chatting.', timestamp: Date.now(), }); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); break; } case 'agentConnected': { // Agent connected successfully; clear any pending spinner handlers.messageHandling.clearWaitingForResponse(); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); break; } @@ -245,6 +255,8 @@ export const useWebViewMessages = ({ content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, timestamp: Date.now(), }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); break; } @@ -259,6 +271,20 @@ export const useWebViewMessages = ({ content: errorMsg, timestamp: Date.now(), }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); + break; + } + + case 'authState': { + const state = ( + message?.data as { authenticated?: boolean | null } | undefined + )?.authenticated; + if (typeof state === 'boolean') { + handlers.setIsAuthenticated?.(state); + } else { + handlers.setIsAuthenticated?.(null); + } break; } @@ -303,6 +329,7 @@ export const useWebViewMessages = ({ } } } + console.log('[useWebViewMessages1111]__________ other message:', msg); break; } @@ -336,7 +363,7 @@ export const useWebViewMessages = ({ const reason = ( (message.data as { reason?: string } | undefined)?.reason || '' ).toLowerCase(); - if (reason === 'user_cancelled') { + if (reason === 'user_cancelled' || reason === 'cancelled') { activeExecToolCallsRef.current.clear(); handlers.messageHandling.clearWaitingForResponse(); break; diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index 4c4b5e08..46d803d5 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -51,8 +51,7 @@ .composer-form:focus-within { /* match existing highlight behavior */ border-color: var(--app-input-highlight); - box-shadow: 0 1px 2px - color-mix(in srgb, var(--app-input-highlight), transparent 80%); + box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%); } /* Composer: input editable area */ @@ -67,7 +66,7 @@ The data attribute is needed because some browsers insert a
in contentEditable, which breaks :empty matching. */ .composer-input:empty:before, - .composer-input[data-empty='true']::before { + .composer-input[data-empty="true"]::before { content: attr(data-placeholder); color: var(--app-input-placeholder-foreground); pointer-events: none; @@ -81,7 +80,7 @@ outline: none; } .composer-input:disabled, - .composer-input[contenteditable='false'] { + .composer-input[contenteditable="false"] { color: #999; cursor: not-allowed; } @@ -111,7 +110,7 @@ } .btn-text-compact > svg { height: 1em; - width: 1em; + width: 1em; flex-shrink: 0; } .btn-text-compact > span { diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css index 25d5cc85..033e82d2 100644 --- a/packages/vscode-ide-companion/src/webview/styles/timeline.css +++ b/packages/vscode-ide-companion/src/webview/styles/timeline.css @@ -88,6 +88,22 @@ z-index: 0; } +/* Single-item AI sequence (both a start and an end): hide the connector entirely */ +.qwen-message.message-item:not(.user-message-container):is( + :first-child, + .user-message-container + + .qwen-message.message-item:not(.user-message-container), + .chat-messages + > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container) + ):is( + :has(+ .user-message-container), + :has(+ :not(.qwen-message.message-item)), + :last-child + )::after { + display: none; +} + /* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */ .qwen-message.message-item:not(.user-message-container):first-child::after, .user-message-container + .qwen-message.message-item:not(.user-message-container)::after, @@ -123,4 +139,4 @@ position: relative; padding-top: 8px; padding-bottom: 8px; -} \ No newline at end of file +}