From 7cd26f728df43f02e3cafd454ba1c99990437360 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 6 Dec 2025 21:46:14 +0800 Subject: [PATCH] feat(vscode-ide-companion): implement session message handling and UI improvements Complete session message handling with JSONL support and UI enhancements - Add JSONL session file reading capability - Improve error handling and authentication flows - Update UI components for better user experience - Fix command identifier references - Enhance MarkdownRenderer with copy functionality - Update Tailwind configuration for better component coverage --- .../src/agents/qwenAgentManager.ts | 533 +++++++++++++++--- .../src/agents/qwenConnectionHandler.ts | 54 +- .../src/agents/qwenTypes.ts | 2 +- .../src/cli/cliVersionManager.ts | 3 - .../src/commands/index.ts | 53 +- .../src/constants/acpSchema.ts | 29 - .../vscode-ide-companion/src/webview/App.tsx | 257 +++++---- .../src/webview/WebViewProvider.ts | 180 ++++-- .../webview/components/InProgressToolCall.tsx | 197 ------- .../MarkdownRenderer/MarkdownRenderer.css | 6 +- .../MarkdownRenderer/MarkdownRenderer.tsx | 48 +- .../PermissionDrawer/PermissionDrawer.tsx | 313 ++++++++++ .../PermissionDrawer/PermissionRequest.tsx | 37 ++ .../webview/components/PermissionRequest.tsx | 227 -------- .../src/webview/components/ToolCall.tsx | 6 +- .../messages/Assistant/AssistantMessage.tsx | 8 +- .../components/messages/UserMessage.tsx | 2 +- .../src/webview/components/messages/index.tsx | 1 - .../webview/components/ui/CheckboxDisplay.tsx | 4 +- .../webview/handlers/AuthMessageHandler.ts | 2 +- .../vscode-ide-companion/tailwind.config.js | 13 +- 21 files changed, 1190 insertions(+), 785 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/components/InProgressToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx diff --git a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts index bed783a8..f1ea5c17 100644 --- a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts @@ -147,50 +147,6 @@ export class QwenAgentManager { } } - /** - * Check if the current session is valid and can send messages - * This performs a lightweight validation by sending a test prompt - * - * @returns True if session is valid, false otherwise - */ - async checkSessionValidity(): Promise { - try { - // If we don't have a current session, it's definitely not valid - if (!this.connection.currentSessionId) { - return false; - } - - // Try to send a lightweight test prompt to validate the session - // We use a simple prompt that should return quickly - await this.connection.sendPrompt('test session validity'); - return true; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - console.warn( - '[QwenAgentManager] Session validity check failed:', - errorMsg, - ); - - // Check for common authentication/session expiration errors - const isAuthError = - errorMsg.includes('Authentication required') || - errorMsg.includes('(code: -32000)') || - errorMsg.includes('No active ACP session') || - errorMsg.includes('Session not found'); - - if (isAuthError) { - console.log( - '[QwenAgentManager] Detected authentication/session expiration', - ); - return false; - } - - // For other errors, we can't determine validity definitively - // Assume session is still valid unless we know it's not - return true; - } - } - /** * Get session list with version-aware strategy * First tries ACP method if CLI version supports it, falls back to file system method @@ -220,16 +176,42 @@ export class QwenAgentManager { const response = await this.connection.listSessions(); console.log('[QwenAgentManager] ACP session list response:', response); - if (response.result && Array.isArray(response.result)) { - const sessions = response.result.map((session) => ({ - id: session.sessionId || session.id, - sessionId: session.sessionId || session.id, - title: session.title || session.name || 'Untitled Session', - name: session.title || session.name || 'Untitled Session', - startTime: session.startTime, - lastUpdated: session.lastUpdated, - messageCount: session.messageCount || 0, - projectHash: session.projectHash, + // sendRequest resolves with the JSON-RPC "result" directly + // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } + // Older prototypes might return an array. Support both. + const res: unknown = response; + let items: Array> = []; + + if ( + typeof response === 'object' && + response !== null && + 'items' in response + ) { + // Type guard to safely access items property + const responseObject: Record = response; + if ('items' in responseObject) { + const itemsValue = responseObject.items; + items = Array.isArray(itemsValue) ? itemsValue : []; + } + } + + console.log( + '[QwenAgentManager] Sessions retrieved via ACP:', + res, + items.length, + ); + if (items.length > 0) { + const sessions = items.map((item) => ({ + id: item.sessionId || item.id, + sessionId: item.sessionId || item.id, + title: item.title || item.name || item.prompt || 'Untitled Session', + name: item.title || item.name || item.prompt || 'Untitled Session', + startTime: item.startTime, + lastUpdated: item.mtime || item.lastUpdated, + messageCount: item.messageCount || 0, + projectHash: item.projectHash, + filePath: item.filePath, + cwd: item.cwd, })); console.log( @@ -282,6 +264,116 @@ export class QwenAgentManager { } } + /** + * Get session list (paged) + * Uses ACP session/list with cursor-based pagination when available. + * Falls back to file system scan with equivalent pagination semantics. + */ + async getSessionListPaged(params?: { + cursor?: number; + size?: number; + }): Promise<{ + sessions: Array>; + nextCursor?: number; + hasMore: boolean; + }> { + const size = params?.size ?? 20; + const cursor = params?.cursor; + + const cliContextManager = CliContextManager.getInstance(); + const supportsSessionList = cliContextManager.supportsSessionList(); + + if (supportsSessionList) { + try { + const response = await this.connection.listSessions({ + size, + ...(cursor !== undefined ? { cursor } : {}), + }); + // sendRequest resolves with the JSON-RPC "result" directly + const res: unknown = response; + let items: Array> = []; + + if (Array.isArray(res)) { + items = res; + } else if (typeof res === 'object' && res !== null && 'items' in res) { + const responseObject = res as { + items?: Array>; + }; + items = Array.isArray(responseObject.items) + ? responseObject.items + : []; + } + + const mapped = items.map((item) => ({ + id: item.sessionId || item.id, + sessionId: item.sessionId || item.id, + title: item.title || item.name || item.prompt || 'Untitled Session', + name: item.title || item.name || item.prompt || 'Untitled Session', + startTime: item.startTime, + lastUpdated: item.mtime || item.lastUpdated, + messageCount: item.messageCount || 0, + projectHash: item.projectHash, + filePath: item.filePath, + cwd: item.cwd, + })); + + const nextCursor: number | undefined = + typeof res === 'object' && res !== null && 'nextCursor' in res + ? typeof res.nextCursor === 'number' + ? res.nextCursor + : undefined + : undefined; + const hasMore: boolean = + typeof res === 'object' && res !== null && 'hasMore' in res + ? Boolean(res.hasMore) + : false; + + return { sessions: mapped, nextCursor, hasMore }; + } catch (error) { + console.warn( + '[QwenAgentManager] Paged ACP session list failed:', + error, + ); + // fall through to file system + } + } + + // Fallback: file system for current project only (to match ACP semantics) + try { + const all = await this.sessionReader.getAllSessions( + this.currentWorkingDir, + false, + ); + // Sorted by lastUpdated desc already per reader + const allWithMtime = all.map((s) => ({ + raw: s, + mtime: new Date(s.lastUpdated).getTime(), + })); + const filtered = + cursor !== undefined + ? allWithMtime.filter((x) => x.mtime < cursor) + : allWithMtime; + const page = filtered.slice(0, size); + const sessions = page.map((x) => ({ + id: x.raw.sessionId, + sessionId: x.raw.sessionId, + title: this.sessionReader.getSessionTitle(x.raw), + name: this.sessionReader.getSessionTitle(x.raw), + startTime: x.raw.startTime, + lastUpdated: x.raw.lastUpdated, + messageCount: x.raw.messages.length, + projectHash: x.raw.projectHash, + })); + const nextCursorVal = + page.length > 0 ? page[page.length - 1].mtime : undefined; + const hasMore = filtered.length > size; + return { sessions, nextCursor: nextCursorVal, hasMore }; + } catch (error) { + console.error('[QwenAgentManager] File system paged list failed:', error); + return { sessions: [], hasMore: false }; + } + } + /** * Get session messages (read from disk) * @@ -290,6 +382,35 @@ export class QwenAgentManager { */ async getSessionMessages(sessionId: string): Promise { try { + // Prefer reading CLI's JSONL if we can find filePath from session/list + const cliContextManager = CliContextManager.getInstance(); + if (cliContextManager.supportsSessionList()) { + try { + const list = await this.getSessionList(); + const item = list.find( + (s) => s.sessionId === sessionId || s.id === sessionId, + ); + console.log( + '[QwenAgentManager] Session list item for filePath lookup:', + item, + ); + if ( + typeof item === 'object' && + item !== null && + 'filePath' in item && + typeof item.filePath === 'string' + ) { + const messages = await this.readJsonlMessages(item.filePath); + // Even if messages array is empty, we should return it rather than falling back + // This ensures we don't accidentally show messages from a different session format + return messages; + } + } catch (e) { + console.warn('[QwenAgentManager] JSONL read path lookup failed:', e); + } + } + + // Fallback: legacy JSON session files const session = await this.sessionReader.getSession( sessionId, this.currentWorkingDir, @@ -297,11 +418,9 @@ export class QwenAgentManager { if (!session) { return []; } - return session.messages.map( (msg: { type: string; content: string; timestamp: string }) => ({ - role: - msg.type === 'user' ? ('user' as const) : ('assistant' as const), + role: msg.type === 'user' ? 'user' : 'assistant', content: msg.content, timestamp: new Date(msg.timestamp).getTime(), }), @@ -315,6 +434,265 @@ export class QwenAgentManager { } } + // Read CLI JSONL session file and convert to ChatMessage[] for UI + private async readJsonlMessages(filePath: string): Promise { + const fs = await import('fs'); + const readline = await import('readline'); + try { + if (!fs.existsSync(filePath)) return []; + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + const records: unknown[] = []; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed); + records.push(obj); + } catch { + /* ignore */ + } + } + // Simple linear reconstruction: filter user/assistant and sort by timestamp + console.log( + '[QwenAgentManager] JSONL records read:', + records.length, + filePath, + ); + + // Include all types of records, not just user/assistant + const allRecords = records + .filter((r) => r && r.type && r.timestamp) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const msgs: ChatMessage[] = []; + for (const r of allRecords) { + // Handle user and assistant messages + if ((r.type === 'user' || r.type === 'assistant') && r.message) { + msgs.push({ + role: + r.type === 'user' ? ('user' as const) : ('assistant' as const), + content: this.contentToText(r.message), + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle tool call records that might have content we want to show + else if (r.type === 'tool_call' || r.type === 'tool_call_update') { + // Convert tool calls to messages if they have relevant content + const toolContent = this.extractToolCallContent(r); + if (toolContent) { + msgs.push({ + role: 'assistant', + content: toolContent, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle tool result records + else if (r.type === 'tool_result' && r.toolCallResult) { + const toolResult = r.toolCallResult; + const callId = toolResult.callId || 'unknown'; + const status = toolResult.status || 'unknown'; + const resultText = `Tool Result (${callId}): ${status}`; + msgs.push({ + role: 'assistant', + content: resultText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + // Handle system telemetry records + else if ( + r.type === 'system' && + r.subtype === 'ui_telemetry' && + r.systemPayload?.uiEvent + ) { + const uiEvent = r.systemPayload.uiEvent; + let telemetryText = ''; + + if ( + uiEvent['event.name'] && + uiEvent['event.name'].includes('tool_call') + ) { + const functionName = uiEvent.function_name || 'Unknown tool'; + const status = uiEvent.status || 'unknown'; + const duration = uiEvent.duration_ms + ? ` (${uiEvent.duration_ms}ms)` + : ''; + telemetryText = `Tool Call: ${functionName} - ${status}${duration}`; + } else if ( + uiEvent['event.name'] && + uiEvent['event.name'].includes('api_response') + ) { + const statusCode = uiEvent.status_code || 'unknown'; + const duration = uiEvent.duration_ms + ? ` (${uiEvent.duration_ms}ms)` + : ''; + telemetryText = `API Response: Status ${statusCode}${duration}`; + } else { + // Generic system telemetry + const eventName = uiEvent['event.name'] || 'Unknown event'; + telemetryText = `System Event: ${eventName}`; + } + + if (telemetryText) { + msgs.push({ + role: 'assistant', + content: telemetryText, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle plan entries + else if (r.type === 'plan' && r.plan) { + const planEntries = r.plan.entries || []; + if (planEntries.length > 0) { + const planText = planEntries + .map( + (entry: Record, index: number) => + `${index + 1}. ${entry.description || entry.title || 'Unnamed step'}`, + ) + .join('\n'); + msgs.push({ + role: 'assistant', + content: `Plan:\n${planText}`, + timestamp: new Date(r.timestamp).getTime(), + }); + } + } + // Handle other types if needed + } + + console.log( + '[QwenAgentManager] JSONL messages reconstructed:', + msgs.length, + ); + return msgs; + } catch (err) { + console.warn('[QwenAgentManager] Failed to read JSONL messages:', err); + return []; + } + } + + // Extract meaningful content from tool call records + private extractToolCallContent(record: unknown): string | null { + try { + // Type guard for record + if (typeof record !== 'object' || record === null) { + return null; + } + + // Cast to a more specific type for easier handling + const typedRecord = record as Record; + + // If the tool call has a result or output, include it + if ('toolCallResult' in typedRecord && typedRecord.toolCallResult) { + return `Tool result: ${this.formatValue(typedRecord.toolCallResult)}`; + } + + // If the tool call has content, include it + if ('content' in typedRecord && typedRecord.content) { + return this.formatValue(typedRecord.content); + } + + // If the tool call has a title or name, include it + if ( + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) + ) { + return `Tool: ${typedRecord.title || typedRecord.name}`; + } + + // Handle tool_call records with more details + if ( + typedRecord.type === 'tool_call' && + 'toolCall' in typedRecord && + typedRecord.toolCall + ) { + const toolCall = typedRecord.toolCall as Record; + if ( + ('title' in toolCall && toolCall.title) || + ('name' in toolCall && toolCall.name) + ) { + return `Tool call: ${toolCall.title || toolCall.name}`; + } + if ('rawInput' in toolCall && toolCall.rawInput) { + return `Tool input: ${this.formatValue(toolCall.rawInput)}`; + } + } + + // Handle tool_call_update records with status + if (typedRecord.type === 'tool_call_update') { + const status = + ('status' in typedRecord && typedRecord.status) || 'unknown'; + const title = + ('title' in typedRecord && typedRecord.title) || + ('name' in typedRecord && typedRecord.name) || + 'Unknown tool'; + return `Tool ${status}: ${title}`; + } + + return null; + } catch { + return null; + } + } + + // Format any value to a string for display + private formatValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); + } + + // Extract plain text from Content (genai Content) + private contentToText(message: unknown): string { + try { + // Type guard for message + if (typeof message !== 'object' || message === null) { + return ''; + } + + // Cast to a more specific type for easier handling + const typedMessage = message as Record; + + const parts = Array.isArray(typedMessage.parts) ? typedMessage.parts : []; + const texts: string[] = []; + for (const p of parts) { + // Type guard for part + if (typeof p !== 'object' || p === null) { + continue; + } + + const typedPart = p as Record; + if (typeof typedPart.text === 'string') { + texts.push(typedPart.text); + } else if (typeof typedPart.data === 'string') { + texts.push(typedPart.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + /** * Save session via /chat save command * Since CLI doesn't support session/save ACP method, we send /chat save command directly @@ -497,7 +875,10 @@ export class QwenAgentManager { * @param sessionId - Session ID * @returns Load response or error */ - async loadSessionViaAcp(sessionId: string): Promise { + async loadSessionViaAcp( + sessionId: string, + cwdOverride?: string, + ): Promise { // Check if CLI supports session/load method const cliContextManager = CliContextManager.getInstance(); const supportsSessionLoad = cliContextManager.supportsSessionLoad(); @@ -513,7 +894,10 @@ export class QwenAgentManager { '[QwenAgentManager] Attempting session/load via ACP for session:', sessionId, ); - const response = await this.connection.loadSession(sessionId); + const response = await this.connection.loadSession( + sessionId, + cwdOverride, + ); console.log( '[QwenAgentManager] Session load succeeded. Response:', JSON.stringify(response).substring(0, 200), @@ -530,19 +914,24 @@ export class QwenAgentManager { console.error('[QwenAgentManager] Error message:', errorMessage); // Check if error is from ACP response - if (error && typeof error === 'object' && 'error' in error) { - const acpError = error as { - error?: { code?: number; message?: string }; - }; - if (acpError.error) { - console.error( - '[QwenAgentManager] ACP error code:', - acpError.error.code, - ); - console.error( - '[QwenAgentManager] ACP error message:', - acpError.error.message, - ); + if (error && typeof error === 'object') { + // Safely check if 'error' property exists + if ('error' in error) { + const acpError = error as { + error?: { code?: number; message?: string }; + }; + if (acpError.error) { + console.error( + '[QwenAgentManager] ACP error code:', + acpError.error.code, + ); + console.error( + '[QwenAgentManager] ACP error message:', + acpError.error.message, + ); + } + } else { + console.error('[QwenAgentManager] Non-ACPIf error details:', error); } } diff --git a/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts index 5e372ab8..00fdd2f7 100644 --- a/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts @@ -89,54 +89,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); - - if (sessions.length > 0) { - console.log( - '[QwenAgentManager] Found existing sessions:', - sessions.length, - ); - const lastSession = sessions[0]; // Already sorted by lastUpdated - - try { - await connection.switchSession(lastSession.sessionId); - console.log( - '[QwenAgentManager] Restored session:', - lastSession.sessionId, - ); - sessionRestored = true; - - // Save auth state after successful session restore - if (authStateManager) { - console.log( - '[QwenAgentManager] Saving auth state after successful session restore', - ); - await authStateManager.saveAuthState(workingDir, authMethod); - } - } catch (switchError) { - console.log( - '[QwenAgentManager] session/switch not supported or failed:', - switchError instanceof Error - ? switchError.message - : String(switchError), - ); - } - } else { - console.log('[QwenAgentManager] No existing sessions found'); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.log( - '[QwenAgentManager] Failed to read local sessions:', - errorMessage, - ); - } + // Note: Auto-restore on connect is disabled to avoid surprising loads + // when user opens a "New Chat" tab. Restoration is now an explicit action + // (session selector → session/load) or handled by higher-level flows. + const sessionRestored = false; // Create new session if unable to restore if (!sessionRestored) { @@ -203,7 +159,7 @@ export class QwenConnectionHandler { console.log('[QwenAgentManager] New session created successfully'); // Ensure auth state is saved (prevent repeated authentication) - if (authStateManager && !hasValidAuth) { + if (authStateManager) { console.log( '[QwenAgentManager] Saving auth state after successful session creation', ); diff --git a/packages/vscode-ide-companion/src/agents/qwenTypes.ts b/packages/vscode-ide-companion/src/agents/qwenTypes.ts index 6cb5c625..2ac22c04 100644 --- a/packages/vscode-ide-companion/src/agents/qwenTypes.ts +++ b/packages/vscode-ide-companion/src/agents/qwenTypes.ts @@ -18,7 +18,7 @@ export interface PlanEntry { /** Entry content */ content: string; /** Priority */ - priority: 'high' | 'medium' | 'low'; + priority?: 'high' | 'medium' | 'low'; /** Status */ status: 'pending' | 'in_progress' | 'completed'; } diff --git a/packages/vscode-ide-companion/src/cli/cliVersionManager.ts b/packages/vscode-ide-companion/src/cli/cliVersionManager.ts index acf76bf6..383ae638 100644 --- a/packages/vscode-ide-companion/src/cli/cliVersionManager.ts +++ b/packages/vscode-ide-companion/src/cli/cliVersionManager.ts @@ -6,9 +6,6 @@ import { CliDetector, type CliDetectionResult } from './cliDetector.js'; -/** - * Minimum CLI version that supports session/list and session/load ACP methods - */ export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.4.0'; /** diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index ef432825..e8830e11 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -4,8 +4,12 @@ import type { WebViewProvider } from '../webview/WebViewProvider.js'; type Logger = (message: string) => void; +export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; -export const openChatCommand = 'qwenCode.openChat'; +export const openChatCommand = 'qwen-code.openChat'; +export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; +export const loginCommand = 'qwen-code.login'; +export const clearAuthCacheCommand = 'qwen-code.clearAuthCache'; export function registerNewCommands( context: vscode.ExtensionContext, @@ -20,15 +24,15 @@ export function registerNewCommands( vscode.commands.registerCommand(openChatCommand, async () => { const config = vscode.workspace.getConfiguration('qwenCode'); const useTerminal = config.get('useTerminal', false); - console.log('[Command] Using terminal mode:', useTerminal); + + // Use terminal mode if (useTerminal) { - // 使用终端模式 await vscode.commands.executeCommand( - 'qwen-code.runQwenCode', - vscode.TerminalLocation.Editor, // 在编辑器区域创建终端, + runQwenCodeCommand, + vscode.TerminalLocation.Editor, // create a terminal in the editor area, ); } else { - // 使用 WebView 模式 + // Use WebView mode const providers = getWebViewProviders(); if (providers.length > 0) { await providers[providers.length - 1].show(); @@ -44,7 +48,6 @@ export function registerNewCommands( vscode.commands.registerCommand( showDiffCommand, async (args: { path: string; oldText: string; newText: string }) => { - log(`[Command] showDiff called for: ${args.path}`); try { let absolutePath = args.path; if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) { @@ -68,27 +71,20 @@ export function registerNewCommands( // TODO: qwenCode.openNewChatTab (not contributed in package.json; used programmatically) disposables.push( - vscode.commands.registerCommand('qwenCode.openNewChatTab', async () => { + vscode.commands.registerCommand(openNewChatTabCommand, async () => { const provider = createWebViewProvider(); + // Suppress auto-restore for this newly created tab so it starts clean + try { + provider.suppressAutoRestoreOnce?.(); + } catch { + // ignore if older provider does not implement the method + } await provider.show(); }), ); disposables.push( - vscode.commands.registerCommand('qwenCode.clearAuthCache', async () => { - const providers = getWebViewProviders(); - for (const provider of providers) { - await provider.clearAuthCache(); - } - vscode.window.showInformationMessage( - 'Qwen Code authentication cache cleared. You will need to login again on next connection.', - ); - log('Auth cache cleared by user'); - }), - ); - - disposables.push( - vscode.commands.registerCommand('qwenCode.login', async () => { + vscode.commands.registerCommand(loginCommand, async () => { const providers = getWebViewProviders(); if (providers.length > 0) { await providers[providers.length - 1].forceReLogin(); @@ -100,5 +96,18 @@ export function registerNewCommands( }), ); + disposables.push( + vscode.commands.registerCommand(clearAuthCacheCommand, async () => { + const providers = getWebViewProviders(); + for (const provider of providers) { + await provider.clearAuthCache(); + } + vscode.window.showInformationMessage( + 'Qwen Code authentication cache cleared. You will need to login again on next connection.', + ); + log('Auth cache cleared by user'); + }), + ); + context.subscriptions.push(...disposables); } diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts index 5f826f97..65125b63 100644 --- a/packages/vscode-ide-companion/src/constants/acpSchema.ts +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -4,26 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** - * ACP (Agent Communication Protocol) Method Definitions - * - * This file defines the protocol methods for communication between - * the VSCode extension (Client) and the qwen CLI (Agent/Server). - */ - -/** - * Methods that the Agent (CLI) implements and receives from Client (VSCode) - * - * Status in qwen CLI: - * ✅ initialize - Protocol initialization - * ✅ authenticate - User authentication - * ✅ session/new - Create new session - * ✅ session/load - Load existing session (v0.2.4+) - * ✅ session/list - List available sessions (v0.2.4+) - * ✅ session/prompt - Send user message to agent - * ✅ session/cancel - Cancel current generation - * ✅ session/save - Save current session - */ export const AGENT_METHODS = { authenticate: 'authenticate', initialize: 'initialize', @@ -35,15 +15,6 @@ export const AGENT_METHODS = { session_save: 'session/save', } as const; -/** - * Methods that the Client (VSCode) implements and receives from Agent (CLI) - * - * Status in VSCode extension: - * ✅ fs/read_text_file - Read file content - * ✅ fs/write_text_file - Write file content - * ✅ session/request_permission - Request user permission for tool execution - * ✅ session/update - Stream session updates (notification) - */ export const CLIENT_METHODS = { fs_read_text_file: 'fs/read_text_file', fs_write_text_file: 'fs/write_text_file', diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 0a15af83..0ecd892c 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -9,6 +9,7 @@ import React, { useEffect, useRef, useCallback, + useMemo, useLayoutEffect, } from 'react'; import { useVSCode } from './hooks/useVSCode.js'; @@ -21,15 +22,13 @@ import { useMessageSubmit } from './hooks/useMessageSubmit.js'; import type { PermissionOption, ToolCall as PermissionToolCall, -} from './components/PermissionRequest.js'; +} from './components/PermissionDrawer/PermissionRequest.js'; import type { TextMessage } from './hooks/message/useMessageHandling.js'; import type { ToolCallData } from './components/ToolCall.js'; -import { PermissionDrawer } from './components/PermissionDrawer.js'; +import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.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/ui/EmptyState.js'; -import type { PlanEntry } from './components/PlanDisplay.js'; import { type CompletionItem } from './types/CompletionTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { InfoBanner } from './components/ui/InfoBanner.js'; @@ -45,6 +44,7 @@ import { InputForm } from './components/InputForm.js'; import { SessionSelector } from './components/session/SessionSelector.js'; import { FileIcon, UserIcon } from './components/icons/index.js'; import type { EditMode } from './types/toolCall.js'; +import type { PlanEntry } from '../agents/qwenTypes.js'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -488,12 +488,138 @@ export const App: React.FC = () => { setThinkingEnabled((prev) => !prev); }; + // Create unified message array containing all types of messages and tool calls + const allMessages = useMemo< + Array<{ + type: 'message' | 'in-progress-tool-call' | 'completed-tool-call'; + data: TextMessage | ToolCallData; + timestamp: number; + }> + >(() => { + // Regular messages + const regularMessages = messageHandling.messages.map((msg) => ({ + type: 'message' as const, + data: msg, + timestamp: msg.timestamp, + })); + + // In-progress tool calls + const inProgressTools = inProgressToolCalls.map((toolCall) => ({ + type: 'in-progress-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Completed tool calls + const completedTools = completedToolCalls + .filter(hasToolCallOutput) + .map((toolCall) => ({ + type: 'completed-tool-call' as const, + data: toolCall, + timestamp: toolCall.timestamp || Date.now(), + })); + + // Merge and sort by timestamp to ensure messages and tool calls are interleaved + return [...regularMessages, ...inProgressTools, ...completedTools].sort( + (a, b) => (a.timestamp || 0) - (b.timestamp || 0), + ); + }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); + + console.log('[App] Rendering messages:', allMessages); + + // Render all messages and tool calls + const renderMessages = useCallback<() => React.ReactNode>( + () => + allMessages.map((item, index) => { + switch (item.type) { + case 'message': { + const msg = item.data as TextMessage; + const handleFileClick = (path: string): void => { + vscode.postMessage({ + type: 'openFile', + data: { path }, + }); + }; + + if (msg.role === 'thinking') { + return ( + + ); + } + + if (msg.role === 'user') { + return ( + + ); + } + + { + const content = (msg.content || '').trim(); + if (content === 'Interrupted' || content === 'Tool interrupted') { + return ( + + ); + } + return ( + + ); + } + } + + case 'in-progress-tool-call': + case 'completed-tool-call': { + const prev = allMessages[index - 1]; + const next = allMessages[index + 1]; + const isToolCallType = ( + x: unknown, + ): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } => + x && + typeof x === 'object' && + 'type' in (x as Record) && + ((x as { type: string }).type === 'in-progress-tool-call' || + (x as { type: string }).type === 'completed-tool-call'); + const isFirst = !isToolCallType(prev); + const isLast = !isToolCallType(next); + return ( + + ); + } + + default: + return null; + } + }), + [allMessages, vscode], + ); + const hasContent = messageHandling.messages.length > 0 || messageHandling.isStreaming || inProgressToolCalls.length > 0 || completedToolCalls.length > 0 || - planEntries.length > 0; + planEntries.length > 0 || + allMessages.length > 0; return (
@@ -508,6 +634,9 @@ export const App: React.FC = () => { sessionManagement.setSessionSearchQuery(''); }} onClose={() => sessionManagement.setShowSessionSelector(false)} + hasMore={sessionManagement.hasMore} + isLoading={sessionManagement.isLoading} + onLoadMore={sessionManagement.handleLoadMoreSessions} /> { ) : ( <> - {/* Create unified message array containing all types of messages and tool calls */} - {(() => { - // Regular messages - const regularMessages = messageHandling.messages.map((msg) => ({ - type: 'message' as const, - data: msg, - timestamp: msg.timestamp, - })); - - // In-progress tool calls - const inProgressTools = inProgressToolCalls.map((toolCall) => ({ - type: 'in-progress-tool-call' as const, - data: toolCall, - timestamp: toolCall.timestamp || Date.now(), - })); - - // Completed tool calls - const completedTools = completedToolCalls - .filter(hasToolCallOutput) - .map((toolCall) => ({ - type: 'completed-tool-call' as const, - data: toolCall, - timestamp: toolCall.timestamp || Date.now(), - })); - - // Merge and sort by timestamp to ensure messages and tool calls are interleaved - const allMessages = [ - ...regularMessages, - ...inProgressTools, - ...completedTools, - ].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); - - console.log('[App] allMessages:', allMessages); - - return allMessages.map((item, index) => { - switch (item.type) { - case 'message': { - const msg = item.data as TextMessage; - const handleFileClick = (path: string) => { - vscode.postMessage({ - type: 'openFile', - data: { path }, - }); - }; - - if (msg.role === 'thinking') { - return ( - - ); - } - - if (msg.role === 'user') { - return ( - - ); - } - - { - const content = (msg.content || '').trim(); - if ( - content === 'Interrupted' || - content === 'Tool interrupted' - ) { - return ( - - ); - } - return ( - - ); - } - } - - // case 'in-progress-tool-call': - // return ( - // - // ); - - case 'in-progress-tool-call': - case 'completed-tool-call': - return ( - - ); - - default: - return null; - } - }); - })()} + {/* Render all messages and tool calls */} + {renderMessages()} {/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 5d97ac31..562f6389 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -16,6 +16,7 @@ import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; import { authMethod } from '../auth/index.js'; +import { runQwenCodeCommand } from '../commands/index.js'; export class WebViewProvider { private panelManager: PanelManager; @@ -25,6 +26,8 @@ export class WebViewProvider { private authStateManager: AuthStateManager; private disposables: vscode.Disposable[] = []; private agentInitialized = false; // Track if agent has been initialized + // Control whether to auto-restore last session on the very first connect of this panel + private autoRestoreOnFirstConnect = true; constructor( context: vscode.ExtensionContext, @@ -239,6 +242,13 @@ export class WebViewProvider { ); } + /** + * Suppress auto-restore once for this panel (used by "New Chat Tab"). + */ + suppressAutoRestoreOnce(): void { + this.autoRestoreOnFirstConnect = false; + } + async show(): Promise { const panel = this.panelManager.getPanel(); @@ -587,6 +597,14 @@ export class WebViewProvider { '[WebViewProvider] Force re-login completed successfully', ); + // Ensure auth state is saved after successful re-login + if (this.authStateManager) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + await this.authStateManager.saveAuthState(workingDir, authMethod); + console.log('[WebViewProvider] Auth state saved after re-login'); + } + // Send success notification to WebView this.sendMessageToWebView({ type: 'loginSuccess', @@ -681,53 +699,139 @@ export class WebViewProvider { authMethod, ); if (hasValidAuth) { - console.log( - '[WebViewProvider] Found valid cached auth, attempting session restoration', - ); + const allowAutoRestore = this.autoRestoreOnFirstConnect; + // Reset for subsequent connects (only once per panel lifecycle unless set again) + this.autoRestoreOnFirstConnect = true; + if (allowAutoRestore) { + console.log( + '[WebViewProvider] Valid auth found, attempting auto-restore of last session...', + ); + try { + const page = await this.agentManager.getSessionListPaged({ + size: 1, + }); + const item = page.sessions[0] as + | { sessionId?: string; id?: string; cwd?: string } + | undefined; + if (item && (item.sessionId || item.id)) { + const targetId = (item.sessionId || item.id) as string; + await this.agentManager.loadSessionViaAcp( + targetId, + (item.cwd as string | undefined) ?? workingDir, + ); + + this.messageHandler.setCurrentConversationId(targetId); + const messages = + await this.agentManager.getSessionMessages(targetId); + + // Even if messages array is empty, we should still switch to the session + // This ensures we don't lose the session context + this.sendMessageToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId: targetId, messages }, + }); + console.log( + '[WebViewProvider] Auto-restored last session:', + targetId, + ); + + // Ensure auth state is saved after successful session restore + if (this.authStateManager) { + await this.authStateManager.saveAuthState( + workingDir, + authMethod, + ); + console.log( + '[WebViewProvider] Auth state saved after session restore', + ); + } + + return; + } + console.log( + '[WebViewProvider] No sessions to auto-restore, creating new session', + ); + } catch (restoreError) { + console.warn( + '[WebViewProvider] Auto-restore failed, will create a new session:', + restoreError, + ); + + // Try to get session messages anyway, even if loadSessionViaAcp failed + // This can happen if the session exists locally but failed to load in the CLI + try { + const page = await this.agentManager.getSessionListPaged({ + size: 1, + }); + const item = page.sessions[0] as + | { sessionId?: string; id?: string } + | undefined; + if (item && (item.sessionId || item.id)) { + const targetId = (item.sessionId || item.id) as string; + const messages = + await this.agentManager.getSessionMessages(targetId); + + // Switch to the session with whatever messages we could get + this.messageHandler.setCurrentConversationId(targetId); + this.sendMessageToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId: targetId, messages }, + }); + console.log( + '[WebViewProvider] Partially restored last session:', + targetId, + ); + + // Ensure auth state is saved after partial session restore + if (this.authStateManager) { + await this.authStateManager.saveAuthState( + workingDir, + authMethod, + ); + console.log( + '[WebViewProvider] Auth state saved after partial session restore', + ); + } + + return; + } + } catch (fallbackError) { + console.warn( + '[WebViewProvider] Fallback session restore also failed:', + fallbackError, + ); + } + } + } else { + console.log( + '[WebViewProvider] Auto-restore suppressed for this panel', + ); + } + + // Create a fresh ACP session (no auto-restore or restore failed) try { - // Try to create a session (this will use cached auth) - const sessionId = await this.agentManager.createNewSession( + await this.agentManager.createNewSession( workingDir, this.authStateManager, ); + console.log('[WebViewProvider] ACP session created successfully'); - if (sessionId) { + // Ensure auth state is saved after successful session creation + if (this.authStateManager) { + await this.authStateManager.saveAuthState(workingDir, authMethod); console.log( - '[WebViewProvider] ACP session restored successfully with ID:', - sessionId, - ); - } else { - console.log( - '[WebViewProvider] ACP session restoration returned no session ID', + '[WebViewProvider] Auth state saved after session creation', ); } - } catch (restoreError) { - console.warn( - '[WebViewProvider] Failed to restore ACP session:', - restoreError, + } catch (sessionError) { + console.error( + '[WebViewProvider] Failed to create ACP session:', + sessionError, + ); + vscode.window.showWarningMessage( + `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, ); - // Clear invalid auth cache - await this.authStateManager.clearAuthState(); - - // Fall back to creating a new session - try { - await this.agentManager.createNewSession( - workingDir, - this.authStateManager, - ); - console.log( - '[WebViewProvider] ACP session created successfully after restore failure', - ); - } catch (sessionError) { - console.error( - '[WebViewProvider] Failed to create ACP session:', - sessionError, - ); - vscode.window.showWarningMessage( - `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, - ); - } } } else { console.log( @@ -1067,7 +1171,7 @@ export class WebViewProvider { if (useTerminal) { // In terminal mode, execute the runQwenCode command to open a new terminal try { - await vscode.commands.executeCommand('qwen-code.runQwenCode'); + await vscode.commands.executeCommand(runQwenCodeCommand); console.log('[WebViewProvider] Opened new terminal session'); } catch (error) { console.error( diff --git a/packages/vscode-ide-companion/src/webview/components/InProgressToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/InProgressToolCall.tsx deleted file mode 100644 index b0837fb1..00000000 --- a/packages/vscode-ide-companion/src/webview/components/InProgressToolCall.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * In-progress tool call component - displays active tool calls with Claude Code style - */ - -import React from 'react'; -import type { ToolCallData } from './toolcalls/shared/types.js'; -import { FileLink } from './ui/FileLink.js'; -import { useVSCode } from '../hooks/useVSCode.js'; -import { handleOpenDiff } from '../utils/diffUtils.js'; - -interface InProgressToolCallProps { - toolCall: ToolCallData; - onFileClick?: (path: string, line?: number | null) => void; -} - -/** - * Format the kind name to a readable label - */ -const formatKind = (kind: string): string => { - const kindMap: Record = { - read: 'Read', - write: 'Write', - edit: 'Edit', - execute: 'Execute', - bash: 'Execute', - command: 'Execute', - search: 'Search', - grep: 'Search', - glob: 'Search', - find: 'Search', - think: 'Think', - thinking: 'Think', - fetch: 'Fetch', - delete: 'Delete', - move: 'Move', - }; - - return kindMap[kind.toLowerCase()] || 'Tool Call'; -}; - -/** - * Get file name from path - */ -const getFileName = (path: string): string => path.split('/').pop() || path; - -/** - * Component to display in-progress tool calls with Claude Code styling - * Shows kind, file name, and file locations - */ -export const InProgressToolCall: React.FC = ({ - toolCall, - onFileClick: _onFileClick, -}) => { - const { kind, title, locations, content } = toolCall; - const vscode = useVSCode(); - - // Format the kind label - const kindLabel = formatKind(kind); - - // Map tool kind to a Tailwind text color class (Claude-like palette) - const kindColorClass = React.useMemo(() => { - const k = kind.toLowerCase(); - if (k === 'read') { - return 'text-[#4ec9b0]'; - } - if (k === 'write' || k === 'edit') { - return 'text-[#e5c07b]'; - } - if (k === 'execute' || k === 'bash' || k === 'command') { - return 'text-[#c678dd]'; - } - if (k === 'search' || k === 'grep' || k === 'glob' || k === 'find') { - return 'text-[#61afef]'; - } - if (k === 'think' || k === 'thinking') { - return 'text-[#98c379]'; - } - return 'text-[var(--app-primary-foreground)]'; - }, [kind]); - - // Get file name from locations or title - let fileName: string | null = null; - let filePath: string | null = null; - let fileLine: number | null = null; - - if (locations && locations.length > 0) { - fileName = getFileName(locations[0].path); - filePath = locations[0].path; - fileLine = locations[0].line || null; - } else if (typeof title === 'string') { - fileName = title; - } - - // Extract content text from content array - let contentText: string | null = null; - // Extract first diff (if present) - let diffData: { - path?: string; - oldText?: string | null; - newText?: string; - } | null = null; - if (content && content.length > 0) { - // Look for text content - for (const item of content) { - if (item.type === 'content' && item.content?.text) { - contentText = item.content.text; - break; - } - } - - // If no text content found, look for other content types - if (!contentText) { - for (const item of content) { - if (item.type === 'content' && item.content) { - contentText = JSON.stringify(item.content, null, 2); - break; - } - } - } - - // Look for diff content - for (const item of content) { - if ( - item.type === 'diff' && - (item.oldText !== undefined || item.newText !== undefined) - ) { - diffData = { - path: item.path, - oldText: item.oldText ?? null, - newText: item.newText, - }; - break; - } - } - } - - // Handle open diff - const handleOpenDiffInternal = () => { - if (!diffData) { - return; - } - const path = diffData.path || filePath || ''; - handleOpenDiff(vscode, path, diffData.oldText, diffData.newText); - }; - - return ( -
-
-
- - {kindLabel} - - {filePath && ( - - )} - {!filePath && fileName && ( - - {fileName} - - )} - - {diffData && ( - - )} -
- - {contentText && ( -
-
- - - {contentText} - -
-
- )} -
-
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css index 8734887b..1382483d 100644 --- a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css +++ b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.css @@ -135,7 +135,8 @@ border: 1px solid var(--app-primary-border-color); border-radius: var(--corner-radius-small, 4px); padding: 0.2em 0.4em; - white-space: nowrap; + white-space: pre-wrap; /* 支持自动换行 */ + word-break: break-word; /* 在必要时断词 */ } .markdown-content pre { @@ -207,7 +208,8 @@ background: none; border: none; padding: 0; - white-space: pre; + white-space: pre-wrap; /* 支持自动换行 */ + word-break: break-word; /* 在必要时断词 */ } .markdown-content .file-path-link { diff --git a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx index 6e852e52..09c41652 100644 --- a/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx +++ b/packages/vscode-ide-companion/src/webview/components/MarkdownRenderer/MarkdownRenderer.tsx @@ -19,11 +19,12 @@ interface MarkdownRendererProps { /** * Regular expressions for parsing content */ +// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts const FILE_PATH_REGEX = - /([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; -// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7 + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi; +// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7 const FILE_PATH_WITH_LINES_REGEX = - /([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; + /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; /** * MarkdownRenderer component - renders markdown content with enhanced features @@ -166,9 +167,22 @@ export const MarkdownRenderer: React.FC = ({ const href = a.getAttribute('href') || ''; const text = (a.textContent || '').trim(); + // Helper function to check if a string looks like a code reference + const isCodeReference = (str: string): boolean => { + // Check if it looks like a code reference (e.g., module.property) + // Patterns like "vscode.contribution", "module.submodule.function" + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + // If linkify turned a bare filename into http://, convert it back const httpMatch = href.match(/^https?:\/\/(.+)$/i); if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) { + // Skip if it looks like a code reference + if (isCodeReference(text)) { + return; + } + // Treat as a file link instead of external URL const filePath = text; // no leading slash a.classList.add('file-path-link'); @@ -182,6 +196,12 @@ export const MarkdownRenderer: React.FC = ({ if (/^(https?|mailto|ftp|data):/i.test(href)) return; const candidate = href || text; + + // Skip if it looks like a code reference + if (isCodeReference(candidate)) { + return; + } + if ( FILE_PATH_WITH_LINES_NO_G.test(candidate) || FILE_PATH_NO_G.test(candidate) @@ -194,6 +214,14 @@ export const MarkdownRenderer: React.FC = ({ } }; + // Helper function to check if a string looks like a code reference + const isCodeReference = (str: string): boolean => { + // Check if it looks like a code reference (e.g., module.property) + // Patterns like "vscode.contribution", "module.submodule.function" + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + const walk = (node: Node) => { // Do not transform inside existing anchors if (node.nodeType === Node.ELEMENT_NODE) { @@ -218,6 +246,20 @@ export const MarkdownRenderer: React.FC = ({ while ((m = union.exec(text))) { const matchText = m[0]; const idx = m.index; + + // Skip if it looks like a code reference + if (isCodeReference(matchText)) { + // Just add the text as-is without creating a link + if (idx > lastIndex) { + frag.appendChild( + document.createTextNode(text.slice(lastIndex, idx)), + ); + } + frag.appendChild(document.createTextNode(matchText)); + lastIndex = idx + matchText.length; + continue; + } + if (idx > lastIndex) { frag.appendChild( document.createTextNode(text.slice(lastIndex, idx)), diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx new file mode 100644 index 00000000..94ad29e1 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import type { PermissionOption, ToolCall } from './PermissionRequest.js'; + +interface PermissionDrawerProps { + isOpen: boolean; + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; + onClose?: () => void; +} + +/** + * Permission drawer component - Claude Code style bottom sheet + */ +export const PermissionDrawer: React.FC = ({ + isOpen, + options, + toolCall, + onResponse, + onClose, +}) => { + const [focusedIndex, setFocusedIndex] = useState(0); + const [customMessage, setCustomMessage] = useState(''); + const containerRef = useRef(null); + // 将自定义输入的 ref 类型修正为 HTMLInputElement,避免后续强转 + const customInputRef = useRef(null); + + console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); + // Prefer file name from locations, fall back to content[].path if present + const getAffectedFileName = (): string => { + const fromLocations = toolCall.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; + } + // Some tool calls (e.g. write/edit with diff content) only include path in content + const fromContent = Array.isArray(toolCall.content) + ? ( + toolCall.content.find( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'path' in (c as Record), + ) as { path?: unknown } | undefined + )?.path + : undefined; + if (typeof fromContent === 'string' && fromContent.length > 0) { + return fromContent.split('/').pop() || fromContent; + } + return 'file'; + }; + + // Get the title for the permission request + const getTitle = () => { + if (toolCall.kind === 'edit' || toolCall.kind === 'write') { + const fileName = getAffectedFileName(); + return ( + <> + Make this edit to{' '} + + {fileName} + + ? + + ); + } + if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { + return 'Allow this bash command?'; + } + if (toolCall.kind === 'read') { + const fileName = getAffectedFileName(); + return ( + <> + Allow read from{' '} + + {fileName} + + ? + + ); + } + return toolCall.title || 'Permission Required'; + }; + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) { + return; + } + + // Number keys 1-9 for quick select + const numMatch = e.key.match(/^[1-9]$/); + if ( + numMatch && + !customInputRef.current?.contains(document.activeElement) + ) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + onResponse(options[index].optionId); + } + return; + } + + // Arrow keys for navigation + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const totalItems = options.length + 1; // +1 for custom input + if (e.key === 'ArrowDown') { + setFocusedIndex((prev) => (prev + 1) % totalItems); + } else { + setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); + } + } + + // Enter to select + if ( + e.key === 'Enter' && + !customInputRef.current?.contains(document.activeElement) + ) { + e.preventDefault(); + if (focusedIndex < options.length) { + onResponse(options[focusedIndex].optionId); + } + } + + // Escape to cancel permission and close (align with CLI/Claude behavior) + if (e.key === 'Escape') { + e.preventDefault(); + const rejectOptionId = + options.find((o) => o.kind.includes('reject'))?.optionId || + options.find((o) => o.optionId === 'cancel')?.optionId || + 'cancel'; + onResponse(rejectOptionId); + if (onClose) onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, options, onResponse, onClose, focusedIndex]); + + // Focus container when opened + useEffect(() => { + if (isOpen && containerRef.current) { + containerRef.current.focus(); + } + }, [isOpen]); + + // Reset focus to the first option when the drawer opens or the options change + useEffect(() => { + if (isOpen) { + setFocusedIndex(0); + } + }, [isOpen, options.length]); + + if (!isOpen) { + return null; + } + + return ( +
+ {/* Main container */} +
+ {/* Background layer */} +
+ + {/* Title + Description (from toolCall.title) */} +
+
+ {getTitle()} +
+ {(toolCall.kind === 'edit' || + toolCall.kind === 'write' || + toolCall.kind === 'read' || + toolCall.kind === 'execute' || + toolCall.kind === 'bash') && + toolCall.title && ( +
+ {toolCall.title} +
+ )} +
+ + {/* Options */} +
+ {options.map((option, index) => { + const isFocused = focusedIndex === index; + + return ( + + ); + })} + + {/* Custom message input (extracted component) */} + {(() => { + const isFocused = focusedIndex === options.length; + const rejectOptionId = options.find((o) => + o.kind.includes('reject'), + )?.optionId; + return ( + setFocusedIndex(options.length)} + onSubmitReject={() => { + if (rejectOptionId) onResponse(rejectOptionId); + }} + inputRef={customInputRef} + /> + ); + })()} +
+
+ + {/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */} +
+ ); +}; + +/** + * CustomMessageInputRow: 复用的自定义输入行组件(无 hooks) + */ +interface CustomMessageInputRowProps { + isFocused: boolean; + customMessage: string; + setCustomMessage: (val: string) => void; + onFocusRow: () => void; // 鼠标移入或输入框 focus 时设置焦点 + onSubmitReject: () => void; // Enter 提交时触发(选择 reject 选项) + inputRef: React.RefObject; +} + +const CustomMessageInputRow: React.FC = ({ + isFocused, + customMessage, + setCustomMessage, + onFocusRow, + onSubmitReject, + inputRef, +}) => ( +
inputRef.current?.focus()} + > + {/* 输入行不显示序号徽标 */} + {/* Input field */} + setCustomMessage(e.target.value)} + onFocus={onFocusRow} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + onSubmitReject(); + } + }} + /> +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx new file mode 100644 index 00000000..a7b7356c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface PermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface ToolCall { + title?: string; + kind?: string; + toolCallId?: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + status?: string; +} + +export interface PermissionRequestProps { + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; +} diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx deleted file mode 100644 index 78016e06..00000000 --- a/packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface PermissionOption { - name: string; - kind: string; - optionId: string; -} - -export interface ToolCall { - title?: string; - kind?: string; - toolCallId?: string; - rawInput?: { - command?: string; - description?: string; - [key: string]: unknown; - }; - content?: Array<{ - type: string; - [key: string]: unknown; - }>; - locations?: Array<{ - path: string; - line?: number | null; - }>; - status?: string; -} - -export interface PermissionRequestProps { - options: PermissionOption[]; - toolCall: ToolCall; - onResponse: (optionId: string) => void; -} - -// export const PermissionRequest: React.FC = ({ -// options, -// toolCall, -// onResponse, -// }) => { -// const [selected, setSelected] = useState(null); -// const [isResponding, setIsResponding] = useState(false); -// const [hasResponded, setHasResponded] = useState(false); - -// const getToolInfo = () => { -// if (!toolCall) { -// return { -// title: 'Permission Request', -// description: 'Agent is requesting permission', -// icon: '🔐', -// }; -// } - -// const displayTitle = -// toolCall.title || toolCall.rawInput?.description || 'Permission Request'; - -// const kindIcons: Record = { -// edit: '✏️', -// read: '📖', -// fetch: '🌐', -// execute: '⚡', -// delete: '🗑️', -// move: '📦', -// search: '🔍', -// think: '💭', -// other: '🔧', -// }; - -// return { -// title: displayTitle, -// icon: kindIcons[toolCall.kind || 'other'] || '🔧', -// }; -// }; - -// const { title, icon } = getToolInfo(); - -// const handleConfirm = async () => { -// if (hasResponded || !selected) { -// return; -// } - -// setIsResponding(true); -// try { -// await onResponse(selected); -// setHasResponded(true); -// } catch (error) { -// console.error('Error confirming permission:', error); -// } finally { -// setIsResponding(false); -// } -// }; - -// if (!toolCall) { -// return null; -// } - -// return ( -//
-//
-// {/* Header with icon and title */} -//
-//
-// {icon} -//
-//
-//
{title}
-//
Waiting for your approval
-//
-//
- -// {/* Show command if available */} -// {(toolCall.rawInput?.command || toolCall.title) && ( -//
-//
-//
-// -// COMMAND -//
-//
-//
-//
-// IN -// -// {toolCall.rawInput?.command || toolCall.title} -// -//
-// {toolCall.rawInput?.description && ( -//
-// {toolCall.rawInput.description} -//
-// )} -//
-//
-// )} - -// {/* Show file locations if available */} -// {toolCall.locations && toolCall.locations.length > 0 && ( -//
-//
Affected Files
-// {toolCall.locations.map((location, index) => ( -//
-// 📄 -// -// {location.path} -// -// {location.line !== null && location.line !== undefined && ( -// -// ::{location.line} -// -// )} -//
-// ))} -//
-// )} - -// {/* Options */} -// {!hasResponded && ( -//
-//
Choose an action:
-//
-// {options && options.length > 0 ? ( -// options.map((option, index) => { -// const isSelected = selected === option.optionId; -// const isAllow = option.kind.includes('allow'); -// const isAlways = option.kind.includes('always'); - -// return ( -// -// ); -// }) -// ) : ( -//
-// No options available -//
-// )} -//
-//
-// -//
-//
-// )} - -// {/* Success message */} -// {hasResponded && ( -//
-// -// -// Response sent successfully -// -//
-// )} -//
-//
-// ); -// }; diff --git a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx index 2f6c60df..36a18368 100644 --- a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx @@ -35,4 +35,8 @@ export type { ToolCallContent } from './toolcalls/shared/types.js'; */ export const ToolCall: React.FC<{ toolCall: import('./toolcalls/shared/types.js').ToolCallData; -}> = ({ toolCall }) => ; + isFirst?: boolean; + isLast?: boolean; +}> = ({ toolCall, isFirst, isLast }) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx index 8d1c4c43..d6320e62 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -68,12 +68,12 @@ export const AssistantMessage: React.FC = ({ paddingLeft: '30px', userSelect: 'text', position: 'relative', - paddingTop: '8px', - paddingBottom: '8px', + // paddingTop: '8px', + // paddingBottom: '8px', }} > -

