Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/web/components/ai-elements/actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/utils";
import type { ComponentProps } from "react";

export type ActionsProps = ComponentProps<"div">;

export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);

export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};

export const Action = ({
tooltip,
children,
label,
className,
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
"size-9 p-1.5 text-muted-foreground hover:text-foreground relative",
className,
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);

if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

return button;
};
148 changes: 148 additions & 0 deletions apps/web/components/ai-elements/code-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"use client";

import { Button } from "@/components/ui/button";
import { cn } from "@/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";

type CodeBlockContextType = {
code: string;
};

const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});

export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};

export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className,
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);

export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};

export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);

const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}

Comment on lines +119 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix Clipboard API feature detection to avoid runtime errors.

Accessing navigator.clipboard.writeText when clipboard is undefined throws before your guard runs. Use optional chaining or check navigator.clipboard first.

-  const copyToClipboard = async () => {
-    if (typeof window === "undefined" || !navigator.clipboard.writeText) {
+  const copyToClipboard = async () => {
+    if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
       onError?.(new Error("Clipboard API not available"));
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
// …rest of function…
🤖 Prompt for AI Agents
In apps/web/components/ai-elements/code-block.tsx around lines 119 to 124, the
current feature detection accesses navigator.clipboard.writeText which throws
when navigator.clipboard is undefined; change the guard to check
navigator.clipboard exists (e.g., navigator.clipboard?.writeText or typeof
navigator !== "undefined" && navigator.clipboard &&
navigator.clipboard.writeText) and call onError with the same error and return
if the Clipboard API is not available.

try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};

const Icon = isCopied ? CheckIcon : CopyIcon;

return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
62 changes: 62 additions & 0 deletions apps/web/components/ai-elements/conversation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { Button } from "@/components/ui/button";
import { cn } from "@/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";

export type ConversationProps = ComponentProps<typeof StickToBottom>;

export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-auto", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);

export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;

export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn("p-4", className)} {...props} />
);

export type ConversationScrollButtonProps = ComponentProps<typeof Button>;

export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();

const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);

return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className,
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
96 changes: 96 additions & 0 deletions apps/web/components/ai-elements/loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { cn } from "@/utils";
import type { HTMLAttributes } from "react";

type LoaderIconProps = {
size?: number;
};

const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);

export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};

export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
"inline-flex animate-spin items-center justify-center",
className,
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);
Loading
Loading