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,
|
FileText,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Archive,
|
Archive,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
|
@ -160,6 +162,10 @@ export function FileViewerModal({
|
||||||
currentFile: string;
|
currentFile: string;
|
||||||
} | null>(null);
|
} | 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
|
// Setup project with sandbox URL if not provided directly
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project) {
|
if (project) {
|
||||||
|
@ -849,6 +855,43 @@ export function FileViewerModal({
|
||||||
return filePath ? filePath.toLowerCase().endsWith('.md') : false;
|
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
|
// Handle PDF export for markdown files
|
||||||
const handleExportPdf = useCallback(
|
const handleExportPdf = useCallback(
|
||||||
async (orientation: 'portrait' | 'landscape' = 'portrait') => {
|
async (orientation: 'portrait' | 'landscape' = 'portrait') => {
|
||||||
|
@ -1313,6 +1356,24 @@ export function FileViewerModal({
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{selectedFilePath && (
|
{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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
@ -6,7 +6,7 @@ import React from 'react';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ApiMessageType } from '@/components/thread/types';
|
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 { cn } from '@/lib/utils';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
@ -82,6 +82,9 @@ export function ToolCallSidePanel({
|
||||||
const [toolCallSnapshots, setToolCallSnapshots] = React.useState<ToolCallSnapshot[]>([]);
|
const [toolCallSnapshots, setToolCallSnapshots] = React.useState<ToolCallSnapshot[]>([]);
|
||||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||||
|
|
||||||
|
// Add copy functionality state
|
||||||
|
const [isCopyingContent, setIsCopyingContent] = React.useState(false);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
|
@ -215,6 +218,55 @@ export function ToolCallSidePanel({
|
||||||
|
|
||||||
const isSuccess = isStreaming ? true : getActualSuccess(displayToolCall);
|
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') => {
|
const internalNavigate = React.useCallback((newIndex: number, source: string = 'internal') => {
|
||||||
if (newIndex < 0 || newIndex >= totalCalls) return;
|
if (newIndex < 0 || newIndex >= totalCalls) return;
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,15 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
Wrench,
|
Wrench,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ToolViewProps } from './types';
|
import { ToolViewProps } from './types';
|
||||||
import { formatTimestamp, getToolTitle, extractToolData } from './utils';
|
import { formatTimestamp, getToolTitle, extractToolData } from './utils';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { LoadingState } from './shared/LoadingState';
|
import { LoadingState } from './shared/LoadingState';
|
||||||
|
|
||||||
export function GenericToolView({
|
export function GenericToolView({
|
||||||
|
@ -119,6 +122,47 @@ export function GenericToolView({
|
||||||
[toolContent],
|
[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 (
|
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">
|
<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">
|
<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">
|
<div className="p-4 space-y-4">
|
||||||
{formattedAssistantContent && (
|
{formattedAssistantContent && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
|
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center justify-between">
|
||||||
<Wrench className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
<div className="flex items-center">
|
||||||
Input
|
<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>
|
||||||
<div className="border-muted bg-muted/20 rounded-lg overflow-hidden border">
|
<div className="border-muted bg-muted/20 rounded-lg overflow-hidden border">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
@ -185,9 +245,25 @@ export function GenericToolView({
|
||||||
|
|
||||||
{formattedToolContent && (
|
{formattedToolContent && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center">
|
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 flex items-center justify-between">
|
||||||
<Wrench className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
<div className="flex items-center">
|
||||||
Output
|
<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>
|
||||||
<div className="border-muted bg-muted/20 rounded-lg overflow-hidden border">
|
<div className="border-muted bg-muted/20 rounded-lg overflow-hidden border">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
Code,
|
Code,
|
||||||
Eye,
|
Eye,
|
||||||
File,
|
File,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
extractFilePath,
|
extractFilePath,
|
||||||
|
@ -68,6 +70,33 @@ export function FileOperationToolView({
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const isDarkTheme = resolvedTheme === 'dark';
|
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 operation = getOperationType(name, assistantContent);
|
||||||
const configs = getOperationConfigs();
|
const configs = getOperationConfigs();
|
||||||
const config = configs[operation];
|
const config = configs[operation];
|
||||||
|
@ -284,6 +313,24 @@ export function FileOperationToolView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
<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 && (
|
{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>
|
<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">
|
<a href={htmlPreviewUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
|
Loading…
Reference in New Issue