mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Fix/vscode ide companion completion menu content (#1243)
* refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file feat(vscode-ide-companion/file-context): improve file context handling and search Enhance file context hook to better handle search queries and reduce redundant requests. Track last query to optimize when to refetch full file list. Improve logging for debugging purposes. * feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic Implement item comparison to prevent unnecessary re-renders when completion items haven't actually changed. Optimize refresh logic to only trigger when workspace files content changes. Improve completion menu stability and responsiveness. refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler to reduce code duplication and simplify message handling architecture. Remove unused settings-related imports and clean up message router configuration. chore(vscode-ide-companion/ui): minor UI improvements and code cleanup Consolidate imports in SessionSelector component. Remove debug console log statement from FileMessageHandler. Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file. Clean up completion menu CSS classes. * 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:
@@ -10,8 +10,8 @@ import type {
|
||||
AcpPermissionRequest,
|
||||
AcpResponse,
|
||||
AcpSessionUpdate,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type { ChildProcess, SpawnOptions } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import type {
|
||||
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
AcpRequest,
|
||||
AcpNotification,
|
||||
AcpResponse,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import { AGENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
@@ -7,8 +7,8 @@ import { AcpConnection } from './acpConnection.js';
|
||||
import type {
|
||||
AcpSessionUpdate,
|
||||
AcpPermissionRequest,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
||||
import { QwenSessionManager } from './qwenSessionManager.js';
|
||||
import type {
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* Handles session updates from ACP and dispatches them to appropriate callbacks
|
||||
*/
|
||||
|
||||
import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js';
|
||||
import type { AcpSessionUpdate } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
||||
|
||||
export const JSONRPC_VERSION = '2.0' as const;
|
||||
export const authMethod = 'qwen-oauth';
|
||||
@@ -138,8 +139,6 @@ export interface PlanUpdate extends BaseSessionUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
|
||||
export {
|
||||
ApprovalMode,
|
||||
APPROVAL_MODE_MAP,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type for approval mode values
|
||||
* Used in ACP protocol for controlling agent behavior
|
||||
*/
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
@@ -3,7 +3,8 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js';
|
||||
import type { AcpPermissionRequest } from './acpTypes.js';
|
||||
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@@ -43,7 +43,7 @@ import { InputForm } from './components/layout/InputForm.js';
|
||||
import { SessionSelector } from './components/layout/SessionSelector.js';
|
||||
import { FileIcon, UserIcon } from './components/icons/index.js';
|
||||
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
import type { PlanEntry } from '../types/chatTypes.js';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
@@ -90,9 +90,13 @@ export const App: React.FC = () => {
|
||||
const getCompletionItems = React.useCallback(
|
||||
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
|
||||
if (trigger === '@') {
|
||||
if (!fileContext.hasRequestedFiles) {
|
||||
fileContext.requestWorkspaceFiles();
|
||||
}
|
||||
console.log('[App] getCompletionItems @ called', {
|
||||
query,
|
||||
requested: fileContext.hasRequestedFiles,
|
||||
workspaceFiles: fileContext.workspaceFiles.length,
|
||||
});
|
||||
// 始终根据当前 query 触发请求,让 hook 判断是否需要真正请求
|
||||
fileContext.requestWorkspaceFiles(query);
|
||||
|
||||
const fileIcon = <FileIcon />;
|
||||
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
|
||||
@@ -109,7 +113,6 @@ export const App: React.FC = () => {
|
||||
);
|
||||
|
||||
if (query && query.length >= 1) {
|
||||
fileContext.requestWorkspaceFiles(query);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allItems.filter(
|
||||
(item) =>
|
||||
@@ -154,17 +157,39 @@ export const App: React.FC = () => {
|
||||
|
||||
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
||||
|
||||
// Track a lightweight signature of workspace files to detect content changes even when length is unchanged
|
||||
const workspaceFilesSignature = useMemo(
|
||||
() =>
|
||||
fileContext.workspaceFiles
|
||||
.map(
|
||||
(file) =>
|
||||
`${file.id}|${file.label}|${file.description ?? ''}|${file.path}`,
|
||||
)
|
||||
.join('||'),
|
||||
[fileContext.workspaceFiles],
|
||||
);
|
||||
|
||||
// When workspace files update while menu open for @, refresh items so the first @ shows the list
|
||||
// Note: Avoid depending on the entire `completion` object here, since its identity
|
||||
// changes on every render which would retrigger this effect and can cause a refresh loop.
|
||||
useEffect(() => {
|
||||
if (completion.isOpen && completion.triggerChar === '@') {
|
||||
// Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search
|
||||
if (
|
||||
completion.isOpen &&
|
||||
completion.triggerChar === '@' &&
|
||||
!completion.query
|
||||
) {
|
||||
// Only refresh items; do not change other completion state to avoid re-renders loops
|
||||
completion.refreshCompletion();
|
||||
}
|
||||
// Only re-run when the actual data source changes, not on every render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
|
||||
}, [
|
||||
workspaceFilesSignature,
|
||||
completion.isOpen,
|
||||
completion.triggerChar,
|
||||
completion.query,
|
||||
]);
|
||||
|
||||
// Message submission
|
||||
const { handleSubmit: submitMessage } = useMessageSubmit({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MessageHandler } from '../webview/MessageHandler.js';
|
||||
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||
import { CliInstaller } from '../cli/cliInstaller.js';
|
||||
import { getFileName } from './utils/webviewUtils.js';
|
||||
import { type ApprovalModeValue } from '../types/acpTypes.js';
|
||||
import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
|
||||
export class WebViewProvider {
|
||||
private panelManager: PanelManager;
|
||||
|
||||
@@ -92,9 +92,8 @@ export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
className={[
|
||||
// Semantic class name for readability (no CSS attached)
|
||||
'completion-menu',
|
||||
// Positioning and container styling (Tailwind)
|
||||
// Positioning and container styling
|
||||
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
|
||||
'rounded-large border bg-[var(--app-menu-background)]',
|
||||
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
PlanModeIcon,
|
||||
CodeBracketsIcon,
|
||||
HideContextIcon,
|
||||
ThinkingIcon,
|
||||
// ThinkingIcon, // Temporarily disabled
|
||||
SlashCommandIcon,
|
||||
LinkIcon,
|
||||
ArrowUpIcon,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { CompletionMenu } from '../layout/CompletionMenu.js';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
|
||||
|
||||
interface InputFormProps {
|
||||
inputText: string;
|
||||
@@ -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
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { groupSessionsByDate } from '../../utils/sessionGrouping.js';
|
||||
import { getTimeAgo } from '../../utils/timeUtils.js';
|
||||
import {
|
||||
getTimeAgo,
|
||||
groupSessionsByDate,
|
||||
} from '../../utils/sessionGrouping.js';
|
||||
import { SearchIcon } from '../icons/index.js';
|
||||
|
||||
interface SessionSelectorProps {
|
||||
|
||||
@@ -49,7 +49,6 @@ export class FileMessageHandler extends BaseMessageHandler {
|
||||
break;
|
||||
|
||||
case 'openDiff':
|
||||
console.log('[FileMessageHandler ===== ] openDiff called with:', data);
|
||||
await this.handleOpenDiff(data);
|
||||
break;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { SessionMessageHandler } from './SessionMessageHandler.js';
|
||||
import { FileMessageHandler } from './FileMessageHandler.js';
|
||||
import { EditorMessageHandler } from './EditorMessageHandler.js';
|
||||
import { AuthMessageHandler } from './AuthMessageHandler.js';
|
||||
import { SettingsMessageHandler } from './SettingsMessageHandler.js';
|
||||
|
||||
/**
|
||||
* Message Router
|
||||
@@ -63,20 +62,12 @@ export class MessageRouter {
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
const settingsHandler = new SettingsMessageHandler(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
// Register handlers in order of priority
|
||||
this.handlers = [
|
||||
this.sessionHandler,
|
||||
fileHandler,
|
||||
editorHandler,
|
||||
this.authHandler,
|
||||
settingsHandler,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -159,11 +150,4 @@ export class MessageRouter {
|
||||
appendStreamContent(chunk: string): void {
|
||||
this.sessionHandler.appendStreamContent(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if saving checkpoint
|
||||
*/
|
||||
getIsSavingCheckpoint(): boolean {
|
||||
return this.sessionHandler.getIsSavingCheckpoint();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { ChatMessage } from '../../services/qwenAgentManager.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
|
||||
/**
|
||||
* Session message handler
|
||||
@@ -14,7 +15,6 @@ import type { ChatMessage } from '../../services/qwenAgentManager.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
|
||||
|
||||
@@ -29,6 +29,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
'cancelStreaming',
|
||||
// UI action: open a new chat tab (new WebviewPanel)
|
||||
'openNewChatTab',
|
||||
// Settings-related messages
|
||||
'setApprovalMode',
|
||||
].includes(messageType);
|
||||
}
|
||||
|
||||
@@ -112,6 +114,14 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
await this.handleCancelStreaming();
|
||||
break;
|
||||
|
||||
case 'setApprovalMode':
|
||||
await this.handleSetApprovalMode(
|
||||
message.data as {
|
||||
modeId?: ApprovalModeValue;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Unknown message type:',
|
||||
@@ -142,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.
|
||||
@@ -374,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);
|
||||
|
||||
@@ -482,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();
|
||||
|
||||
@@ -578,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 {
|
||||
@@ -841,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(
|
||||
@@ -880,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();
|
||||
@@ -1025,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();
|
||||
@@ -1073,4 +973,23 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode via agent (ACP session/set_mode)
|
||||
*/
|
||||
private async handleSetApprovalMode(data?: {
|
||||
modeId?: ApprovalModeValue;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const modeId = data?.modeId || 'default';
|
||||
await this.agentManager.setApprovalModeFromUi(modeId);
|
||||
// No explicit response needed; WebView listens for modeChanged
|
||||
} catch (error) {
|
||||
console.error('[SessionMessageHandler] Failed to set mode:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to set mode: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { ApprovalModeValue } from '../../types/acpTypes.js';
|
||||
|
||||
/**
|
||||
* Settings message handler
|
||||
* Handles all settings-related messages
|
||||
*/
|
||||
export class SettingsMessageHandler extends BaseMessageHandler {
|
||||
canHandle(messageType: string): boolean {
|
||||
return ['openSettings', 'recheckCli', 'setApprovalMode'].includes(
|
||||
messageType,
|
||||
);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'openSettings':
|
||||
await this.handleOpenSettings();
|
||||
break;
|
||||
|
||||
case 'recheckCli':
|
||||
await this.handleRecheckCli();
|
||||
break;
|
||||
|
||||
case 'setApprovalMode':
|
||||
await this.handleSetApprovalMode(
|
||||
message.data as {
|
||||
modeId?: ApprovalModeValue;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[SettingsMessageHandler] Unknown message type:',
|
||||
message.type,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open settings page
|
||||
*/
|
||||
private async handleOpenSettings(): Promise<void> {
|
||||
try {
|
||||
// Open settings in a side panel
|
||||
await vscode.commands.executeCommand('workbench.action.openSettings', {
|
||||
query: 'qwenCode',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to open settings:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open settings: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recheck CLI
|
||||
*/
|
||||
private async handleRecheckCli(): Promise<void> {
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwenCode.recheckCli');
|
||||
this.sendToWebView({
|
||||
type: 'cliRechecked',
|
||||
data: { success: true },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to recheck CLI:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to recheck CLI: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode via agent (ACP session/set_mode)
|
||||
*/
|
||||
private async handleSetApprovalMode(data?: {
|
||||
modeId?: ApprovalModeValue;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const modeId = data?.modeId || 'default';
|
||||
await this.agentManager.setApprovalModeFromUi(modeId);
|
||||
// No explicit response needed; WebView listens for modeChanged
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to set mode:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to set mode: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export const useFileContext = (vscode: VSCodeAPI) => {
|
||||
// Whether workspace files have been requested
|
||||
const hasRequestedFilesRef = useRef(false);
|
||||
|
||||
// Last non-empty query to decide when to refetch full list
|
||||
const lastQueryRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Search debounce timer
|
||||
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -42,12 +45,10 @@ export const useFileContext = (vscode: VSCodeAPI) => {
|
||||
*/
|
||||
const requestWorkspaceFiles = useCallback(
|
||||
(query?: string) => {
|
||||
if (!hasRequestedFilesRef.current && !query) {
|
||||
hasRequestedFilesRef.current = true;
|
||||
}
|
||||
const normalizedQuery = query?.trim();
|
||||
|
||||
// If there's a query, clear previous timer and set up debounce
|
||||
if (query && query.length >= 1) {
|
||||
if (normalizedQuery && normalizedQuery.length >= 1) {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
@@ -55,14 +56,23 @@ export const useFileContext = (vscode: VSCodeAPI) => {
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: { query },
|
||||
data: { query: normalizedQuery },
|
||||
});
|
||||
}, 300);
|
||||
lastQueryRef.current = normalizedQuery;
|
||||
} else {
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: query ? { query } : {},
|
||||
});
|
||||
// For empty query, request once initially and whenever we are returning from a search
|
||||
const shouldRequestFullList =
|
||||
!hasRequestedFilesRef.current || lastQueryRef.current !== undefined;
|
||||
|
||||
if (shouldRequestFullList) {
|
||||
lastQueryRef.current = undefined;
|
||||
hasRequestedFilesRef.current = true;
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[vscode],
|
||||
|
||||
@@ -131,12 +131,55 @@ export function useCompletionTrigger(
|
||||
[getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM],
|
||||
);
|
||||
|
||||
// Helper function to compare completion items arrays
|
||||
const areItemsEqual = (
|
||||
items1: CompletionItem[],
|
||||
items2: CompletionItem[],
|
||||
): boolean => {
|
||||
if (items1.length !== items2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare each item by stable fields (ignore non-deterministic props like icons)
|
||||
for (let i = 0; i < items1.length; i++) {
|
||||
const a = items1[i];
|
||||
const b = items2[i];
|
||||
if (a.id !== b.id) {
|
||||
return false;
|
||||
}
|
||||
if (a.label !== b.label) {
|
||||
return false;
|
||||
}
|
||||
if ((a.description ?? '') !== (b.description ?? '')) {
|
||||
return false;
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
if ((a.value ?? '') !== (b.value ?? '')) {
|
||||
return false;
|
||||
}
|
||||
if ((a.path ?? '') !== (b.path ?? '')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const refreshCompletion = useCallback(async () => {
|
||||
if (!state.isOpen || !state.triggerChar) {
|
||||
return;
|
||||
}
|
||||
const items = await getCompletionItems(state.triggerChar, state.query);
|
||||
setState((prev) => ({ ...prev, items }));
|
||||
|
||||
// Only update state if items have actually changed
|
||||
setState((prev) => {
|
||||
if (areItemsEqual(prev.items, items)) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, items };
|
||||
});
|
||||
}, [state.isOpen, state.triggerChar, state.query, getCompletionItems]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ToolCall as PermissionToolCall,
|
||||
} from '../components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
||||
import type { ApprovalModeValue } from '../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import type { PlanEntry } from '../../types/chatTypes.js';
|
||||
|
||||
interface UseWebViewMessagesProps {
|
||||
|
||||
@@ -62,3 +62,38 @@ export const groupSessionsByDate = (
|
||||
.filter(([, sessions]) => sessions.length > 0)
|
||||
.map(([label, sessions]) => ({ label, sessions }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Time ago formatter
|
||||
*
|
||||
* @param timestamp - ISO timestamp string
|
||||
* @returns Formatted time string
|
||||
*/
|
||||
export const getTimeAgo = (timestamp: string): string => {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
const now = new Date().getTime();
|
||||
const then = new Date(timestamp).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'now';
|
||||
}
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins}m`;
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d`;
|
||||
}
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Time ago formatter
|
||||
*
|
||||
* @param timestamp - ISO timestamp string
|
||||
* @returns Formatted time string
|
||||
*/
|
||||
export const getTimeAgo = (timestamp: string): string => {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
const now = new Date().getTime();
|
||||
const then = new Date(timestamp).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'now';
|
||||
}
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins}m`;
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}d`;
|
||||
}
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
};
|
||||
Reference in New Issue
Block a user