diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2800a34..facc4f5 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -111,6 +111,7 @@ export default function SessionsScreen() { const [isCreating, setIsCreating] = useState(false) const [renaming, setRenaming] = useState(null) const [renameText, setRenameText] = useState("") + const renamingInFlight = useRef(false) const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions() const { @@ -149,9 +150,14 @@ export default function SessionsScreen() { const onRefresh = useCallback(async () => { setRefreshing(true) - await Promise.all([loadSessions(), refreshProject()]) - setRefreshing(false) - }, []) + try { + await Promise.all([loadSessions(), refreshProject()]) + } catch (err) { + console.error("Refresh failed:", err) + } finally { + setRefreshing(false) + } + }, [loadSessions, refreshProject]) const handleRename = useCallback((session: Session) => { setRenameText(session.title || "") @@ -160,13 +166,21 @@ export default function SessionsScreen() { const submitRename = useCallback(async () => { const title = renameText.trim() - if (!title || !renaming) return + if (!title || !renaming || renamingInFlight.current) return const renameClient = renaming.directory ? (clientForDirectory(renaming.directory) ?? client) : client if (!renameClient) return - await renameClient.session.update(renaming.id, { title }) - setRenaming(null) - setRenameText("") - loadSessions() + renamingInFlight.current = true + try { + await renameClient.session.update(renaming.id, { title }) + setRenaming(null) + setRenameText("") + loadSessions() + } catch (err) { + console.error("Rename failed:", err) + Alert.alert("Rename failed", "Could not rename the session. Please try again.") + } finally { + renamingInFlight.current = false + } }, [renaming, renameText, client, clientForDirectory, loadSessions]) const handleDelete = useCallback( @@ -177,7 +191,12 @@ export default function SessionsScreen() { text: "Delete", style: "destructive", onPress: async () => { - await deleteSession(session.id) + try { + await deleteSession(session.id) + } catch (err) { + console.error("Delete failed:", err) + Alert.alert("Delete failed", "Could not delete the session. Please try again.") + } }, }, ]) diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 2839dad..73bd9b4 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -16,6 +16,7 @@ import { useConnections } from "../../src/stores/connections" import type { ConnectionType } from "../../src/lib/types" import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" +import { parseUrl } from "../../src/lib/diagnostics-classify" const CONNECTION_TYPES: Array<{ type: ConnectionType @@ -68,6 +69,10 @@ export default function EditConnectionScreen() { Alert.alert("Error", "Please enter a server URL") return } + if (!parseUrl(url).valid) { + Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096") + return + } setIsTesting(true) const result = await testConnection( @@ -111,6 +116,10 @@ export default function EditConnectionScreen() { Alert.alert("Error", "Please enter a server URL") return } + if (!parseUrl(url).valid) { + Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096") + return + } setIsSaving(true) await updateConnection(connection.id, { diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 3ae2e9f..d07228b 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -17,6 +17,7 @@ import { useConnections } from "../../src/stores/connections" import type { ConnectionType } from "../../src/lib/types" import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" +import { parseUrl } from "../../src/lib/diagnostics-classify" export default function AddConnectionScreen() { const colorScheme = useColorScheme() @@ -121,6 +122,10 @@ export default function AddConnectionScreen() { Alert.alert("Error", "Please enter a server URL") return } + if (!parseUrl(url).valid) { + Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096") + return + } setIsConnecting(true) await addConnection( diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 192320f..41a3c78 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -61,14 +61,6 @@ const BUILTIN_COMMANDS: SlashCommand[] = [ 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 { @@ -214,12 +206,6 @@ export default function SessionScreen() { setInput("") cycleAgent() return - case "compact": - setInput("") - return - case "clear": - setInput("") - return } } setInput(`/${cmd.trigger} `) @@ -316,7 +302,10 @@ export default function SessionScreen() { const handleSend = async () => { if (!input.trim() && attachments.length === 0) return const authenticated = await authenticateForMessage() - if (!authenticated) return + if (!authenticated) { + Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.") + return + } const text = input.trim() const files = [...attachments] @@ -343,7 +332,15 @@ export default function SessionScreen() { // 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) + 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. diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx index db7e7a5..7e3a831 100644 --- a/src/components/AuthGate.tsx +++ b/src/components/AuthGate.tsx @@ -13,6 +13,16 @@ export function AuthGate({ children }: Props) { const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth() + // Auto-prompt the OS biometric dialog once when the lock screen appears, so users + // aren't forced to tap "Unlock" on every cold start. If they cancel/fail, the + // manual "Unlock" button below is the fallback. + useEffect(() => { + if (settings.requireBiometric && hasBiometrics && !isAuthenticated) { + authenticate() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // If biometric not required, or no biometrics available, show children if (!settings.requireBiometric || !hasBiometrics) { return <>{children} diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx index 7209a99..b3ebe7b 100644 --- a/src/components/chat/DiffView.tsx +++ b/src/components/chat/DiffView.tsx @@ -1,4 +1,4 @@ -import { View, Text, StyleSheet, Platform } from "react-native" +import { View, Text, StyleSheet, Platform, ScrollView } from "react-native" const mono = Platform.OS === "ios" ? "Menlo" : "monospace" @@ -76,32 +76,35 @@ export function DiffView({ before, after, isDark }: Props) { return ( - {lines.map((line, idx) => ( - - - {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "} - - - {line.text} - + + + {lines.map((line, idx) => ( + + + {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "} + + + {line.text} + + + ))} - ))} + ) } @@ -135,7 +138,6 @@ const s = StyleSheet.create({ prefixDark: { color: "#666666" }, text: { - flex: 1, fontSize: 12, fontFamily: mono, color: "#0a0a0a", diff --git a/src/components/markdown/CodeBlock.tsx b/src/components/markdown/CodeBlock.tsx index a7f529d..df2bd3f 100644 --- a/src/components/markdown/CodeBlock.tsx +++ b/src/components/markdown/CodeBlock.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform } from "react-native" +import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform, ScrollView } from "react-native" import * as Clipboard from "expo-clipboard" interface Props { @@ -27,9 +27,11 @@ export function CodeBlock({ code, language }: Props) { {copied ? "Copied!" : "Copy"} - - {code} - + + + {code} + + ) } @@ -72,12 +74,14 @@ const styles = StyleSheet.create({ copyBtnDark: { color: "#a78bfa", }, + codeScroll: { + padding: 12, + }, code: { fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", fontSize: 13, lineHeight: 20, color: "#1a1a1a", - padding: 12, }, codeDark: { color: "#e5e5e5",