feat: add tool views and fixes

This commit is contained in:
Vukasin 2025-07-11 00:27:59 +02:00
parent 3746dc25c4
commit 94d3229e51
7 changed files with 779 additions and 8 deletions

View File

@ -1,5 +1,6 @@
import { Message } from '@/api/chat-api';
import { useThemedStyles } from '@/hooks/useThemeColor';
import { useSelectedProject } from '@/stores/ui-store';
import React, { useRef } from 'react';
import { Dimensions, View } from 'react-native';
import { DrawerLayout } from 'react-native-gesture-handler';
@ -29,6 +30,7 @@ export const PanelContainer: React.FC<PanelContainerProps> = ({
}) => {
const leftDrawerRef = useRef<DrawerLayout>(null);
const rightDrawerRef = useRef<DrawerLayout>(null);
const selectedProject = useSelectedProject();
const styles = useThemedStyles((theme) => ({
container: {
@ -65,7 +67,12 @@ export const PanelContainer: React.FC<PanelContainerProps> = ({
);
const rightDrawerContent = (
<RightPanel isVisible={true} onClose={onCloseRight} messages={messages} />
<RightPanel
isVisible={true}
onClose={onCloseRight}
messages={messages}
sandboxId={selectedProject?.sandbox?.id}
/>
);
const mainContent = (

View File

@ -15,7 +15,7 @@ import {
useToolViewState,
useUpdateToolSnapshots
} from '@/stores/ui-store';
import { GenericToolView } from './ToolViews/GenericToolView';
import { ToolView } from './ToolViews/ToolViewRegistry';
import { Body, Caption, H4 } from './Typography';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
@ -24,9 +24,10 @@ interface RightPanelProps {
isVisible: boolean;
onClose: () => void;
messages?: Message[];
sandboxId?: string;
}
export const RightPanel: React.FC<RightPanelProps> = ({ isVisible, onClose, messages = [] }) => {
export const RightPanel: React.FC<RightPanelProps> = ({ isVisible, onClose, messages = [], sandboxId }) => {
const insets = useSafeAreaInsets();
const theme = useTheme();
const toolViewState = useToolViewState();
@ -283,7 +284,7 @@ export const RightPanel: React.FC<RightPanelProps> = ({ isVisible, onClose, mess
<View style={styles.header}>
<View style={styles.headerLeft}>
<Computer size={16} color={styles.title.color} />
<H4 style={styles.title}>Suna's Computer</H4>
<H4 style={styles.title}>Suna&apos;s Computer</H4>
</View>
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<X size={16} color={styles.title.color} />
@ -296,7 +297,7 @@ export const RightPanel: React.FC<RightPanelProps> = ({ isVisible, onClose, mess
</View>
<Body style={styles.emptyTitle}>No tool activity</Body>
<Body style={styles.emptySubtitle}>
Tool calls and computer interactions will appear here when they're being executed.
Tool calls and computer interactions will appear here when they&apos;re being executed.
</Body>
</View>
</View>
@ -307,8 +308,7 @@ export const RightPanel: React.FC<RightPanelProps> = ({ isVisible, onClose, mess
<View style={styles.panel}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Computer size={16} color={styles.title.color} />
<H4 style={styles.title}>Suna's Computer</H4>
<H4 style={styles.title}>Suna&apos;s Computer</H4>
</View>
<View style={styles.headerActions}>
@ -330,10 +330,13 @@ export const RightPanel: React.FC<RightPanelProps> = ({ isVisible, onClose, mess
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
>
<GenericToolView
<ToolView
name={currentSnapshot?.toolCall?.functionName}
toolCall={currentSnapshot?.toolCall}
isStreaming={isGenerating}
isSuccess={true}
sandboxId={sandboxId}
messages={messages}
/>
</ScrollView>

View File

@ -0,0 +1,199 @@
import { useTheme } from '@/hooks/useThemeColor';
import { MessageSquare, Paperclip } from 'lucide-react-native';
import React from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import { FileAttachment } from '../FileAttachment';
import { Body, Caption } from '../Typography';
export interface AskToolViewProps {
toolCall?: any;
isStreaming?: boolean;
isSuccess?: boolean;
onFilePress?: (filePath: string) => void;
sandboxId?: string;
}
const extractAskData = (toolCall: any) => {
// Handle attachments - can be string, array, or object
let attachments: string[] = [];
const rawAttachments = toolCall?.parameters?.attachments ||
toolCall?.input?.attachments ||
toolCall?.arguments?.attachments ||
toolCall?.parameters?.files ||
toolCall?.input?.files ||
toolCall?.arguments?.files;
if (Array.isArray(rawAttachments)) {
// Handle array case - flat map to handle nested arrays and split comma-separated strings
attachments = rawAttachments.flatMap((item: any) => {
if (typeof item === 'string' && item.trim()) {
// Split comma-separated strings
return item.split(',').map(file => file.trim()).filter(file => file.length > 0);
}
return [];
});
} else if (typeof rawAttachments === 'string' && rawAttachments.trim()) {
// Handle single string case - split by comma in case multiple files are provided
attachments = rawAttachments.split(',').map(file => file.trim()).filter(file => file.length > 0);
} else if (rawAttachments && typeof rawAttachments === 'object') {
// Handle object case - extract values and process them
const values = Object.values(rawAttachments).filter(Boolean);
attachments = values.flatMap((item: any) => {
if (typeof item === 'string' && item.trim()) {
// Split comma-separated strings
return item.split(',').map(file => file.trim()).filter(file => file.length > 0);
}
return [];
});
}
// Remove duplicates and empty strings
attachments = [...new Set(attachments)].filter(attachment => attachment.length > 0);
const text = toolCall?.parameters?.text || toolCall?.input?.text || toolCall?.arguments?.text || '';
return {
text,
attachments,
};
};
export const AskToolView: React.FC<AskToolViewProps> = ({
toolCall,
isStreaming = false,
isSuccess = true,
onFilePress,
sandboxId,
...otherProps
}) => {
const theme = useTheme();
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.background,
},
content: {
flex: 1,
},
section: {
padding: 16,
},
sectionTitle: {
color: theme.foreground,
marginBottom: 12,
fontWeight: '600' as const,
},
attachmentsList: {
gap: 12,
},
emptyState: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyIcon: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: theme.muted + '20',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
emptyTitle: {
color: theme.foreground,
textAlign: 'center',
marginBottom: 8,
},
emptySubtitle: {
color: theme.mutedForeground,
textAlign: 'center',
lineHeight: 20,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: theme.border,
backgroundColor: theme.muted + '10',
},
footerBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
},
footerText: {
color: theme.mutedForeground,
fontSize: 12,
},
});
if (!toolCall) {
return (
<View style={styles.container}>
<View style={styles.emptyState}>
<View style={styles.emptyIcon}>
<MessageSquare size={32} color={theme.mutedForeground} />
</View>
<Body style={styles.emptyTitle}>No question selected</Body>
<Body style={styles.emptySubtitle}>
Select a question to view its details
</Body>
</View>
</View>
);
}
const { attachments } = extractAskData(toolCall);
return (
<View style={styles.container}>
<ScrollView style={styles.content}>
{Array.isArray(attachments) && attachments.length > 0 ? (
<View style={styles.section}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
<Paperclip size={16} color={theme.mutedForeground} />
<Caption style={[styles.sectionTitle, { marginLeft: 8, marginBottom: 0 }]}>
Files ({attachments.length})
</Caption>
</View>
<View style={styles.attachmentsList}>
{attachments.map((attachment: string, index: number) => (
<FileAttachment
key={index}
filepath={attachment}
sandboxId={sandboxId}
onPress={onFilePress}
showPreview={true}
layout="grid"
/>
))}
</View>
</View>
) : (
<View style={styles.emptyState}>
<View style={styles.emptyIcon}>
<MessageSquare size={32} color={theme.mutedForeground} />
</View>
<Body style={styles.emptyTitle}>Question Asked</Body>
<Body style={styles.emptySubtitle}>
No files attached to this question
</Body>
</View>
)}
</ScrollView>
</View>
);
};

View File

@ -0,0 +1,369 @@
import { useTheme } from '@/hooks/useThemeColor';
import { CheckCircle, ListChecks, Paperclip, Sparkles, Trophy } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from 'react-native-reanimated';
import { MarkdownComponent } from '../../utils/markdown-renderer';
import { FileAttachment } from '../FileAttachment';
import { Body, Caption } from '../Typography';
import { ToolViewProps } from './ToolViewRegistry';
export interface CompleteToolViewProps extends ToolViewProps {
messages?: any[]; // Add messages prop to access previous message
}
interface CompleteContent {
summary?: string;
result?: string;
tasksCompleted?: string[];
attachments?: string[];
}
const extractCompleteData = (toolCall: any, messages?: any[]): CompleteContent => {
// For complete tool, data comes from the PREVIOUS message in the conversation
// The complete tool is a termination signal that refers to the previous message
let summary = '';
let attachments: string[] = [];
let tasksCompleted: string[] = [];
if (messages && toolCall) {
// Find the message that contains this tool call
const currentMessageIndex = messages.findIndex(msg =>
msg.message_id === toolCall.messageId ||
(msg.content && msg.content.includes && msg.content.includes(toolCall.functionName))
);
if (currentMessageIndex > 0) {
// Get the previous message (which contains the actual completion content)
const previousMessage = messages[currentMessageIndex - 1];
if (previousMessage && previousMessage.type === 'assistant') {
// Extract content from previous message
summary = previousMessage.content || '';
// Clean up function calls from the content
if (typeof summary === 'string') {
summary = summary
.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, '')
.replace(/<invoke name="complete"[\s\S]*?<\/invoke>/g, '')
.trim();
}
// Parse attachments from previous message if mentioned
const attachmentMatches = summary.match(/attachments?[=:]\s*["']([^"']+)["']/gi);
if (attachmentMatches) {
attachments = attachmentMatches.flatMap(match => {
const extracted = match.match(/["']([^"']+)["']/);
return extracted ? extracted[1].split(',').map(f => f.trim()) : [];
});
}
}
}
}
// Fallback to tool parameters if no previous message found
if (!summary) {
summary = toolCall?.parameters?.summary ||
toolCall?.parameters?.text ||
toolCall?.input?.summary ||
toolCall?.input?.text ||
toolCall?.parameters?.message ||
toolCall?.input?.message || '';
}
// Parse tasks from summary if it contains bullet points
if (summary) {
const taskMatches = summary.match(/[•\-\*]\s*([^\n]+)/g);
if (taskMatches) {
tasksCompleted = taskMatches.map((task: string) =>
task.replace(/^[•\-\*]\s*/, '').trim()
);
}
}
// Remove duplicates
attachments = [...new Set(attachments)].filter(attachment => attachment.length > 0);
return {
summary: summary || '',
attachments,
tasksCompleted,
};
};
export const CompleteToolView: React.FC<CompleteToolViewProps> = ({
toolCall,
messages,
isStreaming = false,
isSuccess = true,
onFilePress,
sandboxId,
...otherProps
}) => {
const theme = useTheme();
const [progress] = useState(isStreaming ? 0 : 100);
// Success animation
const sparkleOpacity = useSharedValue(0);
const sparkleScale = useSharedValue(0.8);
useEffect(() => {
if (!isStreaming && isSuccess) {
sparkleOpacity.value = withRepeat(
withSequence(
withTiming(1, { duration: 600 }),
withTiming(0, { duration: 600 })
),
3,
false
);
sparkleScale.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 600 }),
withTiming(0.8, { duration: 600 })
),
3,
false
);
}
}, [isStreaming, isSuccess]);
const sparkleAnimatedStyle = useAnimatedStyle(() => ({
opacity: sparkleOpacity.value,
transform: [{ scale: sparkleScale.value }],
}));
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.background,
},
content: {
flex: 1,
},
section: {
padding: 16,
},
successContainer: {
alignItems: 'center',
paddingVertical: 32,
},
successIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: theme.primary + '20',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
position: 'relative',
},
sparkleIcon: {
position: 'absolute',
top: -4,
right: -4,
},
summaryContainer: {
backgroundColor: theme.muted + '20',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
sectionTitle: {
color: theme.mutedForeground,
fontWeight: '600',
marginLeft: 8,
},
taskItem: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: theme.muted + '20',
borderRadius: 8,
padding: 12,
marginBottom: 8,
},
taskIcon: {
marginRight: 12,
marginTop: 2,
},
taskContent: {
flex: 1,
},
attachmentsList: {
gap: 12,
},
emptyState: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyIcon: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: theme.muted + '20',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
emptyTitle: {
color: theme.foreground,
textAlign: 'center',
marginBottom: 8,
fontWeight: '600',
},
emptySubtitle: {
color: theme.mutedForeground,
textAlign: 'center',
lineHeight: 20,
},
progressContainer: {
marginBottom: 16,
},
progressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
progressText: {
color: theme.mutedForeground,
fontSize: 14,
},
progressBar: {
height: 4,
backgroundColor: theme.muted + '40',
borderRadius: 2,
},
progressFill: {
height: '100%',
backgroundColor: theme.primary,
borderRadius: 2,
},
});
if (!toolCall) {
return (
<View style={styles.container}>
<View style={styles.emptyState}>
<View style={styles.emptyIcon}>
<CheckCircle size={32} color={theme.mutedForeground} />
</View>
<Body style={styles.emptyTitle}>No task selected</Body>
<Body style={styles.emptySubtitle}>
Select a completed task to view its details
</Body>
</View>
</View>
);
}
const { summary, attachments, tasksCompleted } = extractCompleteData(toolCall, messages);
const hasContent = summary || (attachments && attachments.length > 0) || (tasksCompleted && tasksCompleted.length > 0);
// Don't render if no meaningful content (prevents empty duplicate rendering)
if (!hasContent && !isStreaming) {
return (
<View style={styles.container}>
<View style={styles.successContainer}>
<View style={styles.successIcon}>
<Trophy size={40} color={theme.primary} />
<Animated.View style={[styles.sparkleIcon, sparkleAnimatedStyle]}>
<Sparkles size={20} color="#FFD700" />
</Animated.View>
</View>
<Body style={styles.emptyTitle}>Task Completed!</Body>
<Body style={styles.emptySubtitle}>
Successfully finished the assigned task
</Body>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView style={styles.content}>
<View style={styles.section}>
{/* Summary Section with Markdown */}
{summary && (
<View style={styles.summaryContainer}>
<MarkdownComponent>
{summary}
</MarkdownComponent>
</View>
)}
{/* Tasks Completed Section with Markdown */}
{tasksCompleted && tasksCompleted.length > 0 && (
<View style={{ marginBottom: 16 }}>
<View style={styles.sectionHeader}>
<ListChecks size={16} color={theme.mutedForeground} />
<Caption style={styles.sectionTitle}>
Tasks Completed
</Caption>
</View>
{tasksCompleted.map((task, index) => (
<View key={index} style={styles.taskItem}>
<CheckCircle size={16} color={theme.primary} style={styles.taskIcon} />
<View style={styles.taskContent}>
<MarkdownComponent>
{task}
</MarkdownComponent>
</View>
</View>
))}
</View>
)}
{/* Attachments Section */}
{attachments && attachments.length > 0 && (
<View style={{ marginBottom: 16 }}>
<View style={styles.sectionHeader}>
<Paperclip size={16} color={theme.mutedForeground} />
<Caption style={styles.sectionTitle}>
Files ({attachments.length})
</Caption>
</View>
<View style={styles.attachmentsList}>
{attachments.map((attachment, index) => (
<FileAttachment
key={index}
filepath={attachment}
sandboxId={sandboxId}
onPress={onFilePress}
showPreview={true}
layout="grid"
/>
))}
</View>
</View>
)}
{/* Progress Section for Streaming */}
{isStreaming && (
<View style={styles.progressContainer}>
<View style={styles.progressHeader}>
<Caption style={styles.progressText}>
Completing task...
</Caption>
<Caption style={styles.progressText}>
{progress}%
</Caption>
</View>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progress}%` }]} />
</View>
</View>
)}
</View>
</ScrollView>
</View>
);
};

View File

@ -0,0 +1,123 @@
import { useTheme } from '@/hooks/useThemeColor';
import { AlertTriangle, CheckCircle, CheckCircle2, Clock, Computer, MessageCircleQuestion } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Caption, H4 } from '../Typography';
interface ToolHeaderProps {
toolName: string;
isStreaming?: boolean;
isSuccess?: boolean;
icon?: React.ComponentType<any>;
}
const getToolIcon = (toolName: string) => {
switch (toolName) {
case 'ask':
return MessageCircleQuestion;
case 'complete':
return CheckCircle2;
default:
return Computer;
}
};
const getToolDisplayName = (toolName: string) => {
switch (toolName) {
case 'ask':
return 'Ask User';
case 'complete':
return 'Task Complete';
default:
return toolName?.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Unknown Tool';
}
};
export const ToolHeader: React.FC<ToolHeaderProps> = ({
toolName,
isStreaming = false,
isSuccess = true,
icon: IconComponent
}) => {
const theme = useTheme();
const Icon = IconComponent || getToolIcon(toolName);
const displayName = getToolDisplayName(toolName);
const styles = StyleSheet.create({
header: {
backgroundColor: theme.muted + '20',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: theme.border,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: theme.primary + '20',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
title: {
color: theme.foreground,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 12,
fontWeight: '600' as const,
marginLeft: 4,
},
});
const statusColor = isStreaming
? theme.accent
: isSuccess
? theme.primary
: theme.destructive;
const statusText = isStreaming
? 'Running...'
: isSuccess
? 'Success'
: 'Failed';
const StatusIcon = isStreaming
? Clock
: isSuccess
? CheckCircle
: AlertTriangle;
return (
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.iconContainer}>
<Icon size={16} color={theme.primary} />
</View>
<H4 style={styles.title}>{displayName}</H4>
</View>
<View style={[styles.statusBadge, { backgroundColor: statusColor + '20' }]}>
<StatusIcon size={12} color={statusColor} />
<Caption style={[styles.statusText, { color: statusColor }]}>
{statusText}
</Caption>
</View>
</View>
);
};

View File

@ -0,0 +1,63 @@
import React from 'react';
import { View } from 'react-native';
import { AskToolView } from './AskToolView';
import { CompleteToolView } from './CompleteToolView';
import { GenericToolView } from './GenericToolView';
import { ToolHeader } from './ToolHeader';
export interface ToolViewProps {
name?: string;
toolCall?: any;
isStreaming?: boolean;
isSuccess?: boolean;
onFilePress?: (filePath: string) => void;
sandboxId?: string;
messages?: any[]; // Add messages prop for complete tool
// Future props can be added here
[key: string]: any;
}
export type ToolViewComponent = React.ComponentType<ToolViewProps>;
const defaultRegistry: Record<string, ToolViewComponent> = {
'ask': AskToolView,
'complete': CompleteToolView,
'default': GenericToolView,
};
class ToolViewRegistry {
private registry: Record<string, ToolViewComponent>;
constructor(initialRegistry: Record<string, ToolViewComponent> = {}) {
this.registry = { ...defaultRegistry, ...initialRegistry };
}
register(toolName: string, component: ToolViewComponent): void {
this.registry[toolName] = component;
}
get(toolName: string): ToolViewComponent {
return this.registry[toolName] || this.registry['default'];
}
has(toolName: string): boolean {
return toolName in this.registry;
}
}
export const toolViewRegistry = new ToolViewRegistry();
export function ToolView({ name = 'default', ...props }: ToolViewProps) {
const ToolViewComponent = toolViewRegistry.get(name);
return (
<View style={{ flex: 1 }}>
<ToolHeader
toolName={name}
isStreaming={props.isStreaming}
isSuccess={props.isSuccess}
/>
<ToolViewComponent name={name} {...props} />
</View>
);
}

View File

@ -3,6 +3,7 @@ import { ParsedToolCall } from '@/utils/message-parser';
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Body, Caption } from '../Typography';
import { AskToolView } from './AskToolView';
// Generic tool view for unknown tools
export const GenericToolView: React.FC<{ toolCall: ParsedToolCall }> = ({ toolCall }) => {
@ -197,6 +198,11 @@ export const WebSearchToolView: React.FC<{ toolCall: ParsedToolCall }> = ({ tool
);
};
// Ask tool view wrapper to match the registry interface
const AskToolViewWrapper: React.FC<{ toolCall: ParsedToolCall }> = ({ toolCall }) => {
return <AskToolView toolCall={toolCall} />;
};
// Tool registry - maps tool names to their view components
export const TOOL_VIEW_REGISTRY: Record<string, React.FC<{ toolCall: ParsedToolCall }>> = {
'create-file': FileToolView,
@ -206,6 +212,7 @@ export const TOOL_VIEW_REGISTRY: Record<string, React.FC<{ toolCall: ParsedToolC
'execute-command': CommandToolView,
'web-search': WebSearchToolView,
'crawl-webpage': WebSearchToolView,
'ask': AskToolViewWrapper,
};
// Main tool view component that uses the registry