mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +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);
|
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)
|
* Save session as checkpoint (using CLI format)
|
||||||
* Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
|
* Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
|
||||||
|
|||||||
@@ -58,13 +58,12 @@ export class WebViewProvider {
|
|||||||
|
|
||||||
// Setup agent callbacks
|
// Setup agent callbacks
|
||||||
this.agentManager.onMessage((message) => {
|
this.agentManager.onMessage((message) => {
|
||||||
// Ignore history replay while background /chat save is running
|
// Do not suppress messages during checkpoint saves.
|
||||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
// Checkpoint persistence now writes directly to disk and should not
|
||||||
console.log(
|
// generate ACP session/update traffic. Suppressing here could drop
|
||||||
'[WebViewProvider] Ignoring message during checkpoint save',
|
// legitimate history replay messages (e.g., session/load) or
|
||||||
);
|
// assistant replies when a new prompt starts while an async save is
|
||||||
return;
|
// still finishing.
|
||||||
}
|
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
data: message,
|
data: message,
|
||||||
@@ -72,14 +71,8 @@ export class WebViewProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.agentManager.onStreamChunk((chunk: string) => {
|
this.agentManager.onStreamChunk((chunk: string) => {
|
||||||
// Ignore stream chunks from background /chat save commands
|
// Always forward stream chunks; do not gate on checkpoint saves.
|
||||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
// See note in onMessage() above.
|
||||||
console.log(
|
|
||||||
'[WebViewProvider] Ignoring stream chunk from /chat save command',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageHandler.appendStreamContent(chunk);
|
this.messageHandler.appendStreamContent(chunk);
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'streamChunk',
|
type: 'streamChunk',
|
||||||
@@ -89,14 +82,7 @@ export class WebViewProvider {
|
|||||||
|
|
||||||
// Setup thought chunk handler
|
// Setup thought chunk handler
|
||||||
this.agentManager.onThoughtChunk((chunk: string) => {
|
this.agentManager.onThoughtChunk((chunk: string) => {
|
||||||
// Ignore thought chunks from background /chat save commands
|
// Always forward thought chunks; do not gate on checkpoint saves.
|
||||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
|
||||||
console.log(
|
|
||||||
'[WebViewProvider] Ignoring thought chunk from /chat save command',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageHandler.appendStreamContent(chunk);
|
this.messageHandler.appendStreamContent(chunk);
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'thoughtChunk',
|
type: 'thoughtChunk',
|
||||||
@@ -148,14 +134,7 @@ export class WebViewProvider {
|
|||||||
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
|
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
|
||||||
// and sent via onStreamChunk callback
|
// and sent via onStreamChunk callback
|
||||||
this.agentManager.onToolCall((update) => {
|
this.agentManager.onToolCall((update) => {
|
||||||
// Ignore tool calls from background /chat save commands
|
// Always surface tool calls; they are part of the live assistant flow.
|
||||||
if (this.messageHandler.getIsSavingCheckpoint()) {
|
|
||||||
console.log(
|
|
||||||
'[WebViewProvider] Ignoring tool call from /chat save command',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cast update to access sessionUpdate property
|
// Cast update to access sessionUpdate property
|
||||||
const updateData = update as unknown as Record<string, unknown>;
|
const updateData = update as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import './MarkdownRenderer.css';
|
|||||||
interface MarkdownRendererProps {
|
interface MarkdownRendererProps {
|
||||||
content: string;
|
content: string;
|
||||||
onFileClick?: (filePath: string) => void;
|
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> = ({
|
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
content,
|
content,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
|
enableFileLinks = true,
|
||||||
}) => {
|
}) => {
|
||||||
/**
|
/**
|
||||||
* Initialize markdown-it with plugins
|
* Initialize markdown-it with plugins
|
||||||
@@ -59,8 +62,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
// Process the markdown content
|
// Process the markdown content
|
||||||
let html = md.render(content);
|
let html = md.render(content);
|
||||||
|
|
||||||
// Post-process to add file path click handlers
|
// Post-process to add file path click handlers unless disabled
|
||||||
html = processFilePaths(html);
|
if (enableFileLinks) {
|
||||||
|
html = processFilePaths(html);
|
||||||
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,20 +113,41 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
const union = new RegExp(
|
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',
|
'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 makeLink = (text: string) => {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
// Pass base path to the handler; keep the full text as label
|
// Pass base path (with optional :line) to the handler; keep the full text as label
|
||||||
const filePath = text.split('#')[0];
|
const { dataPath } = normalizePathAndLine(text);
|
||||||
link.className = 'file-path-link';
|
link.className = 'file-path-link';
|
||||||
link.textContent = text;
|
link.textContent = text;
|
||||||
link.setAttribute('href', '#');
|
link.setAttribute('href', '#');
|
||||||
link.setAttribute('title', `Open ${text}`);
|
link.setAttribute('title', `Open ${text}`);
|
||||||
// Carry file path via data attribute; click handled by event delegation
|
// 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;
|
return link;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,29 +155,54 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
const href = a.getAttribute('href') || '';
|
const href = a.getAttribute('href') || '';
|
||||||
const text = (a.textContent || '').trim();
|
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 => {
|
const isCodeReference = (str: string): boolean => {
|
||||||
// Check if it looks like a code reference (e.g., module.property)
|
if (BARE_FILE_REGEX.test(str)) {
|
||||||
// Patterns like "vscode.contribution", "module.submodule.function"
|
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$]*)+$/;
|
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||||
return codeRefPattern.test(str);
|
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);
|
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
|
||||||
if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) {
|
if (httpMatch) {
|
||||||
// Skip if it looks like a code reference
|
try {
|
||||||
if (isCodeReference(text)) {
|
const url = new URL(href);
|
||||||
return;
|
const host = url.hostname || '';
|
||||||
}
|
const pathname = url.pathname || '';
|
||||||
|
const noPath = pathname === '' || pathname === '/';
|
||||||
|
|
||||||
// Treat as a file link instead of external URL
|
// Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md)
|
||||||
const filePath = text; // no leading slash
|
if (
|
||||||
a.classList.add('file-path-link');
|
noPath &&
|
||||||
a.setAttribute('href', '#');
|
BARE_FILE_REGEX.test(text) &&
|
||||||
a.setAttribute('title', `Open ${text}`);
|
host.toLowerCase() === text.toLowerCase()
|
||||||
a.setAttribute('data-file-path', filePath);
|
) {
|
||||||
return;
|
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
|
// Ignore other external protocols
|
||||||
@@ -170,18 +221,33 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
|
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
|
||||||
FILE_PATH_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.classList.add('file-path-link');
|
||||||
a.setAttribute('href', '#');
|
a.setAttribute('href', '#');
|
||||||
a.setAttribute('title', `Open ${text || 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 => {
|
const isCodeReference = (str: string): boolean => {
|
||||||
// Check if it looks like a code reference (e.g., module.property)
|
if (BARE_FILE_REGEX.test(str)) {
|
||||||
// Patterns like "vscode.contribution", "module.submodule.function"
|
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$]*)+$/;
|
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||||
return codeRefPattern.test(str);
|
return codeRefPattern.test(str);
|
||||||
};
|
};
|
||||||
@@ -194,6 +260,11 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
|
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
|
||||||
return; // Don't descend into <a>
|
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; ) {
|
for (let child = node.firstChild; child; ) {
|
||||||
@@ -252,6 +323,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
const handleContainerClick = (
|
const handleContainerClick = (
|
||||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||||
) => {
|
) => {
|
||||||
|
// If file links disabled, do nothing
|
||||||
|
if (!enableFileLinks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
@@ -260,18 +335,46 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
// Find nearest anchor with our marker class
|
// Find nearest anchor with our marker class
|
||||||
const anchor = (target.closest &&
|
const anchor = (target.closest &&
|
||||||
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = anchor.getAttribute('data-file-path');
|
// Fallback: intercept "http://README.md" style links that slipped through
|
||||||
if (!filePath) {
|
const anyAnchor = (target.closest &&
|
||||||
|
target.closest('a')) as HTMLAnchorElement | null;
|
||||||
|
if (!anyAnchor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
const href = anyAnchor.getAttribute('href') || '';
|
||||||
e.stopPropagation();
|
if (!/^https?:\/\//i.test(href)) {
|
||||||
onFileClick?.(filePath);
|
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 (
|
return (
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js';
|
|||||||
interface MessageContentProps {
|
interface MessageContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
onFileClick?: (filePath: string) => void;
|
onFileClick?: (filePath: string) => void;
|
||||||
|
enableFileLinks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageContent: React.FC<MessageContentProps> = ({
|
export const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
content,
|
content,
|
||||||
onFileClick,
|
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)',
|
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>
|
</div>
|
||||||
|
|
||||||
{/* File context indicator */}
|
{/* File context indicator */}
|
||||||
|
|||||||
Reference in New Issue
Block a user