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:
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user