From 051b04368d68cafcd69fb1bc00d0e88cef2d6044 Mon Sep 17 00:00:00 2001 From: Yajana Rao Date: Wed, 18 Feb 2026 20:34:08 +0530 Subject: [PATCH 1/4] Resolve merge conflicts - restore deleted files from origin/dev --- packages/mobile/app.json | 43 + packages/mobile/app/(tabs)/connections.tsx | 297 ++++++ packages/mobile/app/session/[id].tsx | 921 ++++++++++++++++++ .../src/components/chat/FileContextPills.tsx | 63 ++ 4 files changed, 1324 insertions(+) create mode 100644 packages/mobile/app.json create mode 100644 packages/mobile/app/(tabs)/connections.tsx create mode 100644 packages/mobile/app/session/[id].tsx create mode 100644 packages/mobile/src/components/chat/FileContextPills.tsx diff --git a/packages/mobile/app.json b/packages/mobile/app.json new file mode 100644 index 00000000000..e650545389d --- /dev/null +++ b/packages/mobile/app.json @@ -0,0 +1,43 @@ +{ + "expo": { + "name": "OpenCode", + "slug": "opencode-mobile", + "version": "1.0.0", + "orientation": "portrait", + "scheme": "opencode", + "userInterfaceStyle": "automatic", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#000000" + }, + "android": { + "package": "ai.opencode.mobile", + "versionCode": 1, + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#000000" + }, + "permissions": ["android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT"], + "usesCleartextTraffic": true + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication"], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + }, + "ios": { + "bundleIdentifier": "ai.opencode.mobile", + "infoPlist": { + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true, + "NSAllowsLocalNetworking": true + } + } + } + } +} diff --git a/packages/mobile/app/(tabs)/connections.tsx b/packages/mobile/app/(tabs)/connections.tsx new file mode 100644 index 00000000000..95724793213 --- /dev/null +++ b/packages/mobile/app/(tabs)/connections.tsx @@ -0,0 +1,297 @@ +import { View, Text, FlatList, TouchableOpacity, StyleSheet, Alert } from "react-native" +import { SafeAreaView } from "react-native-safe-area-context" +import { router } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useConnections } from "../../src/stores/connections" +import { useSettings } from "../../src/stores/settings" +import { useTheme } from "@/lib/theme" +import { Chip } from "@/components/ui/chip" +import type { ServerConnection } from "../../src/lib/types" + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200] as const + +function ConnectionItem({ + connection, + isActive, + onSelect, + onEdit, + onDelete, +}: { + connection: ServerConnection + isActive: boolean + onSelect: () => void + onEdit: () => void + onDelete: () => void +}) { + const { colors } = useTheme() + const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud" + + const handleLongPress = () => { + Alert.alert(connection.name, "What would you like to do?", [ + { text: "Cancel", style: "cancel" }, + { text: "Edit", onPress: onEdit }, + { text: "Delete", style: "destructive", onPress: onDelete }, + ]) + } + + return ( + + + + + + + {connection.name} + {isActive && ( + + Active + + )} + + + {connection.url} + + {connection.lastConnected && ( + + Last connected: {new Date(connection.lastConnected).toLocaleDateString()} + + )} + + + + + + ) +} + +export default function ConnectionsScreen() { + const { colors } = useTheme() + const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections() + const { pageSize, setPageSize } = useSettings() + + const handleDelete = (connection: ServerConnection) => { + Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => removeConnection(connection.id), + }, + ]) + } + + return ( + + item.id} + renderItem={({ item }) => ( + setActiveConnection(item.id)} + onEdit={() => router.push(`/connection/${item.id}`)} + onDelete={() => handleDelete(item)} + /> + )} + ListEmptyComponent={ + + + No Connections + + Add a connection to your OpenCode server + + + } + ListHeaderComponent={ + + + Tap to switch, long press for options + + + } + ListFooterComponent={ + + Preferences + + + + Messages per page + + + {PAGE_SIZE_OPTIONS.map((size) => ( + setPageSize(size)} + > + + {size} + + + ))} + + + + How many messages to load when opening a session. Lower = faster. + + + } + contentContainerStyle={connections.length === 0 ? styles.emptyContent : undefined} + /> + + {/* FAB to add connection */} + router.push("/connection/add")} + > + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + padding: 16, + borderBottomWidth: 1, + }, + headerText: { + fontSize: 13, + }, + connectionItem: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderBottomWidth: 1, + }, + connectionIcon: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + connectionContent: { + flex: 1, + }, + connectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + connectionName: { + fontSize: 16, + fontWeight: "600", + }, + connectionUrl: { + fontSize: 13, + marginTop: 2, + }, + connectionMeta: { + fontSize: 12, + marginTop: 4, + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 32, + }, + emptyTitle: { + fontSize: 20, + fontWeight: "600", + marginTop: 16, + }, + emptySubtitle: { + fontSize: 14, + marginTop: 8, + textAlign: "center", + }, + emptyContent: { + flex: 1, + }, + fab: { + position: "absolute", + right: 16, + bottom: 16, + width: 56, + height: 56, + borderRadius: 28, + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + settingsSection: { + padding: 16, + borderTopWidth: 1, + marginTop: 16, + gap: 10, + }, + settingsTitle: { + fontSize: 15, + fontWeight: "700", + }, + settingRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + settingLabel: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + settingText: { + fontSize: 14, + }, + pagePicker: { + flexDirection: "row", + gap: 6, + }, + pageOption: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 8, + borderWidth: 1, + }, + pageOptionText: { + fontSize: 13, + fontWeight: "500", + }, + settingHint: { + fontSize: 12, + }, +}) diff --git a/packages/mobile/app/session/[id].tsx b/packages/mobile/app/session/[id].tsx new file mode 100644 index 00000000000..ee3d0d8a2d6 --- /dev/null +++ b/packages/mobile/app/session/[id].tsx @@ -0,0 +1,921 @@ +import React, { useEffect, useRef, useState, useCallback, useMemo } from "react" +import { + View, + Text, + FlatList, + TextInput, + TouchableOpacity, + StyleSheet, + useColorScheme, + 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 { useKeyboardHandler } from "react-native-keyboard-controller" +import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated" +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 { AtPopover, type FileItem } from "../../src/components/chat/AtPopover" +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" +import { useTheme } from "@/lib/theme" + +// --- 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", + }, + { + trigger: "compact", + title: "Compact", + description: "Summarize conversation", + icon: "contract-outline", + type: "builtin", + }, + { trigger: "clear", title: "Clear", description: "Clear the session", icon: "trash-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 +} + +// Custom hook for smooth keyboard animation +function useKeyboardAnimation() { + const height = useSharedValue(0) + + useKeyboardHandler( + { + onMove: (event) => { + "worklet" + height.value = Math.max(event.height, 0) + }, + }, + [], + ) + + return { height } +} + +export default function SessionScreen() { + const { id, directory } = useLocalSearchParams<{ id: string; directory?: string }>() + const router = useRouter() + const { colors } = useTheme() + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + const insets = useSafeAreaInsets() + const { height: keyboardHeight } = useKeyboardAnimation() + + 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 } = useConnections() + + // 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) + + // 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) : "" + + // @ file mention state + const [atActive, setAtActive] = useState(false) + const [atQuery, setAtQuery] = useState("") + const [atResults, setAtResults] = useState([]) + const [atLoading, setAtLoading] = useState(false) + const atControllerRef = useRef(null) + + 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 + if (client) refreshPending(client, id) + }) + }, [id]) + + // 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 + case "compact": + setInput("") + return + case "clear": + setInput("") + return + } + } + setInput(`/${cmd.trigger} `) + }, + [router, cycleAgent], + ) + + // @ file mention detection and search + useEffect(() => { + const atMatch = input.match(/@(\S*)$/) + + if (atMatch) { + const query = atMatch[1] + setAtQuery(query) + setAtActive(true) + + // Cancel previous search + atControllerRef.current?.abort() + + const controller = new AbortController() + atControllerRef.current = controller + + // Debounce search + const timeoutId = setTimeout(() => { + if (!client) return + + setAtLoading(true) + client.find + .files({ query, dirs: "true", limit: 10 }, controller.signal) + .then((paths) => { + const items: FileItem[] = paths.map((path) => ({ + path, + display: path, + })) + setAtResults(items) + setAtLoading(false) + }) + .catch((err) => { + if (err.name !== "AbortError") { + console.error("File search failed:", err) + setAtLoading(false) + } + }) + }, 200) + + return () => { + clearTimeout(timeoutId) + controller.abort() + } + } else { + setAtActive(false) + setAtQuery("") + setAtResults([]) + } + }, [input, client]) + + // @ file selection handler + const handleAtSelect = useCallback( + (file: FileItem) => { + // Replace the @query with @filepath + space + const newInput = input.replace(/@(\S*)$/, `@${file.display} `) + setInput(newInput) + + // Close popover + setAtActive(false) + }, + [input], + ) + + // --- 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) return + + const text = input.trim() + const files = [...attachments] + + // Parse @filepath mentions from input text + const atMatches = text.match(/@(\S+)/g) || [] + const context = atMatches.map((match) => ({ + path: match.slice(1), // Remove @ prefix + display: match.slice(1), + })) + + setInput("") + setAttachments([]) + + // Server slash commands (no attachments for commands) + if (text.startsWith("/") && files.length === 0 && context.length === 0) { + const [cmdName, ...args] = text.split(" ") + const name = cmdName.slice(1) + const match = serverCommands.find((c) => c.name === name) + if (match && client && currentSession) { + client.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. + await sendMessage(text, model || undefined, agent || undefined, files, context) + } + + // 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]) + + const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => { + if (!client || !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 client.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 (!client || !sessionID) return + const snapshot = useEvents.getState().questions[sessionID] || [] + useEvents.setState((state) => ({ + questions: { + ...state.questions, + [sessionID]: snapshot.filter((q) => q.id !== requestID), + }, + })) + try { + await client.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 (!client || !sessionID) return + const snapshot = useEvents.getState().questions[sessionID] || [] + useEvents.setState((state) => ({ + questions: { + ...state.questions, + [sessionID]: snapshot.filter((q) => q.id !== requestID), + }, + })) + try { + await client.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 || colors["secondary"] + const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default" + + // Animated style for keyboard spacer + // Subtract the bottom safe area inset since input container already has padding for it + const keyboardSpacerStyle = useAnimatedStyle(() => { + return { + height: Math.max(0, keyboardHeight.value - insets.bottom), + } + }, [insets.bottom]) + + return ( + <> + ( + + {shortDir && ( + + + {shortDir} + + )} + setShowInfo((v) => !v)} hitSlop={8}> + + + + ), + }} + /> + + + {/* Session info pulldown */} + { + if (hasMore && !loadingMore) loadOlderMessages() + }} + onScrollToTop={() => { + flatListRef.current?.scrollToEnd({ animated: true }) + }} + onClose={() => setShowInfo(false)} + /> + + {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 + } + ListEmptyComponent={ + + + 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 && ( + + )} + + {/* @ file mention popover */} + + + {/* 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) && ( + + + + )} + + + + {/* Animated keyboard spacer - pushes content up smoothly */} + + + + {/* Model picker bottom sheet */} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1 }, + 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, + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 4, + }, + + // Loading more (appears at top in inverted list = ListFooterComponent) + loadingMore: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 8, + paddingVertical: 16, + }, + + // Empty (inverted list flips content, so use transform to un-flip) + emptyInverted: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingVertical: 64, + transform: [{ scaleY: -1 }], + }, + + // Empty + empty: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64 }, + + // Toolbar + toolbar: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 12, + paddingVertical: 6, + borderTopWidth: 1, + }, + 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" }, + modelChip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + }, + modelLabel: { fontSize: 12, maxWidth: 160 }, + + // Input + inputContainer: { + padding: 12, + marginBottom: 8, + borderTopWidth: 1, + }, + inputRow: { + flexDirection: "row", + alignItems: "flex-end", + }, + attachBtn: { + width: 36, + height: 40, + justifyContent: "center", + alignItems: "center", + }, + input: { + flex: 1, + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 10, + fontSize: 16, + maxHeight: 120, + }, + sendBtn: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + micBtn: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + micBtnActive: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + stopBtn: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + + // Header + headerRight: { flexDirection: "row", alignItems: "center", gap: 8 }, + dirBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + dirText: { fontSize: 12, fontWeight: "500" }, +}) diff --git a/packages/mobile/src/components/chat/FileContextPills.tsx b/packages/mobile/src/components/chat/FileContextPills.tsx new file mode 100644 index 00000000000..a82b9c28806 --- /dev/null +++ b/packages/mobile/src/components/chat/FileContextPills.tsx @@ -0,0 +1,63 @@ +import { View, ScrollView, StyleSheet } from "react-native" +import { Chip } from "../ui/chip" +import { useTheme } from "@/lib/theme" + +export interface FileContextItem { + path: string + display: string +} + +interface Props { + files: FileContextItem[] + onRemove: (index: number) => void + onPress?: (file: FileContextItem, index: number) => void +} + +export function FileContextPills({ files, onRemove, onPress }: Props) { + const { colors } = useTheme() + + if (files.length === 0) return null + + return ( + + + {files.map((file, idx) => { + // Extract filename from path + const filename = file.display.split("/").pop() || file.display + + // Detect if this is a directory (ends with /) + const isDirectory = file.display.endsWith("/") + const icon = isDirectory ? "folder-outline" : "document-outline" + + return ( + onRemove(idx)} + onPress={onPress ? () => onPress(file, idx) : undefined} + > + {filename} + + ) + })} + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 12, + paddingVertical: 8, + borderTopWidth: 1, + }, + scroll: { + gap: 8, + }, +}) From 1fdf7ecd8c91df2ded6feba7fff9a3625450f71b Mon Sep 17 00:00:00 2001 From: Yajana Rao Date: Wed, 18 Feb 2026 20:47:54 +0530 Subject: [PATCH 2/4] feat: file input --- packages/mobile/app/session/[id].tsx | 32 ++++------ .../src/components/chat/FileContextPills.tsx | 63 ------------------- 2 files changed, 11 insertions(+), 84 deletions(-) delete mode 100644 packages/mobile/src/components/chat/FileContextPills.tsx diff --git a/packages/mobile/app/session/[id].tsx b/packages/mobile/app/session/[id].tsx index 578c64222f4..ee3d0d8a2d6 100644 --- a/packages/mobile/app/session/[id].tsx +++ b/packages/mobile/app/session/[id].tsx @@ -33,7 +33,6 @@ import { type Attachment, } from "../../src/components/chat" import { AtPopover, type FileItem } from "../../src/components/chat/AtPopover" -import { FileContextPills, type FileContextItem } from "../../src/components/chat/FileContextPills" import { useSessions } from "../../src/stores/sessions" import { useEvents, refreshPending } from "../../src/stores/events" import { useConnections } from "../../src/stores/connections" @@ -166,7 +165,6 @@ export default function SessionScreen() { const [atQuery, setAtQuery] = useState("") const [atResults, setAtResults] = useState([]) const [atLoading, setAtLoading] = useState(false) - const [fileContext, setFileContext] = useState([]) const atControllerRef = useRef(null) const allCommands = useMemo(() => { @@ -302,27 +300,16 @@ export default function SessionScreen() { // @ file selection handler const handleAtSelect = useCallback( (file: FileItem) => { - // Remove the @query from input - const newInput = input.replace(/@(\S*)$/, "") + // Replace the @query with @filepath + space + const newInput = input.replace(/@(\S*)$/, `@${file.display} `) setInput(newInput) - // Add to file context if not already there - setFileContext((prev) => { - const exists = prev.some((f) => f.path === file.path) - if (exists) return prev - return [...prev, { path: file.path, display: file.display }] - }) - // Close popover setAtActive(false) }, [input], ) - const removeFileContext = useCallback((index: number) => { - setFileContext((prev) => prev.filter((_, i) => i !== index)) - }, []) - // --- Image picking --- // Convert any image (including HEIC/HEIF from iOS) to guaranteed JPEG bytes @@ -410,16 +397,22 @@ export default function SessionScreen() { // --- Send --- const handleSend = async () => { - if (!input.trim() && attachments.length === 0 && fileContext.length === 0) return + if (!input.trim() && attachments.length === 0) return const authenticated = await authenticateForMessage() if (!authenticated) return const text = input.trim() const files = [...attachments] - const context = [...fileContext] + + // Parse @filepath mentions from input text + const atMatches = text.match(/@(\S+)/g) || [] + const context = atMatches.map((match) => ({ + path: match.slice(1), // Remove @ prefix + display: match.slice(1), + })) + setInput("") setAttachments([]) - setFileContext([]) // Server slash commands (no attachments for commands) if (text.startsWith("/") && files.length === 0 && context.length === 0) { @@ -697,9 +690,6 @@ export default function SessionScreen() { {/* Attachment preview */} - {/* File context pills */} - - {/* Input */} void - onPress?: (file: FileContextItem, index: number) => void -} - -export function FileContextPills({ files, onRemove, onPress }: Props) { - const { colors } = useTheme() - - if (files.length === 0) return null - - return ( - - - {files.map((file, idx) => { - // Extract filename from path - const filename = file.display.split("/").pop() || file.display - - // Detect if this is a directory (ends with /) - const isDirectory = file.display.endsWith("/") - const icon = isDirectory ? "folder-outline" : "document-outline" - - return ( - onRemove(idx)} - onPress={onPress ? () => onPress(file, idx) : undefined} - > - {filename} - - ) - })} - - - ) -} - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 12, - paddingVertical: 8, - borderTopWidth: 1, - }, - scroll: { - gap: 8, - }, -}) From 4605cf10548d526bcccec4ed21318f6f2801fe3a Mon Sep 17 00:00:00 2001 From: Yajana Rao Date: Wed, 18 Feb 2026 22:17:14 +0530 Subject: [PATCH 3/4] Fix Android WiFi connection - add network security config - Created Expo config plugin to inject network security config - Enables cleartext HTTP traffic for local development - Adds usesCleartextTraffic and networkSecurityConfig to AndroidManifest - Allows connections to local IP ranges (192.168.x.x, 10.x.x.x, 172.16.x.x) - Required for GitHub Actions APK builds to connect to opencode server Fixes WiFi connection issues on physical Android devices --- bun.lock | 1 + packages/mobile/app.json | 2 +- packages/mobile/package.json | 1 + .../plugins/withNetworkSecurityConfig.js | 82 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/mobile/plugins/withNetworkSecurityConfig.js diff --git a/bun.lock b/bun.lock index ec228acc5f6..65b78d1da7f 100644 --- a/bun.lock +++ b/bun.lock @@ -295,6 +295,7 @@ "zustand": "^5.0.2", }, "devDependencies": { + "@expo/config-plugins": "54.0.4", "@types/react": "~19.1.10", "typescript": "~5.9.2", }, diff --git a/packages/mobile/app.json b/packages/mobile/app.json index e650545389d..e4e42af4ac6 100644 --- a/packages/mobile/app.json +++ b/packages/mobile/app.json @@ -25,7 +25,7 @@ "web": { "favicon": "./assets/favicon.png" }, - "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication"], + "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication", "./plugins/withNetworkSecurityConfig"], "experiments": { "typedRoutes": true, "reactCompiler": true diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 5421ce57408..398afde601f 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -45,6 +45,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@expo/config-plugins": "54.0.4", "@types/react": "~19.1.10", "typescript": "~5.9.2" }, diff --git a/packages/mobile/plugins/withNetworkSecurityConfig.js b/packages/mobile/plugins/withNetworkSecurityConfig.js new file mode 100644 index 00000000000..578949ce45a --- /dev/null +++ b/packages/mobile/plugins/withNetworkSecurityConfig.js @@ -0,0 +1,82 @@ +const { withDangerousMod } = require("@expo/config-plugins") +const path = require("path") +const fs = require("fs") + +/** + * Plugin to add network security config for cleartext traffic (HTTP). + * Required for local development connections to opencode server. + */ +function withNetworkSecurityConfig(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const projectRoot = config.modRequest.platformProjectRoot + const manifestPath = path.join(projectRoot, "app", "src", "main", "AndroidManifest.xml") + const resPath = path.join(projectRoot, "app", "src", "main", "res") + const xmlPath = path.join(resPath, "xml") + + // Create xml directory if it doesn't exist + if (!fs.existsSync(xmlPath)) { + fs.mkdirSync(xmlPath, { recursive: true }) + } + + // Write network security config + const configPath = path.join(xmlPath, "network_security_config.xml") + const configContent = ` + + + + localhost + 10.0.0.0 + 192.168.0.0 + 172.16.0.0 + + + + + + + + + +` + fs.writeFileSync(configPath, configContent) + + // Update AndroidManifest.xml to reference the config + let manifest = fs.readFileSync(manifestPath, "utf-8") + + // Add usesCleartextTraffic and networkSecurityConfig to application tag + manifest = manifest.replace( + /\n `, + ) + .replace( + /]*>/, + '', + ) + } + } + + fs.writeFileSync(manifestPath, manifest) + + return config + }, + ]) +} + +module.exports = withNetworkSecurityConfig From af8182125b44d871026ad4045c2849ba22b2dcb1 Mon Sep 17 00:00:00 2001 From: Yajana Rao Date: Wed, 18 Feb 2026 22:28:36 +0530 Subject: [PATCH 4/4] fix: Use expo-build-properties for Android cleartext traffic - Replaced custom config plugin with official expo-build-properties - Removed unnecessary @expo/config-plugins dependency - Added expo-build-properties with usesCleartextTraffic: true - This is the official Expo-recommended approach for Android 9+ HTTP support - Fixes WiFi connectivity issues on physical Android devices --- bun.lock | 4 +- packages/mobile/app.json | 17 +++- packages/mobile/package.json | 2 +- .../plugins/withNetworkSecurityConfig.js | 82 ------------------- 4 files changed, 18 insertions(+), 87 deletions(-) delete mode 100644 packages/mobile/plugins/withNetworkSecurityConfig.js diff --git a/bun.lock b/bun.lock index 65b78d1da7f..4c85db1406e 100644 --- a/bun.lock +++ b/bun.lock @@ -295,8 +295,8 @@ "zustand": "^5.0.2", }, "devDependencies": { - "@expo/config-plugins": "54.0.4", "@types/react": "~19.1.10", + "expo-build-properties": "1.0.10", "typescript": "~5.9.2", }, }, @@ -2954,6 +2954,8 @@ "expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="], + "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], + "expo-clipboard": ["expo-clipboard@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA=="], "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], diff --git a/packages/mobile/app.json b/packages/mobile/app.json index e4e42af4ac6..7dfdb566bdb 100644 --- a/packages/mobile/app.json +++ b/packages/mobile/app.json @@ -19,13 +19,24 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#000000" }, - "permissions": ["android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT"], - "usesCleartextTraffic": true + "permissions": ["android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT"] }, "web": { "favicon": "./assets/favicon.png" }, - "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication", "./plugins/withNetworkSecurityConfig"], + "plugins": [ + "expo-router", + "expo-secure-store", + "expo-local-authentication", + [ + "expo-build-properties", + { + "android": { + "usesCleartextTraffic": true + } + } + ] + ], "experiments": { "typedRoutes": true, "reactCompiler": true diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 398afde601f..903eb385d1a 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -45,8 +45,8 @@ "zustand": "^5.0.2" }, "devDependencies": { - "@expo/config-plugins": "54.0.4", "@types/react": "~19.1.10", + "expo-build-properties": "1.0.10", "typescript": "~5.9.2" }, "expo": { diff --git a/packages/mobile/plugins/withNetworkSecurityConfig.js b/packages/mobile/plugins/withNetworkSecurityConfig.js deleted file mode 100644 index 578949ce45a..00000000000 --- a/packages/mobile/plugins/withNetworkSecurityConfig.js +++ /dev/null @@ -1,82 +0,0 @@ -const { withDangerousMod } = require("@expo/config-plugins") -const path = require("path") -const fs = require("fs") - -/** - * Plugin to add network security config for cleartext traffic (HTTP). - * Required for local development connections to opencode server. - */ -function withNetworkSecurityConfig(config) { - return withDangerousMod(config, [ - "android", - async (config) => { - const projectRoot = config.modRequest.platformProjectRoot - const manifestPath = path.join(projectRoot, "app", "src", "main", "AndroidManifest.xml") - const resPath = path.join(projectRoot, "app", "src", "main", "res") - const xmlPath = path.join(resPath, "xml") - - // Create xml directory if it doesn't exist - if (!fs.existsSync(xmlPath)) { - fs.mkdirSync(xmlPath, { recursive: true }) - } - - // Write network security config - const configPath = path.join(xmlPath, "network_security_config.xml") - const configContent = ` - - - - localhost - 10.0.0.0 - 192.168.0.0 - 172.16.0.0 - - - - - - - - - -` - fs.writeFileSync(configPath, configContent) - - // Update AndroidManifest.xml to reference the config - let manifest = fs.readFileSync(manifestPath, "utf-8") - - // Add usesCleartextTraffic and networkSecurityConfig to application tag - manifest = manifest.replace( - /\n `, - ) - .replace( - /]*>/, - '', - ) - } - } - - fs.writeFileSync(manifestPath, manifest) - - return config - }, - ]) -} - -module.exports = withNetworkSecurityConfig