feat(vscode-ide-companion): implement manual login via /login command

BREAKING CHANGE: Login is no longer automatic when opening webview

Changes:
- Remove auto-login on webview open and restore
- Add /login slash command for manual authentication
- Add VSCode progress notification during login process
- Add warning notification when user tries to chat without login
- Implement pending message auto-retry after successful login
- Add NotLoggedInMessage component (for future use)
- Improve InfoBanner close button styling consistency

User flow:
1. Open webview - no automatic login
2. Type /login or select from completion menu to login
3. Show "Logging in to Qwen Code..." progress notification
4. After login, show success message and auto-retry pending messages
5. If user tries to chat without login, show warning with "Login Now" button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yiliang114
2025-11-23 19:20:01 +08:00
parent e5729b0420
commit c4bcd178a4
6 changed files with 369 additions and 43 deletions

View File

@@ -43,6 +43,11 @@ export class WebViewProvider {
(message) => this.sendMessageToWebView(message),
);
// Set login handler for /login command
this.messageHandler.setLoginHandler(async () => {
await this.initializeAgentConnection();
});
// Setup agent callbacks
this.agentManager.onStreamChunk((chunk: string) => {
this.messageHandler.appendStreamContent(chunk);
@@ -168,9 +173,13 @@ export class WebViewProvider {
);
this.disposables.push(editorChangeDisposable);
// Initialize agent connection only once
// Don't auto-login; user must use /login command
// Just initialize empty conversation for the UI
if (!this.agentInitialized) {
await this.initializeAgentConnection();
console.log(
'[WebViewProvider] Agent not initialized, waiting for /login command',
);
await this.initializeEmptyConversation();
} else {
console.log(
'[WebViewProvider] Agent already initialized, reusing existing connection',
@@ -182,9 +191,9 @@ export class WebViewProvider {
/**
* Initialize agent connection and session
* Can be called from show() or restorePanel()
* Can be called from show() or via /login command
*/
private async initializeAgentConnection(): Promise<void> {
async initializeAgentConnection(): Promise<void> {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
@@ -383,14 +392,15 @@ export class WebViewProvider {
console.log('[WebViewProvider] Panel restored successfully');
// Initialize agent connection if not already done
// Don't auto-login on restore; user must use /login command
// Just initialize empty conversation for the UI
if (!this.agentInitialized) {
console.log(
'[WebViewProvider] Initializing agent connection after restore...',
'[WebViewProvider] Agent not initialized after restore, waiting for /login command',
);
this.initializeAgentConnection().catch((error) => {
this.initializeEmptyConversation().catch((error) => {
console.error(
'[WebViewProvider] Failed to initialize agent after restore:',
'[WebViewProvider] Failed to initialize empty conversation after restore:',
error,
);
});

View File

@@ -378,7 +378,42 @@ export const App: React.FC = () => {
const inputElement = inputFieldRef.current;
const currentText = inputElement.textContent || '';
if (item.type === 'file') {
if (item.type === 'command') {
// Handle /login command directly
if (item.label === '/login') {
// Clear input field
inputElement.textContent = '';
setInputText('');
// Close completion
completion.closeCompletion();
// Send login command to extension
vscode.postMessage({
type: 'login',
data: {},
});
return;
}
// For other commands, replace entire input with command
inputElement.textContent = item.label + ' ';
setInputText(item.label + ' ');
// Move cursor to end
setTimeout(() => {
const range = document.createRange();
const sel = window.getSelection();
if (inputElement.firstChild) {
range.setStart(inputElement.firstChild, (item.label + ' ').length);
range.collapse(true);
} else {
range.selectNodeContents(inputElement);
range.collapse(false);
}
sel?.removeAllRanges();
sel?.addRange(range);
inputElement.focus();
}, 10);
} else if (item.type === 'file') {
// Store file reference mapping
const filePath = (item.value as string) || item.label;
fileReferenceMap.current.set(item.label, filePath);
@@ -441,32 +476,12 @@ export const App: React.FC = () => {
inputElement.focus();
}, 10);
}
} else if (item.type === 'command') {
// Replace entire input with command
inputElement.textContent = item.label + ' ';
setInputText(item.label + ' ');
// Move cursor to end
setTimeout(() => {
const range = document.createRange();
const sel = window.getSelection();
if (inputElement.firstChild) {
range.setStart(inputElement.firstChild, (item.label + ' ').length);
range.collapse(true);
} else {
range.selectNodeContents(inputElement);
range.collapse(false);
}
sel?.removeAllRanges();
sel?.addRange(range);
inputElement.focus();
}, 10);
}
// Close completion
completion.closeCompletion();
},
[completion],
[completion, vscode],
);
// Handle attach context button click (Cmd/Ctrl + /)
@@ -642,6 +657,7 @@ export const App: React.FC = () => {
// Listen for messages from extension
const handleMessage = (event: MessageEvent) => {
const message = event.data;
// console.log('[App] Received message from extension:', message.type, message);
switch (message.type) {
case 'conversationLoaded': {
@@ -714,6 +730,18 @@ export const App: React.FC = () => {
setIsWaitingForResponse(false);
break;
// case 'notLoggedIn':
// // Show not logged in message with login button
// console.log('[App] Received notLoggedIn message:', message.data);
// setIsStreaming(false);
// setIsWaitingForResponse(false);
// setNotLoggedInMessage(
// (message.data as { message: string })?.message ||
// 'Please login to start chatting.',
// );
// console.log('[App] Set notLoggedInMessage to:', (message.data as { message: string })?.message);
// break;
case 'permissionRequest':
// Show permission dialog
handlePermissionRequest(message.data);
@@ -987,6 +1015,21 @@ export const App: React.FC = () => {
return;
}
// Check if this is a /login command
if (inputText.trim() === '/login') {
// Clear input field
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
// Send login command to extension
vscode.postMessage({
type: 'login',
data: {},
});
return;
}
// Set waiting state with random loading message
setIsWaitingForResponse(true);
setLoadingMessage(getRandomLoadingMessage());
@@ -1380,6 +1423,24 @@ export const App: React.FC = () => {
</div>
)}
{/* Not Logged In Message with Login Button - COMMENTED OUT */}
{/* {notLoggedInMessage && (
<>
{console.log('[App] Rendering NotLoggedInMessage with message:', notLoggedInMessage)}
<NotLoggedInMessage
message={notLoggedInMessage}
onLoginClick={() => {
setNotLoggedInMessage(null);
vscode.postMessage({
type: 'login',
data: {},
});
}}
onDismiss={() => setNotLoggedInMessage(null)}
/>
</>
)} */}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">

View File

@@ -287,6 +287,11 @@
/* Tool Call Styles */
--app-tool-background: var(--vscode-editor-background);
--app-code-background: var(--vscode-textCodeBlock-background);
/* Warning/Error Styles */
--app-warning-background: var(--vscode-editorWarning-background, rgba(255, 204, 0, 0.1));
--app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00);
--app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00);
}
/* ===========================

View File

@@ -27,6 +27,10 @@ export class MessageHandler {
}) => void;
// 当前消息列表
private messages: ChatMessage[] = [];
// 登录处理器
private loginHandler?: () => Promise<void>;
// 待发送消息(登录后自动重发)
private pendingMessage: string | null = null;
constructor(
private agentManager: QwenAgentManager,
@@ -35,6 +39,13 @@ export class MessageHandler {
private sendToWebView: (message: unknown) => void,
) {}
/**
* 设置登录处理器
*/
setLoginHandler(handler: () => Promise<void>): void {
this.loginHandler = handler;
}
/**
* 获取当前对话 ID
*/
@@ -211,6 +222,10 @@ export class MessageHandler {
await this.handleOpenSettings();
break;
case 'login':
await this.handleLogin();
break;
default:
console.warn('[MessageHandler] Unknown message type:', message.type);
break;
@@ -316,13 +331,33 @@ export class MessageHandler {
console.warn(
'[MessageHandler] Agent is not connected, skipping AI response',
);
this.sendToWebView({
type: 'error',
data: {
message:
'Agent is not connected. Enable Qwen in settings or configure API key.',
},
});
// Save pending message for auto-retry after login
this.pendingMessage = text;
console.log(
'[MessageHandler] Saved pending message for retry after login',
);
// Show VSCode warning notification
const result = await vscode.window.showWarningMessage(
'You need to login first to use Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
// Trigger login
await this.handleLogin();
}
// COMMENTED OUT: Send special error type to WebView for inline display
// console.log('[MessageHandler] Sending notLoggedIn message to webview');
// this.sendToWebView({
// type: 'notLoggedIn',
// data: {
// message: 'Please login to start chatting with Qwen Code.',
// },
// });
// console.log('[MessageHandler] notLoggedIn message sent');
return;
}
@@ -363,11 +398,44 @@ export class MessageHandler {
console.log('[MessageHandler] Stream end sent');
} catch (error) {
console.error('[MessageHandler] Error sending message:', error);
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: String(error) },
});
// Check if error is due to no active ACP session (not logged in)
const errorMsg = String(error);
if (errorMsg.includes('No active ACP session')) {
// Save pending message for auto-retry after login
this.pendingMessage = text;
console.log(
'[MessageHandler] Saved pending message for retry after login',
);
// Show VSCode warning notification with login option
const result = await vscode.window.showWarningMessage(
'You need to login first to use Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
// Trigger login
await this.handleLogin();
}
// COMMENTED OUT: Send special error type to WebView for inline display with login button
// console.log('[MessageHandler] Sending notLoggedIn message (session expired) to webview');
// this.sendToWebView({
// type: 'notLoggedIn',
// data: {
// message: 'Session expired. Please login to continue chatting.',
// },
// });
// console.log('[MessageHandler] notLoggedIn message sent');
} else {
// For other errors, show regular error message
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
}
}
}
@@ -946,4 +1014,58 @@ export class MessageHandler {
});
}
}
/**
* 处理登录请求
* 通过 /login 命令触发登录流程
*/
private async handleLogin(): Promise<void> {
try {
console.log('[MessageHandler] Login requested via /login command');
if (this.loginHandler) {
// Show progress notification in VSCode
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Logging in to Qwen Code...',
cancellable: false,
},
async () => {
await this.loginHandler!();
},
);
console.log('[MessageHandler] Login completed successfully');
// Show success notification
vscode.window.showInformationMessage(
'Successfully logged in to Qwen Code!',
);
// Auto-resend pending message if exists
if (this.pendingMessage) {
console.log(
'[MessageHandler] Auto-resending pending message after login',
);
const messageToSend = this.pendingMessage;
this.pendingMessage = null; // Clear pending message
// Resend the message
await this.handleSendMessage(messageToSend);
}
} else {
console.error('[MessageHandler] No login handler registered');
this.sendToWebView({
type: 'error',
data: { message: 'Login handler not available' },
});
}
} catch (error) {
console.error('[MessageHandler] Login failed:', error);
this.sendToWebView({
type: 'error',
data: { message: `Login failed: ${error}` },
});
}
}
}

View File

@@ -98,11 +98,13 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
{/* Close button */}
<button
className="flex items-center justify-center cursor-pointer p-1.5 rounded"
className="flex items-center justify-center cursor-pointer rounded"
style={{
background: 'none',
border: 'none',
padding: '6px',
color: 'var(--app-secondary-foreground)',
borderRadius: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =

View File

@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface NotLoggedInMessageProps {
/**
* The message to display
*/
message: string;
/**
* Callback when the login button is clicked
*/
onLoginClick: () => void;
/**
* Callback when the message is dismissed (optional)
*/
onDismiss?: () => void;
}
export const NotLoggedInMessage: React.FC<NotLoggedInMessageProps> = ({
message,
onLoginClick,
onDismiss,
}) => (
<div
className="flex items-start gap-3 p-4 my-4 rounded-lg"
style={{
backgroundColor: 'var(--app-warning-background)',
borderLeft: '3px solid var(--app-warning-border)',
}}
>
{/* Warning Icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="flex-shrink-0 w-5 h-5 mt-0.5"
style={{ color: 'var(--app-warning-foreground)' }}
>
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clipRule="evenodd"
/>
</svg>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className="m-0 mb-3 text-sm leading-relaxed"
style={{ color: 'var(--app-primary-foreground)' }}
>
{message}
</p>
{/* Login Button */}
<button
className="px-4 py-2 text-sm font-medium rounded transition-colors duration-200"
style={{
backgroundColor: 'var(--app-qwen-orange)',
color: 'white',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1';
}}
onClick={() => {
if (onDismiss) {
onDismiss();
}
onLoginClick();
}}
>
Login Now
</button>
</div>
{/* Optional Close Button */}
{onDismiss && (
<button
className="flex-shrink-0 flex items-center justify-center cursor-pointer rounded"
style={{
background: 'none',
border: 'none',
padding: '6px',
color: 'var(--app-secondary-foreground)',
borderRadius: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'var(--app-ghost-button-hover-background)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Dismiss"
onClick={onDismiss}
>
<svg
className="w-[10px] h-[10px]"
width="10"
height="10"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div>
);