feat: refactor app structure and components for improved organization and functionality

- Moved components and hooks into a new directory structure under `src/app` for better organization.
- Updated the main entry point to use `index.ts` and adjusted routing accordingly.
- Introduced new components such as `ActiveProcesses`, `AgentStreamView`, and `ConnectionStatus` to enhance the app's functionality.
- Removed deprecated components and hooks to streamline the codebase.
- Updated styles to align with the new theme structure and ensure consistency across the app.

This refactor aims to improve maintainability and scalability of the application.
This commit is contained in:
Mohamed Boudra
2025-10-22 15:48:53 +02:00
parent 2d1ab5bf79
commit 89641a18e7
31 changed files with 8915 additions and 522 deletions
@@ -0,0 +1,220 @@
import { View, Text, Pressable, ScrollView } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/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
}
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.card,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
backButtonActive: {
backgroundColor: theme.colors.palette.zinc[700],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
processItemActive: {
backgroundColor: theme.colors.primary,
},
processItemInactive: {
backgroundColor: theme.colors.muted,
},
agentIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
commandIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.palette.purple[500],
},
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: '500',
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
modeIndicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
opacity: 0.3,
},
}));
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 style={styles.container}>
{hasActiveProcess && (
<View style={styles.header}>
<Pressable
onPress={onBackToOrchestrator}
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonActive]}
>
<Text style={styles.backButtonText}>Back to Chat</Text>
</Pressable>
</View>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={{ gap: 8 }}
>
{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)}
style={({ pressed }) => [
styles.processItem,
isActive ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={styles.processText}>{agent.id.substring(0, 8)}</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
{agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
)}
</Pressable>
);
})}
{commands.map((command) => {
const statusColor = command.isDead
? command.exitCode === 0
? '#22c55e'
: '#ef4444'
: '#3b82f6';
return (
<View key={`command-${command.id}`} style={[styles.processItem, styles.processItemInactive]}>
<View style={styles.commandIcon} />
<Text style={styles.processText} numberOfLines={1}>
{command.name || command.currentCommand.substring(0, 20)}
</Text>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
</View>
);
})}
</ScrollView>
</View>
);
}
@@ -0,0 +1,453 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/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 style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.messageText}>{sessionUpdate.chunk || ''}</Text>
</View>
);
}
// Handle tool calls
if (sessionUpdate.kind === 'tool_call') {
return (
<View style={stylesheet.collapsibleCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.collapsibleHeader}
>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.toolCallText}>
Tool Call: {sessionUpdate.toolName || 'unknown'}
</Text>
</Pressable>
{isExpanded && sessionUpdate.arguments && (
<View style={stylesheet.collapsibleContent}>
<Text style={stylesheet.codeText}>
{JSON.stringify(sessionUpdate.arguments, null, 2)}
</Text>
</View>
)}
</View>
);
}
// Handle tool call updates
if (sessionUpdate.kind === 'tool_call_update') {
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.updateStatusText}>
{sessionUpdate.status || 'updating'}
</Text>
</View>
);
}
// Handle available commands
if (sessionUpdate.kind === 'available_commands_update') {
const commands = sessionUpdate.commands || [];
return (
<View style={stylesheet.collapsibleCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.collapsibleHeader}
>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.commandsText}>
Available Commands ({commands.length})
</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.collapsibleContent}>
{commands.map((cmd: any, idx: number) => (
<Text key={idx} style={stylesheet.commandItem}>
• {cmd.name || cmd}
</Text>
))}
</View>
)}
</View>
);
}
// Generic session update
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.codeText}>
{JSON.stringify(sessionUpdate, null, 2)}
</Text>
</View>
);
}
// Fallback for unknown notification types
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.codeText}>
{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 style={stylesheet.container}>
{/* Header */}
<View style={[stylesheet.header, { paddingTop: insets.top + 16 }]}>
{/* Back button */}
<Pressable
onPress={onBack}
style={stylesheet.backButton}
>
<Text style={stylesheet.backButtonText}>
← Back to Chat
</Text>
</Pressable>
{/* Agent info */}
<View style={stylesheet.agentInfoRow}>
<View style={stylesheet.agentIdRow}>
<Text style={stylesheet.agentLabel}>Agent:</Text>
<Text style={stylesheet.agentId}>
{agentId.substring(0, 8)}
</Text>
</View>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(agent.status) }
]}
>
<Text style={stylesheet.statusIcon}>{getStatusIcon(agent.status)}</Text>
<Text style={stylesheet.statusText}>{agent.status}</Text>
</View>
</View>
<Text style={stylesheet.createdText}>
Created: {formatTimestamp(agent.createdAt)}
</Text>
{/* Control buttons */}
{(canCancel || canKill) && (
<View style={stylesheet.controlButtons}>
{canCancel && (
<Pressable
onPress={() => onCancelAgent(agentId)}
style={stylesheet.cancelButton}
>
<Text style={stylesheet.controlButtonText}>
Cancel
</Text>
</Pressable>
)}
{canKill && (
<Pressable
onPress={() => onKillAgent(agentId)}
style={stylesheet.killButton}
>
<Text style={stylesheet.controlButtonText}>
Kill
</Text>
</Pressable>
)}
</View>
)}
</View>
{/* Updates list */}
<ScrollView
ref={scrollViewRef}
style={stylesheet.scrollView}
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
>
{updates.length === 0 ? (
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
No updates yet.{'\n'}Waiting for agent activity...
</Text>
</View>
) : (
updates.map((update, idx) => <AgentUpdate key={idx} update={update} />)
)}
</ScrollView>
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
backgroundColor: theme.colors.card,
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
backButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[4],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
agentInfoRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
},
agentIdRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
agentId: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
statusIcon: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
statusText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
createdText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
controlButtons: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
cancelButton: {
flex: 1,
backgroundColor: theme.colors.palette.orange[600],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
killButton: {
flex: 1,
backgroundColor: theme.colors.destructive,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
controlButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
scrollView: {
flex: 1,
paddingHorizontal: theme.spacing[4],
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[12],
},
emptyStateText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
updateCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
},
collapsibleCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
collapsibleHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
collapsibleContent: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
timestampText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
messageText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
toolCallText: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
updateStatusText: {
color: theme.colors.palette.yellow[400],
fontSize: theme.fontSize.sm,
},
commandsText: {
color: theme.colors.palette.green[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
commandItem: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
codeText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontFamily: "monospace",
marginTop: theme.spacing[2],
},
}));
@@ -0,0 +1,266 @@
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useEffect } from "react";
import { StyleSheet } from "react-native-unistyles";
export interface Artifact {
id: string;
type: "markdown" | "diff" | "image" | "code";
title: string;
content: string;
isBase64: boolean;
}
interface ArtifactDrawerProps {
artifact: Artifact | null;
onClose: () => void;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
flexDirection: "column",
},
header: {
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
titleContainer: {
flex: 1,
marginRight: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
badge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
badgeMarkdown: {
backgroundColor: theme.colors.primary,
},
badgeDiff: {
backgroundColor: theme.colors.palette.purple[600],
},
badgeImage: {
backgroundColor: theme.colors.palette.green[600],
},
badgeCode: {
backgroundColor: theme.colors.palette.orange[600],
},
badgeText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
backgroundColor: theme.colors.muted,
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
closeButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
contentScroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
contentScrollContainer: {
padding: theme.spacing[4],
flexGrow: 1,
},
imagePlaceholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
imagePlaceholderText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
imagePlaceholderSubtext: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[2],
},
codeContainer: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
codeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
metadataContainer: {
backgroundColor: theme.colors.card,
padding: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
marginTop: theme.spacing[2],
},
metadataTitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
},
metadataRow: {
flexDirection: "row",
marginBottom: theme.spacing[1],
},
metadataLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
width: 80,
},
metadataValue: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
flex: 1,
fontFamily: "monospace",
},
}));
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 style mapping
const typeBadgeStyles = {
markdown: styles.badgeMarkdown,
diff: styles.badgeDiff,
image: styles.badgeImage,
code: styles.badgeCode,
};
return (
<Modal
visible={!!artifact}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView edges={["top", "bottom"]} style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerRow}>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView
style={styles.contentScroll}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
)}
</ScrollView>
{/* Metadata - Fixed at bottom */}
<View style={styles.metadataContainer}>
<Text style={styles.metadataTitle}>METADATA</Text>
<View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>ID:</Text>
<Text style={styles.metadataValue}>{artifact.id}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Type:</Text>
<Text style={styles.metadataValue}>{artifact.type}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Encoding:</Text>
<Text style={styles.metadataValue}>
{artifact.isBase64 ? "Base64" : "Plain text"}
</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
</View>
</View>
</View>
</SafeAreaView>
</Modal>
);
}
@@ -0,0 +1,53 @@
import { View, Text } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
interface ConnectionStatusProps {
isConnected: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
row: {
flexDirection: 'row',
alignItems: 'center',
},
dot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
marginRight: theme.spacing[2],
},
dotConnected: {
backgroundColor: theme.colors.palette.green[500],
},
dotDisconnected: {
backgroundColor: theme.colors.destructive,
},
text: {
fontSize: theme.fontSize.sm,
},
textConnected: {
color: theme.colors.palette.green[500],
},
textDisconnected: {
color: theme.colors.destructive,
},
}));
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? 'Connected' : 'Disconnected'}
</Text>
</View>
</View>
);
}
@@ -0,0 +1,408 @@
import { useState, useEffect } from 'react';
import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StyleSheet } from 'react-native-unistyles';
import type { UseWebSocketReturn } from '../hooks/use-websocket';
const STORAGE_KEY = '@voice-dev: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) => {
if (message.type !== 'list_conversations_response') return;
setConversations(message.payload.conversations);
setIsLoading(false);
});
return unsubscribe;
}, [websocket]);
// Listen for delete conversation responses
useEffect(() => {
const unsubscribe = websocket.on('delete_conversation_response', (message) => {
if (message.type !== 'delete_conversation_response') return;
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)}
style={styles.triggerButton}
>
<MessageSquare size={20} color="white" />
</Pressable>
<Modal
visible={isOpen}
animationType="slide"
transparent={true}
onRequestClose={() => setIsOpen(false)}
>
<View style={styles.modalOverlay}>
<Pressable
style={styles.modalBackdrop}
onPress={() => setIsOpen(false)}
/>
<View style={styles.modalContent}>
<View style={styles.modalInner}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>
Conversations {conversations.length > 0 && `(${conversations.length})`}
</Text>
<Pressable onPress={() => setIsOpen(false)}>
<X size={24} color="white" />
</Pressable>
</View>
{/* New Conversation Button */}
<View style={styles.newButtonContainer}>
<Pressable
onPress={handleNewConversation}
style={styles.newButton}
>
<Plus size={20} color="white" />
<Text style={styles.newButtonText}>New Conversation</Text>
</Pressable>
</View>
{/* Conversations List */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{isLoading && (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
)}
{!isLoading && conversations.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No saved conversations</Text>
</View>
)}
{!isLoading && conversations.map((conversation) => (
<View
key={conversation.id}
style={[
styles.conversationItem,
conversation.id === currentConversationId && styles.conversationItemActive
]}
>
<Pressable
style={styles.conversationContent}
onPress={() => handleSelectConversation(conversation.id)}
>
<Text style={styles.conversationTitle}>
{conversation.messageCount} messages
</Text>
<Text style={styles.conversationDate}>
{formatDate(conversation.lastUpdated)}
</Text>
</Pressable>
<Pressable
onPress={() => handleDeleteConversation(conversation.id)}
style={styles.deleteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Trash2 size={20} color="#ef4444" />
</Pressable>
</View>
))}
{!isLoading && conversations.length > 0 && (
<View style={styles.clearAllContainer}>
<Pressable
onPress={handleClearAll}
style={styles.clearAllButton}
>
<Trash2 size={18} color="#ef4444" />
<Text style={styles.clearAllText}>Clear All</Text>
</Pressable>
</View>
)}
</ScrollView>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
triggerButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalBackdrop: {
flex: 1,
},
modalContent: {
backgroundColor: theme.colors.card,
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
height: '80%',
},
modalInner: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[6],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
newButtonContainer: {
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
newButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[2],
backgroundColor: theme.colors.muted,
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4] * 5,
},
loadingContainer: {
padding: theme.spacing[8],
alignItems: 'center',
},
loadingText: {
color: theme.colors.mutedForeground,
},
emptyContainer: {
padding: theme.spacing[8],
alignItems: 'center',
},
emptyText: {
color: theme.colors.mutedForeground,
},
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
backgroundColor: 'transparent',
},
conversationItemActive: {
backgroundColor: theme.colors.muted,
},
conversationContent: {
flex: 1,
},
conversationTitle: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
conversationDate: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
deleteButton: {
padding: theme.spacing[2],
},
clearAllContainer: {
padding: theme.spacing[4],
},
clearAllButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[2],
backgroundColor: 'rgba(127, 29, 29, 0.2)',
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.red[900],
},
clearAllText: {
color: theme.colors.destructive,
fontWeight: theme.fontWeight.semibold,
},
}));
+546
View File
@@ -0,0 +1,546 @@
import { View, Text, Pressable, Animated } from 'react-native';
import { useState, useEffect, useRef } from 'react';
import Markdown from 'react-native-markdown-display';
import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native';
import { StyleSheet } from 'react-native-unistyles';
interface UserMessageProps {
message: string;
timestamp: number;
}
const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
bubble: {
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
maxWidth: '80%',
},
text: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.lg,
lineHeight: 24,
},
}));
export function UserMessage({ message, timestamp }: UserMessageProps) {
return (
<View style={userMessageStylesheet.container}>
<View style={userMessageStylesheet.bubble}>
<Text style={userMessageStylesheet.text}>{message}</Text>
</View>
</View>
);
}
interface AssistantMessageProps {
message: string;
timestamp: number;
isStreaming?: boolean;
}
const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
streamingIndicator: {
marginTop: theme.spacing[1],
},
streamingText: {
color: theme.colors.palette.teal[200],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.bold,
},
}));
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
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]);
const markdownStyles = {
body: {
color: '#f0fdfa',
fontSize: 16,
lineHeight: 24,
},
paragraph: {
marginTop: 0,
marginBottom: 8,
},
strong: {
fontWeight: '700' as const,
},
em: {
fontStyle: 'italic' as const,
},
code_inline: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 3,
fontFamily: 'monospace',
},
code_block: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
padding: 12,
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 14,
},
fence: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
padding: 12,
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 14,
},
link: {
color: '#5eead4',
textDecorationLine: 'underline' as const,
},
bullet_list: {
marginBottom: 8,
},
ordered_list: {
marginBottom: 8,
},
list_item: {
marginBottom: 4,
},
};
return (
<View style={assistantMessageStylesheet.container}>
<Markdown style={markdownStyles}>{message}</Markdown>
{isStreaming && (
<Animated.View style={[assistantMessageStylesheet.streamingIndicator, { opacity: fadeAnim }]}>
<Text style={assistantMessageStylesheet.streamingText}>...</Text>
</Animated.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;
}
const activityLogStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderRadius: theme.borderRadius.md,
overflow: 'hidden',
},
pressableActive: {
opacity: 0.7,
},
systemBg: {
backgroundColor: 'rgba(39, 39, 42, 0.5)',
},
infoBg: {
backgroundColor: 'rgba(30, 58, 138, 0.3)',
},
successBg: {
backgroundColor: 'rgba(20, 83, 45, 0.3)',
},
errorBg: {
backgroundColor: 'rgba(127, 29, 29, 0.3)',
},
artifactBg: {
backgroundColor: 'rgba(30, 58, 138, 0.4)',
},
content: {
paddingHorizontal: theme.spacing[3],
paddingVertical: 10,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: theme.spacing[2],
},
iconContainer: {
flexShrink: 0,
},
textContainer: {
flex: 1,
},
messageText: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
detailsRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: theme.spacing[1],
},
detailsText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginRight: theme.spacing[1],
},
metadataContainer: {
marginTop: theme.spacing[2],
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: theme.borderRadius.base,
padding: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
metadataText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontFamily: 'monospace',
lineHeight: 16,
},
}));
export function ActivityLog({
type,
message,
timestamp,
metadata,
artifactId,
artifactType,
title,
onArtifactClick
}: ActivityLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
system: { bg: activityLogStylesheet.systemBg, color: '#a1a1aa', Icon: Circle },
info: { bg: activityLogStylesheet.infoBg, color: '#60a5fa', Icon: Info },
success: { bg: activityLogStylesheet.successBg, color: '#4ade80', Icon: CheckCircle },
error: { bg: activityLogStylesheet.errorBg, color: '#f87171', Icon: XCircle },
artifact: { bg: activityLogStylesheet.artifactBg, color: '#93c5fd', Icon: FileText },
};
const config = typeConfig[type];
const IconComponent = config.Icon;
const handlePress = () => {
if (type === 'artifact' && artifactId && onArtifactClick) {
onArtifactClick(artifactId);
} else if (metadata) {
setIsExpanded(!isExpanded);
}
};
const displayMessage = type === 'artifact' && artifactType && title
? `${artifactType}: ${title}`
: message;
const isInteractive = type === 'artifact' || metadata;
return (
<Pressable
onPress={handlePress}
disabled={!isInteractive}
style={[
activityLogStylesheet.pressable,
config.bg,
isInteractive && activityLogStylesheet.pressableActive,
]}
>
<View style={activityLogStylesheet.content}>
<View style={activityLogStylesheet.row}>
<View style={activityLogStylesheet.iconContainer}>
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
<View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text>
{isExpanded ? (
<ChevronDown size={12} color="#71717a" />
) : (
<ChevronRight size={12} color="#71717a" />
)}
</View>
)}
</View>
</View>
{isExpanded && metadata && (
<View style={activityLogStylesheet.metadataContainer}>
<Text style={activityLogStylesheet.metadataText}>
{JSON.stringify(metadata, null, 2)}
</Text>
</View>
)}
</View>
</Pressable>
);
}
interface ToolCallProps {
toolName: string;
args: any;
result?: any;
error?: any;
status: 'executing' | 'completed' | 'failed';
}
const toolCallStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
overflow: 'hidden',
},
pressableActive: {
opacity: 0.8,
},
executingBorder: {
borderColor: theme.colors.palette.amber[500],
},
completedBorder: {
borderColor: theme.colors.palette.green[500],
},
failedBorder: {
borderColor: theme.colors.destructive,
},
content: {
padding: theme.spacing[3],
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
},
chevronContainer: {
marginRight: theme.spacing[2],
},
toolName: {
color: theme.colors.foreground,
fontFamily: 'monospace',
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.sm,
flex: 1,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
executingBadgeBg: {
backgroundColor: 'rgba(245, 158, 11, 0.2)',
},
completedBadgeBg: {
backgroundColor: 'rgba(34, 197, 94, 0.2)',
},
failedBadgeBg: {
backgroundColor: 'rgba(239, 68, 68, 0.2)',
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: 'uppercase',
},
expandedContent: {
marginTop: theme.spacing[3],
gap: theme.spacing[2],
},
section: {
// empty - just for grouping
},
sectionTitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
errorSectionTitle: {
color: theme.colors.palette.red[300],
},
sectionContent: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.base,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[2],
},
errorSectionContent: {
borderColor: theme.colors.palette.red[800],
},
sectionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontFamily: 'monospace',
lineHeight: 16,
},
}));
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: toolCallStylesheet.executingBorder,
badgeBg: toolCallStylesheet.executingBadgeBg,
color: '#fcd34d',
label: 'executing',
},
completed: {
border: toolCallStylesheet.completedBorder,
badgeBg: toolCallStylesheet.completedBadgeBg,
color: '#86efac',
label: 'completed',
},
failed: {
border: toolCallStylesheet.failedBorder,
badgeBg: toolCallStylesheet.failedBadgeBg,
color: '#fca5a5',
label: 'failed',
},
};
const config = statusConfig[status];
return (
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={[toolCallStylesheet.pressable, toolCallStylesheet.pressableActive, config.border]}
>
<View style={toolCallStylesheet.content}>
<View style={toolCallStylesheet.headerRow}>
<View style={toolCallStylesheet.chevronContainer}>
{isExpanded ? (
<ChevronDown size={16} color="#9ca3af" />
) : (
<ChevronRight size={16} color="#9ca3af" />
)}
</View>
<Text style={toolCallStylesheet.toolName}>
{toolName}
</Text>
<View style={[toolCallStylesheet.statusBadge, config.badgeBg]}>
{status === 'executing' ? (
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={14} color={config.color} />
</Animated.View>
) : status === 'completed' ? (
<CheckCircle size={14} color={config.color} />
) : (
<XCircle size={14} color={config.color} />
)}
<Text style={[toolCallStylesheet.statusText, { color: config.color }]}>
{config.label}
</Text>
</View>
</View>
{isExpanded && (
<View style={toolCallStylesheet.expandedContent}>
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
Arguments
</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(args, null, 2)}
</Text>
</View>
</View>
{result !== undefined && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
Result
</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(result, null, 2)}
</Text>
</View>
</View>
)}
{error !== undefined && (
<View style={toolCallStylesheet.section}>
<Text style={[toolCallStylesheet.sectionTitle, toolCallStylesheet.errorSectionTitle]}>
Error
</Text>
<View style={[toolCallStylesheet.sectionContent, toolCallStylesheet.errorSectionContent]}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(error, null, 2)}
</Text>
</View>
</View>
)}
</View>
)}
</View>
</Pressable>
);
}
@@ -0,0 +1,224 @@
import { Pressable, View, Text, Animated } from 'react-native';
import { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native-unistyles';
interface VoiceButtonProps {
state: 'idle' | 'recording' | 'processing' | 'playing';
onPress: () => void;
disabled?: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
alignItems: 'center',
gap: theme.spacing[4],
},
pressable: {
opacity: 1,
},
pressableDisabled: {
opacity: 0.5,
},
button: {
width: 80,
height: 80,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
buttonIdle: {
backgroundColor: theme.colors.muted,
},
buttonRecording: {
backgroundColor: theme.colors.destructive,
},
buttonProcessing: {
backgroundColor: theme.colors.primary,
},
buttonPlaying: {
backgroundColor: theme.colors.palette.green[500],
},
label: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
// Recording icon
recordingIcon: {
width: 24,
height: 24,
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.md,
},
// Processing icon
processingIconContainer: {
width: 24,
height: 24,
},
processingDot: {
width: 6,
height: 6,
backgroundColor: theme.colors.primaryForeground,
borderRadius: theme.borderRadius.full,
position: 'absolute',
},
processingDotTop: {
top: 0,
left: 12,
},
processingDotRight: {
top: 12,
right: 0,
},
processingDotBottom: {
bottom: 0,
left: 12,
},
// Playing icon
playingIconContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
playingBar: {
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.sm,
},
playingBar1: {
width: 4,
height: 16,
},
playingBar2: {
width: 4,
height: 24,
},
playingBar3: {
width: 4,
height: 12,
},
// Idle icon (microphone)
micContainer: {
width: 24,
height: 32,
position: 'relative',
},
micCapsule: {
position: 'absolute',
bottom: 0,
left: 4,
width: 16,
height: 24,
backgroundColor: theme.colors.foreground,
borderTopLeftRadius: 999,
borderTopRightRadius: 999,
},
micBase: {
position: 'absolute',
bottom: 0,
left: 0,
width: 24,
height: 6,
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.full,
},
}));
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 styles.buttonRecording;
case 'processing':
return styles.buttonProcessing;
case 'playing':
return styles.buttonPlaying;
default:
return styles.buttonIdle;
}
};
const getIcon = () => {
switch (state) {
case 'recording':
return <View style={styles.recordingIcon} />;
case 'processing':
return (
<View style={styles.processingIconContainer}>
<View style={[styles.processingDot, styles.processingDotTop]} />
<View style={[styles.processingDot, styles.processingDotRight]} />
<View style={[styles.processingDot, styles.processingDotBottom]} />
</View>
);
case 'playing':
return (
<View style={styles.playingIconContainer}>
<View style={[styles.playingBar, styles.playingBar1]} />
<View style={[styles.playingBar, styles.playingBar2]} />
<View style={[styles.playingBar, styles.playingBar3]} />
</View>
);
default:
return (
<View style={styles.micContainer}>
<View style={styles.micCapsule} />
<View style={styles.micBase} />
</View>
);
}
};
const getLabel = () => {
switch (state) {
case 'recording':
return 'Recording...';
case 'processing':
return 'Processing...';
case 'playing':
return 'Playing...';
default:
return 'Tap to speak';
}
};
return (
<View style={styles.container}>
<Pressable
onPress={onPress}
disabled={disabled}
style={disabled ? styles.pressableDisabled : styles.pressable}
>
<Animated.View style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}>
{getIcon()}
</Animated.View>
</Pressable>
<Text style={styles.label}>{getLabel()}</Text>
</View>
);
}