Skip to content

Commit 169d400

Browse files
committed
Chat UI - first draft with components and keyboard-controller
1 parent ab6f439 commit 169d400

File tree

12 files changed

+1370
-794
lines changed

12 files changed

+1370
-794
lines changed

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"nativewind": "^4.2.1",
3232
"react": "19.1.0",
3333
"react-native": "0.81.5",
34+
"react-native-keyboard-controller": "1.18.5",
3435
"react-native-reanimated": "~4.1.1",
3536
"react-native-safe-area-context": "~5.6.2",
3637
"react-native-screens": "~4.16.0",

apps/mobile/src/app/_layout.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Stack } from "expo-router";
55
import { StatusBar } from "expo-status-bar";
66
import { useEffect } from "react";
77
import { ActivityIndicator, View } from "react-native";
8+
import { KeyboardProvider } from "react-native-keyboard-controller";
89
import { SafeAreaProvider } from "react-native-safe-area-context";
910
import { useAuthStore } from "../stores/authStore";
1011

@@ -50,12 +51,14 @@ function RootLayoutNav() {
5051
export default function RootLayout() {
5152
return (
5253
<SafeAreaProvider>
53-
<QueryClientProvider client={queryClient}>
54-
<View className="flex-1 bg-dark-bg">
55-
<RootLayoutNav />
56-
<StatusBar style="light" />
57-
</View>
58-
</QueryClientProvider>
54+
<KeyboardProvider>
55+
<QueryClientProvider client={queryClient}>
56+
<View className="flex-1 bg-dark-bg">
57+
<RootLayoutNav />
58+
<StatusBar style="light" />
59+
</View>
60+
</QueryClientProvider>
61+
</KeyboardProvider>
5962
</SafeAreaProvider>
6063
);
6164
}

apps/mobile/src/app/chat.tsx

Lines changed: 61 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,82 @@
11
import { Stack } from "expo-router";
2-
import { useState } from "react";
3-
import {
4-
KeyboardAvoidingView,
5-
Platform,
6-
ScrollView,
7-
Text,
8-
TextInput,
9-
TouchableOpacity,
10-
View,
11-
} from "react-native";
2+
import { useCallback } from "react";
3+
import { Text, TouchableOpacity, View } from "react-native";
4+
import Animated, { useAnimatedStyle } from "react-native-reanimated";
5+
import { useSafeAreaInsets } from "react-native-safe-area-context";
6+
import { ChatInput } from "../components/ChatInput";
7+
import { MessagesList } from "../components/MessagesList";
8+
import { useGradualAnimation } from "../hooks/useGradualAnimation";
129
import { useMaxStore } from "../stores/maxStore";
1310

1411
export default function ChatScreen() {
15-
const [inputText, setInputText] = useState("");
16-
const {
17-
thread,
18-
conversation,
19-
streamingActive,
20-
askMax,
21-
stopGeneration,
22-
resetThread,
23-
} = useMaxStore();
12+
const insets = useSafeAreaInsets();
13+
const { thread, streamingActive, askMax, stopGeneration, resetThread } =
14+
useMaxStore();
2415

25-
const handleSend = async () => {
26-
const trimmed = inputText.trim();
27-
if (!trimmed) return;
28-
setInputText("");
29-
await askMax(trimmed);
30-
};
16+
const handleSend = useCallback(
17+
async (message: string) => {
18+
await askMax(message);
19+
},
20+
[askMax],
21+
);
22+
23+
const headerRight = useCallback(() => {
24+
if (streamingActive) {
25+
return (
26+
<TouchableOpacity onPress={stopGeneration} className="px-2">
27+
<Text className="font-medium text-red-500">Stop</Text>
28+
</TouchableOpacity>
29+
);
30+
}
31+
if (thread.length > 0) {
32+
return (
33+
<TouchableOpacity onPress={resetThread} className="px-2">
34+
<Text className="font-medium text-orange-500">New</Text>
35+
</TouchableOpacity>
36+
);
37+
}
38+
return null;
39+
}, [streamingActive, thread.length, stopGeneration, resetThread]);
40+
41+
const { height } = useGradualAnimation();
42+
43+
const contentPosition = useAnimatedStyle(() => {
44+
return {
45+
transform: [{ translateY: -height.value }],
46+
};
47+
}, []);
3148

3249
return (
3350
<>
3451
<Stack.Screen
3552
options={{
3653
headerShown: true,
3754
headerTitle: "Chat",
55+
headerBackTitle: "Back",
3856
headerStyle: { backgroundColor: "#09090b" },
3957
headerTintColor: "#fff",
40-
headerRight: streamingActive
41-
? () => (
42-
<TouchableOpacity onPress={stopGeneration}>
43-
<Text className="text-red-500">Stop</Text>
44-
</TouchableOpacity>
45-
)
46-
: undefined,
58+
headerTitleStyle: {
59+
fontWeight: "600",
60+
},
61+
headerRight,
4762
}}
4863
/>
49-
<KeyboardAvoidingView
50-
behavior={Platform.OS === "ios" ? "padding" : "height"}
51-
className="flex-1 bg-black"
52-
keyboardVerticalOffset={100}
53-
>
54-
{/* JSON Output */}
55-
<ScrollView className="flex-1 p-4">
56-
{/* Conversation metadata */}
57-
{conversation && (
58-
<View className="mb-4">
59-
<Text className="mb-1 font-bold text-green-400">
60-
Conversation:
61-
</Text>
62-
<Text className="font-mono text-green-300 text-xs">
63-
{JSON.stringify(conversation, null, 2)}
64-
</Text>
65-
</View>
66-
)}
67-
68-
{/* Status */}
69-
<View className="mb-4">
70-
<Text className="text-gray-400">
71-
Streaming: {streamingActive ? "true" : "false"}
72-
</Text>
73-
<Text className="text-gray-400">Messages: {thread.length}</Text>
74-
</View>
75-
76-
{/* Messages */}
77-
{thread.map((message, index) => (
78-
<View key={message.id || `msg-${index}`} className="mb-4">
79-
<Text className="mb-1 font-bold text-yellow-400">
80-
[{index}] {message.type} ({message.status})
81-
</Text>
82-
<Text className="font-mono text-white text-xs">
83-
{JSON.stringify(message, null, 2)}
84-
</Text>
85-
</View>
86-
))}
87-
88-
{thread.length === 0 && !streamingActive && (
89-
<Text className="text-center text-gray-500">
90-
Send a message to start
91-
</Text>
92-
)}
93-
94-
{thread.length > 0 && !streamingActive && (
95-
<TouchableOpacity onPress={resetThread} className="mt-4 py-2">
96-
<Text className="text-center text-blue-500 underline">
97-
Start a new chat
98-
</Text>
99-
</TouchableOpacity>
100-
)}
101-
</ScrollView>
64+
<Animated.View className="flex-1 bg-dark-bg" style={[contentPosition]}>
65+
<MessagesList
66+
messages={thread}
67+
streamingActive={streamingActive}
68+
contentContainerStyle={{
69+
paddingTop: 80 + insets.bottom,
70+
paddingBottom: 16,
71+
flexGrow: thread.length === 0 ? 1 : undefined,
72+
}}
73+
/>
10274

103-
{/* Input */}
104-
<View className="flex-row items-center gap-2 border-gray-800 border-t p-4">
105-
<TextInput
106-
className="flex-1 rounded-lg bg-gray-800 px-4 py-3 text-white"
107-
placeholder="Type a message..."
108-
placeholderTextColor="#666"
109-
value={inputText}
110-
onChangeText={setInputText}
111-
onSubmitEditing={handleSend}
112-
editable={!streamingActive}
113-
/>
114-
<TouchableOpacity
115-
onPress={handleSend}
116-
disabled={!inputText.trim() || streamingActive}
117-
className={`rounded-lg px-4 py-3 ${
118-
inputText.trim() && !streamingActive
119-
? "bg-blue-600"
120-
: "bg-gray-700"
121-
}`}
122-
>
123-
<Text className="text-white">Send</Text>
124-
</TouchableOpacity>
75+
{/* Fixed input at bottom */}
76+
<View className="absolute inset-x-0 bottom-0">
77+
<ChatInput onSend={handleSend} disabled={streamingActive} />
12578
</View>
126-
</KeyboardAvoidingView>
79+
</Animated.View>
12780
</>
12881
);
12982
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ActivityIndicator, Text, View } from "react-native";
2+
3+
interface AIMessageProps {
4+
content: string;
5+
isLoading?: boolean;
6+
thinkingText?: string;
7+
}
8+
9+
export function AIMessage({
10+
content,
11+
isLoading,
12+
thinkingText,
13+
}: AIMessageProps) {
14+
return (
15+
<View className="items-start px-4 py-2">
16+
<View className="max-w-[85%] rounded-2xl rounded-bl-md bg-dark-surface px-4 py-3">
17+
{isLoading && !content ? (
18+
<View className="flex-row items-center gap-2">
19+
<ActivityIndicator size="small" color="#a3a3a3" />
20+
<Text className="text-base text-dark-text-muted italic">
21+
{thinkingText || "Thinking..."}
22+
</Text>
23+
</View>
24+
) : (
25+
<Text className="text-base text-dark-text leading-6">{content}</Text>
26+
)}
27+
</View>
28+
</View>
29+
);
30+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState } from "react";
2+
import { TextInput, TextStyle, TouchableOpacity, View } from "react-native";
3+
import { useSafeAreaInsets } from "react-native-safe-area-context";
4+
import { Text } from "./text";
5+
6+
const TEXT_INPUT_STYLE: TextStyle = {
7+
maxHeight: 120,
8+
};
9+
10+
interface ChatInputProps {
11+
onSend: (message: string) => void;
12+
disabled?: boolean;
13+
placeholder?: string;
14+
}
15+
16+
export function ChatInput({
17+
onSend,
18+
disabled = false,
19+
placeholder = "Message",
20+
}: ChatInputProps) {
21+
const insets = useSafeAreaInsets();
22+
const [message, setMessage] = useState("");
23+
24+
const handleSend = () => {
25+
const trimmed = message.trim();
26+
if (!trimmed || disabled) return;
27+
onSend(trimmed);
28+
setMessage("");
29+
};
30+
31+
const canSend = message.trim().length > 0 && !disabled;
32+
33+
return (
34+
<View
35+
style={{
36+
backgroundColor: "#0a0a0a",
37+
paddingBottom: insets.bottom,
38+
borderTopWidth: 0.5,
39+
borderTopColor: "rgba(255, 255, 255, 0.1)",
40+
}}
41+
>
42+
<View className="flex-row items-end gap-2 px-4 py-2">
43+
{/* Plus button */}
44+
<TouchableOpacity
45+
className="mb-0.5 h-9 w-9 items-center justify-center rounded-full bg-dark-surface"
46+
activeOpacity={0.7}
47+
>
48+
<Text className="text-dark-text-muted text-xl">+</Text>
49+
</TouchableOpacity>
50+
51+
{/* Text input */}
52+
<View className="relative flex-1">
53+
<TextInput
54+
placeholder={placeholder}
55+
style={TEXT_INPUT_STYLE}
56+
className="min-h-[36px] flex-1 rounded-[18px] border border-dark-border bg-dark-surface px-4 py-2 pr-10 text-base text-dark-text"
57+
placeholderTextColor="#6b6b6b"
58+
editable={!disabled}
59+
multiline
60+
onChangeText={setMessage}
61+
value={message}
62+
onSubmitEditing={handleSend}
63+
blurOnSubmit={false}
64+
/>
65+
66+
{/* Send / Mic button */}
67+
<View className="absolute right-1 bottom-1">
68+
{canSend ? (
69+
<TouchableOpacity
70+
onPress={handleSend}
71+
className="h-7 w-7 items-center justify-center rounded-full bg-orange-500"
72+
activeOpacity={0.7}
73+
>
74+
<Text className="font-bold text-sm text-white"></Text>
75+
</TouchableOpacity>
76+
) : (
77+
<TouchableOpacity
78+
className="h-7 w-7 items-center justify-center rounded-full opacity-50"
79+
activeOpacity={0.7}
80+
>
81+
<Text className="text-base text-dark-text-muted">🎤</Text>
82+
</TouchableOpacity>
83+
)}
84+
</View>
85+
</View>
86+
</View>
87+
</View>
88+
);
89+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Text, View } from "react-native";
2+
3+
interface FailureMessageProps {
4+
content?: string;
5+
}
6+
7+
export function FailureMessage({ content }: FailureMessageProps) {
8+
return (
9+
<View className="items-start px-4 py-2">
10+
<View className="max-w-[85%] rounded-2xl rounded-bl-md bg-red-900/30 px-4 py-3">
11+
<Text className="text-base text-red-300 leading-6">
12+
{content || "Something went wrong. Please try again."}
13+
</Text>
14+
</View>
15+
</View>
16+
);
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Text, View } from "react-native";
2+
3+
interface HumanMessageProps {
4+
content: string;
5+
}
6+
7+
export function HumanMessage({ content }: HumanMessageProps) {
8+
return (
9+
<View className="items-end px-4 py-2">
10+
<View className="max-w-[85%] rounded-2xl rounded-br-md bg-orange-500 px-4 py-3">
11+
<Text className="text-base text-white leading-6">{content}</Text>
12+
</View>
13+
</View>
14+
);
15+
}

0 commit comments

Comments
 (0)