Prompt is too long

This commit is contained in:
Mohamed Boudra
2025-10-21 18:15:05 +02:00
parent 795c10e3c9
commit 85e4e9ddaa
39 changed files with 8955 additions and 696 deletions
@@ -0,0 +1,169 @@
import { View, Text, Pressable, ScrollView } from 'react-native';
import type { AgentStatus } from '@voice-assistant/server/acp/types';
export interface ActiveProcessesProps {
agents: Array<{
id: string;
status: AgentStatus;
type: 'claude';
currentModeId?: string;
availableModes?: Array<{ id: string; name: string; description?: string | null }>;
}>;
commands: Array<{
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
}>;
activeProcessId: string | null;
activeProcessType: 'agent' | null;
onSelectAgent: (id: string) => void;
onBackToOrchestrator: () => void;
}
function getAgentStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b'; // orange
case 'ready':
return '#3b82f6'; // blue
case 'processing':
return '#fbbf24'; // yellow
case 'completed':
return '#22c55e'; // green
case 'failed':
return '#ef4444'; // red
case 'killed':
return '#9ca3af'; // gray
default:
return '#9ca3af';
}
}
function getModeName(modeId?: string, availableModes?: Array<{ id: string; name: string }>): string {
if (!modeId) return 'unknown';
const mode = availableModes?.find((m) => m.id === modeId);
return mode?.name || modeId;
}
function getModeColor(modeId?: string): string {
if (!modeId) return '#9ca3af'; // gray
// Color based on common mode types
if (modeId.includes('ask')) return '#f59e0b'; // orange - asks permission
if (modeId.includes('code')) return '#22c55e'; // green - writes code
if (modeId.includes('architect') || modeId.includes('plan')) return '#3b82f6'; // blue - plans
return '#9ca3af'; // gray - unknown
}
export function ActiveProcesses({
agents,
commands,
activeProcessId,
activeProcessType,
onSelectAgent,
onBackToOrchestrator,
}: ActiveProcessesProps) {
const hasActiveProcess = activeProcessId !== null && activeProcessType !== null;
if (agents.length === 0 && commands.length === 0) {
return null;
}
return (
<View className="bg-zinc-900 border-b border-zinc-800">
{/* Header with Back button */}
{hasActiveProcess && (
<View className="px-4 py-3 border-b border-zinc-800">
<Pressable
onPress={onBackToOrchestrator}
className="bg-zinc-800 px-4 py-2 rounded-lg active:bg-zinc-700"
>
<Text className="text-white text-sm font-semibold text-center">
Back to Chat
</Text>
</Pressable>
</View>
)}
{/* Scrollable process list */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-4 py-3"
contentContainerStyle={{ gap: 8 }}
>
{/* Agents */}
{agents.map((agent) => {
const modeName = getModeName(agent.currentModeId, agent.availableModes);
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
return (
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.id)}
className={`flex-row items-center gap-2 px-3 py-2 rounded-lg ${
isActive ? 'bg-blue-600' : 'bg-zinc-800'
} active:opacity-70`}
>
{/* Agent icon */}
<View className="w-3 h-3 rounded-full bg-blue-500" />
{/* Agent ID (shortened) */}
<Text className="text-white text-xs font-medium">
{agent.id.substring(0, 8)}
</Text>
{/* Status indicator */}
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getAgentStatusColor(agent.status) }}
/>
{/* Mode indicator */}
{agent.currentModeId && (
<View
className="w-1.5 h-1.5 rounded-full opacity-30"
style={{ backgroundColor: getModeColor(agent.currentModeId) }}
/>
)}
</Pressable>
);
})}
{/* Commands */}
{commands.map((command) => {
const statusColor = command.isDead
? command.exitCode === 0
? '#22c55e'
: '#ef4444'
: '#3b82f6';
return (
<View
key={`command-${command.id}`}
className="flex-row items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800"
>
{/* Command icon */}
<View className="w-3 h-3 rounded-sm bg-purple-500" />
{/* Command name */}
<Text className="text-white text-xs font-medium" numberOfLines={1}>
{command.name || command.currentCommand.substring(0, 20)}
</Text>
{/* Status indicator */}
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
</View>
);
})}
</ScrollView>
</View>
);
}
@@ -0,0 +1,286 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AgentStatus } from '@voice-assistant/server/acp/types';
import type { SessionNotification } from '@agentclientprotocol/sdk';
export interface AgentStreamViewProps {
agentId: string;
agent: {
id: string;
status: AgentStatus;
createdAt: Date;
type: 'claude';
};
updates: Array<{
timestamp: Date;
notification: SessionNotification;
}>;
onBack: () => void;
onKillAgent: (agentId: string) => void;
onCancelAgent: (agentId: string) => void;
}
function getStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b';
case 'ready':
return '#3b82f6';
case 'processing':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case 'killed':
return '#9ca3af';
default:
return '#9ca3af';
}
}
function getStatusIcon(status: AgentStatus): string {
switch (status) {
case 'initializing':
case 'processing':
return '⏳';
case 'ready':
case 'completed':
return '✓';
case 'failed':
case 'killed':
return '✗';
default:
return '•';
}
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
}).format(date);
}
function AgentUpdate({
update,
}: {
update: { timestamp: Date; notification: SessionNotification };
}) {
const [isExpanded, setIsExpanded] = useState(true);
const notification = update.notification as any;
// Parse different notification types
if (notification.type === 'sessionUpdate') {
const sessionUpdate = notification.sessionUpdate || {};
// Handle message chunks
if (sessionUpdate.kind === 'agent_message_chunk') {
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-white text-sm">{sessionUpdate.chunk || ''}</Text>
</View>
);
}
// Handle tool calls
if (sessionUpdate.kind === 'tool_call') {
return (
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className="px-3 py-2 active:bg-zinc-700"
>
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-blue-400 text-sm font-medium">
Tool Call: {sessionUpdate.toolName || 'unknown'}
</Text>
</Pressable>
{isExpanded && sessionUpdate.arguments && (
<View className="px-3 pb-3 border-t border-zinc-700">
<Text className="text-zinc-400 text-xs font-mono mt-2">
{JSON.stringify(sessionUpdate.arguments, null, 2)}
</Text>
</View>
)}
</View>
);
}
// Handle tool call updates
if (sessionUpdate.kind === 'tool_call_update') {
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-yellow-400 text-sm">
{sessionUpdate.status || 'updating'}
</Text>
</View>
);
}
// Handle available commands
if (sessionUpdate.kind === 'available_commands_update') {
const commands = sessionUpdate.commands || [];
return (
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className="px-3 py-2 active:bg-zinc-700"
>
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-green-400 text-sm font-medium">
Available Commands ({commands.length})
</Text>
</Pressable>
{isExpanded && (
<View className="px-3 pb-3 border-t border-zinc-700">
{commands.map((cmd: any, idx: number) => (
<Text key={idx} className="text-zinc-400 text-xs mt-1">
• {cmd.name || cmd}
</Text>
))}
</View>
)}
</View>
);
}
// Generic session update
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-zinc-400 text-xs font-mono">
{JSON.stringify(sessionUpdate, null, 2)}
</Text>
</View>
);
}
// Fallback for unknown notification types
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-zinc-400 text-xs font-mono">
{JSON.stringify(notification, null, 2)}
</Text>
</View>
);
}
export function AgentStreamView({
agentId,
agent,
updates,
onBack,
onKillAgent,
onCancelAgent,
}: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null);
const insets = useSafeAreaInsets();
// Auto-scroll to bottom when new updates arrive
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [updates]);
const canCancel = agent.status === 'processing';
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
return (
<View className="flex-1 bg-black">
{/* Header */}
<View className="bg-zinc-900 border-b border-zinc-800 px-4 pb-4" style={{ paddingTop: insets.top + 16 }}>
{/* Back button */}
<Pressable
onPress={onBack}
className="bg-zinc-800 px-4 py-2 rounded-lg mb-4 active:bg-zinc-700"
>
<Text className="text-white text-sm font-semibold text-center">
← Back to Chat
</Text>
</Pressable>
{/* Agent info */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
<Text className="text-zinc-400 text-sm">Agent:</Text>
<Text className="text-white text-sm font-mono">
{agentId.substring(0, 8)}
</Text>
</View>
<View
className="flex-row items-center gap-2 px-3 py-1 rounded-full"
style={{ backgroundColor: getStatusColor(agent.status) }}
>
<Text className="text-white text-xs">{getStatusIcon(agent.status)}</Text>
<Text className="text-white text-xs font-medium">{agent.status}</Text>
</View>
</View>
<Text className="text-zinc-500 text-xs">
Created: {formatTimestamp(agent.createdAt)}
</Text>
{/* Control buttons */}
{(canCancel || canKill) && (
<View className="flex-row gap-2 mt-4">
{canCancel && (
<Pressable
onPress={() => onCancelAgent(agentId)}
className="flex-1 bg-orange-600 px-4 py-2 rounded-lg active:bg-orange-700"
>
<Text className="text-white text-sm font-semibold text-center">
Cancel
</Text>
</Pressable>
)}
{canKill && (
<Pressable
onPress={() => onKillAgent(agentId)}
className="flex-1 bg-red-600 px-4 py-2 rounded-lg active:bg-red-700"
>
<Text className="text-white text-sm font-semibold text-center">
Kill
</Text>
</Pressable>
)}
</View>
)}
</View>
{/* Updates list */}
<ScrollView
ref={scrollViewRef}
className="flex-1 px-4"
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
>
{updates.length === 0 ? (
<View className="flex-1 items-center justify-center py-12">
<Text className="text-zinc-500 text-sm text-center">
No updates yet.{'\n'}Waiting for agent activity...
</Text>
</View>
) : (
updates.map((update, idx) => <AgentUpdate key={idx} update={update} />)
)}
</ScrollView>
</View>
);
}
@@ -0,0 +1,126 @@
import { View, Text, ScrollView, Pressable, Modal } from 'react-native';
import { useEffect } from 'react';
export interface Artifact {
id: string;
type: 'markdown' | 'diff' | 'image' | 'code';
title: string;
content: string;
isBase64: boolean;
}
interface ArtifactDrawerProps {
artifact: Artifact | null;
onClose: () => void;
}
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
useEffect(() => {
if (!artifact) return;
console.log('[ArtifactDrawer] Showing artifact:', artifact.id, artifact.type, artifact.title);
}, [artifact]);
if (!artifact) {
return null;
}
// Decode content if base64
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
// Type badge colors
const typeColors = {
markdown: 'bg-blue-600',
diff: 'bg-purple-600',
image: 'bg-green-600',
code: 'bg-orange-600',
};
return (
<Modal
visible={!!artifact}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View className="flex-1 bg-black">
{/* Header */}
<View className="pt-16 pb-4 px-4 border-b border-zinc-800">
<View className="flex-row items-center justify-between">
<View className="flex-1 mr-4">
<Text className="text-white text-xl font-bold" numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View className="flex-row items-center gap-2">
<View className={`${typeColors[artifact.type]} px-3 py-1 rounded-full`}>
<Text className="text-white text-xs font-semibold uppercase">
{artifact.type}
</Text>
</View>
<Pressable
onPress={onClose}
className="bg-zinc-800 w-10 h-10 rounded-full items-center justify-center"
>
<Text className="text-white text-xl font-bold">×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView className="flex-1" contentContainerClassName="p-4">
{artifact.type === 'image' ? (
<View className="items-center justify-center">
<Text className="text-zinc-400 text-sm">
Image viewing not yet implemented
</Text>
<Text className="text-zinc-600 text-xs mt-2">
Base64 image data received
</Text>
</View>
) : (
<View className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<Text
className="text-zinc-300 text-sm font-mono"
style={{ fontFamily: 'monospace' }}
>
{content}
</Text>
</ScrollView>
</View>
)}
{/* Metadata */}
<View className="mt-4 bg-zinc-900 rounded-lg p-3 border border-zinc-800">
<Text className="text-zinc-500 text-xs font-semibold mb-2">METADATA</Text>
<View className="space-y-1">
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">ID:</Text>
<Text className="text-zinc-400 text-xs flex-1 font-mono">
{artifact.id}
</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Type:</Text>
<Text className="text-zinc-400 text-xs">{artifact.type}</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Encoding:</Text>
<Text className="text-zinc-400 text-xs">
{artifact.isBase64 ? 'Base64' : 'Plain text'}
</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Size:</Text>
<Text className="text-zinc-400 text-xs">
{content.length.toLocaleString()} characters
</Text>
</View>
</View>
</View>
</ScrollView>
</View>
</Modal>
);
}
@@ -0,0 +1,18 @@
import { View, Text } from 'react-native';
interface ConnectionStatusProps {
isConnected: boolean;
}
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View className="px-4 py-3 border-b border-zinc-800">
<View className="flex-row items-center">
<View className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<Text className={isConnected ? 'text-green-500 text-sm' : 'text-red-500 text-sm'}>
{isConnected ? 'Connected' : 'Disconnected'}
</Text>
</View>
</View>
);
}
@@ -0,0 +1,270 @@
import { useState, useEffect } from 'react';
import { Modal, View, Text, Pressable, ScrollView, Alert } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { UseWebSocketReturn } from '../hooks/use-websocket';
const STORAGE_KEY = '@voice-assistant:conversation-id';
interface Conversation {
id: string;
lastUpdated: string;
messageCount: number;
}
interface ConversationSelectorProps {
currentConversationId: string | null;
onSelectConversation: (conversationId: string | null) => void;
websocket: UseWebSocketReturn;
}
export function ConversationSelector({
currentConversationId,
onSelectConversation,
websocket,
}: ConversationSelectorProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Listen for conversation list responses
useEffect(() => {
const unsubscribe = websocket.on('list_conversations_response', (message) => {
console.log('[ConversationSelector] Received conversations:', message.payload.conversations.length);
setConversations(message.payload.conversations);
setIsLoading(false);
});
return unsubscribe;
}, [websocket]);
// Listen for delete conversation responses
useEffect(() => {
const unsubscribe = websocket.on('delete_conversation_response', (message) => {
console.log('[ConversationSelector] Delete response:', message.payload);
if (message.payload.success) {
// Refresh conversations list
fetchConversations();
// If we deleted the current conversation, start a new one
if (message.payload.conversationId === currentConversationId) {
handleNewConversation();
}
} else {
Alert.alert('Error', `Failed to delete conversation: ${message.payload.error}`);
}
});
return unsubscribe;
}, [websocket, currentConversationId]);
// Fetch conversations when modal opens
useEffect(() => {
if (isOpen) {
fetchConversations();
}
}, [isOpen]);
function fetchConversations() {
setIsLoading(true);
console.log('[ConversationSelector] Requesting conversations via WebSocket');
websocket.send({
type: 'session',
message: {
type: 'list_conversations_request',
},
});
}
function handleDeleteConversation(id: string) {
Alert.alert(
'Delete Conversation',
'Are you sure you want to delete this conversation?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
console.log('[ConversationSelector] Deleting conversation via WebSocket:', id);
websocket.send({
type: 'session',
message: {
type: 'delete_conversation_request',
conversationId: id,
},
});
},
},
]
);
}
function handleClearAll() {
Alert.alert(
'Clear All Conversations',
'Are you sure you want to delete all conversations?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear All',
style: 'destructive',
onPress: () => {
console.log('[ConversationSelector] Clearing all conversations via WebSocket');
// Delete all conversations
conversations.forEach((conv) => {
websocket.send({
type: 'session',
message: {
type: 'delete_conversation_request',
conversationId: conv.id,
},
});
});
// Clear local state
setConversations([]);
// Start new conversation
handleNewConversation();
setIsOpen(false);
},
},
]
);
}
async function handleSelectConversation(id: string) {
try {
// Save to AsyncStorage for persistence
await AsyncStorage.setItem(STORAGE_KEY, id);
onSelectConversation(id);
setIsOpen(false);
} catch (error) {
console.error('[ConversationSelector] Failed to save conversation ID:', error);
}
}
async function handleNewConversation() {
try {
// Clear saved conversation ID
await AsyncStorage.removeItem(STORAGE_KEY);
onSelectConversation(null);
setIsOpen(false);
} catch (error) {
console.error('[ConversationSelector] Failed to clear conversation ID:', error);
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
return (
<View>
<Pressable
onPress={() => setIsOpen(true)}
className="bg-zinc-800 px-4 py-2 rounded-lg"
>
<MaterialIcons name="chat-bubble-outline" size={20} color="white" />
</Pressable>
<Modal
visible={isOpen}
animationType="slide"
transparent={true}
onRequestClose={() => setIsOpen(false)}
>
<View className="flex-1 bg-black/50">
<Pressable
className="flex-1"
onPress={() => setIsOpen(false)}
/>
<View className="bg-zinc-900 rounded-t-3xl max-h-[80%]">
{/* Header */}
<View className="flex-row items-center justify-between p-6 border-b border-zinc-800">
<Text className="text-white text-lg font-semibold">Conversations</Text>
<Pressable onPress={() => setIsOpen(false)}>
<MaterialIcons name="close" size={24} color="white" />
</Pressable>
</View>
{/* New Conversation Button */}
<View className="p-4 border-b border-zinc-800">
<Pressable
onPress={handleNewConversation}
className="flex-row items-center justify-center gap-2 bg-zinc-800 py-3 rounded-lg"
>
<MaterialIcons name="add" size={20} color="white" />
<Text className="text-white font-semibold">New Conversation</Text>
</Pressable>
</View>
{/* Conversations List */}
<ScrollView className="flex-1">
{isLoading ? (
<View className="p-8 items-center">
<Text className="text-zinc-400">Loading...</Text>
</View>
) : conversations.length > 0 ? (
<>
{conversations.map((conversation) => (
<Pressable
key={conversation.id}
onPress={() => handleSelectConversation(conversation.id)}
className={`flex-row items-center justify-between p-4 border-b border-zinc-800 ${
conversation.id === currentConversationId ? 'bg-zinc-800' : ''
}`}
>
<View className="flex-1">
<Text className="text-white font-semibold">
{conversation.messageCount} messages
</Text>
<Text className="text-zinc-400 text-sm mt-1">
{formatDate(conversation.lastUpdated)}
</Text>
</View>
<Pressable
onPress={() => handleDeleteConversation(conversation.id)}
className="p-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons name="delete-outline" size={20} color="#ef4444" />
</Pressable>
</Pressable>
))}
{/* Clear All Button */}
<View className="p-4">
<Pressable
onPress={handleClearAll}
className="flex-row items-center justify-center gap-2 bg-red-900/20 py-3 rounded-lg border border-red-900"
>
<MaterialIcons name="delete-outline" size={18} color="#ef4444" />
<Text className="text-red-500 font-semibold">Clear All</Text>
</Pressable>
</View>
</>
) : (
<View className="p-8 items-center">
<Text className="text-zinc-400">No saved conversations</Text>
</View>
)}
</ScrollView>
</View>
</View>
</Modal>
</View>
);
}
@@ -1,25 +0,0 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
@@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
@@ -1,19 +0,0 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}
@@ -0,0 +1,288 @@
import { View, Text, Pressable, Animated } from 'react-native';
import { useState, useEffect, useRef } from 'react';
interface UserMessageProps {
message: string;
timestamp: number;
}
export function UserMessage({ message, timestamp }: UserMessageProps) {
const time = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<View className="flex-row justify-end mb-3 px-4">
<View className="bg-blue-600 rounded-2xl rounded-tr-sm px-4 py-3 max-w-[80%]">
<Text className="text-white text-base leading-5">{message}</Text>
<Text className="text-blue-200 text-xs mt-1">{time}</Text>
</View>
</View>
);
}
interface AssistantMessageProps {
message: string;
timestamp: number;
isStreaming?: boolean;
}
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
const time = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const fadeAnim = useRef(new Animated.Value(0.3)).current;
useEffect(() => {
if (isStreaming) {
Animated.loop(
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0.3,
duration: 600,
useNativeDriver: true,
}),
])
).start();
} else {
fadeAnim.stopAnimation();
fadeAnim.setValue(1);
}
}, [isStreaming, fadeAnim]);
return (
<View className="flex-row justify-start mb-3 px-4">
<View className="bg-teal-800 rounded-2xl rounded-tl-sm px-4 py-3 max-w-[80%]">
<Text className="text-teal-50 text-base leading-5">{message}</Text>
<View className="flex-row items-center justify-between mt-1">
<Text className="text-teal-200 text-xs">{time}</Text>
{isStreaming && (
<Animated.View style={{ opacity: fadeAnim }}>
<Text className="text-teal-200 text-xs ml-2 font-bold">...</Text>
</Animated.View>
)}
</View>
</View>
</View>
);
}
interface ActivityLogProps {
type: 'system' | 'info' | 'success' | 'error' | 'artifact';
message: string;
timestamp: number;
metadata?: Record<string, unknown>;
artifactId?: string;
artifactType?: string;
title?: string;
onArtifactClick?: (artifactId: string) => void;
}
export function ActivityLog({
type,
message,
timestamp,
metadata,
artifactId,
artifactType,
title,
onArtifactClick
}: ActivityLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
system: { bg: 'bg-zinc-800/50', text: 'text-zinc-400', icon: '●' },
info: { bg: 'bg-blue-900/30', text: 'text-blue-400', icon: 'i' },
success: { bg: 'bg-green-900/30', text: 'text-green-400', icon: '✓' },
error: { bg: 'bg-red-900/30', text: 'text-red-400', icon: '✗' },
artifact: { bg: 'bg-blue-900/40', text: 'text-blue-300', icon: '📋' },
};
const config = typeConfig[type];
const handlePress = () => {
if (type === 'artifact' && artifactId && onArtifactClick) {
onArtifactClick(artifactId);
} else if (metadata) {
setIsExpanded(!isExpanded);
}
};
const displayMessage = type === 'artifact' && artifactType && title
? `${artifactType}: ${title}`
: message;
return (
<Pressable
onPress={handlePress}
disabled={type !== 'artifact' && !metadata}
className={`mx-2 mb-1 rounded-md overflow-hidden ${config.bg} ${
(type === 'artifact' || metadata) ? 'active:opacity-70' : ''
}`}
>
<View className="px-3 py-2.5">
<View className="flex-row items-start gap-2">
<Text className={`${config.text} text-sm leading-5`}>
{config.icon}
</Text>
<View className="flex-1">
<Text className={`${config.text} text-sm leading-5`}>
{displayMessage}
</Text>
{metadata && (
<View className="flex-row items-center mt-1">
<Text className="text-zinc-500 text-xs">Details</Text>
<Text className="text-zinc-500 text-xs ml-1">
{isExpanded ? '▼' : '▶'}
</Text>
</View>
)}
</View>
</View>
{isExpanded && metadata && (
<View className="mt-2 bg-black/50 rounded p-2 border border-zinc-700">
<Text className="text-zinc-300 text-xs font-mono leading-4">
{JSON.stringify(metadata, null, 2)}
</Text>
</View>
)}
</View>
</Pressable>
);
}
interface ToolCallProps {
toolName: string;
args: any;
result?: any;
error?: any;
status: 'executing' | 'completed' | 'failed';
}
export function ToolCall({ toolName, args, result, error, status }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (status === 'executing') {
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
})
).start();
} else {
spinAnim.stopAnimation();
spinAnim.setValue(0);
}
}, [status, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const statusConfig = {
executing: {
border: 'border-amber-500',
bg: 'bg-amber-500/20',
text: 'text-amber-300',
icon: '⟳',
label: 'executing',
},
completed: {
border: 'border-green-500',
bg: 'bg-green-500/20',
text: 'text-green-300',
icon: '✓',
label: 'completed',
},
failed: {
border: 'border-red-500',
bg: 'bg-red-500/20',
text: 'text-red-300',
icon: '✗',
label: 'failed',
},
};
const config = statusConfig[status];
return (
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className={`mx-2 mb-2 bg-zinc-900 rounded-lg border ${config.border} overflow-hidden active:opacity-80`}
>
<View className="p-3">
<View className="flex-row items-center">
<Text className="text-zinc-400 text-sm mr-2">
{isExpanded ? '▼' : '▶'}
</Text>
<Text className="text-slate-200 font-mono font-medium text-sm flex-1">
{toolName}
</Text>
<View className={`flex-row items-center gap-1.5 px-2 py-1 rounded ${config.bg}`}>
{status === 'executing' ? (
<Animated.Text
style={{ transform: [{ rotate: spin }] }}
className={`${config.text} text-xs`}
>
{config.icon}
</Animated.Text>
) : (
<Text className={`${config.text} text-xs`}>
{config.icon}
</Text>
)}
<Text className={`${config.text} text-xs font-medium uppercase`}>
{config.label}
</Text>
</View>
</View>
{isExpanded && (
<View className="mt-3 gap-2">
<View>
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
Arguments
</Text>
<View className="bg-black rounded border border-zinc-700 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
{JSON.stringify(args, null, 2)}
</Text>
</View>
</View>
{result !== undefined && (
<View>
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
Result
</Text>
<View className="bg-black rounded border border-zinc-700 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
{JSON.stringify(result, null, 2)}
</Text>
</View>
</View>
)}
{error !== undefined && (
<View>
<Text className="text-red-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
Error
</Text>
<View className="bg-black rounded border border-red-800 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
{JSON.stringify(error, null, 2)}
</Text>
</View>
</View>
)}
</View>
)}
</View>
</Pressable>
);
}
@@ -1,79 +0,0 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
@@ -1,60 +0,0 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
@@ -1,14 +0,0 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
@@ -1,45 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
@@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
@@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}
@@ -0,0 +1,109 @@
import { Pressable, View, Text, Animated } from 'react-native';
import { useEffect, useRef } from 'react';
interface VoiceButtonProps {
state: 'idle' | 'recording' | 'processing' | 'playing';
onPress: () => void;
disabled?: boolean;
}
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (state === 'recording') {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
).start();
} else {
pulseAnim.setValue(1);
}
}, [state, pulseAnim]);
const getButtonStyle = () => {
switch (state) {
case 'recording':
return 'bg-red-500';
case 'processing':
return 'bg-blue-500';
case 'playing':
return 'bg-green-500';
default:
return 'bg-zinc-700';
}
};
const getIcon = () => {
switch (state) {
case 'recording':
return (
<View className="w-6 h-6 bg-white rounded" />
);
case 'processing':
return (
<View className="w-6 h-6">
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-0 left-3" />
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-3 right-0" />
<View className="w-1.5 h-1.5 bg-white rounded-full absolute bottom-0 left-3" />
</View>
);
case 'playing':
return (
<View className="flex-row items-center gap-1">
<View className="w-1 h-4 bg-white rounded" />
<View className="w-1 h-6 bg-white rounded" />
<View className="w-1 h-3 bg-white rounded" />
</View>
);
default:
return (
<View className="w-6 h-8 relative">
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-6 bg-white rounded-t-full" />
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-1.5 bg-white rounded-full" />
</View>
);
}
};
const getLabel = () => {
switch (state) {
case 'recording':
return 'Recording...';
case 'processing':
return 'Processing...';
case 'playing':
return 'Playing...';
default:
return 'Tap to speak';
}
};
return (
<View className="items-center gap-4">
<Pressable
onPress={onPress}
disabled={disabled}
className={`${disabled ? 'opacity-50' : 'opacity-100'}`}
>
<Animated.View
style={{ transform: [{ scale: pulseAnim }] }}
className={`w-20 h-20 rounded-full ${getButtonStyle()} items-center justify-center shadow-lg`}
>
{getIcon()}
</Animated.View>
</Pressable>
<Text className="text-zinc-400 text-sm">{getLabel()}</Text>
</View>
);
}