mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
|
||||
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<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,29 +155,54 @@ 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
|
||||
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<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) {
|
||||
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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user