Initial commit: OpenCode mobile app (extracted from opencode monorepo)
This commit is contained in:
47
app/(tabs)/_layout.tsx
Normal file
47
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Tabs } from "expo-router"
|
||||
import { useColorScheme } from "react-native"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
||||
tabBarInactiveTintColor: isDark ? "#666666" : "#999999",
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||
borderTopColor: isDark ? "#1a1a1a" : "#e5e5e5",
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||
},
|
||||
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Sessions",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="connections"
|
||||
options={{
|
||||
title: "Connections",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
387
app/(tabs)/connections.tsx
Normal file
387
app/(tabs)/connections.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
|
||||
import { router } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import { useSettings } from "../../src/stores/settings"
|
||||
import type { ServerConnection } from "../../src/lib/types"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200] as const
|
||||
|
||||
function ConnectionItem({
|
||||
connection,
|
||||
isDark,
|
||||
isActive,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
connection: ServerConnection
|
||||
isDark: boolean
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.connectionItem,
|
||||
isDark && styles.connectionItemDark,
|
||||
isActive && styles.connectionItemActive,
|
||||
isActive && isDark && styles.connectionItemActiveDark,
|
||||
]}
|
||||
onPress={onSelect}
|
||||
onLongPress={handleLongPress}
|
||||
>
|
||||
<View style={styles.connectionIcon}>
|
||||
<Ionicons name={typeIcon} size={24} color={isActive ? "#22c55e" : isDark ? "#888888" : "#666666"} />
|
||||
</View>
|
||||
<View style={styles.connectionContent}>
|
||||
<View style={styles.connectionHeader}>
|
||||
<Text
|
||||
style={[
|
||||
styles.connectionName,
|
||||
isDark && styles.textDark,
|
||||
isActive && styles.connectionNameActive,
|
||||
isActive && isDark && styles.connectionNameActiveDark,
|
||||
]}
|
||||
>
|
||||
{connection.name}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
|
||||
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>Active</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.connectionUrl, isDark && styles.metaDark, isActive && styles.connectionUrlActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{connection.url}
|
||||
</Text>
|
||||
{connection.lastConnected && (
|
||||
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
|
||||
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity onPress={onEdit} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={isDark ? "#666666" : "#999999"} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConnectionsScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
|
||||
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 (
|
||||
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||
<FlatList
|
||||
data={connections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ConnectionItem
|
||||
connection={item}
|
||||
isDark={isDark}
|
||||
isActive={activeConnection?.id === item.id}
|
||||
onSelect={() => setActiveConnection(item.id)}
|
||||
onEdit={() => router.push(`/connection/${item.id}`)}
|
||||
onDelete={() => handleDelete(item)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
|
||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||
Add a connection to your OpenCode server
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<View style={[styles.header, isDark && styles.headerDark]}>
|
||||
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
|
||||
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>Preferences</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingLabel}>
|
||||
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.settingText, isDark && styles.textDark]}>Messages per page</Text>
|
||||
</View>
|
||||
<View style={styles.pagePicker}>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<TouchableOpacity
|
||||
key={size}
|
||||
style={[
|
||||
styles.pageOption,
|
||||
isDark && styles.pageOptionDark,
|
||||
pageSize === size && styles.pageOptionActive,
|
||||
pageSize === size && isDark && styles.pageOptionActiveDark,
|
||||
]}
|
||||
onPress={() => setPageSize(size)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.pageOptionText,
|
||||
isDark && styles.metaDark,
|
||||
pageSize === size && styles.pageOptionTextActive,
|
||||
]}
|
||||
>
|
||||
{size}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
|
||||
How many messages to load when opening a session. Lower = faster.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentContainerStyle={connections.length === 0 ? styles.emptyContent : undefined}
|
||||
/>
|
||||
|
||||
{/* FAB to add connection */}
|
||||
<TouchableOpacity style={[styles.fab, isDark && styles.fabDark]} onPress={() => router.push("/connection/add")}>
|
||||
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e5e5",
|
||||
},
|
||||
headerDark: {
|
||||
borderBottomColor: "#1a1a1a",
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
},
|
||||
connectionItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e5e5",
|
||||
},
|
||||
connectionItemDark: {
|
||||
borderBottomColor: "#1a1a1a",
|
||||
},
|
||||
connectionItemActive: {
|
||||
backgroundColor: "#f0fdf4",
|
||||
},
|
||||
connectionItemActiveDark: {
|
||||
backgroundColor: "#1a2e1a",
|
||||
},
|
||||
connectionIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: "#f5f5f5",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
connectionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
connectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
connectionName: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
connectionNameActive: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
connectionNameActiveDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
textDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
activeBadge: {
|
||||
backgroundColor: "#22c55e",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
activeBadgeDark: {
|
||||
backgroundColor: "#16a34a",
|
||||
},
|
||||
activeBadgeText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
activeBadgeTextDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
connectionUrl: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
marginTop: 2,
|
||||
},
|
||||
connectionUrlActive: {
|
||||
color: "#888888",
|
||||
},
|
||||
connectionMeta: {
|
||||
fontSize: 12,
|
||||
color: "#999999",
|
||||
marginTop: 4,
|
||||
},
|
||||
metaDark: {
|
||||
color: "#888888",
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 32,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
marginTop: 16,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
color: "#666666",
|
||||
marginTop: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContent: {
|
||||
flex: 1,
|
||||
},
|
||||
fab: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: "#0a0a0a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
fabDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
settingsSection: {
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#e5e5e5",
|
||||
marginTop: 16,
|
||||
gap: 10,
|
||||
},
|
||||
settingsSectionDark: {
|
||||
borderTopColor: "#1a1a1a",
|
||||
},
|
||||
settingsTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
settingLabel: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
settingText: {
|
||||
fontSize: 14,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
pagePicker: {
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
},
|
||||
pageOption: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e5e5",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
pageOptionDark: {
|
||||
borderColor: "#2a2a2a",
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
pageOptionActive: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
borderColor: "#0a0a0a",
|
||||
},
|
||||
pageOptionActiveDark: {
|
||||
backgroundColor: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
pageOptionText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: "#666666",
|
||||
},
|
||||
pageOptionTextActive: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
settingHint: {
|
||||
fontSize: 12,
|
||||
color: "#999999",
|
||||
},
|
||||
})
|
||||
836
app/(tabs)/index.tsx
Normal file
836
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,836 @@
|
||||
import { useEffect, useCallback, useState, useRef } from "react"
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
useColorScheme,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
TextInput,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native"
|
||||
import { router } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useSessions } from "../../src/stores/sessions"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import type BottomSheet from "@gorhom/bottom-sheet"
|
||||
import type { Session } from "../../src/lib/sdk"
|
||||
import { DirectorySwitcher } from "../../src/components/chat"
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000) return "Just now"
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
isDark,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
session: Session
|
||||
isDark: boolean
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const onPress = () => {
|
||||
router.push(`/session/${session.id}`)
|
||||
}
|
||||
|
||||
const onLongPress = () => {
|
||||
Alert.alert(session.title || "Untitled Session", undefined, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Rename", onPress: onRename },
|
||||
{ text: "Delete", style: "destructive", onPress: onDelete },
|
||||
])
|
||||
}
|
||||
|
||||
// Extract short directory name from session
|
||||
const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<View style={styles.sessionContent}>
|
||||
<View style={styles.sessionHeader}>
|
||||
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
|
||||
{session.title || "Untitled Session"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionMetaRow}>
|
||||
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
|
||||
{formatTime(session.time.updated)}
|
||||
{session.summary && ` · ${session.summary.files} files`}
|
||||
</Text>
|
||||
{shortDir && (
|
||||
<View style={styles.sessionDirBadge}>
|
||||
<Ionicons name="folder-outline" size={12} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.sessionDirText, isDark && styles.metaDark]}>{shortDir}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
// Get short directory name (last folder or project name)
|
||||
function getShortPath(
|
||||
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
|
||||
): string {
|
||||
if (!project) return ""
|
||||
if (project.name) return project.name
|
||||
if (!project.path?.absolute) return ""
|
||||
const parts = project.path.absolute.split("/").filter(Boolean)
|
||||
return parts[parts.length - 1] || project.path.absolute
|
||||
}
|
||||
|
||||
export default function SessionsScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const [showNewSession, setShowNewSession] = useState(false)
|
||||
const [customDir, setCustomDir] = useState("")
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [renaming, setRenaming] = useState<Session | null>(null)
|
||||
const [renameText, setRenameText] = useState("")
|
||||
|
||||
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
|
||||
const {
|
||||
activeConnection,
|
||||
client,
|
||||
currentProject,
|
||||
serverHome,
|
||||
refreshProject,
|
||||
clientForDirectory,
|
||||
switchDirectory,
|
||||
addRecentDirectory,
|
||||
recentDirectories,
|
||||
} = useConnections()
|
||||
const dirSheetRef = useRef<BottomSheet>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const handleSwitchDirectory = useCallback(
|
||||
async (dir?: string) => {
|
||||
await switchDirectory(dir)
|
||||
loadSessions()
|
||||
refreshProject()
|
||||
},
|
||||
[switchDirectory, loadSessions, refreshProject],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
loadSessions()
|
||||
refreshProject()
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await Promise.all([loadSessions(), refreshProject()])
|
||||
setRefreshing(false)
|
||||
}, [])
|
||||
|
||||
const handleRename = useCallback((session: Session) => {
|
||||
setRenameText(session.title || "")
|
||||
setRenaming(session)
|
||||
}, [])
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
const title = renameText.trim()
|
||||
if (!title || !renaming || !client) return
|
||||
await client.session.update(renaming.id, { title })
|
||||
setRenaming(null)
|
||||
setRenameText("")
|
||||
loadSessions()
|
||||
}, [renaming, renameText, client, loadSessions])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(session: Session) => {
|
||||
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteSession(session.id)
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
[deleteSession],
|
||||
)
|
||||
|
||||
const onCreateSession = async () => {
|
||||
const session = await createSession()
|
||||
if (session) {
|
||||
router.push(`/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onCreateInDirectory = async (dir?: string) => {
|
||||
if (!activeConnection) return
|
||||
|
||||
setIsCreating(true)
|
||||
|
||||
// If a custom directory is specified, use a one-off client for that directory
|
||||
// so we don't mutate the connection's default project
|
||||
if (dir && dir.trim()) {
|
||||
const dirClient = clientForDirectory(dir.trim())
|
||||
if (!dirClient) {
|
||||
setIsCreating(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const session = await dirClient.session.create({})
|
||||
addRecentDirectory(dir.trim())
|
||||
setIsCreating(false)
|
||||
setShowNewSession(false)
|
||||
setCustomDir("")
|
||||
if (session) {
|
||||
router.push({ pathname: `/session/[id]`, params: { id: session.id, directory: dir.trim() } })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create session in directory:", error)
|
||||
Alert.alert("Error", "Failed to create session in that directory.")
|
||||
setIsCreating(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const session = await createSession()
|
||||
setIsCreating(false)
|
||||
setShowNewSession(false)
|
||||
setCustomDir("")
|
||||
if (session) {
|
||||
router.push(`/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onFabPress = () => {
|
||||
// Quick create in current project
|
||||
onCreateSession()
|
||||
}
|
||||
|
||||
const onFabLongPress = () => {
|
||||
// Show modal with more options
|
||||
setCustomDir("")
|
||||
setShowNewSession(true)
|
||||
}
|
||||
|
||||
if (!activeConnection) {
|
||||
return (
|
||||
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
||||
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
|
||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||
onPress={() => router.push("/connection/add")}
|
||||
>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const shortPath = getShortPath(currentProject)
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||
{/* Connection indicator — tap to switch project */}
|
||||
<TouchableOpacity
|
||||
style={[styles.connectionBar, isDark && styles.connectionBarDark]}
|
||||
onPress={() => dirSheetRef.current?.expand()}
|
||||
onLongPress={() => router.push("/(tabs)/connections")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.connectionInfo}>
|
||||
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} />
|
||||
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
|
||||
{activeConnection.name}
|
||||
</Text>
|
||||
{shortPath && (
|
||||
<>
|
||||
<Ionicons name="folder" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.projectPath, isDark && styles.metaDark]} numberOfLines={1}>
|
||||
{shortPath}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons name="swap-horizontal-outline" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBar}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={sessions}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<SessionItem
|
||||
session={item}
|
||||
isDark={isDark}
|
||||
onRename={() => handleRename(item)}
|
||||
onDelete={() => handleDelete(item)}
|
||||
/>
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
}
|
||||
ListEmptyComponent={
|
||||
isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyList}>
|
||||
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
|
||||
/>
|
||||
|
||||
{/* FAB to create new session */}
|
||||
<TouchableOpacity
|
||||
style={[styles.fab, isDark && styles.fabDark]}
|
||||
onPress={onFabPress}
|
||||
onLongPress={onFabLongPress}
|
||||
delayLongPress={500}
|
||||
>
|
||||
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* New Session Info Modal */}
|
||||
<Modal visible={showNewSession} animationType="slide" transparent>
|
||||
<KeyboardAvoidingView style={styles.modalOverlay} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
|
||||
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
|
||||
<TouchableOpacity onPress={() => setShowNewSession(false)}>
|
||||
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalBody}>
|
||||
{/* Current directory */}
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Directory</Text>
|
||||
<View style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}>
|
||||
<Ionicons name="folder" size={20} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
|
||||
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Custom directory input */}
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||
Or use a different folder
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={customDir}
|
||||
onChangeText={(text) => {
|
||||
// Expand ~ to server home directory
|
||||
if (serverHome && text.startsWith("~/")) {
|
||||
setCustomDir(serverHome + text.slice(1))
|
||||
} else if (serverHome && text === "~") {
|
||||
setCustomDir(serverHome)
|
||||
} else {
|
||||
setCustomDir(text)
|
||||
}
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{/* Quick path shortcuts */}
|
||||
{serverHome && (
|
||||
<View style={styles.pathChips}>
|
||||
<TouchableOpacity
|
||||
style={[styles.pathChip, isDark && styles.pathChipDark]}
|
||||
onPress={() => setCustomDir(serverHome)}
|
||||
>
|
||||
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.pathChip, isDark && styles.pathChipDark]}
|
||||
onPress={() => setCustomDir(serverHome + "/")}
|
||||
>
|
||||
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~/</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
{customDir.trim() ? (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
styles.modalButtonPrimary,
|
||||
isDark && styles.modalButtonPrimaryDark,
|
||||
styles.modalButtonFull,
|
||||
]}
|
||||
onPress={() => onCreateInDirectory(customDir)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
styles.modalButtonPrimary,
|
||||
isDark && styles.modalButtonPrimaryDark,
|
||||
styles.modalButtonFull,
|
||||
]}
|
||||
onPress={() => onCreateInDirectory()}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||
Create Session
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
|
||||
{/* Rename modal */}
|
||||
<Modal visible={!!renaming} animationType="fade" transparent>
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.modalOverlay, { justifyContent: "center" }]}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
||||
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
|
||||
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||
value={renameText}
|
||||
onChangeText={setRenameText}
|
||||
onSubmitEditing={submitRename}
|
||||
returnKeyType="done"
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
autoCapitalize="sentences"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<View style={styles.renameActions}>
|
||||
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
|
||||
<Text style={styles.renameBtnCancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
|
||||
onPress={submitRename}
|
||||
disabled={!renameText.trim()}
|
||||
>
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
|
||||
{/* Directory switcher bottom sheet */}
|
||||
<DirectorySwitcher
|
||||
sheetRef={dirSheetRef}
|
||||
current={activeConnection?.directory}
|
||||
recents={recentDirectories}
|
||||
serverHome={serverHome}
|
||||
isDark={isDark}
|
||||
onSwitch={handleSwitchDirectory}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
connectionBar: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e5e5",
|
||||
},
|
||||
connectionBarDark: {
|
||||
borderBottomColor: "#1a1a1a",
|
||||
},
|
||||
connectionInfo: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
connectionDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
connectionName: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
connectionUrl: {
|
||||
fontSize: 12,
|
||||
color: "#666666",
|
||||
},
|
||||
projectPath: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
flex: 1,
|
||||
},
|
||||
errorBar: {
|
||||
backgroundColor: "#fef2f2",
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#fecaca",
|
||||
},
|
||||
errorText: {
|
||||
color: "#dc2626",
|
||||
fontSize: 14,
|
||||
},
|
||||
sessionItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e5e5",
|
||||
},
|
||||
sessionItemDark: {
|
||||
borderBottomColor: "#1a1a1a",
|
||||
},
|
||||
sessionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
sessionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
sessionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#0a0a0a",
|
||||
marginBottom: 4,
|
||||
},
|
||||
textDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
sessionMeta: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
},
|
||||
sessionMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
sessionDirBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
backgroundColor: "#f5f5f5",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
sessionDirText: {
|
||||
fontSize: 11,
|
||||
color: "#666666",
|
||||
},
|
||||
metaDark: {
|
||||
color: "#888888",
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 32,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
marginTop: 16,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
color: "#666666",
|
||||
marginTop: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
marginTop: 24,
|
||||
},
|
||||
addButtonDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
addButtonText: {
|
||||
color: "#ffffff",
|
||||
fontWeight: "600",
|
||||
},
|
||||
addButtonTextDark: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 64,
|
||||
},
|
||||
emptyList: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 64,
|
||||
},
|
||||
emptyListText: {
|
||||
fontSize: 16,
|
||||
color: "#666666",
|
||||
},
|
||||
emptyContent: {
|
||||
flex: 1,
|
||||
},
|
||||
fab: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: "#0a0a0a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
fabDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalDismiss: {
|
||||
flex: 1,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
modalContentDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
modalBody: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
modalLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#666666",
|
||||
marginBottom: 8,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
modalDirBox: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
backgroundColor: "#f5f5f5",
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
modalDirBoxDark: {
|
||||
backgroundColor: "#2a2a2a",
|
||||
},
|
||||
modalDirText: {
|
||||
fontSize: 15,
|
||||
color: "#0a0a0a",
|
||||
flex: 1,
|
||||
},
|
||||
modalInput: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 15,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
modalInputDark: {
|
||||
backgroundColor: "#2a2a2a",
|
||||
color: "#ffffff",
|
||||
},
|
||||
pathChips: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
pathChip: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: "#e8e5f0",
|
||||
borderRadius: 16,
|
||||
},
|
||||
pathChipDark: {
|
||||
backgroundColor: "#2a2040",
|
||||
},
|
||||
pathChipText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#6d28d9",
|
||||
},
|
||||
pathChipTextDark: {
|
||||
color: "#c4b5fd",
|
||||
},
|
||||
modalHint: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
marginTop: 12,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
modalButtonSecondary: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
modalButtonSecondaryDark: {
|
||||
backgroundColor: "#2a2a2a",
|
||||
},
|
||||
modalButtonPrimary: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
modalButtonPrimaryDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
modalButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
modalButtonTextPrimary: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#ffffff",
|
||||
},
|
||||
modalButtonTextPrimaryDark: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
modalButtonFull: {
|
||||
flex: 0,
|
||||
width: "100%",
|
||||
},
|
||||
// Rename modal
|
||||
renameCard: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginHorizontal: 32,
|
||||
gap: 16,
|
||||
},
|
||||
renameCardDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
renameTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
renameActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: 12,
|
||||
},
|
||||
renameBtn: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
},
|
||||
renameBtnCancel: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
renameBtnCancelText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#888888",
|
||||
},
|
||||
})
|
||||
289
app/(tabs)/settings.tsx
Normal file
289
app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Switch,
|
||||
StyleSheet,
|
||||
useColorScheme,
|
||||
Linking,
|
||||
Alert,
|
||||
} from "react-native"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useAuth } from "../../src/stores/auth"
|
||||
import { useSettings } from "../../src/stores/settings"
|
||||
import {
|
||||
categories,
|
||||
categoryMeta,
|
||||
setup as setupNotifications,
|
||||
granted as notificationsGranted,
|
||||
} from "../../src/lib/notifications"
|
||||
import type { Category } from "../../src/lib/notifications"
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
isDark,
|
||||
right,
|
||||
onPress,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
label: string
|
||||
description?: string
|
||||
isDark: boolean
|
||||
right?: React.ReactNode
|
||||
onPress?: () => void
|
||||
}) {
|
||||
const content = (
|
||||
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
|
||||
<View style={[styles.settingIcon, isDark && styles.settingIconDark]}>
|
||||
<Ionicons name={icon} size={22} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={[styles.settingLabel, isDark && styles.textDark]}>{label}</Text>
|
||||
{description && <Text style={[styles.settingDescription, isDark && styles.metaDark]}>{description}</Text>}
|
||||
</View>
|
||||
{right}
|
||||
</View>
|
||||
)
|
||||
|
||||
if (onPress) {
|
||||
return <TouchableOpacity onPress={onPress}>{content}</TouchableOpacity>
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function SettingSection({ title, children, isDark }: { title: string; children: React.ReactNode; isDark: boolean }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDark && styles.sectionTitleDark]}>{title}</Text>
|
||||
<View style={[styles.sectionContent, isDark && styles.sectionContentDark]}>{children}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
|
||||
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
|
||||
const { notifications, setNotification } = useSettings()
|
||||
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
||||
|
||||
// Check OS permission state on first toggle attempt
|
||||
const handleToggle = useCallback(
|
||||
async (category: Category, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
const ok = await setupNotifications()
|
||||
setOsGranted(ok)
|
||||
if (!ok) {
|
||||
Alert.alert(
|
||||
"Notifications Disabled",
|
||||
"Enable notifications for OpenCode in your device settings to receive alerts.",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
setNotification(category, enabled)
|
||||
},
|
||||
[setNotification],
|
||||
)
|
||||
|
||||
// Lazy-check OS permission for status display
|
||||
if (osGranted === null) {
|
||||
notificationsGranted().then(setOsGranted)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
|
||||
<SettingSection title="Security" isDark={isDark}>
|
||||
<SettingRow
|
||||
icon="finger-print"
|
||||
label="Require Biometric to Open"
|
||||
description={
|
||||
hasBiometrics ? "Use Face ID or Touch ID to unlock the app" : "Biometric authentication not available"
|
||||
}
|
||||
isDark={isDark}
|
||||
right={
|
||||
<Switch
|
||||
value={settings.requireBiometric}
|
||||
onValueChange={(value) => updateSettings({ requireBiometric: value })}
|
||||
disabled={!hasBiometrics}
|
||||
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
icon="lock-closed"
|
||||
label="Require Biometric to Send"
|
||||
description="Authenticate before sending messages"
|
||||
isDark={isDark}
|
||||
right={
|
||||
<Switch
|
||||
value={settings.requireBiometricForMessages}
|
||||
onValueChange={(value) => updateSettings({ requireBiometricForMessages: value })}
|
||||
disabled={!hasBiometrics || !settings.requireBiometric}
|
||||
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{settings.requireBiometric && (
|
||||
<SettingRow
|
||||
icon="exit"
|
||||
label="Lock App Now"
|
||||
description="Require authentication to reopen"
|
||||
isDark={isDark}
|
||||
onPress={lock}
|
||||
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||
/>
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection title="Notifications" isDark={isDark}>
|
||||
{categories.map((category) => {
|
||||
const meta = categoryMeta[category]
|
||||
return (
|
||||
<SettingRow
|
||||
key={category}
|
||||
icon={meta.icon as keyof typeof Ionicons.glyphMap}
|
||||
label={meta.label}
|
||||
description={meta.description}
|
||||
isDark={isDark}
|
||||
right={
|
||||
<Switch
|
||||
value={notifications[category]}
|
||||
onValueChange={(value) => handleToggle(category, value)}
|
||||
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{osGranted === false && (
|
||||
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
|
||||
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
|
||||
Notifications are disabled at the system level. Enable them in Settings to receive alerts.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection title="About" isDark={isDark}>
|
||||
<SettingRow icon="information-circle" label="Version" description="1.0.0" isDark={isDark} />
|
||||
<SettingRow
|
||||
icon="logo-github"
|
||||
label="GitHub"
|
||||
description="View source code"
|
||||
isDark={isDark}
|
||||
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
|
||||
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||
/>
|
||||
<SettingRow
|
||||
icon="document-text"
|
||||
label="Documentation"
|
||||
description="Learn how to use OpenCode"
|
||||
isDark={isDark}
|
||||
onPress={() => Linking.openURL("https://opencode.ai/docs")}
|
||||
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, isDark && styles.metaDark]}>OpenCode Mobile</Text>
|
||||
<Text style={[styles.footerText, isDark && styles.metaDark]}>
|
||||
Connect to your AI coding assistant from anywhere
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
section: {
|
||||
marginTop: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
color: "#666666",
|
||||
marginLeft: 16,
|
||||
marginBottom: 8,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
sectionTitleDark: {
|
||||
color: "#888888",
|
||||
},
|
||||
sectionContent: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#e5e5e5",
|
||||
},
|
||||
sectionContentDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderColor: "#2a2a2a",
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e5e5",
|
||||
},
|
||||
settingRowDark: {
|
||||
borderBottomColor: "#2a2a2a",
|
||||
},
|
||||
settingIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#f5f5f5",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
settingIconDark: {
|
||||
backgroundColor: "#2a2a2a",
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 16,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
textDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
marginTop: 2,
|
||||
},
|
||||
metaDark: {
|
||||
color: "#888888",
|
||||
},
|
||||
footer: {
|
||||
alignItems: "center",
|
||||
padding: 32,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 13,
|
||||
color: "#999999",
|
||||
textAlign: "center",
|
||||
},
|
||||
})
|
||||
120
app/_layout.tsx
Normal file
120
app/_layout.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { Stack, router } from "expo-router"
|
||||
import { StatusBar } from "expo-status-bar"
|
||||
import { useColorScheme, View, ActivityIndicator } from "react-native"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
||||
import { useAuth } from "../src/stores/auth"
|
||||
import { useConnections } from "../src/stores/connections"
|
||||
import { useEvents } from "../src/stores/events"
|
||||
import { useCatalog } from "../src/stores/catalog"
|
||||
import { useSettings } from "../src/stores/settings"
|
||||
import { AuthGate } from "../src/components/AuthGate"
|
||||
import * as notifications from "../src/lib/notifications"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
|
||||
const { initialize: initAuth, isLoading: authLoading } = useAuth()
|
||||
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
|
||||
const sseStarted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
initAuth()
|
||||
loadConnections()
|
||||
useSettings.getState().load()
|
||||
|
||||
// Connect notification preferences to the notification module
|
||||
notifications.configure(() => useSettings.getState().notifications)
|
||||
|
||||
// Navigate to session when user taps a notification
|
||||
return notifications.onTap((data) => {
|
||||
router.push(`/session/${data.sessionId}`)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Connect/disconnect SSE and load catalog when client changes
|
||||
useEffect(() => {
|
||||
if (client && !sseStarted.current) {
|
||||
sseStarted.current = true
|
||||
useEvents.getState().connect()
|
||||
useCatalog.getState().load()
|
||||
} else if (!client && sseStarted.current) {
|
||||
sseStarted.current = false
|
||||
useEvents.getState().disconnect()
|
||||
}
|
||||
return () => {
|
||||
if (sseStarted.current) {
|
||||
sseStarted.current = false
|
||||
useEvents.getState().disconnect()
|
||||
}
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const isLoading = authLoading || connectionsLoading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthGate>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||
},
|
||||
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
||||
contentStyle: {
|
||||
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="session/[id]"
|
||||
options={{
|
||||
title: "Session",
|
||||
presentation: "card",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="connection/add"
|
||||
options={{
|
||||
title: "Add Connection",
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="connection/[id]"
|
||||
options={{
|
||||
title: "Edit Connection",
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style={isDark ? "light" : "dark"} />
|
||||
</AuthGate>
|
||||
</QueryClientProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
413
app/connection/[id].tsx
Normal file
413
app/connection/[id].tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
useColorScheme,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native"
|
||||
import { router, useLocalSearchParams } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import type { ConnectionType } from "../../src/lib/types"
|
||||
|
||||
const CONNECTION_TYPES: Array<{
|
||||
type: ConnectionType
|
||||
label: string
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
}> = [
|
||||
{ type: "local", label: "Local", icon: "wifi" },
|
||||
{ type: "tunnel", label: "Tunnel", icon: "globe" },
|
||||
{ type: "cloud", label: "Cloud", icon: "cloud" },
|
||||
]
|
||||
|
||||
export default function EditConnectionScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
|
||||
const { connections, updateConnection, removeConnection, testConnection } = useConnections()
|
||||
|
||||
const connection = connections.find((c) => c.id === id)
|
||||
|
||||
const [type, setType] = useState<ConnectionType>(connection?.type || "local")
|
||||
const [name, setName] = useState(connection?.name || "")
|
||||
const [url, setUrl] = useState(connection?.url || "")
|
||||
const [directory, setDirectory] = useState(connection?.directory || "")
|
||||
const [username, setUsername] = useState(connection?.username || "")
|
||||
const [password, setPassword] = useState("")
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
setType(connection.type)
|
||||
setName(connection.name)
|
||||
setUrl(connection.url)
|
||||
setDirectory(connection.directory || "")
|
||||
setUsername(connection.username || "")
|
||||
}
|
||||
}, [connection])
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<View style={[styles.container, isDark && styles.containerDark, styles.center]}>
|
||||
<Text style={[styles.errorText, isDark && styles.textDark]}>Connection not found</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!url.trim()) {
|
||||
Alert.alert("Error", "Please enter a server URL")
|
||||
return
|
||||
}
|
||||
|
||||
setIsTesting(true)
|
||||
const success = await testConnection(
|
||||
{
|
||||
id: connection.id,
|
||||
name: name || "Test",
|
||||
type,
|
||||
url: url.trim(),
|
||||
directory: directory.trim() || undefined,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsTesting(false)
|
||||
|
||||
Alert.alert(
|
||||
success ? "Success" : "Failed",
|
||||
success ? "Connection successful!" : "Could not connect to the server. Check the URL and credentials.",
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Error", "Please enter a connection name")
|
||||
return
|
||||
}
|
||||
if (!url.trim()) {
|
||||
Alert.alert("Error", "Please enter a server URL")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
await updateConnection(connection.id, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: url.trim(),
|
||||
directory: directory.trim() || undefined,
|
||||
username: username.trim() || undefined,
|
||||
})
|
||||
setIsSaving(false)
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await removeConnection(connection.id)
|
||||
router.back()
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, isDark && styles.containerDark]}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Connection Type */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
||||
<View style={styles.typeContainer}>
|
||||
{CONNECTION_TYPES.map((t) => (
|
||||
<TouchableOpacity
|
||||
key={t.type}
|
||||
style={[
|
||||
styles.typeOption,
|
||||
isDark && styles.typeOptionDark,
|
||||
type === t.type && styles.typeOptionSelected,
|
||||
type === t.type && isDark && styles.typeOptionSelectedDark,
|
||||
]}
|
||||
onPress={() => setType(t.type)}
|
||||
>
|
||||
<Ionicons
|
||||
name={t.icon}
|
||||
size={20}
|
||||
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeLabel,
|
||||
isDark && styles.textDark,
|
||||
type === t.type && styles.typeLabelSelected,
|
||||
type === t.type && isDark && styles.typeLabelSelectedDark,
|
||||
]}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="My Server"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
{/* URL */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="http://192.168.1.100:4096"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
|
||||
{/* Directory */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={directory}
|
||||
onChangeText={setDirectory}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||
Leave empty to use the server's current directory. Sessions will be created in this folder.
|
||||
</Text>
|
||||
|
||||
{/* Auth */}
|
||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="admin"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password (leave empty to keep existing)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.testButton, isDark && styles.testButtonDark]}
|
||||
onPress={handleTest}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="pulse" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
<Text style={[styles.testButtonText, isDark && styles.textDark]}>Test Connection</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isDark && styles.saveButtonDark]}
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>Save Changes</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
||||
<Ionicons name="trash-outline" size={20} color="#ef4444" />
|
||||
<Text style={styles.deleteButtonText}>Delete Connection</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
center: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: "#666666",
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
labelDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
typeContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
typeOption: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#f5f5f5",
|
||||
gap: 6,
|
||||
},
|
||||
typeOptionDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
typeOptionSelected: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
typeOptionSelectedDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
typeLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: "#666666",
|
||||
},
|
||||
textDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
typeLabelSelected: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
typeLabelSelectedDark: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
inputDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
color: "#ffffff",
|
||||
},
|
||||
hint: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
marginTop: 6,
|
||||
},
|
||||
hintDark: {
|
||||
color: "#888888",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
marginTop: 32,
|
||||
marginBottom: 8,
|
||||
},
|
||||
actions: {
|
||||
marginTop: 32,
|
||||
gap: 12,
|
||||
},
|
||||
testButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e5e5",
|
||||
},
|
||||
testButtonDark: {
|
||||
borderColor: "#333333",
|
||||
},
|
||||
testButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
saveButton: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
saveButtonDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#ffffff",
|
||||
},
|
||||
saveButtonTextDark: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
deleteButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#fef2f2",
|
||||
},
|
||||
deleteButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#ef4444",
|
||||
},
|
||||
})
|
||||
548
app/connection/add.tsx
Normal file
548
app/connection/add.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
useColorScheme,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native"
|
||||
import { router } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import type { ConnectionType } from "../../src/lib/types"
|
||||
|
||||
export default function AddConnectionScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
|
||||
const { addConnection, testConnection } = useConnections()
|
||||
|
||||
const [mode, setMode] = useState<"quick" | "advanced">("quick")
|
||||
const [type, setType] = useState<ConnectionType>("local")
|
||||
const [name, setName] = useState("")
|
||||
const [ip, setIp] = useState("")
|
||||
const [port, setPort] = useState("4096")
|
||||
const [url, setUrl] = useState("")
|
||||
const [directory, setDirectory] = useState("")
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
|
||||
const buildUrl = () => {
|
||||
if (mode === "advanced") return url.trim()
|
||||
if (!ip.trim()) return ""
|
||||
return `http://${ip.trim()}:${port || "4096"}`
|
||||
}
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
const serverUrl = buildUrl()
|
||||
if (!serverUrl) {
|
||||
Alert.alert("Error", "Please enter your computer's IP address")
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
|
||||
// Test connection first
|
||||
const success = await testConnection(
|
||||
{
|
||||
id: "",
|
||||
name: name || "My Server",
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
// Save and go back
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim() || "My Server",
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsConnecting(false)
|
||||
router.back()
|
||||
} else {
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
"Connection Failed",
|
||||
"Could not connect to the server.\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same WiFi network\n3. The IP address is correct",
|
||||
[{ text: "OK" }],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdvancedSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Error", "Please enter a connection name")
|
||||
return
|
||||
}
|
||||
if (!url.trim()) {
|
||||
Alert.alert("Error", "Please enter a server URL")
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: url.trim(),
|
||||
directory: directory.trim() || undefined,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsConnecting(false)
|
||||
router.back()
|
||||
}
|
||||
|
||||
// Quick connect mode - simplified
|
||||
if (mode === "quick") {
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, isDark && styles.containerDark]}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.quickHeader}>
|
||||
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
<Text style={[styles.quickTitle, isDark && styles.textDark]}>Connect to OpenCode</Text>
|
||||
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>
|
||||
Enter your computer's IP address to connect
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* IP Address */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>IP Address</Text>
|
||||
<View style={styles.ipRow}>
|
||||
<TextInput
|
||||
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
|
||||
placeholder="192.168.1.100"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={ip}
|
||||
onChangeText={setIp}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
<Text style={[styles.ipColon, isDark && styles.textDark]}>:</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.portInput, isDark && styles.inputDark]}
|
||||
placeholder="4096"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={port}
|
||||
onChangeText={setPort}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Optional name */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name (optional)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="My Mac"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
{/* Password if needed */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password (if set on server)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="Leave empty if none"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{/* Connect button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, isDark && styles.connectButtonDark]}
|
||||
onPress={handleQuickConnect}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="flash" size={20} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Connect</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Help text */}
|
||||
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
|
||||
<Text style={[styles.helpTitle, isDark && styles.textDark]}>How to find your IP:</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark]}>
|
||||
On your Mac, run:{"\n"}
|
||||
<Text style={styles.code}>ipconfig getifaddr en0</Text>
|
||||
</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||
Tailscale examples:{"\n"}
|
||||
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
||||
{"\n"}
|
||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
||||
</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||
Use <Text style={styles.code}>http://</Text> for local and Tailscale addresses unless TLS is configured,
|
||||
then use <Text style={styles.code}>https://</Text>.
|
||||
</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||
Make sure OpenCode is running:{"\n"}
|
||||
<Text style={styles.code}>opencode serve --hostname 0.0.0.0</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Advanced mode link */}
|
||||
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
|
||||
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
|
||||
Advanced options (tunnels, cloud, auth)
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
// Advanced mode - full options
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, isDark && styles.containerDark]}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
|
||||
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>Simple mode</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Connection Type */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
||||
<View style={styles.typeContainer}>
|
||||
{[
|
||||
{ type: "local" as const, label: "Local", icon: "wifi" as const },
|
||||
{ type: "tunnel" as const, label: "Tunnel", icon: "globe" as const },
|
||||
{ type: "cloud" as const, label: "Cloud", icon: "cloud" as const },
|
||||
].map((t) => (
|
||||
<TouchableOpacity
|
||||
key={t.type}
|
||||
style={[
|
||||
styles.typeOption,
|
||||
isDark && styles.typeOptionDark,
|
||||
type === t.type && styles.typeOptionSelected,
|
||||
type === t.type && isDark && styles.typeOptionSelectedDark,
|
||||
]}
|
||||
onPress={() => setType(t.type)}
|
||||
>
|
||||
<Ionicons
|
||||
name={t.icon}
|
||||
size={20}
|
||||
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeLabel,
|
||||
isDark && styles.textDark,
|
||||
type === t.type && styles.typeLabelSelected,
|
||||
type === t.type && isDark && styles.typeLabelSelectedDark,
|
||||
]}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="My Server"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
{/* URL */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder={
|
||||
type === "local"
|
||||
? "http://192.168.1.100:4096"
|
||||
: type === "tunnel"
|
||||
? "https://your-tunnel.trycloudflare.com"
|
||||
: "https://api.opencode.ai"
|
||||
}
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||
Local/Tailscale examples: <Text style={styles.code}>http://100.64.12.34:4096</Text> or{" "}
|
||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>. Use <Text style={styles.code}>https://</Text>
|
||||
only when TLS is configured.
|
||||
</Text>
|
||||
|
||||
{/* Directory */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={directory}
|
||||
onChangeText={setDirectory}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||
Leave empty to use the server's current directory, or specify a path to work in a different folder.
|
||||
</Text>
|
||||
|
||||
{/* Auth */}
|
||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="admin"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="password"
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{/* Save */}
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, isDark && styles.connectButtonDark, { marginTop: 32 }]}
|
||||
onPress={handleAdvancedSave}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Save Connection</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
containerDark: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
// Quick connect styles
|
||||
quickHeader: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 24,
|
||||
},
|
||||
quickTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: "700",
|
||||
color: "#0a0a0a",
|
||||
marginTop: 16,
|
||||
},
|
||||
quickSubtitle: {
|
||||
fontSize: 15,
|
||||
color: "#666666",
|
||||
marginTop: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
ipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
ipInput: {
|
||||
flex: 1,
|
||||
},
|
||||
ipColon: {
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
portInput: {
|
||||
width: 80,
|
||||
},
|
||||
connectButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#0a0a0a",
|
||||
marginTop: 24,
|
||||
},
|
||||
connectButtonDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
connectButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#ffffff",
|
||||
},
|
||||
connectButtonTextDark: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
helpBox: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 24,
|
||||
},
|
||||
helpBoxDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
helpTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
marginBottom: 8,
|
||||
},
|
||||
helpText: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
lineHeight: 20,
|
||||
},
|
||||
code: {
|
||||
fontFamily: "monospace",
|
||||
backgroundColor: "#e5e5e5",
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
advancedLink: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
paddingVertical: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
advancedLinkText: {
|
||||
fontSize: 14,
|
||||
color: "#666666",
|
||||
},
|
||||
backToQuick: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
backToQuickText: {
|
||||
fontSize: 14,
|
||||
color: "#666666",
|
||||
},
|
||||
// Original styles
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
labelDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
typeContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
typeOption: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#f5f5f5",
|
||||
gap: 6,
|
||||
},
|
||||
typeOptionDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
typeOptionSelected: {
|
||||
backgroundColor: "#0a0a0a",
|
||||
},
|
||||
typeOptionSelectedDark: {
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
typeLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: "#666666",
|
||||
},
|
||||
textDark: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
typeLabelSelected: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
typeLabelSelectedDark: {
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
hint: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
marginTop: 8,
|
||||
},
|
||||
hintDark: {
|
||||
color: "#888888",
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: "#0a0a0a",
|
||||
},
|
||||
inputDark: {
|
||||
backgroundColor: "#1a1a1a",
|
||||
color: "#ffffff",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#0a0a0a",
|
||||
marginTop: 32,
|
||||
marginBottom: 8,
|
||||
},
|
||||
})
|
||||
817
app/session/[id].tsx
Normal file
817
app/session/[id].tsx
Normal file
@@ -0,0 +1,817 @@
|
||||
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",
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
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<FlatList>(null)
|
||||
const modelSheetRef = useRef<BottomSheet>(null)
|
||||
const [input, setInput] = useState("")
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
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) : ""
|
||||
|
||||
const allCommands = useMemo<SlashCommand[]>(() => {
|
||||
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],
|
||||
)
|
||||
|
||||
// --- 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<Attachment> {
|
||||
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]
|
||||
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 && 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)
|
||||
}
|
||||
|
||||
// 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 || "#8b5cf6"
|
||||
const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: currentSession?.title || "Session",
|
||||
headerRight: () => (
|
||||
<View style={s.headerRight}>
|
||||
{shortDir && (
|
||||
<View style={[s.dirBadge, isDark && s.dirBadgeDark]}>
|
||||
<Ionicons name="folder-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[s.dirText, isDark && s.dirTextDark]}>{shortDir}</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity onPress={() => setShowInfo((v) => !v)} hitSlop={8}>
|
||||
<Ionicons
|
||||
name={showInfo ? "stats-chart" : "stats-chart-outline"}
|
||||
size={20}
|
||||
color={showInfo ? "#3b82f6" : isDark ? "#888888" : "#666666"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={[s.container, isDark && s.containerDark]}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
||||
>
|
||||
{/* Session info pulldown */}
|
||||
<SessionInfo
|
||||
session={currentSession}
|
||||
messages={messages || []}
|
||||
providers={providers}
|
||||
visible={showInfo}
|
||||
isDark={isDark}
|
||||
hasMore={hasMore}
|
||||
loadingAll={loadingMore}
|
||||
onLoadAll={() => {
|
||||
if (hasMore && !loadingMore) loadOlderMessages()
|
||||
}}
|
||||
onScrollToTop={() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true })
|
||||
}}
|
||||
onClose={() => setShowInfo(false)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={s.loading}>
|
||||
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={s.listWrap}>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messageData}
|
||||
inverted
|
||||
keyExtractor={(item) => item.message.id}
|
||||
renderItem={({ item }) => <MessageBubble message={item.message} parts={item.parts} isDark={isDark} />}
|
||||
contentContainerStyle={s.messageList}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={100}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
// Prevent jump when older messages are prepended
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
ListFooterComponent={
|
||||
loadingMore ? (
|
||||
<View style={s.loadingMore}>
|
||||
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={s.emptyInverted}>
|
||||
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
|
||||
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
{showScrollButton && (
|
||||
<TouchableOpacity style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
|
||||
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{currentSession && <StatusIndicator sessionID={currentSession.id} isDark={isDark} />}
|
||||
|
||||
{/* Permissions */}
|
||||
{permissions.map((perm) => (
|
||||
<PermissionPrompt
|
||||
key={perm.id}
|
||||
permission={perm}
|
||||
isDark={isDark}
|
||||
onReply={(reply) => handlePermissionReply(perm.id, reply)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Questions */}
|
||||
{questions.map((q) => (
|
||||
<QuestionPrompt
|
||||
key={q.id}
|
||||
request={q}
|
||||
isDark={isDark}
|
||||
onReply={(answers) => handleQuestionReply(q.id, answers)}
|
||||
onReject={() => handleQuestionReject(q.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Slash popover */}
|
||||
{slashActive && (
|
||||
<SlashPopover query={slashQuery} commands={allCommands} isDark={isDark} onSelect={handleSlashSelect} />
|
||||
)}
|
||||
|
||||
{/* Agent/model toolbar */}
|
||||
<View style={[s.toolbar, isDark && s.toolbarDark]}>
|
||||
<TouchableOpacity
|
||||
style={[s.agentChip, { borderColor: agentColor }]}
|
||||
onPress={() => cycleAgent()}
|
||||
onLongPress={() => cycleAgent(-1)}
|
||||
>
|
||||
<View style={[s.agentDot, { backgroundColor: agentColor }]} />
|
||||
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || "build"}</Text>
|
||||
<Ionicons name="swap-horizontal-outline" size={12} color={isDark ? "#888888" : "#666666"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[s.modelChip, isDark && s.modelChipDark]}
|
||||
onPress={() => modelSheetRef.current?.expand()}
|
||||
>
|
||||
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
|
||||
{modelLabel}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Attachment preview */}
|
||||
<ImageAttachments attachments={attachments} isDark={isDark} onRemove={removeAttachment} />
|
||||
|
||||
{/* Input */}
|
||||
<View
|
||||
style={[s.inputContainer, isDark && s.inputContainerDark, { paddingBottom: Math.max(12, insets.bottom) }]}
|
||||
>
|
||||
<View style={s.inputRow}>
|
||||
{/* Attach button */}
|
||||
<TouchableOpacity style={s.attachBtn} onPress={pickFromLibrary} onLongPress={pickFromCamera}>
|
||||
<Ionicons name="add-circle-outline" size={26} color={isDark ? "#888888" : "#666666"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Clipboard paste button */}
|
||||
<TouchableOpacity style={s.attachBtn} onPress={pasteFromClipboard}>
|
||||
<Ionicons name="clipboard-outline" size={22} color={isDark ? "#888888" : "#666666"} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TextInput
|
||||
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
|
||||
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
|
||||
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
|
||||
value={speech.listening ? speech.transcript : input}
|
||||
onChangeText={speech.listening ? undefined : setInput}
|
||||
editable={!speech.listening}
|
||||
multiline
|
||||
maxLength={10000}
|
||||
/>
|
||||
{/* Stop button: only when busy and no input */}
|
||||
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
||||
<TouchableOpacity style={s.stopBtn} onPress={abortSession}>
|
||||
<Ionicons name="stop" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Mic button: when no input, not sending, and not listening */}
|
||||
{!isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
||||
<TouchableOpacity style={s.micBtn} onPress={speech.start}>
|
||||
<Ionicons name="mic" size={22} color={isDark ? "#888888" : "#666666"} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Listening indicator: tap to stop */}
|
||||
{speech.listening && (
|
||||
<TouchableOpacity style={s.micBtnActive} onPress={speech.stop}>
|
||||
<Ionicons name="mic" size={22} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Send button: when there's input */}
|
||||
{!speech.listening && (input.trim() || attachments.length > 0) && (
|
||||
<TouchableOpacity style={s.sendBtn} onPress={handleSend}>
|
||||
<Ionicons name="send" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Model picker bottom sheet */}
|
||||
<ModelPicker
|
||||
sheetRef={modelSheetRef}
|
||||
providers={providers}
|
||||
selected={model}
|
||||
isDark={isDark}
|
||||
onSelect={handleModelSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (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 },
|
||||
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" },
|
||||
})
|
||||
Reference in New Issue
Block a user