Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 7 additions & 1 deletion app/(docs)/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { notFound } from "next/navigation";
import { CommunityButton } from "../../../components/Community";
import { NavButton } from "../../../components/NavButton";
import CopyPageDropdown from "../../../components/CopyPageDropdown";

export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
Expand All @@ -40,7 +41,12 @@ export default async function Page(props: {
path: `content/${page.file.path}`,
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0 mb-4">
<DocsTitle className="mb-0">{page.data.title}</DocsTitle>
<div className="hidden sm:flex justify-end">
<CopyPageDropdown enhanced={true} />
</div>
</div>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX
Expand Down
2 changes: 0 additions & 2 deletions app/(docs)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { source } from "@/lib/source";
import { DocsLayout } from "fumadocs-ui/layouts/notebook";
import { LargeSearchToggle } from 'fumadocs-ui/components/layout/search-toggle';
import AISearchToggle from "../../components/AISearchToggle";
import CopyPageDropdown from "../../components/CopyPageDropdown";
import type { ReactNode } from "react";

export default function Layout({ children }: { children: ReactNode }) {
Expand All @@ -17,7 +16,6 @@ export default function Layout({ children }: { children: ReactNode }) {
<div className="flex gap-1.5 max-md:hidden">
<LargeSearchToggle className="flex-1" />
<AISearchToggle />
<CopyPageDropdown />
</div>
),
},
Expand Down
262 changes: 207 additions & 55 deletions components/CopyPageDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
'use client';

import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { Copy, ExternalLink, FileText, MessageSquare } from 'lucide-react';
import { Copy, FileText, ChevronDown } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { OpenAIIcon } from './icons/OpenAIIcon';
import { ClaudeIcon } from './icons/ClaudeIcon';

interface PageContent {
content: string;
Expand All @@ -12,11 +14,50 @@ interface PageContent {
path: string;
}

export default function CopyPageDropdown() {
type ActionType = 'copy-markdown' | 'view-markdown' | 'open-chatgpt' | 'open-claude';

interface ActionConfig {
id: ActionType;
label: string;
icon: React.ComponentType<{ className?: string }>;
handler: () => Promise<void>;
}

interface CopyPageDropdownProps {
enhanced?: boolean;
}

const STORAGE_KEY = 'agentuity-copy-preference';

export default function CopyPageDropdown({ enhanced = false }: CopyPageDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [preferredAction, setPreferredAction] = useState<ActionType>('copy-markdown');
const [isInitialized, setIsInitialized] = useState(false);
const pathname = usePathname();

useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && ['copy-markdown', 'view-markdown', 'open-chatgpt', 'open-claude'].includes(stored)) {
setPreferredAction(stored as ActionType);
}
} catch (error) {
console.error('Failed to load copy preference:', error);
} finally {
setIsInitialized(true);
}
}, []);

const savePreference = (action: ActionType) => {
try {
localStorage.setItem(STORAGE_KEY, action);
setPreferredAction(action);
} catch (error) {
console.error('Failed to save copy preference:', error);
}
};

const formatMarkdownForLLM = (content: PageContent): string => {
return `# ${content.title}\n\n${content.description ? `${content.description}\n\n` : ''}${content.content}`;
};
Expand Down Expand Up @@ -45,9 +86,36 @@ export default function CopyPageDropdown() {
const markdownForLLM = formatMarkdownForLLM(content);

try {
await navigator.clipboard.writeText(markdownForLLM);
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(markdownForLLM);
} else {
const textArea = document.createElement('textarea');
textArea.value = markdownForLLM;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
const textArea = document.createElement('textarea');
textArea.value = markdownForLLM;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
} catch (fallbackError) {
console.error('Fallback copy also failed:', fallbackError);
}
document.body.removeChild(textArea);
}
setIsOpen(false);
};
Expand All @@ -59,79 +127,163 @@ export default function CopyPageDropdown() {
const markdownForLLM = formatMarkdownForLLM(content);
const blob = new Blob([markdownForLLM], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 100);

try {
const newWindow = window.open(url, '_blank');
if (!newWindow) {
const link = document.createElement('a');
link.href = url;
link.download = `${content.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} catch (error) {
console.error('Failed to open markdown view:', error);
const link = document.createElement('a');
link.href = url;
link.download = `${content.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

setTimeout(() => URL.revokeObjectURL(url), 1000);
setIsOpen(false);
};

const handleOpenInChatGPT = async () => {
const content = await fetchPageContent();
if (!content) return;
const currentUrl = `${window.location.origin}${pathname}`;
const chatGPTUrl = `https://chatgpt.com/?hints=search&prompt=${encodeURIComponent(`Read from ${currentUrl} so I can ask questions about it`)}`;

const markdownForLLM = formatMarkdownForLLM(content);
const chatGPTUrl = `https://chatgpt.com/?q=${encodeURIComponent(`Please help me understand this documentation:\n\n${markdownForLLM}`)}`;
window.open(chatGPTUrl, '_blank');
try {
const newWindow = window.open(chatGPTUrl, '_blank');
if (!newWindow) {
window.location.href = chatGPTUrl;
}
} catch (error) {
console.error('Failed to open ChatGPT:', error);
window.location.href = chatGPTUrl;
}
setIsOpen(false);
};

const handleOpenInClaude = async () => {
const content = await fetchPageContent();
if (!content) return;
const currentUrl = `${window.location.origin}${pathname}`;
const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(`Read from ${currentUrl} so I can ask questions about it`)}`;

const markdownForLLM = formatMarkdownForLLM(content);
const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(`Please help me understand this documentation:\n\n${markdownForLLM}`)}`;
window.open(claudeUrl, '_blank');
try {
const newWindow = window.open(claudeUrl, '_blank');
if (!newWindow) {
window.location.href = claudeUrl;
}
} catch (error) {
console.error('Failed to open Claude:', error);
window.location.href = claudeUrl;
}
setIsOpen(false);
};

const actionConfigs: ActionConfig[] = [
{
id: 'copy-markdown',
label: 'Copy as Markdown',
icon: Copy,
handler: handleCopyMarkdown
},
{
id: 'view-markdown',
label: 'View as Markdown',
icon: FileText,
handler: handleViewMarkdown
},
{
id: 'open-chatgpt',
label: 'Open in ChatGPT',
icon: OpenAIIcon,
handler: handleOpenInChatGPT
},
{
id: 'open-claude',
label: 'Open in Claude',
icon: ClaudeIcon,
handler: handleOpenInClaude
}
];

const primaryAction = actionConfigs.find(action => action.id === preferredAction) || actionConfigs[0];

const handlePrimaryAction = async () => {
await primaryAction.handler();
};

const handleActionSelect = async (actionId: ActionType) => {
savePreference(actionId);
const action = actionConfigs.find(a => a.id === actionId);
if (action) {
await action.handler();
}
};

if (!isInitialized) {
return null;
}

return (
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger asChild>
<button
aria-label="Copy page options"
className="flex items-center justify-center transition-all duration-200 hover:scale-110 active:scale-95 transform-origin-center border border-gray-200 dark:border-cyan-900 rounded-md size-10 hover:border-cyan-300 dark:hover:border-cyan-600"
>
<Copy className="size-4 text-cyan-700 dark:text-cyan-500" />
</button>
</Popover.Trigger>
<Popover.Content
className="w-64 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50"
align="end"
sideOffset={8}
>
<div className="flex flex-col gap-1">
{enhanced ? (
<div className="inline-flex rounded-md overflow-hidden border border-gray-200 dark:border-gray-700">
<button
onClick={handleCopyMarkdown}
onClick={handlePrimaryAction}
disabled={isLoading}
className="flex items-center gap-2 w-full p-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-left disabled:opacity-50"
aria-label={`${primaryAction.label} (primary action)`}
className="inline-flex items-center gap-1.5 px-2 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 disabled:opacity-50 touch-manipulation rounded-l-md border-r border-gray-200 dark:border-gray-700"
>
<Copy className="size-4" />
Copy as Markdown for LLMs
</button>
<button
onClick={handleViewMarkdown}
disabled={isLoading}
className="flex items-center gap-2 w-full p-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-left disabled:opacity-50"
>
<FileText className="size-4" />
View as Markdown
</button>
<button
onClick={handleOpenInChatGPT}
disabled={isLoading}
className="flex items-center gap-2 w-full p-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-left disabled:opacity-50"
>
<MessageSquare className="size-4" />
Open in ChatGPT
<primaryAction.icon className="size-3.5" />
{primaryAction.label}
</button>
<Popover.Trigger asChild>
<button
aria-label="More copy options"
className="inline-flex items-center px-1.5 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 touch-manipulation rounded-r-md"
>
<ChevronDown className="size-3.5" />
</button>
</Popover.Trigger>
</div>
) : (
<Popover.Trigger asChild>
<button
onClick={handleOpenInClaude}
disabled={isLoading}
className="flex items-center gap-2 w-full p-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-left disabled:opacity-50"
aria-label="Copy page options"
className="flex items-center justify-center transition-all duration-200 hover:scale-110 active:scale-95 transform-origin-center border border-gray-200 dark:border-cyan-900 rounded-md size-10 hover:border-cyan-300 dark:hover:border-cyan-600"
>
<ExternalLink className="size-4" />
Open in Claude
<Copy className="size-4 text-cyan-700 dark:text-cyan-500" />
</button>
</Popover.Trigger>
)}
<Popover.Content
className="w-64 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50"
align="end"
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1">
{actionConfigs.map((action) => (
<button
key={action.id}
onClick={() => handleActionSelect(action.id)}
disabled={isLoading}
className={`flex items-center gap-2 w-full px-2 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-left disabled:opacity-50 ${
action.id === preferredAction ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
>
<action.icon className="size-4" />
{action.label}
{action.id === preferredAction && (
<span className="ml-auto text-xs text-gray-500 dark:text-gray-400">Default</span>
)}
</button>
))}
</div>
</Popover.Content>
</Popover.Root>
Expand Down
23 changes: 23 additions & 0 deletions components/icons/ClaudeIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from "react";

interface ClaudeIconProps {
className?: string;
size?: number;
}

export function ClaudeIcon({ className = "w-5 h-5", size }: ClaudeIconProps) {
return (
<svg
className={className}
width={size}
height={size}
Comment on lines +8 to +13
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Fix size prop handling to properly override default className.

When the size prop is provided, it should override the width/height from the default className. Currently, both the className (w-5 h-5) and size prop are applied simultaneously, which may cause conflicts.

-export function ClaudeIcon({ className = "w-5 h-5", size }: ClaudeIconProps) {
+export function ClaudeIcon({ className, size }: ClaudeIconProps) {
   return (
     <svg 
-      className={className}
+      className={size ? className || "" : className || "w-5 h-5"}
       width={size}
       height={size}
📝 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
export function ClaudeIcon({ className = "w-5 h-5", size }: ClaudeIconProps) {
return (
<svg
className={className}
width={size}
height={size}
export function ClaudeIcon({ className, size }: ClaudeIconProps) {
return (
<svg
className={size ? className || "" : className || "w-5 h-5"}
width={size}
height={size}
🤖 Prompt for AI Agents
In components/icons/ClaudeIcon.tsx around lines 8 to 13, the size prop is
applied alongside the default className which sets width and height, causing
conflicts. To fix this, modify the component to conditionally apply the
className only when size is not provided; if size is given, omit the width and
height classes from className and rely solely on the size prop for dimensions.

fill="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Claude"
>
<title>Claude</title>
<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/>
</svg>
);
}
Loading