Skip to content

Commit 507d537

Browse files
Copy button
1 parent 585d3ed commit 507d537

4 files changed

Lines changed: 87 additions & 25 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import { Button } from "@/components/ui/button";
4+
import { cn } from "@/lib/utils";
5+
import { CheckCircle2, Copy } from "lucide-react";
6+
import { useCallback, useState } from "react";
7+
8+
interface CopyIconButtonProps {
9+
onCopy: () => boolean;
10+
className?: string;
11+
}
12+
13+
export const CopyIconButton = ({ onCopy, className }: CopyIconButtonProps) => {
14+
const [copied, setCopied] = useState(false);
15+
16+
const onClick = useCallback(() => {
17+
const success = onCopy();
18+
if (success) {
19+
setCopied(true);
20+
setTimeout(() => setCopied(false), 2000);
21+
}
22+
}, [onCopy]);
23+
24+
return (
25+
<Button
26+
variant="ghost"
27+
size="sm"
28+
className={cn("h-6 w-6 text-muted-foreground", className)}
29+
onClick={onClick}
30+
aria-label="Copy to clipboard"
31+
>
32+
{copied ? (
33+
<CheckCircle2 className="h-3 w-3 text-green-500" />
34+
) : (
35+
<Copy className="h-3 w-3" />
36+
)}
37+
</Button>
38+
)
39+
}

