fix(coding-agent): strip skill wrapper XML from HTML export user messages (#4234)

Skill slash commands store a structural <skill>...</skill> wrapper in raw
user messages. The TUI uses parseSkillBlock() to split this into separate
SkillInvocationMessageComponent and UserMessageComponent siblings, but the
HTML export renderer passed the full raw text through markdown, causing
broken/dangling XML tags to appear in exported HTML.

Add parseSkillBlock() to the export template and render skill-invocation
and user-message as separate sibling blocks:
- Sidebar tree shows skill name + user prompt separately
- Content area shows a clickable skill-invocation block (collapsed by
  default, markdown content on expand) followed by the user message
- Copy-link button preserved on the wrapper element
- Toggle tools (O key) expands/collapses skill invocations alongside
  compaction and tool output blocks
This commit is contained in:
Aliou Diallo
2026-05-06 18:06:37 +02:00
committed by GitHub
parent ace3fd7e30
commit 88619669e2
3 changed files with 153 additions and 5 deletions

View File

@@ -204,6 +204,10 @@
color: var(--accent);
}
.tree-role-skill {
color: var(--customMessageLabel);
}
.tree-role-assistant {
color: var(--success);
}
@@ -383,7 +387,8 @@
}
.user-message:hover .copy-link-btn,
.assistant-message:hover .copy-link-btn {
.assistant-message:hover .copy-link-btn,
.skill-user-entry:hover .copy-link-btn {
opacity: 1;
}
@@ -786,6 +791,45 @@
font-weight: bold;
}
/* Skill invocation - matches compaction style (clickable, collapsed by default) */
.skill-invocation {
background: var(--customMessageBg);
border-radius: 4px;
padding: var(--line-height);
cursor: pointer;
}
.skill-invocation-label {
color: var(--customMessageLabel);
font-weight: bold;
}
.skill-invocation-collapsed {
color: var(--customMessageText);
}
.skill-invocation-content {
display: none;
color: var(--customMessageText);
margin-top: var(--line-height);
}
.skill-invocation.expanded .skill-invocation-collapsed {
display: none;
}
.skill-invocation.expanded .skill-invocation-content {
display: block;
}
.skill-invocation + .user-message {
margin-top: var(--line-height);
}
.skill-user-entry {
position: relative;
}
/* Branch summary */
.branch-summary {
background: var(--customMessageBg);

View File

@@ -313,6 +313,22 @@
return '';
}
/**
* Parse a skill block from message text.
* Returns null if the text doesn't contain a skill block.
* Matches the format: <skill name="..." location="...">\n...\n</skill>\n\nuser message
*/
function parseSkillBlock(text) {
const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
if (!match) return null;
return {
name: match[1],
location: match[2],
content: match[3],
userMessage: match[4]?.trim() || undefined,
};
}
function getSearchableText(entry, label) {
const parts = [];
if (label) parts.push(label);
@@ -613,7 +629,16 @@
case 'message': {
const msg = entry.message;
if (msg.role === 'user') {
const content = truncate(normalize(extractContent(msg.content)));
const rawContent = extractContent(msg.content);
const skillBlock = parseSkillBlock(rawContent);
if (skillBlock) {
let treeHtml = labelHtml + `<span class="tree-role-skill">skill:</span> ${escapeHtml(skillBlock.name)}`;
if (skillBlock.userMessage) {
treeHtml += ` · <span class="tree-role-user">user:</span> ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`;
}
return treeHtml;
}
const content = truncate(normalize(rawContent));
return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
}
if (msg.role === 'assistant') {
@@ -1140,8 +1165,46 @@
const msg = entry.message;
if (msg.role === 'user') {
let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
const content = msg.content;
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
const skillBlock = parseSkillBlock(text);
if (skillBlock) {
// Collect images from content array
const images = Array.isArray(content) ? content.filter(c => c.type === 'image') : [];
const hasUserContent = skillBlock.userMessage || images.length > 0;
let html = `<div class="skill-user-entry" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
// Skill invocation (collapsed by default, click to expand)
html += `<div class="skill-invocation" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
<div class="skill-invocation-label">[skill] ${escapeHtml(skillBlock.name)}</div>
<div class="skill-invocation-collapsed">${escapeHtml(skillBlock.name)} (click to expand)</div>
<div class="skill-invocation-content markdown-content">${safeMarkedParse(skillBlock.content)}</div>
</div>`;
// User message (separate block if present)
if (hasUserContent) {
html += '<div class="user-message">';
if (images.length > 0) {
html += '<div class="message-images">';
for (const img of images) {
html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`;
}
html += '</div>';
}
if (skillBlock.userMessage) {
html += `<div class="markdown-content">${safeMarkedParse(skillBlock.userMessage)}</div>`;
}
html += '</div>';
}
html += '</div>';
return html;
}
// No skill block - normal user message
let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
if (Array.isArray(content)) {
const images = content.filter(c => c.type === 'image');
@@ -1154,8 +1217,6 @@
}
}
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
if (text.trim()) {
html += `<div class="markdown-content">${safeMarkedParse(text)}</div>`;
}
@@ -1716,6 +1777,9 @@
document.querySelectorAll('.compaction').forEach(el => {
el.classList.toggle('expanded', toolOutputsExpanded);
});
document.querySelectorAll('.skill-invocation').forEach(el => {
el.classList.toggle('expanded', toolOutputsExpanded);
});
};
const attachHeaderHandlers = () => {