Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

AskAIScreen.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

AskAIScreen.tsxBlame494 lines · 1 contributor
9a8fbedClaude1import React, { useCallback, useRef, useState } from 'react';
2import {
3 FlatList,
4 KeyboardAvoidingView,
5 Platform,
6 StyleSheet,
7 Text,
8 TextInput,
9 TouchableOpacity,
10 View,
11 ActivityIndicator,
12} from 'react-native';
13import { SafeAreaView } from 'react-native-safe-area-context';
14import Markdown from 'react-native-markdown-display';
15import { postJSON } from '../api/client';
16import { colors } from '../theme/colors';
17
18interface ChatMessage {
19 id: string;
20 role: 'user' | 'assistant';
21 content: string;
22}
23
24interface AiChatResponse {
25 message: string;
26 chatId?: string;
27}
28
29const markdownStyles = {
30 body: {
31 color: colors.text,
32 fontSize: 14,
33 lineHeight: 22,
34 },
35 code_inline: {
36 backgroundColor: colors.bgTertiary,
37 color: colors.accentPurple,
38 fontFamily: 'monospace',
39 paddingHorizontal: 4,
40 borderRadius: 3,
41 fontSize: 13,
42 },
43 fence: {
44 backgroundColor: colors.bgTertiary,
45 borderRadius: 6,
46 padding: 12,
47 marginVertical: 8,
48 },
49 code_block: {
50 backgroundColor: colors.bgTertiary,
51 borderRadius: 6,
52 padding: 12,
53 fontFamily: 'monospace',
54 fontSize: 12,
55 },
56 link: {
57 color: colors.textLink,
58 },
59 blockquote: {
60 borderLeftColor: colors.accentPurple,
61 borderLeftWidth: 3,
62 paddingLeft: 10,
63 color: colors.textMuted,
64 },
65 heading1: {
66 color: colors.text,
67 fontWeight: '700' as const,
68 fontSize: 18,
69 },
70 heading2: {
71 color: colors.text,
72 fontWeight: '700' as const,
73 fontSize: 16,
74 },
75 heading3: {
76 color: colors.text,
77 fontWeight: '600' as const,
78 fontSize: 15,
79 },
80};
81
82function MessageBubble({ message }: { message: ChatMessage }): React.ReactElement {
83 const isUser = message.role === 'user';
84
85 return (
86 <View style={[styles.messageBubbleWrapper, isUser ? styles.userWrapper : styles.aiWrapper]}>
87 {!isUser && (
88 <View style={styles.aiAvatar}>
89 <Text style={styles.aiAvatarText}>✦</Text>
90 </View>
91 )}
92 <View style={[styles.bubble, isUser ? styles.userBubble : styles.aiBubble]}>
93 {isUser ? (
94 <Text style={styles.userText}>{message.content}</Text>
95 ) : (
96 <Markdown style={markdownStyles}>{message.content}</Markdown>
97 )}
98 </View>
99 </View>
100 );
101}
102
103export function AskAIScreen(): React.ReactElement {
104 const [messages, setMessages] = useState<ChatMessage[]>([]);
105 const [input, setInput] = useState('');
106 const [isLoading, setIsLoading] = useState(false);
107 const [error, setError] = useState<string | null>(null);
108 const [chatId, setChatId] = useState<string | undefined>(undefined);
109 const listRef = useRef<FlatList<ChatMessage>>(null);
110 const idCounter = useRef(0);
111
112 const nextId = useCallback(() => {
113 idCounter.current += 1;
114 return String(idCounter.current);
115 }, []);
116
117 const handleSend = useCallback(async () => {
118 const text = input.trim();
119 if (!text || isLoading) return;
120
121 setError(null);
122 setInput('');
123
124 const userMessage: ChatMessage = {
125 id: nextId(),
126 role: 'user',
127 content: text,
128 };
129
130 setMessages((prev) => [...prev, userMessage]);
131 setIsLoading(true);
132
133 // Scroll to bottom
134 setTimeout(() => {
135 listRef.current?.scrollToEnd({ animated: true });
136 }, 100);
137
138 try {
139 const response = await postJSON<AiChatResponse>('/ask', {
140 message: text,
141 chatId,
142 });
143
144 if (response.chatId !== undefined) {
145 setChatId(response.chatId);
146 }
147
148 const aiMessage: ChatMessage = {
149 id: nextId(),
150 role: 'assistant',
151 content: response.message,
152 };
153
154 setMessages((prev) => [...prev, aiMessage]);
155
156 setTimeout(() => {
157 listRef.current?.scrollToEnd({ animated: true });
158 }, 100);
159 } catch (err) {
160 setError(err instanceof Error ? err.message : 'AI request failed');
161 // Put user message back in input on failure
162 setInput(text);
163 setMessages((prev) => prev.filter((m) => m.id !== userMessage.id));
164 } finally {
165 setIsLoading(false);
166 }
167 }, [input, isLoading, chatId, nextId]);
168
169 const handleClear = useCallback(() => {
170 setMessages([]);
171 setChatId(undefined);
172 setError(null);
173 }, []);
174
175 const renderMessage = useCallback(
176 ({ item }: { item: ChatMessage }) => <MessageBubble message={item} />,
177 [],
178 );
179
180 const keyExtractor = useCallback((item: ChatMessage) => item.id, []);
181
182 return (
183 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
184 {/* Header */}
185 <View style={styles.header}>
186 <View style={styles.headerLeft}>
187 <Text style={styles.headerIcon}>✦</Text>
188 <Text style={styles.headerTitle}>Ask AI</Text>
189 </View>
190 {messages.length > 0 && (
191 <TouchableOpacity onPress={handleClear}>
192 <Text style={styles.clearText}>New chat</Text>
193 </TouchableOpacity>
194 )}
195 </View>
196
197 <KeyboardAvoidingView
198 style={styles.flex}
199 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
200 keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
201 >
202 {/* Messages */}
203 {messages.length === 0 ? (
204 <View style={styles.emptyState}>
205 <Text style={styles.emptyIcon}>✦</Text>
206 <Text style={styles.emptyTitle}>Ask anything about your code</Text>
207 <Text style={styles.emptySubtitle}>
208 Ask about repositories, code reviews, gate failures, or anything Gluecron-related.
209 </Text>
210 <View style={styles.suggestions}>
211 {[
212 'Why did my gate check fail?',
213 'Review my latest pull request',
214 'Summarize recent changes',
215 ].map((s) => (
216 <TouchableOpacity
217 key={s}
218 style={styles.suggestion}
219 onPress={() => setInput(s)}
220 activeOpacity={0.8}
221 >
222 <Text style={styles.suggestionText}>{s}</Text>
223 </TouchableOpacity>
224 ))}
225 </View>
226 </View>
227 ) : (
228 <FlatList
229 ref={listRef}
230 data={messages}
231 keyExtractor={keyExtractor}
232 renderItem={renderMessage}
233 style={styles.messageList}
234 contentContainerStyle={styles.messageListContent}
235 onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })}
236 />
237 )}
238
239 {/* Loading indicator */}
240 {isLoading && (
241 <View style={styles.thinkingRow}>
242 <View style={styles.aiAvatar}>
243 <Text style={styles.aiAvatarText}>✦</Text>
244 </View>
245 <View style={styles.thinkingBubble}>
246 <ActivityIndicator size="small" color={colors.accentPurple} />
247 <Text style={styles.thinkingText}>Thinking...</Text>
248 </View>
249 </View>
250 )}
251
252 {/* Error */}
253 {error !== null && (
254 <View style={styles.errorRow}>
255 <Text style={styles.errorText}>{error}</Text>
256 </View>
257 )}
258
259 {/* Input area */}
260 <View style={styles.inputArea}>
261 <TextInput
262 style={styles.input}
263 value={input}
264 onChangeText={setInput}
265 placeholder="Ask about your code..."
266 placeholderTextColor={colors.textMuted}
267 multiline
268 maxLength={4096}
269 returnKeyType="send"
270 onSubmitEditing={handleSend}
271 blurOnSubmit={false}
272 />
273 <TouchableOpacity
274 style={[styles.sendButton, (!input.trim() || isLoading) && styles.sendButtonDisabled]}
275 onPress={handleSend}
276 disabled={!input.trim() || isLoading}
277 activeOpacity={0.8}
278 >
279 <Text style={styles.sendIcon}>↑</Text>
280 </TouchableOpacity>
281 </View>
282 </KeyboardAvoidingView>
283 </SafeAreaView>
284 );
285}
286
287const styles = StyleSheet.create({
288 safe: {
289 flex: 1,
290 backgroundColor: colors.bg,
291 },
292 flex: {
293 flex: 1,
294 },
295 header: {
296 flexDirection: 'row',
297 alignItems: 'center',
298 justifyContent: 'space-between',
299 paddingHorizontal: 16,
300 paddingVertical: 14,
301 backgroundColor: colors.bgSecondary,
302 borderBottomWidth: 1,
303 borderBottomColor: colors.border,
304 },
305 headerLeft: {
306 flexDirection: 'row',
307 alignItems: 'center',
308 gap: 8,
309 },
310 headerIcon: {
311 fontSize: 18,
312 color: colors.accentPurple,
313 },
314 headerTitle: {
315 fontSize: 18,
316 fontWeight: '700',
317 color: colors.accentPurple,
318 },
319 clearText: {
320 fontSize: 13,
321 color: colors.textLink,
322 },
323 emptyState: {
324 flex: 1,
325 alignItems: 'center',
326 justifyContent: 'center',
327 padding: 32,
328 gap: 12,
329 },
330 emptyIcon: {
331 fontSize: 48,
332 color: colors.accentPurple,
333 },
334 emptyTitle: {
335 fontSize: 18,
336 fontWeight: '700',
337 color: colors.text,
338 textAlign: 'center',
339 },
340 emptySubtitle: {
341 fontSize: 14,
342 color: colors.textMuted,
343 textAlign: 'center',
344 lineHeight: 20,
345 },
346 suggestions: {
347 width: '100%',
348 gap: 8,
349 marginTop: 8,
350 },
351 suggestion: {
352 paddingHorizontal: 16,
353 paddingVertical: 12,
354 backgroundColor: colors.bgSecondary,
355 borderRadius: 10,
356 borderWidth: 1,
357 borderColor: colors.border,
358 },
359 suggestionText: {
360 fontSize: 14,
361 color: colors.textLink,
362 textAlign: 'center',
363 },
364 messageList: {
365 flex: 1,
366 },
367 messageListContent: {
368 paddingVertical: 16,
369 paddingHorizontal: 12,
370 gap: 12,
371 },
372 messageBubbleWrapper: {
373 flexDirection: 'row',
374 alignItems: 'flex-end',
375 gap: 8,
376 },
377 userWrapper: {
378 justifyContent: 'flex-end',
379 },
380 aiWrapper: {
381 justifyContent: 'flex-start',
382 },
383 aiAvatar: {
384 width: 28,
385 height: 28,
386 borderRadius: 14,
387 backgroundColor: colors.accentPurple + '33',
388 borderWidth: 1,
389 borderColor: colors.accentPurple,
390 alignItems: 'center',
391 justifyContent: 'center',
392 flexShrink: 0,
393 },
394 aiAvatarText: {
395 fontSize: 13,
396 color: colors.accentPurple,
397 fontWeight: '700',
398 },
399 bubble: {
400 maxWidth: '80%',
401 borderRadius: 14,
402 paddingHorizontal: 14,
403 paddingVertical: 10,
404 },
405 userBubble: {
406 backgroundColor: colors.accentBlue,
407 borderBottomRightRadius: 4,
408 },
409 aiBubble: {
410 backgroundColor: colors.bgSecondary,
411 borderWidth: 1,
412 borderColor: colors.border,
413 borderBottomLeftRadius: 4,
414 },
415 userText: {
416 fontSize: 14,
417 color: colors.bg,
418 lineHeight: 20,
419 },
420 thinkingRow: {
421 flexDirection: 'row',
422 alignItems: 'center',
423 paddingHorizontal: 12,
424 paddingVertical: 8,
425 gap: 8,
426 },
427 thinkingBubble: {
428 flexDirection: 'row',
429 alignItems: 'center',
430 backgroundColor: colors.bgSecondary,
431 borderRadius: 14,
432 borderBottomLeftRadius: 4,
433 paddingHorizontal: 14,
434 paddingVertical: 10,
435 gap: 8,
436 borderWidth: 1,
437 borderColor: colors.border,
438 },
439 thinkingText: {
440 fontSize: 13,
441 color: colors.textMuted,
442 },
443 errorRow: {
444 marginHorizontal: 12,
445 marginBottom: 8,
446 padding: 10,
447 backgroundColor: colors.bg,
448 borderRadius: 8,
449 borderWidth: 1,
450 borderColor: colors.accentRed,
451 },
452 errorText: {
453 fontSize: 13,
454 color: colors.accentRed,
455 },
456 inputArea: {
457 flexDirection: 'row',
458 alignItems: 'flex-end',
459 padding: 12,
460 backgroundColor: colors.bgSecondary,
461 borderTopWidth: 1,
462 borderTopColor: colors.border,
463 gap: 10,
464 },
465 input: {
466 flex: 1,
467 backgroundColor: colors.bg,
468 borderWidth: 1,
469 borderColor: colors.border,
470 borderRadius: 20,
471 paddingHorizontal: 16,
472 paddingVertical: 10,
473 fontSize: 14,
474 color: colors.text,
475 maxHeight: 120,
476 minHeight: 44,
477 },
478 sendButton: {
479 width: 44,
480 height: 44,
481 borderRadius: 22,
482 backgroundColor: colors.accentPurple,
483 alignItems: 'center',
484 justifyContent: 'center',
485 },
486 sendButtonDisabled: {
487 opacity: 0.4,
488 },
489 sendIcon: {
490 fontSize: 20,
491 color: colors.text,
492 fontWeight: '700',
493 },
494});