## 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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user