= ({ }} > -

+
); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx index 69086197..102b2756 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx @@ -67,7 +67,7 @@ export const UserMessage: React.FC = ({
fileContext && onFileClick?.(fileContext.filePath)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx index 33bfe6c7..a8c7224a 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -10,4 +10,3 @@ export { ThinkingMessage } from './ThinkingMessage.js'; export { StreamingMessage } from './StreamingMessage.js'; export { WaitingMessage } from './Waiting/WaitingMessage.js'; export { InterruptedMessage } from './Waiting/InterruptedMessage.js'; -export { PlanDisplay } from '../PlanDisplay.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/ui/CheckboxDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/ui/CheckboxDisplay.tsx index 243abec4..d17ed073 100644 --- a/packages/vscode-ide-companion/src/webview/components/ui/CheckboxDisplay.tsx +++ b/packages/vscode-ide-companion/src/webview/components/ui/CheckboxDisplay.tsx @@ -71,9 +71,9 @@ export const CheckboxDisplay: React.FC = ({ aria-hidden className={[ 'absolute inline-block', - 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2', + 'left-1/2 top-[10px] -translate-x-1/2 -translate-y-1/2', // Use a literal star; no icon font needed - 'text-[11px] leading-none text-[#e1c08d] select-none', + 'text-[16px] leading-none text-[#e1c08d] select-none', ].join(' ')} > * diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index dce9aed6..ab4b70b2 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -64,7 +64,7 @@ export class AuthMessageHandler extends BaseMessageHandler { vscode.window.showInformationMessage( 'Please wait while we connect to Qwen Code...', ); - await vscode.commands.executeCommand('qwenCode.login'); + await vscode.commands.executeCommand('qwen-code.login'); } } catch (error) { console.error('[AuthMessageHandler] Login failed:', error); diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js index 72ea91b9..ad8fd1ef 100644 --- a/packages/vscode-ide-companion/tailwind.config.js +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -9,17 +9,8 @@ export default { content: [ // Progressive adoption strategy: Only scan newly created Tailwind components - // './src/webview/App.tsx', - './src/webview/**/*.{js,jsx,ts,tsx}', - // './src/webview/components/messages/**/*.{js,jsx,ts,tsx}', - // './src/webview/components/toolcalls/**/*.{js,jsx,ts,tsx}', - // './src/webview/components/InProgressToolCall.tsx', - // './src/webview/components/MessageContent.tsx', - // './src/webview/components/InputForm.tsx', - // './src/webview/components/PermissionDrawer.tsx', - // './src/webview/components/PlanDisplay.tsx', - // './src/webview/components/session/SessionSelector.tsx', - // './src/webview/components/messages/UserMessage.tsx', + './src/webview/**/**/*.{js,jsx,ts,tsx}', + './src/webview/components/ui/CheckboxDisplay.tsx', ], theme: { extend: {