diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 53cb8b1c..91ce53cb 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -292,7 +292,7 @@ class GeminiAgent { private async ensureAuthenticated(config: Config): Promise { const selectedType = this.settings.merged.security?.auth?.selectedType; if (!selectedType) { - throw acp.RequestError.authRequired(); + throw acp.RequestError.authRequired('No Selected Type'); } try { @@ -300,7 +300,9 @@ class GeminiAgent { await config.refreshAuth(selectedType, true); } catch (e) { console.error(`Authentication failed: ${e}`); - throw acp.RequestError.authRequired(); + throw acp.RequestError.authRequired( + 'Authentication failed: ' + (e as Error).message, + ); } } diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 3cb94a82..77c5345a 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -677,6 +677,19 @@ async function authWithQwenDeviceFlow( // Cache the new tokens await cacheQwenCredentials(credentials); + // IMPORTANT: + // SharedTokenManager maintains an in-memory cache and throttles file checks. + // If we only write the creds file here, a subsequent `getQwenOAuthClient()` + // call in the same process (within the throttle window) may not re-read the + // updated file and could incorrectly re-trigger device auth. + // Clearing the cache forces the next call to reload from disk. + try { + SharedTokenManager.getInstance().clearCache(); + } catch { + // In unit tests we sometimes mock SharedTokenManager.getInstance() with a + // minimal stub; cache invalidation is best-effort and should not break auth. + } + // Emit auth progress success event qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, @@ -880,6 +893,14 @@ export async function clearQwenCredentials(): Promise { } // Log other errors but don't throw - clearing credentials should be non-critical console.warn('Warning: Failed to clear cached Qwen credentials:', error); + } finally { + // Also clear SharedTokenManager in-memory cache to prevent stale credentials + // from being reused within the same process after the file is removed. + try { + SharedTokenManager.getInstance().clearCache(); + } catch { + // Best-effort; don't fail credential clearing if SharedTokenManager is mocked. + } } } diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts index 18a69641..9f06e4fa 100644 --- a/packages/vscode-ide-companion/src/constants/acpSchema.ts +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -19,6 +19,7 @@ export const AGENT_METHODS = { export const CLIENT_METHODS = { fs_read_text_file: 'fs/read_text_file', fs_write_text_file: 'fs/write_text_file', + authenticate_update: 'authenticate/update', session_request_permission: 'session/request_permission', session_update: 'session/update', } as const; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 659c020f..4b2c4028 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -10,6 +10,7 @@ import type { AcpPermissionRequest, AcpResponse, AcpSessionUpdate, + AuthenticateUpdateNotification, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; @@ -42,6 +43,8 @@ export class AcpConnection { onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string; }> = () => Promise.resolve({ optionId: 'allow' }); + onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = + () => {}; onEndTurn: () => void = () => {}; // Called after successful initialize() with the initialize result onInitialized: (init: unknown) => void = () => {}; @@ -207,6 +210,7 @@ export class AcpConnection { const callbacks: AcpConnectionCallbacks = { onSessionUpdate: this.onSessionUpdate, onPermissionRequest: this.onPermissionRequest, + onAuthenticateUpdate: this.onAuthenticateUpdate, onEndTurn: this.onEndTurn, }; diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts index db7802ce..8766fdf3 100644 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -17,6 +17,7 @@ import type { AcpResponse, AcpSessionUpdate, AcpPermissionRequest, + AuthenticateUpdateNotification, } from '../types/acpTypes.js'; import { CLIENT_METHODS } from '../constants/acpSchema.js'; import type { @@ -110,13 +111,20 @@ export class AcpMessageHandler { // JSON.stringify(message.result).substring(0, 200), message.result, ); - if ( - message.result && - typeof message.result === 'object' && - 'stopReason' in message.result && - message.result.stopReason === 'end_turn' - ) { - 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) { @@ -161,6 +169,15 @@ export class AcpMessageHandler { ); callbacks.onSessionUpdate(params as AcpSessionUpdate); break; + case CLIENT_METHODS.authenticate_update: + console.log( + '[ACP] >>> Processing authenticate_update:', + JSON.stringify(params).substring(0, 300), + ); + callbacks.onAuthenticateUpdate( + params as AuthenticateUpdateNotification, + ); + break; case CLIENT_METHODS.session_request_permission: result = await this.handlePermissionRequest( params as AcpPermissionRequest, diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts index 55b1d2b5..cfa299bf 100644 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -54,8 +54,14 @@ export class AcpSessionManager { }; return new Promise((resolve, reject) => { - const timeoutDuration = - method === AGENT_METHODS.session_prompt ? 120000 : 60000; + // different timeout durations based on methods + let timeoutDuration = 60000; // default 60 seconds + if ( + method === AGENT_METHODS.session_prompt || + method === AGENT_METHODS.initialize + ) { + timeoutDuration = 120000; // 2min for session_prompt and initialize + } const timeoutId = setTimeout(() => { pendingRequests.delete(id); @@ -163,7 +169,7 @@ export class AcpSessionManager { pendingRequests, nextRequestId, ); - console.log('[ACP] Authenticate successful'); + console.log('[ACP] Authenticate successful', response); return response; } diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index c87d3783..e60ee3a2 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -7,6 +7,7 @@ import { AcpConnection } from './acpConnection.js'; import type { AcpSessionUpdate, AcpPermissionRequest, + AuthenticateUpdateNotification, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; @@ -17,9 +18,14 @@ 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 { authMethod } from '../types/acpTypes.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; @@ -28,6 +34,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; @@ -117,10 +130,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(''); @@ -130,6 +143,20 @@ export class QwenAgentManager { } }; + this.connection.onAuthenticateUpdate = ( + data: AuthenticateUpdateNotification, + ) => { + try { + // Handle authentication update notifications by showing VS Code notification + handleAuthenticateUpdate(data); + } catch (err) { + console.warn( + '[QwenAgentManager] onAuthenticateUpdate callback error:', + err, + ); + } + }; + // Initialize callback to surface available modes and current mode to UI this.connection.onInitialized = (init: unknown) => { try { @@ -162,13 +189,17 @@ export class QwenAgentManager { * @param workingDir - Working directory * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) */ - async connect(workingDir: string, cliEntryPath: string): Promise { + async connect( + workingDir: string, + cliEntryPath: string, + options?: AgentConnectOptions, + ): Promise { this.currentWorkingDir = workingDir; - await this.connectionHandler.connect( + return this.connectionHandler.connect( this.connection, - this.sessionReader, workingDir, cliEntryPath, + options, ); } @@ -250,9 +281,10 @@ export class QwenAgentManager { '[QwenAgentManager] Getting session list with version-aware strategy', ); - // Prefer ACP method first; fall back to file system if it fails for any reason. try { - console.log('[QwenAgentManager] Attempting to get session list via ACP'); + console.log( + '[QwenAgentManager] Attempting to get session list via ACP method', + ); const response = await this.connection.listSessions(); console.log('[QwenAgentManager] ACP session list response:', response); @@ -262,19 +294,21 @@ export class QwenAgentManager { const res: unknown = response; let items: Array> = []; - if (Array.isArray(res)) { - items = res as Array>; - } else if (res && typeof res === 'object' && 'items' in res) { + // Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC + // "result" directly (not the full AcpResponse). Treat it as unknown + // and carefully narrow before accessing `items` to satisfy strict TS. + if (res && typeof res === 'object' && 'items' in res) { const itemsValue = (res as { items?: unknown }).items; items = Array.isArray(itemsValue) ? (itemsValue as Array>) : []; } - console.log('[QwenAgentManager] Sessions retrieved via ACP:', { - count: items.length, - }); - + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + res, + items.length, + ); if (items.length > 0) { const sessions = items.map((item) => ({ id: item.sessionId || item.id, @@ -288,6 +322,11 @@ export class QwenAgentManager { filePath: item.filePath, cwd: item.cwd, })); + + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + sessions.length, + ); return sessions; } } catch (error) { @@ -350,6 +389,7 @@ export class QwenAgentManager { }> { const size = params?.size ?? 20; const cursor = params?.cursor; + try { const response = await this.connection.listSessions({ size, @@ -444,7 +484,6 @@ export class QwenAgentManager { */ async getSessionMessages(sessionId: string): Promise { try { - // Prefer reading CLI's JSONL if we can find filePath from session/list try { const list = await this.getSessionList(); const item = list.find( @@ -664,7 +703,9 @@ export class QwenAgentManager { const planText = planEntries .map( (entry: Record, index: number) => - `${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`, + `${index + 1}. ${ + entry.description || entry.title || 'Unnamed step' + }`, ) .join('\n'); msgs.push({ @@ -943,13 +984,15 @@ export class QwenAgentManager { sessionId, ); - // Prefer ACP session/load first; fall back to file system on failure. try { - console.log('[QwenAgentManager] Attempting to load session via ACP'); + console.log( + '[QwenAgentManager] Attempting to load session via ACP method', + ); await this.loadSessionViaAcp(sessionId); console.log('[QwenAgentManager] Session loaded successfully via ACP'); - // After loading via ACP, we still need to get messages from file system. - // In future, we might get them directly from the ACP response. + + // After loading via ACP, we still need to get messages from file system + // In future, we might get them directly from the ACP response } catch (error) { console.warn( '[QwenAgentManager] ACP session load failed, falling back to file system method:', @@ -1030,7 +1073,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; @@ -1048,18 +1095,24 @@ 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...', ); try { // Let CLI handle authentication - it's the single source of truth await this.connection.authenticate(authMethod); + console.log( + '[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...', + ); // Add a slight delay to ensure auth state is settled await new Promise((resolve) => setTimeout(resolve, 300)); await this.connection.newSession(workingDir); @@ -1170,9 +1223,9 @@ 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 { 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 33aeef30..c66ee23c 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -11,9 +11,14 @@ */ import type { AcpConnection } from './acpConnection.js'; -import type { QwenSessionReader } from '../services/qwenSessionReader.js'; +import { isAuthenticationRequiredError } from '../utils/authErrors.js'; import { authMethod } from '../types/acpTypes.js'; +export interface QwenConnectionResult { + sessionCreated: boolean; + requiresAuth: boolean; +} + /** * Qwen Connection Handler class * Handles connection, authentication, and session initialization @@ -23,23 +28,27 @@ export class QwenConnectionHandler { * Connect to Qwen service and establish session * * @param connection - ACP connection instance - * @param sessionReader - Session reader instance * @param workingDir - Working directory - * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) + * @param cliPath - CLI path (optional, if provided will override the path in configuration) */ async connect( connection: AcpConnection, - sessionReader: QwenSessionReader, workingDir: string, cliEntryPath: 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; // Build extra CLI arguments (only essential parameters) const extraArgs: string[] = []; - await connection.connect(cliEntryPath, workingDir, extraArgs); + await connection.connect(cliEntryPath!, workingDir, extraArgs); // Try to restore existing session or create new session // Note: Auto-restore on connect is disabled to avoid surprising loads @@ -57,18 +66,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 }; } /** @@ -83,6 +114,7 @@ export class QwenConnectionHandler { workingDir: string, maxRetries: number, authMethod: string, + autoAuthenticate: boolean, ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -102,10 +134,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...', ); @@ -115,6 +151,9 @@ export class QwenConnectionHandler { // newSession may cause the cli authorization jump to be triggered again // Add a slight delay to ensure auth state is settled await new Promise((resolve) => setTimeout(resolve, 300)); + console.log( + '[QwenAgentManager] newSessionWithRetry Authentication successful', + ); // Retry immediately after successful auth await connection.newSession(workingDir); console.log( diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 252f3d5d..5ddbfd06 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -166,6 +166,13 @@ export interface CurrentModeUpdate extends BaseSessionUpdate { }; } +// Authenticate update (sent by agent during authentication process) +export interface AuthenticateUpdateNotification { + _meta: { + authUri: string; + }; +} + export type AcpSessionUpdate = | UserMessageChunkUpdate | AgentMessageChunkUpdate diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index bafe154d..4cffd4eb 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -35,7 +35,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..7ada3aed 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -5,7 +5,11 @@ */ import type { ChildProcess } from 'child_process'; -import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js'; +import type { + AcpSessionUpdate, + AcpPermissionRequest, + AuthenticateUpdateNotification, +} from './acpTypes.js'; export interface PendingRequest { resolve: (value: T) => void; @@ -19,7 +23,8 @@ export interface AcpConnectionCallbacks { onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string; }>; - onEndTurn: () => void; + onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => 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..8b0e6af9 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const AUTH_ERROR_PATTERNS = [ + 'Authentication required', // Standard authentication request message + '(code: -32000)', // RPC error code -32000 indicates authentication failure + 'Unauthorized', // HTTP unauthorized error + 'Invalid token', // Invalid token + 'Session expired', // Session expired +]; + +/** + * Determines if the given error is authentication-related + */ +export const isAuthenticationRequiredError = (error: unknown): boolean => { + // Null check to avoid unnecessary processing + if (!error) { + return false; + } + + // Extract error message text + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error); + + // Match authentication-related errors using predefined patterns + return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +}; diff --git a/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts new file mode 100644 index 00000000..362867c2 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/authNotificationHandler.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; + +// Store reference to the current notification +let currentNotification: Thenable | null = null; + +/** + * Handle authentication update notifications by showing a VS Code notification + * with the authentication URI and action buttons. + * + * @param data - Authentication update notification data containing the auth URI + */ +export function handleAuthenticateUpdate( + data: AuthenticateUpdateNotification, +): void { + const authUri = data._meta.authUri; + + // Store reference to the current notification + currentNotification = vscode.window.showInformationMessage( + `Qwen Code needs authentication. Click an action below:`, + 'Open in Browser', + 'Copy Link', + 'Dismiss', + ); + + currentNotification.then((selection) => { + if (selection === 'Open in Browser') { + // Open the authentication URI in the default browser + vscode.env.openExternal(vscode.Uri.parse(authUri)); + vscode.window.showInformationMessage( + 'Opening authentication page in your browser...', + ); + } else if (selection === 'Copy Link') { + // Copy the authentication URI to clipboard + vscode.env.clipboard.writeText(authUri); + vscode.window.showInformationMessage( + 'Authentication link copied to clipboard!', + ); + } + + // Clear the notification reference after user interaction + currentNotification = null; + }); +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 1db91d39..5eacdabf 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 { Onboarding } from './components/layout/Onboarding.js'; import { type CompletionItem } from '../types/completionItemTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { ChatHeader } from './components/layout/ChatHeader.js'; @@ -67,6 +68,8 @@ export const App: React.FC = () => { toolCall: PermissionToolCall; } | null>(null); const [planEntries, setPlanEntries] = useState([]); + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading const messagesEndRef = useRef( null, ) as React.RefObject; @@ -201,6 +204,7 @@ export const App: React.FC = () => { vscode, inputFieldRef, isStreaming: messageHandling.isStreaming, + isWaitingForResponse: messageHandling.isWaitingForResponse, }); // Handle cancel/stop from the input bar @@ -243,6 +247,7 @@ export const App: React.FC = () => { inputFieldRef, setInputText, setEditMode, + setIsAuthenticated, }); // Auto-scroll handling: keep the view pinned to bottom when new content arrives, @@ -356,6 +361,14 @@ export const App: React.FC = () => { completedToolCalls, ]); + // Set loading state to false after initial mount and when we have authentication info + useEffect(() => { + // If we have determined authentication status, we're done loading + if (isAuthenticated !== null) { + setIsLoading(false); + } + }, [isAuthenticated]); + // Handle permission response const handlePermissionResponse = useCallback( (optionId: string) => { @@ -662,7 +675,19 @@ export const App: React.FC = () => { allMessages.length > 0; return ( -
+
+ {/* Top-level loading overlay */} + {isLoading && ( +
+
+
+

+ Preparing Qwen Code... +

+
+
+ )} + {
- {!hasContent ? ( - + {!hasContent && !isLoading ? ( + 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 && ( +
- )} -
- +
+ )}
)}
- setIsComposing(true)} - onCompositionEnd={() => setIsComposing(false)} - onKeyDown={() => {}} - onSubmit={handleSubmitWithScroll} - onCancel={handleCancel} - onToggleEditMode={handleToggleEditMode} - onToggleThinking={handleToggleThinking} - onFocusActiveEditor={fileContext.focusActiveEditor} - onToggleSkipAutoActiveContext={() => - setSkipAutoActiveContext((v) => !v) - } - onShowCommandMenu={async () => { - if (inputFieldRef.current) { - inputFieldRef.current.focus(); + {isAuthenticated && ( + setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={() => {}} + onSubmit={handleSubmitWithScroll} + onCancel={handleCancel} + onToggleEditMode={handleToggleEditMode} + onToggleThinking={handleToggleThinking} + onFocusActiveEditor={fileContext.focusActiveEditor} + onToggleSkipAutoActiveContext={() => + setSkipAutoActiveContext((v) => !v) + } + onShowCommandMenu={async () => { + if (inputFieldRef.current) { + inputFieldRef.current.focus(); - const selection = window.getSelection(); - let position = { top: 0, left: 0 }; + const selection = window.getSelection(); + let position = { top: 0, left: 0 }; - if (selection && selection.rangeCount > 0) { - try { - const range = selection.getRangeAt(0); - const rangeRect = range.getBoundingClientRect(); - if (rangeRect.top > 0 && rangeRect.left > 0) { - position = { - top: rangeRect.top, - left: rangeRect.left, - }; - } else { + if (selection && selection.rangeCount > 0) { + try { + const range = selection.getRangeAt(0); + const rangeRect = range.getBoundingClientRect(); + if (rangeRect.top > 0 && rangeRect.left > 0) { + position = { + top: rangeRect.top, + left: rangeRect.left, + }; + } else { + const inputRect = + inputFieldRef.current.getBoundingClientRect(); + position = { top: inputRect.top, left: inputRect.left }; + } + } catch (error) { + console.error('[App] Error getting cursor position:', error); const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } - } catch (error) { - console.error('[App] Error getting cursor position:', error); + } else { const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } - } else { - const inputRect = inputFieldRef.current.getBoundingClientRect(); - position = { top: inputRect.top, left: inputRect.left }; + + await completion.openCompletion('/', '', position); } + }} + onAttachContext={handleAttachContextClick} + completionIsOpen={completion.isOpen} + completionItems={completion.items} + onCompletionSelect={handleCompletionSelect} + onCompletionClose={completion.closeCompletion} + /> + )} - await completion.openCompletion('/', '', position); - } - }} - onAttachContext={handleAttachContextClick} - completionIsOpen={completion.isOpen} - completionItems={completion.items} - onCompletionSelect={handleCompletionSelect} - onCompletionClose={completion.closeCompletion} - /> - - {permissionRequest && ( + {isAuthenticated && permissionRequest && ( { + // Setup end-turn handler from ACP stopReason notifications + this.agentManager.onEndTurn((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', + }, }); }); @@ -517,11 +521,9 @@ export class WebViewProvider { */ private async attemptAuthStateRestoration(): Promise { try { - console.log( - '[WebViewProvider] Attempting connection (CLI handle authentication)...', - ); - //always attempt connection and let CLI handle authentication - await this.initializeAgentConnection(); + console.log('[WebViewProvider] Attempting connection...'); + // Attempt a connection to detect prior auth without forcing login + await this.initializeAgentConnection({ autoAuthenticate: false }); } catch (error) { console.error( '[WebViewProvider] Error in attemptAuthStateRestoration:', @@ -535,14 +537,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(); @@ -551,7 +558,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})`, + ); const bundledCliEntry = vscode.Uri.joinPath( this.extensionUri, @@ -561,25 +570,57 @@ export class WebViewProvider { ).fsPath; try { - console.log('[WebViewProvider] Connecting to bundled agent...'); - console.log('[WebViewProvider] Bundled CLI entry:', bundledCliEntry); + console.log('[WebViewProvider] Connecting to agent...'); - await this.agentManager.connect(workingDir, bundledCliEntry); + // Pass the detected CLI path to ensure we use the correct installation + const connectResult = await this.agentManager.connect( + workingDir, + bundledCliEntry, + options, + ); console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; - // Load messages from the current Qwen session - await this.loadCurrentSessionMessages(); + // If authentication is required and autoAuthenticate is false, + // send authState message and return without creating session + if (connectResult.requiresAuth && !autoAuthenticate) { + console.log( + '[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning', + ); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + // Initialize empty conversation to allow browsing history + await this.initializeEmptyConversation(); + return; + } - // Notify webview that agent is connected - this.sendMessageToWebView({ - type: 'agentConnected', - data: {}, - }); + if (connectResult.requiresAuth) { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } + + // Load messages from the current Qwen session + const sessionReady = await this.loadCurrentSessionMessages(options); + + 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( - `Failed to start bundled Qwen Code CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`, + `Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`, ); // Fallback to empty conversation await this.initializeEmptyConversation(); @@ -607,7 +648,6 @@ export class WebViewProvider { return vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: 'Logging in to Qwen Code... ', cancellable: false, }, async (progress) => { @@ -633,7 +673,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', ); @@ -716,7 +756,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)', @@ -727,22 +771,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(); @@ -755,7 +824,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..1b424e24 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -7,24 +7,56 @@ 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 (
{/* Qwen Logo */}
- Qwen Logo + {iconUri ? ( + Qwen Logo { + // Fallback to a div with text if image fails to load + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent) { + const fallback = document.createElement('div'); + fallback.className = + 'w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold'; + fallback.textContent = 'Q'; + parent.appendChild(fallback); + } + }} + /> + ) : ( +
+ Q +
+ )}
- What to do first? Ask about this codebase or we can start writing - code. + {description}
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 73f3fa26..86ba42be 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 (
@@ -179,10 +180,16 @@ 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) => { 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} @@ -281,7 +288,7 @@ export const InputForm: React.FC = ({ diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx new file mode 100644 index 00000000..2eddc4d3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface OnboardingPageProps { + onLogin: () => void; +} + +export const Onboarding: React.FC = ({ onLogin }) => { + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+
+ {/* Application icon container */} +
+ Qwen Code Logo +
+ +
+

+ Welcome to Qwen Code +

+

+ Unlock the power of AI to understand, navigate, and transform your + codebase faster than ever before. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx index ed8badcc..84712efa 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -75,7 +75,11 @@ export const AssistantMessage: React.FC = ({ whiteSpace: 'normal', }} > - +
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 1ee50b27..c8d507f2 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; } @@ -324,30 +350,42 @@ export const useWebViewMessages = ({ } case 'streamEnd': { - // Always end local streaming state and collapse any thoughts + // Always end local streaming state and clear thinking state handlers.messageHandling.endStreaming(); handlers.messageHandling.clearThinking(); - // If the stream ended due to explicit user cancel, proactively - // clear the waiting indicator and reset any tracked exec calls. - // This avoids the UI being stuck with the Stop button visible - // after rejecting a permission request. + // If stream ended due to explicit user cancellation, proactively clear + // waiting indicator and reset tracked execution calls. + // This avoids UI getting stuck with Stop button visible after + // rejecting a permission request. try { const reason = ( (message.data as { reason?: string } | undefined)?.reason || '' ).toLowerCase(); - if (reason === 'user_cancelled') { + + /** + * Handle different types of stream end reasons: + * - 'user_cancelled': User explicitly cancelled operation + * - 'cancelled': General cancellation + * For these cases, immediately clear all active states + */ + if (reason === 'user_cancelled' || reason === 'cancelled') { + // Clear active execution tool call tracking, reset state activeExecToolCallsRef.current.clear(); + // Clear waiting response state to ensure UI returns to normal handlers.messageHandling.clearWaitingForResponse(); break; } } catch (_error) { - // best-effort + // Best-effort handling, errors don't affect main flow } - // Otherwise, clear the generic waiting indicator only if there are - // no active long-running tool calls. If there are still active - // execute/bash/command calls, keep the hint visible. + /** + * For other types of stream end (non-user cancellation): + * Only clear generic waiting indicator when there are no active + * long-running tool calls. If there are still active execute/bash/command + * calls, keep the hint visible. + */ if (activeExecToolCallsRef.current.size === 0) { handlers.messageHandling.clearWaitingForResponse(); } @@ -548,15 +586,21 @@ export const useWebViewMessages = ({ // While long-running tools (e.g., execute/bash/command) are in progress, // surface a lightweight loading indicator and expose the Stop button. try { + const id = (toolCallData.toolCallId || '').toString(); const kind = (toolCallData.kind || '').toString().toLowerCase(); - const isExec = + const isExecKind = kind === 'execute' || kind === 'bash' || kind === 'command'; + // CLI sometimes omits kind in tool_call_update payloads; fall back to + // whether we've already tracked this ID as an exec tool. + const wasTrackedExec = activeExecToolCallsRef.current.has(id); + const isExec = isExecKind || wasTrackedExec; - if (isExec) { - const id = (toolCallData.toolCallId || '').toString(); + if (!isExec || !id) { + break; + } - // Maintain the active set by status - if (status === 'pending' || status === 'in_progress') { + if (status === 'pending' || status === 'in_progress') { + if (isExecKind) { activeExecToolCallsRef.current.add(id); // Build a helpful hint from rawInput @@ -570,14 +614,14 @@ export const useWebViewMessages = ({ } const hint = cmd ? `Running: ${cmd}` : 'Running command...'; handlers.messageHandling.setWaitingForResponse(hint); - } else if (status === 'completed' || status === 'failed') { - activeExecToolCallsRef.current.delete(id); } + } else if (status === 'completed' || status === 'failed') { + activeExecToolCallsRef.current.delete(id); + } - // If no active exec tool remains, clear the waiting message. - if (activeExecToolCallsRef.current.size === 0) { - handlers.messageHandling.clearWaitingForResponse(); - } + // If no active exec tool remains, clear the waiting message. + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); } } catch (_error) { // Best-effort UI hint; ignore errors 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 +}