## Summary Adds AI response copy functionality to chat messages that preserves formatting and converts Dyad-specific tags to clean, readable markdown. ## Changes - **New `useCopyToClipboard` hook**: Parses Dyad tags and converts them to professional markdown format - **Updated `ChatMessage` component**: Positions copy button on left side of approval status - **Dyad tag conversion**: Transforms custom tags to readable format: - `<dyad-write>` → `### File: path/to/file.js` + code block - `<dyad-edit>` → `### Edit: path/to/file.js` + code block - `<dyad-execute-sql>` → `### Execute SQL` + ```sql block - `<think>` → `### Thinking` + content ## Features - ✅ Automatic programming language detection from file extensions - ✅ Professional markdown formatting with proper headings and code blocks - ✅ Tooltip showing "Copied" confirmation - ✅ Reuses existing DyadMarkdownParser logic for consistency closes (#1290) <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a Copy button to assistant messages that copies a clean Markdown version of the response by converting Dyad tags and preserving code blocks. Improves shareability and removes Dyad-only markup; addresses Linear #1290. - **New Features** - Added useCopyToClipboard hook that parses Dyad tags to Markdown, auto-detects code language, and cleans spacing. - Updated ChatMessage to show a Copy button (with Copy/Copied tooltip) to the left of approval status; disabled while streaming. - Tag conversions: think → "### Thinking"; dyad-write/edit → "### File/Edit: path" + fenced code; dyad-execute-sql → "### Execute SQL" + sql block; other Dyad tags map to concise headings; chat-summary/command are omitted. - Added e2e tests for clipboard copy, Dyad tag stripping/formatting, and tooltip states. <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
9cca1d2af0
commit
2597d50529
277
src/hooks/useCopyToClipboard.ts
Normal file
277
src/hooks/useCopyToClipboard.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { getLanguage } from "@/utils/get_language";
|
||||
|
||||
const CUSTOM_TAG_NAMES = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
"dyad-execute-sql",
|
||||
"dyad-add-integration",
|
||||
"dyad-output",
|
||||
"dyad-problem-report",
|
||||
"dyad-chat-summary",
|
||||
"dyad-edit",
|
||||
"dyad-codebase-context",
|
||||
"think",
|
||||
"dyad-command",
|
||||
];
|
||||
export const useCopyToClipboard = () => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyMessageContent = async (messageContent: string) => {
|
||||
try {
|
||||
// Use the same parsing logic as DyadMarkdownParser but convert to clean text
|
||||
const formattedContent = convertDyadContentToMarkdown(messageContent);
|
||||
|
||||
// Copy to clipboard
|
||||
await navigator.clipboard.writeText(formattedContent);
|
||||
|
||||
setCopied(true);
|
||||
// Clear existing timeout if any
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout and store reference
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy content:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert Dyad content to clean markdown using the same parsing logic as DyadMarkdownParser
|
||||
const convertDyadContentToMarkdown = (content: string): string => {
|
||||
if (!content) return "";
|
||||
|
||||
// Use the same parsing functions from DyadMarkdownParser
|
||||
const contentPieces = parseCustomTags(content);
|
||||
|
||||
let result = "";
|
||||
|
||||
contentPieces.forEach((piece) => {
|
||||
if (piece.type === "markdown") {
|
||||
// Add regular markdown content as-is
|
||||
result += piece.content || "";
|
||||
} else {
|
||||
// Convert custom tags to markdown format
|
||||
const markdownVersion = convertCustomTagToMarkdown(piece.tagInfo);
|
||||
result += markdownVersion;
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up the final result
|
||||
return result
|
||||
.replace(/\n{3,}/g, "\n\n") // Max 2 consecutive newlines
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Convert individual custom tags to markdown (reuse the same logic from DyadMarkdownParser)
|
||||
const convertCustomTagToMarkdown = (tagInfo: any): string => {
|
||||
const { tag, attributes, content } = tagInfo;
|
||||
|
||||
switch (tag) {
|
||||
case "think":
|
||||
return `### Thinking\n\n${content}\n\n`;
|
||||
|
||||
case "dyad-write": {
|
||||
const writePath = attributes.path || "file";
|
||||
const writeDesc = attributes.description || "";
|
||||
const language = getLanguage(writePath);
|
||||
|
||||
let writeResult = `### File: ${writePath}\n\n`;
|
||||
if (writeDesc && writeDesc !== writePath) {
|
||||
writeResult += `${writeDesc}\n\n`;
|
||||
}
|
||||
writeResult += `\`\`\`${language}\n${content}\n\`\`\`\n\n`;
|
||||
return writeResult;
|
||||
}
|
||||
|
||||
case "dyad-edit": {
|
||||
const editPath = attributes.path || "file";
|
||||
const editDesc = attributes.description || "";
|
||||
const editLang = getLanguage(editPath);
|
||||
|
||||
let editResult = `### Edit: ${editPath}\n\n`;
|
||||
if (editDesc && editDesc !== editPath) {
|
||||
editResult += `${editDesc}\n\n`;
|
||||
}
|
||||
editResult += `\`\`\`${editLang}\n${content}\n\`\`\`\n\n`;
|
||||
return editResult;
|
||||
}
|
||||
|
||||
case "dyad-rename": {
|
||||
const from = attributes.from || "";
|
||||
const to = attributes.to || "";
|
||||
return `### Rename: ${from} → ${to}\n\n`;
|
||||
}
|
||||
|
||||
case "dyad-delete": {
|
||||
const deletePath = attributes.path || "";
|
||||
return `### Delete: ${deletePath}\n\n`;
|
||||
}
|
||||
|
||||
case "dyad-add-dependency": {
|
||||
const packages = attributes.packages || "";
|
||||
return `### Add Dependencies\n\n\`\`\`bash\n${packages}\n\`\`\`\n\n`;
|
||||
}
|
||||
|
||||
case "dyad-execute-sql": {
|
||||
const sqlDesc = attributes.description || "";
|
||||
let sqlResult = `### Execute SQL\n\n`;
|
||||
if (sqlDesc) {
|
||||
sqlResult += `${sqlDesc}\n\n`;
|
||||
}
|
||||
sqlResult += `\`\`\`sql\n${content}\n\`\`\`\n\n`;
|
||||
return sqlResult;
|
||||
}
|
||||
|
||||
case "dyad-add-integration": {
|
||||
const provider = attributes.provider || "";
|
||||
return `### Add Integration: ${provider}\n\n`;
|
||||
}
|
||||
|
||||
case "dyad-codebase-context": {
|
||||
const files = attributes.files || "";
|
||||
let contextResult = `### Codebase Context\n\n`;
|
||||
if (files) {
|
||||
contextResult += `Files: ${files}\n\n`;
|
||||
}
|
||||
contextResult += `\`\`\`\n${content}\n\`\`\`\n\n`;
|
||||
return contextResult;
|
||||
}
|
||||
|
||||
case "dyad-output": {
|
||||
const outputType = attributes.type || "info";
|
||||
const message = attributes.message || "";
|
||||
const emoji =
|
||||
outputType === "error"
|
||||
? "❌"
|
||||
: outputType === "warning"
|
||||
? "⚠️"
|
||||
: "ℹ️";
|
||||
|
||||
let outputResult = `${emoji} **${outputType.toUpperCase()}**`;
|
||||
if (message) {
|
||||
outputResult += `: ${message}`;
|
||||
}
|
||||
if (content) {
|
||||
outputResult += `\n\n${content}`;
|
||||
}
|
||||
return outputResult + "\n\n";
|
||||
}
|
||||
|
||||
case "dyad-problem-report": {
|
||||
const summary = attributes.summary || "";
|
||||
let problemResult = `### Problem Report\n\n`;
|
||||
if (summary) {
|
||||
problemResult += `**Summary:** ${summary}\n\n`;
|
||||
}
|
||||
if (content) {
|
||||
problemResult += content;
|
||||
}
|
||||
return problemResult + "\n\n";
|
||||
}
|
||||
|
||||
case "dyad-chat-summary":
|
||||
case "dyad-command":
|
||||
// Don't include these in copy
|
||||
return "";
|
||||
|
||||
default:
|
||||
return content ? `${content}\n\n` : "";
|
||||
}
|
||||
};
|
||||
|
||||
// Reuse the same parsing functions from DyadMarkdownParser but simplified
|
||||
const parseCustomTags = (content: string) => {
|
||||
const { processedContent } = preprocessUnclosedTags(content);
|
||||
|
||||
const tagPattern = new RegExp(
|
||||
`<(${CUSTOM_TAG_NAMES.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
|
||||
"gs",
|
||||
);
|
||||
|
||||
const contentPieces: any[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = tagPattern.exec(processedContent)) !== null) {
|
||||
const [fullMatch, tag, attributesStr, tagContent] = match;
|
||||
const startIndex = match.index;
|
||||
|
||||
// Add markdown content before this tag
|
||||
if (startIndex > lastIndex) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex, startIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse attributes
|
||||
const attributes: Record<string, string> = {};
|
||||
const attrPattern = /(\w+)="([^"]*)"/g;
|
||||
let attrMatch;
|
||||
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
|
||||
attributes[attrMatch[1]] = attrMatch[2];
|
||||
}
|
||||
|
||||
// Add the tag info
|
||||
contentPieces.push({
|
||||
type: "custom-tag",
|
||||
tagInfo: {
|
||||
tag,
|
||||
attributes,
|
||||
content: tagContent,
|
||||
fullMatch,
|
||||
},
|
||||
});
|
||||
|
||||
lastIndex = startIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add remaining markdown content
|
||||
if (lastIndex < processedContent.length) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return contentPieces;
|
||||
};
|
||||
|
||||
// Simplified version of preprocessUnclosedTags
|
||||
const preprocessUnclosedTags = (content: string) => {
|
||||
let processedContent = content;
|
||||
|
||||
for (const tagName of CUSTOM_TAG_NAMES) {
|
||||
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
|
||||
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
|
||||
|
||||
const openCount = (processedContent.match(openTagPattern) || []).length;
|
||||
const closeCount = (processedContent.match(closeTagPattern) || []).length;
|
||||
|
||||
const missingCloseTags = openCount - closeCount;
|
||||
if (missingCloseTags > 0) {
|
||||
processedContent += Array(missingCloseTags)
|
||||
.fill(`</${tagName}>`)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
return { processedContent };
|
||||
};
|
||||
|
||||
return { copyMessageContent, copied };
|
||||
};
|
||||
Reference in New Issue
Block a user