diff --git a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts index 00c49e59..13328b51 100644 --- a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts @@ -27,9 +27,9 @@ import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; /** - * Qwen Agent管理器 + * Qwen Agent Manager * - * 协调各个模块,提供统一的接口 + * Coordinates various modules and provides unified interface */ export class QwenAgentManager { private connection: AcpConnection; @@ -39,7 +39,7 @@ export class QwenAgentManager { private sessionUpdateHandler: QwenSessionUpdateHandler; private currentWorkingDir: string = process.cwd(); - // 回调函数存储 + // Callback storage private callbacks: QwenAgentCallbacks = {}; constructor() { @@ -49,7 +49,7 @@ export class QwenAgentManager { this.connectionHandler = new QwenConnectionHandler(); this.sessionUpdateHandler = new QwenSessionUpdateHandler({}); - // 设置ACP连接的回调 + // Set ACP connection callbacks this.connection.onSessionUpdate = (data: AcpSessionUpdate) => { this.sessionUpdateHandler.handleSessionUpdate(data); }; @@ -65,15 +65,15 @@ export class QwenAgentManager { }; this.connection.onEndTurn = () => { - // 通知UI响应完成 + // Notify UI response complete }; } /** - * 连接到Qwen服务 + * Connect to Qwen service * - * @param workingDir - 工作目录 - * @param authStateManager - 认证状态管理器(可选) + * @param workingDir - Working directory + * @param authStateManager - Auth state manager (optional) */ async connect( workingDir: string, @@ -89,18 +89,18 @@ export class QwenAgentManager { } /** - * 发送消息 + * Send message * - * @param message - 消息内容 + * @param message - Message content */ async sendMessage(message: string): Promise { await this.connection.sendPrompt(message); } /** - * 获取会话列表 + * Get session list * - * @returns 会话列表 + * @returns Session list */ async getSessionList(): Promise>> { try { @@ -129,10 +129,10 @@ export class QwenAgentManager { } /** - * 获取会话消息(从磁盘读取) + * Get session messages (read from disk) * - * @param sessionId - 会话ID - * @returns 消息列表 + * @param sessionId - Session ID + * @returns Message list */ async getSessionMessages(sessionId: string): Promise { try { @@ -162,12 +162,12 @@ export class QwenAgentManager { } /** - * 通过发送 /chat save 命令保存会话 - * 由于 CLI 不支持 session/save ACP 方法,我们直接发送 /chat save 命令 + * Save session via /chat save command + * Since CLI doesn't support session/save ACP method, we send /chat save command directly * - * @param sessionId - 会话ID - * @param tag - 保存标签 - * @returns 保存响应 + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response */ async saveSessionViaCommand( sessionId: string, @@ -200,12 +200,12 @@ export class QwenAgentManager { } /** - * 通过 ACP session/save 方法保存会话 (已废弃,CLI 不支持) + * Save session via ACP session/save method (deprecated, CLI doesn't support) * * @deprecated Use saveSessionViaCommand instead - * @param sessionId - 会话ID - * @param tag - 保存标签 - * @returns 保存响应 + * @param sessionId - Session ID + * @param tag - Save tag + * @returns Save response */ async saveSessionViaAcp( sessionId: string, @@ -219,11 +219,11 @@ export class QwenAgentManager { } /** - * 通过发送 /chat save 命令保存会话(CLI 方式) - * 这会调用 CLI 的原生保存功能,确保保存的内容完整 + * Save session via /chat save command (CLI way) + * Calls CLI's native save function to ensure complete content is saved * - * @param tag - Checkpoint 标签 - * @returns 保存结果 + * @param tag - Checkpoint tag + * @returns Save result */ async saveCheckpointViaCommand( tag: string, @@ -263,13 +263,13 @@ export class QwenAgentManager { } /** - * 保存会话为 checkpoint(使用 CLI 的格式) - * 保存到 ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json - * 同时用 sessionId 和 conversationId 保存两份,确保可以通过任一 ID 恢复 + * 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 - 当前会话消息 + * @param messages - Current session messages * @param conversationId - Conversation ID (from VSCode extension) - * @returns 保存结果 + * @returns Save result */ async saveCheckpoint( messages: ChatMessage[], @@ -319,11 +319,11 @@ export class QwenAgentManager { } /** - * 直接保存会话到文件系统(不依赖 ACP) + * Save session directly to file system (without relying on ACP) * - * @param messages - 当前会话消息 - * @param sessionName - 会话名称 - * @returns 保存结果 + * @param messages - Current session messages + * @param sessionName - Session name + * @returns Save result */ async saveSessionDirect( messages: ChatMessage[], @@ -335,11 +335,11 @@ export class QwenAgentManager { } /** - * 尝试通过 ACP session/load 方法加载会话 - * 这是一个测试方法,用于验证 CLI 是否支持 session/load + * Try to load session via ACP session/load method + * This is a test method to verify if CLI supports session/load * - * @param sessionId - 会话ID - * @returns 加载响应或错误 + * @param sessionId - Session ID + * @returns Load response or error */ async loadSessionViaAcp(sessionId: string): Promise { try { @@ -385,16 +385,16 @@ export class QwenAgentManager { } /** - * 直接从文件系统加载会话(不依赖 ACP) + * Load session directly from file system (without relying on ACP) * - * @param sessionId - 会话ID - * @returns 加载的会话消息或null + * @param sessionId - Session ID + * @returns Loaded session messages or null */ async loadSessionDirect(sessionId: string): Promise { try { console.log('[QwenAgentManager] Loading session directly:', sessionId); - // 加载会话 + // Load session const session = await this.sessionManager.loadSession( sessionId, this.currentWorkingDir, @@ -405,7 +405,7 @@ export class QwenAgentManager { return null; } - // 转换消息格式 + // Convert message format const messages: ChatMessage[] = session.messages.map((msg) => ({ role: msg.type === 'user' ? 'user' : 'assistant', content: msg.content, @@ -421,17 +421,17 @@ export class QwenAgentManager { } /** - * 创建新会话 + * Create new session * - * 注意:认证应该在connect()方法中完成,这里只创建会话 + * Note: Authentication should be done in connect() method, only create session here * - * @param workingDir - 工作目录 - * @returns 新创建的 session ID + * @param workingDir - Working directory + * @returns Newly created session ID */ async createNewSession(workingDir: string): Promise { console.log('[QwenAgentManager] Creating new session...'); - // 先进行认证 + // Authenticate first console.log('[QwenAgentManager] Authenticating before creating session...'); try { const config = vscode.workspace.getConfiguration('qwenCode'); @@ -455,16 +455,16 @@ export class QwenAgentManager { } /** - * 切换到指定会话 + * Switch to specified session * - * @param sessionId - 会话ID + * @param sessionId - Session ID */ async switchToSession(sessionId: string): Promise { await this.connection.switchSession(sessionId); } /** - * 取消当前提示 + * Cancel current prompt */ async cancelCurrentPrompt(): Promise { console.log('[QwenAgentManager] Cancelling current prompt'); @@ -472,9 +472,9 @@ export class QwenAgentManager { } /** - * 注册消息回调 + * Register message callback * - * @param callback - 消息回调函数 + * @param callback - Message callback function */ onMessage(callback: (message: ChatMessage) => void): void { this.callbacks.onMessage = callback; @@ -482,9 +482,9 @@ export class QwenAgentManager { } /** - * 注册流式文本块回调 + * Register stream chunk callback * - * @param callback - 流式文本块回调函数 + * @param callback - Stream chunk callback function */ onStreamChunk(callback: (chunk: string) => void): void { this.callbacks.onStreamChunk = callback; @@ -492,9 +492,9 @@ export class QwenAgentManager { } /** - * 注册思考文本块回调 + * Register thought chunk callback * - * @param callback - 思考文本块回调函数 + * @param callback - Thought chunk callback function */ onThoughtChunk(callback: (chunk: string) => void): void { this.callbacks.onThoughtChunk = callback; @@ -502,9 +502,9 @@ export class QwenAgentManager { } /** - * 注册工具调用回调 + * Register tool call callback * - * @param callback - 工具调用回调函数 + * @param callback - Tool call callback function */ onToolCall(callback: (update: ToolCallUpdateData) => void): void { this.callbacks.onToolCall = callback; @@ -512,9 +512,9 @@ export class QwenAgentManager { } /** - * 注册计划回调 + * Register plan callback * - * @param callback - 计划回调函数 + * @param callback - Plan callback function */ onPlan(callback: (entries: PlanEntry[]) => void): void { this.callbacks.onPlan = callback; @@ -522,9 +522,9 @@ export class QwenAgentManager { } /** - * 注册权限请求回调 + * Register permission request callback * - * @param callback - 权限请求回调函数 + * @param callback - Permission request callback function */ onPermissionRequest( callback: (request: AcpPermissionRequest) => Promise, @@ -534,21 +534,21 @@ export class QwenAgentManager { } /** - * 断开连接 + * Disconnect */ disconnect(): void { this.connection.disconnect(); } /** - * 检查是否已连接 + * Check if connected */ get isConnected(): boolean { return this.connection.isConnected; } /** - * 获取当前会话ID + * Get current session ID */ get currentSessionId(): string | null { return this.connection.currentSessionId; diff --git a/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts index 01ff7758..161cf297 100644 --- a/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts @@ -5,9 +5,9 @@ */ /** - * Qwen连接处理器 + * Qwen Connection Handler * - * 负责Qwen Agent的连接建立、认证和会话创建 + * Handles Qwen Agent connection establishment, authentication, and session creation */ import * as vscode from 'vscode'; @@ -16,17 +16,17 @@ import type { QwenSessionReader } from '../services/qwenSessionReader.js'; import type { AuthStateManager } from '../auth/authStateManager.js'; /** - * Qwen连接处理器类 - * 处理连接、认证和会话初始化 + * Qwen Connection Handler class + * Handles connection, authentication, and session initialization */ export class QwenConnectionHandler { /** - * 连接到Qwen服务并建立会话 + * Connect to Qwen service and establish session * - * @param connection - ACP连接实例 - * @param sessionReader - 会话读取器实例 - * @param workingDir - 工作目录 - * @param authStateManager - 认证状态管理器(可选) + * @param connection - ACP connection instance + * @param sessionReader - Session reader instance + * @param workingDir - Working directory + * @param authStateManager - Auth state manager (optional) */ async connect( connection: AcpConnection, @@ -47,7 +47,7 @@ export class QwenConnectionHandler { const model = config.get('qwen.model', ''); const proxy = config.get('qwen.proxy', ''); - // 构建额外的CLI参数 + // Build extra CLI arguments const extraArgs: string[] = []; if (openaiApiKey) { extraArgs.push('--openai-api-key', openaiApiKey); @@ -65,10 +65,10 @@ export class QwenConnectionHandler { await connection.connect('qwen', cliPath, workingDir, extraArgs); - // 确定认证方法 + // Determine authentication method const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth'; - // 检查是否有有效的缓存认证 + // Check if we have valid cached authentication if (authStateManager) { const hasValidAuth = await authStateManager.hasValidAuth( workingDir, @@ -79,10 +79,10 @@ export class QwenConnectionHandler { } } - // 尝试恢复现有会话或创建新会话 + // Try to restore existing session or create new session let sessionRestored = false; - // 尝试从本地文件获取会话 + // Try to get session from local files console.log('[QwenAgentManager] Reading local session files...'); try { const sessions = await sessionReader.getAllSessions(workingDir); @@ -129,11 +129,11 @@ export class QwenConnectionHandler { ); } - // 如果无法恢复会话则创建新会话 + // Create new session if unable to restore if (!sessionRestored) { console.log('[QwenAgentManager] Creating new session...'); - // 检查是否有有效的缓存认证 + // Check if we have valid cached authentication let hasValidAuth = false; if (authStateManager) { hasValidAuth = await authStateManager.hasValidAuth( @@ -142,7 +142,7 @@ export class QwenConnectionHandler { ); } - // 只在没有有效缓存认证时进行认证 + // Only authenticate if we don't have valid cached auth if (!hasValidAuth) { console.log( '[QwenAgentManager] Authenticating before creating session...', @@ -151,7 +151,7 @@ export class QwenConnectionHandler { await connection.authenticate(authMethod); console.log('[QwenAgentManager] Authentication successful'); - // 保存认证状态 + // Save auth state if (authStateManager) { console.log( '[QwenAgentManager] Saving auth state after successful authentication', @@ -160,7 +160,7 @@ export class QwenConnectionHandler { } } catch (authError) { console.error('[QwenAgentManager] Authentication failed:', authError); - // 清除可能无效的缓存 + // Clear potentially invalid cache if (authStateManager) { console.log( '[QwenAgentManager] Clearing auth cache due to authentication failure', @@ -182,7 +182,7 @@ export class QwenConnectionHandler { await this.newSessionWithRetry(connection, workingDir, 3); console.log('[QwenAgentManager] New session created successfully'); - // 确保认证状态已保存(防止重复认证) + // Ensure auth state is saved (prevent repeated authentication) if (authStateManager && !hasValidAuth) { console.log( '[QwenAgentManager] Saving auth state after successful session creation', @@ -193,7 +193,7 @@ export class QwenConnectionHandler { console.log(`\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`); console.log(`[QwenAgentManager] Error details:`, sessionError); - // 清除缓存 + // Clear cache if (authStateManager) { console.log('[QwenAgentManager] Clearing auth cache due to failure'); await authStateManager.clearAuthState(); @@ -209,11 +209,11 @@ export class QwenConnectionHandler { } /** - * 创建新会话(带重试) + * Create new session (with retry) * - * @param connection - ACP连接实例 - * @param workingDir - 工作目录 - * @param maxRetries - 最大重试次数 + * @param connection - ACP connection instance + * @param workingDir - Working directory + * @param maxRetries - Maximum number of retries */ private async newSessionWithRetry( connection: AcpConnection, diff --git a/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts index a138326a..e9d2adfd 100644 --- a/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/agents/qwenSessionUpdateHandler.ts @@ -5,17 +5,17 @@ */ /** - * Qwen会话更新处理器 + * Qwen Session Update Handler * - * 负责处理来自ACP的会话更新,并分发到相应的回调函数 + * Handles session updates from ACP and dispatches them to appropriate callbacks */ import type { AcpSessionUpdate } from '../shared/acpTypes.js'; import type { QwenAgentCallbacks } from './qwenTypes.js'; /** - * Qwen会话更新处理器类 - * 处理各种会话更新事件并调用相应的回调 + * Qwen Session Update Handler class + * Processes various session update events and calls appropriate callbacks */ export class QwenSessionUpdateHandler { private callbacks: QwenAgentCallbacks; @@ -25,18 +25,18 @@ export class QwenSessionUpdateHandler { } /** - * 更新回调函数 + * Update callbacks * - * @param callbacks - 新的回调函数集合 + * @param callbacks - New callback collection */ updateCallbacks(callbacks: QwenAgentCallbacks): void { this.callbacks = callbacks; } /** - * 处理会话更新 + * Handle session update * - * @param data - ACP会话更新数据 + * @param data - ACP session update data */ handleSessionUpdate(data: AcpSessionUpdate): void { const update = data.update; @@ -47,21 +47,21 @@ export class QwenSessionUpdateHandler { switch (update.sessionUpdate) { case 'user_message_chunk': - // 处理用户消息块 + // Handle user message chunk if (update.content?.text && this.callbacks.onStreamChunk) { this.callbacks.onStreamChunk(update.content.text); } break; case 'agent_message_chunk': - // 处理助手消息块 + // Handle assistant message chunk if (update.content?.text && this.callbacks.onStreamChunk) { this.callbacks.onStreamChunk(update.content.text); } break; case 'agent_thought_chunk': - // 处理思考块 - 使用特殊回调 + // Handle thought chunk - use special callback console.log( '[SessionUpdateHandler] 🧠 THOUGHT CHUNK:', update.content?.text, @@ -73,7 +73,7 @@ export class QwenSessionUpdateHandler { ); this.callbacks.onThoughtChunk(update.content.text); } else if (this.callbacks.onStreamChunk) { - // 回退到常规流处理 + // Fallback to regular stream processing console.log( '[SessionUpdateHandler] 🧠 Falling back to onStreamChunk', ); @@ -83,7 +83,7 @@ export class QwenSessionUpdateHandler { break; case 'tool_call': { - // 处理新的工具调用 + // Handle new tool call if (this.callbacks.onToolCall && 'toolCallId' in update) { this.callbacks.onToolCall({ toolCallId: update.toolCallId as string, @@ -103,7 +103,7 @@ export class QwenSessionUpdateHandler { } case 'tool_call_update': { - // 处理工具调用状态更新 + // Handle tool call status update if (this.callbacks.onToolCall && 'toolCallId' in update) { this.callbacks.onToolCall({ toolCallId: update.toolCallId as string, @@ -123,7 +123,7 @@ export class QwenSessionUpdateHandler { } case 'plan': { - // 处理计划更新 + // Handle plan update if ('entries' in update) { const entries = update.entries as Array<{ content: string; @@ -134,7 +134,7 @@ export class QwenSessionUpdateHandler { if (this.callbacks.onPlan) { this.callbacks.onPlan(entries); } else if (this.callbacks.onStreamChunk) { - // 回退到流处理 + // Fallback to stream processing const planText = '\n📋 Plan:\n' + entries diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 5e5c55d3..26d49789 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -4,15 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useVSCode } from './hooks/useVSCode.js'; -import type { Conversation } from '../storage/conversationStore.js'; -import { - type PermissionOption, - type ToolCall as PermissionToolCall, +import { useSessionManagement } from './hooks/session/useSessionManagement.js'; +import { useFileContext } from './hooks/file/useFileContext.js'; +import { useMessageHandling } from './hooks/message/useMessageHandling.js'; +import { useToolCalls } from './hooks/useToolCalls.js'; +import { useWebViewMessages } from './hooks/useWebViewMessages.js'; +import { useMessageSubmit } from './hooks/useMessageSubmit.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, } from './components/PermissionRequest.js'; import { PermissionDrawer } from './components/PermissionDrawer.js'; -import { ToolCall, type ToolCallData } from './components/ToolCall.js'; +import { ToolCall } from './components/ToolCall.js'; import { hasToolCallOutput } from './components/toolcalls/shared/utils.js'; import { InProgressToolCall } from './components/InProgressToolCall.js'; import { EmptyState } from './components/EmptyState.js'; @@ -33,323 +38,79 @@ import { WaitingMessage, } from './components/messages/index.js'; import { InputForm } from './components/InputForm.js'; - -interface ToolCallUpdate { - type: 'tool_call' | 'tool_call_update'; - toolCallId: string; - kind?: string; - title?: string; - status?: 'pending' | 'in_progress' | 'completed' | 'failed'; - rawInput?: unknown; - content?: Array<{ - type: 'content' | 'diff'; - content?: { - type: string; - text?: string; - [key: string]: unknown; - }; - path?: string; - oldText?: string | null; - newText?: string; - [key: string]: unknown; - }>; - locations?: Array<{ - path: string; - line?: number | null; - }>; -} - -interface TextMessage { - role: 'user' | 'assistant' | 'thinking'; - content: string; - timestamp: number; - fileContext?: { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - }; -} - -// Loading messages from Claude Code CLI -// Source: packages/cli/src/ui/hooks/usePhraseCycler.ts -const WITTY_LOADING_PHRASES = [ - "I'm Feeling Lucky", - 'Shipping awesomeness... ', - 'Painting the serifs back on...', - 'Navigating the slime mold...', - 'Consulting the digital spirits...', - 'Reticulating splines...', - 'Warming up the AI hamsters...', - 'Asking the magic conch shell...', - 'Generating witty retort...', - 'Polishing the algorithms...', - "Don't rush perfection (or my code)...", - 'Brewing fresh bytes...', - 'Counting electrons...', - 'Engaging cognitive processors...', - 'Checking for syntax errors in the universe...', - 'One moment, optimizing humor...', - 'Shuffling punchlines...', - 'Untangling neural nets...', - 'Compiling brilliance...', - 'Loading wit.exe...', - 'Summoning the cloud of wisdom...', - 'Preparing a witty response...', - "Just a sec, I'm debugging reality...", - 'Confuzzling the options...', - 'Tuning the cosmic frequencies...', - 'Crafting a response worthy of your patience...', - 'Compiling the 1s and 0s...', - 'Resolving dependencies... and existential crises...', - 'Defragmenting memories... both RAM and personal...', - 'Rebooting the humor module...', - 'Caching the essentials (mostly cat memes)...', - 'Optimizing for ludicrous speed', - "Swapping bits... don't tell the bytes...", - 'Garbage collecting... be right back...', - 'Assembling the interwebs...', - 'Converting coffee into code...', - 'Updating the syntax for reality...', - 'Rewiring the synapses...', - 'Looking for a misplaced semicolon...', - "Greasin' the cogs of the machine...", - 'Pre-heating the servers...', - 'Calibrating the flux capacitor...', - 'Engaging the improbability drive...', - 'Channeling the Force...', - 'Aligning the stars for optimal response...', - 'So say we all...', - 'Loading the next great idea...', - "Just a moment, I'm in the zone...", - 'Preparing to dazzle you with brilliance...', - "Just a tick, I'm polishing my wit...", - "Hold tight, I'm crafting a masterpiece...", - "Just a jiffy, I'm debugging the universe...", - "Just a moment, I'm aligning the pixels...", - "Just a sec, I'm optimizing the humor...", - "Just a moment, I'm tuning the algorithms...", - 'Warp speed engaged...', - 'Mining for more Dilithium crystals...', - "Don't panic...", - 'Following the white rabbit...', - 'The truth is in here... somewhere...', - 'Blowing on the cartridge...', - 'Loading... Do a barrel roll!', - 'Waiting for the respawn...', - 'Finishing the Kessel Run in less than 12 parsecs...', - "The cake is not a lie, it's just still loading...", - 'Fiddling with the character creation screen...', - "Just a moment, I'm finding the right meme...", - "Pressing 'A' to continue...", - 'Herding digital cats...', - 'Polishing the pixels...', - 'Finding a suitable loading screen pun...', - 'Distracting you with this witty phrase...', - 'Almost there... probably...', - 'Our hamsters are working as fast as they can...', - 'Giving Cloudy a pat on the head...', - 'Petting the cat...', - 'Rickrolling my boss...', - 'Never gonna give you up, never gonna let you down...', - 'Slapping the bass...', - 'Tasting the snozberries...', - "I'm going the distance, I'm going for speed...", - 'Is this the real life? Is this just fantasy?...', - "I've got a good feeling about this...", - 'Poking the bear...', - 'Doing research on the latest memes...', - 'Figuring out how to make this more witty...', - 'Hmmm... let me think...', - 'What do you call a fish with no eyes? A fsh...', - 'Why did the computer go to therapy? It had too many bytes...', - "Why don't programmers like nature? It has too many bugs...", - 'Why do programmers prefer dark mode? Because light attracts bugs...', - 'Why did the developer go broke? Because they used up all their cache...', - "What can you do with a broken pencil? Nothing, it's pointless...", - 'Applying percussive maintenance...', - 'Searching for the correct USB orientation...', - 'Ensuring the magic smoke stays inside the wires...', - 'Rewriting in Rust for no particular reason...', - 'Trying to exit Vim...', - 'Spinning up the hamster wheel...', - "That's not a bug, it's an undocumented feature...", - 'Engage.', - "I'll be back... with an answer.", - 'My other process is a TARDIS...', - 'Communing with the machine spirit...', - 'Letting the thoughts marinate...', - 'Just remembered where I put my keys...', - 'Pondering the orb...', - "I've seen things you people wouldn't believe... like a user who reads loading messages.", - 'Initiating thoughtful gaze...', - "What's a computer's favorite snack? Microchips.", - "Why do Java developers wear glasses? Because they don't C#.", - 'Charging the laser... pew pew!', - 'Dividing by zero... just kidding!', - 'Looking for an adult superviso... I mean, processing.', - 'Making it go beep boop.', - 'Buffering... because even AIs need a moment.', - 'Entangling quantum particles for a faster response...', - 'Polishing the chrome... on the algorithms.', - 'Are you not entertained? (Working on it!)', - 'Summoning the code gremlins... to help, of course.', - 'Just waiting for the dial-up tone to finish...', - 'Recalibrating the humor-o-meter.', - 'My other loading screen is even funnier.', - "Pretty sure there's a cat walking on the keyboard somewhere...", - 'Enhancing... Enhancing... Still loading.', - "It's not a bug, it's a feature... of this loading screen.", - 'Have you tried turning it off and on again? (The loading screen, not me.)', - 'Constructing additional pylons...', - "New line? That's Ctrl+J.", -]; - -const getRandomLoadingMessage = () => - WITTY_LOADING_PHRASES[ - Math.floor(Math.random() * WITTY_LOADING_PHRASES.length) - ]; - -type EditMode = 'ask' | 'auto' | 'plan'; +import { SessionSelector } from './components/session/SessionSelector.js'; +import { FileIcon, UserIcon } from './components/icons/index.js'; +import type { EditMode } from './types/toolCall.js'; export const App: React.FC = () => { const vscode = useVSCode(); - const [messages, setMessages] = useState([]); + + // Core hooks + const sessionManagement = useSessionManagement(vscode); + const fileContext = useFileContext(vscode); + const messageHandling = useMessageHandling(); + const { + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + } = useToolCalls(); + + // UI state const [inputText, setInputText] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); - const [loadingMessage, setLoadingMessage] = useState(''); - const [currentStreamContent, setCurrentStreamContent] = useState(''); - const [qwenSessions, setQwenSessions] = useState< - Array> - >([]); - const [currentSessionId, setCurrentSessionId] = useState(null); - const [currentSessionTitle, setCurrentSessionTitle] = - useState('Past Conversations'); - const [showSessionSelector, setShowSessionSelector] = useState(false); - const [sessionSearchQuery, setSessionSearchQuery] = useState(''); const [permissionRequest, setPermissionRequest] = useState<{ options: PermissionOption[]; toolCall: PermissionToolCall; } | null>(null); - const [toolCalls, setToolCalls] = useState>( - new Map(), - ); const [planEntries, setPlanEntries] = useState([]); const messagesEndRef = useRef(null); const inputFieldRef = useRef(null); const [showBanner, setShowBanner] = useState(true); - const currentStreamContentRef = useRef(''); const [editMode, setEditMode] = useState('ask'); const [thinkingEnabled, setThinkingEnabled] = useState(false); - const [activeFileName, setActiveFileName] = useState(null); - const [activeFilePath, setActiveFilePath] = useState(null); - const [activeSelection, setActiveSelection] = useState<{ - startLine: number; - endLine: number; - } | null>(null); const [isComposing, setIsComposing] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false); - const [savedSessionTags, setSavedSessionTags] = useState([]); - // Workspace files cache - const [workspaceFiles, setWorkspaceFiles] = useState< - Array<{ - id: string; - label: string; - description: string; - path: string; - }> - >([]); - - // File reference map: @filename -> full path - const fileReferenceMap = useRef>(new Map()); - - // Request workspace files on mount or when @ is first triggered - const hasRequestedFilesRef = useRef(false); - - // Debounce timer for search requests - const searchTimerRef = useRef(null); - - // Get completion items based on trigger character - const getCompletionItems = useCallback( + // Completion system + const getCompletionItems = React.useCallback( async (trigger: '@' | '/', query: string): Promise => { if (trigger === '@') { - // Request workspace files on first @ trigger - if (!hasRequestedFilesRef.current) { - hasRequestedFilesRef.current = true; - vscode.postMessage({ - type: 'getWorkspaceFiles', - data: {}, - }); + if (!fileContext.hasRequestedFiles) { + fileContext.requestWorkspaceFiles(); } - // Convert workspace files to completion items - const fileIcon = ( - - - + const fileIcon = ; + const allItems: CompletionItem[] = fileContext.workspaceFiles.map( + (file) => ({ + id: file.id, + label: file.label, + description: file.description, + type: 'file' as const, + icon: fileIcon, + value: file.path, + }), ); - // Convert all files to items - const allItems: CompletionItem[] = workspaceFiles.map((file) => ({ - id: file.id, - label: file.label, - description: file.description, - type: 'file' as const, - icon: fileIcon, - value: file.path, - })); - - // If query provided, filter locally AND request from backend (debounced) if (query && query.length >= 1) { - // Clear previous search timer - if (searchTimerRef.current) { - clearTimeout(searchTimerRef.current); - } - - // Debounce backend search request (300ms) - searchTimerRef.current = setTimeout(() => { - vscode.postMessage({ - type: 'getWorkspaceFiles', - data: { query }, - }); - }, 300); - - // Filter locally for immediate feedback + fileContext.requestWorkspaceFiles(query); const lowerQuery = query.toLowerCase(); - const filtered = allItems.filter( + return allItems.filter( (item) => item.label.toLowerCase().includes(lowerQuery) || (item.description && item.description.toLowerCase().includes(lowerQuery)), ); - - return filtered; } return allItems; } else { - // Slash commands - only /login for now const commands: CompletionItem[] = [ { id: 'login', label: '/login', description: 'Login to Qwen Code', type: 'command', - icon: ( - - - - ), + icon: , }, ]; @@ -358,25 +119,44 @@ export const App: React.FC = () => { ); } }, - [vscode, workspaceFiles], + [fileContext], ); - // Use completion trigger hook const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); - // Don't auto-refresh completion menu when workspace files update - // This was causing flickering. User can re-type to get fresh results. + // Message submission + const { handleSubmit } = useMessageSubmit({ + vscode, + inputText, + setInputText, + inputFieldRef, + isStreaming: messageHandling.isStreaming, + fileContext, + messageHandling, + }); - const handlePermissionRequest = React.useCallback( - (request: { - options: PermissionOption[]; - toolCall: PermissionToolCall; - }) => { - setPermissionRequest(request); - }, - [], - ); + // WebView messages + useWebViewMessages({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest: React.useCallback( + (request: { + options: PermissionOption[]; + toolCall: PermissionToolCall; + }) => { + setPermissionRequest(request); + }, + [], + ), + inputFieldRef, + setInputText, + }); + // Permission handling const handlePermissionResponse = React.useCallback( (optionId: string) => { vscode.postMessage({ @@ -388,8 +168,8 @@ export const App: React.FC = () => { [vscode], ); - // Handle completion item selection - const handleCompletionSelect = useCallback( + // Completion selection + const handleCompletionSelect = React.useCallback( (item: CompletionItem) => { if (!inputFieldRef.current) { return; @@ -399,14 +179,10 @@ export const App: React.FC = () => { const currentText = inputElement.textContent || ''; if (item.type === 'command') { - // Handle /login command directly if (item.label === '/login') { - // Clear input field inputElement.textContent = ''; setInputText(''); - // Close completion completion.closeCompletion(); - // Send login command to extension vscode.postMessage({ type: 'login', data: {}, @@ -414,11 +190,9 @@ export const App: React.FC = () => { return; } - // For other commands, replace entire input with command inputElement.textContent = item.label + ' '; setInputText(item.label + ' '); - // Move cursor to end setTimeout(() => { const range = document.createRange(); const sel = window.getSelection(); @@ -434,38 +208,26 @@ export const App: React.FC = () => { inputElement.focus(); }, 10); } else if (item.type === 'file') { - // Store file reference mapping const filePath = (item.value as string) || item.label; - fileReferenceMap.current.set(item.label, filePath); + fileContext.addFileReference(item.label, filePath); - console.log('[handleCompletionSelect] Current text:', currentText); - console.log('[handleCompletionSelect] Selected file:', item.label); - - // Find the @ position in current text const atPos = currentText.lastIndexOf('@'); if (atPos !== -1) { - // Find the end of the query (could be at cursor or at next space/end) const textAfterAt = currentText.substring(atPos + 1); const spaceIndex = textAfterAt.search(/[\s\n]/); const queryEnd = spaceIndex === -1 ? currentText.length : atPos + 1 + spaceIndex; - // Replace from @ to end of query with @filename const textBefore = currentText.substring(0, atPos); const textAfter = currentText.substring(queryEnd); const newText = `${textBefore}@${item.label} ${textAfter}`; - console.log('[handleCompletionSelect] New text:', newText); - - // Update the input inputElement.textContent = newText; setInputText(newText); - // Set cursor after the inserted filename (after the space) - const newCursorPos = atPos + item.label.length + 2; // +1 for @, +1 for space + const newCursorPos = atPos + item.label.length + 2; - // Wait for DOM to update, then set cursor setTimeout(() => { const textNode = inputElement.firstChild; if (textNode && textNode.nodeType === Node.TEXT_NODE) { @@ -481,11 +243,7 @@ export const App: React.FC = () => { selection.removeAllRanges(); selection.addRange(range); } catch (e) { - console.error( - '[handleCompletionSelect] Error setting cursor:', - e, - ); - // Fallback: move cursor to end + console.error('[handleCompletionSelect] Error:', e); range.selectNodeContents(inputElement); range.collapse(false); selection.removeAllRanges(); @@ -498,25 +256,21 @@ export const App: React.FC = () => { } } - // Close completion completion.closeCompletion(); }, - [completion, vscode], + [completion, vscode, fileContext], ); - // Handle attach context button click (Cmd/Ctrl + /) - const handleAttachContextClick = useCallback(async () => { + // Attach context (Cmd/Ctrl + /) + const handleAttachContextClick = React.useCallback(async () => { if (inputFieldRef.current) { - // Focus the input first inputFieldRef.current.focus(); - // Insert @ at the end of current text const currentText = inputFieldRef.current.textContent || ''; const newText = currentText ? `${currentText} @` : '@'; inputFieldRef.current.textContent = newText; setInputText(newText); - // Move cursor to end const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(inputFieldRef.current); @@ -524,13 +278,11 @@ export const App: React.FC = () => { sel?.removeAllRanges(); sel?.addRange(range); - // Wait for DOM to update before getting position and opening menu requestAnimationFrame(async () => { if (!inputFieldRef.current) { return; } - // Get cursor position for menu placement let position = { top: 0, left: 0 }; const selection = window.getSelection(); @@ -557,16 +309,14 @@ export const App: React.FC = () => { position = { top: inputRect.top, left: inputRect.left }; } - // Open completion menu with @ trigger await completion.openCompletion('@', '', position); }); } }, [completion]); - // Handle keyboard shortcut for attach context (Cmd/Ctrl + /) + // Keyboard shortcut for attach context useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Cmd/Ctrl + / for attach context if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); handleAttachContextClick(); @@ -577,488 +327,22 @@ export const App: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [handleAttachContextClick]); - // Handle removing context attachment - const handleToolCallUpdate = React.useCallback( - (update: ToolCallUpdate) => { - console.log('[App] handleToolCallUpdate:', { - type: update.type, - toolCallId: update.toolCallId, - kind: update.kind, - title: update.title, - status: update.status, - }); - - setToolCalls((prevToolCalls) => { - const newMap = new Map(prevToolCalls); - const existing = newMap.get(update.toolCallId); - - console.log( - '[App] existing tool call:', - existing - ? { - kind: existing.kind, - title: existing.title, - status: existing.status, - } - : 'not found', - ); - - // Helper function to safely convert title to string - const safeTitle = (title: unknown): string => { - if (typeof title === 'string') { - return title; - } - if (title && typeof title === 'object') { - return JSON.stringify(title); - } - return 'Tool Call'; - }; - - if (update.type === 'tool_call') { - // New tool call - cast content to proper type - const content = update.content?.map((item) => ({ - type: item.type as 'content' | 'diff', - content: item.content, - path: item.path, - oldText: item.oldText, - newText: item.newText, - })); - - newMap.set(update.toolCallId, { - toolCallId: update.toolCallId, - kind: update.kind || 'other', - title: safeTitle(update.title), - status: update.status || 'pending', - rawInput: update.rawInput as string | object | undefined, - content, - locations: update.locations, - }); - } else if (update.type === 'tool_call_update') { - // Update existing tool call, or create if it doesn't exist - const updatedContent = update.content - ? update.content.map((item) => ({ - type: item.type as 'content' | 'diff', - content: item.content, - path: item.path, - oldText: item.oldText, - newText: item.newText, - })) - : undefined; - - if (existing) { - // Update existing tool call - merge content arrays - const mergedContent = updatedContent - ? [...(existing.content || []), ...updatedContent] - : existing.content; - - newMap.set(update.toolCallId, { - ...existing, - ...(update.kind && { kind: update.kind }), - ...(update.title && { title: safeTitle(update.title) }), - ...(update.status && { status: update.status }), - content: mergedContent, - ...(update.locations && { locations: update.locations }), - }); - } else { - // Create new tool call if it doesn't exist (missed the initial tool_call message) - newMap.set(update.toolCallId, { - toolCallId: update.toolCallId, - kind: update.kind || 'other', - title: update.title ? safeTitle(update.title) : '', - status: update.status || 'pending', - rawInput: update.rawInput as string | object | undefined, - content: updatedContent, - locations: update.locations, - }); - } - } - - return newMap; - }); - }, - [setToolCalls], - ); - - const handleSaveSession = useCallback( - (tag: string) => { - // Send save session request to extension - vscode.postMessage({ - type: 'saveSession', - data: { tag }, - }); - setShowSaveDialog(false); - }, - [vscode], - ); - - // Handle save session response - const handleSaveSessionResponse = useCallback( - (response: { success: boolean; message?: string }) => { - if (response.success) { - // Add the new tag to saved session tags - if (response.message) { - const tagMatch = response.message.match(/tag: (.+)$/); - if (tagMatch) { - setSavedSessionTags((prev) => [...prev, tagMatch[1]]); - } - } - } else { - // Handle error - could show a toast or error message - console.error('Failed to save session:', response.message); - } - }, - [setSavedSessionTags], - ); - + // Auto-scroll to latest message useEffect(() => { - // Listen for messages from extension - const handleMessage = (event: MessageEvent) => { - const message = event.data; - // console.log('[App] Received message from extension:', message.type, message); - - switch (message.type) { - case 'conversationLoaded': { - const conversation = message.data as Conversation; - setMessages(conversation.messages); - break; - } - - case 'message': { - const newMessage = message.data as TextMessage; - setMessages((prev) => [...prev, newMessage]); - break; - } - - case 'streamStart': - setIsStreaming(true); - setCurrentStreamContent(''); - currentStreamContentRef.current = ''; - break; - - case 'streamChunk': { - const chunkData = message.data; - if (chunkData.role === 'thinking') { - // Handle thinking chunks separately if needed - setCurrentStreamContent((prev) => { - const newContent = prev + chunkData.chunk; - currentStreamContentRef.current = newContent; - return newContent; - }); - } else { - setCurrentStreamContent((prev) => { - const newContent = prev + chunkData.chunk; - currentStreamContentRef.current = newContent; - return newContent; - }); - } - break; - } - - case 'thoughtChunk': { - const chunkData = message.data; - console.log('[App] 🧠 THOUGHT CHUNK RECEIVED:', chunkData); - // Handle thought chunks for AI thinking display - const thinkingMessage: TextMessage = { - role: 'thinking', - content: chunkData.content || chunkData.chunk || '', - timestamp: Date.now(), - }; - console.log('[App] 🧠 Adding thinking message:', thinkingMessage); - setMessages((prev) => [...prev, thinkingMessage]); - break; - } - - case 'streamEnd': - // Finalize the streamed message - if (currentStreamContentRef.current) { - const assistantMessage: TextMessage = { - role: 'assistant', - content: currentStreamContentRef.current, - timestamp: Date.now(), - }; - setMessages((prev) => [...prev, assistantMessage]); - } - setIsStreaming(false); - setIsWaitingForResponse(false); // Clear waiting state - setCurrentStreamContent(''); - currentStreamContentRef.current = ''; - break; - - case 'error': - setIsStreaming(false); - setIsWaitingForResponse(false); - break; - - // case 'notLoggedIn': - // // Show not logged in message with login button - // console.log('[App] Received notLoggedIn message:', message.data); - // setIsStreaming(false); - // setIsWaitingForResponse(false); - // setNotLoggedInMessage( - // (message.data as { message: string })?.message || - // 'Please login to start chatting.', - // ); - // console.log('[App] Set notLoggedInMessage to:', (message.data as { message: string })?.message); - // break; - - case 'permissionRequest': { - // Show permission dialog - handlePermissionRequest(message.data); - - // Also create a tool call entry for the permission request - // This ensures that if it's rejected, we can show it properly - const permToolCall = message.data?.toolCall as { - toolCallId?: string; - kind?: string; - title?: string; - status?: string; - content?: unknown[]; - locations?: Array<{ path: string; line?: number | null }>; - }; - - if (permToolCall?.toolCallId) { - // Infer kind from title if not provided - let kind = permToolCall.kind || 'execute'; - if (permToolCall.title) { - const title = permToolCall.title.toLowerCase(); - if (title.includes('touch') || title.includes('echo')) { - kind = 'execute'; - } else if (title.includes('read') || title.includes('cat')) { - kind = 'read'; - } else if (title.includes('write') || title.includes('edit')) { - kind = 'edit'; - } - } - - // Normalize status to our union type - const normalizedStatus = ( - permToolCall.status === 'pending' || - permToolCall.status === 'in_progress' || - permToolCall.status === 'completed' || - permToolCall.status === 'failed' - ? permToolCall.status - : 'pending' - ) as ToolCallUpdate['status']; - - handleToolCallUpdate({ - type: 'tool_call', - toolCallId: permToolCall.toolCallId, - kind, - title: permToolCall.title, - status: normalizedStatus, - content: permToolCall.content as Array<{ - type: 'content' | 'diff'; - content?: { - type: string; - text?: string; - [key: string]: unknown; - }; - path?: string; - oldText?: string | null; - newText?: string; - [key: string]: unknown; - }>, - locations: permToolCall.locations, - }); - } - break; - } - - case 'plan': - // Update plan entries - console.log('[App] Plan received:', message.data); - if (message.data.entries && Array.isArray(message.data.entries)) { - setPlanEntries(message.data.entries as PlanEntry[]); - } - break; - - case 'toolCall': - case 'toolCallUpdate': { - // Handle tool call updates - // Convert sessionUpdate to type if needed - const toolCallData = message.data; - if (toolCallData.sessionUpdate && !toolCallData.type) { - toolCallData.type = toolCallData.sessionUpdate; - } - handleToolCallUpdate(toolCallData); - break; - } - case 'qwenSessionList': { - const sessions = message.data.sessions || []; - setQwenSessions(sessions); - // Only update title if we have a current session selected - if (currentSessionId && sessions.length > 0) { - // Update title for the current session if it exists in the list - const currentSession = sessions.find( - (s: Record) => - (s.id as string) === currentSessionId || - (s.sessionId as string) === currentSessionId, - ); - if (currentSession) { - const title = - (currentSession.title as string) || - (currentSession.name as string) || - 'Past Conversations'; - setCurrentSessionTitle(title); - } - } - break; - } - - case 'qwenSessionSwitched': - console.log('[App] Session switched:', message.data); - setShowSessionSelector(false); - // Update current session ID - if (message.data.sessionId) { - setCurrentSessionId(message.data.sessionId as string); - console.log( - '[App] Current session ID updated to:', - message.data.sessionId, - ); - } - // Update current session title from session object - if (message.data.session) { - const session = message.data.session as Record; - const title = - (session.title as string) || - (session.name as string) || - 'Past Conversations'; - setCurrentSessionTitle(title); - console.log('[App] Session title updated to:', title); - } - // Load messages from the session - if (message.data.messages) { - console.log( - '[App] Loading messages:', - message.data.messages.length, - ); - setMessages(message.data.messages); - } else { - console.log('[App] No messages in session, clearing'); - setMessages([]); - } - setCurrentStreamContent(''); - setToolCalls(new Map()); - setPlanEntries([]); // Clear plan entries when switching sessions - break; - - case 'conversationCleared': - setMessages([]); - setCurrentStreamContent(''); - setToolCalls(new Map()); - // Reset session ID and title when conversation is cleared (new session created) - setCurrentSessionId(null); - setCurrentSessionTitle('Past Conversations'); - break; - - case 'sessionTitleUpdated': { - // Update session title when first message is sent - const sessionId = message.data?.sessionId as string; - const title = message.data?.title as string; - if (sessionId && title) { - console.log('[App] Session title updated:', title); - setCurrentSessionId(sessionId); - setCurrentSessionTitle(title); - } - break; - } - - case 'activeEditorChanged': { - // 从扩展接收当前激活编辑器的文件名和选中的行号 - const fileName = message.data?.fileName as string | null; - const filePath = message.data?.filePath as string | null; - const selection = message.data?.selection as { - startLine: number; - endLine: number; - } | null; - setActiveFileName(fileName); - setActiveFilePath(filePath); - setActiveSelection(selection); - break; - } - - case 'fileAttached': { - // Handle file attachment from VSCode - insert as @mention - const attachment = message.data as { - id: string; - type: string; - name: string; - value: string; - }; - - // Store file reference - fileReferenceMap.current.set(attachment.name, attachment.value); - - // Insert @filename into input - if (inputFieldRef.current) { - const currentText = inputFieldRef.current.textContent || ''; - const newText = currentText - ? `${currentText} @${attachment.name} ` - : `@${attachment.name} `; - inputFieldRef.current.textContent = newText; - setInputText(newText); - - // Move cursor to end - const range = document.createRange(); - const sel = window.getSelection(); - range.selectNodeContents(inputFieldRef.current); - range.collapse(false); - sel?.removeAllRanges(); - sel?.addRange(range); - } - break; - } - - case 'workspaceFiles': { - // Handle workspace files list from VSCode - const files = message.data?.files as Array<{ - id: string; - label: string; - description: string; - path: string; - }>; - if (files) { - setWorkspaceFiles(files); - } - break; - } - - case 'saveSessionResponse': { - // Handle save session response - handleSaveSessionResponse(message.data); - break; - } - - default: - break; - } - }; - - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, [ - currentSessionId, - handlePermissionRequest, - handleToolCallUpdate, - handleSaveSessionResponse, - ]); - - useEffect(() => { - // Auto-scroll to bottom when messages change messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, currentStreamContent]); + }, [messageHandling.messages, messageHandling.currentStreamContent]); - // Load sessions on component mount + // Load sessions on mount useEffect(() => { vscode.postMessage({ type: 'getQwenSessions', data: {} }); }, [vscode]); - // Request current active editor on component mount + // Request active editor on mount useEffect(() => { - vscode.postMessage({ type: 'getActiveEditor', data: {} }); - }, [vscode]); + fileContext.requestActiveEditor(); + }, [fileContext]); - // Toggle edit mode: ask → auto → plan → ask + // Toggle edit mode const handleToggleEditMode = () => { setEditMode((prev) => { if (prev === 'ask') { @@ -1071,344 +355,37 @@ export const App: React.FC = () => { }); }; - // Toggle thinking on/off const handleToggleThinking = () => { setThinkingEnabled((prev) => !prev); }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - if (!inputText.trim() || isStreaming) { - return; - } - - // Check if this is a /login command - if (inputText.trim() === '/login') { - // Clear input field - setInputText(''); - if (inputFieldRef.current) { - inputFieldRef.current.textContent = ''; - } - // Send login command to extension - vscode.postMessage({ - type: 'login', - data: {}, - }); - return; - } - - // Set waiting state with random loading message - setIsWaitingForResponse(true); - setLoadingMessage(getRandomLoadingMessage()); - - // Parse @file references from input text - const context: Array<{ - type: string; - name: string; - value: string; - startLine?: number; - endLine?: number; - }> = []; - const fileRefPattern = /@([^\s]+)/g; - let match; - - while ((match = fileRefPattern.exec(inputText)) !== null) { - const fileName = match[1]; - const filePath = fileReferenceMap.current.get(fileName); - - if (filePath) { - context.push({ - type: 'file', - name: fileName, - value: filePath, - }); - } - } - - // Add active file selection context if present - if (activeFilePath) { - const fileName = activeFileName || 'current file'; - context.push({ - type: 'file', - name: fileName, - value: activeFilePath, - startLine: activeSelection?.startLine, - endLine: activeSelection?.endLine, - }); - } - - // Build file context for the message - let fileContextForMessage: - | { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - } - | undefined; - - if (activeFilePath && activeFileName) { - fileContextForMessage = { - fileName: activeFileName, - filePath: activeFilePath, - startLine: activeSelection?.startLine, - endLine: activeSelection?.endLine, - }; - } - - vscode.postMessage({ - type: 'sendMessage', - data: { - text: inputText, - context: context.length > 0 ? context : undefined, - fileContext: fileContextForMessage, - }, - }); - - // Clear input field and file reference map - setInputText(''); - if (inputFieldRef.current) { - inputFieldRef.current.textContent = ''; - } - fileReferenceMap.current.clear(); - }; - - const handleLoadQwenSessions = () => { - vscode.postMessage({ type: 'getQwenSessions', data: {} }); - setShowSessionSelector(true); - }; - - const handleNewQwenSession = () => { - // Send message to open a new chat tab - vscode.postMessage({ type: 'openNewChatTab', data: {} }); - setShowSessionSelector(false); - }; - - // Time ago formatter (matching Claude Code) - 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(); - }; - - // Group sessions by date (matching Claude Code) - const groupSessionsByDate = ( - sessions: Array>, - ): Array<{ label: string; sessions: Array> }> => { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const groups: { - [key: string]: Array>; - } = { - Today: [], - Yesterday: [], - 'This Week': [], - Older: [], - }; - - sessions.forEach((session) => { - const timestamp = - (session.lastUpdated as string) || (session.startTime as string) || ''; - if (!timestamp) { - groups['Older'].push(session); - return; - } - - const sessionDate = new Date(timestamp); - const sessionDay = new Date( - sessionDate.getFullYear(), - sessionDate.getMonth(), - sessionDate.getDate(), - ); - - if (sessionDay.getTime() === today.getTime()) { - groups['Today'].push(session); - } else if (sessionDay.getTime() === yesterday.getTime()) { - groups['Yesterday'].push(session); - } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { - groups['This Week'].push(session); - } else { - groups['Older'].push(session); - } - }); - - return Object.entries(groups) - .filter(([, sessions]) => sessions.length > 0) - .map(([label, sessions]) => ({ label, sessions })); - }; - - // Filter sessions by search query - const filteredSessions = React.useMemo(() => { - if (!sessionSearchQuery.trim()) { - return qwenSessions; - } - const query = sessionSearchQuery.toLowerCase(); - return qwenSessions.filter((session) => { - const title = ( - (session.title as string) || - (session.name as string) || - '' - ).toLowerCase(); - return title.includes(query); - }); - }, [qwenSessions, sessionSearchQuery]); - - const handleSwitchSession = (sessionId: string) => { - if (sessionId === currentSessionId) { - console.log('[App] Already on this session, ignoring'); - setShowSessionSelector(false); - return; - } - - console.log('[App] Switching to session:', sessionId); - vscode.postMessage({ - type: 'switchQwenSession', - data: { sessionId }, - }); - // Don't set currentSessionId or close selector here - wait for qwenSessionSwitched response - }; - - // Check if there are any messages or active content const hasContent = - messages.length > 0 || - isStreaming || - toolCalls.size > 0 || + messageHandling.messages.length > 0 || + messageHandling.isStreaming || + inProgressToolCalls.length > 0 || + completedToolCalls.length > 0 || planEntries.length > 0; return (
- {showSessionSelector && ( - <> -
setShowSessionSelector(false)} - /> -
e.stopPropagation()} - > - {/* Search Box */} -
- - setSessionSearchQuery(e.target.value)} - /> -
- - {/* Session List with Grouping */} -
- {filteredSessions.length === 0 ? ( -
- {sessionSearchQuery - ? 'No matching sessions' - : 'No sessions available'} -
- ) : ( - groupSessionsByDate(filteredSessions).map((group) => ( - -
{group.label}
-
- {group.sessions.map((session) => { - const sessionId = - (session.id as string) || - (session.sessionId as string) || - ''; - const title = - (session.title as string) || - (session.name as string) || - 'Untitled'; - const lastUpdated = - (session.lastUpdated as string) || - (session.startTime as string) || - ''; - const isActive = sessionId === currentSessionId; - - return ( - - ); - })} -
-
- )) - )} -
-
- - )} + { + sessionManagement.handleSwitchSession(sessionId); + sessionManagement.setSessionSearchQuery(''); + }} + onClose={() => sessionManagement.setShowSessionSelector(false)} + /> setShowSaveDialog(true)} - onNewSession={handleNewQwenSession} + onNewSession={sessionManagement.handleNewQwenSession} />
{ ) : ( <> - {messages.map((msg, index) => { + {messageHandling.messages.map((msg, index) => { const handleFileClick = (path: string) => { vscode.postMessage({ type: 'openFile', @@ -1460,76 +437,44 @@ export const App: React.FC = () => { ); })} - {/* In-Progress Tool Calls - show only pending/in_progress */} - {Array.from(toolCalls.values()) - .filter( - (toolCall) => - toolCall.status === 'pending' || - toolCall.status === 'in_progress', - ) - .map((toolCall) => ( - - ))} + {inProgressToolCalls.map((toolCall) => ( + + ))} - {/* Completed Tool Calls - only show those with actual output */} - {Array.from(toolCalls.values()) - .filter( - (toolCall) => - (toolCall.status === 'completed' || - toolCall.status === 'failed') && - hasToolCallOutput(toolCall), - ) - .map((toolCall) => ( - - ))} + {completedToolCalls.filter(hasToolCallOutput).map((toolCall) => ( + + ))} - {/* Plan Display - shows task list when available */} {planEntries.length > 0 && } - {/* Loading/Waiting Message - in message list */} - {isWaitingForResponse && loadingMessage && ( - - )} + {messageHandling.isWaitingForResponse && + messageHandling.loadingMessage && ( + + )} - {/* Not Logged In Message with Login Button - COMMENTED OUT */} - {/* {notLoggedInMessage && ( - <> - {console.log('[App] Rendering NotLoggedInMessage with message:', notLoggedInMessage)} - { - setNotLoggedInMessage(null); + {messageHandling.isStreaming && + messageHandling.currentStreamContent && ( + { vscode.postMessage({ - type: 'login', - data: {}, + type: 'openFile', + data: { path }, }); }} - onDismiss={() => setNotLoggedInMessage(null)} /> - - )} */} - - {isStreaming && currentStreamContent && ( - { - vscode.postMessage({ - type: 'openFile', - data: { path }, - }); - }} - /> - )} + )}
)}
- {/* Info Banner */} setShowBanner(false)} @@ -1542,12 +487,12 @@ export const App: React.FC = () => { setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} @@ -1555,12 +500,7 @@ export const App: React.FC = () => { onSubmit={handleSubmit} onToggleEditMode={handleToggleEditMode} onToggleThinking={handleToggleThinking} - onFocusActiveEditor={() => { - vscode.postMessage({ - type: 'focusActiveEditor', - data: {}, - }); - }} + onFocusActiveEditor={fileContext.focusActiveEditor} onShowCommandMenu={async () => { if (inputFieldRef.current) { inputFieldRef.current.focus(); @@ -1599,15 +539,13 @@ export const App: React.FC = () => { completionIsOpen={completion.isOpen} /> - {/* Save Session Dialog */} setShowSaveDialog(false)} - onSave={handleSaveSession} - existingTags={savedSessionTags} + onSave={sessionManagement.handleSaveSession} + existingTags={sessionManagement.savedSessionTags} /> - {/* Permission Drawer - Cursor style */} {permissionRequest && ( { /> )} - {/* Completion Menu for @ and / */} {completion.isOpen && completion.items.length > 0 && ( void; - // 当前消息列表 - private messages: ChatMessage[] = []; - // 登录处理器 - private loginHandler?: () => Promise; - // 待发送消息(登录后自动重发) - private pendingMessage: string | null = null; - // 标记是否正在执行后台保存命令 - private isSavingCheckpoint = false; + private router: MessageRouter; constructor( - private agentManager: QwenAgentManager, - private conversationStore: ConversationStore, - private currentConversationId: string | null, - private sendToWebView: (message: unknown) => void, - ) {} - - /** - * 设置登录处理器 - */ - setLoginHandler(handler: () => Promise): void { - this.loginHandler = handler; + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.router = new MessageRouter( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); } /** - * 检查是否正在后台保存 checkpoint + * 路由消息到对应的处理器 */ - getIsSavingCheckpoint(): boolean { - return this.isSavingCheckpoint; + async route(message: { type: string; data?: unknown }): Promise { + await this.router.route(message); } /** - * 获取当前对话 ID - */ - getCurrentConversationId(): string | null { - return this.currentConversationId; - } - - /** - * 设置当前对话 ID + * 设置当前会话 ID */ setCurrentConversationId(id: string | null): void { - this.currentConversationId = id; + this.router.setCurrentConversationId(id); } /** - * 获取当前流式内容 + * 获取当前会话 ID */ - getCurrentStreamContent(): string { - return this.currentStreamContent; - } - - /** - * 追加流式内容 - */ - appendStreamContent(chunk: string): void { - this.currentStreamContent += chunk; - } - - /** - * 重置流式内容 - */ - resetStreamContent(): void { - this.currentStreamContent = ''; + getCurrentConversationId(): string | null { + return this.router.getCurrentConversationId(); } /** * 设置权限处理器 */ setPermissionHandler( - handler: (msg: { type: string; data: { optionId: string } }) => void, + handler: (message: { type: string; data: { optionId: string } }) => void, ): void { - this.permissionHandler = handler; + this.router.setPermissionHandler(handler); } /** - * 清除权限处理器 + * 设置登录处理器 */ - clearPermissionHandler(): void { - this.permissionHandler = undefined; + setLoginHandler(handler: () => Promise): void { + this.router.setLoginHandler(handler); } /** - * 路由 WebView 消息到对应的处理函数 + * 追加流式内容 */ - async route(message: { type: string; data?: unknown }): Promise { - console.log('[MessageHandler] Received message from webview:', message); - - // Type guard for safe access to data properties - const data = message.data as Record | undefined; - - switch (message.type) { - case 'sendMessage': - await this.handleSendMessage( - (data?.text as string) || '', - data?.context as - | Array<{ - type: string; - name: string; - value: string; - startLine?: number; - endLine?: number; - }> - | undefined, - data?.fileContext as - | { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - } - | undefined, - ); - break; - - case 'permissionResponse': - // Forward to permission handler - if (this.permissionHandler) { - this.permissionHandler( - message as { type: string; data: { optionId: string } }, - ); - this.clearPermissionHandler(); - } - break; - - case 'loadConversation': - await this.handleLoadConversation((data?.id as string) || ''); - break; - - case 'newConversation': - await this.handleNewConversation(); - break; - - case 'newQwenSession': - await this.handleNewQwenSession(); - break; - - case 'deleteConversation': - await this.handleDeleteConversation((data?.id as string) || ''); - break; - - case 'getQwenSessions': - await this.handleGetQwenSessions(); - break; - - case 'getActiveEditor': { - // 发送当前激活编辑器的文件名和选中的行号给 WebView - const editor = vscode.window.activeTextEditor; - const filePath = editor?.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (editor && !editor.selection.isEmpty) { - const selection = editor.selection; - selectionInfo = { - startLine: selection.start.line + 1, // VSCode is 0-indexed, display as 1-indexed - endLine: selection.end.line + 1, - }; - } - - this.sendToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - break; - } - - case 'focusActiveEditor': { - // 聚焦到当前激活的编辑器 - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - vscode.window.showTextDocument(activeEditor.document, { - viewColumn: activeEditor.viewColumn, - preserveFocus: false, - }); - } - break; - } - - case 'switchQwenSession': - await this.handleSwitchQwenSession((data?.sessionId as string) || ''); - break; - - case 'recheckCli': - // Clear cache and recheck CLI installation - CliDetector.clearCache(); - await CliInstaller.checkInstallation(this.sendToWebView); - break; - - case 'cancelPrompt': - await this.handleCancelPrompt(); - break; - - case 'openFile': - await FileOperations.openFile(data?.path as string | undefined); - break; - - case 'openDiff': - console.log('[MessageHandler] openDiff called with:', data); - await vscode.commands.executeCommand('qwenCode.showDiff', { - path: (data as { path?: string })?.path || '', - oldText: (data as { oldText?: string })?.oldText || '', - newText: (data as { newText?: string })?.newText || '', - }); - break; - - case 'openNewChatTab': - // Create a new WebviewPanel (tab) in the same view column - await vscode.commands.executeCommand('qwenCode.openNewChatTab'); - break; - - case 'attachFile': - await this.handleAttachFile(); - break; - - case 'showContextPicker': - await this.handleShowContextPicker(); - break; - - case 'getWorkspaceFiles': - await this.handleGetWorkspaceFiles(data?.query as string); - break; - - case 'saveSession': - await this.handleSaveSession(data?.tag as string); - break; - - case 'resumeSession': - await this.handleResumeSession(data?.sessionId as string); - break; - - case 'openSettings': - await this.handleOpenSettings(); - break; - - case 'login': - await this.handleLogin(); - break; - - default: - console.warn('[MessageHandler] Unknown message type:', message.type); - break; - } + appendStreamContent(chunk: string): void { + this.router.appendStreamContent(chunk); } /** - * 处理发送消息请求 + * 检查是否正在保存 checkpoint */ - private async handleSendMessage( - text: string, - context?: Array<{ - type: string; - name: string; - value: string; - startLine?: number; - endLine?: number; - }>, - fileContext?: { - fileName: string; - filePath: string; - startLine?: number; - endLine?: number; - }, - ): Promise { - console.log('[MessageHandler] handleSendMessage called with:', text); - console.log('[MessageHandler] Context:', context); - console.log('[MessageHandler] FileContext:', fileContext); - - // Format message with file context if present - let formattedText = text; - if (context && context.length > 0) { - const contextParts = context - .map((ctx) => { - if (ctx.startLine && ctx.endLine) { - // Include line numbers in the file reference - return `${ctx.value}#${ctx.startLine}${ctx.startLine !== ctx.endLine ? `-${ctx.endLine}` : ''}`; - } - return ctx.value; - }) - .join('\n'); - - // Prepend context to the message - formattedText = `${contextParts}\n\n${text}`; - } - - // Ensure we have an active conversation - create one if needed - if (!this.currentConversationId) { - console.log('[MessageHandler] No active conversation, creating one...'); - try { - const newConv = await this.conversationStore.createConversation(); - this.currentConversationId = newConv.id; - this.sendToWebView({ - type: 'conversationLoaded', - data: newConv, - }); - console.log( - '[MessageHandler] Created conversation:', - this.currentConversationId, - ); - } catch (error) { - const errorMsg = `Failed to create conversation: ${error}`; - console.error('[MessageHandler]', errorMsg); - vscode.window.showErrorMessage(errorMsg); - this.sendToWebView({ - type: 'error', - data: { message: errorMsg }, - }); - return; - } - } - - // Double check after creation attempt - if (!this.currentConversationId) { - const errorMsg = - 'Failed to create conversation. Please restart the extension.'; - console.error('[MessageHandler]', errorMsg); - vscode.window.showErrorMessage(errorMsg); - this.sendToWebView({ - type: 'error', - data: { message: errorMsg }, - }); - return; - } - - // Check if this is the first message by checking conversation messages - let isFirstMessage = false; - try { - const conversation = await this.conversationStore.getConversation( - this.currentConversationId, - ); - // First message if conversation has no messages yet - isFirstMessage = !conversation || conversation.messages.length === 0; - console.log('[MessageHandler] Is first message:', isFirstMessage); - } catch (error) { - console.error('[MessageHandler] Failed to check conversation:', error); - } - - // If this is the first message, generate and send session title - if (isFirstMessage) { - // Generate title from first message (max 50 characters) - const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); - console.log('[MessageHandler] Generated session title:', title); - - // Send title update to WebView - this.sendToWebView({ - type: 'sessionTitleUpdated', - data: { - sessionId: this.currentConversationId, - title, - }, - }); - } - - // Save user message (save original text, not formatted) - const userMessage: ChatMessage = { - role: 'user', - content: text, - timestamp: Date.now(), - }; - - await this.conversationStore.addMessage( - this.currentConversationId, - userMessage, - ); - console.log('[MessageHandler] User message saved to store'); - - // Send to WebView (show original text with file context) - this.sendToWebView({ - type: 'message', - data: { ...userMessage, fileContext }, - }); - console.log('[MessageHandler] User message sent to webview'); - - // Check if agent is connected - if (!this.agentManager.isConnected) { - console.warn( - '[MessageHandler] Agent is not connected, skipping AI response', - ); - - // Save pending message for auto-retry after login (save formatted text for AI) - this.pendingMessage = formattedText; - console.log( - '[MessageHandler] Saved pending message for retry after login', - ); - - // Show VSCode warning notification - const result = await vscode.window.showWarningMessage( - 'You need to login first to use Qwen Code.', - 'Login Now', - ); - - if (result === 'Login Now') { - // Trigger login - await this.handleLogin(); - } - - // COMMENTED OUT: Send special error type to WebView for inline display - // console.log('[MessageHandler] Sending notLoggedIn message to webview'); - // this.sendToWebView({ - // type: 'notLoggedIn', - // data: { - // message: 'Please login to start chatting with Qwen Code.', - // }, - // }); - // console.log('[MessageHandler] notLoggedIn message sent'); - return; - } - - // Send to agent (use formatted text with file context) - try { - // Reset stream content - this.resetStreamContent(); - - // Create placeholder for assistant message - this.sendToWebView({ - type: 'streamStart', - data: { timestamp: Date.now() }, - }); - console.log('[MessageHandler] Stream start sent'); - - console.log('[MessageHandler] Sending to agent manager:', formattedText); - await this.agentManager.sendMessage(formattedText); - console.log('[MessageHandler] Agent manager send complete'); - - // Stream is complete - save assistant message - if (this.currentStreamContent && this.currentConversationId) { - const assistantMessage: ChatMessage = { - role: 'assistant', - content: this.currentStreamContent, - timestamp: Date.now(), - }; - await this.conversationStore.addMessage( - this.currentConversationId, - assistantMessage, - ); - console.log('[MessageHandler] Assistant message saved to store'); - } - - this.sendToWebView({ - type: 'streamEnd', - data: { timestamp: Date.now() }, - }); - console.log('[MessageHandler] Stream end sent'); - - // Auto-save session after response completes - // Use CLI's /chat save command for complete checkpoint with tool calls - if (this.currentConversationId) { - console.log( - '[MessageHandler] ===== STARTING AUTO-SAVE CHECKPOINT =====', - ); - console.log( - '[MessageHandler] Session ID (will be used as checkpoint tag):', - this.currentConversationId, - ); - - try { - // Get conversation messages - const conversation = await this.conversationStore.getConversation( - this.currentConversationId, - ); - console.log( - '[MessageHandler] Conversation loaded, message count:', - conversation?.messages.length, - ); - - // Save via CLI /chat save command (will trigger a response we need to ignore) - const messages = conversation?.messages || []; - console.log( - '[MessageHandler] Calling saveCheckpoint with', - messages.length, - 'messages', - ); - - // Set flag to ignore the upcoming response from /chat save - this.isSavingCheckpoint = true; - console.log('[MessageHandler] Set isSavingCheckpoint = true'); - - const result = await this.agentManager.saveCheckpoint( - messages, - this.currentConversationId, - ); - - console.log('[MessageHandler] Checkpoint save result:', result); - - // Reset flag after a delay (in case the command response comes late) - setTimeout(() => { - this.isSavingCheckpoint = false; - console.log('[MessageHandler] Reset isSavingCheckpoint = false'); - }, 2000); - - if (result.success) { - console.log( - '[MessageHandler] ===== CHECKPOINT SAVE SUCCESSFUL =====', - ); - console.log('[MessageHandler] Checkpoint tag:', result.tag); - } else { - console.error( - '[MessageHandler] ===== CHECKPOINT SAVE FAILED =====', - ); - console.error('[MessageHandler] Error:', result.message); - } - } catch (error) { - console.error( - '[MessageHandler] ===== CHECKPOINT SAVE EXCEPTION =====', - ); - console.error('[MessageHandler] Exception details:', error); - this.isSavingCheckpoint = false; - // Don't show error to user - this is a background operation - } - } else { - console.warn( - '[MessageHandler] Skipping checkpoint save: no current conversation ID', - ); - } - } catch (error) { - console.error('[MessageHandler] Error sending message:', error); - - // Check if error is due to no active ACP session (not logged in) - const errorMsg = String(error); - if (errorMsg.includes('No active ACP session')) { - // Save pending message for auto-retry after login - this.pendingMessage = text; - console.log( - '[MessageHandler] Saved pending message for retry after login', - ); - - // Show VSCode warning notification with login option - const result = await vscode.window.showWarningMessage( - 'You need to login first to use Qwen Code.', - 'Login Now', - ); - - if (result === 'Login Now') { - // Trigger login - await this.handleLogin(); - } - - // COMMENTED OUT: Send special error type to WebView for inline display with login button - // console.log('[MessageHandler] Sending notLoggedIn message (session expired) to webview'); - // this.sendToWebView({ - // type: 'notLoggedIn', - // data: { - // message: 'Session expired. Please login to continue chatting.', - // }, - // }); - // console.log('[MessageHandler] notLoggedIn message sent'); - } else { - // For other errors, show regular error message - vscode.window.showErrorMessage(`Error sending message: ${error}`); - this.sendToWebView({ - type: 'error', - data: { message: errorMsg }, - }); - } - } - } - - /** - * 处理加载对话请求 - */ - private async handleLoadConversation(id: string): Promise { - const conversation = await this.conversationStore.getConversation(id); - if (conversation) { - this.currentConversationId = id; - this.sendToWebView({ - type: 'conversationLoaded', - data: conversation, - }); - } - } - - /** - * 处理新建对话请求 - */ - private async handleNewConversation(): Promise { - const newConv = await this.conversationStore.createConversation(); - this.currentConversationId = newConv.id; - this.sendToWebView({ - type: 'conversationLoaded', - data: newConv, - }); - } - - /** - * 处理删除对话请求 - */ - private async handleDeleteConversation(id: string): Promise { - await this.conversationStore.deleteConversation(id); - this.sendToWebView({ - type: 'conversationDeleted', - data: { id }, - }); - } - - /** - * 处理获取 Qwen 会话列表请求 - */ - private async handleGetQwenSessions(): Promise { - try { - console.log('[MessageHandler] Getting Qwen sessions...'); - const sessions = await this.agentManager.getSessionList(); - console.log('[MessageHandler] Retrieved sessions:', sessions.length); - - this.sendToWebView({ - type: 'qwenSessionList', - data: { sessions }, - }); - } catch (error) { - console.error('[MessageHandler] Failed to get Qwen sessions:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to get sessions: ${error}` }, - }); - } - } - - /** - * 处理新建 Qwen 会话请求 - * 在创建新 session 前,先保存当前 session - */ - private async handleNewQwenSession(): Promise { - try { - console.log('[MessageHandler] Creating new Qwen session...'); - - // Save current session as checkpoint before switching to a new one - if (this.currentConversationId && this.agentManager.isConnected) { - try { - console.log( - '[MessageHandler] Auto-saving current session as checkpoint before creating new:', - this.currentConversationId, - ); - - const conversation = await this.conversationStore.getConversation( - this.currentConversationId, - ); - const messages = conversation?.messages || []; - - // Save as checkpoint using sessionId as tag - await this.agentManager.saveCheckpoint( - messages, - this.currentConversationId, - ); - console.log( - '[MessageHandler] Current session checkpoint saved successfully before creating new session', - ); - } catch (error) { - console.warn( - '[MessageHandler] Failed to auto-save current session checkpoint:', - error, - ); - // Don't block new session creation if save fails - } - } - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - - await this.agentManager.createNewSession(workingDir); - - // Clear current conversation UI - this.sendToWebView({ - type: 'conversationCleared', - data: {}, - }); - } catch (error) { - console.error('[MessageHandler] Failed to create new session:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to create new session: ${error}` }, - }); - } - } - - /** - * 处理切换 Qwen 会话请求 - * 优先使用 CLI 的 checkpoint (session/load) 能力从保存的完整会话恢复 - */ - private async handleSwitchQwenSession(sessionId: string): Promise { - try { - console.log('[MessageHandler] Switching to Qwen session:', sessionId); - - // Save current session as checkpoint before switching - if ( - this.currentConversationId && - this.currentConversationId !== sessionId && - this.agentManager.isConnected - ) { - try { - console.log( - '[MessageHandler] Auto-saving current session as checkpoint before switching:', - this.currentConversationId, - ); - - const conversation = await this.conversationStore.getConversation( - this.currentConversationId, - ); - const messages = conversation?.messages || []; - - // Save as checkpoint using sessionId as tag - await this.agentManager.saveCheckpoint( - messages, - this.currentConversationId, - ); - console.log( - '[MessageHandler] Current session checkpoint saved successfully before switching', - ); - } catch (error) { - console.warn( - '[MessageHandler] Failed to auto-save current session checkpoint:', - error, - ); - // Don't block session switching if save fails - } - } - - // Get session details for the header - let sessionDetails = null; - try { - const allSessions = await this.agentManager.getSessionList(); - sessionDetails = allSessions.find( - (s: { id?: string; sessionId?: string }) => - s.id === sessionId || s.sessionId === sessionId, - ); - } catch (err) { - console.log('[MessageHandler] Could not get session details:', err); - } - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - - // Try to load session via ACP checkpoint (session/load) first - // This will restore the full session with all tool calls and context - try { - console.log( - '[MessageHandler] Loading session via CLI checkpoint (session/load):', - sessionId, - ); - const loadResponse = - await this.agentManager.loadSessionViaAcp(sessionId); - console.log('[MessageHandler] session/load succeeded:', loadResponse); - - // If load succeeded, use the loaded session - this.currentConversationId = sessionId; - console.log( - '[MessageHandler] Set currentConversationId to loaded session:', - sessionId, - ); - - // Get session messages for display from loaded session - // This will now have complete tool call information - const messages = await this.agentManager.getSessionMessages(sessionId); - console.log( - '[MessageHandler] Loaded complete messages from checkpoint:', - messages.length, - ); - - // Send messages and session details to WebView - this.sendToWebView({ - type: 'qwenSessionSwitched', - data: { sessionId, messages, session: sessionDetails }, - }); - } catch (loadError) { - const errorMessage = - loadError instanceof Error ? loadError.message : String(loadError); - console.warn( - '[MessageHandler] session/load failed, falling back to file-based restore.', - ); - console.warn('[MessageHandler] Load error details:', errorMessage); - console.warn( - '[MessageHandler] This may happen if the session was not saved via /chat save.', - ); - - // Fallback: Load messages from local files (may be incomplete) - // and create a new ACP session for continuation - const messages = await this.agentManager.getSessionMessages(sessionId); - console.log( - '[MessageHandler] Loaded messages from local files:', - messages.length, - ); - - try { - const newAcpSessionId = - await this.agentManager.createNewSession(workingDir); - console.log( - '[MessageHandler] Created new ACP session for conversation:', - newAcpSessionId, - ); - - // Use the NEW ACP session ID for sending messages to CLI - this.currentConversationId = newAcpSessionId; - console.log( - '[MessageHandler] Set currentConversationId (ACP) to:', - newAcpSessionId, - ); - - // Send messages and session details to WebView - // Note: These messages may be incomplete (no tool calls) - this.sendToWebView({ - type: 'qwenSessionSwitched', - data: { sessionId, messages, session: sessionDetails }, - }); - - vscode.window.showWarningMessage( - 'Session restored from local cache. Some context may be incomplete. Save sessions regularly for full restoration.', - ); - } catch (createError) { - console.error( - '[MessageHandler] Failed to create new ACP session:', - createError, - ); - vscode.window.showErrorMessage( - 'Could not switch to session. Please try again.', - ); - throw createError; - } - } - } catch (error) { - console.error('[MessageHandler] Failed to switch session:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to switch session: ${error}` }, - }); - vscode.window.showErrorMessage(`Failed to switch session: ${error}`); - } - } - - /** - * 处理取消提示请求 - * 取消当前 AI 响应生成 - */ - private async handleCancelPrompt(): Promise { - try { - console.log('[MessageHandler] Cancel prompt requested'); - - if (!this.agentManager.isConnected) { - console.warn('[MessageHandler] Agent not connected, cannot cancel'); - return; - } - - await this.agentManager.cancelCurrentPrompt(); - - this.sendToWebView({ - type: 'promptCancelled', - data: { timestamp: Date.now() }, - }); - - console.log('[MessageHandler] Prompt cancelled successfully'); - } catch (error) { - console.error('[MessageHandler] Failed to cancel prompt:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to cancel: ${error}` }, - }); - } - } - - /** - * 处理附加文件请求 - * 打开文件选择器,将选中的文件信息发送回WebView - */ - private async handleAttachFile(): Promise { - try { - const uris = await vscode.window.showOpenDialog({ - canSelectMany: false, - canSelectFiles: true, - canSelectFolders: false, - openLabel: 'Attach', - }); - - if (uris && uris.length > 0) { - const uri = uris[0]; - const fileName = getFileName(uri.fsPath); - - this.sendToWebView({ - type: 'fileAttached', - data: { - id: `file-${Date.now()}`, - type: 'file', - name: fileName, - value: uri.fsPath, - }, - }); - } - } catch (error) { - console.error('[MessageHandler] Failed to attach file:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to attach file: ${error}` }, - }); - } - } - - /** - * 获取工作区文件列表 - * 用于在 @ 触发时显示文件补全 - * 优先显示最近使用的文件(打开的标签页) - */ - private async handleGetWorkspaceFiles(query?: string): Promise { - try { - const files: Array<{ - id: string; - label: string; - description: string; - path: string; - }> = []; - const addedPaths = new Set(); - - // Helper function to add a file - const addFile = (uri: vscode.Uri, isCurrentFile = false) => { - if (addedPaths.has(uri.fsPath)) { - return; - } - - const fileName = getFileName(uri.fsPath); - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); - const relativePath = workspaceFolder - ? vscode.workspace.asRelativePath(uri, false) - : uri.fsPath; - - // Filter by query if provided - if ( - query && - !fileName.toLowerCase().includes(query.toLowerCase()) && - !relativePath.toLowerCase().includes(query.toLowerCase()) - ) { - return; - } - - files.push({ - id: isCurrentFile ? 'current-file' : uri.fsPath, - label: fileName, - description: relativePath, - path: uri.fsPath, - }); - addedPaths.add(uri.fsPath); - }; - - // If query provided, search entire workspace - if (query) { - // Search workspace files matching the query - const uris = await vscode.workspace.findFiles( - `**/*${query}*`, - '**/node_modules/**', - 50, // Allow more results for search - ); - - for (const uri of uris) { - addFile(uri); - } - } else { - // No query: show recently used files - // 1. Add current active file first - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - addFile(activeEditor.document.uri, true); - } - - // 2. Add all open tabs (recently used files) - const tabGroups = vscode.window.tabGroups.all; - for (const tabGroup of tabGroups) { - for (const tab of tabGroup.tabs) { - const input = tab.input as { uri?: vscode.Uri } | undefined; - if (input && input.uri instanceof vscode.Uri) { - addFile(input.uri); - } - } - } - - // 3. If still not enough files (less than 10), add some workspace files - if (files.length < 10) { - const recentUris = await vscode.workspace.findFiles( - '**/*', - '**/node_modules/**', - 20, - ); - - for (const uri of recentUris) { - if (files.length >= 20) { - break; - } - addFile(uri); - } - } - } - - this.sendToWebView({ - type: 'workspaceFiles', - data: { files }, - }); - } catch (error) { - console.error('[MessageHandler] Failed to get workspace files:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to get workspace files: ${error}` }, - }); - } - } - - /** - * 处理显示上下文选择器请求 - * 显示快速选择菜单,包含文件、符号等选项 - * 参考 vscode-copilot-chat 的 AttachContextAction - */ - private async handleShowContextPicker(): Promise { - try { - const items: vscode.QuickPickItem[] = []; - - // Add current file - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - const fileName = getFileName(activeEditor.document.uri.fsPath); - items.push({ - label: `$(file) ${fileName}`, - description: 'Current file', - detail: activeEditor.document.uri.fsPath, - }); - } - - // Add file picker option - items.push({ - label: '$(file) File...', - description: 'Choose a file to attach', - }); - - // Add workspace files option - items.push({ - label: '$(search) Search files...', - description: 'Search workspace files', - }); - - const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Attach context', - matchOnDescription: true, - matchOnDetail: true, - }); - - if (selected) { - if (selected.label.includes('Current file') && activeEditor) { - const fileName = getFileName(activeEditor.document.uri.fsPath); - this.sendToWebView({ - type: 'fileAttached', - data: { - id: `file-${Date.now()}`, - type: 'file', - name: fileName, - value: activeEditor.document.uri.fsPath, - }, - }); - } else if (selected.label.includes('File...')) { - await this.handleAttachFile(); - } else if (selected.label.includes('Search files')) { - // Open workspace file picker - const uri = await vscode.window.showOpenDialog({ - defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, - canSelectMany: false, - canSelectFiles: true, - canSelectFolders: false, - openLabel: 'Attach', - }); - - if (uri && uri.length > 0) { - const fileName = getFileName(uri[0].fsPath); - this.sendToWebView({ - type: 'fileAttached', - data: { - id: `file-${Date.now()}`, - type: 'file', - name: fileName, - value: uri[0].fsPath, - }, - }); - } - } - } - } catch (error) { - console.error('[MessageHandler] Failed to show context picker:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to show context picker: ${error}` }, - }); - } - } - - /** - * 处理保存会话请求 - * 首先尝试通过 ACP 协议保存,如果失败则直接保存到文件系统 - */ - private async handleSaveSession(tag: string): Promise { - try { - console.log('[MessageHandler] Saving session with tag:', tag); - - if (!this.currentConversationId) { - throw new Error('No active conversation to save'); - } - - // 从 conversationStore 获取当前会话消息 - const conversation = await this.conversationStore.getConversation( - this.currentConversationId, - ); - const messages = conversation?.messages || []; - - // 首先尝试通过 ACP 保存 - try { - const response = await this.agentManager.saveSessionViaAcp( - this.currentConversationId, - tag, - ); - - console.log('[MessageHandler] Session saved via ACP:', response); - - // Send response back to WebView - this.sendToWebView({ - type: 'saveSessionResponse', - data: response, - }); - } catch (acpError) { - console.warn( - '[MessageHandler] ACP save failed, falling back to direct save:', - acpError, - ); - - // ACP 保存失败,尝试直接保存到文件系统 - const response = await this.agentManager.saveSessionDirect( - messages, - tag, - ); - - console.log('[MessageHandler] Session saved directly:', response); - - // Send response back to WebView - this.sendToWebView({ - type: 'saveSessionResponse', - data: response, - }); - } - - // Also refresh the session list - await this.handleGetQwenSessions(); - } catch (error) { - console.error('[MessageHandler] Failed to save session:', error); - this.sendToWebView({ - type: 'saveSessionResponse', - data: { - success: false, - message: `Failed to save session: ${error}`, - }, - }); - } - } - - /** - * 处理恢复会话请求 - * 首先尝试通过 ACP 协议加载,如果失败则直接从文件系统加载 - */ - private async handleResumeSession(sessionId: string): Promise { - try { - console.log('[MessageHandler] Resuming session:', sessionId); - - // 首先尝试通过 ACP 加载 - try { - await this.agentManager.loadSessionViaAcp(sessionId); - - // Set current conversation ID - this.currentConversationId = sessionId; - - // Get session messages for display - const messages = await this.agentManager.getSessionMessages(sessionId); - - // Send response back to WebView - this.sendToWebView({ - type: 'qwenSessionSwitched', - data: { sessionId, messages }, - }); - } catch (acpError) { - console.warn( - '[MessageHandler] ACP load failed, falling back to direct load:', - acpError, - ); - - // ACP 加载失败,尝试直接从文件系统加载 - const messages = await this.agentManager.loadSessionDirect(sessionId); - - if (messages) { - // Set current conversation ID - this.currentConversationId = sessionId; - - // Send response back to WebView - this.sendToWebView({ - type: 'qwenSessionSwitched', - data: { sessionId, messages }, - }); - } else { - throw new Error('会话加载失败'); - } - } - - // Also refresh the session list - await this.handleGetQwenSessions(); - } catch (error) { - console.error('[MessageHandler] Failed to resume session:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to resume session: ${error}` }, - }); - } - } - - /** - * 处理打开设置请求 - * 打开 VSCode 设置页面并定位到扩展配置 - */ - private async handleOpenSettings(): Promise { - try { - console.log('[MessageHandler] Opening settings'); - await vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'qwenCode', - ); - } catch (error) { - console.error('[MessageHandler] Failed to open settings:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to open settings: ${error}` }, - }); - } - } - - /** - * 处理登录请求 - * 通过 /login 命令触发登录流程 - */ - private async handleLogin(): Promise { - try { - console.log('[MessageHandler] Login requested via /login command'); - - if (this.loginHandler) { - // Show progress notification in VSCode - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Logging in to Qwen Code...', - cancellable: false, - }, - async () => { - await this.loginHandler!(); - }, - ); - console.log('[MessageHandler] Login completed successfully'); - - // Show success notification - // vscode.window.showInformationMessage( - // 'Successfully logged in to Qwen Code!', - // ); - - // Auto-resend pending message if exists - if (this.pendingMessage) { - console.log( - '[MessageHandler] Auto-resending pending message after login', - ); - const messageToSend = this.pendingMessage; - this.pendingMessage = null; // Clear pending message - - // Resend the message - await this.handleSendMessage(messageToSend); - } - } else { - console.error('[MessageHandler] No login handler registered'); - this.sendToWebView({ - type: 'error', - data: { message: 'Login handler not available' }, - }); - } - } catch (error) { - console.error('[MessageHandler] Login failed:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Login failed: ${error}` }, - }); - } + getIsSavingCheckpoint(): boolean { + return this.router.getIsSavingCheckpoint(); } } diff --git a/packages/vscode-ide-companion/src/webview/components/ContextPills.tsx b/packages/vscode-ide-companion/src/webview/components/ContextPills.tsx index 44a1821c..8150cf79 100644 --- a/packages/vscode-ide-companion/src/webview/components/ContextPills.tsx +++ b/packages/vscode-ide-companion/src/webview/components/ContextPills.tsx @@ -6,6 +6,13 @@ import type React from 'react'; import type { ContextAttachment } from '../ContextAttachmentManager.js'; +import { + FileListIcon, + PlusSmallIcon, + SymbolIcon, + SelectionIcon, + CloseSmallIcon, +} from './icons/index.js'; import './ContextPills.css'; interface ContextPillsProps { @@ -28,45 +35,13 @@ export const ContextPills: React.FC = ({ const getIcon = (type: string) => { switch (type) { case 'file': - return ( - - - - ); + return ; case 'symbol': - return ( - - - - ); + return ; case 'selection': - return ( - - - - ); + return ; default: - return ( - - - - ); + return ; } }; @@ -81,13 +56,7 @@ export const ContextPills: React.FC = ({ onClick={() => onRemove(attachment.id)} aria-label="Remove attachment" > - - - +
))} diff --git a/packages/vscode-ide-companion/src/webview/components/InfoBanner.tsx b/packages/vscode-ide-companion/src/webview/components/InfoBanner.tsx index 6d0c789e..2da09430 100644 --- a/packages/vscode-ide-companion/src/webview/components/InfoBanner.tsx +++ b/packages/vscode-ide-companion/src/webview/components/InfoBanner.tsx @@ -5,6 +5,7 @@ */ import type React from 'react'; +import { TerminalIcon, CloseIcon } from './icons/index.js'; interface InfoBannerProps { /** @@ -56,22 +57,7 @@ export const InfoBanner: React.FC = ({ >
{/* Icon */} - - - - - + {/* Message */}
); diff --git a/packages/vscode-ide-companion/src/webview/components/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/InputForm.tsx index 1cc4c4f8..7340fe30 100644 --- a/packages/vscode-ide-companion/src/webview/components/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/InputForm.tsx @@ -5,6 +5,16 @@ */ import type React from 'react'; +import { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + ThinkingIcon, + SlashCommandIcon, + LinkIcon, + ArrowUpIcon, +} from './icons/index.js'; type EditMode = 'ask' | 'auto' | 'plan'; @@ -39,50 +49,19 @@ const getEditModeInfo = (editMode: EditMode) => { return { text: 'Ask before edits', title: 'Qwen will ask before each edit. Click to switch modes.', - icon: ( - - ), + icon: , }; case 'auto': return { text: 'Edit automatically', title: 'Qwen will edit files automatically. Click to switch modes.', - icon: ( - - ), + icon: , }; case 'plan': return { text: 'Plan mode', title: 'Qwen will plan before executing. Click to switch modes.', - icon: ( - - ), + icon: , }; default: return { @@ -208,19 +187,7 @@ export const InputForm: React.FC = ({ title={`Showing Qwen Code your current file selection: ${activeFileName}${activeSelection ? `#${activeSelection.startLine}-${activeSelection.endLine}` : ''}`} onClick={onFocusActiveEditor} > - + {activeFileName} {activeSelection && @@ -259,26 +226,7 @@ export const InputForm: React.FC = ({ title={thinkingEnabled ? 'Thinking on' : 'Thinking off'} onClick={onToggleThinking} > - - - + {/* Command button */} @@ -289,18 +237,7 @@ export const InputForm: React.FC = ({ title="Show command menu (/)" onClick={onShowCommandMenu} > - + {/* Attach button */} @@ -311,18 +248,7 @@ export const InputForm: React.FC = ({ title="Attach context (Cmd/Ctrl + /)" onClick={onAttachContext} > - + {/* Send button */} @@ -335,18 +261,7 @@ export const InputForm: React.FC = ({ }} disabled={isStreaming || !inputText.trim()} > - +
diff --git a/packages/vscode-ide-companion/src/webview/components/NotLoggedInMessage.tsx b/packages/vscode-ide-companion/src/webview/components/NotLoggedInMessage.tsx index 23a310c4..51ef9652 100644 --- a/packages/vscode-ide-companion/src/webview/components/NotLoggedInMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/NotLoggedInMessage.tsx @@ -5,6 +5,7 @@ */ import type React from 'react'; +import { WarningTriangleIcon, CloseIcon } from './icons/index.js'; interface NotLoggedInMessageProps { /** @@ -28,99 +29,76 @@ export const NotLoggedInMessage: React.FC = ({ onLoginClick, onDismiss, }) => ( -
- {/* Warning Icon */} - + {/* Warning Icon */} + + + {/* Content */} +
+

- - + {message} +

- {/* Content */} -
-

- {message} -

- - {/* Login Button */} - -
- - {/* Optional Close Button */} - {onDismiss && ( - - )} + {/* Login Button */} +
- ); + + {/* Optional Close Button */} + {onDismiss && ( + + )} +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/PlanDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/PlanDisplay.tsx index 2e0f2b58..2243025d 100644 --- a/packages/vscode-ide-companion/src/webview/components/PlanDisplay.tsx +++ b/packages/vscode-ide-companion/src/webview/components/PlanDisplay.tsx @@ -5,6 +5,11 @@ */ import type React from 'react'; +import { + PlanCompletedIcon, + PlanInProgressIcon, + PlanPendingIcon, +} from './icons/index.js'; import './PlanDisplay.css'; export interface PlanEntry { @@ -28,63 +33,12 @@ export const PlanDisplay: React.FC = ({ entries }) => { const getStatusIcon = (status: string) => { switch (status) { case 'completed': - return ( - - - - - ); + return ; case 'in_progress': - return ( - - - - ); + return ; default: // pending - return ( - - - - ); + return ; } }; @@ -92,38 +46,8 @@ export const PlanDisplay: React.FC = ({ entries }) => {
- - - - - - - + +
{completedCount} of {totalCount} Done diff --git a/packages/vscode-ide-companion/src/webview/components/SaveSessionDialog.tsx b/packages/vscode-ide-companion/src/webview/components/SaveSessionDialog.tsx index 9198edb2..298fbf8a 100644 --- a/packages/vscode-ide-companion/src/webview/components/SaveSessionDialog.tsx +++ b/packages/vscode-ide-companion/src/webview/components/SaveSessionDialog.tsx @@ -6,6 +6,7 @@ import type React from 'react'; import { useState, useEffect, useRef } from 'react'; +import { CloseIcon } from './icons/index.js'; interface SaveSessionDialogProps { isOpen: boolean; @@ -70,14 +71,7 @@ export const SaveSessionDialog: React.FC = ({

Save Conversation

diff --git a/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx b/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx index 57fe0bc2..1b8b3461 100644 --- a/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx +++ b/packages/vscode-ide-companion/src/webview/components/SessionManager.tsx @@ -6,6 +6,13 @@ import React, { useState, useEffect } from 'react'; import { useVSCode } from '../hooks/useVSCode.js'; +import { + RefreshIcon, + SaveDocumentIcon, + SearchIcon, + PlayIcon, + SwitchIcon, +} from './icons/index.js'; interface Session { id: string; @@ -86,63 +93,19 @@ export const SessionManager: React.FC = ({ disabled={isLoading} title="Refresh sessions" > - - - - +
- - - - + = ({ onClick={() => handleResumeSession(session.id)} title="Resume this conversation" > - - - +
diff --git a/packages/vscode-ide-companion/src/webview/components/icons/ActionIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/ActionIcons.tsx new file mode 100644 index 00000000..aa27db28 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/ActionIcons.tsx @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Playback and session control icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Play/resume icon (16x16) + * Used for resume session + */ +export const PlayIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Switch/arrow right icon (16x16) + * Used for switch session + */ +export const SwitchIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx new file mode 100644 index 00000000..39a59f68 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit mode related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Edit pencil icon (16x16) + * Used for "Ask before edits" mode + */ +export const EditPencilIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Auto/fast-forward icon (16x16) + * Used for "Edit automatically" mode + */ +export const AutoEditIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Plan mode/bars icon (16x16) + * Used for "Plan mode" + */ +export const PlanModeIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Code brackets icon (20x20) + * Used for active file indicator + */ +export const CodeBracketsIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Slash command icon (20x20) + * Used for command menu button + */ +export const SlashCommandIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Link/attachment icon (20x20) + * Used for attach context button + */ +export const LinkIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Open diff icon (16x16) + * Used for opening diff in VS Code + */ +export const OpenDiffIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx new file mode 100644 index 00000000..89bb8110 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * File and document related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * File document icon (16x16) + * Used for file completion menu + */ +export const FileIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * File list icon (16x16) + * Used for file type indicator in context pills + */ +export const FileListIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Save document icon (16x16) + * Used for save session button + */ +export const SaveDocumentIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx new file mode 100644 index 00000000..e8bd9512 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Navigation and action icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Chevron down icon (20x20) + * Used for dropdown arrows + */ +export const ChevronDownIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Plus icon (20x20) + * Used for new session button + */ +export const PlusIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Small plus icon (16x16) + * Used for default attachment type + */ +export const PlusSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Arrow up icon (20x20) + * Used for send message button + */ +export const ArrowUpIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Close X icon (14x14) + * Used for close buttons in banners and dialogs + */ +export const CloseIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Close X icon for context pills (16x16) + * Used to remove attachments + */ +export const CloseSmallIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Search/magnifying glass icon (20x20) + * Used for search input + */ +export const SearchIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * Refresh/reload icon (16x16) + * Used for refresh session list + */ +export const RefreshIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx new file mode 100644 index 00000000..b8e536de --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Special UI icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +interface ThinkingIconProps extends IconProps { + /** + * Whether thinking is enabled (affects styling) + */ + enabled?: boolean; +} + +/** + * Thinking/brain wave icon (16x16) + * Used for thinking mode toggle + */ +export const ThinkingIcon: React.FC = ({ + size = 16, + className, + enabled = false, + style, + ...props +}) => ( + +); + +/** + * Terminal/code editor icon (20x20) + * Used for terminal preference info banner + */ +export const TerminalIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx new file mode 100644 index 00000000..375bba94 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Status and state related icons + */ + +import type React from 'react'; +import type { IconProps } from './types.js'; + +/** + * Plan completed icon (14x14) + * Used for completed plan items + */ +export const PlanCompletedIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan in progress icon (14x14) + * Used for in-progress plan items + */ +export const PlanInProgressIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Plan pending icon (14x14) + * Used for pending plan items + */ +export const PlanPendingIcon: React.FC = ({ + size = 14, + className, + ...props +}) => ( + +); + +/** + * Warning triangle icon (20x20) + * Used for warning messages + */ +export const WarningTriangleIcon: React.FC = ({ + size = 20, + className, + ...props +}) => ( + +); + +/** + * User profile icon (16x16) + * Used for login command + */ +export const UserIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Symbol arrow icon (16x16) + * Used for symbol type in context pills + */ +export const SymbolIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Selection/text lines icon (16x16) + * Used for selection type in context pills + */ +export const SelectionIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/index.ts b/packages/vscode-ide-companion/src/webview/components/icons/index.ts new file mode 100644 index 00000000..448dc20e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/index.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Icons index - exports all icon components + */ + +// Types +export type { IconProps } from './types.js'; + +// File icons +export { FileIcon, FileListIcon, SaveDocumentIcon } from './FileIcons.js'; + +// Navigation icons +export { + ChevronDownIcon, + PlusIcon, + PlusSmallIcon, + ArrowUpIcon, + CloseIcon, + CloseSmallIcon, + SearchIcon, + RefreshIcon, +} from './NavigationIcons.js'; + +// Edit mode icons +export { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + SlashCommandIcon, + LinkIcon, + OpenDiffIcon, +} from './EditIcons.js'; + +// Status icons +export { + PlanCompletedIcon, + PlanInProgressIcon, + PlanPendingIcon, + WarningTriangleIcon, + UserIcon, + SymbolIcon, + SelectionIcon, +} from './StatusIcons.js'; + +// Action icons +export { PlayIcon, SwitchIcon } from './ActionIcons.js'; + +// Special icons +export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/types.ts b/packages/vscode-ide-companion/src/webview/components/icons/types.ts new file mode 100644 index 00000000..6290d720 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/icons/types.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Common icon props interface + */ + +import type React from 'react'; + +export interface IconProps extends React.SVGProps { + /** + * Icon size (width and height) + * @default 16 + */ + size?: number; + + /** + * Additional CSS classes + */ + className?: string; +} diff --git a/packages/vscode-ide-companion/src/webview/components/layouts/ChatHeader.tsx b/packages/vscode-ide-companion/src/webview/components/layouts/ChatHeader.tsx index 2c51f9ea..48e15ab8 100644 --- a/packages/vscode-ide-companion/src/webview/components/layouts/ChatHeader.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layouts/ChatHeader.tsx @@ -5,6 +5,7 @@ */ import type React from 'react'; +import { ChevronDownIcon, PlusIcon } from '../icons/index.js'; interface ChatHeaderProps { currentSessionTitle: string; @@ -41,19 +42,7 @@ export const ChatHeader: React.FC = ({ {currentSessionTitle} - + @@ -94,16 +83,7 @@ export const ChatHeader: React.FC = ({ onClick={onNewSession} title="New Session" > - +
); diff --git a/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx new file mode 100644 index 00000000..fed4cf44 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { groupSessionsByDate } from '../../utils/sessionGrouping.js'; +import { getTimeAgo } from '../../utils/timeUtils.js'; +import { SearchIcon } from '../icons/index.js'; + +interface SessionSelectorProps { + visible: boolean; + sessions: Array>; + currentSessionId: string | null; + searchQuery: string; + onSearchChange: (query: string) => void; + onSelectSession: (sessionId: string) => void; + onClose: () => void; +} + +/** + * 会话选择器组件 + * 显示会话列表并支持搜索和选择 + */ +export const SessionSelector: React.FC = ({ + visible, + sessions, + currentSessionId, + searchQuery, + onSearchChange, + onSelectSession, + onClose, +}) => { + if (!visible) { + return null; + } + + const hasNoSessions = sessions.length === 0; + + return ( + <> +
+
e.stopPropagation()} + > + {/* Search Box */} +
+ + onSearchChange(e.target.value)} + /> +
+ + {/* Session List with Grouping */} +
+ {hasNoSessions ? ( +
+ {searchQuery ? 'No matching sessions' : 'No sessions available'} +
+ ) : ( + groupSessionsByDate(sessions).map((group) => ( + +
{group.label}
+
+ {group.sessions.map((session) => { + const sessionId = + (session.id as string) || + (session.sessionId as string) || + ''; + const title = + (session.title as string) || + (session.name as string) || + 'Untitled'; + const lastUpdated = + (session.lastUpdated as string) || + (session.startTime as string) || + ''; + const isActive = sessionId === currentSessionId; + + return ( + + ); + })} +
+
+ )) + )} +
+
+ + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx index 4ce2c059..b54798b4 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx @@ -13,6 +13,7 @@ import { calculateDiffStats, formatDiffStatsDetailed, } from '../../../utils/diffStats.js'; +import { OpenDiffIcon } from '../../icons/index.js'; import './DiffDisplay.css'; /** @@ -119,9 +120,7 @@ export const DiffDisplay: React.FC = ({ onClick={onOpenDiff} title="Open in VS Code diff viewer" > - - - + Open Diff )} diff --git a/packages/vscode-ide-companion/src/webview/components/ui/ChatHeader.tsx b/packages/vscode-ide-companion/src/webview/components/ui/ChatHeader.tsx index 2c51f9ea..48e15ab8 100644 --- a/packages/vscode-ide-companion/src/webview/components/ui/ChatHeader.tsx +++ b/packages/vscode-ide-companion/src/webview/components/ui/ChatHeader.tsx @@ -5,6 +5,7 @@ */ import type React from 'react'; +import { ChevronDownIcon, PlusIcon } from '../icons/index.js'; interface ChatHeaderProps { currentSessionTitle: string; @@ -41,19 +42,7 @@ export const ChatHeader: React.FC = ({ {currentSessionTitle} - + @@ -94,16 +83,7 @@ export const ChatHeader: React.FC = ({ onClick={onNewSession} title="New Session" > - +
); diff --git a/packages/vscode-ide-companion/src/webview/constants/loadingMessages.ts b/packages/vscode-ide-companion/src/webview/constants/loadingMessages.ts new file mode 100644 index 00000000..25d5bd66 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/constants/loadingMessages.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Loading messages from Claude Code CLI + * Source: packages/cli/src/ui/hooks/usePhraseCycler.ts + */ +export const WITTY_LOADING_PHRASES = [ + "I'm Feeling Lucky", + 'Shipping awesomeness... ', + 'Painting the serifs back on...', + 'Navigating the slime mold...', + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', + 'Loading wit.exe...', + 'Summoning the cloud of wisdom...', + 'Preparing a witty response...', + "Just a sec, I'm debugging reality...", + 'Confuzzling the options...', + 'Tuning the cosmic frequencies...', + 'Crafting a response worthy of your patience...', + 'Compiling the 1s and 0s...', + 'Resolving dependencies... and existential crises...', + 'Defragmenting memories... both RAM and personal...', + 'Rebooting the humor module...', + 'Caching the essentials (mostly cat memes)...', + 'Optimizing for ludicrous speed', + "Swapping bits... don't tell the bytes...", + 'Garbage collecting... be right back...', + 'Assembling the interwebs...', + 'Converting coffee into code...', + 'Updating the syntax for reality...', + 'Rewiring the synapses...', + 'Looking for a misplaced semicolon...', + "Greasin' the cogs of the machine...", + 'Pre-heating the servers...', + 'Calibrating the flux capacitor...', + 'Engaging the improbability drive...', + 'Channeling the Force...', + 'Aligning the stars for optimal response...', + 'So say we all...', + 'Loading the next great idea...', + "Just a moment, I'm in the zone...", + 'Preparing to dazzle you with brilliance...', + "Just a tick, I'm polishing my wit...", + "Hold tight, I'm crafting a masterpiece...", + "Just a jiffy, I'm debugging the universe...", + "Just a moment, I'm aligning the pixels...", + "Just a sec, I'm optimizing the humor...", + "Just a moment, I'm tuning the algorithms...", + 'Warp speed engaged...', + 'Mining for more Dilithium crystals...', + "Don't panic...", + 'Following the white rabbit...', + 'The truth is in here... somewhere...', + 'Blowing on the cartridge...', + 'Loading... Do a barrel roll!', + 'Waiting for the respawn...', + 'Finishing the Kessel Run in less than 12 parsecs...', + "The cake is not a lie, it's just still loading...", + 'Fiddling with the character creation screen...', + "Just a moment, I'm finding the right meme...", + "Pressing 'A' to continue...", + 'Herding digital cats...', + 'Polishing the pixels...', + 'Finding a suitable loading screen pun...', + 'Distracting you with this witty phrase...', + 'Almost there... probably...', + 'Our hamsters are working as fast as they can...', + 'Giving Cloudy a pat on the head...', + 'Petting the cat...', + 'Rickrolling my boss...', + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...', + 'Tasting the snozberries...', + "I'm going the distance, I'm going for speed...", + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...", + 'Poking the bear...', + 'Doing research on the latest memes...', + 'Figuring out how to make this more witty...', + 'Hmmm... let me think...', + 'What do you call a fish with no eyes? A fsh...', + 'Why did the computer go to therapy? It had too many bytes...', + "Why don't programmers like nature? It has too many bugs...", + 'Why do programmers prefer dark mode? Because light attracts bugs...', + 'Why did the developer go broke? Because they used up all their cache...', + "What can you do with a broken pencil? Nothing, it's pointless...", + 'Applying percussive maintenance...', + 'Searching for the correct USB orientation...', + 'Ensuring the magic smoke stays inside the wires...', + 'Rewriting in Rust for no particular reason...', + 'Trying to exit Vim...', + 'Spinning up the hamster wheel...', + "That's not a bug, it's an undocumented feature...", + 'Engage.', + "I'll be back... with an answer.", + 'My other process is a TARDIS...', + 'Communing with the machine spirit...', + 'Letting the thoughts marinate...', + 'Just remembered where I put my keys...', + 'Pondering the orb...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.", + 'Initiating thoughtful gaze...', + "What's a computer's favorite snack? Microchips.", + "Why do Java developers wear glasses? Because they don't C#.", + 'Charging the laser... pew pew!', + 'Dividing by zero... just kidding!', + 'Looking for an adult superviso... I mean, processing.', + 'Making it go beep boop.', + 'Buffering... because even AIs need a moment.', + 'Entangling quantum particles for a faster response...', + 'Polishing the chrome... on the algorithms.', + 'Are you not entertained? (Working on it!)', + 'Summoning the code gremlins... to help, of course.', + 'Just waiting for the dial-up tone to finish...', + 'Recalibrating the humor-o-meter.', + 'My other loading screen is even funnier.', + "Pretty sure there's a cat walking on the keyboard somewhere...", + 'Enhancing... Enhancing... Still loading.', + "It's not a bug, it's a feature... of this loading screen.", + 'Have you tried turning it off and on again? (The loading screen, not me.)', + 'Constructing additional pylons...', + "New line? That's Ctrl+J.", +]; + +/** + * Get random loading message + */ +export const getRandomLoadingMessage = (): string => + WITTY_LOADING_PHRASES[ + Math.floor(Math.random() * WITTY_LOADING_PHRASES.length) + ]; diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts new file mode 100644 index 00000000..38125196 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; + +/** + * Auth message handler + * Handles all authentication-related messages + */ +export class AuthMessageHandler extends BaseMessageHandler { + private loginHandler: (() => Promise) | null = null; + + canHandle(messageType: string): boolean { + return ['login'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'login': + await this.handleLogin(); + break; + + default: + console.warn( + '[AuthMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.loginHandler = handler; + } + + /** + * Handle login request + */ + private async handleLogin(): Promise { + try { + console.log('[AuthMessageHandler] Login requested'); + + if (this.loginHandler) { + await this.loginHandler(); + } else { + vscode.window.showInformationMessage( + 'Please wait while we connect to Qwen Code...', + ); + + // Fallback: trigger WebViewProvider's forceReLogin + await vscode.commands.executeCommand('qwenCode.login'); + } + } catch (error) { + console.error('[AuthMessageHandler] Login failed:', error); + this.sendToWebView({ + type: 'loginError', + data: { message: `Login failed: ${error}` }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts new file mode 100644 index 00000000..f091eea4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/BaseMessageHandler.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { QwenAgentManager } from '../agents/qwenAgentManager.js'; +import type { ConversationStore } from '../storage/conversationStore.js'; + +/** + * Base message handler interface + * All sub-handlers should implement this interface + */ +export interface IMessageHandler { + /** + * Handle message + * @param message - Message object + * @returns Promise + */ + handle(message: { type: string; data?: unknown }): Promise; + + /** + * Check if this handler can handle the message type + * @param messageType - Message type + * @returns boolean + */ + canHandle(messageType: string): boolean; +} + +/** + * Base message handler class + * Provides common dependency injection and helper methods + */ +export abstract class BaseMessageHandler implements IMessageHandler { + constructor( + protected agentManager: QwenAgentManager, + protected conversationStore: ConversationStore, + protected currentConversationId: string | null, + protected sendToWebView: (message: unknown) => void, + ) {} + + abstract handle(message: { type: string; data?: unknown }): Promise; + abstract canHandle(messageType: string): boolean; + + /** + * Update current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts new file mode 100644 index 00000000..187555ed --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/EditorMessageHandler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../../utils/webviewUtils.js'; + +/** + * Editor message handler + * Handles all editor state-related messages + */ +export class EditorMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return ['getActiveEditor', 'focusActiveEditor'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'getActiveEditor': + await this.handleGetActiveEditor(); + break; + + case 'focusActiveEditor': + await this.handleFocusActiveEditor(); + break; + + default: + console.warn( + '[EditorMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current active editor info + */ + private async handleGetActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + const filePath = activeEditor.document.uri.fsPath; + const fileName = getFileName(filePath); + + let selectionInfo = null; + if (!activeEditor.selection.isEmpty) { + const selection = activeEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } else { + this.sendToWebView({ + type: 'activeEditorChanged', + data: { fileName: null, filePath: null, selection: null }, + }); + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to get active editor:', + error, + ); + } + } + + /** + * Focus on active editor + */ + private async handleFocusActiveEditor(): Promise { + try { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + await vscode.window.showTextDocument(activeEditor.document, { + viewColumn: activeEditor.viewColumn, + preserveFocus: false, + }); + } else { + // If no active editor, show file picker + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Open', + }); + + if (uri && uri.length > 0) { + await vscode.window.showTextDocument(uri[0]); + } + } + } catch (error) { + console.error( + '[EditorMessageHandler] Failed to focus active editor:', + error, + ); + vscode.window.showErrorMessage(`Failed to focus editor: ${error}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts new file mode 100644 index 00000000..3fa83122 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -0,0 +1,326 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import { getFileName } from '../../utils/webviewUtils.js'; + +/** + * File message handler + * Handles all file-related messages + */ +export class FileMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return [ + 'attachFile', + 'showContextPicker', + 'getWorkspaceFiles', + 'openFile', + 'openDiff', + ].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'attachFile': + await this.handleAttachFile(); + break; + + case 'showContextPicker': + await this.handleShowContextPicker(); + break; + + case 'getWorkspaceFiles': + await this.handleGetWorkspaceFiles(data?.query as string | undefined); + break; + + case 'openFile': + await this.handleOpenFile(data?.path as string | undefined); + break; + + case 'openDiff': + await this.handleOpenDiff(data); + break; + + default: + console.warn( + '[FileMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Handle attach file request + */ + private async handleAttachFile(): Promise { + try { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uris && uris.length > 0) { + const uri = uris[0]; + const fileName = getFileName(uri.fsPath); + + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri.fsPath, + }, + }); + } + } catch (error) { + console.error('[FileMessageHandler] Failed to attach file:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to attach file: ${error}` }, + }); + } + } + + /** + * Handle show context picker request + */ + private async handleShowContextPicker(): Promise { + try { + const items: vscode.QuickPickItem[] = []; + + // Add current file + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + items.push({ + label: `$(file) ${fileName}`, + description: 'Current file', + detail: activeEditor.document.uri.fsPath, + }); + } + + // Add file picker option + items.push({ + label: '$(file) File...', + description: 'Choose a file to attach', + }); + + // Add workspace files option + items.push({ + label: '$(search) Search files...', + description: 'Search workspace files', + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Attach context', + matchOnDescription: true, + matchOnDetail: true, + }); + + if (selected) { + if (selected.label.includes('Current file') && activeEditor) { + const fileName = getFileName(activeEditor.document.uri.fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: activeEditor.document.uri.fsPath, + }, + }); + } else if (selected.label.includes('File...')) { + await this.handleAttachFile(); + } else if (selected.label.includes('Search files')) { + const uri = await vscode.window.showOpenDialog({ + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectMany: false, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Attach', + }); + + if (uri && uri.length > 0) { + const fileName = getFileName(uri[0].fsPath); + this.sendToWebView({ + type: 'fileAttached', + data: { + id: `file-${Date.now()}`, + type: 'file', + name: fileName, + value: uri[0].fsPath, + }, + }); + } + } + } + } catch (error) { + console.error( + '[FileMessageHandler] Failed to show context picker:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to show context picker: ${error}` }, + }); + } + } + + /** + * Get workspace files + */ + private async handleGetWorkspaceFiles(query?: string): Promise { + try { + const files: Array<{ + id: string; + label: string; + description: string; + path: string; + }> = []; + const addedPaths = new Set(); + + const addFile = (uri: vscode.Uri, isCurrentFile = false) => { + if (addedPaths.has(uri.fsPath)) { + return; + } + + const fileName = getFileName(uri.fsPath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(uri, false) + : uri.fsPath; + + // Filter by query if provided + if ( + query && + !fileName.toLowerCase().includes(query.toLowerCase()) && + !relativePath.toLowerCase().includes(query.toLowerCase()) + ) { + return; + } + + files.push({ + id: isCurrentFile ? 'current-file' : uri.fsPath, + label: fileName, + description: relativePath, + path: uri.fsPath, + }); + addedPaths.add(uri.fsPath); + }; + + // Search or show recent files + if (query) { + const uris = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50, + ); + + for (const uri of uris) { + addFile(uri); + } + } else { + // Add current active file first + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + addFile(activeEditor.document.uri, true); + } + + // Add all open tabs + const tabGroups = vscode.window.tabGroups.all; + for (const tabGroup of tabGroups) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { uri?: vscode.Uri } | undefined; + if (input && input.uri instanceof vscode.Uri) { + addFile(input.uri); + } + } + } + + // If not enough files, add some workspace files + if (files.length < 10) { + const recentUris = await vscode.workspace.findFiles( + '**/*', + '**/node_modules/**', + 20, + ); + + for (const uri of recentUris) { + if (files.length >= 20) { + break; + } + addFile(uri); + } + } + } + + this.sendToWebView({ + type: 'workspaceFiles', + data: { files }, + }); + } catch (error) { + console.error( + '[FileMessageHandler] Failed to get workspace files:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get workspace files: ${error}` }, + }); + } + } + + /** + * Open file + */ + private async handleOpenFile(path?: string): Promise { + if (!path) { + console.warn('[FileMessageHandler] No path provided for openFile'); + return; + } + + try { + const uri = vscode.Uri.file(path); + await vscode.window.showTextDocument(uri, { + preview: false, + preserveFocus: false, + }); + } catch (error) { + console.error('[FileMessageHandler] Failed to open file:', error); + vscode.window.showErrorMessage(`Failed to open file: ${error}`); + } + } + + /** + * Open diff view + */ + private async handleOpenDiff( + data: Record | undefined, + ): Promise { + if (!data) { + console.warn('[FileMessageHandler] No data provided for openDiff'); + return; + } + + try { + await vscode.commands.executeCommand('qwenCode.showDiff', { + path: (data.path as string) || '', + oldText: (data.oldText as string) || '', + newText: (data.newText as string) || '', + }); + } catch (error) { + console.error('[FileMessageHandler] Failed to open diff:', error); + vscode.window.showErrorMessage(`Failed to open diff: ${error}`); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts new file mode 100644 index 00000000..2ba3ca22 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IMessageHandler } from './BaseMessageHandler.js'; +import type { QwenAgentManager } from '../../agents/qwenAgentManager.js'; +import type { ConversationStore } from '../../storage/conversationStore.js'; +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 + * Routes messages to appropriate handlers + */ +export class MessageRouter { + private handlers: IMessageHandler[] = []; + private sessionHandler: SessionMessageHandler; + private authHandler: AuthMessageHandler; + private currentConversationId: string | null = null; + private permissionHandler: + | ((message: { type: string; data: { optionId: string } }) => void) + | null = null; + + constructor( + agentManager: QwenAgentManager, + conversationStore: ConversationStore, + currentConversationId: string | null, + sendToWebView: (message: unknown) => void, + ) { + this.currentConversationId = currentConversationId; + + // Initialize all handlers + this.sessionHandler = new SessionMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const fileHandler = new FileMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const editorHandler = new EditorMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + this.authHandler = new AuthMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + const settingsHandler = new SettingsMessageHandler( + agentManager, + conversationStore, + currentConversationId, + sendToWebView, + ); + + // Register handlers in order of priority + this.handlers = [ + this.sessionHandler, + fileHandler, + editorHandler, + this.authHandler, + settingsHandler, + ]; + } + + /** + * Route message to appropriate handler + */ + async route(message: { type: string; data?: unknown }): Promise { + console.log('[MessageRouter] Routing message:', message.type); + + // Handle permission response specially + if (message.type === 'permissionResponse') { + if (this.permissionHandler) { + this.permissionHandler( + message as { type: string; data: { optionId: string } }, + ); + } + return; + } + + // Find appropriate handler + const handler = this.handlers.find((h) => h.canHandle(message.type)); + + if (handler) { + try { + await handler.handle(message); + } catch (error) { + console.error('[MessageRouter] Handler error:', error); + throw error; + } + } else { + console.warn( + '[MessageRouter] No handler found for message type:', + message.type, + ); + } + } + + /** + * Set current conversation ID + */ + setCurrentConversationId(id: string | null): void { + this.currentConversationId = id; + // Update all handlers + this.handlers.forEach((handler) => { + if ('setCurrentConversationId' in handler) { + ( + handler as { setCurrentConversationId: (id: string | null) => void } + ).setCurrentConversationId(id); + } + }); + } + + /** + * Get current conversation ID + */ + getCurrentConversationId(): string | null { + return this.currentConversationId; + } + + /** + * Set permission handler + */ + setPermissionHandler( + handler: (message: { type: string; data: { optionId: string } }) => void, + ): void { + this.permissionHandler = handler; + } + + /** + * Set login handler + */ + setLoginHandler(handler: () => Promise): void { + this.authHandler.setLoginHandler(handler); + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.sessionHandler.appendStreamContent(chunk); + } + + /** + * Check if saving checkpoint + */ + getIsSavingCheckpoint(): boolean { + return this.sessionHandler.getIsSavingCheckpoint(); + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts new file mode 100644 index 00000000..953c9be9 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -0,0 +1,590 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; +import type { ChatMessage } from '../../agents/qwenAgentManager.js'; + +/** + * Session message handler + * Handles all session-related messages + */ +export class SessionMessageHandler extends BaseMessageHandler { + private currentStreamContent = ''; + private isSavingCheckpoint = false; + + canHandle(messageType: string): boolean { + return [ + 'sendMessage', + 'newQwenSession', + 'switchQwenSession', + 'getQwenSessions', + 'saveSession', + 'resumeSession', + ].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + const data = message.data as Record | undefined; + + switch (message.type) { + case 'sendMessage': + await this.handleSendMessage( + (data?.text as string) || '', + data?.context as + | Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> + | undefined, + data?.fileContext as + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined, + ); + break; + + case 'newQwenSession': + await this.handleNewQwenSession(); + break; + + case 'switchQwenSession': + await this.handleSwitchQwenSession((data?.sessionId as string) || ''); + break; + + case 'getQwenSessions': + await this.handleGetQwenSessions(); + break; + + case 'saveSession': + await this.handleSaveSession((data?.tag as string) || ''); + break; + + case 'resumeSession': + await this.handleResumeSession((data?.sessionId as string) || ''); + break; + + default: + console.warn( + '[SessionMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Get current stream content + */ + getCurrentStreamContent(): string { + return this.currentStreamContent; + } + + /** + * Append stream content + */ + appendStreamContent(chunk: string): void { + this.currentStreamContent += chunk; + } + + /** + * Reset stream content + */ + resetStreamContent(): void { + this.currentStreamContent = ''; + } + + /** + * Check if saving checkpoint + */ + getIsSavingCheckpoint(): boolean { + return this.isSavingCheckpoint; + } + + /** + * Handle send message request + */ + private async handleSendMessage( + text: string, + context?: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }>, + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }, + ): Promise { + console.log('[SessionMessageHandler] handleSendMessage called with:', text); + + // Format message with file context if present + let formattedText = text; + if (context && context.length > 0) { + const contextParts = context + .map((ctx) => { + if (ctx.startLine && ctx.endLine) { + return `${ctx.value}#${ctx.startLine}${ctx.startLine !== ctx.endLine ? `-${ctx.endLine}` : ''}`; + } + return ctx.value; + }) + .join('\n'); + + formattedText = `${contextParts}\n\n${text}`; + } + + // Ensure we have an active conversation + if (!this.currentConversationId) { + console.log( + '[SessionMessageHandler] No active conversation, creating one...', + ); + try { + const newConv = await this.conversationStore.createConversation(); + this.currentConversationId = newConv.id; + this.sendToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + } catch (error) { + const errorMsg = `Failed to create conversation: ${error}`; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + } + + if (!this.currentConversationId) { + const errorMsg = + 'Failed to create conversation. Please restart the extension.'; + console.error('[SessionMessageHandler]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + + // Check if this is the first message + let isFirstMessage = false; + try { + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + isFirstMessage = !conversation || conversation.messages.length === 0; + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to check conversation:', + error, + ); + } + + // Generate title for first message + if (isFirstMessage) { + const title = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: this.currentConversationId, + title, + }, + }); + } + + // Save user message + const userMessage: ChatMessage = { + role: 'user', + content: text, + timestamp: Date.now(), + }; + + await this.conversationStore.addMessage( + this.currentConversationId, + userMessage, + ); + + // Send to WebView + this.sendToWebView({ + type: 'message', + data: { ...userMessage, fileContext }, + }); + + // Check if agent is connected + if (!this.agentManager.isConnected) { + console.warn('[SessionMessageHandler] Agent not connected'); + + const result = await vscode.window.showWarningMessage( + 'You need to login first to use Qwen Code.', + 'Login Now', + ); + + if (result === 'Login Now') { + vscode.commands.executeCommand('qwenCode.login'); + } + return; + } + + // Send to agent + try { + this.resetStreamContent(); + + this.sendToWebView({ + type: 'streamStart', + data: { timestamp: Date.now() }, + }); + + await this.agentManager.sendMessage(formattedText); + + // Save assistant message + if (this.currentStreamContent && this.currentConversationId) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: this.currentStreamContent, + timestamp: Date.now(), + }; + await this.conversationStore.addMessage( + this.currentConversationId, + assistantMessage, + ); + } + + this.sendToWebView({ + 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); + + const errorMsg = String(error); + if (errorMsg.includes('No active ACP session')) { + const result = await vscode.window.showWarningMessage( + 'You need to login first to use Qwen Code.', + 'Login Now', + ); + + if (result === 'Login Now') { + vscode.commands.executeCommand('qwenCode.login'); + } + } else { + vscode.window.showErrorMessage(`Error sending message: ${error}`); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + } + } + } + + /** + * Handle new Qwen session request + */ + private async handleNewQwenSession(): Promise { + try { + console.log('[SessionMessageHandler] Creating new Qwen session...'); + + // 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(); + + await this.agentManager.createNewSession(workingDir); + + this.sendToWebView({ + type: 'conversationCleared', + data: {}, + }); + } catch (error) { + console.error( + '[SessionMessageHandler] Failed to create new session:', + error, + ); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to create new session: ${error}` }, + }); + } + } + + /** + * Handle switch Qwen session request + */ + private async handleSwitchQwenSession(sessionId: string): Promise { + try { + console.log('[SessionMessageHandler] Switching to session:', sessionId); + + // 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 + let sessionDetails = null; + try { + const allSessions = await this.agentManager.getSessionList(); + sessionDetails = allSessions.find( + (s: { id?: string; sessionId?: string }) => + s.id === sessionId || s.sessionId === sessionId, + ); + } catch (err) { + console.log( + '[SessionMessageHandler] Could not get session details:', + err, + ); + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + + // Try to load session via ACP + try { + const loadResponse = + await this.agentManager.loadSessionViaAcp(sessionId); + console.log( + '[SessionMessageHandler] session/load succeeded:', + loadResponse, + ); + + this.currentConversationId = sessionId; + + const messages = await this.agentManager.getSessionMessages(sessionId); + + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + } catch (_loadError) { + console.warn( + '[SessionMessageHandler] session/load failed, using fallback', + ); + + // Fallback: create new session + const messages = await this.agentManager.getSessionMessages(sessionId); + + try { + const newAcpSessionId = + await this.agentManager.createNewSession(workingDir); + + this.currentConversationId = newAcpSessionId; + + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + + vscode.window.showWarningMessage( + 'Session restored from local cache. Some context may be incomplete.', + ); + } catch (createError) { + console.error( + '[SessionMessageHandler] Failed to create session:', + createError, + ); + throw createError; + } + } + } catch (error) { + console.error('[SessionMessageHandler] Failed to switch session:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to switch session: ${error}` }, + }); + } + } + + /** + * Handle get Qwen sessions request + */ + private async handleGetQwenSessions(): Promise { + try { + const sessions = await this.agentManager.getSessionList(); + this.sendToWebView({ + type: 'qwenSessionList', + data: { sessions }, + }); + } catch (error) { + console.error('[SessionMessageHandler] Failed to get sessions:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to get sessions: ${error}` }, + }); + } + } + + /** + * Handle save session request + */ + private async handleSaveSession(tag: string): Promise { + try { + if (!this.currentConversationId) { + 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( + this.currentConversationId, + tag, + ); + + this.sendToWebView({ + type: 'saveSessionResponse', + data: response, + }); + } catch (_acpError) { + // Fallback to direct save + const response = await this.agentManager.saveSessionDirect( + messages, + tag, + ); + + this.sendToWebView({ + type: 'saveSessionResponse', + data: response, + }); + } + + await this.handleGetQwenSessions(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to save session:', error); + this.sendToWebView({ + type: 'saveSessionResponse', + data: { + success: false, + message: `Failed to save session: ${error}`, + }, + }); + } + } + + /** + * Handle resume session request + */ + private async handleResumeSession(sessionId: string): Promise { + try { + // Try ACP load first + try { + await this.agentManager.loadSessionViaAcp(sessionId); + + this.currentConversationId = sessionId; + + const messages = await this.agentManager.getSessionMessages(sessionId); + + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages }, + }); + } catch (_acpError) { + // 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(); + } catch (error) { + console.error('[SessionMessageHandler] Failed to resume session:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to resume session: ${error}` }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts new file mode 100644 index 00000000..a434425e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { BaseMessageHandler } from './BaseMessageHandler.js'; + +/** + * Settings message handler + * Handles all settings-related messages + */ +export class SettingsMessageHandler extends BaseMessageHandler { + canHandle(messageType: string): boolean { + return ['openSettings', 'recheckCli'].includes(messageType); + } + + async handle(message: { type: string; data?: unknown }): Promise { + switch (message.type) { + case 'openSettings': + await this.handleOpenSettings(); + break; + + case 'recheckCli': + await this.handleRecheckCli(); + break; + + default: + console.warn( + '[SettingsMessageHandler] Unknown message type:', + message.type, + ); + break; + } + } + + /** + * Open settings page + */ + private async handleOpenSettings(): Promise { + try { + await vscode.commands.executeCommand( + 'workbench.action.openSettings', + '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 { + 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}` }, + }); + } + } +} diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts new file mode 100644 index 00000000..a0508acb --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef } from 'react'; +import type { VSCodeAPI } from '../hooks/useVSCode.js'; + +/** + * File context management Hook + * Manages active file, selection content, and workspace file list + */ +export const useFileContext = (vscode: VSCodeAPI) => { + const [activeFileName, setActiveFileName] = useState(null); + const [activeFilePath, setActiveFilePath] = useState(null); + const [activeSelection, setActiveSelection] = useState<{ + startLine: number; + endLine: number; + } | null>(null); + + const [workspaceFiles, setWorkspaceFiles] = useState< + Array<{ + id: string; + label: string; + description: string; + path: string; + }> + >([]); + + // File reference mapping: @filename -> full path + const fileReferenceMap = useRef>(new Map()); + + // Whether workspace files have been requested + const hasRequestedFilesRef = useRef(false); + + // Search debounce timer + const searchTimerRef = useRef(null); + + /** + * Request workspace files + */ + const requestWorkspaceFiles = useCallback( + (query?: string) => { + if (!hasRequestedFilesRef.current && !query) { + hasRequestedFilesRef.current = true; + } + + // If there's a query, clear previous timer and set up debounce + if (query && query.length >= 1) { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + searchTimerRef.current = setTimeout(() => { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: { query }, + }); + }, 300); + } else { + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: query ? { query } : {}, + }); + } + }, + [vscode], + ); + + /** + * Add file reference + */ + const addFileReference = useCallback((fileName: string, filePath: string) => { + fileReferenceMap.current.set(fileName, filePath); + }, []); + + /** + * Get file reference + */ + const getFileReference = useCallback( + (fileName: string) => fileReferenceMap.current.get(fileName), + [], + ); + + /** + * Clear file references + */ + const clearFileReferences = useCallback(() => { + fileReferenceMap.current.clear(); + }, []); + + /** + * Request active editor info + */ + const requestActiveEditor = useCallback(() => { + vscode.postMessage({ type: 'getActiveEditor', data: {} }); + }, [vscode]); + + /** + * Focus on active editor + */ + const focusActiveEditor = useCallback(() => { + vscode.postMessage({ + type: 'focusActiveEditor', + data: {}, + }); + }, [vscode]); + + return { + // State + activeFileName, + activeFilePath, + activeSelection, + workspaceFiles, + hasRequestedFiles: hasRequestedFilesRef.current, + + // State setters + setActiveFileName, + setActiveFilePath, + setActiveSelection, + setWorkspaceFiles, + + // File reference operations + addFileReference, + getFileReference, + clearFileReferences, + + // Operations + requestWorkspaceFiles, + requestActiveEditor, + focusActiveEditor, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts new file mode 100644 index 00000000..d9725c0a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useRef, useCallback } from 'react'; + +export interface TextMessage { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; +} + +/** + * Message handling Hook + * Manages message list, streaming responses, and loading state + */ +export const useMessageHandling = () => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(''); + const [currentStreamContent, setCurrentStreamContent] = useState(''); + + // Use ref to store current stream content, avoiding useEffect dependency issues + const currentStreamContentRef = useRef(''); + + /** + * Add message + */ + const addMessage = useCallback((message: TextMessage) => { + setMessages((prev) => [...prev, message]); + }, []); + + /** + * Clear messages + */ + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + /** + * Start streaming response + */ + const startStreaming = useCallback(() => { + setIsStreaming(true); + setCurrentStreamContent(''); + currentStreamContentRef.current = ''; + }, []); + + /** + * Add stream chunk + */ + const appendStreamChunk = useCallback((chunk: string) => { + setCurrentStreamContent((prev) => { + const newContent = prev + chunk; + currentStreamContentRef.current = newContent; + return newContent; + }); + }, []); + + /** + * End streaming response + */ + const endStreaming = useCallback(() => { + // If there is streaming content, add it as complete assistant message + if (currentStreamContentRef.current) { + const assistantMessage: TextMessage = { + role: 'assistant', + content: currentStreamContentRef.current, + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, assistantMessage]); + } + + setIsStreaming(false); + setIsWaitingForResponse(false); + setCurrentStreamContent(''); + currentStreamContentRef.current = ''; + }, []); + + /** + * Set waiting for response state + */ + const setWaitingForResponse = useCallback((message: string) => { + setIsWaitingForResponse(true); + setLoadingMessage(message); + }, []); + + /** + * Clear waiting for response state + */ + const clearWaitingForResponse = useCallback(() => { + setIsWaitingForResponse(false); + setLoadingMessage(''); + }, []); + + return { + // State + messages, + isStreaming, + isWaitingForResponse, + loadingMessage, + currentStreamContent, + + // Operations + addMessage, + clearMessages, + startStreaming, + appendStreamChunk, + endStreaming, + setWaitingForResponse, + clearWaitingForResponse, + setMessages, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts new file mode 100644 index 00000000..23bab679 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useMemo } from 'react'; +import type { VSCodeAPI } from '../hooks/useVSCode.js'; + +/** + * Session management Hook + * Manages session list, current session, session switching, and search + */ +export const useSessionManagement = (vscode: VSCodeAPI) => { + const [qwenSessions, setQwenSessions] = useState< + Array> + >([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [currentSessionTitle, setCurrentSessionTitle] = + useState('Past Conversations'); + const [showSessionSelector, setShowSessionSelector] = useState(false); + const [sessionSearchQuery, setSessionSearchQuery] = useState(''); + const [savedSessionTags, setSavedSessionTags] = useState([]); + + /** + * Filter session list + */ + const filteredSessions = useMemo(() => { + if (!sessionSearchQuery.trim()) { + return qwenSessions; + } + const query = sessionSearchQuery.toLowerCase(); + return qwenSessions.filter((session) => { + const title = ( + (session.title as string) || + (session.name as string) || + '' + ).toLowerCase(); + return title.includes(query); + }); + }, [qwenSessions, sessionSearchQuery]); + + /** + * Load session list + */ + const handleLoadQwenSessions = useCallback(() => { + vscode.postMessage({ type: 'getQwenSessions', data: {} }); + setShowSessionSelector(true); + }, [vscode]); + + /** + * Create new session + */ + const handleNewQwenSession = useCallback(() => { + vscode.postMessage({ type: 'openNewChatTab', data: {} }); + setShowSessionSelector(false); + }, [vscode]); + + /** + * Switch session + */ + const handleSwitchSession = useCallback( + (sessionId: string) => { + if (sessionId === currentSessionId) { + console.log('[useSessionManagement] Already on this session, ignoring'); + setShowSessionSelector(false); + return; + } + + console.log('[useSessionManagement] Switching to session:', sessionId); + vscode.postMessage({ + type: 'switchQwenSession', + data: { sessionId }, + }); + }, + [currentSessionId, vscode], + ); + + /** + * Save session + */ + const handleSaveSession = useCallback( + (tag: string) => { + vscode.postMessage({ + type: 'saveSession', + data: { tag }, + }); + }, + [vscode], + ); + + /** + * 处理Save session响应 + */ + const handleSaveSessionResponse = useCallback( + (response: { success: boolean; message?: string }) => { + if (response.success) { + if (response.message) { + const tagMatch = response.message.match(/tag: (.+)$/); + if (tagMatch) { + setSavedSessionTags((prev) => [...prev, tagMatch[1]]); + } + } + } else { + console.error('Failed to save session:', response.message); + } + }, + [], + ); + + return { + // State + qwenSessions, + currentSessionId, + currentSessionTitle, + showSessionSelector, + sessionSearchQuery, + filteredSessions, + savedSessionTags, + + // State setters + setQwenSessions, + setCurrentSessionId, + setCurrentSessionTitle, + setShowSessionSelector, + setSessionSearchQuery, + setSavedSessionTags, + + // Operations + handleLoadQwenSessions, + handleNewQwenSession, + handleSwitchSession, + handleSaveSession, + handleSaveSessionResponse, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts new file mode 100644 index 00000000..cc7d7c2e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import type { VSCodeAPI } from './useVSCode.js'; +import { getRandomLoadingMessage } from '../constants/loadingMessages.js'; + +interface UseMessageSubmitProps { + vscode: VSCodeAPI; + inputText: string; + setInputText: (text: string) => void; + inputFieldRef: React.RefObject; + isStreaming: boolean; + + fileContext: { + getFileReference: (fileName: string) => string | undefined; + activeFilePath: string | null; + activeFileName: string | null; + activeSelection: { startLine: number; endLine: number } | null; + clearFileReferences: () => void; + }; + + messageHandling: { + setWaitingForResponse: (message: string) => void; + }; +} + +/** + * Message submit Hook + * Handles message submission logic and context parsing + */ +export const useMessageSubmit = ({ + vscode, + inputText, + setInputText, + inputFieldRef, + isStreaming, + fileContext, + messageHandling, +}: UseMessageSubmitProps) => { + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (!inputText.trim() || isStreaming) { + return; + } + + // Handle /login command + if (inputText.trim() === '/login') { + setInputText(''); + if (inputFieldRef.current) { + inputFieldRef.current.textContent = ''; + } + vscode.postMessage({ + type: 'login', + data: {}, + }); + return; + } + + messageHandling.setWaitingForResponse(getRandomLoadingMessage()); + + // Parse @file references from input text + const context: Array<{ + type: string; + name: string; + value: string; + startLine?: number; + endLine?: number; + }> = []; + const fileRefPattern = /@([^\s]+)/g; + let match; + + while ((match = fileRefPattern.exec(inputText)) !== null) { + const fileName = match[1]; + const filePath = fileContext.getFileReference(fileName); + + if (filePath) { + context.push({ + type: 'file', + name: fileName, + value: filePath, + }); + } + } + + // Add active file selection context if present + if (fileContext.activeFilePath) { + const fileName = fileContext.activeFileName || 'current file'; + context.push({ + type: 'file', + name: fileName, + value: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }); + } + + let fileContextForMessage: + | { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + } + | undefined; + + if (fileContext.activeFilePath && fileContext.activeFileName) { + fileContextForMessage = { + fileName: fileContext.activeFileName, + filePath: fileContext.activeFilePath, + startLine: fileContext.activeSelection?.startLine, + endLine: fileContext.activeSelection?.endLine, + }; + } + + vscode.postMessage({ + type: 'sendMessage', + data: { + text: inputText, + context: context.length > 0 ? context : undefined, + fileContext: fileContextForMessage, + }, + }); + + setInputText(''); + if (inputFieldRef.current) { + inputFieldRef.current.textContent = ''; + } + fileContext.clearFileReferences(); + }, + [ + inputText, + isStreaming, + setInputText, + inputFieldRef, + vscode, + fileContext, + messageHandling, + ], + ); + + return { handleSubmit }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts new file mode 100644 index 00000000..6fc10aff --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { ToolCallData } from '../components/ToolCall.js'; +import type { ToolCallUpdate } from '../types/toolCall.js'; + +/** + * Tool call management Hook + * Manages tool call states and updates + */ +export const useToolCalls = () => { + const [toolCalls, setToolCalls] = useState>( + new Map(), + ); + + /** + * Handle tool call update + */ + const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => { + setToolCalls((prevToolCalls) => { + const newMap = new Map(prevToolCalls); + const existing = newMap.get(update.toolCallId); + + const safeTitle = (title: unknown): string => { + if (typeof title === 'string') { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return 'Tool Call'; + }; + + if (update.type === 'tool_call') { + const content = update.content?.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })); + + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: safeTitle(update.title), + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content, + locations: update.locations, + }); + } else if (update.type === 'tool_call_update') { + const updatedContent = update.content + ? update.content.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })) + : undefined; + + if (existing) { + const mergedContent = updatedContent + ? [...(existing.content || []), ...updatedContent] + : existing.content; + + newMap.set(update.toolCallId, { + ...existing, + ...(update.kind && { kind: update.kind }), + ...(update.title && { title: safeTitle(update.title) }), + ...(update.status && { status: update.status }), + content: mergedContent, + ...(update.locations && { locations: update.locations }), + }); + } else { + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: update.title ? safeTitle(update.title) : '', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content: updatedContent, + locations: update.locations, + }); + } + } + + return newMap; + }); + }, []); + + /** + * Clear all tool calls + */ + const clearToolCalls = useCallback(() => { + setToolCalls(new Map()); + }, []); + + /** + * Get in-progress tool calls + */ + const inProgressToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'pending' || toolCall.status === 'in_progress', + ); + + /** + * Get completed tool calls + */ + const completedToolCalls = Array.from(toolCalls.values()).filter( + (toolCall) => + toolCall.status === 'completed' || toolCall.status === 'failed', + ); + + return { + toolCalls, + inProgressToolCalls, + completedToolCalls, + handleToolCallUpdate, + clearToolCalls, + }; +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts index 51282961..1a161346 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts @@ -13,14 +13,14 @@ export interface VSCodeAPI { declare const acquireVsCodeApi: () => VSCodeAPI; /** - * 模块级别的 VS Code API 实例缓存 - * acquireVsCodeApi() 只能调用一次,必须在模块级别缓存 + * Module-level VS Code API instance cache + * acquireVsCodeApi() can only be called once, must be cached at module level */ let vscodeApiInstance: VSCodeAPI | null = null; /** - * 获取 VS Code API 实例 - * 使用模块级别缓存确保 acquireVsCodeApi() 只被调用一次 + * Get VS Code API instance + * Uses module-level cache to ensure acquireVsCodeApi() is only called once */ function getVSCodeAPI(): VSCodeAPI { if (vscodeApiInstance) { @@ -47,7 +47,7 @@ function getVSCodeAPI(): VSCodeAPI { /** * Hook to get VS Code API - * 多个组件可以安全地调用此 hook,API 实例会被复用 + * Multiple components can safely call this hook, API instance will be reused */ export function useVSCode(): VSCodeAPI { return getVSCodeAPI(); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts new file mode 100644 index 00000000..a8ec047d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import type { Conversation } from '../../storage/conversationStore.js'; +import type { + PermissionOption, + ToolCall as PermissionToolCall, +} from '../components/PermissionRequest.js'; +import type { PlanEntry } from '../components/PlanDisplay.js'; +import type { ToolCallUpdate } from '../types/toolCall.js'; + +interface UseWebViewMessagesProps { + // Session management + sessionManagement: { + currentSessionId: string | null; + setQwenSessions: (sessions: Array>) => void; + setCurrentSessionId: (id: string | null) => void; + setCurrentSessionTitle: (title: string) => void; + setShowSessionSelector: (show: boolean) => void; + handleSaveSessionResponse: (response: { + success: boolean; + message?: string; + }) => void; + }; + + // File context + fileContext: { + setActiveFileName: (name: string | null) => void; + setActiveFilePath: (path: string | null) => void; + setActiveSelection: ( + selection: { startLine: number; endLine: number } | null, + ) => void; + setWorkspaceFiles: ( + files: Array<{ + id: string; + label: string; + description: string; + path: string; + }>, + ) => void; + addFileReference: (name: string, path: string) => void; + }; + + // Message handling + messageHandling: { + setMessages: ( + messages: Array<{ + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + fileContext?: { + fileName: string; + filePath: string; + startLine?: number; + endLine?: number; + }; + }>, + ) => void; + addMessage: (message: { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; + }) => void; + clearMessages: () => void; + startStreaming: () => void; + appendStreamChunk: (chunk: string) => void; + endStreaming: () => void; + clearWaitingForResponse: () => void; + }; + + // Tool calls + handleToolCallUpdate: (update: ToolCallUpdate) => void; + clearToolCalls: () => void; + + // Plan + setPlanEntries: (entries: PlanEntry[]) => void; + + // Permission + handlePermissionRequest: (request: { + options: PermissionOption[]; + toolCall: PermissionToolCall; + }) => void; + + // Input + inputFieldRef: React.RefObject; + setInputText: (text: string) => void; +} + +/** + * WebView message handling Hook + * Handles all messages from VSCode Extension uniformly + */ +export const useWebViewMessages = ({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + inputFieldRef, + setInputText, +}: UseWebViewMessagesProps) => { + // Use ref to store callbacks to avoid useEffect dependency issues + const handlersRef = useRef({ + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + }); + + // Update refs + useEffect(() => { + handlersRef.current = { + sessionManagement, + fileContext, + messageHandling, + handleToolCallUpdate, + clearToolCalls, + setPlanEntries, + handlePermissionRequest, + }; + }); + + const handleMessage = useCallback( + (event: MessageEvent) => { + const message = event.data; + const handlers = handlersRef.current; + + switch (message.type) { + case 'conversationLoaded': { + const conversation = message.data as Conversation; + handlers.messageHandling.setMessages(conversation.messages); + break; + } + + case 'message': { + handlers.messageHandling.addMessage(message.data); + break; + } + + case 'streamStart': + handlers.messageHandling.startStreaming(); + break; + + case 'streamChunk': { + handlers.messageHandling.appendStreamChunk(message.data.chunk); + break; + } + + case 'thoughtChunk': { + const thinkingMessage = { + role: 'thinking' as const, + content: message.data.content || message.data.chunk || '', + timestamp: Date.now(), + }; + handlers.messageHandling.addMessage(thinkingMessage); + break; + } + + case 'streamEnd': + handlers.messageHandling.endStreaming(); + break; + + case 'error': + handlers.messageHandling.clearWaitingForResponse(); + break; + + case 'permissionRequest': { + handlers.handlePermissionRequest(message.data); + + const permToolCall = message.data?.toolCall as { + toolCallId?: string; + kind?: string; + title?: string; + status?: string; + content?: unknown[]; + locations?: Array<{ path: string; line?: number | null }>; + }; + + if (permToolCall?.toolCallId) { + let kind = permToolCall.kind || 'execute'; + if (permToolCall.title) { + const title = permToolCall.title.toLowerCase(); + if (title.includes('touch') || title.includes('echo')) { + kind = 'execute'; + } else if (title.includes('read') || title.includes('cat')) { + kind = 'read'; + } else if (title.includes('write') || title.includes('edit')) { + kind = 'edit'; + } + } + + const normalizedStatus = ( + permToolCall.status === 'pending' || + permToolCall.status === 'in_progress' || + permToolCall.status === 'completed' || + permToolCall.status === 'failed' + ? permToolCall.status + : 'pending' + ) as ToolCallUpdate['status']; + + handlers.handleToolCallUpdate({ + type: 'tool_call', + toolCallId: permToolCall.toolCallId, + kind, + title: permToolCall.title, + status: normalizedStatus, + content: permToolCall.content as ToolCallUpdate['content'], + locations: permToolCall.locations, + }); + } + break; + } + + case 'plan': + if (message.data.entries && Array.isArray(message.data.entries)) { + handlers.setPlanEntries(message.data.entries as PlanEntry[]); + } + break; + + case 'toolCall': + case 'toolCallUpdate': { + const toolCallData = message.data; + if (toolCallData.sessionUpdate && !toolCallData.type) { + toolCallData.type = toolCallData.sessionUpdate; + } + handlers.handleToolCallUpdate(toolCallData); + break; + } + + case 'qwenSessionList': { + const sessions = message.data.sessions || []; + handlers.sessionManagement.setQwenSessions(sessions); + if ( + handlers.sessionManagement.currentSessionId && + sessions.length > 0 + ) { + const currentSession = sessions.find( + (s: Record) => + (s.id as string) === + handlers.sessionManagement.currentSessionId || + (s.sessionId as string) === + handlers.sessionManagement.currentSessionId, + ); + if (currentSession) { + const title = + (currentSession.title as string) || + (currentSession.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + } + } + break; + } + + case 'qwenSessionSwitched': + handlers.sessionManagement.setShowSessionSelector(false); + if (message.data.sessionId) { + handlers.sessionManagement.setCurrentSessionId( + message.data.sessionId as string, + ); + } + if (message.data.session) { + const session = message.data.session as Record; + const title = + (session.title as string) || + (session.name as string) || + 'Past Conversations'; + handlers.sessionManagement.setCurrentSessionTitle(title); + } + if (message.data.messages) { + handlers.messageHandling.setMessages(message.data.messages); + } else { + handlers.messageHandling.clearMessages(); + } + handlers.clearToolCalls(); + handlers.setPlanEntries([]); + break; + + case 'conversationCleared': + handlers.messageHandling.clearMessages(); + handlers.clearToolCalls(); + handlers.sessionManagement.setCurrentSessionId(null); + handlers.sessionManagement.setCurrentSessionTitle( + 'Past Conversations', + ); + break; + + case 'sessionTitleUpdated': { + const sessionId = message.data?.sessionId as string; + const title = message.data?.title as string; + if (sessionId && title) { + handlers.sessionManagement.setCurrentSessionId(sessionId); + handlers.sessionManagement.setCurrentSessionTitle(title); + } + break; + } + + case 'activeEditorChanged': { + const fileName = message.data?.fileName as string | null; + const filePath = message.data?.filePath as string | null; + const selection = message.data?.selection as { + startLine: number; + endLine: number; + } | null; + handlers.fileContext.setActiveFileName(fileName); + handlers.fileContext.setActiveFilePath(filePath); + handlers.fileContext.setActiveSelection(selection); + break; + } + + case 'fileAttached': { + const attachment = message.data as { + id: string; + type: string; + name: string; + value: string; + }; + + handlers.fileContext.addFileReference( + attachment.name, + attachment.value, + ); + + if (inputFieldRef.current) { + const currentText = inputFieldRef.current.textContent || ''; + const newText = currentText + ? `${currentText} @${attachment.name} ` + : `@${attachment.name} `; + inputFieldRef.current.textContent = newText; + setInputText(newText); + + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(inputFieldRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + break; + } + + case 'workspaceFiles': { + const files = message.data?.files as Array<{ + id: string; + label: string; + description: string; + path: string; + }>; + if (files) { + handlers.fileContext.setWorkspaceFiles(files); + } + break; + } + + case 'saveSessionResponse': { + handlers.sessionManagement.handleSaveSessionResponse(message.data); + break; + } + + default: + break; + } + }, + [inputFieldRef, setInputText], + ); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [handleMessage]); +}; diff --git a/packages/vscode-ide-companion/src/webview/types/toolCall.ts b/packages/vscode-ide-companion/src/webview/types/toolCall.ts new file mode 100644 index 00000000..353d4059 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/types/toolCall.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool call update type + */ +export interface ToolCallUpdate { + type: 'tool_call' | 'tool_call_update'; + toolCallId: string; + kind?: string; + title?: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; +} + +/** + * Edit mode type + */ +export type EditMode = 'ask' | 'auto' | 'plan'; diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts new file mode 100644 index 00000000..c3ebd3d9 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface SessionGroup { + label: string; + sessions: Array>; +} + +/** + * Group sessions by date (matching Claude Code) + * + * @param sessions - Array of session objects + * @returns Array of grouped sessions + */ +export const groupSessionsByDate = ( + sessions: Array>, +): SessionGroup[] => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const groups: { + [key: string]: Array>; + } = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + sessions.forEach((session) => { + const timestamp = + (session.lastUpdated as string) || (session.startTime as string) || ''; + if (!timestamp) { + groups['Older'].push(session); + return; + } + + const sessionDate = new Date(timestamp); + const sessionDay = new Date( + sessionDate.getFullYear(), + sessionDate.getMonth(), + sessionDate.getDate(), + ); + + if (sessionDay.getTime() === today.getTime()) { + groups['Today'].push(session); + } else if (sessionDay.getTime() === yesterday.getTime()) { + groups['Yesterday'].push(session); + } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { + groups['This Week'].push(session); + } else { + groups['Older'].push(session); + } + }); + + return Object.entries(groups) + .filter(([, sessions]) => sessions.length > 0) + .map(([label, sessions]) => ({ label, sessions })); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts b/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts new file mode 100644 index 00000000..99ee82b1 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Time ago formatter (matching Claude Code) + * + * @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(); +};