feat: add copy functionality for ai responses with Dyad tag formatting (#1290) (#1315)

## 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:
Adeniji Adekunle James
2025-09-23 07:32:55 +01:00
committed by GitHub
parent 9cca1d2af0
commit 2597d50529
5 changed files with 451 additions and 44 deletions

View 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 };
};