import { useEffect, useRef, useState, useCallback, useMemo } from "react" import { View, Text, FlatList, TextInput, TouchableOpacity, StyleSheet, useColorScheme, KeyboardAvoidingView, Platform, ActivityIndicator, Alert, } from "react-native" import { useLocalSearchParams, Stack, useRouter } from "expo-router" import { Ionicons } from "@expo/vector-icons" import { useSafeAreaInsets } from "react-native-safe-area-context" import * as ImagePicker from "expo-image-picker" import * as ImageManipulator from "expo-image-manipulator" import * as Clipboard from "expo-clipboard" import type BottomSheet from "@gorhom/bottom-sheet" import { MessageBubble, PermissionPrompt, QuestionPrompt, StatusIndicator, SlashPopover, ModelPicker, ImageAttachments, SessionInfo, type SlashCommand, type Attachment, } from "../../src/components/chat" import { useSessions } from "../../src/stores/sessions" import { useEvents, refreshPending } from "../../src/stores/events" import { useConnections } from "../../src/stores/connections" import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ { trigger: "new", title: "New Session", description: "Start a new session", icon: "add-circle-outline", type: "builtin", }, { trigger: "model", title: "Switch Model", description: "Choose a different model", icon: "hardware-chip-outline", type: "builtin", }, { trigger: "agent", title: "Switch Agent", description: "Cycle to next agent", icon: "person-outline", type: "builtin", }, ] function getShortDir(dir?: string): string | null { if (!dir) return null const parts = dir.split("/").filter(Boolean) return parts[parts.length - 1] || null } export default function SessionScreen() { const { id, directory } = useLocalSearchParams<{ id: string; directory?: string }>() const router = useRouter() const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const insets = useSafeAreaInsets() const flatListRef = useRef(null) const modelSheetRef = useRef(null) const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) const { currentSession, messages, parts, isLoading, loadingMore, hasMore, selectSession, sendMessage, abortSession, loadOlderMessages, } = useSessions() // Derive sending state for this specific session const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id])) const { authenticateForMessage } = useAuth() const { client, clientForDirectory } = useConnections() // Use directory-aware client for sessions that belong to a project other than the active one const sessionClient = useMemo( () => (currentSession?.directory ? (clientForDirectory(currentSession.directory) ?? client) : client), [currentSession?.directory, clientForDirectory, client], ) // Catalog const catalog = useCatalog() const agents = Array.isArray(catalog.agents) ? catalog.agents : [] const serverCommands = Array.isArray(catalog.commands) ? catalog.commands : [] const providers = Array.isArray(catalog.providers) ? catalog.providers : [] const agent = catalog.agent || "" const model = catalog.model const setModel = catalog.setModel const cycleAgent = catalog.cycleAgent // Permission & question state const sessionID = currentSession?.id const permissions = useEvents((s) => (sessionID ? s.permissions[sessionID] : undefined)) || [] const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || [] const shortDir = getShortDir(currentSession?.directory) const [showScrollButton, setShowScrollButton] = useState(false) // SSE reconnect banner const reconnectAttempts = useEvents((s) => s.reconnectAttempts) const [showConnectedFlash, setShowConnectedFlash] = useState(false) const prevReconnecting = useRef(false) // Voice input — transcript appends to the text input on completion const speech = useSpeech( useCallback((text: string) => { setInput((prev) => (prev ? prev + " " + text : text)) }, []), ) // Slash command state const slashActive = input.startsWith("/") && !input.includes(" ") const slashQuery = slashActive ? input.slice(1) : "" const allCommands = useMemo(() => { const custom: SlashCommand[] = serverCommands.map((cmd) => ({ trigger: cmd.name, title: cmd.name, description: cmd.description, icon: "code-slash-outline", type: "custom", })) return [...custom, ...BUILTIN_COMMANDS] }, [serverCommands]) // Inverted FlatList: data is reversed (newest first) so newest renders at bottom const messageData = useMemo( () => (messages || []) .map((msg) => ({ message: msg, parts: (parts && parts[msg.id]) || [], })) .reverse(), [messages, parts], ) const scrollToBottom = useCallback((animated = true) => { flatListRef.current?.scrollToOffset({ offset: 0, animated }) }, []) useEffect(() => { if (!id) return selectSession(id, directory).then(() => { // Re-fetch pending permissions/questions from the server to recover from // missed SSE events or failed optimistic removals const connState = useConnections.getState() const c = directory ? (connState.clientForDirectory(directory) ?? connState.client) : connState.client if (c) refreshPending(c, id) }) }, [id, directory]) // Sync model chip from latest assistant message useEffect(() => { if (!messages || messages.length === 0) return for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg.role === "assistant" && msg.providerID && msg.modelID) { setModel({ providerID: msg.providerID, modelID: msg.modelID }) return } if (msg.role === "user" && msg.model) { setModel(msg.model) return } } }, [currentSession?.id, messages?.length]) // Slash command handler const handleSlashSelect = useCallback( (cmd: SlashCommand) => { if (cmd.type === "builtin") { switch (cmd.trigger) { case "new": router.back() return case "model": setInput("") modelSheetRef.current?.expand() return case "agent": setInput("") cycleAgent() return } } setInput(`/${cmd.trigger} `) }, [router, cycleAgent], ) // --- Image picking --- // Convert any image (including HEIC/HEIF from iOS) to guaranteed JPEG bytes const MAX_DIMENSION = 1568 // Anthropic recommended max async function toJpeg(uri: string, width: number, height: number): Promise { const actions: ImageManipulator.Action[] = [] if (width > MAX_DIMENSION || height > MAX_DIMENSION) { const scale = MAX_DIMENSION / Math.max(width, height) actions.push({ resize: { width: Math.round(width * scale), height: Math.round(height * scale) } }) } const result = await ImageManipulator.manipulateAsync(uri, actions, { format: ImageManipulator.SaveFormat.JPEG, compress: 0.8, base64: true, }) return { uri: result.uri, mime: "image/jpeg", filename: "image.jpg", width: result.width, height: result.height, base64: result.base64 || undefined, } } const pickFromLibrary = useCallback(async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, quality: 1, // full quality - we compress in manipulator }) if (result.canceled) return const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height))) setAttachments((prev) => [...prev, ...items]) }, []) const pickFromCamera = useCallback(async () => { const perm = await ImagePicker.requestCameraPermissionsAsync() if (!perm.granted) { Alert.alert("Permission needed", "Camera access is required to take photos.") return } const result = await ImagePicker.launchCameraAsync({ quality: 1 }) if (result.canceled) return const a = result.assets[0] const item = await toJpeg(a.uri, a.width, a.height) setAttachments((prev) => [...prev, item]) }, []) const pasteFromClipboard = useCallback(async () => { // Try image first const hasImage = await Clipboard.hasImageAsync() if (hasImage) { const img = await Clipboard.getImageAsync({ format: "png" }) if (img?.data) { const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}` setAttachments((prev) => [ ...prev, { uri, mime: "image/png", filename: "clipboard.png", width: img.size.width, height: img.size.height, }, ]) return } } // Fall back to text const hasText = await Clipboard.hasStringAsync() if (hasText) { const text = await Clipboard.getStringAsync() if (text) { setInput((prev) => prev + text) return } } Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.") }, []) const removeAttachment = useCallback((index: number) => { setAttachments((prev) => prev.filter((_, i) => i !== index)) }, []) // --- Send --- const handleSend = async () => { if (!input.trim() && attachments.length === 0) return const authenticated = await authenticateForMessage() if (!authenticated) { Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.") return } const text = input.trim() const files = [...attachments] setInput("") setAttachments([]) // Server slash commands (no attachments for commands) if (text.startsWith("/") && files.length === 0) { const [cmdName, ...args] = text.split(" ") const name = cmdName.slice(1) const match = serverCommands.find((c) => c.name === name) if (match && sessionClient && currentSession) { sessionClient.session .command(currentSession.id, { command: name, arguments: args.join(" "), agent, model: model ? `${model.providerID}/${model.modelID}` : undefined, }) .catch((err) => console.error("Command failed:", err)) return } } // Messages are queued server-side when the session is busy. // No need to abort - just send and it will be processed after current response. try { await sendMessage(text, model || undefined, agent || undefined, files) } catch (err) { console.error("Send failed:", err) // Restore the user's text and attachments so their input isn't lost. setInput((prev) => (prev ? prev : text)) setAttachments((prev) => (prev.length ? prev : files)) Alert.alert("Message not sent", "Could not send your message. Check your connection and try again.") } } // In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom. const handleScroll = useCallback((event: any) => { const { contentOffset } = event.nativeEvent setShowScrollButton(contentOffset.y > 200) }, []) // Debounce: onEndReached can fire multiple times during a single scroll gesture const loadingTriggered = useRef(false) const handleLoadMore = useCallback(() => { if (hasMore && !loadingMore && !loadingTriggered.current) { loadingTriggered.current = true loadOlderMessages() } }, [hasMore, loadingMore, loadOlderMessages]) // Reset trigger when loading finishes useEffect(() => { if (!loadingMore) loadingTriggered.current = false }, [loadingMore]) // Detect reconnecting → stable transition for the "Connected ✓" flash. // reconnectAttempts and lastDisconnectAt reset in the same set() call, so we // can't use lastDisconnectAt alone; a useRef tracks the prior reconnecting state. useEffect(() => { const isReconnecting = reconnectAttempts > 0 if (prevReconnecting.current && !isReconnecting) { setShowConnectedFlash(true) const t = setTimeout(() => setShowConnectedFlash(false), 2000) return () => clearTimeout(t) } prevReconnecting.current = isReconnecting }, [reconnectAttempts]) const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => { if (!sessionClient || !sessionID) return // Snapshot for rollback const snapshot = useEvents.getState().permissions[sessionID] || [] // Optimistically remove from UI useEvents.setState((state) => ({ permissions: { ...state.permissions, [sessionID]: snapshot.filter((p) => p.id !== requestID), }, })) try { await sessionClient.permission.reply(requestID, reply) } catch (err) { console.error("Permission reply failed:", err) // Restore the prompt so the user can retry useEvents.setState((state) => ({ permissions: { ...state.permissions, [sessionID]: snapshot }, })) Alert.alert("Reply Failed", "Could not send your response. Please try again.") } } const handleQuestionReply = async (requestID: string, answers: string[][]) => { if (!sessionClient || !sessionID) return const snapshot = useEvents.getState().questions[sessionID] || [] useEvents.setState((state) => ({ questions: { ...state.questions, [sessionID]: snapshot.filter((q) => q.id !== requestID), }, })) try { await sessionClient.question.reply(requestID, answers) } catch (err) { console.error("Question reply failed:", err) useEvents.setState((state) => ({ questions: { ...state.questions, [sessionID]: snapshot }, })) Alert.alert("Reply Failed", "Could not send your response. Please try again.") } } const handleQuestionReject = async (requestID: string) => { if (!sessionClient || !sessionID) return const snapshot = useEvents.getState().questions[sessionID] || [] useEvents.setState((state) => ({ questions: { ...state.questions, [sessionID]: snapshot.filter((q) => q.id !== requestID), }, })) try { await sessionClient.question.reject(requestID) } catch (err) { console.error("Question reject failed:", err) useEvents.setState((state) => ({ questions: { ...state.questions, [sessionID]: snapshot }, })) Alert.alert("Reject Failed", "Could not send your response. Please try again.") } } const handleModelSelect = useCallback( (providerID: string, modelID: string) => { setModel({ providerID, modelID }) }, [setModel], ) // Current agent display const currentAgent = agents.find((a) => a.name === agent) const agentColor = currentAgent?.color || "#8b5cf6" const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default" return ( <> ( {shortDir && ( {shortDir} )} setShowInfo((v) => !v)} hitSlop={8}> ), }} /> {/* Session info pulldown */} { if (hasMore && !loadingMore) loadOlderMessages() }} onScrollToTop={() => { flatListRef.current?.scrollToEnd({ animated: true }) }} onClose={() => setShowInfo(false)} /> {/* SSE reconnect/connected banner */} {reconnectAttempts > 0 && ( Reconnecting\u2026 (attempt {reconnectAttempts}) )} {showConnectedFlash && reconnectAttempts === 0 && ( Connected ✓ )} {isLoading ? ( ) : ( item.message.id} renderItem={({ item }) => } contentContainerStyle={s.messageList} onScroll={handleScroll} scrollEventThrottle={100} onEndReached={handleLoadMore} onEndReachedThreshold={0.5} // Prevent jump when older messages are prepended maintainVisibleContentPosition={{ minIndexForVisible: 0 }} ListFooterComponent={ loadingMore ? ( Loading older messages... ) : null } /> {/* Empty state rendered OUTSIDE the inverted list to avoid the inverted transform mirroring its text/icon (see #ui-mirror). */} {messageData.length === 0 && ( Start a conversation Type / for commands )} {showScrollButton && ( scrollToBottom(true)}> )} )} {/* Status */} {currentSession && } {/* Permissions */} {permissions.map((perm) => ( handlePermissionReply(perm.id, reply)} /> ))} {/* Questions */} {questions.map((q) => ( handleQuestionReply(q.id, answers)} onReject={() => handleQuestionReject(q.id)} /> ))} {/* Slash popover */} {slashActive && ( )} {/* Agent/model toolbar */} cycleAgent()} onLongPress={() => cycleAgent(-1)} > {agent || "build"} modelSheetRef.current?.expand()} > {modelLabel} {/* Attachment preview */} {/* Input */} {/* Attach button */} {/* Clipboard paste button */} {/* Stop button: only when busy and no input */} {isSending && !input.trim() && attachments.length === 0 && !speech.listening && ( )} {/* Mic button: when no input, not sending, and not listening */} {!isSending && !input.trim() && attachments.length === 0 && !speech.listening && ( )} {/* Listening indicator: tap to stop */} {speech.listening && ( )} {/* Send button: when there's input */} {!speech.listening && (input.trim() || attachments.length > 0) && ( )} {/* Model picker bottom sheet */} ) } const s = StyleSheet.create({ container: { flex: 1, backgroundColor: "#ffffff" }, containerDark: { backgroundColor: "#0a0a0a" }, loading: { flex: 1, justifyContent: "center", alignItems: "center" }, listWrap: { flex: 1, position: "relative" }, // Messages messageList: { padding: 16, paddingBottom: 8 }, // Scroll button scrollBtn: { position: "absolute", bottom: 16, right: 16, width: 44, height: 44, borderRadius: 22, backgroundColor: "#ffffff", justifyContent: "center", alignItems: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4, }, scrollBtnDark: { backgroundColor: "#2a2a2a" }, // Loading more (appears at top in inverted list = ListFooterComponent) loadingMore: { flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 8, paddingVertical: 16, }, loadingMoreText: { fontSize: 13, color: "#999999" }, // Empty state overlay — sits on top of the (empty) inverted list, untransformed, // so its text/icon render upright and un-mirrored on Android. emptyOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: "center", alignItems: "center", paddingVertical: 64, }, // Empty empty: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64 }, emptyText: { fontSize: 16, color: "#999999", marginTop: 12 }, emptyHint: { fontSize: 13, color: "#bbbbbb", marginTop: 4 }, metaDark: { color: "#666666" }, textWhite: { color: "#ffffff" }, // Toolbar toolbar: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 6, borderTopWidth: 1, borderTopColor: "#e5e5e5", backgroundColor: "#ffffff", }, toolbarDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" }, agentChip: { flexDirection: "row", alignItems: "center", gap: 5, borderWidth: 1, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, }, agentDot: { width: 8, height: 8, borderRadius: 4 }, agentLabel: { fontSize: 12, fontWeight: "600", color: "#0a0a0a" }, modelChip: { flexDirection: "row", alignItems: "center", gap: 4, backgroundColor: "#f5f5f5", borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, }, modelChipDark: { backgroundColor: "#1a1a1a" }, modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 }, // Input inputContainer: { padding: 12, borderTopWidth: 1, borderTopColor: "#e5e5e5", backgroundColor: "#ffffff", }, inputContainerDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" }, inputRow: { flexDirection: "row", alignItems: "flex-end", }, attachBtn: { width: 36, height: 40, justifyContent: "center", alignItems: "center", }, input: { flex: 1, backgroundColor: "#f5f5f5", borderRadius: 20, paddingHorizontal: 16, paddingVertical: 10, fontSize: 16, maxHeight: 120, color: "#0a0a0a", }, inputDark: { backgroundColor: "#1a1a1a", color: "#ffffff" }, inputListening: { borderWidth: 1, borderColor: "#ef4444" }, sendBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#0a0a0a", justifyContent: "center", alignItems: "center", marginLeft: 8, }, sendBtnDisabled: { backgroundColor: "#cccccc" }, micBtn: { width: 40, height: 40, borderRadius: 20, justifyContent: "center", alignItems: "center", marginLeft: 8, }, micBtnActive: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#ef4444", justifyContent: "center", alignItems: "center", marginLeft: 8, }, stopBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#ef4444", justifyContent: "center", alignItems: "center", marginLeft: 8, }, // Header headerRight: { flexDirection: "row", alignItems: "center", gap: 8 }, dirBadge: { flexDirection: "row", alignItems: "center", gap: 4, backgroundColor: "#f5f5f5", paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, }, dirBadgeDark: { backgroundColor: "#1a1a1a" }, dirText: { fontSize: 12, color: "#666666", fontWeight: "500" }, dirTextDark: { color: "#888888" }, // SSE reconnect/connected banner banner: { paddingHorizontal: 16, paddingVertical: 6, alignItems: "center", }, bannerReconnecting: { backgroundColor: "#92400e" }, bannerConnected: { backgroundColor: "#065f46" }, bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" }, })