Skip to content
88 changes: 32 additions & 56 deletions src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import { formatDistanceToNow, format } from "date-fns";
import { useVersions } from "@/hooks/useVersions";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { calculateVersionNumber } from "./versionUtils";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { CopyButton } from "@/components/ui/copy-button";
import { unescapeXmlAttr } from "../../../shared/xmlEscape";
import {
isCancelledResponseContent,
Expand Down Expand Up @@ -123,16 +125,15 @@ const ChatMessage = ({
return null;
}, [message.commitHash, message.role, liveVersions]);

// handle copy request id
const [copiedRequestId, setCopiedRequestId] = useState(false);
const copiedRequestIdTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (copiedRequestIdTimeoutRef.current) {
clearTimeout(copiedRequestIdTimeoutRef.current);
}
};
}, []);
// Calculate version number (reverse index: newest = 1, older = 2, 3, etc.)
const versionNumber = useMemo(() => {
if (messageVersion && liveVersions.length) {
return calculateVersionNumber(messageVersion, liveVersions);
}
return null;
}, [messageVersion, liveVersions]);

// handle copy request id and commit hash using CopyButton

// Format the message timestamp
const formatTimestamp = (timestamp: string | Date) => {
Expand Down Expand Up @@ -284,9 +285,9 @@ const ChatMessage = ({
<span>{formatTimestamp(message.createdAt)}</span>
</div>
{messageVersion && messageVersion.message && (
Comment thread
nourzakhama2003 marked this conversation as resolved.
<div className="flex items-center space-x-1">
<GitCommit className="h-3 w-3" />
{messageVersion && messageVersion.message && (
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
<GitCommit className="h-3 w-3" />
<span
className="max-w-50 truncate font-medium"
title={messageVersion.message}
Expand All @@ -297,52 +298,27 @@ const ChatMessage = ({
.split("\n")[0]
}
</span>
</div>
{versionNumber && (
<CopyButton
Comment thread
nourzakhama2003 marked this conversation as resolved.
value={String(versionNumber)}
Comment thread
nourzakhama2003 marked this conversation as resolved.
ariaLabel="Copy Version Number"
displayText={`Version ${versionNumber}`}
tooltipText={`Version ${versionNumber}`}
mono={true}
raw={true}
/>
)}
</div>
)}
{message.requestId && (
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => {
if (!message.requestId) return;
navigator.clipboard
.writeText(message.requestId)
.then(() => {
setCopiedRequestId(true);
if (copiedRequestIdTimeoutRef.current) {
clearTimeout(copiedRequestIdTimeoutRef.current);
}
copiedRequestIdTimeoutRef.current = setTimeout(
() => setCopiedRequestId(false),
2000,
);
})
.catch(() => {
// noop
});
}}
aria-label="Copy Request ID"
className="flex items-center space-x-1 px-1 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors duration-200 cursor-pointer"
/>
}
>
{copiedRequestId ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
<span className="text-xs">
{copiedRequestId ? "Copied" : "Request ID"}
</span>
</TooltipTrigger>
<TooltipContent>
{copiedRequestId
? "Copied!"
: `Copy Request ID: ${message.requestId.slice(0, 8)}...`}
</TooltipContent>
</Tooltip>
<CopyButton
value={message.requestId}
ariaLabel="Copy Request ID"
displayText="Request ID"
tooltipText={`Copy Request ID: ${message.requestId.slice(0, 8)}...`}
raw={true}
/>
)}
{isLastMessage && message.totalTokens && (
<div
Expand Down
5 changes: 2 additions & 3 deletions src/components/chat/VersionPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAtom, useAtomValue } from "jotai";
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
import { useVersions } from "@/hooks/useVersions";
import { formatDistanceToNow } from "date-fns";
import { calculateVersionNumber } from "./versionUtils";
import { RotateCcw, X, Database, Loader2, Search } from "lucide-react";
import type { Version } from "@/ipc/types";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -209,9 +210,7 @@ export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
<span className="font-medium text-xs">
Version{" "}
<HighlightMatch
text={String(
versions.length - versions.indexOf(version),
)}
text={String(calculateVersionNumber(version, versions))}
query={searchQuery.trim()}
/>{" "}
(
Expand Down
8 changes: 8 additions & 0 deletions src/components/chat/versionUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//Calculate the version number from a version object using reverse index.

export function calculateVersionNumber(
version: { oid: string },
versions: { oid: string }[],
): number {
return versions.length - versions.indexOf(version);
Comment thread
nourzakhama2003 marked this conversation as resolved.
}
60 changes: 60 additions & 0 deletions src/components/ui/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Copy, Check } from "lucide-react";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";

interface CopyButtonProps {
value: string;
ariaLabel: string;
displayText: string;
tooltipText: string;
mono?: boolean;
raw?: boolean;
}

export function CopyButton({
value,
ariaLabel,
displayText,
tooltipText,
mono = false,
raw = false,
}: CopyButtonProps) {
const { copyMessageContent, copyRawText, copied } = useCopyToClipboard();

const handleCopy = () => {
if (!value) return;
if (raw) {
copyRawText(value);
} else {
copyMessageContent(value);
}
};

return (
<Tooltip>
<TooltipTrigger
render={
<button
onClick={handleCopy}
aria-label={ariaLabel}
Comment thread
nourzakhama2003 marked this conversation as resolved.
className="flex items-center space-x-1 px-1 py-0.5 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-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
Comment thread
nourzakhama2003 marked this conversation as resolved.
<span className={`${mono ? "font-mono" : ""} text-xs`}>
{copied ? "Copied" : displayText}
</span>
</TooltipTrigger>
<TooltipContent>{copied ? "Copied!" : tooltipText}</TooltipContent>
Comment thread
nourzakhama2003 marked this conversation as resolved.
</Tooltip>
);
}
21 changes: 20 additions & 1 deletion src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ export const useCopyToClipboard = () => {
}
};

const copyRawText = async (text: string) => {
try {
await navigator.clipboard.writeText(text);

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);
Comment thread
nourzakhama2003 marked this conversation as resolved.
return true;
} catch (error) {
console.error("Failed to copy text:", error);
return false;
}
};

// Convert Dyad content to clean markdown using the same parsing logic as DyadMarkdownParser
const convertDyadContentToMarkdown = (content: string): string => {
if (!content) return "";
Expand Down Expand Up @@ -273,5 +292,5 @@ export const useCopyToClipboard = () => {
return { processedContent };
};

return { copyMessageContent, copied };
return { copyMessageContent, copyRawText, copied };
};
Loading