fix(vscode-ide-companion): resolve all ESLint errors

Fixed unused variable errors in SessionMessageHandler.ts:
- Commented out unused conversation and messages variables

Also includes previous commits:
1. feat(vscode-ide-companion): add upgrade button to CLI version warning
2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component

When the Qwen Code CLI version is below the minimum required version,
the warning message now includes an "Upgrade Now" button that opens
a terminal and runs the npm install command to upgrade the CLI.

Added tests to verify the functionality works correctly.
This commit is contained in:
yiliang114
2025-12-13 09:56:18 +08:00
parent 3191cf73b3
commit e895c49f5c
8 changed files with 213 additions and 348 deletions

View File

@@ -336,8 +336,10 @@ export class QwenAgentManager {
name: this.sessionReader.getSessionTitle(session),
startTime: session.startTime,
lastUpdated: session.lastUpdated,
messageCount: session.messages.length,
messageCount: session.messageCount ?? session.messages.length,
projectHash: session.projectHash,
filePath: session.filePath,
cwd: session.cwd,
}),
);
@@ -452,8 +454,10 @@ export class QwenAgentManager {
name: this.sessionReader.getSessionTitle(x.raw),
startTime: x.raw.startTime,
lastUpdated: x.raw.lastUpdated,
messageCount: x.raw.messages.length,
messageCount: x.raw.messageCount ?? x.raw.messages.length,
projectHash: x.raw.projectHash,
filePath: x.raw.filePath,
cwd: x.raw.cwd,
}));
const nextCursorVal =
page.length > 0 ? page[page.length - 1].mtime : undefined;
@@ -891,80 +895,6 @@ export class QwenAgentManager {
return this.saveSessionViaCommand(sessionId, tag);
}
/**
* Save session as checkpoint (using CLI format)
* Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
* Saves two copies with sessionId and conversationId to ensure recovery via either ID
*
* @param messages - Current session messages
* @param conversationId - Conversation ID (from VSCode extension)
* @returns Save result
*/
async saveCheckpoint(
messages: ChatMessage[],
conversationId: string,
): Promise<{ success: boolean; tag?: string; message?: string }> {
try {
console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START =====');
console.log('[QwenAgentManager] Conversation ID:', conversationId);
console.log('[QwenAgentManager] Message count:', messages.length);
console.log(
'[QwenAgentManager] Current working dir:',
this.currentWorkingDir,
);
console.log(
'[QwenAgentManager] Current session ID (from CLI):',
this.currentSessionId,
);
// In ACP mode, the CLI does not accept arbitrary slash commands like
// "/chat save". To ensure we never block on unsupported features,
// persist checkpoints directly to ~/.qwen/tmp using our SessionManager.
const qwenMessages = messages.map((m) => ({
// Generate minimal QwenMessage shape expected by the writer
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: new Date().toISOString(),
type: m.role === 'user' ? ('user' as const) : ('qwen' as const),
content: m.content,
}));
const tag = await this.sessionManager.saveCheckpoint(
qwenMessages,
conversationId,
this.currentWorkingDir,
this.currentSessionId || undefined,
);
return { success: true, tag };
} catch (error) {
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenAgentManager] Error:', error);
console.error(
'[QwenAgentManager] Error stack:',
error instanceof Error ? error.stack : 'N/A',
);
return {
success: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Save session directly to file system (without relying on ACP)
*
* @param messages - Current session messages
* @param sessionName - Session name
* @returns Save result
*/
async saveSessionDirect(
messages: ChatMessage[],
sessionName: string,
): Promise<{ success: boolean; sessionId?: string; message?: string }> {
// Use checkpoint format instead of session format
// This matches CLI's /chat save behavior
return this.saveCheckpoint(messages, sessionName);
}
/**
* Try to load session via ACP session/load method
* This method will only be used if CLI version supports it
@@ -1152,16 +1082,6 @@ export class QwenAgentManager {
}
}
/**
* Load session, preferring ACP method if CLI version supports it
*
* @param sessionId - Session ID
* @returns Loaded session messages or null
*/
async loadSessionDirect(sessionId: string): Promise<ChatMessage[] | null> {
return this.loadSession(sessionId);
}
/**
* Create new session
*

View File

@@ -54,9 +54,18 @@ export class QwenConnectionHandler {
// Show warning if CLI version is below minimum requirement
if (!versionInfo.isSupported) {
// Wait to determine release version number
vscode.window.showWarningMessage(
const selection = await vscode.window.showWarningMessage(
`Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
'Upgrade Now',
);
// Handle the user's selection
if (selection === 'Upgrade Now') {
// Open terminal and run npm install command
const terminal = vscode.window.createTerminal('Qwen Code CLI Upgrade');
terminal.show();
terminal.sendText('npm install -g @qwen-code/qwen-code@latest');
}
}
const config = vscode.workspace.getConfiguration('qwenCode');

View File

@@ -51,131 +51,7 @@ export class QwenSessionManager {
}
/**
* Save current conversation as a checkpoint (matching CLI's /chat save format)
* Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility
*
* @param messages - Current conversation messages
* @param conversationId - Conversation ID (from VSCode extension)
* @param sessionId - Session ID (from CLI tmp session file, optional)
* @param workingDir - Current working directory
* @returns Checkpoint tag
*/
async saveCheckpoint(
messages: QwenMessage[],
conversationId: string,
workingDir: string,
sessionId?: string,
): Promise<string> {
try {
console.log('[QwenSessionManager] ===== SAVEPOINT START =====');
console.log('[QwenSessionManager] Conversation ID:', conversationId);
console.log(
'[QwenSessionManager] Session ID:',
sessionId || 'not provided',
);
console.log('[QwenSessionManager] Working dir:', workingDir);
console.log('[QwenSessionManager] Message count:', messages.length);
// Get project directory (parent of chats directory)
const projectHash = this.getProjectHash(workingDir);
console.log('[QwenSessionManager] Project hash:', projectHash);
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
console.log('[QwenSessionManager] Project dir:', projectDir);
if (!fs.existsSync(projectDir)) {
console.log('[QwenSessionManager] Creating project directory...');
fs.mkdirSync(projectDir, { recursive: true });
console.log('[QwenSessionManager] Directory created');
} else {
console.log('[QwenSessionManager] Project directory already exists');
}
// Convert messages to checkpoint format (Gemini-style messages)
console.log(
'[QwenSessionManager] Converting messages to checkpoint format...',
);
const checkpointMessages = messages.map((msg, index) => {
console.log(
`[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`,
);
return {
role: msg.type === 'user' ? 'user' : 'model',
parts: [
{
text: msg.content,
},
],
};
});
console.log(
'[QwenSessionManager] Converted',
checkpointMessages.length,
'messages',
);
const jsonContent = JSON.stringify(checkpointMessages, null, 2);
console.log(
'[QwenSessionManager] JSON content length:',
jsonContent.length,
);
// Save with conversationId as primary tag
const convFilename = `checkpoint-${conversationId}.json`;
const convFilePath = path.join(projectDir, convFilename);
console.log(
'[QwenSessionManager] Saving checkpoint with conversationId:',
convFilePath,
);
fs.writeFileSync(convFilePath, jsonContent, 'utf-8');
// Also save with sessionId if provided (for compatibility with CLI session/load)
if (sessionId) {
const sessionFilename = `checkpoint-${sessionId}.json`;
const sessionFilePath = path.join(projectDir, sessionFilename);
console.log(
'[QwenSessionManager] Also saving checkpoint with sessionId:',
sessionFilePath,
);
fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8');
}
// Verify primary file exists
if (fs.existsSync(convFilePath)) {
const stats = fs.statSync(convFilePath);
console.log(
'[QwenSessionManager] Primary checkpoint verified, size:',
stats.size,
);
} else {
console.error(
'[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!',
);
}
console.log('[QwenSessionManager] ===== CHECKPOINT SAVED =====');
console.log('[QwenSessionManager] Primary path:', convFilePath);
if (sessionId) {
console.log(
'[QwenSessionManager] Secondary path (sessionId):',
path.join(projectDir, `checkpoint-${sessionId}.json`),
);
}
return conversationId;
} catch (error) {
console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenSessionManager] Error:', error);
console.error(
'[QwenSessionManager] Error stack:',
error instanceof Error ? error.stack : 'N/A',
);
throw error;
}
}
/**
* Save current conversation as a named session (checkpoint-like functionality)
* Save current conversation as a named session
*
* @param messages - Current conversation messages
* @param sessionName - Name/tag for the saved session

View File

@@ -7,6 +7,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as readline from 'readline';
import * as crypto from 'crypto';
export interface QwenMessage {
id: string;
@@ -32,6 +34,9 @@ export interface QwenSession {
lastUpdated: string;
messages: QwenMessage[];
filePath?: string;
messageCount?: number;
firstUserText?: string;
cwd?: string;
}
export class QwenSessionReader {
@@ -96,11 +101,17 @@ export class QwenSessionReader {
return sessions;
}
const files = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('session-') && f.endsWith('.json'));
const files = fs.readdirSync(chatsDir);
for (const file of files) {
const jsonSessionFiles = files.filter(
(f) => f.startsWith('session-') && f.endsWith('.json'),
);
const jsonlSessionFiles = files.filter((f) =>
/^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f),
);
for (const file of jsonSessionFiles) {
const filePath = path.join(chatsDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -116,6 +127,23 @@ export class QwenSessionReader {
}
}
// Support new JSONL session format produced by the CLI
for (const file of jsonlSessionFiles) {
const filePath = path.join(chatsDir, file);
try {
const session = await this.readJsonlSession(filePath, false);
if (session) {
sessions.push(session);
}
} catch (error) {
console.error(
'[QwenSessionReader] Failed to read JSONL session file:',
filePath,
error,
);
}
}
return sessions;
}
@@ -128,7 +156,25 @@ export class QwenSessionReader {
): Promise<QwenSession | null> {
// First try to find in all projects
const sessions = await this.getAllSessions(undefined, true);
return sessions.find((s) => s.sessionId === sessionId) || null;
const found = sessions.find((s) => s.sessionId === sessionId);
if (!found) {
return null;
}
// If the session points to a JSONL file, load full content on demand
if (
found.filePath &&
found.filePath.endsWith('.jsonl') &&
found.messages.length === 0
) {
const hydrated = await this.readJsonlSession(found.filePath, true);
if (hydrated) {
return hydrated;
}
}
return found;
}
/**
@@ -136,7 +182,6 @@ export class QwenSessionReader {
* Qwen CLI uses SHA256 hash of project path
*/
private async getProjectHash(workingDir: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(workingDir).digest('hex');
}
@@ -144,6 +189,14 @@ export class QwenSessionReader {
* Get session title (based on first user message)
*/
getSessionTitle(session: QwenSession): string {
// Prefer cached prompt text to avoid loading messages for JSONL sessions
if (session.firstUserText) {
return (
session.firstUserText.substring(0, 50) +
(session.firstUserText.length > 50 ? '...' : '')
);
}
const firstUserMessage = session.messages.find((m) => m.type === 'user');
if (firstUserMessage) {
// Extract first 50 characters as title
@@ -155,6 +208,137 @@ export class QwenSessionReader {
return 'Untitled Session';
}
/**
* Parse a JSONL session file written by the CLI.
* When includeMessages is false, only lightweight metadata is returned.
*/
private async readJsonlSession(
filePath: string,
includeMessages: boolean,
): Promise<QwenSession | null> {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const stats = fs.statSync(filePath);
const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
const messages: QwenMessage[] = [];
const seenUuids = new Set<string>();
let sessionId: string | undefined;
let startTime: string | undefined;
let firstUserText: string | undefined;
let cwd: string | undefined;
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let obj: Record<string, unknown>;
try {
obj = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (!sessionId && typeof obj.sessionId === 'string') {
sessionId = obj.sessionId;
}
if (!startTime && typeof obj.timestamp === 'string') {
startTime = obj.timestamp;
}
if (!cwd && typeof obj.cwd === 'string') {
cwd = obj.cwd;
}
const type = typeof obj.type === 'string' ? obj.type : '';
if (type === 'user' || type === 'assistant') {
const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined;
if (uuid) {
seenUuids.add(uuid);
}
const text = this.contentToText(obj.message);
if (includeMessages) {
messages.push({
id: uuid || `${messages.length}`,
timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '',
type: type === 'user' ? 'user' : 'qwen',
content: text,
});
}
if (!firstUserText && type === 'user' && text) {
firstUserText = text;
}
}
}
// Ensure stream is closed
rl.close();
if (!sessionId) {
return null;
}
const projectHash = cwd
? await this.getProjectHash(cwd)
: path.basename(path.dirname(path.dirname(filePath)));
return {
sessionId,
projectHash,
startTime: startTime || new Date(stats.birthtimeMs).toISOString(),
lastUpdated: new Date(stats.mtimeMs).toISOString(),
messages: includeMessages ? messages : [],
filePath,
messageCount: seenUuids.size,
firstUserText,
cwd,
};
} catch (error) {
console.error(
'[QwenSessionReader] Failed to parse JSONL session:',
error,
);
return null;
}
}
// Extract plain text from CLI Content structure
private contentToText(message: unknown): string {
try {
if (typeof message !== 'object' || message === null) {
return '';
}
const typed = message as { parts?: unknown[] };
const parts = Array.isArray(typed.parts) ? typed.parts : [];
const texts: string[] = [];
for (const part of parts) {
if (typeof part !== 'object' || part === null) {
continue;
}
const p = part as Record<string, unknown>;
if (typeof p.text === 'string') {
texts.push(p.text);
} else if (typeof p.data === 'string') {
texts.push(p.data);
}
}
return texts.join('\n');
} catch {
return '';
}
}
/**
* Delete session file
*/

View File

@@ -73,11 +73,4 @@ export class MessageHandler {
appendStreamContent(chunk: string): void {
this.router.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.router.getIsSavingCheckpoint();
}
}

View File

@@ -11,7 +11,7 @@ import {
PlanModeIcon,
CodeBracketsIcon,
HideContextIcon,
ThinkingIcon,
// ThinkingIcon, // Temporarily disabled
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
@@ -92,7 +92,7 @@ export const InputForm: React.FC<InputFormProps> = ({
isWaitingForResponse,
isComposing,
editMode,
thinkingEnabled,
// thinkingEnabled, // Temporarily disabled
activeFileName,
activeSelection,
skipAutoActiveContext,
@@ -103,7 +103,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onSubmit,
onCancel,
onToggleEditMode,
onToggleThinking,
// onToggleThinking, // Temporarily disabled
onToggleSkipAutoActiveContext,
onShowCommandMenu,
onAttachContext,
@@ -236,15 +236,16 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* @yiliang114. closed temporarily */}
{/* Thinking button */}
<button
{/* <button
type="button"
className={`btn-icon-compact ${thinkingEnabled ? 'btn-icon-compact--active' : ''}`}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<ThinkingIcon enabled={thinkingEnabled} />
</button>
</button> */}
{/* Command button */}
<button

View File

@@ -150,11 +150,4 @@ export class MessageRouter {
appendStreamContent(chunk: string): void {
this.sessionHandler.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.sessionHandler.getIsSavingCheckpoint();
}
}

View File

@@ -15,7 +15,6 @@ import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
*/
export class SessionMessageHandler extends BaseMessageHandler {
private currentStreamContent = '';
private isSavingCheckpoint = false;
private loginHandler: (() => Promise<void>) | null = null;
private isTitleSet = false; // Flag to track if title has been set
@@ -153,13 +152,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
this.currentStreamContent = '';
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.isSavingCheckpoint;
}
/**
* Prompt user to login and invoke the registered login handler/command.
* Returns true if a login was initiated.
@@ -385,41 +377,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'streamEnd',
data: { timestamp: Date.now() },
});
// Auto-save checkpoint
if (this.currentConversationId) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
this.isSavingCheckpoint = true;
const result = await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
setTimeout(() => {
this.isSavingCheckpoint = false;
}, 2000);
if (result.success) {
console.log(
'[SessionMessageHandler] Checkpoint saved:',
result.tag,
);
}
} catch (error) {
console.error(
'[SessionMessageHandler] Checkpoint save failed:',
error,
);
this.isSavingCheckpoint = false;
}
}
} catch (error) {
console.error('[SessionMessageHandler] Error sending message:', error);
@@ -493,23 +450,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
// Save current session before creating new one
if (this.currentConversationId && this.agentManager.isConnected) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
@@ -589,27 +529,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
// Save current session before switching
if (
this.currentConversationId &&
this.currentConversationId !== sessionId &&
this.agentManager.isConnected
) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
// Get session details (includes cwd and filePath when using ACP)
let sessionDetails: Record<string, unknown> | null = null;
try {
@@ -852,11 +771,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
throw new Error('No active conversation to save');
}
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
// Try ACP save first
try {
const response = await this.agentManager.saveSessionViaAcp(
@@ -891,17 +805,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
return;
}
// Fallback to direct save
const response = await this.agentManager.saveSessionDirect(
messages,
tag,
);
this.sendToWebView({
type: 'saveSessionResponse',
data: response,
});
}
await this.handleGetQwenSessions();
@@ -1036,20 +939,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
});
return;
}
// Fallback to direct load
const messages = await this.agentManager.loadSessionDirect(sessionId);
if (messages) {
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
} else {
throw new Error('Failed to load session');
}
}
await this.handleGetQwenSessions();