react-native-ajora
Version:
The most complete AI agent UI for React Native
411 lines (410 loc) • 16.2 kB
JavaScript
import React, { useState, useEffect } from "react";
import { StyleSheet, Text, View, TouchableOpacity, ScrollView, Dimensions, } from "react-native";
import Color from "../Color";
import MaterialIcons from "@expo/vector-icons/build/MaterialIcons";
import LoadingAnimation from "../LoadingAnimation";
// Get responsive card width (90% of screen width, max 400px, min 280px)
const getCardWidth = () => {
const screenWidth = Dimensions.get("window").width;
const cardWidth = screenWidth * 0.9;
return Math.min(Math.max(cardWidth, 280), 400);
};
const TodoListTool = ({ message, request, submitQuery: _submitQuery, }) => {
const [todoData, setTodoData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [lastCompletedTodo, setLastCompletedTodo] = useState(null);
const [nextPendingTodo, setNextPendingTodo] = useState(null);
const styles = createStyles();
const { tool } = request;
const { action = "get" } = tool.args || {};
useEffect(() => {
if (request?.tool.name === "todo_list") {
// Use response data from the merged functionCall
if (request.tool.response) {
const responseData = request.tool.response;
// Handle the new response format with output and error fields
if (responseData.error) {
setError(responseData.error);
setLoading(false);
return;
}
let todoData;
if (responseData.output && Array.isArray(responseData.output)) {
// If response has output array with todo lists, use the first one
const todoList = responseData.output[0];
if (todoList) {
todoData = {
action: action,
todos: todoList.todos || [],
totalTodos: todoList.todos?.length || 0,
queueTodos: todoList.todos?.filter((todo) => todo.status === "queue")
.length || 0,
completedTodos: todoList.todos?.filter((todo) => todo.status === "completed").length || 0,
errorTodos: todoList.todos?.filter((todo) => todo.status === "error")
.length || 0,
listName: todoList.name || "Todo List",
listDescription: todoList.description || "",
};
}
}
else if (Array.isArray(responseData.output)) {
// Fallback: If output is directly an array of todo lists, use the first one
const todoList = responseData.output[0];
if (todoList) {
todoData = {
action: action,
todos: todoList.todos || [],
totalTodos: todoList.todos?.length || 0,
queueTodos: todoList.todos?.filter((todo) => todo.status === "queue")
.length || 0,
completedTodos: todoList.todos?.filter((todo) => todo.status === "completed").length || 0,
errorTodos: todoList.todos?.filter((todo) => todo.status === "error")
.length || 0,
listName: todoList.name || "Todo List",
listDescription: todoList.description || "",
};
}
}
else if (responseData.output && responseData.output.todos) {
// Fallback: If output is a single todo list object
todoData = {
action: action,
todos: responseData.output.todos || [],
totalTodos: responseData.output.todos?.length || 0,
queueTodos: responseData.output.todos?.filter((todo) => todo.status === "queue").length || 0,
completedTodos: responseData.output.todos?.filter((todo) => todo.status === "completed").length || 0,
errorTodos: responseData.output.todos?.filter((todo) => todo.status === "error").length || 0,
listName: responseData.output.name || "Todo List",
listDescription: responseData.output.description || "",
};
}
else {
// Handle error or empty response
todoData = {
action: action,
todos: [],
totalTodos: 0,
queueTodos: 0,
completedTodos: 0,
errorTodos: 0,
listName: "Todo List",
listDescription: "No todos found",
};
}
setTodoData(todoData);
setLoading(false);
setError(null);
// Track the last completed todo and next pending todo
if (todoData && todoData.todos) {
const completedTodos = todoData.todos.filter((todo) => todo.status === "completed");
const pendingTodos = todoData.todos.filter((todo) => todo.status === "queue");
// Set the last completed todo
if (completedTodos.length > 0) {
const lastCompleted = completedTodos[completedTodos.length - 1];
setLastCompletedTodo(lastCompleted);
}
else {
setLastCompletedTodo(null);
}
// Set the next pending todo (first one in queue)
if (pendingTodos.length > 0) {
setNextPendingTodo(pendingTodos[0]);
}
else {
setNextPendingTodo(null);
}
}
// Auto-collapse if there are completed todos
if (todoData && todoData.completedTodos > 0) {
setIsCollapsed(true);
}
}
else {
setLoading(true);
setError(null);
}
}
}, [request, action]);
if (!request) {
return (<View style={styles.container}>
<Text style={{
fontSize: 14,
color: Color.destructive,
textAlign: "center",
}}>
No todo list data provided
</Text>
</View>);
}
if (loading) {
return (<View style={styles.container}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>
<Text style={{ fontWeight: "bold" }}>Updating todo list</Text>...
</Text>
<LoadingAnimation containerStyle={styles.loadingAnimation}/>
</View>
</View>);
}
if (error) {
return (<View style={styles.container}>
<View style={[
styles.collapsedCard,
{ flexDirection: "row", alignItems: "center", gap: 8 },
]}>
<View style={styles.collapsedHeader}>
<MaterialIcons name="format-list-bulleted" size={20} color={Color.cardForeground}/>
</View>
<View style={styles.collapsedProgress}>
<Text style={styles.errorMessage}>
The model messed up something while updating the todo list
</Text>
</View>
</View>
</View>);
}
if (!todoData) {
return null;
}
const getStatusIcon = (status) => {
switch (status) {
case "queue":
return "radio-button-unchecked";
case "completed":
return "check-circle";
case "error":
return "error";
default:
return "help-outline";
}
};
// Collapsed UI component
const CollapsedView = () => {
return (<TouchableOpacity style={styles.collapsedCard} onPress={() => setIsCollapsed(false)} activeOpacity={0.7}>
<View style={styles.collapsedHeader}>
<MaterialIcons name="checklist" size={20} color={Color.cardForeground}/>
<Text style={styles.collapsedTitle}>
{todoData.listName || "Todo List"}
</Text>
<MaterialIcons name="expand-more" size={20} color={Color.mutedForeground}/>
</View>
<View style={styles.collapsedProgress}>
{lastCompletedTodo && (<View style={styles.todoItem}>
<View style={styles.todoHeader}>
<View style={styles.todoInfo}>
<MaterialIcons name={getStatusIcon(lastCompletedTodo.status)} size={20} color={lastCompletedTodo.status === "error"
? Color.destructive
: Color.cardForeground} style={styles.statusIcon}/>
<Text style={[
styles.todoText,
lastCompletedTodo.status === "completed" &&
styles.completedTodo,
]} numberOfLines={1}>
{lastCompletedTodo.name}
</Text>
</View>
</View>
</View>)}
{nextPendingTodo && (<View style={styles.todoItem}>
<View style={styles.todoHeader}>
<View style={styles.todoInfo}>
<MaterialIcons name={getStatusIcon(nextPendingTodo.status)} size={20} color={nextPendingTodo.status === "error"
? Color.destructive
: Color.cardForeground} style={styles.statusIcon}/>
<Text style={[
styles.todoText,
nextPendingTodo.status === "completed" &&
styles.completedTodo,
]} numberOfLines={1}>
{nextPendingTodo.name}
</Text>
</View>
</View>
</View>)}
<Text style={styles.collapsedProgressText}>
{todoData.completedTodos} of {todoData.totalTodos} Done
</Text>
</View>
</TouchableOpacity>);
};
// Expanded UI component
const ExpandedView = () => (<View style={styles.todoCard}>
<TouchableOpacity style={styles.header} onPress={() => setIsCollapsed(true)} activeOpacity={0.7}>
<MaterialIcons name="checklist" size={20} color={Color.cardForeground}/>
<Text style={styles.todoTitle}>{todoData.listName || "Todo List"}</Text>
<MaterialIcons name="expand-less" size={20} color={Color.mutedForeground}/>
</TouchableOpacity>
<ScrollView style={styles.todosContainer} showsVerticalScrollIndicator={false}>
{todoData.todos.length > 0 ? (todoData.todos.map((todo) => (<View key={todo.id} style={styles.todoItem}>
<View style={styles.todoHeader}>
<View style={styles.todoInfo}>
<MaterialIcons name={getStatusIcon(todo.status)} size={20} color={todo.status === "error"
? Color.destructive
: Color.cardForeground} style={styles.statusIcon}/>
<Text style={[
styles.todoText,
todo.status === "completed" && styles.completedTodo,
]} numberOfLines={2}>
{todo.name}
</Text>
</View>
</View>
</View>))) : (<View style={styles.emptyContainer}>
<MaterialIcons name="checklist" size={32} color={Color.mutedForeground}/>
<Text style={styles.emptyText}>No todos found</Text>
<Text style={styles.emptySubtext}>
{todoData.listDescription ||
"Create your first todo to get started"}
</Text>
</View>)}
</ScrollView>
</View>);
return (<View style={styles.container}>
{isCollapsed ? <CollapsedView /> : <ExpandedView />}
</View>);
};
TodoListTool.displayName = "todo_list";
export default TodoListTool;
const createStyles = () => {
const cardWidth = getCardWidth();
return StyleSheet.create({
container: {
marginVertical: 8,
},
todoCard: {
width: cardWidth,
backgroundColor: Color.card,
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: Color.border,
shadowColor: Color.shadow,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
gap: 8,
},
collapsedCard: {
width: cardWidth,
backgroundColor: Color.card,
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: Color.border,
shadowColor: Color.shadow,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
collapsedHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
collapsedTitle: {
fontSize: 16,
fontWeight: "600",
color: Color.cardForeground,
flex: 1,
},
collapsedProgress: {
marginTop: 8,
},
collapsedProgressText: {
fontSize: 12,
color: Color.mutedForeground,
fontWeight: "500",
fontStyle: "italic",
},
errorMessage: {
fontSize: 14,
color: Color.mutedForeground,
fontStyle: "italic",
},
todoTitle: {
fontSize: 16,
fontWeight: "600",
color: Color.cardForeground,
},
todosContainer: {
maxHeight: 300,
},
todoItem: {
paddingVertical: 6,
},
todoHeader: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 6,
},
todoInfo: {
flexDirection: "row",
alignItems: "flex-start",
flex: 1,
},
statusIcon: {
marginRight: 8,
marginTop: 2,
},
todoText: {
fontSize: 14,
color: Color.cardForeground,
flex: 1,
lineHeight: 20,
},
completedTodo: {
textDecorationLine: "line-through",
color: Color.mutedForeground,
},
emptyContainer: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 32,
},
emptyText: {
fontSize: 16,
fontWeight: "600",
color: Color.mutedForeground,
marginTop: 12,
marginBottom: 4,
},
emptySubtext: {
fontSize: 14,
color: Color.mutedForeground,
textAlign: "center",
lineHeight: 20,
},
loadingContainer: {
width: cardWidth,
gap: 10,
padding: 16,
backgroundColor: Color.muted,
borderRadius: 12,
borderWidth: 1,
borderColor: Color.border,
},
loadingText: {
fontSize: 14,
color: Color.mutedForeground,
},
loadingAnimation: {
marginTop: 8,
},
});
};
//# sourceMappingURL=TodoListTool.js.map