mirror of https://github.com/kortix-ai/suna.git
issue: add copy functionality to file manager and tool-call side panel
This commit is contained in:
parent
d5159f5fba
commit
a004a1b2ee
|
@ -21,6 +21,8 @@ import {
|
|||
FileText,
|
||||
ChevronDown,
|
||||
Archive,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
|
@ -160,6 +162,10 @@ export function FileViewerModal({
|
|||
currentFile: string;
|
||||
} | null>(null);
|
||||
|
||||
// Add state for copy functionality
|
||||
const [isCopyingPath, setIsCopyingPath] = useState(false);
|
||||
const [isCopyingContent, setIsCopyingContent] = useState(false);
|
||||
|
||||
// Setup project with sandbox URL if not provided directly
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
|
@ -849,6 +855,43 @@ export function FileViewerModal({
|
|||
return filePath ? filePath.toLowerCase().endsWith('.md') : false;
|
||||
}, []);
|
||||
|
||||
// Copy functions
|
||||
const copyToClipboard = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyPath = useCallback(async () => {
|
||||
if (!textContentForRenderer) return;
|
||||
|
||||
setIsCopyingPath(true);
|
||||
const success = await copyToClipboard(textContentForRenderer);
|
||||
if (success) {
|
||||
toast.success('File content copied to clipboard');
|
||||
} else {
|
||||
toast.error('Failed to copy file content');
|
||||
}
|
||||
setTimeout(() => setIsCopyingPath(false), 500);
|
||||
}, [textContentForRenderer, copyToClipboard]);
|
||||
|
||||
const handleCopyContent = useCallback(async () => {
|
||||
if (!textContentForRenderer) return;
|
||||
|
||||
setIsCopyingContent(true);
|
||||
const success = await copyToClipboard(textContentForRenderer);
|
||||
if (success) {
|
||||
toast.success('File content copied to clipboard');
|
||||
} else {
|
||||
toast.error('Failed to copy file content');
|
||||
}
|
||||
setTimeout(() => setIsCopyingContent(false), 500);
|
||||
}, [textContentForRenderer, copyToClipboard]);
|
||||
|
||||
// Handle PDF export for markdown files
|
||||
const handleExportPdf = useCallback(
|
||||
async (orientation: 'portrait' | 'landscape' = 'portrait') => {
|
||||
|
@ -1313,6 +1356,24 @@ export function FileViewerModal({
|
|||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{selectedFilePath && (
|
||||
<>
|
||||
{/* Copy content button - only show for text files */}
|
||||
{textContentForRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyContent}
|
||||
disabled={isCopyingContent || isCachedFileLoading}
|
||||
className="h-8 gap-1"
|
||||
>
|
||||
{isCopyingContent ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Copy</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
@ -6,7 +6,7 @@ import React from 'react';
|
|||
import { Slider } from '@/components/ui/slider';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ApiMessageType } from '@/components/thread/types';
|
||||
import { CircleDashed, X, ChevronLeft, ChevronRight, Computer, Radio, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { CircleDashed, X, ChevronLeft, ChevronRight, Computer, Radio, Maximize2, Minimize2, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
@ -82,6 +82,9 @@ export function ToolCallSidePanel({
|
|||
const [toolCallSnapshots, setToolCallSnapshots] = React.useState<ToolCallSnapshot[]>([]);
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
|
||||
// Add copy functionality state
|
||||
const [isCopyingContent, setIsCopyingContent] = React.useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
|
@ -215,6 +218,55 @@ export function ToolCallSidePanel({
|
|||
|
||||
const isSuccess = isStreaming ? true : getActualSuccess(displayToolCall);
|
||||
|
||||
// Copy functions
|
||||
const copyToClipboard = React.useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyContent = React.useCallback(async () => {
|
||||
const toolContent = displayToolCall?.toolResult?.content;
|
||||
if (!toolContent || toolContent === 'STREAMING') return;
|
||||
|
||||
// Try to extract file content from tool result
|
||||
let fileContent = '';
|
||||
|
||||
// If the tool result is JSON, try to extract file content
|
||||
try {
|
||||
const parsed = JSON.parse(toolContent);
|
||||
if (parsed.content && typeof parsed.content === 'string') {
|
||||
fileContent = parsed.content;
|
||||
} else if (parsed.file_content && typeof parsed.file_content === 'string') {
|
||||
fileContent = parsed.file_content;
|
||||
} else if (parsed.result && typeof parsed.result === 'string') {
|
||||
fileContent = parsed.result;
|
||||
} else if (parsed.toolOutput && typeof parsed.toolOutput === 'string') {
|
||||
fileContent = parsed.toolOutput;
|
||||
} else {
|
||||
// If no string content found, stringify the object
|
||||
fileContent = JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
// If it's not JSON, use the content as is
|
||||
fileContent = typeof toolContent === 'string' ? toolContent : JSON.stringify(toolContent, null, 2);
|
||||
}
|
||||
|
||||
setIsCopyingContent(true);
|
||||
const success = await copyToClipboard(fileContent);
|
||||
if (success) {
|
||||
// Use toast if available, otherwise just log
|
||||
console.log('File content copied to clipboard');
|
||||
} else {
|
||||
console.error('Failed to copy file content');
|
||||
}
|
||||
setTimeout(() => setIsCopyingContent(false), 500);
|
||||
}, [displayToolCall?.toolResult?.content, copyToClipboard]);
|
||||
|
||||
const internalNavigate = React.useCallback((newIndex: number, source: string = 'internal') => {
|
||||
if (newIndex < 0 || newIndex >= totalCalls) return;
|
||||
|
||||
|
|
|
@ -6,12 +6,15 @@ import {
|
|||
AlertTriangle,
|
||||
Clock,
|
||||
Wrench,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { ToolViewProps } from './types';
|
||||
import { formatTimestamp, getToolTitle, extractToolData } from './utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingState } from './shared/LoadingState';
|
||||
|
||||
export function GenericToolView({
|
||||
|
@ -119,6 +122,47 @@ export function GenericToolView({
|
|||
[toolContent],
|
||||
);
|
||||
|
||||
// Add copy functionality state
|
||||
const [isCopyingInput, setIsCopyingInput] = React.useState(false);
|
||||
const [isCopyingOutput, setIsCopyingOutput] = React.useState(false);
|
||||
|
||||
// Copy functions
|
||||
const copyToClipboard = React.useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyInput = React.useCallback(async () => {
|
||||
if (!formattedAssistantContent) return;
|
||||
|
||||
setIsCopyingInput(true);
|
||||
const success = await copyToClipboard(formattedAssistantContent);
|
||||
if (success) {
|
||||
console.log('Tool input copied to clipboard');
|
||||
} else {
|
||||
console.error('Failed to copy tool input');
|
||||
}
|
||||
setTimeout(() => setIsCopyingInput(false), 500);
|
||||
}, [formattedAssistantContent, copyToClipboard]);
|
||||
|
||||
const handleCopyOutput = React.useCallback(async () => {
|
||||
if (!formattedToolContent) return;
|
||||
|
||||
setIsCopyingOutput(true);
|
||||
const success = await copyToClipboard(formattedToolContent);
|
||||
if (success) {
|
||||
console.log('Tool output copied to clipboard');
|
||||
} else {
|
||||
console.error('Failed to copy tool output');
|
||||
}
|
||||
setTimeout(() => setIsCopyingOutput(false), 500);
|
||||
}, [formattedToolContent, copyToClipboard]);
|
||||
|
||||
return (
|
||||
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
|
||||
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
|
||||
|
@ -169,9 +213,25 @@ export function GenericToolView({
|
|||
<div className="p-4 space-y-4">
|
||||
{formattedAssistantContent && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
|
||||
<Wrench className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
Input
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Wrench className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
Input
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyInput}
|
||||
disabled={isCopyingInput}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Copy input"
|
||||
>
|
||||
{isCopyingInput ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-muted bg-muted/20 rounded-lg overflow-hidden border">
|
||||
<div className="p-4">
|
||||
|
@ -185,9 +245,25 @@ export function GenericToolView({
|
|||
|
||||
{formattedToolContent && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
|
||||
<Wrench className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
Output
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Wrench className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
Output
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyOutput}
|
||||
disabled={isCopyingOutput}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Copy output"
|
||||
>
|
||||
{isCopyingOutput ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-muted bg-muted/20 rounded-lg overflow-hidden border">
|
||||
<div className="p-4">
|
||||
|
|
|
@ -7,6 +7,8 @@ import {
|
|||
Code,
|
||||
Eye,
|
||||
File,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
extractFilePath,
|
||||
|
@ -68,6 +70,33 @@ export function FileOperationToolView({
|
|||
const { resolvedTheme } = useTheme();
|
||||
const isDarkTheme = resolvedTheme === 'dark';
|
||||
|
||||
// Add copy functionality state
|
||||
const [isCopyingContent, setIsCopyingContent] = useState(false);
|
||||
|
||||
// Copy functions
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
if (!fileContent) return;
|
||||
|
||||
setIsCopyingContent(true);
|
||||
const success = await copyToClipboard(fileContent);
|
||||
if (success) {
|
||||
console.log('File content copied to clipboard');
|
||||
} else {
|
||||
console.error('Failed to copy file content');
|
||||
}
|
||||
setTimeout(() => setIsCopyingContent(false), 500);
|
||||
};
|
||||
|
||||
const operation = getOperationType(name, assistantContent);
|
||||
const configs = getOperationConfigs();
|
||||
const config = configs[operation];
|
||||
|
@ -284,6 +313,24 @@ export function FileOperationToolView({
|
|||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Copy button - only show when there's file content */}
|
||||
{fileContent && !isStreaming && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyContent}
|
||||
disabled={isCopyingContent}
|
||||
className="h-8 text-xs bg-white dark:bg-muted/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 shadow-none"
|
||||
title="Copy file content"
|
||||
>
|
||||
{isCopyingContent ? (
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Copy</span>
|
||||
</Button>
|
||||
)}
|
||||
{isHtml && htmlPreviewUrl && !isStreaming && (
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs bg-white dark:bg-muted/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 shadow-none" asChild>
|
||||
<a href={htmlPreviewUrl} target="_blank" rel="noopener noreferrer">
|
||||
|
|
Loading…
Reference in New Issue