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

@@ -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 && (

View File

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