## 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
@@ -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) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.approvalState && (
|
||||
<div className="mt-2 flex items-center justify-end space-x-1 text-xs">
|
||||
{message.approvalState === "approved" ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span>Approved</span>
|
||||
</>
|
||||
) : message.approvalState === "rejected" ? (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<span>Rejected</span>
|
||||
</>
|
||||
) : null}
|
||||
{(message.role === "assistant" && message.content && !isStreaming) ||
|
||||
message.approvalState ? (
|
||||
<div
|
||||
className={`mt-2 flex items-center ${
|
||||
message.role === "assistant" &&
|
||||
message.content &&
|
||||
!isStreaming &&
|
||||
message.approvalState
|
||||
? "justify-between"
|
||||
: ""
|
||||
} text-xs`}
|
||||
>
|
||||
{message.role === "assistant" &&
|
||||
message.content &&
|
||||
!isStreaming && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
data-testid="copy-message-button"
|
||||
onClick={handleCopyFormatted}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline"></span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{message.approvalState && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{message.approvalState === "approved" ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span>Approved</span>
|
||||
</>
|
||||
) : message.approvalState === "rejected" ? (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<span>Rejected</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{/* Timestamp and commit info for assistant messages - only visible on hover */}
|
||||
{message.role === "assistant" && message.createdAt && (
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 <div className="p-4">Loading file content...</div>;
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
29
src/utils/get_language.ts
Normal file
29
src/utils/get_language.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Determine language based on file extension
|
||||
const getLanguage = (filePath: string) => {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
const languageMap: Record<string, string> = {
|
||||
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 };
|
||||
Reference in New Issue
Block a user