From 2597d50529d2b1e6ef84bf014022b61cd080b377 Mon Sep 17 00:00:00 2001 From: Adeniji Adekunle James Date: Tue, 23 Sep 2025 07:32:55 +0100 Subject: [PATCH] feat: add copy functionality for ai responses with Dyad tag formatting (#1290) (#1315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: - `` → `### File: path/to/file.js` + code block - `` → `### Edit: path/to/file.js` + code block - `` → `### Execute SQL` + ```sql block - `` → `### 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) --- ## 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. --- e2e-tests/copy_chat.spec.ts | 71 +++++ src/components/chat/ChatMessage.tsx | 88 +++++-- src/components/preview_panel/FileEditor.tsx | 30 +-- src/hooks/useCopyToClipboard.ts | 277 ++++++++++++++++++++ src/utils/get_language.ts | 29 ++ 5 files changed, 451 insertions(+), 44 deletions(-) create mode 100644 e2e-tests/copy_chat.spec.ts create mode 100644 src/hooks/useCopyToClipboard.ts create mode 100644 src/utils/get_language.ts diff --git a/e2e-tests/copy_chat.spec.ts b/e2e-tests/copy_chat.spec.ts new file mode 100644 index 0000000..760bfd9 --- /dev/null +++ b/e2e-tests/copy_chat.spec.ts @@ -0,0 +1,71 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test("copy message content - basic functionality", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + await po.sendPrompt("[dump] Just say hello without creating any files"); + + await po.page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + + const copyButton = po.page.getByTestId("copy-message-button").first(); + await copyButton.click(); + + const clipboardContent = await po.page.evaluate(() => + navigator.clipboard.readText(), + ); + + // Test that copy functionality works + expect(clipboardContent.length).toBeGreaterThan(0); + expect(clipboardContent).not.toContain(" { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + await po.sendPrompt( + "Create a simple React component in src/components/Button.tsx", + ); + + await po.page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + + const copyButton = po.page.getByTestId("copy-message-button").first(); + await copyButton.click(); + + const clipboardContent = await po.page.evaluate(() => + navigator.clipboard.readText(), + ); + + // Should convert dyad-write to markdown format (flexible path matching) + expect(clipboardContent).toContain("### File:"); + expect(clipboardContent).toContain("```"); + expect(clipboardContent).not.toContain(" { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + await po.sendPrompt("Say hello"); + + const copyButton = po.page.getByTestId("copy-message-button").first(); + + // Check initial tooltip + await copyButton.hover(); + const tooltip = po.page.locator('[role="tooltip"]'); + await expect(tooltip).toHaveText("Copy"); + + // Copy and check "Copied!" state + await po.page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await copyButton.hover(); + await expect(tooltip).toHaveText("Copied!"); +}); diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index c283e78..5e2209d 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -5,12 +5,26 @@ import { } from "./DyadMarkdownParser"; import { motion } from "framer-motion"; import { useStreamChat } from "@/hooks/useStreamChat"; -import { CheckCircle, XCircle, Clock, GitCommit } from "lucide-react"; +import { + CheckCircle, + XCircle, + Clock, + GitCommit, + Copy, + Check, +} from "lucide-react"; import { formatDistanceToNow, format } from "date-fns"; import { useVersions } from "@/hooks/useVersions"; import { useAtomValue } from "jotai"; import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { useMemo } from "react"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; interface ChatMessageProps { message: Message; @@ -21,6 +35,11 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { const { isStreaming } = useStreamChat(); const appId = useAtomValue(selectedAppIdAtom); const { versions: liveVersions } = useVersions(appId); + //handle copy chat + const { copyMessageContent, copied } = useCopyToClipboard(); + const handleCopyFormatted = async () => { + await copyMessageContent(message.content); + }; // Find the version that was active when this message was sent const messageVersion = useMemo(() => { if ( @@ -123,21 +142,60 @@ const ChatMessage = ({ message, isLastMessage }: ChatMessageProps) => { )} )} - {message.approvalState && ( -
- {message.approvalState === "approved" ? ( - <> - - Approved - - ) : message.approvalState === "rejected" ? ( - <> - - Rejected - - ) : null} + {(message.role === "assistant" && message.content && !isStreaming) || + message.approvalState ? ( +
+ {message.role === "assistant" && + message.content && + !isStreaming && ( + + + + + + + {copied ? "Copied!" : "Copy"} + + + + )} + {message.approvalState && ( +
+ {message.approvalState === "approved" ? ( + <> + + Approved + + ) : message.approvalState === "rejected" ? ( + <> + + Rejected + + ) : null} +
+ )}
- )} + ) : null}
{/* Timestamp and commit info for assistant messages - only visible on hover */} {message.role === "assistant" && message.createdAt && ( diff --git a/src/components/preview_panel/FileEditor.tsx b/src/components/preview_panel/FileEditor.tsx index 945e403..a45fc46 100644 --- a/src/components/preview_panel/FileEditor.tsx +++ b/src/components/preview_panel/FileEditor.tsx @@ -15,6 +15,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { useSettings } from "@/hooks/useSettings"; import { useCheckProblems } from "@/hooks/useCheckProblems"; +import { getLanguage } from "@/utils/get_language"; interface FileEditorProps { appId: number | null; @@ -190,35 +191,6 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => { } }; - // Determine language based on file extension - const getLanguage = (filePath: string) => { - const extension = filePath.split(".").pop()?.toLowerCase() || ""; - const languageMap: Record = { - js: "javascript", - jsx: "javascript", - ts: "typescript", - tsx: "typescript", - html: "html", - css: "css", - json: "json", - md: "markdown", - py: "python", - java: "java", - c: "c", - cpp: "cpp", - cs: "csharp", - go: "go", - rs: "rust", - rb: "ruby", - php: "php", - swift: "swift", - kt: "kotlin", - // Add more as needed - }; - - return languageMap[extension] || "plaintext"; - }; - if (loading) { return
Loading file content...
; } diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts new file mode 100644 index 0000000..10929ff --- /dev/null +++ b/src/hooks/useCopyToClipboard.ts @@ -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(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 = {}; + 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(``, "g"); + + const openCount = (processedContent.match(openTagPattern) || []).length; + const closeCount = (processedContent.match(closeTagPattern) || []).length; + + const missingCloseTags = openCount - closeCount; + if (missingCloseTags > 0) { + processedContent += Array(missingCloseTags) + .fill(``) + .join(""); + } + } + + return { processedContent }; + }; + + return { copyMessageContent, copied }; +}; diff --git a/src/utils/get_language.ts b/src/utils/get_language.ts new file mode 100644 index 0000000..76924e6 --- /dev/null +++ b/src/utils/get_language.ts @@ -0,0 +1,29 @@ +// Determine language based on file extension +const getLanguage = (filePath: string) => { + const extension = filePath.split(".").pop()?.toLowerCase() || ""; + const languageMap: Record = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + html: "html", + css: "css", + json: "json", + md: "markdown", + py: "python", + java: "java", + c: "c", + cpp: "cpp", + cs: "csharp", + go: "go", + rs: "rust", + rb: "ruby", + php: "php", + swift: "swift", + kt: "kotlin", + // Add more as needed + }; + + return languageMap[extension] || "plaintext"; +}; +export { getLanguage };