diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index d1ec4169..c737ecc3 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -90,6 +90,7 @@ async function main() { outfile: 'dist/webview.js', logLevel: 'silent', plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin], + jsx: 'automatic', // Use new JSX transform (React 17+) define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"', }, diff --git a/packages/vscode-ide-companion/eslint.config.mjs b/packages/vscode-ide-companion/eslint.config.mjs index 02fc9fba..62ceef17 100644 --- a/packages/vscode-ide-companion/eslint.config.mjs +++ b/packages/vscode-ide-companion/eslint.config.mjs @@ -9,7 +9,7 @@ import tsParser from '@typescript-eslint/parser'; export default [ { - files: ['**/*.ts'], + files: ['**/*.ts', '**/*.tsx'], }, { plugins: { @@ -20,6 +20,11 @@ export default [ parser: tsParser, ecmaVersion: 2022, sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, }, rules: { diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 56ea79b0..5b83df42 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -59,6 +59,10 @@ "command": "qwenCode.openChat", "title": "Qwen Code: Open Chat", "icon": "./assets/icon.png" + }, + { + "command": "qwenCode.clearAuthCache", + "title": "Qwen Code: Clear Authentication Cache" } ], "configuration": { diff --git a/packages/vscode-ide-companion/src/WebViewProvider.ts b/packages/vscode-ide-companion/src/WebViewProvider.ts index 3c517b5d..a2d485a8 100644 --- a/packages/vscode-ide-companion/src/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/WebViewProvider.ts @@ -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'; export class WebViewProvider { private panel: vscode.WebviewPanel | null = null; private agentManager: QwenAgentManager; private conversationStore: ConversationStore; + private authStateManager: AuthStateManager; private currentConversationId: string | null = null; private disposables: vscode.Disposable[] = []; private agentInitialized = false; // Track if agent has been initialized @@ -26,6 +28,7 @@ export class WebViewProvider { ) { this.agentManager = new QwenAgentManager(); this.conversationStore = new ConversationStore(context); + this.authStateManager = new AuthStateManager(context); // Setup agent callbacks this.agentManager.onStreamChunk((chunk: string) => { @@ -122,7 +125,10 @@ export class WebViewProvider { if (qwenEnabled) { try { console.log('[WebViewProvider] Connecting to agent...'); - await this.agentManager.connect(workingDir); + const authInfo = await this.authStateManager.getAuthInfo(); + console.log('[WebViewProvider] Auth cache status:', authInfo); + + await this.agentManager.connect(workingDir, this.authStateManager); console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; @@ -132,6 +138,8 @@ export class WebViewProvider { ); } catch (error) { console.error('[WebViewProvider] Agent connection error:', error); + // Clear auth cache on error + await this.authStateManager.clearAuthState(); vscode.window.showWarningMessage( `Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`, ); diff --git a/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts index 3f6b1dc1..bee8637d 100644 --- a/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts @@ -14,6 +14,7 @@ import { QwenSessionReader, type QwenSession, } from '../services/QwenSessionReader.js'; +import type { AuthStateManager } from '../auth/AuthStateManager.js'; export interface ChatMessage { role: 'user' | 'assistant'; @@ -57,7 +58,10 @@ export class QwenAgentManager { }; } - async connect(workingDir: string): Promise { + async connect( + workingDir: string, + authStateManager?: AuthStateManager, + ): Promise { this.currentWorkingDir = workingDir; const config = vscode.workspace.getConfiguration('qwenCode'); const cliPath = config.get('qwen.cliPath', 'qwen'); @@ -87,7 +91,23 @@ export class QwenAgentManager { // Determine auth method based on configuration const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth'; - // Since session/list is not supported, try to get sessions from local files + // Check if we have valid cached authentication + let needsAuth = true; + if (authStateManager) { + const hasValidAuth = await authStateManager.hasValidAuth( + workingDir, + authMethod, + ); + if (hasValidAuth) { + console.log('[QwenAgentManager] Using cached authentication'); + needsAuth = false; + } + } + + // Try to restore existing session or create new one + let sessionRestored = false; + + // Try to get sessions from local files console.log('[QwenAgentManager] Reading local session files...'); try { const sessions = await this.sessionReader.getAllSessions(workingDir); @@ -107,31 +127,145 @@ export class QwenAgentManager { '[QwenAgentManager] Restored session:', lastSession.sessionId, ); - } catch (_switchError) { + sessionRestored = true; + // If session restored successfully, we don't need to authenticate + needsAuth = false; + } catch (switchError) { console.log( - '[QwenAgentManager] session/switch not supported, creating new session', + '[QwenAgentManager] session/switch not supported or failed:', + switchError instanceof Error + ? switchError.message + : String(switchError), ); - await this.connection.authenticate(authMethod); - await this.connection.newSession(workingDir); + // Will create new session below } } else { - // No sessions, authenticate and create a new one - console.log( - '[QwenAgentManager] No existing sessions, creating new session', - ); - await this.connection.authenticate(authMethod); - await this.connection.newSession(workingDir); + console.log('[QwenAgentManager] No existing sessions found'); } } catch (error) { - // If reading local sessions fails, fall back to creating new session + // If reading local sessions fails, log and continue const errorMessage = error instanceof Error ? error.message : String(error); console.log( - '[QwenAgentManager] Failed to read local sessions, creating new session:', + '[QwenAgentManager] Failed to read local sessions:', errorMessage, ); - await this.connection.authenticate(authMethod); - await this.connection.newSession(workingDir); + // Will create new session below + } + + // Create new session if we couldn't restore one + if (!sessionRestored) { + console.log('[QwenAgentManager] Creating new session...'); + + // Authenticate only if needed (not cached or session restore failed) + if (needsAuth) { + await this.authenticateWithRetry(authMethod, 3); + // Save successful auth to cache + if (authStateManager) { + await authStateManager.saveAuthState(workingDir, authMethod); + } + } + + // Try to create session + try { + await this.newSessionWithRetry(workingDir, 3); + console.log('[QwenAgentManager] New session created successfully'); + } catch (sessionError) { + // If we used cached auth but session creation failed, + // the cached auth might be invalid (token expired on server) + // Clear cache and retry with fresh authentication + if (!needsAuth && authStateManager) { + console.log( + '[QwenAgentManager] Session creation failed with cached auth, clearing cache and re-authenticating...', + ); + await authStateManager.clearAuthState(); + + // Retry with fresh authentication + await this.authenticateWithRetry(authMethod, 3); + await authStateManager.saveAuthState(workingDir, authMethod); + await this.newSessionWithRetry(workingDir, 3); + console.log( + '[QwenAgentManager] Successfully authenticated and created session after cache invalidation', + ); + } else { + // If we already tried with fresh auth, or no auth manager, just throw + throw sessionError; + } + } + } + } + + /** + * Authenticate with retry logic + */ + private async authenticateWithRetry( + authMethod: string, + maxRetries: number, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log( + `[QwenAgentManager] Authenticating (attempt ${attempt}/${maxRetries})...`, + ); + await this.connection.authenticate(authMethod); + console.log('[QwenAgentManager] Authentication successful'); + return; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[QwenAgentManager] Authentication attempt ${attempt} failed:`, + errorMessage, + ); + + if (attempt === maxRetries) { + throw new Error( + `Authentication failed after ${maxRetries} attempts: ${errorMessage}`, + ); + } + + // Wait before retrying (exponential backoff) + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); + console.log(`[QwenAgentManager] Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + /** + * Create new session with retry logic + */ + private async newSessionWithRetry( + workingDir: string, + maxRetries: number, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log( + `[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`, + ); + await this.connection.newSession(workingDir); + console.log('[QwenAgentManager] Session created successfully'); + return; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[QwenAgentManager] Session creation attempt ${attempt} failed:`, + errorMessage, + ); + + if (attempt === maxRetries) { + throw new Error( + `Session creation failed after ${maxRetries} attempts: ${errorMessage}`, + ); + } + + // Wait before retrying + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); + console.log(`[QwenAgentManager] Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } } } diff --git a/packages/vscode-ide-companion/src/auth/AuthStateManager.ts b/packages/vscode-ide-companion/src/auth/AuthStateManager.ts new file mode 100644 index 00000000..23d00ae0 --- /dev/null +++ b/packages/vscode-ide-companion/src/auth/AuthStateManager.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; + +interface AuthState { + isAuthenticated: boolean; + authMethod: string; + timestamp: number; + workingDir?: string; +} + +/** + * Manages authentication state caching to avoid repeated logins + */ +export class AuthStateManager { + private static readonly AUTH_STATE_KEY = 'qwen.authState'; + private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours + + constructor(private context: vscode.ExtensionContext) {} + + /** + * Check if there's a valid cached authentication + */ + async hasValidAuth(workingDir: string, authMethod: string): Promise { + const state = await this.getAuthState(); + + if (!state) { + return false; + } + + // Check if auth is still valid (within cache duration) + const now = Date.now(); + const isExpired = + now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; + + if (isExpired) { + console.log('[AuthStateManager] Cached auth expired'); + await this.clearAuthState(); + return false; + } + + // Check if it's for the same working directory and auth method + const isSameContext = + state.workingDir === workingDir && state.authMethod === authMethod; + + if (!isSameContext) { + console.log('[AuthStateManager] Working dir or auth method changed'); + return false; + } + + console.log('[AuthStateManager] Valid cached auth found'); + return state.isAuthenticated; + } + + /** + * Save successful authentication state + */ + async saveAuthState(workingDir: string, authMethod: string): Promise { + const state: AuthState = { + isAuthenticated: true, + authMethod, + workingDir, + timestamp: Date.now(), + }; + + await this.context.globalState.update( + AuthStateManager.AUTH_STATE_KEY, + state, + ); + console.log('[AuthStateManager] Auth state saved'); + } + + /** + * Clear authentication state + */ + async clearAuthState(): Promise { + await this.context.globalState.update( + AuthStateManager.AUTH_STATE_KEY, + undefined, + ); + console.log('[AuthStateManager] Auth state cleared'); + } + + /** + * Get current auth state + */ + private async getAuthState(): Promise { + return this.context.globalState.get( + AuthStateManager.AUTH_STATE_KEY, + ); + } + + /** + * Get auth state info for debugging + */ + async getAuthInfo(): Promise { + const state = await this.getAuthState(); + if (!state) { + return 'No cached auth'; + } + + const age = Math.floor((Date.now() - state.timestamp) / 1000 / 60); + return `Auth cached ${age}m ago, method: ${state.authMethod}`; + } +} diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index eb0562d0..44f0889f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,6 +15,7 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './WebViewProvider.js'; +import { AuthStateManager } from './auth/AuthStateManager.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; @@ -33,6 +34,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; let webViewProvider: WebViewProvider; +let authStateManager: AuthStateManager; let log: (message: string) => void = () => {}; @@ -112,6 +114,9 @@ export async function activate(context: vscode.ExtensionContext) { const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager(log, diffContentProvider); + // Initialize Auth State Manager + authStateManager = new AuthStateManager(context); + // Initialize WebView Provider webViewProvider = new WebViewProvider(context, context.extensionUri); @@ -140,6 +145,13 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('qwenCode.openChat', () => { webViewProvider.show(); }), + vscode.commands.registerCommand('qwenCode.clearAuthCache', async () => { + await authStateManager.clearAuthState(); + vscode.window.showInformationMessage( + 'Qwen Code authentication cache cleared. You will need to login again on next connection.', + ); + log('Auth cache cleared by user'); + }), ); ideServer = new IDEServer(log, diffManager); diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 4cf9c6d9..538ec461 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -5,6 +5,7 @@ "target": "ES2022", "lib": ["ES2022", "dom"], "jsx": "react-jsx", + "jsxImportSource": "react", "sourceMap": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */