feat: Universal clipboard utility with improved copy functionality (#663)

* feat: Add universal clipboard utility with enhanced copy functionality

- Add comprehensive clipboard utility (src/utils/clipboard.ts) with:
  - Modern Clipboard API with automatic fallback to document.execCommand
  - Cross-browser compatibility and security context handling
  - Detailed error reporting and debugging capabilities
  - Support for secure (HTTPS) and insecure (HTTP/localhost) contexts

- Update components to use new clipboard utility:
  - BugReportModal: Enhanced copy functionality with error handling
  - CodeViewerModal: Improved copy-to-clipboard for code snippets
  - IDEGlobalRules: Robust clipboard operations for rule copying
  - McpConfigSection: Enhanced config and command copying
  - DocumentCard: Reliable ID copying functionality
  - KnowledgeInspector: Improved content copying
  - ButtonPlayground: Enhanced CSS style copying

- Benefits:
  - Consistent copy behavior across all browser environments
  - Better error handling and user feedback
  - Improved accessibility and security context support
  - Enhanced debugging capabilities

Fixes #662

* fix: Improve clipboard utility robustness and add missing host configuration

Clipboard utility improvements:
- Prevent textarea element leak in clipboard fallback with proper cleanup
- Add SSR compatibility with typeof guards for navigator/document
- Use finally block to ensure cleanup in all error cases

Host configuration fixes:
- Update MCP API to use ARCHON_HOST environment variable instead of hardcoded localhost
- Add ARCHON_HOST to docker-compose environment variables
- Ensures MCP configuration shows correct hostname in different deployment environments

Addresses CodeRabbit feedback and restores missing host functionality

* fix: Use relative URLs for Vite proxy in development

- Update getApiUrl() to return empty string when VITE_API_URL is unset
- Ensures all API requests use relative paths (/api/...) in development
- Prevents bypassing Vite proxy with absolute URLs (host:port)
- Maintains existing functionality for explicit VITE_API_URL configuration
- Fix TypeScript error by using bracket notation for environment access

Addresses CodeRabbit feedback about dev setup relying on Vite proxy

* fix: Resolve TypeScript error in API configuration

Use proper type assertion to access VITE_API_URL environment variable

* Address PR review comments: Move clipboard utility to features architecture

- Move clipboard.ts from src/utils/ to src/features/shared/utils/
- Remove copyTextToClipboard backward compatibility function (dead code)
- Update all import statements to use new file location
- Maintain full clipboard functionality with modern API and fallbacks

Addresses:
- Review comment r2348420743: Move to new architecture location
- Review comment r2348422625: Remove unused backward compatibility function

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix SSR safety issue in clipboard utility

- Add typeof navigator !== 'undefined' guard before accessing navigator.clipboard
- Add typeof document !== 'undefined' guard before using document.execCommand fallback
- Ensure proper error handling when running in server-side environment
- Maintain existing functionality while preventing ReferenceError during SSR/prerender

Addresses CodeRabbit feedback: Navigator access needs SSR-safe guards

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
John C Fitzpatrick
2025-09-18 10:04:46 -07:00
committed by GitHub
parent 89fa9b4b49
commit 9ffca825ff
11 changed files with 225 additions and 59 deletions

View File

@@ -11,6 +11,7 @@ import {
BugContext,
BugReportData,
} from "../../services/bugReportService";
import { copyToClipboard } from "../../features/shared/utils/clipboard";
interface BugReportModalProps {
isOpen: boolean;
@@ -99,13 +100,21 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
// Fallback: copy to clipboard
const formattedReport =
bugReportService.formatReportForClipboard(bugReportData);
await navigator.clipboard.writeText(formattedReport);
const clipboardResult = await copyToClipboard(formattedReport);
showToast(
"Failed to create GitHub issue, but bug report was copied to clipboard. Please paste it in a new GitHub issue.",
"warning",
10000,
);
if (clipboardResult.success) {
showToast(
"Failed to create GitHub issue, but bug report was copied to clipboard. Please paste it in a new GitHub issue.",
"warning",
10000,
);
} else {
showToast(
"Failed to create GitHub issue and could not copy to clipboard. Please report manually.",
"error",
10000,
);
}
}
} catch (error) {
console.error("Bug report submission failed:", error);
@@ -118,15 +127,15 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
}
};
const copyToClipboard = async () => {
const handleCopyToClipboard = async () => {
const bugReportData: BugReportData = { ...report, context };
const formattedReport =
bugReportService.formatReportForClipboard(bugReportData);
try {
await navigator.clipboard.writeText(formattedReport);
const result = await copyToClipboard(formattedReport);
if (result.success) {
showToast("Bug report copied to clipboard", "success");
} catch {
} else {
showToast("Failed to copy to clipboard", "error");
}
};
@@ -372,7 +381,7 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
<Button
type="button"
variant="ghost"
onClick={copyToClipboard}
onClick={handleCopyToClipboard}
className="sm:order-1"
>
<Copy className="w-4 h-4 mr-2" />

View File

@@ -30,6 +30,7 @@ import 'prismjs/components/prism-graphql'
import 'prismjs/themes/prism-tomorrow.css'
import { Button } from '../ui/Button'
import { Badge } from '../ui/Badge'
import { copyToClipboard } from '../../features/shared/utils/clipboard'
export interface CodeExample {
id: string
@@ -102,11 +103,15 @@ export const CodeViewerModal: React.FC<CodeViewerModalProps> = ({
setActiveExampleIndex(0)
}, [searchQuery])
const handleCopyCode = () => {
const handleCopyCode = async () => {
if (activeExample) {
navigator.clipboard.writeText(activeExample.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const result = await copyToClipboard(activeExample.code)
if (result.success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} else {
console.error('Failed to copy to clipboard:', result.error)
}
}
}

View File

@@ -3,6 +3,7 @@ import { Copy, Check, Link, Unlink } from 'lucide-react';
import { NeonButton, type CornerRadius, type GlowIntensity, type ColorOption } from '../ui/NeonButton';
import { motion } from 'framer-motion';
import { cn } from '../../lib/utils';
import { copyToClipboard } from '../../features/shared/utils/clipboard';
export const ButtonPlayground: React.FC = () => {
const [showLayer2, setShowLayer2] = useState(true);
@@ -279,10 +280,14 @@ export const ButtonPlayground: React.FC = () => {
return colors[color];
};
const copyToClipboard = () => {
navigator.clipboard.writeText(generateCSS());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const handleCopyToClipboard = async () => {
const result = await copyToClipboard(generateCSS());
if (result.success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error('Failed to copy to clipboard:', result.error);
}
};
// Corner input component
@@ -654,7 +659,7 @@ export const ButtonPlayground: React.FC = () => {
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">CSS Styles</h3>
<button
onClick={copyToClipboard}
onClick={handleCopyToClipboard}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center gap-2 shadow-lg shadow-purple-600/25"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}

View File

@@ -3,6 +3,7 @@ import { FileCode, Copy, Check } from 'lucide-react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { useToast } from '../../features/ui/hooks/useToast';
import { copyToClipboard } from '../../features/shared/utils/clipboard';
type RuleType = 'claude' | 'universal';
@@ -472,8 +473,9 @@ archon:manage_task(
};
const handleCopyToClipboard = async () => {
try {
await navigator.clipboard.writeText(currentRules);
const result = await copyToClipboard(currentRules);
if (result.success) {
setCopied(true);
showToast(`${selectedRuleType === 'claude' ? 'Claude Code' : 'Universal'} rules copied to clipboard!`, 'success');
@@ -481,8 +483,8 @@ archon:manage_task(
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
} else {
console.error('Failed to copy text:', result.error);
showToast('Failed to copy to clipboard', 'error');
}
};

View File

@@ -5,29 +5,17 @@
* and handles different environments (development, Docker, production)
*/
// Get the API URL from environment or construct it
// Get the API URL from environment or use relative URLs for proxy
export function getApiUrl(): string {
// For relative URLs in production (goes through proxy)
if (import.meta.env.PROD) {
return '';
// Check if VITE_API_URL is explicitly provided (for absolute URL mode)
const viteApiUrl = (import.meta.env as any).VITE_API_URL as string | undefined;
if (viteApiUrl) {
return viteApiUrl;
}
// Check if VITE_API_URL is provided (set by docker-compose)
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// For development, construct from window location
const protocol = window.location.protocol;
const host = window.location.hostname;
// Use configured port or default to 8181
const port = import.meta.env.VITE_ARCHON_SERVER_PORT || '8181';
if (!import.meta.env.VITE_ARCHON_SERVER_PORT) {
console.info('[Archon] Using default ARCHON_SERVER_PORT: 8181');
}
return `${protocol}//${host}:${port}`;
// Default to relative URLs to use Vite proxy in development
// or direct proxy in production - this ensures all requests go through proxy
return '';
}
// Get the base path for API endpoints

View File

@@ -10,6 +10,7 @@ import { useInspectorPagination } from "../hooks/useInspectorPagination";
import { ContentViewer } from "./ContentViewer";
import { InspectorHeader } from "./InspectorHeader";
import { InspectorSidebar } from "./InspectorSidebar";
import { copyToClipboard } from "../../../shared/utils/clipboard";
interface KnowledgeInspectorProps {
item: KnowledgeItem;
@@ -92,12 +93,12 @@ export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
}, [viewMode, currentItems, selectedItem]);
const handleCopy = useCallback(async (text: string, id: string) => {
try {
await navigator.clipboard.writeText(text);
const result = await copyToClipboard(text);
if (result.success) {
setCopiedId(id);
setTimeout(() => setCopiedId((v) => (v === id ? null : v)), 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
} else {
console.error("Failed to copy to clipboard:", result.error);
}
}, []);

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useToast } from "../../ui/hooks";
import { Button, cn, glassmorphism, Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
import type { McpServerConfig, McpServerStatus, SupportedIDE } from "../types";
import { copyToClipboard } from "../../shared/utils/clipboard";
interface McpConfigSectionProps {
config?: McpServerConfig;
@@ -185,10 +186,16 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
);
}
const handleCopyConfig = () => {
const handleCopyConfig = async () => {
const configText = ideConfigurations[selectedIDE].configGenerator(config);
navigator.clipboard.writeText(configText);
showToast("Configuration copied to clipboard", "success");
const result = await copyToClipboard(configText);
if (result.success) {
showToast("Configuration copied to clipboard", "success");
} else {
console.error("Failed to copy config:", result.error);
showToast("Failed to copy configuration", "error");
}
};
const handleCursorOneClick = () => {
@@ -202,10 +209,16 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
showToast("Opening Cursor with Archon MCP configuration...", "info");
};
const handleClaudeCodeCommand = () => {
const handleClaudeCodeCommand = async () => {
const command = `claude mcp add --transport http archon http://${config.host}:${config.port}/mcp`;
navigator.clipboard.writeText(command);
showToast("Command copied to clipboard", "success");
const result = await copyToClipboard(command);
if (result.success) {
showToast("Command copied to clipboard", "success");
} else {
console.error("Failed to copy command:", result.error);
showToast("Failed to copy command", "error");
}
};
const selectedConfig = ideConfigurations[selectedIDE];

View File

@@ -15,6 +15,7 @@ import type React from "react";
import { memo, useCallback, useState } from "react";
import { Button } from "../../../ui/primitives";
import type { DocumentCardProps, DocumentType } from "../types";
import { copyToClipboard } from "../../../shared/utils/clipboard";
const getDocumentIcon = (type?: DocumentType) => {
switch (type) {
@@ -67,11 +68,13 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
const [isCopied, setIsCopied] = useState(false);
const handleCopyId = useCallback(
(e: React.MouseEvent) => {
async (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(document.id);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
const result = await copyToClipboard(document.id);
if (result.success) {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}
},
[document.id],
);

View File

@@ -0,0 +1,139 @@
/**
* Universal clipboard utility with modern API and fallback support
* Handles various security contexts and browser compatibility issues
*/
export interface ClipboardResult {
success: boolean;
method: 'clipboard-api' | 'execCommand' | 'failed';
error?: string;
}
/**
* Copy text to clipboard with automatic fallback mechanisms
* @param text - Text to copy to clipboard
* @returns Promise<ClipboardResult> - Result of the copy operation
*/
export const copyToClipboard = async (text: string): Promise<ClipboardResult> => {
// Try modern clipboard API first with SSR-safe guards
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
navigator.clipboard.writeText
) {
try {
await navigator.clipboard.writeText(text);
return { success: true, method: 'clipboard-api' };
} catch (error) {
console.warn('Clipboard API failed, trying fallback:', error);
}
}
// Fallback to document.execCommand for older browsers or insecure contexts
// Add SSR guards for document access
if (typeof document === 'undefined') {
return {
success: false,
method: 'failed',
error: 'Running in server-side environment - clipboard not available'
};
}
let textarea: HTMLTextAreaElement | null = null;
try {
// Ensure document.body exists before proceeding
if (!document.body) {
return {
success: false,
method: 'failed',
error: 'document.body is not available'
};
}
textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.setAttribute('readonly', '');
textarea.setAttribute('aria-hidden', 'true');
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
const success = document.execCommand('copy');
if (success) {
return { success: true, method: 'execCommand' };
} else {
return {
success: false,
method: 'failed',
error: 'execCommand copy returned false'
};
}
} catch (error) {
return {
success: false,
method: 'failed',
error: error instanceof Error ? error.message : 'Unknown error'
};
} finally {
// Always clean up the textarea element if it was created and added to DOM
if (textarea && document.body && document.body.contains(textarea)) {
try {
document.body.removeChild(textarea);
} catch (cleanupError) {
// Ignore cleanup errors - element may have already been removed
console.warn('Failed to cleanup textarea element:', cleanupError);
}
}
}
};
/**
* Check if clipboard functionality is supported in current context
* @returns boolean - True if any clipboard method is available
*/
export const isClipboardSupported = (): boolean => {
// Check modern clipboard API with proper SSR guards
if (
typeof navigator !== 'undefined' &&
typeof navigator.clipboard !== 'undefined' &&
typeof navigator.clipboard.writeText === 'function'
) {
return true;
}
// Check execCommand fallback with SSR guards
if (
typeof document !== 'undefined' &&
typeof document.queryCommandSupported === 'function'
) {
try {
return document.queryCommandSupported('copy');
} catch {
return false;
}
}
// Return false if running in SSR or globals are unavailable
return false;
};
/**
* Get current security context information for debugging
* @returns string - Description of current security context
*/
export const getSecurityContext = (): string => {
if (typeof window === 'undefined') return 'server';
if (window.isSecureContext) return 'secure';
if (window.location.protocol === 'https:') return 'https';
if (window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1') return 'localhost';
return 'insecure';
};

View File

@@ -28,6 +28,7 @@ services:
- ARCHON_MCP_PORT=${ARCHON_MCP_PORT:-8051}
- ARCHON_AGENTS_PORT=${ARCHON_AGENTS_PORT:-8052}
- AGENTS_ENABLED=${AGENTS_ENABLED:-false}
- ARCHON_HOST=${HOST:-localhost}
networks:
- app-network
volumes:

View File

@@ -109,7 +109,7 @@ async def get_mcp_config():
# Configuration for streamable-http mode with actual port
config = {
"host": "localhost",
"host": os.getenv("ARCHON_HOST", "localhost"),
"port": mcp_port,
"transport": "streamable-http",
}