Files
moreminimore-vibe/src/hooks/useCopyToClipboard.ts
Adeniji Adekunle James 2597d50529 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. -->
2025-09-22 23:32:55 -07:00

278 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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