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
This commit is contained in:
yiliang114
2025-12-10 01:28:41 +08:00
parent 49c032492a
commit 5ef3d32f16
5 changed files with 161 additions and 110 deletions

View File

@@ -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

View File

@@ -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<string, unknown>;

View File

@@ -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<MarkdownRendererProps> = ({
content,
onFileClick,
enableFileLinks = true,
}) => {
/**
* Initialize markdown-it with plugins
@@ -59,8 +62,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Process the markdown content
let html = md.render(content);
// Post-process to add file path click handlers
// 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<MarkdownRendererProps> = ({
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,31 +155,56 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
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://<filename>, convert it back
// If linkify turned a bare filename (e.g. README.md) into http://<filename>, 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
// 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', filePath);
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
if (/^(https?|mailto|ftp|data):/i.test(href)) {
return;
@@ -170,18 +221,33 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
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<MarkdownRendererProps> = ({
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
return; // Don't descend into <a>
}
// 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<MarkdownRendererProps> = ({
const handleContainerClick = (
e: React.MouseEvent<HTMLDivElement, 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<MarkdownRendererProps> = ({
// Find nearest anchor with our marker class
const anchor = (target.closest &&
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
if (!anchor) {
return;
}
if (anchor) {
const filePath = anchor.getAttribute('data-file-path');
if (!filePath) {
return;
}
e.preventDefault();
e.stopPropagation();
onFileClick?.(filePath);
return;
}
// Fallback: intercept "http://README.md" style links that slipped through
const anyAnchor = (target.closest &&
target.closest('a')) as HTMLAnchorElement | null;
if (!anyAnchor) {
return;
}
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 (

View File

@@ -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<MessageContentProps> = ({
content,
onFileClick,
}) => <MarkdownRenderer content={content} onFileClick={onFileClick} />;
enableFileLinks,
}) => (
<MarkdownRenderer
content={content}
onFileClick={onFileClick}
enableFileLinks={enableFileLinks}
/>
);

View File

@@ -58,7 +58,12 @@ export const UserMessage: React.FC<UserMessageProps> = ({
color: 'var(--app-primary-foreground)',
}}
>
<MessageContent content={content} onFileClick={onFileClick} />
{/* For user messages, do NOT convert filenames to clickable links */}
<MessageContent
content={content}
onFileClick={onFileClick}
enableFileLinks={false}
/>
</div>
{/* File context indicator */}