mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge pull request #1248 from QwenLM/feat/vscode-ide-companion-borading
refactor(vscode-ide-companion): optimize CLI detection and version management
This commit is contained in:
@@ -292,7 +292,7 @@ class GeminiAgent {
|
|||||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||||
if (!selectedType) {
|
if (!selectedType) {
|
||||||
throw acp.RequestError.authRequired();
|
throw acp.RequestError.authRequired('No Selected Type');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -300,7 +300,9 @@ class GeminiAgent {
|
|||||||
await config.refreshAuth(selectedType, true);
|
await config.refreshAuth(selectedType, true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Authentication failed: ${e}`);
|
console.error(`Authentication failed: ${e}`);
|
||||||
throw acp.RequestError.authRequired();
|
throw acp.RequestError.authRequired(
|
||||||
|
'Authentication failed: ' + (e as Error).message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -677,6 +677,19 @@ async function authWithQwenDeviceFlow(
|
|||||||
// Cache the new tokens
|
// Cache the new tokens
|
||||||
await cacheQwenCredentials(credentials);
|
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
|
// Emit auth progress success event
|
||||||
qwenOAuth2Events.emit(
|
qwenOAuth2Events.emit(
|
||||||
QwenOAuth2Event.AuthProgress,
|
QwenOAuth2Event.AuthProgress,
|
||||||
@@ -880,6 +893,14 @@ export async function clearQwenCredentials(): Promise<void> {
|
|||||||
}
|
}
|
||||||
// Log other errors but don't throw - clearing credentials should be non-critical
|
// Log other errors but don't throw - clearing credentials should be non-critical
|
||||||
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
|
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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const AGENT_METHODS = {
|
|||||||
export const CLIENT_METHODS = {
|
export const CLIENT_METHODS = {
|
||||||
fs_read_text_file: 'fs/read_text_file',
|
fs_read_text_file: 'fs/read_text_file',
|
||||||
fs_write_text_file: 'fs/write_text_file',
|
fs_write_text_file: 'fs/write_text_file',
|
||||||
|
authenticate_update: 'authenticate/update',
|
||||||
session_request_permission: 'session/request_permission',
|
session_request_permission: 'session/request_permission',
|
||||||
session_update: 'session/update',
|
session_update: 'session/update',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AcpPermissionRequest,
|
AcpPermissionRequest,
|
||||||
AcpResponse,
|
AcpResponse,
|
||||||
AcpSessionUpdate,
|
AcpSessionUpdate,
|
||||||
|
AuthenticateUpdateNotification,
|
||||||
} from '../types/acpTypes.js';
|
} from '../types/acpTypes.js';
|
||||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import type { ChildProcess, SpawnOptions } from 'child_process';
|
import type { ChildProcess, SpawnOptions } from 'child_process';
|
||||||
@@ -42,6 +43,8 @@ export class AcpConnection {
|
|||||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
||||||
optionId: string;
|
optionId: string;
|
||||||
}> = () => Promise.resolve({ optionId: 'allow' });
|
}> = () => Promise.resolve({ optionId: 'allow' });
|
||||||
|
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
|
||||||
|
() => {};
|
||||||
onEndTurn: () => void = () => {};
|
onEndTurn: () => void = () => {};
|
||||||
// Called after successful initialize() with the initialize result
|
// Called after successful initialize() with the initialize result
|
||||||
onInitialized: (init: unknown) => void = () => {};
|
onInitialized: (init: unknown) => void = () => {};
|
||||||
@@ -207,6 +210,7 @@ export class AcpConnection {
|
|||||||
const callbacks: AcpConnectionCallbacks = {
|
const callbacks: AcpConnectionCallbacks = {
|
||||||
onSessionUpdate: this.onSessionUpdate,
|
onSessionUpdate: this.onSessionUpdate,
|
||||||
onPermissionRequest: this.onPermissionRequest,
|
onPermissionRequest: this.onPermissionRequest,
|
||||||
|
onAuthenticateUpdate: this.onAuthenticateUpdate,
|
||||||
onEndTurn: this.onEndTurn,
|
onEndTurn: this.onEndTurn,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
AcpResponse,
|
AcpResponse,
|
||||||
AcpSessionUpdate,
|
AcpSessionUpdate,
|
||||||
AcpPermissionRequest,
|
AcpPermissionRequest,
|
||||||
|
AuthenticateUpdateNotification,
|
||||||
} from '../types/acpTypes.js';
|
} from '../types/acpTypes.js';
|
||||||
import { CLIENT_METHODS } from '../constants/acpSchema.js';
|
import { CLIENT_METHODS } from '../constants/acpSchema.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -110,14 +111,21 @@ export class AcpMessageHandler {
|
|||||||
// JSON.stringify(message.result).substring(0, 200),
|
// JSON.stringify(message.result).substring(0, 200),
|
||||||
message.result,
|
message.result,
|
||||||
);
|
);
|
||||||
if (
|
|
||||||
message.result &&
|
if (message.result && typeof message.result === 'object') {
|
||||||
typeof message.result === 'object' &&
|
const stopReasonValue =
|
||||||
'stopReason' in message.result &&
|
(message.result as { stopReason?: unknown }).stopReason ??
|
||||||
message.result.stopReason === 'end_turn'
|
(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();
|
callbacks.onEndTurn();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
resolve(message.result);
|
resolve(message.result);
|
||||||
} else if ('error' in message) {
|
} else if ('error' in message) {
|
||||||
const errorCode = message.error?.code || 'unknown';
|
const errorCode = message.error?.code || 'unknown';
|
||||||
@@ -161,6 +169,15 @@ export class AcpMessageHandler {
|
|||||||
);
|
);
|
||||||
callbacks.onSessionUpdate(params as AcpSessionUpdate);
|
callbacks.onSessionUpdate(params as AcpSessionUpdate);
|
||||||
break;
|
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:
|
case CLIENT_METHODS.session_request_permission:
|
||||||
result = await this.handlePermissionRequest(
|
result = await this.handlePermissionRequest(
|
||||||
params as AcpPermissionRequest,
|
params as AcpPermissionRequest,
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ export class AcpSessionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeoutDuration =
|
// different timeout durations based on methods
|
||||||
method === AGENT_METHODS.session_prompt ? 120000 : 60000;
|
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(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
pendingRequests.delete(id);
|
pendingRequests.delete(id);
|
||||||
@@ -163,7 +169,7 @@ export class AcpSessionManager {
|
|||||||
pendingRequests,
|
pendingRequests,
|
||||||
nextRequestId,
|
nextRequestId,
|
||||||
);
|
);
|
||||||
console.log('[ACP] Authenticate successful');
|
console.log('[ACP] Authenticate successful', response);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AcpConnection } from './acpConnection.js';
|
|||||||
import type {
|
import type {
|
||||||
AcpSessionUpdate,
|
AcpSessionUpdate,
|
||||||
AcpPermissionRequest,
|
AcpPermissionRequest,
|
||||||
|
AuthenticateUpdateNotification,
|
||||||
} from '../types/acpTypes.js';
|
} from '../types/acpTypes.js';
|
||||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
||||||
@@ -17,9 +18,14 @@ import type {
|
|||||||
ToolCallUpdateData,
|
ToolCallUpdateData,
|
||||||
QwenAgentCallbacks,
|
QwenAgentCallbacks,
|
||||||
} from '../types/chatTypes.js';
|
} from '../types/chatTypes.js';
|
||||||
import { QwenConnectionHandler } from '../services/qwenConnectionHandler.js';
|
import {
|
||||||
|
QwenConnectionHandler,
|
||||||
|
type QwenConnectionResult,
|
||||||
|
} from '../services/qwenConnectionHandler.js';
|
||||||
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
||||||
import { authMethod } from '../types/acpTypes.js';
|
import { authMethod } from '../types/acpTypes.js';
|
||||||
|
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
|
||||||
|
import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js';
|
||||||
|
|
||||||
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
|
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
|
||||||
|
|
||||||
@@ -28,6 +34,13 @@ export type { ChatMessage, PlanEntry, ToolCallUpdateData };
|
|||||||
*
|
*
|
||||||
* Coordinates various modules and provides unified interface
|
* Coordinates various modules and provides unified interface
|
||||||
*/
|
*/
|
||||||
|
interface AgentConnectOptions {
|
||||||
|
autoAuthenticate?: boolean;
|
||||||
|
}
|
||||||
|
interface AgentSessionOptions {
|
||||||
|
autoAuthenticate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class QwenAgentManager {
|
export class QwenAgentManager {
|
||||||
private connection: AcpConnection;
|
private connection: AcpConnection;
|
||||||
private sessionReader: QwenSessionReader;
|
private sessionReader: QwenSessionReader;
|
||||||
@@ -117,10 +130,10 @@ export class QwenAgentManager {
|
|||||||
return { optionId: 'allow_once' };
|
return { optionId: 'allow_once' };
|
||||||
};
|
};
|
||||||
|
|
||||||
this.connection.onEndTurn = () => {
|
this.connection.onEndTurn = (reason?: string) => {
|
||||||
try {
|
try {
|
||||||
if (this.callbacks.onEndTurn) {
|
if (this.callbacks.onEndTurn) {
|
||||||
this.callbacks.onEndTurn();
|
this.callbacks.onEndTurn(reason);
|
||||||
} else if (this.callbacks.onStreamChunk) {
|
} else if (this.callbacks.onStreamChunk) {
|
||||||
// Fallback: send a zero-length chunk then rely on streamEnd elsewhere
|
// Fallback: send a zero-length chunk then rely on streamEnd elsewhere
|
||||||
this.callbacks.onStreamChunk('');
|
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
|
// Initialize callback to surface available modes and current mode to UI
|
||||||
this.connection.onInitialized = (init: unknown) => {
|
this.connection.onInitialized = (init: unknown) => {
|
||||||
try {
|
try {
|
||||||
@@ -162,13 +189,17 @@ export class QwenAgentManager {
|
|||||||
* @param workingDir - Working directory
|
* @param workingDir - Working directory
|
||||||
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
|
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
|
||||||
*/
|
*/
|
||||||
async connect(workingDir: string, cliEntryPath: string): Promise<void> {
|
async connect(
|
||||||
|
workingDir: string,
|
||||||
|
cliEntryPath: string,
|
||||||
|
options?: AgentConnectOptions,
|
||||||
|
): Promise<QwenConnectionResult> {
|
||||||
this.currentWorkingDir = workingDir;
|
this.currentWorkingDir = workingDir;
|
||||||
await this.connectionHandler.connect(
|
return this.connectionHandler.connect(
|
||||||
this.connection,
|
this.connection,
|
||||||
this.sessionReader,
|
|
||||||
workingDir,
|
workingDir,
|
||||||
cliEntryPath,
|
cliEntryPath,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +281,10 @@ export class QwenAgentManager {
|
|||||||
'[QwenAgentManager] Getting session list with version-aware strategy',
|
'[QwenAgentManager] Getting session list with version-aware strategy',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefer ACP method first; fall back to file system if it fails for any reason.
|
|
||||||
try {
|
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();
|
const response = await this.connection.listSessions();
|
||||||
console.log('[QwenAgentManager] ACP session list response:', response);
|
console.log('[QwenAgentManager] ACP session list response:', response);
|
||||||
|
|
||||||
@@ -262,19 +294,21 @@ export class QwenAgentManager {
|
|||||||
const res: unknown = response;
|
const res: unknown = response;
|
||||||
let items: Array<Record<string, unknown>> = [];
|
let items: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
if (Array.isArray(res)) {
|
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
|
||||||
items = res as Array<Record<string, unknown>>;
|
// "result" directly (not the full AcpResponse). Treat it as unknown
|
||||||
} else if (res && typeof res === 'object' && 'items' in res) {
|
// 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;
|
const itemsValue = (res as { items?: unknown }).items;
|
||||||
items = Array.isArray(itemsValue)
|
items = Array.isArray(itemsValue)
|
||||||
? (itemsValue as Array<Record<string, unknown>>)
|
? (itemsValue as Array<Record<string, unknown>>)
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[QwenAgentManager] Sessions retrieved via ACP:', {
|
console.log(
|
||||||
count: items.length,
|
'[QwenAgentManager] Sessions retrieved via ACP:',
|
||||||
});
|
res,
|
||||||
|
items.length,
|
||||||
|
);
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
const sessions = items.map((item) => ({
|
const sessions = items.map((item) => ({
|
||||||
id: item.sessionId || item.id,
|
id: item.sessionId || item.id,
|
||||||
@@ -288,6 +322,11 @@ export class QwenAgentManager {
|
|||||||
filePath: item.filePath,
|
filePath: item.filePath,
|
||||||
cwd: item.cwd,
|
cwd: item.cwd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[QwenAgentManager] Sessions retrieved via ACP:',
|
||||||
|
sessions.length,
|
||||||
|
);
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -350,6 +389,7 @@ export class QwenAgentManager {
|
|||||||
}> {
|
}> {
|
||||||
const size = params?.size ?? 20;
|
const size = params?.size ?? 20;
|
||||||
const cursor = params?.cursor;
|
const cursor = params?.cursor;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.connection.listSessions({
|
const response = await this.connection.listSessions({
|
||||||
size,
|
size,
|
||||||
@@ -444,7 +484,6 @@ export class QwenAgentManager {
|
|||||||
*/
|
*/
|
||||||
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||||
try {
|
try {
|
||||||
// Prefer reading CLI's JSONL if we can find filePath from session/list
|
|
||||||
try {
|
try {
|
||||||
const list = await this.getSessionList();
|
const list = await this.getSessionList();
|
||||||
const item = list.find(
|
const item = list.find(
|
||||||
@@ -664,7 +703,9 @@ export class QwenAgentManager {
|
|||||||
const planText = planEntries
|
const planText = planEntries
|
||||||
.map(
|
.map(
|
||||||
(entry: Record<string, unknown>, index: number) =>
|
(entry: Record<string, unknown>, index: number) =>
|
||||||
`${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`,
|
`${index + 1}. ${
|
||||||
|
entry.description || entry.title || 'Unnamed step'
|
||||||
|
}`,
|
||||||
)
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
msgs.push({
|
msgs.push({
|
||||||
@@ -943,13 +984,15 @@ export class QwenAgentManager {
|
|||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefer ACP session/load first; fall back to file system on failure.
|
|
||||||
try {
|
try {
|
||||||
console.log('[QwenAgentManager] Attempting to load session via ACP');
|
console.log(
|
||||||
|
'[QwenAgentManager] Attempting to load session via ACP method',
|
||||||
|
);
|
||||||
await this.loadSessionViaAcp(sessionId);
|
await this.loadSessionViaAcp(sessionId);
|
||||||
console.log('[QwenAgentManager] Session loaded successfully via ACP');
|
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) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
|
'[QwenAgentManager] ACP session load failed, falling back to file system method:',
|
||||||
@@ -1030,7 +1073,11 @@ export class QwenAgentManager {
|
|||||||
* @param workingDir - Working directory
|
* @param workingDir - Working directory
|
||||||
* @returns Newly created session ID
|
* @returns Newly created session ID
|
||||||
*/
|
*/
|
||||||
async createNewSession(workingDir: string): Promise<string | null> {
|
async createNewSession(
|
||||||
|
workingDir: string,
|
||||||
|
options?: AgentSessionOptions,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const autoAuthenticate = options?.autoAuthenticate ?? true;
|
||||||
// Reuse existing session if present
|
// Reuse existing session if present
|
||||||
if (this.connection.currentSessionId) {
|
if (this.connection.currentSessionId) {
|
||||||
return this.connection.currentSessionId;
|
return this.connection.currentSessionId;
|
||||||
@@ -1048,18 +1095,24 @@ export class QwenAgentManager {
|
|||||||
try {
|
try {
|
||||||
await this.connection.newSession(workingDir);
|
await this.connection.newSession(workingDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const requiresAuth = isAuthenticationRequiredError(err);
|
||||||
const requiresAuth =
|
|
||||||
msg.includes('Authentication required') ||
|
|
||||||
msg.includes('(code: -32000)');
|
|
||||||
|
|
||||||
if (requiresAuth) {
|
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(
|
console.warn(
|
||||||
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
|
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
// Let CLI handle authentication - it's the single source of truth
|
// Let CLI handle authentication - it's the single source of truth
|
||||||
await this.connection.authenticate(authMethod);
|
await this.connection.authenticate(authMethod);
|
||||||
|
console.log(
|
||||||
|
'[QwenAgentManager] createNewSession Authentication successful. Retrying session/new...',
|
||||||
|
);
|
||||||
// Add a slight delay to ensure auth state is settled
|
// Add a slight delay to ensure auth state is settled
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
await this.connection.newSession(workingDir);
|
await this.connection.newSession(workingDir);
|
||||||
@@ -1170,9 +1223,9 @@ export class QwenAgentManager {
|
|||||||
/**
|
/**
|
||||||
* Register end-of-turn callback
|
* 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.callbacks.onEndTurn = callback;
|
||||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AcpConnection } from './acpConnection.js';
|
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';
|
import { authMethod } from '../types/acpTypes.js';
|
||||||
|
|
||||||
|
export interface QwenConnectionResult {
|
||||||
|
sessionCreated: boolean;
|
||||||
|
requiresAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Qwen Connection Handler class
|
* Qwen Connection Handler class
|
||||||
* Handles connection, authentication, and session initialization
|
* Handles connection, authentication, and session initialization
|
||||||
@@ -23,23 +28,27 @@ export class QwenConnectionHandler {
|
|||||||
* Connect to Qwen service and establish session
|
* Connect to Qwen service and establish session
|
||||||
*
|
*
|
||||||
* @param connection - ACP connection instance
|
* @param connection - ACP connection instance
|
||||||
* @param sessionReader - Session reader instance
|
|
||||||
* @param workingDir - Working directory
|
* @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(
|
async connect(
|
||||||
connection: AcpConnection,
|
connection: AcpConnection,
|
||||||
sessionReader: QwenSessionReader,
|
|
||||||
workingDir: string,
|
workingDir: string,
|
||||||
cliEntryPath: string,
|
cliEntryPath: string,
|
||||||
): Promise<void> {
|
options?: {
|
||||||
|
autoAuthenticate?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<QwenConnectionResult> {
|
||||||
const connectId = Date.now();
|
const connectId = Date.now();
|
||||||
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
|
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)
|
// Build extra CLI arguments (only essential parameters)
|
||||||
const extraArgs: string[] = [];
|
const extraArgs: string[] = [];
|
||||||
|
|
||||||
await connection.connect(cliEntryPath, workingDir, extraArgs);
|
await connection.connect(cliEntryPath!, workingDir, extraArgs);
|
||||||
|
|
||||||
// Try to restore existing session or create new session
|
// Try to restore existing session or create new session
|
||||||
// Note: Auto-restore on connect is disabled to avoid surprising loads
|
// Note: Auto-restore on connect is disabled to avoid surprising loads
|
||||||
@@ -57,18 +66,40 @@ export class QwenConnectionHandler {
|
|||||||
console.log(
|
console.log(
|
||||||
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
|
'[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');
|
console.log('[QwenAgentManager] New session created successfully');
|
||||||
|
sessionCreated = true;
|
||||||
} catch (sessionError) {
|
} catch (sessionError) {
|
||||||
console.log(`\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`);
|
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);
|
console.log(`[QwenAgentManager] Error details:`, sessionError);
|
||||||
throw sessionError;
|
throw sessionError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
sessionCreated = true;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n========================================`);
|
console.log(`\n========================================`);
|
||||||
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
|
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
|
||||||
console.log(`========================================\n`);
|
console.log(`========================================\n`);
|
||||||
|
return { sessionCreated, requiresAuth };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +114,7 @@ export class QwenConnectionHandler {
|
|||||||
workingDir: string,
|
workingDir: string,
|
||||||
maxRetries: number,
|
maxRetries: number,
|
||||||
authMethod: string,
|
authMethod: string,
|
||||||
|
autoAuthenticate: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -102,10 +134,14 @@ export class QwenConnectionHandler {
|
|||||||
|
|
||||||
// If Qwen reports that authentication is required, try to
|
// If Qwen reports that authentication is required, try to
|
||||||
// authenticate on-the-fly once and retry without waiting.
|
// authenticate on-the-fly once and retry without waiting.
|
||||||
const requiresAuth =
|
const requiresAuth = isAuthenticationRequiredError(error);
|
||||||
errorMessage.includes('Authentication required') ||
|
|
||||||
errorMessage.includes('(code: -32000)');
|
|
||||||
if (requiresAuth) {
|
if (requiresAuth) {
|
||||||
|
if (!autoAuthenticate) {
|
||||||
|
console.log(
|
||||||
|
'[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
|
'[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
|
// newSession may cause the cli authorization jump to be triggered again
|
||||||
// Add a slight delay to ensure auth state is settled
|
// Add a slight delay to ensure auth state is settled
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
console.log(
|
||||||
|
'[QwenAgentManager] newSessionWithRetry Authentication successful',
|
||||||
|
);
|
||||||
// Retry immediately after successful auth
|
// Retry immediately after successful auth
|
||||||
await connection.newSession(workingDir);
|
await connection.newSession(workingDir);
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -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 =
|
export type AcpSessionUpdate =
|
||||||
| UserMessageChunkUpdate
|
| UserMessageChunkUpdate
|
||||||
| AgentMessageChunkUpdate
|
| AgentMessageChunkUpdate
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface QwenAgentCallbacks {
|
|||||||
onToolCall?: (update: ToolCallUpdateData) => void;
|
onToolCall?: (update: ToolCallUpdateData) => void;
|
||||||
onPlan?: (entries: PlanEntry[]) => void;
|
onPlan?: (entries: PlanEntry[]) => void;
|
||||||
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
||||||
onEndTurn?: () => void;
|
onEndTurn?: (reason?: string) => void;
|
||||||
onModeInfo?: (info: {
|
onModeInfo?: (info: {
|
||||||
currentModeId?: ApprovalModeValue;
|
currentModeId?: ApprovalModeValue;
|
||||||
availableModes?: Array<{
|
availableModes?: Array<{
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChildProcess } from 'child_process';
|
import type { ChildProcess } from 'child_process';
|
||||||
import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js';
|
import type {
|
||||||
|
AcpSessionUpdate,
|
||||||
|
AcpPermissionRequest,
|
||||||
|
AuthenticateUpdateNotification,
|
||||||
|
} from './acpTypes.js';
|
||||||
|
|
||||||
export interface PendingRequest<T = unknown> {
|
export interface PendingRequest<T = unknown> {
|
||||||
resolve: (value: T) => void;
|
resolve: (value: T) => void;
|
||||||
@@ -19,7 +23,8 @@ export interface AcpConnectionCallbacks {
|
|||||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
||||||
optionId: string;
|
optionId: string;
|
||||||
}>;
|
}>;
|
||||||
onEndTurn: () => void;
|
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
|
||||||
|
onEndTurn: (reason?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcpConnectionState {
|
export interface AcpConnectionState {
|
||||||
|
|||||||
34
packages/vscode-ide-companion/src/utils/authErrors.ts
Normal file
34
packages/vscode-ide-companion/src/utils/authErrors.ts
Normal file
@@ -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));
|
||||||
|
};
|
||||||
@@ -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<string | undefined> | 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer
|
|||||||
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
|
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
|
||||||
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
|
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
|
||||||
import { EmptyState } from './components/layout/EmptyState.js';
|
import { EmptyState } from './components/layout/EmptyState.js';
|
||||||
|
import { Onboarding } from './components/layout/Onboarding.js';
|
||||||
import { type CompletionItem } from '../types/completionItemTypes.js';
|
import { type CompletionItem } from '../types/completionItemTypes.js';
|
||||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||||
import { ChatHeader } from './components/layout/ChatHeader.js';
|
import { ChatHeader } from './components/layout/ChatHeader.js';
|
||||||
@@ -67,6 +68,8 @@ export const App: React.FC = () => {
|
|||||||
toolCall: PermissionToolCall;
|
toolCall: PermissionToolCall;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(
|
const messagesEndRef = useRef<HTMLDivElement>(
|
||||||
null,
|
null,
|
||||||
) as React.RefObject<HTMLDivElement>;
|
) as React.RefObject<HTMLDivElement>;
|
||||||
@@ -201,6 +204,7 @@ export const App: React.FC = () => {
|
|||||||
vscode,
|
vscode,
|
||||||
inputFieldRef,
|
inputFieldRef,
|
||||||
isStreaming: messageHandling.isStreaming,
|
isStreaming: messageHandling.isStreaming,
|
||||||
|
isWaitingForResponse: messageHandling.isWaitingForResponse,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle cancel/stop from the input bar
|
// Handle cancel/stop from the input bar
|
||||||
@@ -243,6 +247,7 @@ export const App: React.FC = () => {
|
|||||||
inputFieldRef,
|
inputFieldRef,
|
||||||
setInputText,
|
setInputText,
|
||||||
setEditMode,
|
setEditMode,
|
||||||
|
setIsAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||||
@@ -356,6 +361,14 @@ export const App: React.FC = () => {
|
|||||||
completedToolCalls,
|
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
|
// Handle permission response
|
||||||
const handlePermissionResponse = useCallback(
|
const handlePermissionResponse = useCallback(
|
||||||
(optionId: string) => {
|
(optionId: string) => {
|
||||||
@@ -662,7 +675,19 @@ export const App: React.FC = () => {
|
|||||||
allMessages.length > 0;
|
allMessages.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-container">
|
<div className="chat-container relative">
|
||||||
|
{/* Top-level loading overlay */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Preparing Qwen Code...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SessionSelector
|
<SessionSelector
|
||||||
visible={sessionManagement.showSessionSelector}
|
visible={sessionManagement.showSessionSelector}
|
||||||
sessions={sessionManagement.filteredSessions}
|
sessions={sessionManagement.filteredSessions}
|
||||||
@@ -687,31 +712,43 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
||||||
>
|
>
|
||||||
{!hasContent ? (
|
{!hasContent && !isLoading ? (
|
||||||
<EmptyState />
|
isAuthenticated === false ? (
|
||||||
|
<Onboarding
|
||||||
|
onLogin={() => {
|
||||||
|
vscode.postMessage({ type: 'login', data: {} });
|
||||||
|
messageHandling.setWaitingForResponse(
|
||||||
|
'Logging in to Qwen Code...',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : isAuthenticated === null ? (
|
||||||
|
<EmptyState loadingMessage="Checking login status…" />
|
||||||
|
) : (
|
||||||
|
<EmptyState isAuthenticated />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Render all messages and tool calls */}
|
{/* Render all messages and tool calls */}
|
||||||
{renderMessages()}
|
{renderMessages()}
|
||||||
{/* Flow-in persistent slot: keeps a small constant height so toggling */}
|
|
||||||
{/* the waiting message doesn't change list height to zero. When */}
|
{/* Waiting message positioned fixed above the input form to avoid layout shifts */}
|
||||||
{/* active, render the waiting message inline (not fixed). */}
|
|
||||||
<div className="waiting-message-slot min-h-[28px]">
|
|
||||||
{messageHandling.isWaitingForResponse &&
|
{messageHandling.isWaitingForResponse &&
|
||||||
messageHandling.loadingMessage && (
|
messageHandling.loadingMessage && (
|
||||||
|
<div className="waiting-message-slot min-h-[28px]">
|
||||||
<WaitingMessage
|
<WaitingMessage
|
||||||
loadingMessage={messageHandling.loadingMessage}
|
loadingMessage={messageHandling.loadingMessage}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
<InputForm
|
<InputForm
|
||||||
inputText={inputText}
|
inputText={inputText}
|
||||||
inputFieldRef={inputFieldRef}
|
inputFieldRef={inputFieldRef}
|
||||||
@@ -758,7 +795,8 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[App] Error getting cursor position:', error);
|
console.error('[App] Error getting cursor position:', error);
|
||||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
const inputRect =
|
||||||
|
inputFieldRef.current.getBoundingClientRect();
|
||||||
position = { top: inputRect.top, left: inputRect.left };
|
position = { top: inputRect.top, left: inputRect.left };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -775,8 +813,9 @@ export const App: React.FC = () => {
|
|||||||
onCompletionSelect={handleCompletionSelect}
|
onCompletionSelect={handleCompletionSelect}
|
||||||
onCompletionClose={completion.closeCompletion}
|
onCompletionClose={completion.closeCompletion}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{permissionRequest && (
|
{isAuthenticated && permissionRequest && (
|
||||||
<PermissionDrawer
|
<PermissionDrawer
|
||||||
isOpen={!!permissionRequest}
|
isOpen={!!permissionRequest}
|
||||||
options={permissionRequest.options}
|
options={permissionRequest.options}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MessageHandler } from '../webview/MessageHandler.js';
|
|||||||
import { WebViewContent } from '../webview/WebViewContent.js';
|
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||||
import { getFileName } from './utils/webviewUtils.js';
|
import { getFileName } from './utils/webviewUtils.js';
|
||||||
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
|
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
|
||||||
|
|
||||||
export class WebViewProvider {
|
export class WebViewProvider {
|
||||||
private panelManager: PanelManager;
|
private panelManager: PanelManager;
|
||||||
@@ -30,7 +31,7 @@ export class WebViewProvider {
|
|||||||
private currentModeId: ApprovalModeValue | null = null;
|
private currentModeId: ApprovalModeValue | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
context: vscode.ExtensionContext,
|
private context: vscode.ExtensionContext,
|
||||||
private extensionUri: vscode.Uri,
|
private extensionUri: vscode.Uri,
|
||||||
) {
|
) {
|
||||||
this.agentManager = new QwenAgentManager();
|
this.agentManager = new QwenAgentManager();
|
||||||
@@ -117,12 +118,15 @@ export class WebViewProvider {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup end-turn handler from ACP stopReason=end_turn
|
// Setup end-turn handler from ACP stopReason notifications
|
||||||
this.agentManager.onEndTurn(() => {
|
this.agentManager.onEndTurn((reason) => {
|
||||||
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
|
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'streamEnd',
|
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<void> {
|
private async attemptAuthStateRestoration(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log('[WebViewProvider] Attempting connection...');
|
||||||
'[WebViewProvider] Attempting connection (CLI handle authentication)...',
|
// Attempt a connection to detect prior auth without forcing login
|
||||||
);
|
await this.initializeAgentConnection({ autoAuthenticate: false });
|
||||||
//always attempt connection and let CLI handle authentication
|
|
||||||
await this.initializeAgentConnection();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[WebViewProvider] Error in attemptAuthStateRestoration:',
|
'[WebViewProvider] Error in attemptAuthStateRestoration:',
|
||||||
@@ -535,14 +537,19 @@ export class WebViewProvider {
|
|||||||
* Initialize agent connection and session
|
* Initialize agent connection and session
|
||||||
* Can be called from show() or via /login command
|
* Can be called from show() or via /login command
|
||||||
*/
|
*/
|
||||||
async initializeAgentConnection(): Promise<void> {
|
async initializeAgentConnection(options?: {
|
||||||
return this.doInitializeAgentConnection();
|
autoAuthenticate?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
return this.doInitializeAgentConnection(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: perform actual connection/initialization (no auth locking).
|
* Internal: perform actual connection/initialization (no auth locking).
|
||||||
*/
|
*/
|
||||||
private async doInitializeAgentConnection(): Promise<void> {
|
private async doInitializeAgentConnection(options?: {
|
||||||
|
autoAuthenticate?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const autoAuthenticate = options?.autoAuthenticate ?? true;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||||
@@ -551,7 +558,9 @@ export class WebViewProvider {
|
|||||||
'[WebViewProvider] Starting initialization, workingDir:',
|
'[WebViewProvider] Starting initialization, workingDir:',
|
||||||
workingDir,
|
workingDir,
|
||||||
);
|
);
|
||||||
console.log('[WebViewProvider] Using CLI-managed authentication');
|
console.log(
|
||||||
|
`[WebViewProvider] Using CLI-managed authentication (autoAuth=${autoAuthenticate})`,
|
||||||
|
);
|
||||||
|
|
||||||
const bundledCliEntry = vscode.Uri.joinPath(
|
const bundledCliEntry = vscode.Uri.joinPath(
|
||||||
this.extensionUri,
|
this.extensionUri,
|
||||||
@@ -561,25 +570,57 @@ export class WebViewProvider {
|
|||||||
).fsPath;
|
).fsPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[WebViewProvider] Connecting to bundled agent...');
|
console.log('[WebViewProvider] Connecting to agent...');
|
||||||
console.log('[WebViewProvider] Bundled CLI entry:', bundledCliEntry);
|
|
||||||
|
|
||||||
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');
|
console.log('[WebViewProvider] Agent connected successfully');
|
||||||
this.agentInitialized = true;
|
this.agentInitialized = true;
|
||||||
|
|
||||||
// Load messages from the current Qwen session
|
// If authentication is required and autoAuthenticate is false,
|
||||||
await this.loadCurrentSessionMessages();
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Notify webview that agent is connected
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'agentConnected',
|
type: 'agentConnected',
|
||||||
data: {},
|
data: {},
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'[WebViewProvider] Session creation deferred until user logs in.',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.error('[WebViewProvider] Agent connection error:', _error);
|
console.error('[WebViewProvider] Agent connection error:', _error);
|
||||||
vscode.window.showWarningMessage(
|
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
|
// Fallback to empty conversation
|
||||||
await this.initializeEmptyConversation();
|
await this.initializeEmptyConversation();
|
||||||
@@ -607,7 +648,6 @@ export class WebViewProvider {
|
|||||||
return vscode.window.withProgress(
|
return vscode.window.withProgress(
|
||||||
{
|
{
|
||||||
location: vscode.ProgressLocation.Notification,
|
location: vscode.ProgressLocation.Notification,
|
||||||
title: 'Logging in to Qwen Code... ',
|
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
},
|
},
|
||||||
async (progress) => {
|
async (progress) => {
|
||||||
@@ -633,7 +673,7 @@ export class WebViewProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reinitialize connection (will trigger fresh authentication)
|
// Reinitialize connection (will trigger fresh authentication)
|
||||||
await this.doInitializeAgentConnection();
|
await this.doInitializeAgentConnection({ autoAuthenticate: true });
|
||||||
console.log(
|
console.log(
|
||||||
'[WebViewProvider] Force re-login completed successfully',
|
'[WebViewProvider] Force re-login completed successfully',
|
||||||
);
|
);
|
||||||
@@ -716,7 +756,11 @@ export class WebViewProvider {
|
|||||||
* Load messages from current Qwen session
|
* Load messages from current Qwen session
|
||||||
* Skips session restoration and creates a new session directly
|
* Skips session restoration and creates a new session directly
|
||||||
*/
|
*/
|
||||||
private async loadCurrentSessionMessages(): Promise<void> {
|
private async loadCurrentSessionMessages(options?: {
|
||||||
|
autoAuthenticate?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const autoAuthenticate = options?.autoAuthenticate ?? true;
|
||||||
|
let sessionReady = false;
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
'[WebViewProvider] Initializing with new session (skipping restoration)',
|
'[WebViewProvider] Initializing with new session (skipping restoration)',
|
||||||
@@ -727,10 +771,32 @@ export class WebViewProvider {
|
|||||||
|
|
||||||
// avoid creating another session if connect() already created one.
|
// avoid creating another session if connect() already created one.
|
||||||
if (!this.agentManager.currentSessionId) {
|
if (!this.agentManager.currentSessionId) {
|
||||||
|
if (!autoAuthenticate) {
|
||||||
|
console.log(
|
||||||
|
'[WebViewProvider] Skipping ACP session creation until user logs in.',
|
||||||
|
);
|
||||||
|
this.sendMessageToWebView({
|
||||||
|
type: 'authState',
|
||||||
|
data: { authenticated: false },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
await this.agentManager.createNewSession(workingDir);
|
await this.agentManager.createNewSession(workingDir, {
|
||||||
|
autoAuthenticate,
|
||||||
|
});
|
||||||
console.log('[WebViewProvider] ACP session created successfully');
|
console.log('[WebViewProvider] ACP session created successfully');
|
||||||
|
sessionReady = true;
|
||||||
} catch (sessionError) {
|
} 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(
|
console.error(
|
||||||
'[WebViewProvider] Failed to create ACP session:',
|
'[WebViewProvider] Failed to create ACP session:',
|
||||||
sessionError,
|
sessionError,
|
||||||
@@ -739,10 +805,13 @@ export class WebViewProvider {
|
|||||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
'[WebViewProvider] Existing ACP session detected, skipping new session creation',
|
'[WebViewProvider] Existing ACP session detected, skipping new session creation',
|
||||||
);
|
);
|
||||||
|
sessionReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.initializeEmptyConversation();
|
await this.initializeEmptyConversation();
|
||||||
@@ -755,7 +824,10 @@ export class WebViewProvider {
|
|||||||
`Failed to load session messages: ${_error}`,
|
`Failed to load session messages: ${_error}`,
|
||||||
);
|
);
|
||||||
await this.initializeEmptyConversation();
|
await this.initializeEmptyConversation();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sessionReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,24 +7,56 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { generateIconUrl } from '../../utils/resourceUrl.js';
|
import { generateIconUrl } from '../../utils/resourceUrl.js';
|
||||||
|
|
||||||
export const EmptyState: React.FC = () => {
|
interface EmptyStateProps {
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
loadingMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
isAuthenticated = false,
|
||||||
|
loadingMessage,
|
||||||
|
}) => {
|
||||||
// Generate icon URL using the utility function
|
// Generate icon URL using the utility function
|
||||||
const iconUri = generateIconUrl('icon.png');
|
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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
|
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
|
||||||
<div className="flex flex-col items-center gap-8 w-full">
|
<div className="flex flex-col items-center gap-8 w-full">
|
||||||
{/* Qwen Logo */}
|
{/* Qwen Logo */}
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
{iconUri ? (
|
||||||
<img
|
<img
|
||||||
src={iconUri}
|
src={iconUri}
|
||||||
alt="Qwen Logo"
|
alt="Qwen Logo"
|
||||||
className="w-[60px] h-[60px] object-contain"
|
className="w-[60px] h-[60px] object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold bg-gray-200 rounded">
|
||||||
|
Q
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
|
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
|
||||||
What to do first? Ask about this codebase or we can start writing
|
{description}
|
||||||
code.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
onCompletionClose,
|
onCompletionClose,
|
||||||
}) => {
|
}) => {
|
||||||
const editModeInfo = getEditModeInfo(editMode);
|
const editModeInfo = getEditModeInfo(editMode);
|
||||||
|
const composerDisabled = isStreaming || isWaitingForResponse;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// ESC should cancel the current interaction (stop generation)
|
// ESC should cancel the current interaction (stop generation)
|
||||||
@@ -144,7 +145,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="p-1 px-4 pb-4"
|
className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0"
|
||||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
||||||
>
|
>
|
||||||
<div className="block">
|
<div className="block">
|
||||||
@@ -179,10 +180,16 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
data-placeholder="Ask Qwen Code …"
|
data-placeholder="Ask Qwen Code …"
|
||||||
// Use a data flag so CSS can show placeholder even if the browser
|
// Use a data flag so CSS can show placeholder even if the browser
|
||||||
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
|
// inserts an invisible <br> 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) => {
|
onInput={(e) => {
|
||||||
const target = e.target as HTMLDivElement;
|
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}
|
onCompositionStart={onCompositionStart}
|
||||||
onCompositionEnd={onCompositionEnd}
|
onCompositionEnd={onCompositionEnd}
|
||||||
@@ -281,7 +288,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
||||||
disabled={!inputText.trim()}
|
disabled={composerDisabled || !inputText.trim()}
|
||||||
>
|
>
|
||||||
<ArrowUpIcon />
|
<ArrowUpIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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<OnboardingPageProps> = ({ onLogin }) => {
|
||||||
|
const iconUri = generateIconUrl('icon.png');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
|
||||||
|
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
{/* Application icon container */}
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={iconUri}
|
||||||
|
alt="Qwen Code Logo"
|
||||||
|
className="w-[80px] h-[80px] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-app-primary-foreground mb-2">
|
||||||
|
Welcome to Qwen Code
|
||||||
|
</h1>
|
||||||
|
<p className="text-app-secondary-foreground max-w-sm">
|
||||||
|
Unlock the power of AI to understand, navigate, and transform your
|
||||||
|
codebase faster than ever before.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onLogin}
|
||||||
|
className="w-full px-4 py-3 bg-[#4f46e5] text-white font-medium rounded-lg shadow-sm hover:bg-[#4338ca] transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Get Started with Qwen Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -75,7 +75,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|||||||
whiteSpace: 'normal',
|
whiteSpace: 'normal',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MessageContent content={content} onFileClick={onFileClick} />
|
<MessageContent
|
||||||
|
content={content}
|
||||||
|
onFileClick={onFileClick}
|
||||||
|
enableFileLinks={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface UseMessageSubmitProps {
|
|||||||
setInputText: (text: string) => void;
|
setInputText: (text: string) => void;
|
||||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
isWaitingForResponse: boolean;
|
||||||
// When true, do NOT auto-attach the active editor file/selection to context
|
// When true, do NOT auto-attach the active editor file/selection to context
|
||||||
skipAutoActiveContext?: boolean;
|
skipAutoActiveContext?: boolean;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export const useMessageSubmit = ({
|
|||||||
setInputText,
|
setInputText,
|
||||||
inputFieldRef,
|
inputFieldRef,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isWaitingForResponse,
|
||||||
skipAutoActiveContext = false,
|
skipAutoActiveContext = false,
|
||||||
fileContext,
|
fileContext,
|
||||||
messageHandling,
|
messageHandling,
|
||||||
@@ -48,7 +50,7 @@ export const useMessageSubmit = ({
|
|||||||
(e: React.FormEvent) => {
|
(e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!inputText.trim() || isStreaming) {
|
if (!inputText.trim() || isStreaming || isWaitingForResponse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +58,10 @@ export const useMessageSubmit = ({
|
|||||||
if (inputText.trim() === '/login') {
|
if (inputText.trim() === '/login') {
|
||||||
setInputText('');
|
setInputText('');
|
||||||
if (inputFieldRef.current) {
|
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({
|
vscode.postMessage({
|
||||||
type: 'login',
|
type: 'login',
|
||||||
@@ -142,7 +147,10 @@ export const useMessageSubmit = ({
|
|||||||
|
|
||||||
setInputText('');
|
setInputText('');
|
||||||
if (inputFieldRef.current) {
|
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();
|
fileContext.clearFileReferences();
|
||||||
},
|
},
|
||||||
@@ -154,6 +162,7 @@ export const useMessageSubmit = ({
|
|||||||
vscode,
|
vscode,
|
||||||
fileContext,
|
fileContext,
|
||||||
skipAutoActiveContext,
|
skipAutoActiveContext,
|
||||||
|
isWaitingForResponse,
|
||||||
messageHandling,
|
messageHandling,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ interface UseWebViewMessagesProps {
|
|||||||
setInputText: (text: string) => void;
|
setInputText: (text: string) => void;
|
||||||
// Edit mode setter (maps ACP modes to UI modes)
|
// Edit mode setter (maps ACP modes to UI modes)
|
||||||
setEditMode?: (mode: ApprovalModeValue) => void;
|
setEditMode?: (mode: ApprovalModeValue) => void;
|
||||||
|
// Authentication state setter
|
||||||
|
setIsAuthenticated?: (authenticated: boolean | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +128,7 @@ export const useWebViewMessages = ({
|
|||||||
inputFieldRef,
|
inputFieldRef,
|
||||||
setInputText,
|
setInputText,
|
||||||
setEditMode,
|
setEditMode,
|
||||||
|
setIsAuthenticated,
|
||||||
}: UseWebViewMessagesProps) => {
|
}: UseWebViewMessagesProps) => {
|
||||||
// VS Code API for posting messages back to the extension host
|
// VS Code API for posting messages back to the extension host
|
||||||
const vscode = useVSCode();
|
const vscode = useVSCode();
|
||||||
@@ -141,6 +144,7 @@ export const useWebViewMessages = ({
|
|||||||
clearToolCalls,
|
clearToolCalls,
|
||||||
setPlanEntries,
|
setPlanEntries,
|
||||||
handlePermissionRequest,
|
handlePermissionRequest,
|
||||||
|
setIsAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
|
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
|
||||||
@@ -185,6 +189,7 @@ export const useWebViewMessages = ({
|
|||||||
clearToolCalls,
|
clearToolCalls,
|
||||||
setPlanEntries,
|
setPlanEntries,
|
||||||
handlePermissionRequest,
|
handlePermissionRequest,
|
||||||
|
setIsAuthenticated,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +221,7 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'loginSuccess': {
|
case 'loginSuccess': {
|
||||||
// Clear loading state and show a short assistant notice
|
// Clear loading state and show a short assistant notice
|
||||||
handlers.messageHandling.clearWaitingForResponse();
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
@@ -224,12 +230,16 @@ export const useWebViewMessages = ({
|
|||||||
content: 'Successfully logged in. You can continue chatting.',
|
content: 'Successfully logged in. You can continue chatting.',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
// Set authentication state to true
|
||||||
|
handlers.setIsAuthenticated?.(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'agentConnected': {
|
case 'agentConnected': {
|
||||||
// Agent connected successfully; clear any pending spinner
|
// Agent connected successfully; clear any pending spinner
|
||||||
handlers.messageHandling.clearWaitingForResponse();
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
|
// Set authentication state to true
|
||||||
|
handlers.setIsAuthenticated?.(true);
|
||||||
break;
|
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.`,
|
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(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
// Set authentication state to false
|
||||||
|
handlers.setIsAuthenticated?.(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +271,20 @@ export const useWebViewMessages = ({
|
|||||||
content: errorMsg,
|
content: errorMsg,
|
||||||
timestamp: Date.now(),
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,30 +350,42 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'streamEnd': {
|
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.endStreaming();
|
||||||
handlers.messageHandling.clearThinking();
|
handlers.messageHandling.clearThinking();
|
||||||
|
|
||||||
// If the stream ended due to explicit user cancel, proactively
|
// If stream ended due to explicit user cancellation, proactively clear
|
||||||
// clear the waiting indicator and reset any tracked exec calls.
|
// waiting indicator and reset tracked execution calls.
|
||||||
// This avoids the UI being stuck with the Stop button visible
|
// This avoids UI getting stuck with Stop button visible after
|
||||||
// after rejecting a permission request.
|
// rejecting a permission request.
|
||||||
try {
|
try {
|
||||||
const reason = (
|
const reason = (
|
||||||
(message.data as { reason?: string } | undefined)?.reason || ''
|
(message.data as { reason?: string } | undefined)?.reason || ''
|
||||||
).toLowerCase();
|
).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();
|
activeExecToolCallsRef.current.clear();
|
||||||
|
// Clear waiting response state to ensure UI returns to normal
|
||||||
handlers.messageHandling.clearWaitingForResponse();
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} 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
|
* For other types of stream end (non-user cancellation):
|
||||||
// execute/bash/command calls, keep the hint visible.
|
* 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) {
|
if (activeExecToolCallsRef.current.size === 0) {
|
||||||
handlers.messageHandling.clearWaitingForResponse();
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
}
|
}
|
||||||
@@ -548,15 +586,21 @@ export const useWebViewMessages = ({
|
|||||||
// While long-running tools (e.g., execute/bash/command) are in progress,
|
// While long-running tools (e.g., execute/bash/command) are in progress,
|
||||||
// surface a lightweight loading indicator and expose the Stop button.
|
// surface a lightweight loading indicator and expose the Stop button.
|
||||||
try {
|
try {
|
||||||
const kind = (toolCallData.kind || '').toString().toLowerCase();
|
|
||||||
const isExec =
|
|
||||||
kind === 'execute' || kind === 'bash' || kind === 'command';
|
|
||||||
|
|
||||||
if (isExec) {
|
|
||||||
const id = (toolCallData.toolCallId || '').toString();
|
const id = (toolCallData.toolCallId || '').toString();
|
||||||
|
const kind = (toolCallData.kind || '').toString().toLowerCase();
|
||||||
|
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 || !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);
|
activeExecToolCallsRef.current.add(id);
|
||||||
|
|
||||||
// Build a helpful hint from rawInput
|
// Build a helpful hint from rawInput
|
||||||
@@ -570,6 +614,7 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
const hint = cmd ? `Running: ${cmd}` : 'Running command...';
|
const hint = cmd ? `Running: ${cmd}` : 'Running command...';
|
||||||
handlers.messageHandling.setWaitingForResponse(hint);
|
handlers.messageHandling.setWaitingForResponse(hint);
|
||||||
|
}
|
||||||
} else if (status === 'completed' || status === 'failed') {
|
} else if (status === 'completed' || status === 'failed') {
|
||||||
activeExecToolCallsRef.current.delete(id);
|
activeExecToolCallsRef.current.delete(id);
|
||||||
}
|
}
|
||||||
@@ -578,7 +623,6 @@ export const useWebViewMessages = ({
|
|||||||
if (activeExecToolCallsRef.current.size === 0) {
|
if (activeExecToolCallsRef.current.size === 0) {
|
||||||
handlers.messageHandling.clearWaitingForResponse();
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Best-effort UI hint; ignore errors
|
// Best-effort UI hint; ignore errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,7 @@
|
|||||||
.composer-form:focus-within {
|
.composer-form:focus-within {
|
||||||
/* match existing highlight behavior */
|
/* match existing highlight behavior */
|
||||||
border-color: var(--app-input-highlight);
|
border-color: var(--app-input-highlight);
|
||||||
box-shadow: 0 1px 2px
|
box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%);
|
||||||
color-mix(in srgb, var(--app-input-highlight), transparent 80%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Composer: input editable area */
|
/* Composer: input editable area */
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
The data attribute is needed because some browsers insert a <br> in
|
The data attribute is needed because some browsers insert a <br> in
|
||||||
contentEditable, which breaks :empty matching. */
|
contentEditable, which breaks :empty matching. */
|
||||||
.composer-input:empty:before,
|
.composer-input:empty:before,
|
||||||
.composer-input[data-empty='true']::before {
|
.composer-input[data-empty="true"]::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
color: var(--app-input-placeholder-foreground);
|
color: var(--app-input-placeholder-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -81,7 +80,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.composer-input:disabled,
|
.composer-input:disabled,
|
||||||
.composer-input[contenteditable='false'] {
|
.composer-input[contenteditable="false"] {
|
||||||
color: #999;
|
color: #999;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,22 @@
|
|||||||
z-index: 0;
|
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 */
|
/* 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,
|
.qwen-message.message-item:not(.user-message-container):first-child::after,
|
||||||
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after,
|
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after,
|
||||||
|
|||||||
Reference in New Issue
Block a user