From 5ef3d32f160553287a24390dcfa76fadc84487ba Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 10 Dec 2025 01:28:41 +0800 Subject: [PATCH] refactor(vscode-ide-companion/qwenAgentManager): remove saveCheckpointViaCommand method refactor(vscode-ide-companion/webview): improve message handling during checkpoint saves feat(vscode-ide-companion/markdown): enhance file path link handling with line numbers support feat(vscode-ide-companion/message): add enableFileLinks prop to MessageContent component feat(vscode-ide-companion/user-message): disable file links in user messages --- .../src/services/qwenAgentManager.ts | 44 ----- .../src/webview/WebViewProvider.ts | 41 ++--- .../MarkdownRenderer/MarkdownRenderer.tsx | 169 ++++++++++++++---- .../components/messages/MessageContent.tsx | 10 +- .../components/messages/UserMessage.tsx | 7 +- 5 files changed, 161 insertions(+), 110 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index e48492e1..a57d15b7 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -900,50 +900,6 @@ export class QwenAgentManager { return this.saveSessionViaCommand(sessionId, tag); } - /** - * Save session via /chat save command (CLI way) - * Calls CLI's native save function to ensure complete content is saved - * - * @param tag - Checkpoint tag - * @returns Save result - */ - async saveCheckpointViaCommand( - tag: string, - ): Promise<{ success: boolean; tag?: string; message?: string }> { - try { - console.log( - '[QwenAgentManager] ===== SAVING VIA /chat save COMMAND =====', - ); - console.log('[QwenAgentManager] Tag:', tag); - - // Send /chat save command as a prompt - // The CLI will handle this as a special command and save the checkpoint - const command = `/chat save "${tag}"`; - console.log('[QwenAgentManager] Sending command:', command); - - await this.connection.sendPrompt(command); - - console.log( - '[QwenAgentManager] Command sent, checkpoint should be saved by CLI', - ); - - // Wait a bit for CLI to process the command - await new Promise((resolve) => setTimeout(resolve, 500)); - - return { - success: true, - tag, - message: `Checkpoint saved via CLI: ${tag}`, - }; - } catch (error) { - console.error('[QwenAgentManager] /chat save command failed:', error); - return { - success: false, - message: error instanceof Error ? error.message : String(error), - }; - } - } - /** * Save session as checkpoint (using CLI format) * Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index b61f9599..82629787 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -58,13 +58,12 @@ export class WebViewProvider { // Setup agent callbacks this.agentManager.onMessage((message) => { - // Ignore history replay while background /chat save is running - if (this.messageHandler.getIsSavingCheckpoint()) { - console.log( - '[WebViewProvider] Ignoring message during checkpoint save', - ); - return; - } + // Do not suppress messages during checkpoint saves. + // Checkpoint persistence now writes directly to disk and should not + // generate ACP session/update traffic. Suppressing here could drop + // legitimate history replay messages (e.g., session/load) or + // assistant replies when a new prompt starts while an async save is + // still finishing. this.sendMessageToWebView({ type: 'message', data: message, @@ -72,14 +71,8 @@ export class WebViewProvider { }); this.agentManager.onStreamChunk((chunk: string) => { - // Ignore stream chunks from background /chat save commands - if (this.messageHandler.getIsSavingCheckpoint()) { - console.log( - '[WebViewProvider] Ignoring stream chunk from /chat save command', - ); - return; - } - + // Always forward stream chunks; do not gate on checkpoint saves. + // See note in onMessage() above. this.messageHandler.appendStreamContent(chunk); this.sendMessageToWebView({ type: 'streamChunk', @@ -89,14 +82,7 @@ export class WebViewProvider { // Setup thought chunk handler this.agentManager.onThoughtChunk((chunk: string) => { - // Ignore thought chunks from background /chat save commands - if (this.messageHandler.getIsSavingCheckpoint()) { - console.log( - '[WebViewProvider] Ignoring thought chunk from /chat save command', - ); - return; - } - + // Always forward thought chunks; do not gate on checkpoint saves. this.messageHandler.appendStreamContent(chunk); this.sendMessageToWebView({ type: 'thoughtChunk', @@ -148,14 +134,7 @@ export class WebViewProvider { // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager // and sent via onStreamChunk callback this.agentManager.onToolCall((update) => { - // Ignore tool calls from background /chat save commands - if (this.messageHandler.getIsSavingCheckpoint()) { - console.log( - '[WebViewProvider] Ignoring tool call from /chat save command', - ); - return; - } - + // Always surface tool calls; they are part of the live assistant flow. // Cast update to access sessionUpdate property const updateData = update as unknown as Record; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx index 12a1e996..11246420 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -14,6 +14,8 @@ import './MarkdownRenderer.css'; interface MarkdownRendererProps { content: string; onFileClick?: (filePath: string) => void; + /** When false, do not convert file paths into clickable links. Default: true */ + enableFileLinks?: boolean; } /** @@ -32,6 +34,7 @@ const FILE_PATH_WITH_LINES_REGEX = export const MarkdownRenderer: React.FC = ({ content, onFileClick, + enableFileLinks = true, }) => { /** * Initialize markdown-it with plugins @@ -59,8 +62,10 @@ export const MarkdownRenderer: React.FC = ({ // Process the markdown content let html = md.render(content); - // Post-process to add file path click handlers - html = processFilePaths(html); + // Post-process to add file path click handlers unless disabled + if (enableFileLinks) { + html = processFilePaths(html); + } return html; } catch (error) { @@ -108,20 +113,41 @@ export const MarkdownRenderer: React.FC = ({ container.innerHTML = html; const union = new RegExp( - `${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}`, + `${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`, 'gi', ); + // Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line) + const normalizePathAndLine = ( + raw: string, + ): { displayText: string; dataPath: string } => { + const displayText = raw; + let base = raw; + // Extract hash fragment like #12, #L12 or #12-34 and keep only the first number + const hashIndex = raw.indexOf('#'); + if (hashIndex >= 0) { + const frag = raw.slice(hashIndex + 1); + // Accept L12, 12 or 12-34 + const m = frag.match(/^L?(\d+)(?:-\d+)?$/i); + if (m) { + const line = parseInt(m[1], 10); + base = raw.slice(0, hashIndex); + return { displayText, dataPath: `${base}:${line}` }; + } + } + return { displayText, dataPath: base }; + }; + const makeLink = (text: string) => { const link = document.createElement('a'); - // Pass base path to the handler; keep the full text as label - const filePath = text.split('#')[0]; + // Pass base path (with optional :line) to the handler; keep the full text as label + const { dataPath } = normalizePathAndLine(text); link.className = 'file-path-link'; link.textContent = text; link.setAttribute('href', '#'); link.setAttribute('title', `Open ${text}`); // Carry file path via data attribute; click handled by event delegation - link.setAttribute('data-file-path', filePath); + link.setAttribute('data-file-path', dataPath); return link; }; @@ -129,29 +155,54 @@ 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 + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. const isCodeReference = (str: string): boolean => { - // Check if it looks like a code reference (e.g., module.property) - // Patterns like "vscode.contribution", "module.submodule.function" + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } 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 + // If linkify turned a bare filename (e.g. README.md) 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; - } + if (httpMatch) { + try { + const url = new URL(href); + const host = url.hostname || ''; + const pathname = url.pathname || ''; + const noPath = pathname === '' || pathname === '/'; - // Treat as a file link instead of external URL - const filePath = text; // no leading slash - a.classList.add('file-path-link'); - a.setAttribute('href', '#'); - a.setAttribute('title', `Open ${text}`); - a.setAttribute('data-file-path', filePath); - return; + // Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md) + if ( + noPath && + BARE_FILE_REGEX.test(text) && + host.toLowerCase() === text.toLowerCase() + ) { + const { dataPath } = normalizePathAndLine(text); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text}`); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Case 2: host itself looks like a filename (rare but happens), use it + if (noPath && BARE_FILE_REGEX.test(host)) { + const { dataPath } = normalizePathAndLine(host); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || host}`); + a.setAttribute('data-file-path', dataPath); + return; + } + } catch { + // fall through; unparseable URL + } } // Ignore other external protocols @@ -170,18 +221,33 @@ export const MarkdownRenderer: React.FC = ({ FILE_PATH_WITH_LINES_NO_G.test(candidate) || FILE_PATH_NO_G.test(candidate) ) { - const filePath = candidate.split('#')[0]; + const { dataPath } = normalizePathAndLine(candidate); a.classList.add('file-path-link'); a.setAttribute('href', '#'); a.setAttribute('title', `Open ${text || href}`); - a.setAttribute('data-file-path', filePath); + a.setAttribute('data-file-path', dataPath); + return; + } + + // Bare file name or relative path (e.g. README.md or docs/README.md) + if (BARE_FILE_REGEX.test(candidate)) { + const { dataPath } = normalizePathAndLine(candidate); + a.classList.add('file-path-link'); + a.setAttribute('href', '#'); + a.setAttribute('title', `Open ${text || href}`); + a.setAttribute('data-file-path', dataPath); } }; - // Helper function to check if a string looks like a code reference + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + // but DO NOT treat filenames/paths as code refs. const isCodeReference = (str: string): boolean => { - // Check if it looks like a code reference (e.g., module.property) - // Patterns like "vscode.contribution", "module.submodule.function" + if (BARE_FILE_REGEX.test(str)) { + return false; // looks like a filename + } + if (/[/\\]/.test(str)) { + return false; // contains a path separator + } const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; return codeRefPattern.test(str); }; @@ -194,6 +260,11 @@ export const MarkdownRenderer: React.FC = ({ upgradeAnchorIfFilePath(el as HTMLAnchorElement); return; // Don't descend into } + // Avoid transforming inside code/pre blocks + const tag = el.tagName.toLowerCase(); + if (tag === 'code' || tag === 'pre') { + return; + } } for (let child = node.firstChild; child; ) { @@ -252,6 +323,10 @@ export const MarkdownRenderer: React.FC = ({ const handleContainerClick = ( e: React.MouseEvent, ) => { + // If file links disabled, do nothing + if (!enableFileLinks) { + return; + } const target = e.target as HTMLElement | null; if (!target) { return; @@ -260,18 +335,46 @@ export const MarkdownRenderer: React.FC = ({ // Find nearest anchor with our marker class const anchor = (target.closest && target.closest('a.file-path-link')) as HTMLAnchorElement | null; - if (!anchor) { + if (anchor) { + const filePath = anchor.getAttribute('data-file-path'); + if (!filePath) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(filePath); return; } - const filePath = anchor.getAttribute('data-file-path'); - if (!filePath) { + // Fallback: intercept "http://README.md" style links that slipped through + const anyAnchor = (target.closest && + target.closest('a')) as HTMLAnchorElement | null; + if (!anyAnchor) { return; } - e.preventDefault(); - e.stopPropagation(); - onFileClick?.(filePath); + const href = anyAnchor.getAttribute('href') || ''; + if (!/^https?:\/\//i.test(href)) { + return; + } + try { + const url = new URL(href); + const host = url.hostname || ''; + const path = url.pathname || ''; + const noPath = path === '' || path === '/'; + + // Basic bare filename heuristic on the host part (e.g. README.md) + if (noPath && /\.[a-z0-9]+$/i.test(host)) { + // Prefer the readable text content if it looks like a file + const text = (anyAnchor.textContent || '').trim(); + const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host; + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(candidate); + } + } catch { + // ignore + } }; return ( diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx index c3437e6e..3381e90d 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/MessageContent.tsx @@ -10,9 +10,17 @@ import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js'; interface MessageContentProps { content: string; onFileClick?: (filePath: string) => void; + enableFileLinks?: boolean; } export const MessageContent: React.FC = ({ content, onFileClick, -}) => ; + enableFileLinks, +}) => ( + +); 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 0f8cd939..1014736a 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/UserMessage.tsx @@ -58,7 +58,12 @@ export const UserMessage: React.FC = ({ color: 'var(--app-primary-foreground)', }} > - + {/* For user messages, do NOT convert filenames to clickable links */} + {/* File context indicator */}