packages/web/src/app/[domain]/components/pathHeader.tsx

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
44
import { LaptopIcon } from "@radix-ui/react-icons";
55
import Image from "next/image";
66
import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
7-
import { Copy, CheckCircle2, ChevronRight, MoreHorizontal } from "lucide-react";
7+
import { ChevronRight, MoreHorizontal } from "lucide-react";
88
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
99
import { useToast } from "@/components/hooks/use-toast";
1010
import {
@@ -14,6 +14,7 @@ import {
1414
DropdownMenuTrigger,
1515
} from "@/components/ui/dropdown-menu";
1616
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
17+
import { CopyIconButton } from "./copyIconButton";
1718

1819
interface FileHeaderProps {
1920
path: string;
@@ -65,7 +66,6 @@ export const PathHeader = ({
6566

6667
const { navigateToPath } = useBrowseNavigation();
6768
const { toast } = useToast();
68-
const [copied, setCopied] = useState(false);
6969
const containerRef = useRef<HTMLDivElement>(null);
7070
const breadcrumbsRef = useRef<HTMLDivElement>(null);
7171
const [visibleSegmentCount, setVisibleSegmentCount] = useState<number | null>(null);
@@ -175,9 +175,8 @@ export const PathHeader = ({
175175

176176
const onCopyPath = useCallback(() => {
177177
navigator.clipboard.writeText(path);
178-
setCopied(true);
179178
toast({ description: "✅ Copied to clipboard" });
180-
setTimeout(() => setCopied(false), 1500);
179+
return true;
181180
}, [path, toast]);
182181

183182
const onBreadcrumbClick = useCallback((segment: BreadcrumbSegment) => {
@@ -296,18 +295,10 @@ export const PathHeader = ({
296295
</div>
297296
))}
298297
</div>
299-
<button
300-
className="ml-2 p-1 rounded transition-colors flex-shrink-0"
301-
onClick={onCopyPath}
302-
aria-label="Copy file path"
303-
type="button"
304-
>
305-
{copied ? (
306-
<CheckCircle2 className="h-4 w-4 text-green-500" />
307-
) : (
308-
<Copy className="h-4 w-4 text-muted-foreground" />
309-
)}
310-
</button>
298+
<CopyIconButton
299+
onCopy={onCopyPath}
300+
className="ml-2"
301+
/>
311302
</div>
312303
</div>
313304
)

packages/web/src/features/chat/components/chatThread/answerCard.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
import { useExtractTOCItems } from "../../useTOCItems";
44
import { TableOfContents } from "./tableOfContents";
55
import { Button } from "@/components/ui/button";
6-
import { Copy, TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
6+
import { TableOfContentsIcon, ThumbsDown, ThumbsUp } from "lucide-react";
77
import { Separator } from "@/components/ui/separator";
88
import { MarkdownRenderer } from "./markdownRenderer";
9-
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
9+
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
1010
import { Toggle } from "@/components/ui/toggle";
1111
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
12+
import { CopyIconButton } from "@/app/[domain]/components/copyIconButton";
13+
import { useToast } from "@/components/hooks/use-toast";
14+
import { convertLLMOutputToPortableMarkdown } from "../../utils";
1215

1316
interface AnswerCardProps {
1417
answerText: string;
@@ -21,12 +24,22 @@ export const AnswerCard = forwardRef<HTMLDivElement, AnswerCardProps>(({
2124
const markdownRendererRef = useRef<HTMLDivElement>(null);
2225
const { tocItems, activeId } = useExtractTOCItems({ target: markdownRendererRef.current });
2326
const [isTOCButtonToggled, setIsTOCButtonToggled] = useState(false);
27+
const { toast } = useToast();
2428

2529
useImperativeHandle(
2630
forwardedRef,
2731
() => markdownRendererRef.current as HTMLDivElement
2832
);
2933

34+
const onCopyAnswer = useCallback(() => {
35+
const markdownText = convertLLMOutputToPortableMarkdown(answerText);
36+
navigator.clipboard.writeText(markdownText);
37+
toast({
38+
description: "✅ Copied to clipboard",
39+
});
40+
return true;
41+
}, [answerText, toast]);
42+
3043
return (
3144
<div className="flex flex-row w-full relative scroll-mt-16">
3245
{(isTOCButtonToggled && tocItems.length > 0) && (
@@ -43,13 +56,10 @@ export const AnswerCard = forwardRef<HTMLDivElement, AnswerCardProps>(({
4356
<div className="flex items-center gap-2">
4457
<Tooltip>
4558
<TooltipTrigger asChild>
46-
<Button
47-
variant="ghost"
48-
size="sm"
59+
<CopyIconButton
60+
onCopy={onCopyAnswer}
4961
className="h-6 w-6 text-muted-foreground"
50-
>
51-
<Copy className="h-3 w-3" />
52-
</Button>
62+
/>
5363
</TooltipTrigger>
5464
<TooltipContent
5565
side="bottom"

packages/web/src/features/chat/utils.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { env } from "@/env.mjs"
22
import { CreateUIMessage, UIMessagePart } from "ai"
33
import { Descendant, Editor, Point, Range, Transforms } from "slate"
4-
import { FILE_REFERENCE_PREFIX } from "./constants"
4+
import { FILE_REFERENCE_PREFIX, FILE_REFERENCE_REGEX } from "./constants"
55
import { CustomEditor, CustomText, FileReference, FileSource, MentionData, MentionElement, ModelProviderInfo, ParagraphElement, SBChatMessage, SBChatMessagePart, SBChatMessageToolTypes, Source } from "./types"
66

77
export const insertMention = (editor: CustomEditor, data: MentionData, target?: Range | null) => {
@@ -305,6 +305,28 @@ export const createFileReference = ({ fileName, startLine, endLine }: { fileName
305305
}
306306
}
307307

308+
/**
309+
* Converts LLM text that includes references (e.g., @file:...) into a portable
310+
* Markdown format. Practically, this means converting references into Markdown
311+
* links.
312+
*/
313+
export const convertLLMOutputToPortableMarkdown = (text: string): string => {
314+
return text.replace(FILE_REFERENCE_REGEX, (_, fileName, startLine, endLine) => {
315+
const displayName = fileName.split('/').pop() || fileName;
316+
317+
let linkText = displayName;
318+
if (startLine) {
319+
if (endLine && startLine !== endLine) {
320+
linkText += `:${startLine}-${endLine}`;
321+
} else {
322+
linkText += `:${startLine}`;
323+
}
324+
}
325+
326+
return `[${linkText}](${fileName})`;
327+
});
328+
}
329+
308330
// Groups message parts into groups based on step-start delimiters.
309331
export const groupMessageIntoSteps = (parts: SBChatMessagePart[]) => {
310332
if (!parts || parts.length === 0) {

0 commit comments

Comments
 (0)