feat(i18n): extract remaining screens into en/zh-Hans catalogs; full Simplified Chinese coverage (closes #68) (#103)
Extends the i18n infra from #97 (Settings screen only) to the rest of the app: session chat screen, connection add/edit/list screens, sessions list (incl. directory grouping from #96), navigation titles, notifications category metadata, error boundary, telemetry consent modal, auth gate, and every chat UI component (permission/question prompts, status indicator, model/variant pickers, directory switcher/browser, reasoning block, tool call card, session info). - 244 new keys added to en.json/zh-Hans.json with reviewed, natural Simplified Chinese (not machine-garbage), keeping key sets identical. - User content, server URLs, code snippets, log/error-detail text, and diagnostics-classify.ts (pure dependency-free module feeding Sentry/ support reports) are intentionally left untranslated per scope. - Interpolation used for counts/names (e.g. reconnect attempt, files count, connection name in delete confirmations); categoryMeta/ CONNECTION_TYPES switched to labelKey indirection since they're module-level constants evaluated before i18next is guaranteed ready. - Added src/lib/i18n/catalog-parity.test.ts (node --test) asserting en.json/zh-Hans.json expose identical key sets and no empty values, to catch future locale drift. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { Tabs } from "expo-router"
|
import { Tabs } from "expo-router"
|
||||||
import { useColorScheme } from "react-native"
|
import { useColorScheme } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -24,21 +26,21 @@ export default function TabLayout() {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: "Sessions",
|
title: t("nav.sessionsTab"),
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="connections"
|
name="connections"
|
||||||
options={{
|
options={{
|
||||||
title: "Connections",
|
title: t("nav.connectionsTab"),
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: t("nav.settingsTab"),
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
|
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
|
||||||
import { router } from "expo-router"
|
import { router } from "expo-router"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useConnections } from "../../src/stores/connections"
|
import { useConnections } from "../../src/stores/connections"
|
||||||
import { useSettings } from "../../src/stores/settings"
|
import { useSettings } from "../../src/stores/settings"
|
||||||
import type { ServerConnection } from "../../src/lib/types"
|
import type { ServerConnection } from "../../src/lib/types"
|
||||||
@@ -22,13 +23,14 @@ function ConnectionItem({
|
|||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
|
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
|
||||||
|
|
||||||
const handleLongPress = () => {
|
const handleLongPress = () => {
|
||||||
Alert.alert(connection.name, "What would you like to do?", [
|
Alert.alert(connection.name, t("connectionsList.actionsAlert.message"), [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
{ text: "Edit", onPress: onEdit },
|
{ text: t("connectionsList.actionsAlert.edit"), onPress: onEdit },
|
||||||
{ text: "Delete", style: "destructive", onPress: onDelete },
|
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +62,9 @@ function ConnectionItem({
|
|||||||
</Text>
|
</Text>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
|
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
|
||||||
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>Active</Text>
|
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>
|
||||||
|
{t("connectionsList.activeBadge")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -72,7 +76,7 @@ function ConnectionItem({
|
|||||||
</Text>
|
</Text>
|
||||||
{connection.lastConnected && (
|
{connection.lastConnected && (
|
||||||
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
|
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
|
||||||
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
|
{t("connectionsList.lastConnected", { date: new Date(connection.lastConnected).toLocaleDateString() })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -86,19 +90,24 @@ function ConnectionItem({
|
|||||||
export default function ConnectionsScreen() {
|
export default function ConnectionsScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
|
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
|
||||||
const { pageSize, setPageSize } = useSettings()
|
const { pageSize, setPageSize } = useSettings()
|
||||||
|
|
||||||
const handleDelete = (connection: ServerConnection) => {
|
const handleDelete = (connection: ServerConnection) => {
|
||||||
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
|
Alert.alert(
|
||||||
{ text: "Cancel", style: "cancel" },
|
t("connection.edit.alerts.deleteTitle"),
|
||||||
{
|
t("connection.edit.alerts.deleteMessage", { name: connection.name }),
|
||||||
text: "Delete",
|
[
|
||||||
style: "destructive",
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
onPress: () => removeConnection(connection.id),
|
{
|
||||||
},
|
text: t("common.delete"),
|
||||||
])
|
style: "destructive",
|
||||||
|
onPress: () => removeConnection(connection.id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,24 +128,28 @@ export default function ConnectionsScreen() {
|
|||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("connectionsList.empty.title")}</Text>
|
||||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||||
Add a connection to your OpenCode server
|
{t("connectionsList.empty.subtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={[styles.header, isDark && styles.headerDark]}>
|
<View style={[styles.header, isDark && styles.headerDark]}>
|
||||||
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
|
<Text style={[styles.headerText, isDark && styles.metaDark]}>{t("connectionsList.header")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
|
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
|
||||||
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>Preferences</Text>
|
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>
|
||||||
|
{t("connectionsList.preferences.title")}
|
||||||
|
</Text>
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<View style={styles.settingLabel}>
|
<View style={styles.settingLabel}>
|
||||||
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
|
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
|
||||||
<Text style={[styles.settingText, isDark && styles.textDark]}>Messages per page</Text>
|
<Text style={[styles.settingText, isDark && styles.textDark]}>
|
||||||
|
{t("connectionsList.preferences.pageSizeLabel")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.pagePicker}>
|
<View style={styles.pagePicker}>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
@@ -164,7 +177,7 @@ export default function ConnectionsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
|
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
|
||||||
How many messages to load when opening a session. Lower = faster.
|
{t("connectionsList.preferences.pageSizeHint")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "react-native"
|
} from "react-native"
|
||||||
import { router, useFocusEffect } from "expo-router"
|
import { router, useFocusEffect } from "expo-router"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useSessions } from "../../src/stores/sessions"
|
import { useSessions } from "../../src/stores/sessions"
|
||||||
import { useConnections } from "../../src/stores/connections"
|
import { useConnections } from "../../src/stores/connections"
|
||||||
import { useEvents } from "../../src/stores/events"
|
import { useEvents } from "../../src/stores/events"
|
||||||
@@ -27,15 +28,15 @@ import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/c
|
|||||||
import { groupByDirectory } from "../../src/lib/session-grouping"
|
import { groupByDirectory } from "../../src/lib/session-grouping"
|
||||||
import { nameOf } from "../../src/lib/path-utils"
|
import { nameOf } from "../../src/lib/path-utils"
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
function formatTime(timestamp: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now.getTime() - date.getTime()
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
if (diff < 60000) return "Just now"
|
if (diff < 60000) return t("sessionsList.time.justNow")
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
if (diff < 3600000) return t("sessionsList.time.minutesAgo", { count: Math.floor(diff / 60000) })
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
if (diff < 86400000) return t("sessionsList.time.hoursAgo", { count: Math.floor(diff / 3600000) })
|
||||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
|
if (diff < 604800000) return t("sessionsList.time.daysAgo", { count: Math.floor(diff / 86400000) })
|
||||||
|
|
||||||
return date.toLocaleDateString()
|
return date.toLocaleDateString()
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,8 @@ function SessionItem({
|
|||||||
onRename: () => void
|
onRename: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/session/[id]`,
|
pathname: `/session/[id]`,
|
||||||
@@ -59,10 +62,10 @@ function SessionItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onLongPress = () => {
|
const onLongPress = () => {
|
||||||
Alert.alert(session.title || "Untitled Session", undefined, [
|
Alert.alert(session.title || t("sessionsList.untitledSession"), undefined, [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
{ text: "Rename", onPress: onRename },
|
{ text: t("sessionsList.actions.rename"), onPress: onRename },
|
||||||
{ text: "Delete", style: "destructive", onPress: onDelete },
|
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +82,17 @@ function SessionItem({
|
|||||||
<View style={styles.sessionContent}>
|
<View style={styles.sessionContent}>
|
||||||
<View style={styles.sessionHeader}>
|
<View style={styles.sessionHeader}>
|
||||||
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
|
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
|
||||||
{session.title || "Untitled Session"}
|
{session.title || t("sessionsList.untitledSession")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.sessionMetaRow}>
|
<View style={styles.sessionMetaRow}>
|
||||||
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
|
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
|
||||||
{formatTime(session.time.updated)}
|
{formatTime(session.time.updated, t)}
|
||||||
{/* summary is always present but files defaults to 0 until the
|
{/* summary is always present but files defaults to 0 until the
|
||||||
server populates it — only show the count when it's meaningful,
|
server populates it — only show the count when it's meaningful,
|
||||||
matching the SessionInfo panel's `summary.files > 0` guard (#55) */}
|
matching the SessionInfo panel's `summary.files > 0` guard (#55) */}
|
||||||
{session.summary && session.summary.files > 0 && ` · ${session.summary.files} files`}
|
{session.summary && session.summary.files > 0 &&
|
||||||
|
` · ${t("sessionsList.filesCount", { count: session.summary.files })}`}
|
||||||
</Text>
|
</Text>
|
||||||
{shortDir && (
|
{shortDir && (
|
||||||
<View style={styles.sessionDirBadge}>
|
<View style={styles.sessionDirBadge}>
|
||||||
@@ -153,6 +157,7 @@ function getShortPath(
|
|||||||
export default function SessionsScreen() {
|
export default function SessionsScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
const [showNewSession, setShowNewSession] = useState(false)
|
const [showNewSession, setShowNewSession] = useState(false)
|
||||||
const [customDir, setCustomDir] = useState("")
|
const [customDir, setCustomDir] = useState("")
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
@@ -277,31 +282,35 @@ export default function SessionsScreen() {
|
|||||||
loadSessions()
|
loadSessions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Rename failed:", err)
|
console.error("Rename failed:", err)
|
||||||
Alert.alert("Rename failed", "Could not rename the session. Please try again.")
|
Alert.alert(t("sessionsList.alerts.renameFailedTitle"), t("sessionsList.alerts.renameFailedMessage"))
|
||||||
} finally {
|
} finally {
|
||||||
renamingInFlight.current = false
|
renamingInFlight.current = false
|
||||||
}
|
}
|
||||||
}, [renaming, renameText, client, clientForDirectory, loadSessions])
|
}, [renaming, renameText, client, clientForDirectory, loadSessions, t])
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
(session: Session) => {
|
(session: Session) => {
|
||||||
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
|
Alert.alert(
|
||||||
{ text: "Cancel", style: "cancel" },
|
t("sessionsList.alerts.deleteTitle"),
|
||||||
{
|
t("sessionsList.alerts.deleteMessage", { title: session.title || t("sessionsList.untitledSession") }),
|
||||||
text: "Delete",
|
[
|
||||||
style: "destructive",
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
onPress: async () => {
|
{
|
||||||
try {
|
text: t("common.delete"),
|
||||||
await deleteSession(session.id)
|
style: "destructive",
|
||||||
} catch (err) {
|
onPress: async () => {
|
||||||
console.error("Delete failed:", err)
|
try {
|
||||||
Alert.alert("Delete failed", "Could not delete the session. Please try again.")
|
await deleteSession(session.id)
|
||||||
}
|
} catch (err) {
|
||||||
|
console.error("Delete failed:", err)
|
||||||
|
Alert.alert(t("sessionsList.alerts.deleteFailedTitle"), t("sessionsList.alerts.deleteFailedMessage"))
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
])
|
)
|
||||||
},
|
},
|
||||||
[deleteSession],
|
[deleteSession, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onCreateSession = async () => {
|
const onCreateSession = async () => {
|
||||||
@@ -341,7 +350,7 @@ export default function SessionsScreen() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create session in directory:", error)
|
console.error("Failed to create session in directory:", error)
|
||||||
Alert.alert("Error", "Failed to create session in that directory.")
|
Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage"))
|
||||||
setIsCreating(false)
|
setIsCreating(false)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -413,14 +422,18 @@ export default function SessionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
||||||
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.noConnectionTitle")}</Text>
|
||||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||||
|
{t("sessionsList.empty.noConnectionSubtitle")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addButton, isDark && styles.addButtonDark]}
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||||
onPress={() => router.push("/connection/add")}
|
onPress={() => router.push("/connection/add")}
|
||||||
testID="add-connection-button"
|
testID="add-connection-button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>
|
||||||
|
{t("sessionsList.empty.addConnectionButton")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -434,9 +447,9 @@ export default function SessionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
||||||
<Ionicons name="lock-closed-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
<Ionicons name="lock-closed-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>Authentication Failed</Text>
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.authFailedTitle")}</Text>
|
||||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||||
{activeConnection.name} rejected your credentials. Check the username and password to reconnect.
|
{t("sessionsList.empty.authFailedSubtitle", { name: activeConnection.name })}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.authErrorButtonRow}>
|
<View style={styles.authErrorButtonRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -444,7 +457,9 @@ export default function SessionsScreen() {
|
|||||||
onPress={() => router.push(`/connection/${activeConnection.id}`)}
|
onPress={() => router.push(`/connection/${activeConnection.id}`)}
|
||||||
testID="fix-connection-button"
|
testID="fix-connection-button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Check Credentials</Text>
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>
|
||||||
|
{t("sessionsList.empty.checkCredentialsButton")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addButton, isDark && styles.addButtonDark]}
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||||
@@ -456,7 +471,7 @@ export default function SessionsScreen() {
|
|||||||
}}
|
}}
|
||||||
testID="retry-connection-button"
|
testID="retry-connection-button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Retry</Text>
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>{t("common.retry")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -523,7 +538,7 @@ export default function SessionsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyList}>
|
<View style={styles.emptyList}>
|
||||||
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
|
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>{t("sessionsList.empty.noSessions")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -547,7 +562,7 @@ export default function SessionsScreen() {
|
|||||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
|
||||||
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
|
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
|
<Text style={[styles.modalTitle, isDark && styles.textDark]}>{t("sessionsList.newSessionModal.title")}</Text>
|
||||||
<TouchableOpacity onPress={() => setShowNewSession(false)}>
|
<TouchableOpacity onPress={() => setShowNewSession(false)}>
|
||||||
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -555,7 +570,9 @@ export default function SessionsScreen() {
|
|||||||
|
|
||||||
<ScrollView style={styles.modalScrollBody} keyboardShouldPersistTaps="handled">
|
<ScrollView style={styles.modalScrollBody} keyboardShouldPersistTaps="handled">
|
||||||
{/* Current directory — tapping creates session immediately */}
|
{/* Current directory — tapping creates session immediately */}
|
||||||
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Project</Text>
|
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>
|
||||||
|
{t("sessionsList.newSessionModal.currentProjectLabel")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
|
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
|
||||||
onPress={() => onCreateInDirectory()}
|
onPress={() => onCreateInDirectory()}
|
||||||
@@ -563,7 +580,7 @@ export default function SessionsScreen() {
|
|||||||
>
|
>
|
||||||
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||||
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
|
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
|
||||||
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
|
{currentProject?.path?.absolute || activeConnection?.directory || t("sessionsList.newSessionModal.serverDefault")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="arrow-forward-circle" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
<Ionicons name="arrow-forward-circle" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -572,7 +589,7 @@ export default function SessionsScreen() {
|
|||||||
{recentDirectories.length > 0 && (
|
{recentDirectories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||||
Recent Projects
|
{t("sessionsList.newSessionModal.recentProjectsLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
{recentDirectories.map((dir) => {
|
{recentDirectories.map((dir) => {
|
||||||
const short = dir.split("/").filter(Boolean).pop() || dir
|
const short = dir.split("/").filter(Boolean).pop() || dir
|
||||||
@@ -620,7 +637,7 @@ export default function SessionsScreen() {
|
|||||||
{serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && (
|
{serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||||
Server Projects
|
{t("sessionsList.newSessionModal.serverProjectsLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
{serverProjects
|
{serverProjects
|
||||||
.filter((p) => p.path?.absolute !== currentProject?.path?.absolute)
|
.filter((p) => p.path?.absolute !== currentProject?.path?.absolute)
|
||||||
@@ -661,9 +678,11 @@ export default function SessionsScreen() {
|
|||||||
>
|
>
|
||||||
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||||
<View style={styles.projectRowContent}>
|
<View style={styles.projectRowContent}>
|
||||||
<Text style={[styles.projectRowName, isDark && styles.textDark]}>Browse Folders…</Text>
|
<Text style={[styles.projectRowName, isDark && styles.textDark]}>
|
||||||
|
{t("sessionsList.newSessionModal.browseFoldersLabel")}
|
||||||
|
</Text>
|
||||||
<Text style={[styles.projectRowPath, isDark && styles.metaDark]}>
|
<Text style={[styles.projectRowPath, isDark && styles.metaDark]}>
|
||||||
Explore the server's filesystem
|
{t("sessionsList.newSessionModal.browseFoldersHint")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
|
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||||
@@ -671,7 +690,7 @@ export default function SessionsScreen() {
|
|||||||
|
|
||||||
{/* Manual path input fallback */}
|
{/* Manual path input fallback */}
|
||||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||||
Enter Path Manually
|
{t("sessionsList.newSessionModal.enterPathLabel")}
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||||
@@ -726,7 +745,9 @@ export default function SessionsScreen() {
|
|||||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||||
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
|
{t("sessionsList.newSessionModal.createInButton", {
|
||||||
|
dir: customDir.split("/").filter(Boolean).pop() || customDir,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -745,7 +766,7 @@ export default function SessionsScreen() {
|
|||||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||||
Create Session
|
{t("sessionsList.newSessionModal.createSessionButton")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -763,7 +784,7 @@ export default function SessionsScreen() {
|
|||||||
>
|
>
|
||||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
||||||
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
|
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
|
||||||
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
|
<Text style={[styles.renameTitle, isDark && styles.textDark]}>{t("sessionsList.renameModal.title")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||||
value={renameText}
|
value={renameText}
|
||||||
@@ -777,14 +798,16 @@ export default function SessionsScreen() {
|
|||||||
/>
|
/>
|
||||||
<View style={styles.renameActions}>
|
<View style={styles.renameActions}>
|
||||||
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
|
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
|
||||||
<Text style={styles.renameBtnCancelText}>Cancel</Text>
|
<Text style={styles.renameBtnCancelText}>{t("common.cancel")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
|
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
|
||||||
onPress={submitRename}
|
onPress={submitRename}
|
||||||
disabled={!renameText.trim()}
|
disabled={!renameText.trim()}
|
||||||
>
|
>
|
||||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ export default function SettingsScreen() {
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
key={category}
|
key={category}
|
||||||
icon={meta.icon as keyof typeof Ionicons.glyphMap}
|
icon={meta.icon as keyof typeof Ionicons.glyphMap}
|
||||||
label={meta.label}
|
label={t(meta.labelKey)}
|
||||||
description={meta.description}
|
description={t(meta.descriptionKey)}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useColorScheme, View, ActivityIndicator } from "react-native"
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
||||||
import { I18nextProvider } from "react-i18next"
|
import { I18nextProvider, useTranslation } from "react-i18next"
|
||||||
import i18n from "../src/lib/i18n/config"
|
import i18n from "../src/lib/i18n/config"
|
||||||
import { useAuth } from "../src/stores/auth"
|
import { useAuth } from "../src/stores/auth"
|
||||||
import { useConnections } from "../src/stores/connections"
|
import { useConnections } from "../src/stores/connections"
|
||||||
@@ -25,6 +25,7 @@ const queryClient = new QueryClient()
|
|||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { initialize: initAuth, isLoading: authLoading } = useAuth()
|
const { initialize: initAuth, isLoading: authLoading } = useAuth()
|
||||||
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
|
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
|
||||||
@@ -129,21 +130,21 @@ function RootLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="session/[id]"
|
name="session/[id]"
|
||||||
options={{
|
options={{
|
||||||
title: "Session",
|
title: t("session.titleFallback"),
|
||||||
presentation: "card",
|
presentation: "card",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="connection/add"
|
name="connection/add"
|
||||||
options={{
|
options={{
|
||||||
title: "Add Connection",
|
title: t("nav.addConnectionTitle"),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="connection/[id]"
|
name="connection/[id]"
|
||||||
options={{
|
options={{
|
||||||
title: "Edit Connection",
|
title: t("nav.editConnectionTitle"),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "react-native"
|
} from "react-native"
|
||||||
import { router, useLocalSearchParams } from "expo-router"
|
import { router, useLocalSearchParams } from "expo-router"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useConnections } from "../../src/stores/connections"
|
import { useConnections } from "../../src/stores/connections"
|
||||||
import { useEvents } from "../../src/stores/events"
|
import { useEvents } from "../../src/stores/events"
|
||||||
import type { ConnectionType } from "../../src/lib/types"
|
import type { ConnectionType } from "../../src/lib/types"
|
||||||
@@ -19,20 +20,24 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
|||||||
import { captureDiagnostic } from "../../src/lib/sentry"
|
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||||
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||||
|
|
||||||
|
// labelKey (not literal text): this is a module-level constant evaluated
|
||||||
|
// before i18next is guaranteed ready, so the label is resolved with t() at
|
||||||
|
// render time — same pattern as categoryMeta in src/lib/notifications.ts.
|
||||||
const CONNECTION_TYPES: Array<{
|
const CONNECTION_TYPES: Array<{
|
||||||
type: ConnectionType
|
type: ConnectionType
|
||||||
label: string
|
labelKey: string
|
||||||
icon: keyof typeof Ionicons.glyphMap
|
icon: keyof typeof Ionicons.glyphMap
|
||||||
}> = [
|
}> = [
|
||||||
{ type: "local", label: "Local", icon: "wifi" },
|
{ type: "local", labelKey: "connection.shared.types.local", icon: "wifi" },
|
||||||
{ type: "tunnel", label: "Tunnel", icon: "globe" },
|
{ type: "tunnel", labelKey: "connection.shared.types.tunnel", icon: "globe" },
|
||||||
{ type: "cloud", label: "Cloud", icon: "cloud" },
|
{ type: "cloud", labelKey: "connection.shared.types.cloud", icon: "cloud" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function EditConnectionScreen() {
|
export default function EditConnectionScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>()
|
const { id } = useLocalSearchParams<{ id: string }>()
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { connections, updateConnection, removeConnection, testConnection } = useConnections()
|
const { connections, updateConnection, removeConnection, testConnection } = useConnections()
|
||||||
|
|
||||||
@@ -60,18 +65,18 @@ export default function EditConnectionScreen() {
|
|||||||
if (!connection) {
|
if (!connection) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, isDark && styles.containerDark, styles.center]}>
|
<View style={[styles.container, isDark && styles.containerDark, styles.center]}>
|
||||||
<Text style={[styles.errorText, isDark && styles.textDark]}>Connection not found</Text>
|
<Text style={[styles.errorText, isDark && styles.textDark]}>{t("connection.edit.notFound")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
Alert.alert("Error", "Please enter a server URL")
|
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!parseUrl(url).valid) {
|
if (!parseUrl(url).valid) {
|
||||||
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ export default function EditConnectionScreen() {
|
|||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setIsTesting(false)
|
setIsTesting(false)
|
||||||
Alert.alert("Success", "Connection successful!")
|
Alert.alert(t("connection.edit.alerts.successTitle"), t("connection.edit.alerts.successMessage"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,23 +108,30 @@ export default function EditConnectionScreen() {
|
|||||||
captureDiagnostic(report)
|
captureDiagnostic(report)
|
||||||
setIsTesting(false)
|
setIsTesting(false)
|
||||||
|
|
||||||
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [
|
Alert.alert(
|
||||||
{ text: "OK", style: "cancel" },
|
t("connection.shared.alerts.connectionFailedTitle"),
|
||||||
{ text: "Share report", onPress: () => shareReport(report) },
|
t("connection.edit.alerts.connectionFailedMessage", {
|
||||||
])
|
summary: report.summary,
|
||||||
|
detail: result.error || t("connection.edit.alerts.noDetail"),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{ text: t("common.ok"), style: "cancel" },
|
||||||
|
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
|
||||||
|
],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
Alert.alert("Error", "Please enter a connection name")
|
Alert.alert(t("common.error"), t("connection.shared.alerts.enterName"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
Alert.alert("Error", "Please enter a server URL")
|
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!parseUrl(url).valid) {
|
if (!parseUrl(url).valid) {
|
||||||
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +155,10 @@ export default function EditConnectionScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
|
Alert.alert(t("connection.edit.alerts.deleteTitle"), t("connection.edit.alerts.deleteMessage", { name: connection.name }), [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: t("common.delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await removeConnection(connection.id)
|
await removeConnection(connection.id)
|
||||||
@@ -163,50 +175,50 @@ export default function EditConnectionScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
{/* Connection Type */}
|
{/* Connection Type */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.connectionType")}</Text>
|
||||||
<View style={styles.typeContainer}>
|
<View style={styles.typeContainer}>
|
||||||
{CONNECTION_TYPES.map((t) => (
|
{CONNECTION_TYPES.map((opt) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={t.type}
|
key={opt.type}
|
||||||
style={[
|
style={[
|
||||||
styles.typeOption,
|
styles.typeOption,
|
||||||
isDark && styles.typeOptionDark,
|
isDark && styles.typeOptionDark,
|
||||||
type === t.type && styles.typeOptionSelected,
|
type === opt.type && styles.typeOptionSelected,
|
||||||
type === t.type && isDark && styles.typeOptionSelectedDark,
|
type === opt.type && isDark && styles.typeOptionSelectedDark,
|
||||||
]}
|
]}
|
||||||
onPress={() => setType(t.type)}
|
onPress={() => setType(opt.type)}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={t.icon}
|
name={opt.icon}
|
||||||
size={20}
|
size={20}
|
||||||
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
color={type === opt.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.typeLabel,
|
styles.typeLabel,
|
||||||
isDark && styles.textDark,
|
isDark && styles.textDark,
|
||||||
type === t.type && styles.typeLabelSelected,
|
type === opt.type && styles.typeLabelSelected,
|
||||||
type === t.type && isDark && styles.typeLabelSelectedDark,
|
type === opt.type && isDark && styles.typeLabelSelectedDark,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t(opt.labelKey)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.name")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="My Server"
|
placeholder={t("connection.shared.namePlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* URL */}
|
{/* URL */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="http://192.168.1.100:4096"
|
placeholder="http://192.168.1.100:4096"
|
||||||
@@ -219,7 +231,7 @@ export default function EditConnectionScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Directory */}
|
{/* Directory */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.directoryOptional")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="/path/to/project"
|
placeholder="/path/to/project"
|
||||||
@@ -229,14 +241,12 @@ export default function EditConnectionScreen() {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
<Text style={[styles.hint, isDark && styles.hintDark]}>{t("connection.edit.directoryHint")}</Text>
|
||||||
Leave empty to use the server's current directory. Sessions will be created in this folder.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Auth */}
|
{/* Auth */}
|
||||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>{t("connection.shared.authentication")}</Text>
|
||||||
|
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.username")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="admin"
|
placeholder="admin"
|
||||||
@@ -247,7 +257,7 @@ export default function EditConnectionScreen() {
|
|||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password (leave empty to keep existing)</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.edit.passwordLabel")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
@@ -269,7 +279,7 @@ export default function EditConnectionScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="pulse" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
<Ionicons name="pulse" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
<Text style={[styles.testButtonText, isDark && styles.textDark]}>Test Connection</Text>
|
<Text style={[styles.testButtonText, isDark && styles.textDark]}>{t("connection.edit.testButton")}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -282,13 +292,15 @@ export default function EditConnectionScreen() {
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>Save Changes</Text>
|
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>
|
||||||
|
{t("connection.edit.saveButton")}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
||||||
<Ionicons name="trash-outline" size={20} color="#ef4444" />
|
<Ionicons name="trash-outline" size={20} color="#ef4444" />
|
||||||
<Text style={styles.deleteButtonText}>Delete Connection</Text>
|
<Text style={styles.deleteButtonText}>{t("connection.edit.deleteButton")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "react-native"
|
} from "react-native"
|
||||||
import { router } from "expo-router"
|
import { router } from "expo-router"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useConnections } from "../../src/stores/connections"
|
import { useConnections } from "../../src/stores/connections"
|
||||||
import type { ConnectionType } from "../../src/lib/types"
|
import type { ConnectionType } from "../../src/lib/types"
|
||||||
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||||
@@ -24,6 +25,7 @@ import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/wait
|
|||||||
export default function AddConnectionScreen() {
|
export default function AddConnectionScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { addConnection, testConnection } = useConnections()
|
const { addConnection, testConnection } = useConnections()
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ export default function AddConnectionScreen() {
|
|||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
const serverUrl = buildUrl()
|
const serverUrl = buildUrl()
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
Alert.alert("Error", "Please enter your computer's IP address")
|
Alert.alert(t("common.error"), t("connection.add.alerts.enterIp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ export default function AddConnectionScreen() {
|
|||||||
const result = await testConnection(
|
const result = await testConnection(
|
||||||
{
|
{
|
||||||
id: "",
|
id: "",
|
||||||
name: name || "My Server",
|
name: name || t("connection.shared.namePlaceholder"),
|
||||||
type: "local",
|
type: "local",
|
||||||
url: serverUrl,
|
url: serverUrl,
|
||||||
username: username.trim() || undefined,
|
username: username.trim() || undefined,
|
||||||
@@ -90,7 +92,7 @@ export default function AddConnectionScreen() {
|
|||||||
// Save and go back
|
// Save and go back
|
||||||
await addConnection(
|
await addConnection(
|
||||||
{
|
{
|
||||||
name: name.trim() || "My Server",
|
name: name.trim() || t("connection.shared.namePlaceholder"),
|
||||||
type: "local",
|
type: "local",
|
||||||
url: serverUrl,
|
url: serverUrl,
|
||||||
username: username.trim() || undefined,
|
username: username.trim() || undefined,
|
||||||
@@ -108,11 +110,15 @@ export default function AddConnectionScreen() {
|
|||||||
captureDiagnostic(report)
|
captureDiagnostic(report)
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Connection Failed",
|
t("connection.shared.alerts.connectionFailedTitle"),
|
||||||
`${report.summary}\n\nTarget: ${serverUrl}\nError: ${result.error || "Unknown error"}`,
|
t("connection.add.alerts.connectionFailedMessage", {
|
||||||
|
summary: report.summary,
|
||||||
|
target: serverUrl,
|
||||||
|
error: result.error || t("connection.shared.alerts.unknownError"),
|
||||||
|
}),
|
||||||
[
|
[
|
||||||
{ text: "OK", style: "cancel" },
|
{ text: t("common.ok"), style: "cancel" },
|
||||||
{ text: "Share report", onPress: () => shareReport(report) },
|
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,15 +126,15 @@ export default function AddConnectionScreen() {
|
|||||||
|
|
||||||
const handleAdvancedSave = async () => {
|
const handleAdvancedSave = async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
Alert.alert("Error", "Please enter a connection name")
|
Alert.alert(t("common.error"), t("connection.shared.alerts.enterName"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
Alert.alert("Error", "Please enter a server URL")
|
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!parseUrl(url).valid) {
|
if (!parseUrl(url).valid) {
|
||||||
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +185,15 @@ export default function AddConnectionScreen() {
|
|||||||
captureDiagnostic(report)
|
captureDiagnostic(report)
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Connection Failed",
|
t("connection.shared.alerts.connectionFailedTitle"),
|
||||||
`${report.summary}\n\nTarget: ${url.trim()}\nError: ${result.error || "Unknown error"}`,
|
t("connection.add.alerts.connectionFailedMessage", {
|
||||||
|
summary: report.summary,
|
||||||
|
target: url.trim(),
|
||||||
|
error: result.error || t("connection.shared.alerts.unknownError"),
|
||||||
|
}),
|
||||||
[
|
[
|
||||||
{ text: "OK", style: "cancel" },
|
{ text: t("common.ok"), style: "cancel" },
|
||||||
{ text: "Share report", onPress: () => shareReport(report) },
|
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -204,10 +214,10 @@ export default function AddConnectionScreen() {
|
|||||||
await Linking.openURL(buildWaitlistMailtoUrl(result.email))
|
await Linking.openURL(buildWaitlistMailtoUrl(result.email))
|
||||||
} catch {
|
} catch {
|
||||||
// No mail app either — tell the user instead of failing silently.
|
// No mail app either — tell the user instead of failing silently.
|
||||||
Alert.alert("Join Waitlist", "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\".")
|
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.fallbackMessage"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Alert.alert("Join Waitlist", result.error)
|
Alert.alert(t("connection.add.waitlist.alertTitle"), result.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +231,12 @@ export default function AddConnectionScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.quickHeader}>
|
<View style={styles.quickHeader}>
|
||||||
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
<Text style={[styles.quickTitle, isDark && styles.textDark]}>Connect to OpenCode</Text>
|
<Text style={[styles.quickTitle, isDark && styles.textDark]}>{t("connection.add.quick.title")}</Text>
|
||||||
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>
|
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>{t("connection.add.quick.subtitle")}</Text>
|
||||||
Enter your computer's IP address to connect
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* IP Address */}
|
{/* IP Address */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>IP Address</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.add.quick.ipAddressLabel")}</Text>
|
||||||
<View style={styles.ipRow}>
|
<View style={styles.ipRow}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
|
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
|
||||||
@@ -254,20 +262,20 @@ export default function AddConnectionScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Optional name */}
|
{/* Optional name */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name (optional)</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.add.quick.nameOptionalLabel")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="My Mac"
|
placeholder={t("connection.add.quick.namePlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password if needed */}
|
{/* Password if needed */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password (if set on server)</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.add.quick.passwordIfSetLabel")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="Leave empty if none"
|
placeholder={t("connection.add.quick.passwordPlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
@@ -275,11 +283,13 @@ export default function AddConnectionScreen() {
|
|||||||
testID="connect-password-input"
|
testID="connect-password-input"
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.usernameHint, isDark && styles.hintDark]}>
|
<Text style={[styles.usernameHint, isDark && styles.hintDark]}>
|
||||||
Username defaults to <Text style={styles.code}>opencode</Text>. Custom username? Use{" "}
|
{t("connection.add.quick.usernameHintPrefix")}
|
||||||
|
<Text style={styles.code}>opencode</Text>
|
||||||
|
{t("connection.add.quick.usernameHintMiddle")}
|
||||||
<Text style={styles.usernameHintLink} onPress={() => setMode("advanced")}>
|
<Text style={styles.usernameHintLink} onPress={() => setMode("advanced")}>
|
||||||
Advanced options
|
{t("connection.add.quick.advancedOptionsLink")}
|
||||||
</Text>
|
</Text>
|
||||||
.
|
{t("connection.add.quick.usernameHintSuffix")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Connect button */}
|
{/* Connect button */}
|
||||||
@@ -294,30 +304,38 @@ export default function AddConnectionScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="flash" size={20} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<Ionicons name="flash" size={20} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Connect</Text>
|
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>
|
||||||
|
{t("connection.add.quick.connectButton")}
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
|
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
|
||||||
<Text style={[styles.helpTitle, isDark && styles.textDark]}>How to find your IP:</Text>
|
<Text style={[styles.helpTitle, isDark && styles.textDark]}>{t("connection.add.quick.helpTitle")}</Text>
|
||||||
<Text style={[styles.helpText, isDark && styles.hintDark]}>
|
<Text style={[styles.helpText, isDark && styles.hintDark]}>
|
||||||
On your Mac, run:{"\n"}
|
{t("connection.add.quick.helpMacPrefix")}
|
||||||
|
{"\n"}
|
||||||
<Text style={styles.code}>ipconfig getifaddr en0</Text>
|
<Text style={styles.code}>ipconfig getifaddr en0</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||||
Tailscale examples:{"\n"}
|
{t("connection.add.quick.helpTailscalePrefix")}
|
||||||
|
{"\n"}
|
||||||
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
||||||
{"\n"}
|
{"\n"}
|
||||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
<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,
|
{t("connection.add.quick.helpProtocolPrefix")}
|
||||||
then use <Text style={styles.code}>https://</Text>.
|
<Text style={styles.code}>http://</Text>
|
||||||
|
{t("connection.add.quick.helpProtocolMiddle")}
|
||||||
|
<Text style={styles.code}>https://</Text>
|
||||||
|
{t("connection.add.quick.helpProtocolSuffix")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||||
Make sure OpenCode is running:{"\n"}
|
{t("connection.add.quick.helpRunningPrefix")}
|
||||||
|
{"\n"}
|
||||||
<Text style={styles.code}>opencode serve --hostname 0.0.0.0</Text>
|
<Text style={styles.code}>opencode serve --hostname 0.0.0.0</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -327,21 +345,22 @@ export default function AddConnectionScreen() {
|
|||||||
<View style={styles.connectCardHeader}>
|
<View style={styles.connectCardHeader}>
|
||||||
<Ionicons name="cloud-done-outline" size={28} color="#6366f1" />
|
<Ionicons name="cloud-done-outline" size={28} color="#6366f1" />
|
||||||
<View style={styles.connectCardTitles}>
|
<View style={styles.connectCardTitles}>
|
||||||
<Text style={[styles.connectCardTitle, isDark && styles.textDark]}>OpenCode Connect</Text>
|
<Text style={[styles.connectCardTitle, isDark && styles.textDark]}>
|
||||||
|
{t("connection.add.quick.connectCardTitle")}
|
||||||
|
</Text>
|
||||||
<View style={styles.connectCardBadge}>
|
<View style={styles.connectCardBadge}>
|
||||||
<Text style={styles.connectCardBadgeText}>Coming Soon</Text>
|
<Text style={styles.connectCardBadgeText}>{t("connection.add.quick.connectCardBadge")}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.connectCardDesc, isDark && styles.hintDark]}>
|
<Text style={[styles.connectCardDesc, isDark && styles.hintDark]}>
|
||||||
Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from
|
{t("connection.add.quick.connectCardDesc")}
|
||||||
anywhere.
|
|
||||||
</Text>
|
</Text>
|
||||||
{waitlistState === "joined" ? (
|
{waitlistState === "joined" ? (
|
||||||
<View style={styles.waitlistSuccess} testID="waitlist-success">
|
<View style={styles.waitlistSuccess} testID="waitlist-success">
|
||||||
<Ionicons name="checkmark-circle" size={20} color="#22c55e" />
|
<Ionicons name="checkmark-circle" size={20} color="#22c55e" />
|
||||||
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
|
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
|
||||||
You're on the list — we'll email you when OpenCode Connect is ready.
|
{t("connection.add.waitlist.successText")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -369,7 +388,7 @@ export default function AddConnectionScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="mail-outline" size={16} color="#ffffff" />
|
<Ionicons name="mail-outline" size={16} color="#ffffff" />
|
||||||
<Text style={styles.waitlistButtonText}>Join Waitlist</Text>
|
<Text style={styles.waitlistButtonText}>{t("connection.add.waitlist.joinButton")}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -380,7 +399,7 @@ export default function AddConnectionScreen() {
|
|||||||
{/* Advanced mode link */}
|
{/* Advanced mode link */}
|
||||||
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
|
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
|
||||||
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
|
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
|
||||||
Advanced options (tunnels, cloud, auth)
|
{t("connection.add.quick.advancedLink")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
|
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -397,58 +416,58 @@ export default function AddConnectionScreen() {
|
|||||||
>
|
>
|
||||||
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
|
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
|
||||||
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
|
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||||
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>Simple mode</Text>
|
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>{t("connection.add.advanced.backToQuick")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Connection Type */}
|
{/* Connection Type */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.connectionType")}</Text>
|
||||||
<View style={styles.typeContainer}>
|
<View style={styles.typeContainer}>
|
||||||
{[
|
{[
|
||||||
{ type: "local" as const, label: "Local", icon: "wifi" as const },
|
{ type: "local" as const, label: t("connection.shared.types.local"), icon: "wifi" as const },
|
||||||
{ type: "tunnel" as const, label: "Tunnel", icon: "globe" as const },
|
{ type: "tunnel" as const, label: t("connection.shared.types.tunnel"), icon: "globe" as const },
|
||||||
{ type: "cloud" as const, label: "Cloud", icon: "cloud" as const },
|
{ type: "cloud" as const, label: t("connection.shared.types.cloud"), icon: "cloud" as const },
|
||||||
].map((t) => (
|
].map((opt) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={t.type}
|
key={opt.type}
|
||||||
style={[
|
style={[
|
||||||
styles.typeOption,
|
styles.typeOption,
|
||||||
isDark && styles.typeOptionDark,
|
isDark && styles.typeOptionDark,
|
||||||
type === t.type && styles.typeOptionSelected,
|
type === opt.type && styles.typeOptionSelected,
|
||||||
type === t.type && isDark && styles.typeOptionSelectedDark,
|
type === opt.type && isDark && styles.typeOptionSelectedDark,
|
||||||
]}
|
]}
|
||||||
onPress={() => setType(t.type)}
|
onPress={() => setType(opt.type)}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={t.icon}
|
name={opt.icon}
|
||||||
size={20}
|
size={20}
|
||||||
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
color={type === opt.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.typeLabel,
|
styles.typeLabel,
|
||||||
isDark && styles.textDark,
|
isDark && styles.textDark,
|
||||||
type === t.type && styles.typeLabelSelected,
|
type === opt.type && styles.typeLabelSelected,
|
||||||
type === t.type && isDark && styles.typeLabelSelectedDark,
|
type === opt.type && isDark && styles.typeLabelSelectedDark,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{t.label}
|
{opt.label}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.name")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="My Server"
|
placeholder={t("connection.shared.namePlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* URL */}
|
{/* URL */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -466,13 +485,17 @@ export default function AddConnectionScreen() {
|
|||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||||
Local/Tailscale examples: <Text style={styles.code}>http://100.64.12.34:4096</Text> or{" "}
|
{t("connection.add.advanced.urlHintPrefix")}
|
||||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>. Use <Text style={styles.code}>https://</Text>
|
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
||||||
only when TLS is configured.
|
{t("connection.add.advanced.urlHintOr")}
|
||||||
|
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
||||||
|
{t("connection.add.advanced.urlHintUse")}
|
||||||
|
<Text style={styles.code}>https://</Text>
|
||||||
|
{t("connection.add.advanced.urlHintSuffix")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Directory */}
|
{/* Directory */}
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.directoryOptional")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="/path/to/project"
|
placeholder="/path/to/project"
|
||||||
@@ -482,14 +505,12 @@ export default function AddConnectionScreen() {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
<Text style={[styles.hint, isDark && styles.hintDark]}>{t("connection.add.advanced.directoryHint")}</Text>
|
||||||
Leave empty to use the server's current directory, or specify a path to work in a different folder.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Auth */}
|
{/* Auth */}
|
||||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>{t("connection.shared.authentication")}</Text>
|
||||||
|
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.username")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="admin"
|
placeholder="admin"
|
||||||
@@ -500,7 +521,7 @@ export default function AddConnectionScreen() {
|
|||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password</Text>
|
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.password")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, isDark && styles.inputDark]}
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
placeholder="password"
|
placeholder="password"
|
||||||
@@ -519,7 +540,9 @@ export default function AddConnectionScreen() {
|
|||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Save Connection</Text>
|
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>
|
||||||
|
{t("connection.add.advanced.saveButton")}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
|
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import * as ImagePicker from "expo-image-picker"
|
import * as ImagePicker from "expo-image-picker"
|
||||||
import * as ImageManipulator from "expo-image-manipulator"
|
import * as ImageManipulator from "expo-image-manipulator"
|
||||||
import * as Clipboard from "expo-clipboard"
|
import * as Clipboard from "expo-clipboard"
|
||||||
@@ -76,6 +77,7 @@ export default function SessionScreen() {
|
|||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null)
|
const flatListRef = useRef<FlatList>(null)
|
||||||
const modelSheetRef = useRef<BottomSheet>(null)
|
const modelSheetRef = useRef<BottomSheet>(null)
|
||||||
@@ -190,14 +192,11 @@ export default function SessionScreen() {
|
|||||||
const applyRevertResult = useCallback((result: Awaited<ReturnType<typeof revertToMessage>>) => {
|
const applyRevertResult = useCallback((result: Awaited<ReturnType<typeof revertToMessage>>) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === "unsupported") {
|
if (result.reason === "unsupported") {
|
||||||
Alert.alert(
|
Alert.alert(t("session.alerts.notSupportedTitle"), t("session.alerts.notSupportedMessage"))
|
||||||
"Not supported",
|
|
||||||
"Editing sent messages needs a newer opencode server. Please update the server and try again.",
|
|
||||||
)
|
|
||||||
} else if (result.reason === "auth") {
|
} else if (result.reason === "auth") {
|
||||||
Alert.alert("Authentication failed", "Your server credentials were rejected. Check your connection and try again.")
|
Alert.alert(t("session.alerts.revertAuthFailedTitle"), t("session.alerts.revertAuthFailedMessage"))
|
||||||
} else {
|
} else {
|
||||||
Alert.alert("Edit failed", "Could not revert to this message. Please try again.")
|
Alert.alert(t("session.alerts.editFailedTitle"), t("session.alerts.editFailedMessage"))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -209,16 +208,16 @@ export default function SessionScreen() {
|
|||||||
.filter((f): f is typeof f & { url: string; mime: string } => !!f.url && !!f.mime)
|
.filter((f): f is typeof f & { url: string; mime: string } => !!f.url && !!f.mime)
|
||||||
.map((f) => ({ uri: f.url, mime: f.mime, filename: f.filename })),
|
.map((f) => ({ uri: f.url, mime: f.mime, filename: f.filename })),
|
||||||
)
|
)
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
// Stable across renders (reads fresh state via getState() rather than
|
// Stable across renders (reads fresh state via getState() rather than
|
||||||
// closing over props) so MessageBubble's custom memo comparator can bail
|
// closing over props) so MessageBubble's custom memo comparator can bail
|
||||||
// safely without risking a stale handler.
|
// safely without risking a stale handler.
|
||||||
const handleMessageLongPress = useCallback((messageID: string) => {
|
const handleMessageLongPress = useCallback((messageID: string) => {
|
||||||
Alert.alert("Message actions", undefined, [
|
Alert.alert(t("session.alerts.messageActionsTitle"), undefined, [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Edit message",
|
text: t("session.actions.editMessage"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const doRevert = async () => {
|
const doRevert = async () => {
|
||||||
const result = await useSessions.getState().revertToMessage(messageID)
|
const result = await useSessions.getState().revertToMessage(messageID)
|
||||||
@@ -228,11 +227,11 @@ export default function SessionScreen() {
|
|||||||
// in-progress unsent draft.
|
// in-progress unsent draft.
|
||||||
if (inputRef.current.trim()) {
|
if (inputRef.current.trim()) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Replace draft?",
|
t("session.alerts.replaceDraftTitle"),
|
||||||
"You have an unsent message in the composer. Editing this message will replace it.",
|
t("session.alerts.replaceDraftMessage"),
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
{ text: "Replace", style: "destructive", onPress: doRevert },
|
{ text: t("session.actions.replace"), style: "destructive", onPress: doRevert },
|
||||||
],
|
],
|
||||||
{ cancelable: false },
|
{ cancelable: false },
|
||||||
)
|
)
|
||||||
@@ -242,7 +241,7 @@ export default function SessionScreen() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}, [applyRevertResult])
|
}, [applyRevertResult, t])
|
||||||
|
|
||||||
const scrollToBottom = useCallback((animated = true) => {
|
const scrollToBottom = useCallback((animated = true) => {
|
||||||
flatListRef.current?.scrollToOffset({ offset: 0, animated })
|
flatListRef.current?.scrollToOffset({ offset: 0, animated })
|
||||||
@@ -337,7 +336,7 @@ export default function SessionScreen() {
|
|||||||
const pickFromCamera = useCallback(async () => {
|
const pickFromCamera = useCallback(async () => {
|
||||||
const perm = await ImagePicker.requestCameraPermissionsAsync()
|
const perm = await ImagePicker.requestCameraPermissionsAsync()
|
||||||
if (!perm.granted) {
|
if (!perm.granted) {
|
||||||
Alert.alert("Permission needed", "Camera access is required to take photos.")
|
Alert.alert(t("session.alerts.cameraPermissionTitle"), t("session.alerts.cameraPermissionMessage"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
|
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
|
||||||
@@ -345,7 +344,7 @@ export default function SessionScreen() {
|
|||||||
const a = result.assets[0]
|
const a = result.assets[0]
|
||||||
const item = await toJpeg(a.uri, a.width, a.height)
|
const item = await toJpeg(a.uri, a.width, a.height)
|
||||||
setAttachments((prev) => [...prev, item])
|
setAttachments((prev) => [...prev, item])
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const pasteFromClipboard = useCallback(async () => {
|
const pasteFromClipboard = useCallback(async () => {
|
||||||
// Try image first
|
// Try image first
|
||||||
@@ -376,8 +375,8 @@ export default function SessionScreen() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.")
|
Alert.alert(t("session.alerts.emptyClipboardTitle"), t("session.alerts.emptyClipboardMessage"))
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const removeAttachment = useCallback((index: number) => {
|
const removeAttachment = useCallback((index: number) => {
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||||
@@ -388,7 +387,7 @@ export default function SessionScreen() {
|
|||||||
if (!input.trim() && attachments.length === 0) return
|
if (!input.trim() && attachments.length === 0) return
|
||||||
const authenticated = await authenticateForMessage()
|
const authenticated = await authenticateForMessage()
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.")
|
Alert.alert(t("session.alerts.authRequiredTitle"), t("session.alerts.authRequiredMessage"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +423,7 @@ export default function SessionScreen() {
|
|||||||
// Restore the user's text and attachments so their input isn't lost.
|
// Restore the user's text and attachments so their input isn't lost.
|
||||||
setInput((prev) => (prev ? prev : text))
|
setInput((prev) => (prev ? prev : text))
|
||||||
setAttachments((prev) => (prev.length ? prev : files))
|
setAttachments((prev) => (prev.length ? prev : files))
|
||||||
Alert.alert("Message not sent", "Could not send your message. Check your connection and try again.")
|
Alert.alert(t("session.alerts.sendFailedTitle"), t("session.alerts.sendFailedMessage"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +479,7 @@ export default function SessionScreen() {
|
|||||||
useEvents.setState((state) => ({
|
useEvents.setState((state) => ({
|
||||||
permissions: { ...state.permissions, [sessionID]: snapshot },
|
permissions: { ...state.permissions, [sessionID]: snapshot },
|
||||||
}))
|
}))
|
||||||
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
|
Alert.alert(t("session.alerts.replyFailedTitle"), t("session.alerts.replyFailedMessage"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +499,7 @@ export default function SessionScreen() {
|
|||||||
useEvents.setState((state) => ({
|
useEvents.setState((state) => ({
|
||||||
questions: { ...state.questions, [sessionID]: snapshot },
|
questions: { ...state.questions, [sessionID]: snapshot },
|
||||||
}))
|
}))
|
||||||
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
|
Alert.alert(t("session.alerts.replyFailedTitle"), t("session.alerts.replyFailedMessage"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +519,7 @@ export default function SessionScreen() {
|
|||||||
useEvents.setState((state) => ({
|
useEvents.setState((state) => ({
|
||||||
questions: { ...state.questions, [sessionID]: snapshot },
|
questions: { ...state.questions, [sessionID]: snapshot },
|
||||||
}))
|
}))
|
||||||
Alert.alert("Reject Failed", "Could not send your response. Please try again.")
|
Alert.alert(t("session.alerts.rejectFailedTitle"), t("session.alerts.rejectFailedMessage"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +547,7 @@ export default function SessionScreen() {
|
|||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{
|
options={{
|
||||||
title: currentSession?.title || "Session",
|
title: currentSession?.title || t("session.titleFallback"),
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View style={s.headerRight}>
|
<View style={s.headerRight}>
|
||||||
{shortDir && (
|
{shortDir && (
|
||||||
@@ -595,12 +594,12 @@ export default function SessionScreen() {
|
|||||||
{/* SSE reconnect/connected banner */}
|
{/* SSE reconnect/connected banner */}
|
||||||
{reconnectAttempts > 0 && (
|
{reconnectAttempts > 0 && (
|
||||||
<View style={[s.banner, s.bannerReconnecting]}>
|
<View style={[s.banner, s.bannerReconnecting]}>
|
||||||
<Text style={s.bannerText}>Reconnecting\u2026 (attempt {reconnectAttempts})</Text>
|
<Text style={s.bannerText}>{t("session.banners.reconnecting", { attempt: reconnectAttempts })}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{showConnectedFlash && reconnectAttempts === 0 && (
|
{showConnectedFlash && reconnectAttempts === 0 && (
|
||||||
<View style={[s.banner, s.bannerConnected]}>
|
<View style={[s.banner, s.bannerConnected]}>
|
||||||
<Text style={s.bannerText}>Connected ✓</Text>
|
<Text style={s.bannerText}>{t("session.banners.connected")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -608,9 +607,9 @@ export default function SessionScreen() {
|
|||||||
cleaned up by the next prompt. */}
|
cleaned up by the next prompt. */}
|
||||||
{revertMessageID && (
|
{revertMessageID && (
|
||||||
<View style={[s.banner, s.bannerRevert]}>
|
<View style={[s.banner, s.bannerRevert]}>
|
||||||
<Text style={s.bannerText}>Message reverted — resend to confirm</Text>
|
<Text style={s.bannerText}>{t("session.banners.reverted")}</Text>
|
||||||
<TouchableOpacity onPress={() => unrevertSession()} hitSlop={8}>
|
<TouchableOpacity onPress={() => unrevertSession()} hitSlop={8}>
|
||||||
<Text style={s.bannerAction}>Undo</Text>
|
<Text style={s.bannerAction}>{t("session.banners.undo")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -645,7 +644,7 @@ export default function SessionScreen() {
|
|||||||
loadingMore ? (
|
loadingMore ? (
|
||||||
<View style={s.loadingMore}>
|
<View style={s.loadingMore}>
|
||||||
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
||||||
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
|
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>{t("session.loadingOlder")}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -655,8 +654,8 @@ export default function SessionScreen() {
|
|||||||
{messageData.length === 0 && (
|
{messageData.length === 0 && (
|
||||||
<View style={s.emptyOverlay} pointerEvents="none">
|
<View style={s.emptyOverlay} pointerEvents="none">
|
||||||
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
|
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
|
<Text style={[s.emptyText, isDark && s.metaDark]}>{t("session.empty.title")}</Text>
|
||||||
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
|
<Text style={[s.emptyHint, isDark && s.metaDark]}>{t("session.empty.hint")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{showScrollButton && (
|
{showScrollButton && (
|
||||||
@@ -726,7 +725,7 @@ export default function SessionScreen() {
|
|||||||
>
|
>
|
||||||
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
|
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
|
||||||
<Text style={[s.variantLabel, isDark && s.metaDark, variant && s.variantLabelActive]} numberOfLines={1}>
|
<Text style={[s.variantLabel, isDark && s.metaDark, variant && s.variantLabelActive]} numberOfLines={1}>
|
||||||
{variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Auto"}
|
{variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : t("session.toolbar.auto")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@@ -752,7 +751,13 @@ export default function SessionScreen() {
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
|
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
|
||||||
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
|
placeholder={
|
||||||
|
speech.listening
|
||||||
|
? t("session.input.placeholderListening")
|
||||||
|
: isSending
|
||||||
|
? t("session.input.placeholderFollowUp")
|
||||||
|
: t("session.input.placeholderDefault")
|
||||||
|
}
|
||||||
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
|
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
|
||||||
value={speech.listening ? speech.transcript : input}
|
value={speech.listening ? speech.transcript : input}
|
||||||
onChangeText={speech.listening ? undefined : setInput}
|
onChangeText={speech.listening ? undefined : setInput}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, type ReactNode } from "react"
|
import { useEffect, type ReactNode } from "react"
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useAuth } from "../stores/auth"
|
import { useAuth } from "../stores/auth"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +11,7 @@ interface Props {
|
|||||||
export function AuthGate({ children }: Props) {
|
export function AuthGate({ children }: Props) {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth()
|
const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth()
|
||||||
|
|
||||||
@@ -45,14 +47,14 @@ export function AuthGate({ children }: Props) {
|
|||||||
<View style={[styles.container, isDark && styles.containerDark]}>
|
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Ionicons name={iconName} size={64} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
<Ionicons name={iconName} size={64} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
<Text style={[styles.title, isDark && styles.textDark]}>OpenCode Locked</Text>
|
<Text style={[styles.title, isDark && styles.textDark]}>{t("authGate.title")}</Text>
|
||||||
<Text style={[styles.subtitle, isDark && styles.subtitleDark]}>Authenticate to access your sessions</Text>
|
<Text style={[styles.subtitle, isDark && styles.subtitleDark]}>{t("authGate.subtitle")}</Text>
|
||||||
|
|
||||||
{error && <Text style={styles.error}>{error}</Text>}
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
<TouchableOpacity style={[styles.button, isDark && styles.buttonDark]} onPress={authenticate}>
|
<TouchableOpacity style={[styles.button, isDark && styles.buttonDark]} onPress={authenticate}>
|
||||||
<Ionicons name={iconName} size={24} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<Ionicons name={iconName} size={24} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
<Text style={[styles.buttonText, isDark && styles.buttonTextDark]}>Unlock</Text>
|
<Text style={[styles.buttonText, isDark && styles.buttonTextDark]}>{t("authGate.unlockButton")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-nati
|
|||||||
import { captureException } from "../lib/sentry"
|
import { captureException } from "../lib/sentry"
|
||||||
import { buildCrashReport, shareReport } from "../lib/diagnostics"
|
import { buildCrashReport, shareReport } from "../lib/diagnostics"
|
||||||
import { log } from "../lib/logbuffer"
|
import { log } from "../lib/logbuffer"
|
||||||
|
// Class component — can't use the useTranslation() hook, so resolve strings
|
||||||
|
// via the shared i18next instance directly (already initialized by the time
|
||||||
|
// this can render, since app/_layout.tsx imports it before mounting).
|
||||||
|
import i18n from "../lib/i18n/config"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -52,21 +56,18 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
const { error, componentStack } = this.state
|
const { error, componentStack } = this.state
|
||||||
if (!error) return this.props.children
|
if (!error) return this.props.children
|
||||||
|
|
||||||
const message = error.message || "Unknown error"
|
const message = error.message || i18n.t("errorBoundary.unknownError")
|
||||||
const stack = (error.stack ?? "").split("\n").slice(0, 6).join("\n")
|
const stack = (error.stack ?? "").split("\n").slice(0, 6).join("\n")
|
||||||
const compStack = (componentStack ?? "").split("\n").slice(0, 6).join("\n")
|
const compStack = (componentStack ?? "").split("\n").slice(0, 6).join("\n")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.title}>Something went wrong</Text>
|
<Text style={styles.title}>{i18n.t("errorBoundary.title")}</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>{i18n.t("errorBoundary.subtitle")}</Text>
|
||||||
The app hit an unexpected error. It has been reported automatically. You can share a detailed report to help us
|
|
||||||
fix it faster.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardLabel}>Error</Text>
|
<Text style={styles.cardLabel}>{i18n.t("errorBoundary.errorLabel")}</Text>
|
||||||
<Text style={styles.cardBody} selectable>
|
<Text style={styles.cardBody} selectable>
|
||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -74,7 +75,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
|
|
||||||
{stack ? (
|
{stack ? (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardLabel}>Stack</Text>
|
<Text style={styles.cardLabel}>{i18n.t("errorBoundary.stackLabel")}</Text>
|
||||||
<Text style={styles.code} selectable>
|
<Text style={styles.code} selectable>
|
||||||
{stack}
|
{stack}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -83,7 +84,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
|
|
||||||
{compStack ? (
|
{compStack ? (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardLabel}>Component</Text>
|
<Text style={styles.cardLabel}>{i18n.t("errorBoundary.componentLabel")}</Text>
|
||||||
<Text style={styles.code} selectable>
|
<Text style={styles.code} selectable>
|
||||||
{compStack}
|
{compStack}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -92,10 +93,10 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<TouchableOpacity style={[styles.button, styles.buttonPrimary]} onPress={this.handleShare}>
|
<TouchableOpacity style={[styles.button, styles.buttonPrimary]} onPress={this.handleShare}>
|
||||||
<Text style={styles.buttonPrimaryText}>Share Report</Text>
|
<Text style={styles.buttonPrimaryText}>{i18n.t("errorBoundary.shareButton")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[styles.button, styles.buttonSecondary]} onPress={this.handleRetry}>
|
<TouchableOpacity style={[styles.button, styles.buttonSecondary]} onPress={this.handleRetry}>
|
||||||
<Text style={styles.buttonSecondaryText}>Try Again</Text>
|
<Text style={styles.buttonSecondaryText}>{i18n.t("errorBoundary.tryAgainButton")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Modal, Linking } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Modal, Linking } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { PRIVACY_POLICY_URL } from "../lib/links"
|
import { PRIVACY_POLICY_URL } from "../lib/links"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,6 +23,7 @@ interface Props {
|
|||||||
export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const isDark = colorScheme === "dark"
|
const isDark = colorScheme === "dark"
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onDecline}>
|
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onDecline}>
|
||||||
@@ -33,32 +35,43 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text style={[styles.title, isDark && styles.textDark]}>Help improve OpenCode?</Text>
|
<Text style={[styles.title, isDark && styles.textDark]}>{t("telemetryConsent.title")}</Text>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<Text style={[styles.body, isDark && styles.bodyDark]}>
|
<Text style={[styles.body, isDark && styles.bodyDark]}>{t("telemetryConsent.body")}</Text>
|
||||||
Share anonymous crash reports and usage analytics to help us find and fix bugs
|
|
||||||
faster. Diagnostic reports you share are also delivered to our support inbox. No
|
|
||||||
code, prompts, or server addresses are ever included.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Detail bullets */}
|
{/* Detail bullets */}
|
||||||
<View style={styles.bullets}>
|
<View style={styles.bullets}>
|
||||||
<BulletRow icon="checkmark-circle" text="Device model, OS version, app version" isDark={isDark} positive />
|
<BulletRow icon="checkmark-circle" text={t("telemetryConsent.bullets.deviceInfo")} isDark={isDark} positive />
|
||||||
<BulletRow icon="checkmark-circle" text="Stack traces of crashes (no variable values)" isDark={isDark} positive />
|
|
||||||
<BulletRow
|
<BulletRow
|
||||||
icon="checkmark-circle"
|
icon="checkmark-circle"
|
||||||
text="Anonymous usage events (app opened, connection attempts, messages sent) — sent to PostHog EU"
|
text={t("telemetryConsent.bullets.stackTraces")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
positive
|
positive
|
||||||
/>
|
/>
|
||||||
<BulletRow icon="close-circle" text="Your code, prompts, or chat messages" isDark={isDark} positive={false} />
|
<BulletRow
|
||||||
<BulletRow icon="close-circle" text="Server URLs or authentication tokens" isDark={isDark} positive={false} />
|
icon="checkmark-circle"
|
||||||
|
text={t("telemetryConsent.bullets.usageEvents")}
|
||||||
|
isDark={isDark}
|
||||||
|
positive
|
||||||
|
/>
|
||||||
|
<BulletRow
|
||||||
|
icon="close-circle"
|
||||||
|
text={t("telemetryConsent.bullets.noCode")}
|
||||||
|
isDark={isDark}
|
||||||
|
positive={false}
|
||||||
|
/>
|
||||||
|
<BulletRow
|
||||||
|
icon="close-circle"
|
||||||
|
text={t("telemetryConsent.bullets.noServerUrls")}
|
||||||
|
isDark={isDark}
|
||||||
|
positive={false}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Privacy policy link */}
|
{/* Privacy policy link */}
|
||||||
<TouchableOpacity onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
<TouchableOpacity onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
||||||
<Text style={styles.privacyLink}>Read our full privacy policy</Text>
|
<Text style={styles.privacyLink}>{t("telemetryConsent.privacyLink")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -66,24 +79,24 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.btn, styles.btnDecline, isDark && styles.btnDeclineDark]}
|
style={[styles.btn, styles.btnDecline, isDark && styles.btnDeclineDark]}
|
||||||
onPress={onDecline}
|
onPress={onDecline}
|
||||||
accessibilityLabel="No thanks, decline crash reporting and analytics"
|
accessibilityLabel={t("telemetryConsent.declineA11yLabel")}
|
||||||
testID="telemetry-decline-button"
|
testID="telemetry-decline-button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.btnDeclineText, isDark && styles.btnDeclineTextDark]}>No thanks</Text>
|
<Text style={[styles.btnDeclineText, isDark && styles.btnDeclineTextDark]}>
|
||||||
|
{t("telemetryConsent.declineButton")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.btn, styles.btnAllow]}
|
style={[styles.btn, styles.btnAllow]}
|
||||||
onPress={onAllow}
|
onPress={onAllow}
|
||||||
accessibilityLabel="Allow anonymous crash reports and usage analytics"
|
accessibilityLabel={t("telemetryConsent.allowA11yLabel")}
|
||||||
testID="telemetry-allow-button"
|
testID="telemetry-allow-button"
|
||||||
>
|
>
|
||||||
<Text style={styles.btnAllowText}>Allow</Text>
|
<Text style={styles.btnAllowText}>{t("telemetryConsent.allowButton")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={[styles.footnote, isDark && styles.footnoteDark]}>
|
<Text style={[styles.footnote, isDark && styles.footnoteDark]}>{t("telemetryConsent.footnote")}</Text>
|
||||||
You can change this at any time in Settings → Privacy.
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"
|
|||||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"
|
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import type { Client, FileEntry } from "../../lib/sdk"
|
import type { Client, FileEntry } from "../../lib/sdk"
|
||||||
import { parentOf, nameOf } from "../../lib/path-utils"
|
import { parentOf, nameOf } from "../../lib/path-utils"
|
||||||
import { normalizeRoots, type FileRoot } from "../../lib/file-roots"
|
import { normalizeRoots, type FileRoot } from "../../lib/file-roots"
|
||||||
@@ -27,6 +28,7 @@ export function DirectoryBrowserSheet({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [browseDir, setBrowseDir] = useState<string | null>(null)
|
const [browseDir, setBrowseDir] = useState<string | null>(null)
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -48,7 +50,7 @@ export function DirectoryBrowserSheet({
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
setEntries([])
|
setEntries([])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setError("No active connection")
|
setError(t("chat.directoryBrowserSheet.noActiveConnection"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
client.file
|
client.file
|
||||||
@@ -60,13 +62,13 @@ export function DirectoryBrowserSheet({
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (loadToken.current !== token) return
|
if (loadToken.current !== token) return
|
||||||
setEntries([])
|
setEntries([])
|
||||||
setError(err instanceof Error ? err.message : "Failed to list directory")
|
setError(err instanceof Error ? err.message : t("chat.directoryBrowserSheet.listFailed"))
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (loadToken.current === token) setLoading(false)
|
if (loadToken.current === token) setLoading(false)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[clientForDirectory],
|
[clientForDirectory, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const enter = useCallback(
|
const enter = useCallback(
|
||||||
@@ -164,7 +166,7 @@ export function DirectoryBrowserSheet({
|
|||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
>
|
>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Text style={[s.title, isDark && s.white]}>Browse Folders</Text>
|
<Text style={[s.title, isDark && s.white]}>{t("chat.directoryBrowserSheet.title")}</Text>
|
||||||
<View style={s.pathRow}>
|
<View style={s.pathRow}>
|
||||||
<TouchableOpacity onPress={goUp} disabled={!canGoUp} hitSlop={8} testID="directory-up-button">
|
<TouchableOpacity onPress={goUp} disabled={!canGoUp} hitSlop={8} testID="directory-up-button">
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -211,7 +213,7 @@ export function DirectoryBrowserSheet({
|
|||||||
<View style={s.inputWrap}>
|
<View style={s.inputWrap}>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
style={[s.input, isDark && s.inputDark]}
|
style={[s.input, isDark && s.inputDark]}
|
||||||
placeholder="Jump to path..."
|
placeholder={t("chat.directoryBrowserSheet.jumpPlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={jumpPath}
|
value={jumpPath}
|
||||||
onChangeText={setJumpPath}
|
onChangeText={setJumpPath}
|
||||||
@@ -263,7 +265,9 @@ export function DirectoryBrowserSheet({
|
|||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
!loading && !error ? (
|
!loading && !error ? (
|
||||||
<Text style={[s.emptyText, isDark && s.dimDark]}>
|
<Text style={[s.emptyText, isDark && s.dimDark]}>
|
||||||
{browseDir ? "No subfolders here" : "Enter a path above to start browsing"}
|
{browseDir
|
||||||
|
? t("chat.directoryBrowserSheet.noSubfolders")
|
||||||
|
: t("chat.directoryBrowserSheet.enterPathHint")}
|
||||||
</Text>
|
</Text>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -278,7 +282,9 @@ export function DirectoryBrowserSheet({
|
|||||||
>
|
>
|
||||||
<Ionicons name="checkmark-circle" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<Ionicons name="checkmark-circle" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
<Text style={[s.selectBtnText, isDark && s.selectBtnTextDark]} numberOfLines={1}>
|
<Text style={[s.selectBtnText, isDark && s.selectBtnTextDark]} numberOfLines={1}>
|
||||||
Use {browseDir ? nameOf(browseDir) : "this folder"}
|
{t("chat.directoryBrowserSheet.useFolderButton", {
|
||||||
|
folder: browseDir ? nameOf(browseDir) : t("chat.directoryBrowserSheet.thisFolderFallback"),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from "react"
|
|||||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sheetRef: React.RefObject<BottomSheet | null>
|
sheetRef: React.RefObject<BottomSheet | null>
|
||||||
@@ -16,6 +17,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch, onBrowse }: Props) {
|
export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch, onBrowse }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [custom, setCustom] = useState("")
|
const [custom, setCustom] = useState("")
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
@@ -36,7 +38,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
// Build list: server default + recents (excluding current)
|
// Build list: server default + recents (excluding current)
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const list: Array<{ label: string; dir?: string; active: boolean }> = [
|
const list: Array<{ label: string; dir?: string; active: boolean }> = [
|
||||||
{ label: "Server Default", dir: undefined, active: !current },
|
{ label: t("chat.directorySwitcher.serverDefaultLabel"), dir: undefined, active: !current },
|
||||||
]
|
]
|
||||||
for (const dir of recents) {
|
for (const dir of recents) {
|
||||||
if (dir === current) continue
|
if (dir === current) continue
|
||||||
@@ -44,7 +46,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
list.push({ label: short, dir, active: false })
|
list.push({ label: short, dir, active: false })
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}, [recents, current])
|
}, [recents, current, t])
|
||||||
|
|
||||||
const shortCurrent = current ? current.split("/").filter(Boolean).pop() || current : null
|
const shortCurrent = current ? current.split("/").filter(Boolean).pop() || current : null
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Text style={[s.title, isDark && s.white]}>Switch Project</Text>
|
<Text style={[s.title, isDark && s.white]}>{t("chat.directorySwitcher.title")}</Text>
|
||||||
{shortCurrent && (
|
{shortCurrent && (
|
||||||
<View style={s.current}>
|
<View style={s.current}>
|
||||||
<Ionicons name="folder" size={14} color="#8b5cf6" />
|
<Ionicons name="folder" size={14} color="#8b5cf6" />
|
||||||
@@ -124,7 +126,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="folder-open-outline" size={14} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
<Ionicons name="folder-open-outline" size={14} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||||
<Text style={[s.chipText, isDark && s.chipTextDark]}>Browse…</Text>
|
<Text style={[s.chipText, isDark && s.chipTextDark]}>{t("chat.directorySwitcher.browseLabel")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -155,14 +157,18 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
{item.dir}
|
{item.dir}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!item.dir && <Text style={[s.rowPath, isDark && s.dimDark]}>Uses server's working directory</Text>}
|
{!item.dir && (
|
||||||
|
<Text style={[s.rowPath, isDark && s.dimDark]}>{t("chat.directorySwitcher.usesServerDir")}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{item.active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
|
{item.active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={s.list}
|
contentContainerStyle={s.list}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
items.length > 1 ? <Text style={[s.section, isDark && s.dimDark]}>Recent Projects</Text> : null
|
items.length > 1 ? (
|
||||||
|
<Text style={[s.section, isDark && s.dimDark]}>{t("chat.directorySwitcher.recentProjectsLabel")}</Text>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react"
|
|||||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import BottomSheet, { BottomSheetBackdrop, BottomSheetSectionList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
import BottomSheet, { BottomSheetBackdrop, BottomSheetSectionList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface ModelItem {
|
interface ModelItem {
|
||||||
providerID: string
|
providerID: string
|
||||||
@@ -25,6 +26,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: Props) {
|
export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
@@ -96,10 +98,10 @@ export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }:
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Text style={[s.title, isDark && s.textWhite]}>Select Model</Text>
|
<Text style={[s.title, isDark && s.textWhite]}>{t("chat.modelPicker.title")}</Text>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
style={[s.search, isDark && s.searchDark]}
|
style={[s.search, isDark && s.searchDark]}
|
||||||
placeholder="Search models..."
|
placeholder={t("chat.modelPicker.searchPlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={setSearch}
|
onChangeText={setSearch}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
permission: { id: string; permission: string; patterns: string[] }
|
permission: { id: string; permission: string; patterns: string[] }
|
||||||
@@ -8,24 +9,25 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionPrompt({ permission, isDark, onReply }: Props) {
|
export function PermissionPrompt({ permission, isDark, onReply }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<View style={[s.card, isDark && s.cardDark]}>
|
<View style={[s.card, isDark && s.cardDark]}>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Ionicons name="shield-outline" size={18} color="#f59e0b" />
|
<Ionicons name="shield-outline" size={18} color="#f59e0b" />
|
||||||
<Text style={[s.title, isDark && s.textWhite]}>Permission Required</Text>
|
<Text style={[s.title, isDark && s.textWhite]}>{t("chat.permissionPrompt.title")}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[s.type, isDark && s.typeDark]}>
|
<Text style={[s.type, isDark && s.typeDark]}>
|
||||||
{permission.permission}: {permission.patterns.join(", ")}
|
{permission.permission}: {permission.patterns.join(", ")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={s.actions}>
|
<View style={s.actions}>
|
||||||
<TouchableOpacity style={[s.btn, s.deny]} onPress={() => onReply("reject")}>
|
<TouchableOpacity style={[s.btn, s.deny]} onPress={() => onReply("reject")}>
|
||||||
<Text style={s.denyText}>Deny</Text>
|
<Text style={s.denyText}>{t("chat.permissionPrompt.deny")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[s.btn, s.always, isDark && s.alwaysDark]} onPress={() => onReply("always")}>
|
<TouchableOpacity style={[s.btn, s.always, isDark && s.alwaysDark]} onPress={() => onReply("always")}>
|
||||||
<Text style={[s.alwaysText, isDark && s.textWhite]}>Always</Text>
|
<Text style={[s.alwaysText, isDark && s.textWhite]}>{t("chat.permissionPrompt.always")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[s.btn, s.allow, isDark && s.allowDark]} onPress={() => onReply("once")}>
|
<TouchableOpacity style={[s.btn, s.allow, isDark && s.allowDark]} onPress={() => onReply("once")}>
|
||||||
<Text style={[s.allowText, isDark && s.allowTextDark]}>Allow</Text>
|
<Text style={[s.allowText, isDark && s.allowTextDark]}>{t("chat.permissionPrompt.allow")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native"
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface QuestionOption {
|
interface QuestionOption {
|
||||||
label: string
|
label: string
|
||||||
@@ -26,6 +27,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [answers, setAnswers] = useState<string[][]>(request.questions.map(() => []))
|
const [answers, setAnswers] = useState<string[][]>(request.questions.map(() => []))
|
||||||
const [custom, setCustom] = useState("")
|
const [custom, setCustom] = useState("")
|
||||||
const [showCustom, setShowCustom] = useState(false)
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
@@ -66,7 +68,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
|||||||
<View style={[s.card, isDark && s.cardDark]}>
|
<View style={[s.card, isDark && s.cardDark]}>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
|
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
|
||||||
<Text style={[s.title, isDark && s.textWhite]}>{q.header || "Question"}</Text>
|
<Text style={[s.title, isDark && s.textWhite]}>{q.header || t("chat.questionPrompt.headerFallback")}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
|
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
|||||||
<View style={s.customRow}>
|
<View style={s.customRow}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[s.customInput, isDark && s.customInputDark]}
|
style={[s.customInput, isDark && s.customInputDark]}
|
||||||
placeholder="Type your answer..."
|
placeholder={t("chat.questionPrompt.answerPlaceholder")}
|
||||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
value={custom}
|
value={custom}
|
||||||
onChangeText={setCustom}
|
onChangeText={setCustom}
|
||||||
@@ -108,14 +110,14 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity style={[s.option, isDark && s.optionDark]} onPress={() => setShowCustom(true)}>
|
<TouchableOpacity style={[s.option, isDark && s.optionDark]} onPress={() => setShowCustom(true)}>
|
||||||
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>Type your own answer</Text>
|
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>{t("chat.questionPrompt.customAnswerLabel")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={s.footer}>
|
<View style={s.footer}>
|
||||||
<TouchableOpacity onPress={onReject}>
|
<TouchableOpacity onPress={onReject}>
|
||||||
<Text style={[s.dismiss, isDark && s.metaDark]}>Dismiss</Text>
|
<Text style={[s.dismiss, isDark && s.metaDark]}>{t("chat.questionPrompt.dismiss")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{(request.questions.length > 1 || q.multiple) && (
|
{(request.questions.length > 1 || q.multiple) && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -128,7 +130,9 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={s.submitText}>{current < request.questions.length - 1 ? "Next" : "Submit"}</Text>
|
<Text style={s.submitText}>
|
||||||
|
{current < request.questions.length - 1 ? t("chat.questionPrompt.next") : t("chat.questionPrompt.submit")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string
|
text: string
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReasoningBlock({ text, isDark }: Props) {
|
export function ReasoningBlock({ text, isDark }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +20,7 @@ export function ReasoningBlock({ text, isDark }: Props) {
|
|||||||
>
|
>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Ionicons name="bulb-outline" size={14} color="#f59e0b" />
|
<Ionicons name="bulb-outline" size={14} color="#f59e0b" />
|
||||||
<Text style={[s.label, isDark && s.labelDark]}>Thinking</Text>
|
<Text style={[s.label, isDark && s.labelDark]}>{t("chat.reasoningBlock.label")}</Text>
|
||||||
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color={isDark ? "#666666" : "#999999"} />
|
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color={isDark ? "#666666" : "#999999"} />
|
||||||
</View>
|
</View>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native"
|
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import type { Message, Session } from "../../lib/sdk"
|
import type { Message, Session } from "../../lib/sdk"
|
||||||
import type { Provider } from "../../stores/catalog"
|
import type { Provider } from "../../stores/catalog"
|
||||||
|
|
||||||
@@ -27,15 +28,15 @@ function formatCost(cost: number): string {
|
|||||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cost)
|
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cost)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
function formatTime(ts: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||||
const diff = Date.now() - ts
|
const diff = Date.now() - ts
|
||||||
const mins = Math.floor(diff / 60_000)
|
const mins = Math.floor(diff / 60_000)
|
||||||
if (mins < 1) return "just now"
|
if (mins < 1) return t("chat.sessionInfo.time.justNow")
|
||||||
if (mins < 60) return `${mins}m ago`
|
if (mins < 60) return t("chat.sessionInfo.time.minutesAgo", { count: mins })
|
||||||
const hours = Math.floor(mins / 60)
|
const hours = Math.floor(mins / 60)
|
||||||
if (hours < 24) return `${hours}h ago`
|
if (hours < 24) return t("chat.sessionInfo.time.hoursAgo", { count: hours })
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
if (days < 7) return `${days}d ago`
|
if (days < 7) return t("chat.sessionInfo.time.daysAgo", { count: days })
|
||||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export function SessionInfo({
|
|||||||
onScrollToTop,
|
onScrollToTop,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// Match TUI: last assistant message tokens (context window), cumulative cost
|
// Match TUI: last assistant message tokens (context window), cumulative cost
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
let cost = 0
|
let cost = 0
|
||||||
@@ -104,7 +106,9 @@ export function SessionInfo({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{hasCost && <Text style={[s.cost, isDark && s.dimDark]}>({formatCost(stats.cost)})</Text>}
|
{hasCost && <Text style={[s.cost, isDark && s.dimDark]}>({formatCost(stats.cost)})</Text>}
|
||||||
{!hasTokens && !hasCost && <Text style={[s.cost, isDark && s.dimDark]}>No usage data yet</Text>}
|
{!hasTokens && !hasCost && (
|
||||||
|
<Text style={[s.cost, isDark && s.dimDark]}>{t("chat.sessionInfo.noUsageData")}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity onPress={onClose} hitSlop={8}>
|
<TouchableOpacity onPress={onClose} hitSlop={8}>
|
||||||
<Ionicons name="close" size={16} color={isDark ? "#666666" : "#999999"} />
|
<Ionicons name="close" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||||
@@ -127,37 +131,50 @@ export function SessionInfo({
|
|||||||
{/* Token breakdown pills */}
|
{/* Token breakdown pills */}
|
||||||
{hasTokens && (
|
{hasTokens && (
|
||||||
<View style={s.breakdown}>
|
<View style={s.breakdown}>
|
||||||
<TokenPill label="In" value={stats.input} color="#3b82f6" isDark={isDark} />
|
<TokenPill label={t("chat.sessionInfo.pills.in")} value={stats.input} color="#3b82f6" isDark={isDark} />
|
||||||
<TokenPill label="Out" value={stats.output} color="#10b981" isDark={isDark} />
|
<TokenPill label={t("chat.sessionInfo.pills.out")} value={stats.output} color="#10b981" isDark={isDark} />
|
||||||
{stats.reasoning > 0 && <TokenPill label="Think" value={stats.reasoning} color="#f59e0b" isDark={isDark} />}
|
{stats.reasoning > 0 && (
|
||||||
{stats.cacheRead > 0 && <TokenPill label="Cache R" value={stats.cacheRead} color="#8b5cf6" isDark={isDark} />}
|
<TokenPill label={t("chat.sessionInfo.pills.think")} value={stats.reasoning} color="#f59e0b" isDark={isDark} />
|
||||||
|
)}
|
||||||
|
{stats.cacheRead > 0 && (
|
||||||
|
<TokenPill label={t("chat.sessionInfo.pills.cacheRead")} value={stats.cacheRead} color="#8b5cf6" isDark={isDark} />
|
||||||
|
)}
|
||||||
{stats.cacheWrite > 0 && (
|
{stats.cacheWrite > 0 && (
|
||||||
<TokenPill label="Cache W" value={stats.cacheWrite} color="#ec4899" isDark={isDark} />
|
<TokenPill
|
||||||
|
label={t("chat.sessionInfo.pills.cacheWrite")}
|
||||||
|
value={stats.cacheWrite}
|
||||||
|
color="#ec4899"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Session metadata */}
|
{/* Session metadata */}
|
||||||
<View style={s.meta}>
|
<View style={s.meta}>
|
||||||
{created && <MetaItem icon="time-outline" label="Created" value={formatTime(created)} isDark={isDark} />}
|
{created && (
|
||||||
|
<MetaItem icon="time-outline" label={t("chat.sessionInfo.meta.created")} value={formatTime(created, t)} isDark={isDark} />
|
||||||
|
)}
|
||||||
{updated && updated !== created && (
|
{updated && updated !== created && (
|
||||||
<MetaItem icon="refresh-outline" label="Updated" value={formatTime(updated)} isDark={isDark} />
|
<MetaItem icon="refresh-outline" label={t("chat.sessionInfo.meta.updated")} value={formatTime(updated, t)} isDark={isDark} />
|
||||||
)}
|
)}
|
||||||
<MetaItem
|
<MetaItem
|
||||||
icon="chatbubbles-outline"
|
icon="chatbubbles-outline"
|
||||||
label="Messages"
|
label={t("chat.sessionInfo.meta.messages")}
|
||||||
value={String(messages.length) + (hasMore ? "+" : "")}
|
value={String(messages.length) + (hasMore ? "+" : "")}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
{summary && summary.files > 0 && (
|
{summary && summary.files > 0 && (
|
||||||
<MetaItem
|
<MetaItem
|
||||||
icon="code-outline"
|
icon="code-outline"
|
||||||
label="Changes"
|
label={t("chat.sessionInfo.meta.changes")}
|
||||||
value={`${summary.files}f +${summary.additions} -${summary.deletions}`}
|
value={`${summary.files}f +${summary.additions} -${summary.deletions}`}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{session?.share?.url && <MetaItem icon="share-outline" label="Shared" value="Yes" isDark={isDark} />}
|
{session?.share?.url && (
|
||||||
|
<MetaItem icon="share-outline" label={t("chat.sessionInfo.meta.shared")} value={t("chat.sessionInfo.meta.yes")} isDark={isDark} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Navigation actions */}
|
{/* Navigation actions */}
|
||||||
@@ -169,13 +186,15 @@ export function SessionInfo({
|
|||||||
) : (
|
) : (
|
||||||
<Ionicons name="download-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
<Ionicons name="download-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
)}
|
)}
|
||||||
<Text style={[s.actionText, isDark && s.dimDark]}>{loadingAll ? "Loading..." : "Load all messages"}</Text>
|
<Text style={[s.actionText, isDark && s.dimDark]}>
|
||||||
|
{loadingAll ? t("chat.sessionInfo.loading") : t("chat.sessionInfo.loadAllMessages")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onScrollToTop}>
|
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onScrollToTop}>
|
||||||
<Ionicons name="arrow-up-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
<Ionicons name="arrow-up-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
<Text style={[s.actionText, isDark && s.dimDark]}>Jump to beginning</Text>
|
<Text style={[s.actionText, isDark && s.dimDark]}>{t("chat.sessionInfo.jumpToBeginning")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Keyboard, Platform } from "react-native"
|
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Keyboard, Platform } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
trigger: string
|
trigger: string
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SlashPopover({ query, commands, isDark, onSelect }: Props) {
|
export function SlashPopover({ query, commands, isDark, onSelect }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
return commands.filter((c) => c.trigger.toLowerCase().startsWith(q) || c.title.toLowerCase().includes(q))
|
return commands.filter((c) => c.trigger.toLowerCase().startsWith(q) || c.title.toLowerCase().includes(q))
|
||||||
@@ -45,7 +47,7 @@ export function SlashPopover({ query, commands, isDark, onSelect }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
{cmd.type === "custom" && (
|
{cmd.type === "custom" && (
|
||||||
<View style={[s.badge, isDark && s.badgeDark]}>
|
<View style={[s.badge, isDark && s.badgeDark]}>
|
||||||
<Text style={s.badgeText}>cmd</Text>
|
<Text style={s.badgeText}>{t("chat.slashPopover.customBadge")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { View, Text, StyleSheet, ActivityIndicator } from "react-native"
|
import { View, Text, StyleSheet, ActivityIndicator } from "react-native"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { useEvents } from "../../stores/events"
|
import { useEvents } from "../../stores/events"
|
||||||
import { useSessions } from "../../stores/sessions"
|
import { useSessions } from "../../stores/sessions"
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusIndicator({ sessionID, isDark }: Props) {
|
export function StatusIndicator({ sessionID, isDark }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const status = useEvents((s) => s.sessionStatus[sessionID])
|
const status = useEvents((s) => s.sessionStatus[sessionID])
|
||||||
const text = useEvents((s) => s.statusText[sessionID])
|
const text = useEvents((s) => s.statusText[sessionID])
|
||||||
const optimistic = useSessions((s) => s.sending[sessionID])
|
const optimistic = useSessions((s) => s.sending[sessionID])
|
||||||
@@ -19,7 +21,8 @@ export function StatusIndicator({ sessionID, isDark }: Props) {
|
|||||||
const busy = sseBusy || (optimistic && !status)
|
const busy = sseBusy || (optimistic && !status)
|
||||||
if (!busy) return null
|
if (!busy) return null
|
||||||
|
|
||||||
const label = status?.type === "retry" ? `Retrying (attempt ${status.attempt})...` : text || "Working..."
|
const label =
|
||||||
|
status?.type === "retry" ? t("chat.statusIndicator.retrying", { attempt: status.attempt }) : text || t("chat.statusIndicator.working")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[s.bar, isDark && s.barDark]}>
|
<View style={[s.bar, isDark && s.barDark]}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback } from "react"
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, Platform } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, Platform } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import type { Part } from "../../lib/sdk"
|
import type { Part } from "../../lib/sdk"
|
||||||
import { DiffView } from "./DiffView"
|
import { DiffView } from "./DiffView"
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
|
function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const pattern = typeof input === "object" && input !== null ? (input as Record<string, unknown>).pattern : undefined
|
const pattern = typeof input === "object" && input !== null ? (input as Record<string, unknown>).pattern : undefined
|
||||||
const path = typeof input === "object" && input !== null ? (input as Record<string, unknown>).path : undefined
|
const path = typeof input === "object" && input !== null ? (input as Record<string, unknown>).path : undefined
|
||||||
const results = typeof output === "string" ? output : undefined
|
const results = typeof output === "string" ? output : undefined
|
||||||
@@ -158,8 +160,9 @@ function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unk
|
|||||||
<View style={s.detailSection}>
|
<View style={s.detailSection}>
|
||||||
{typeof pattern === "string" && (
|
{typeof pattern === "string" && (
|
||||||
<Text style={[s.detailMeta, isDark && s.detailMetaDark]}>
|
<Text style={[s.detailMeta, isDark && s.detailMetaDark]}>
|
||||||
Pattern: {pattern}
|
{typeof path === "string"
|
||||||
{typeof path === "string" ? ` in ${path}` : ""}
|
? t("chat.toolCallCard.patternWithPath", { pattern, path })
|
||||||
|
: t("chat.toolCallCard.patternOnly", { pattern })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{results && results.length > 0 && (
|
{results && results.length > 0 && (
|
||||||
@@ -310,6 +313,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallCard({ tool, isDark }: Props) {
|
export function ToolCallCard({ tool, isDark }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline"
|
const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline"
|
||||||
const status = tool.state?.status || "pending"
|
const status = tool.state?.status || "pending"
|
||||||
@@ -338,7 +342,7 @@ export function ToolCallCard({ tool, isDark }: Props) {
|
|||||||
<View style={s.headerLeft}>
|
<View style={s.headerLeft}>
|
||||||
<Ionicons name={icon as any} size={16} color={color} />
|
<Ionicons name={icon as any} size={16} color={color} />
|
||||||
<Text style={[s.name, isDark && s.nameDark]} numberOfLines={1}>
|
<Text style={[s.name, isDark && s.nameDark]} numberOfLines={1}>
|
||||||
{tool.state?.title || tool.tool || "Tool"}
|
{tool.state?.title || tool.tool || t("chat.toolCallCard.fallbackTitle")}
|
||||||
</Text>
|
</Text>
|
||||||
{elapsed && <Text style={[s.elapsed, isDark && s.elapsedDark]}>{elapsed}</Text>}
|
{elapsed && <Text style={[s.elapsed, isDark && s.elapsedDark]}>{elapsed}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
|
||||||
import { Ionicons } from "@expo/vector-icons"
|
import { Ionicons } from "@expo/vector-icons"
|
||||||
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList } from "@gorhom/bottom-sheet"
|
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList } from "@gorhom/bottom-sheet"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface VariantOption {
|
interface VariantOption {
|
||||||
id: string | null
|
id: string | null
|
||||||
@@ -16,25 +17,26 @@ interface Props {
|
|||||||
sheetRef: React.RefObject<BottomSheet | null>
|
sheetRef: React.RefObject<BottomSheet | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_OPTION: VariantOption = {
|
|
||||||
id: null,
|
|
||||||
label: "Auto",
|
|
||||||
description: "Use model default reasoning",
|
|
||||||
}
|
|
||||||
|
|
||||||
const EFFORT_DESCRIPTIONS: Record<string, string> = {
|
|
||||||
low: "Faster, less thorough reasoning",
|
|
||||||
medium: "Balanced reasoning and speed",
|
|
||||||
high: "Deep, thorough reasoning",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }: Props) {
|
export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const effortDescriptions: Record<string, string> = {
|
||||||
|
low: t("chat.variantPicker.effort.low"),
|
||||||
|
medium: t("chat.variantPicker.effort.medium"),
|
||||||
|
high: t("chat.variantPicker.effort.high"),
|
||||||
|
}
|
||||||
|
const autoOption: VariantOption = {
|
||||||
|
id: null,
|
||||||
|
label: t("chat.variantPicker.autoLabel"),
|
||||||
|
description: t("chat.variantPicker.autoDescription"),
|
||||||
|
}
|
||||||
|
|
||||||
const options: VariantOption[] = [
|
const options: VariantOption[] = [
|
||||||
AUTO_OPTION,
|
autoOption,
|
||||||
...Object.keys(variants || {}).map((id) => ({
|
...Object.keys(variants || {}).map((id) => ({
|
||||||
id,
|
id,
|
||||||
label: id.charAt(0).toUpperCase() + id.slice(1),
|
label: id.charAt(0).toUpperCase() + id.slice(1),
|
||||||
description: EFFORT_DESCRIPTIONS[id] ?? id,
|
description: effortDescriptions[id] ?? id,
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
<Text style={[s.title, isDark && s.textWhite]}>Reasoning Effort</Text>
|
<Text style={[s.title, isDark && s.textWhite]}>{t("chat.variantPicker.title")}</Text>
|
||||||
</View>
|
</View>
|
||||||
<BottomSheetFlatList
|
<BottomSheetFlatList
|
||||||
data={options}
|
data={options}
|
||||||
|
|||||||
46
src/lib/i18n/catalog-parity.test.ts
Normal file
46
src/lib/i18n/catalog-parity.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Guards against locale drift: en.json and zh-Hans.json must expose exactly
|
||||||
|
// the same set of translation keys, or i18next silently falls back to the
|
||||||
|
// key path (en) / nothing sensible (missing zh copy) at runtime. Run with
|
||||||
|
// plain `node --test` — no i18next/expo-localization imports needed, same
|
||||||
|
// as locale-resolve.test.ts.
|
||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import en from "./en.json" with { type: "json" }
|
||||||
|
import zhHans from "./zh-Hans.json" with { type: "json" }
|
||||||
|
|
||||||
|
// Flattens a nested translation object into dotted leaf-key paths, e.g.
|
||||||
|
// { settings: { language: { label: "..." } } } -> ["settings.language.label"]
|
||||||
|
function flattenKeys(obj: unknown, prefix = ""): string[] {
|
||||||
|
if (typeof obj !== "object" || obj === null) return [prefix]
|
||||||
|
const keys: string[] = []
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
keys.push(...flattenKeys(value, path))
|
||||||
|
} else {
|
||||||
|
keys.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
test("en.json and zh-Hans.json expose identical translation keys", () => {
|
||||||
|
const enKeys = new Set(flattenKeys(en))
|
||||||
|
const zhKeys = new Set(flattenKeys(zhHans))
|
||||||
|
|
||||||
|
const missingFromZh = [...enKeys].filter((k) => !zhKeys.has(k)).sort()
|
||||||
|
const missingFromEn = [...zhKeys].filter((k) => !enKeys.has(k)).sort()
|
||||||
|
|
||||||
|
assert.deepEqual(missingFromZh, [], `keys present in en.json but missing from zh-Hans.json: ${missingFromZh.join(", ")}`)
|
||||||
|
assert.deepEqual(missingFromEn, [], `keys present in zh-Hans.json but missing from en.json: ${missingFromEn.join(", ")}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("no translation value is an empty string", () => {
|
||||||
|
for (const [name, catalog] of [["en", en], ["zh-Hans", zhHans]] as const) {
|
||||||
|
const keys = flattenKeys(catalog)
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = key.split(".").reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], catalog)
|
||||||
|
assert.notEqual(value, "", `${name}.json: "${key}" is an empty string`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"ok": "OK",
|
||||||
|
"error": "Error",
|
||||||
|
"delete": "Delete",
|
||||||
|
"retry": "Retry",
|
||||||
|
"save": "Save",
|
||||||
|
"shareReport": "Share report"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -65,5 +71,357 @@
|
|||||||
"notificationsDisabledTitle": "Notifications Disabled",
|
"notificationsDisabledTitle": "Notifications Disabled",
|
||||||
"notificationsDisabledMessage": "Enable notifications for OpenCode in your device settings to receive alerts."
|
"notificationsDisabledMessage": "Enable notifications for OpenCode in your device settings to receive alerts."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"titleFallback": "Session",
|
||||||
|
"toolbar": {
|
||||||
|
"auto": "Auto"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"reconnecting": "Reconnecting… (attempt {{attempt}})",
|
||||||
|
"connected": "Connected ✓",
|
||||||
|
"reverted": "Message reverted — resend to confirm",
|
||||||
|
"undo": "Undo"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Start a conversation",
|
||||||
|
"hint": "Type / for commands"
|
||||||
|
},
|
||||||
|
"loadingOlder": "Loading older messages...",
|
||||||
|
"input": {
|
||||||
|
"placeholderListening": "Listening...",
|
||||||
|
"placeholderFollowUp": "Send a follow-up...",
|
||||||
|
"placeholderDefault": "Type a message..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"editMessage": "Edit message",
|
||||||
|
"replace": "Replace"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"messageActionsTitle": "Message actions",
|
||||||
|
"notSupportedTitle": "Not supported",
|
||||||
|
"notSupportedMessage": "Editing sent messages needs a newer opencode server. Please update the server and try again.",
|
||||||
|
"revertAuthFailedTitle": "Authentication failed",
|
||||||
|
"revertAuthFailedMessage": "Your server credentials were rejected. Check your connection and try again.",
|
||||||
|
"editFailedTitle": "Edit failed",
|
||||||
|
"editFailedMessage": "Could not revert to this message. Please try again.",
|
||||||
|
"replaceDraftTitle": "Replace draft?",
|
||||||
|
"replaceDraftMessage": "You have an unsent message in the composer. Editing this message will replace it.",
|
||||||
|
"cameraPermissionTitle": "Permission needed",
|
||||||
|
"cameraPermissionMessage": "Camera access is required to take photos.",
|
||||||
|
"emptyClipboardTitle": "Empty clipboard",
|
||||||
|
"emptyClipboardMessage": "Clipboard does not contain text or an image.",
|
||||||
|
"authRequiredTitle": "Authentication required",
|
||||||
|
"authRequiredMessage": "Biometric authentication is required to send. Please try again.",
|
||||||
|
"sendFailedTitle": "Message not sent",
|
||||||
|
"sendFailedMessage": "Could not send your message. Check your connection and try again.",
|
||||||
|
"replyFailedTitle": "Reply Failed",
|
||||||
|
"replyFailedMessage": "Could not send your response. Please try again.",
|
||||||
|
"rejectFailedTitle": "Reject Failed",
|
||||||
|
"rejectFailedMessage": "Could not send your response. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"shared": {
|
||||||
|
"connectionType": "Connection Type",
|
||||||
|
"types": {
|
||||||
|
"local": "Local",
|
||||||
|
"tunnel": "Tunnel",
|
||||||
|
"cloud": "Cloud"
|
||||||
|
},
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "My Server",
|
||||||
|
"serverUrl": "Server URL",
|
||||||
|
"directoryOptional": "Project Directory (Optional)",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"alerts": {
|
||||||
|
"enterName": "Please enter a connection name",
|
||||||
|
"enterUrl": "Please enter a server URL",
|
||||||
|
"invalidUrlTitle": "Invalid URL",
|
||||||
|
"invalidUrlMessage": "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096",
|
||||||
|
"connectionFailedTitle": "Connection Failed",
|
||||||
|
"unknownError": "Unknown error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"add": {
|
||||||
|
"quick": {
|
||||||
|
"title": "Connect to OpenCode",
|
||||||
|
"subtitle": "Enter your computer's IP address to connect",
|
||||||
|
"ipAddressLabel": "IP Address",
|
||||||
|
"nameOptionalLabel": "Name (optional)",
|
||||||
|
"namePlaceholder": "My Mac",
|
||||||
|
"passwordIfSetLabel": "Password (if set on server)",
|
||||||
|
"passwordPlaceholder": "Leave empty if none",
|
||||||
|
"usernameHintPrefix": "Username defaults to ",
|
||||||
|
"usernameHintMiddle": ". Custom username? Use ",
|
||||||
|
"usernameHintSuffix": ".",
|
||||||
|
"advancedOptionsLink": "Advanced options",
|
||||||
|
"connectButton": "Connect",
|
||||||
|
"helpTitle": "How to find your IP:",
|
||||||
|
"helpMacPrefix": "On your Mac, run:",
|
||||||
|
"helpTailscalePrefix": "Tailscale examples:",
|
||||||
|
"helpProtocolPrefix": "Use ",
|
||||||
|
"helpProtocolMiddle": " for local and Tailscale addresses unless TLS is configured, then use ",
|
||||||
|
"helpProtocolSuffix": ".",
|
||||||
|
"helpRunningPrefix": "Make sure OpenCode is running:",
|
||||||
|
"connectCardTitle": "OpenCode Connect",
|
||||||
|
"connectCardBadge": "Coming Soon",
|
||||||
|
"connectCardDesc": "Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from anywhere.",
|
||||||
|
"advancedLink": "Advanced options (tunnels, cloud, auth)"
|
||||||
|
},
|
||||||
|
"waitlist": {
|
||||||
|
"successText": "You're on the list — we'll email you when OpenCode Connect is ready.",
|
||||||
|
"joinButton": "Join Waitlist",
|
||||||
|
"alertTitle": "Join Waitlist",
|
||||||
|
"fallbackMessage": "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\"."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"backToQuick": "Simple mode",
|
||||||
|
"urlHintPrefix": "Local/Tailscale examples: ",
|
||||||
|
"urlHintOr": " or ",
|
||||||
|
"urlHintUse": ". Use ",
|
||||||
|
"urlHintSuffix": " only when TLS is configured.",
|
||||||
|
"directoryHint": "Leave empty to use the server's current directory, or specify a path to work in a different folder.",
|
||||||
|
"saveButton": "Save Connection"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"enterIp": "Please enter your computer's IP address",
|
||||||
|
"connectionFailedMessage": "{{summary}}\n\nTarget: {{target}}\nError: {{error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"notFound": "Connection not found",
|
||||||
|
"directoryHint": "Leave empty to use the server's current directory. Sessions will be created in this folder.",
|
||||||
|
"passwordLabel": "Password (leave empty to keep existing)",
|
||||||
|
"testButton": "Test Connection",
|
||||||
|
"saveButton": "Save Changes",
|
||||||
|
"deleteButton": "Delete Connection",
|
||||||
|
"alerts": {
|
||||||
|
"successTitle": "Success",
|
||||||
|
"successMessage": "Connection successful!",
|
||||||
|
"connectionFailedMessage": "{{summary}}\n\n({{detail}})",
|
||||||
|
"noDetail": "no detail",
|
||||||
|
"deleteTitle": "Delete Connection",
|
||||||
|
"deleteMessage": "Are you sure you want to delete \"{{name}}\"?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"sessionsTab": "Sessions",
|
||||||
|
"connectionsTab": "Connections",
|
||||||
|
"settingsTab": "Settings",
|
||||||
|
"addConnectionTitle": "Add Connection",
|
||||||
|
"editConnectionTitle": "Edit Connection"
|
||||||
|
},
|
||||||
|
"connectionsList": {
|
||||||
|
"activeBadge": "Active",
|
||||||
|
"lastConnected": "Last connected: {{date}}",
|
||||||
|
"actionsAlert": {
|
||||||
|
"message": "What would you like to do?",
|
||||||
|
"edit": "Edit"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No Connections",
|
||||||
|
"subtitle": "Add a connection to your OpenCode server"
|
||||||
|
},
|
||||||
|
"header": "Tap to switch, long press for options",
|
||||||
|
"preferences": {
|
||||||
|
"title": "Preferences",
|
||||||
|
"pageSizeLabel": "Messages per page",
|
||||||
|
"pageSizeHint": "How many messages to load when opening a session. Lower = faster."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessionsList": {
|
||||||
|
"untitledSession": "Untitled Session",
|
||||||
|
"time": {
|
||||||
|
"justNow": "Just now",
|
||||||
|
"minutesAgo": "{{count}}m ago",
|
||||||
|
"hoursAgo": "{{count}}h ago",
|
||||||
|
"daysAgo": "{{count}}d ago"
|
||||||
|
},
|
||||||
|
"filesCount": "{{count}} files",
|
||||||
|
"actions": {
|
||||||
|
"rename": "Rename"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"renameFailedTitle": "Rename failed",
|
||||||
|
"renameFailedMessage": "Could not rename the session. Please try again.",
|
||||||
|
"deleteTitle": "Delete Session",
|
||||||
|
"deleteMessage": "Delete \"{{title}}\"?",
|
||||||
|
"deleteFailedTitle": "Delete failed",
|
||||||
|
"deleteFailedMessage": "Could not delete the session. Please try again.",
|
||||||
|
"createFailedMessage": "Failed to create session in that directory."
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noConnectionTitle": "No Connection",
|
||||||
|
"noConnectionSubtitle": "Add a server connection to get started",
|
||||||
|
"addConnectionButton": "Add Connection",
|
||||||
|
"authFailedTitle": "Authentication Failed",
|
||||||
|
"authFailedSubtitle": "{{name}} rejected your credentials. Check the username and password to reconnect.",
|
||||||
|
"checkCredentialsButton": "Check Credentials",
|
||||||
|
"noSessions": "No sessions yet"
|
||||||
|
},
|
||||||
|
"newSessionModal": {
|
||||||
|
"title": "New Session",
|
||||||
|
"currentProjectLabel": "Current Project",
|
||||||
|
"serverDefault": "Server default",
|
||||||
|
"recentProjectsLabel": "Recent Projects",
|
||||||
|
"serverProjectsLabel": "Server Projects",
|
||||||
|
"browseFoldersLabel": "Browse Folders…",
|
||||||
|
"browseFoldersHint": "Explore the server's filesystem",
|
||||||
|
"enterPathLabel": "Enter Path Manually",
|
||||||
|
"createInButton": "Create in {{dir}}",
|
||||||
|
"createSessionButton": "Create Session"
|
||||||
|
},
|
||||||
|
"renameModal": {
|
||||||
|
"title": "Rename Session"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"categories": {
|
||||||
|
"permissions": {
|
||||||
|
"label": "Permission Requests",
|
||||||
|
"description": "When a tool needs approval to proceed"
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"label": "Questions",
|
||||||
|
"description": "When the assistant needs your input"
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"label": "Task Completed",
|
||||||
|
"description": "When a session finishes processing"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"label": "Errors",
|
||||||
|
"description": "When a session encounters a problem"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"label": "Connection",
|
||||||
|
"description": "When the app loses connection for an extended period"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorBoundary": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"subtitle": "The app hit an unexpected error. It has been reported automatically. You can share a detailed report to help us fix it faster.",
|
||||||
|
"errorLabel": "Error",
|
||||||
|
"unknownError": "Unknown error",
|
||||||
|
"stackLabel": "Stack",
|
||||||
|
"componentLabel": "Component",
|
||||||
|
"shareButton": "Share Report",
|
||||||
|
"tryAgainButton": "Try Again"
|
||||||
|
},
|
||||||
|
"telemetryConsent": {
|
||||||
|
"title": "Help improve OpenCode?",
|
||||||
|
"body": "Share anonymous crash reports and usage analytics to help us find and fix bugs faster. Diagnostic reports you share are also delivered to our support inbox. No code, prompts, or server addresses are ever included.",
|
||||||
|
"bullets": {
|
||||||
|
"deviceInfo": "Device model, OS version, app version",
|
||||||
|
"stackTraces": "Stack traces of crashes (no variable values)",
|
||||||
|
"usageEvents": "Anonymous usage events (app opened, connection attempts, messages sent) — sent to PostHog EU",
|
||||||
|
"noCode": "Your code, prompts, or chat messages",
|
||||||
|
"noServerUrls": "Server URLs or authentication tokens"
|
||||||
|
},
|
||||||
|
"privacyLink": "Read our full privacy policy",
|
||||||
|
"declineA11yLabel": "No thanks, decline crash reporting and analytics",
|
||||||
|
"declineButton": "No thanks",
|
||||||
|
"allowA11yLabel": "Allow anonymous crash reports and usage analytics",
|
||||||
|
"allowButton": "Allow",
|
||||||
|
"footnote": "You can change this at any time in Settings → Privacy."
|
||||||
|
},
|
||||||
|
"authGate": {
|
||||||
|
"title": "OpenCode Locked",
|
||||||
|
"subtitle": "Authenticate to access your sessions",
|
||||||
|
"unlockButton": "Unlock"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"permissionPrompt": {
|
||||||
|
"title": "Permission Required",
|
||||||
|
"deny": "Deny",
|
||||||
|
"always": "Always",
|
||||||
|
"allow": "Allow"
|
||||||
|
},
|
||||||
|
"questionPrompt": {
|
||||||
|
"headerFallback": "Question",
|
||||||
|
"answerPlaceholder": "Type your answer...",
|
||||||
|
"customAnswerLabel": "Type your own answer",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"next": "Next",
|
||||||
|
"submit": "Submit"
|
||||||
|
},
|
||||||
|
"statusIndicator": {
|
||||||
|
"retrying": "Retrying (attempt {{attempt}})...",
|
||||||
|
"working": "Working..."
|
||||||
|
},
|
||||||
|
"slashPopover": {
|
||||||
|
"customBadge": "cmd"
|
||||||
|
},
|
||||||
|
"modelPicker": {
|
||||||
|
"title": "Select Model",
|
||||||
|
"searchPlaceholder": "Search models..."
|
||||||
|
},
|
||||||
|
"variantPicker": {
|
||||||
|
"title": "Reasoning Effort",
|
||||||
|
"autoLabel": "Auto",
|
||||||
|
"autoDescription": "Use model default reasoning",
|
||||||
|
"effort": {
|
||||||
|
"low": "Faster, less thorough reasoning",
|
||||||
|
"medium": "Balanced reasoning and speed",
|
||||||
|
"high": "Deep, thorough reasoning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directorySwitcher": {
|
||||||
|
"title": "Switch Project",
|
||||||
|
"serverDefaultLabel": "Server Default",
|
||||||
|
"browseLabel": "Browse…",
|
||||||
|
"recentProjectsLabel": "Recent Projects",
|
||||||
|
"usesServerDir": "Uses server's working directory"
|
||||||
|
},
|
||||||
|
"directoryBrowserSheet": {
|
||||||
|
"noActiveConnection": "No active connection",
|
||||||
|
"listFailed": "Failed to list directory",
|
||||||
|
"title": "Browse Folders",
|
||||||
|
"jumpPlaceholder": "Jump to path...",
|
||||||
|
"noSubfolders": "No subfolders here",
|
||||||
|
"enterPathHint": "Enter a path above to start browsing",
|
||||||
|
"useFolderButton": "Use {{folder}}",
|
||||||
|
"thisFolderFallback": "this folder"
|
||||||
|
},
|
||||||
|
"reasoningBlock": {
|
||||||
|
"label": "Thinking"
|
||||||
|
},
|
||||||
|
"toolCallCard": {
|
||||||
|
"fallbackTitle": "Tool",
|
||||||
|
"patternOnly": "Pattern: {{pattern}}",
|
||||||
|
"patternWithPath": "Pattern: {{pattern}} in {{path}}"
|
||||||
|
},
|
||||||
|
"sessionInfo": {
|
||||||
|
"noUsageData": "No usage data yet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"loadAllMessages": "Load all messages",
|
||||||
|
"jumpToBeginning": "Jump to beginning",
|
||||||
|
"pills": {
|
||||||
|
"in": "In",
|
||||||
|
"out": "Out",
|
||||||
|
"think": "Think",
|
||||||
|
"cacheRead": "Cache R",
|
||||||
|
"cacheWrite": "Cache W"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"created": "Created",
|
||||||
|
"updated": "Updated",
|
||||||
|
"messages": "Messages",
|
||||||
|
"changes": "Changes",
|
||||||
|
"shared": "Shared",
|
||||||
|
"yes": "Yes"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "just now",
|
||||||
|
"minutesAgo": "{{count}}m ago",
|
||||||
|
"hoursAgo": "{{count}}h ago",
|
||||||
|
"daysAgo": "{{count}}d ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消"
|
"cancel": "取消",
|
||||||
|
"ok": "确定",
|
||||||
|
"error": "错误",
|
||||||
|
"delete": "删除",
|
||||||
|
"retry": "重试",
|
||||||
|
"save": "保存",
|
||||||
|
"shareReport": "分享报告"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -65,5 +71,357 @@
|
|||||||
"notificationsDisabledTitle": "通知已关闭",
|
"notificationsDisabledTitle": "通知已关闭",
|
||||||
"notificationsDisabledMessage": "请在设备设置中为 OpenCode 启用通知以接收提醒。"
|
"notificationsDisabledMessage": "请在设备设置中为 OpenCode 启用通知以接收提醒。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"titleFallback": "会话",
|
||||||
|
"toolbar": {
|
||||||
|
"auto": "自动"
|
||||||
|
},
|
||||||
|
"banners": {
|
||||||
|
"reconnecting": "正在重新连接…(第 {{attempt}} 次尝试)",
|
||||||
|
"connected": "已连接 ✓",
|
||||||
|
"reverted": "消息已撤回 — 重新发送以确认",
|
||||||
|
"undo": "撤销"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "开始一段对话",
|
||||||
|
"hint": "输入 / 使用命令"
|
||||||
|
},
|
||||||
|
"loadingOlder": "正在加载更早的消息...",
|
||||||
|
"input": {
|
||||||
|
"placeholderListening": "正在聆听...",
|
||||||
|
"placeholderFollowUp": "发送后续消息...",
|
||||||
|
"placeholderDefault": "输入消息..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"editMessage": "编辑消息",
|
||||||
|
"replace": "替换"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"messageActionsTitle": "消息操作",
|
||||||
|
"notSupportedTitle": "不支持此操作",
|
||||||
|
"notSupportedMessage": "编辑已发送的消息需要更新的 opencode 服务器版本。请更新服务器后重试。",
|
||||||
|
"revertAuthFailedTitle": "身份验证失败",
|
||||||
|
"revertAuthFailedMessage": "您的服务器凭据被拒绝。请检查连接后重试。",
|
||||||
|
"editFailedTitle": "编辑失败",
|
||||||
|
"editFailedMessage": "无法撤回到该消息。请重试。",
|
||||||
|
"replaceDraftTitle": "替换草稿?",
|
||||||
|
"replaceDraftMessage": "输入框中有未发送的消息。编辑此消息将替换它。",
|
||||||
|
"cameraPermissionTitle": "需要权限",
|
||||||
|
"cameraPermissionMessage": "拍照需要相机访问权限。",
|
||||||
|
"emptyClipboardTitle": "剪贴板为空",
|
||||||
|
"emptyClipboardMessage": "剪贴板中没有文本或图片。",
|
||||||
|
"authRequiredTitle": "需要身份验证",
|
||||||
|
"authRequiredMessage": "发送消息需要生物识别验证。请重试。",
|
||||||
|
"sendFailedTitle": "消息未发送",
|
||||||
|
"sendFailedMessage": "无法发送您的消息。请检查连接后重试。",
|
||||||
|
"replyFailedTitle": "回复失败",
|
||||||
|
"replyFailedMessage": "无法发送您的回复。请重试。",
|
||||||
|
"rejectFailedTitle": "拒绝失败",
|
||||||
|
"rejectFailedMessage": "无法发送您的回复。请重试。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"shared": {
|
||||||
|
"connectionType": "连接类型",
|
||||||
|
"types": {
|
||||||
|
"local": "本地",
|
||||||
|
"tunnel": "隧道",
|
||||||
|
"cloud": "云端"
|
||||||
|
},
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "我的服务器",
|
||||||
|
"serverUrl": "服务器地址",
|
||||||
|
"directoryOptional": "项目目录(可选)",
|
||||||
|
"authentication": "身份验证",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"alerts": {
|
||||||
|
"enterName": "请输入连接名称",
|
||||||
|
"enterUrl": "请输入服务器地址",
|
||||||
|
"invalidUrlTitle": "地址无效",
|
||||||
|
"invalidUrlMessage": "请输入包含 http:// 或 https:// 的完整地址,例如 http://192.168.1.100:4096",
|
||||||
|
"connectionFailedTitle": "连接失败",
|
||||||
|
"unknownError": "未知错误"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"add": {
|
||||||
|
"quick": {
|
||||||
|
"title": "连接到 OpenCode",
|
||||||
|
"subtitle": "输入您电脑的 IP 地址进行连接",
|
||||||
|
"ipAddressLabel": "IP 地址",
|
||||||
|
"nameOptionalLabel": "名称(可选)",
|
||||||
|
"namePlaceholder": "我的 Mac",
|
||||||
|
"passwordIfSetLabel": "密码(如服务器已设置)",
|
||||||
|
"passwordPlaceholder": "留空表示无密码",
|
||||||
|
"usernameHintPrefix": "用户名默认为 ",
|
||||||
|
"usernameHintMiddle": "。需要自定义用户名?请使用",
|
||||||
|
"usernameHintSuffix": "。",
|
||||||
|
"advancedOptionsLink": "高级选项",
|
||||||
|
"connectButton": "连接",
|
||||||
|
"helpTitle": "如何查找您的 IP 地址:",
|
||||||
|
"helpMacPrefix": "在您的 Mac 上运行:",
|
||||||
|
"helpTailscalePrefix": "Tailscale 示例:",
|
||||||
|
"helpProtocolPrefix": "本地和 Tailscale 地址请使用 ",
|
||||||
|
"helpProtocolMiddle": ",除非已配置 TLS,此时请使用 ",
|
||||||
|
"helpProtocolSuffix": "。",
|
||||||
|
"helpRunningPrefix": "请确保 OpenCode 正在运行:",
|
||||||
|
"connectCardTitle": "OpenCode Connect",
|
||||||
|
"connectCardBadge": "即将上线",
|
||||||
|
"connectCardDesc": "将手机桥接到您的 opencode 服务器 — 无需配置隧道或防火墙。一键连接,随时随地。",
|
||||||
|
"advancedLink": "高级选项(隧道、云端、身份验证)"
|
||||||
|
},
|
||||||
|
"waitlist": {
|
||||||
|
"successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。",
|
||||||
|
"joinButton": "加入候补名单",
|
||||||
|
"alertTitle": "加入候补名单",
|
||||||
|
"fallbackMessage": "无法连接注册服务或打开邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"backToQuick": "简易模式",
|
||||||
|
"urlHintPrefix": "本地/Tailscale 示例:",
|
||||||
|
"urlHintOr": " 或 ",
|
||||||
|
"urlHintUse": "。仅在已配置 TLS 时使用 ",
|
||||||
|
"urlHintSuffix": "。",
|
||||||
|
"directoryHint": "留空则使用服务器的当前目录,或指定一个路径以在其他文件夹中工作。",
|
||||||
|
"saveButton": "保存连接"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"enterIp": "请输入您电脑的 IP 地址",
|
||||||
|
"connectionFailedMessage": "{{summary}}\n\n目标:{{target}}\n错误:{{error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"notFound": "未找到该连接",
|
||||||
|
"directoryHint": "留空则使用服务器的当前目录。新会话将在此文件夹中创建。",
|
||||||
|
"passwordLabel": "密码(留空则保留原密码)",
|
||||||
|
"testButton": "测试连接",
|
||||||
|
"saveButton": "保存更改",
|
||||||
|
"deleteButton": "删除连接",
|
||||||
|
"alerts": {
|
||||||
|
"successTitle": "成功",
|
||||||
|
"successMessage": "连接成功!",
|
||||||
|
"connectionFailedMessage": "{{summary}}\n\n({{detail}})",
|
||||||
|
"noDetail": "无详细信息",
|
||||||
|
"deleteTitle": "删除连接",
|
||||||
|
"deleteMessage": "确定要删除 \"{{name}}\" 吗?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"sessionsTab": "会话",
|
||||||
|
"connectionsTab": "连接",
|
||||||
|
"settingsTab": "设置",
|
||||||
|
"addConnectionTitle": "添加连接",
|
||||||
|
"editConnectionTitle": "编辑连接"
|
||||||
|
},
|
||||||
|
"connectionsList": {
|
||||||
|
"activeBadge": "当前使用",
|
||||||
|
"lastConnected": "上次连接:{{date}}",
|
||||||
|
"actionsAlert": {
|
||||||
|
"message": "您想执行什么操作?",
|
||||||
|
"edit": "编辑"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "暂无连接",
|
||||||
|
"subtitle": "添加一个 OpenCode 服务器连接"
|
||||||
|
},
|
||||||
|
"header": "点击切换,长按查看更多选项",
|
||||||
|
"preferences": {
|
||||||
|
"title": "偏好设置",
|
||||||
|
"pageSizeLabel": "每页消息数",
|
||||||
|
"pageSizeHint": "打开会话时加载的消息数量。数值越小加载越快。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessionsList": {
|
||||||
|
"untitledSession": "未命名会话",
|
||||||
|
"time": {
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"minutesAgo": "{{count}} 分钟前",
|
||||||
|
"hoursAgo": "{{count}} 小时前",
|
||||||
|
"daysAgo": "{{count}} 天前"
|
||||||
|
},
|
||||||
|
"filesCount": "{{count}} 个文件",
|
||||||
|
"actions": {
|
||||||
|
"rename": "重命名"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"renameFailedTitle": "重命名失败",
|
||||||
|
"renameFailedMessage": "无法重命名该会话。请重试。",
|
||||||
|
"deleteTitle": "删除会话",
|
||||||
|
"deleteMessage": "删除 \"{{title}}\"?",
|
||||||
|
"deleteFailedTitle": "删除失败",
|
||||||
|
"deleteFailedMessage": "无法删除该会话。请重试。",
|
||||||
|
"createFailedMessage": "无法在该目录中创建会话。"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noConnectionTitle": "无连接",
|
||||||
|
"noConnectionSubtitle": "添加服务器连接以开始使用",
|
||||||
|
"addConnectionButton": "添加连接",
|
||||||
|
"authFailedTitle": "身份验证失败",
|
||||||
|
"authFailedSubtitle": "{{name}} 拒绝了您的凭据。请检查用户名和密码后重新连接。",
|
||||||
|
"checkCredentialsButton": "检查凭据",
|
||||||
|
"noSessions": "暂无会话"
|
||||||
|
},
|
||||||
|
"newSessionModal": {
|
||||||
|
"title": "新建会话",
|
||||||
|
"currentProjectLabel": "当前项目",
|
||||||
|
"serverDefault": "服务器默认目录",
|
||||||
|
"recentProjectsLabel": "最近项目",
|
||||||
|
"serverProjectsLabel": "服务器项目",
|
||||||
|
"browseFoldersLabel": "浏览文件夹…",
|
||||||
|
"browseFoldersHint": "浏览服务器的文件系统",
|
||||||
|
"enterPathLabel": "手动输入路径",
|
||||||
|
"createInButton": "在 {{dir}} 中创建",
|
||||||
|
"createSessionButton": "创建会话"
|
||||||
|
},
|
||||||
|
"renameModal": {
|
||||||
|
"title": "重命名会话"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"categories": {
|
||||||
|
"permissions": {
|
||||||
|
"label": "权限请求",
|
||||||
|
"description": "当工具需要批准才能继续时"
|
||||||
|
},
|
||||||
|
"questions": {
|
||||||
|
"label": "问题",
|
||||||
|
"description": "当助手需要您的输入时"
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"label": "任务完成",
|
||||||
|
"description": "当会话处理完成时"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"label": "错误",
|
||||||
|
"description": "当会话遇到问题时"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"label": "连接",
|
||||||
|
"description": "当应用长时间失去连接时"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorBoundary": {
|
||||||
|
"title": "出现了一些问题",
|
||||||
|
"subtitle": "应用遇到了一个意外错误,已自动上报。您可以分享详细报告以帮助我们更快修复。",
|
||||||
|
"errorLabel": "错误",
|
||||||
|
"unknownError": "未知错误",
|
||||||
|
"stackLabel": "堆栈",
|
||||||
|
"componentLabel": "组件",
|
||||||
|
"shareButton": "分享报告",
|
||||||
|
"tryAgainButton": "重试"
|
||||||
|
},
|
||||||
|
"telemetryConsent": {
|
||||||
|
"title": "帮助改进 OpenCode?",
|
||||||
|
"body": "分享匿名崩溃报告和使用分析,帮助我们更快地发现并修复问题。您分享的诊断报告也会发送至我们的支持收件箱。绝不包含代码、提示词或服务器地址。",
|
||||||
|
"bullets": {
|
||||||
|
"deviceInfo": "设备型号、系统版本、应用版本",
|
||||||
|
"stackTraces": "崩溃堆栈跟踪(不含变量值)",
|
||||||
|
"usageEvents": "匿名使用事件(打开应用、连接尝试、发送消息)— 发送至 PostHog EU",
|
||||||
|
"noCode": "您的代码、提示词或聊天消息",
|
||||||
|
"noServerUrls": "服务器地址或身份验证令牌"
|
||||||
|
},
|
||||||
|
"privacyLink": "阅读完整隐私政策",
|
||||||
|
"declineA11yLabel": "不用了,拒绝崩溃报告和使用分析",
|
||||||
|
"declineButton": "不用了",
|
||||||
|
"allowA11yLabel": "允许匿名崩溃报告和使用分析",
|
||||||
|
"allowButton": "允许",
|
||||||
|
"footnote": "您可以随时在设置 → 隐私中更改此设置。"
|
||||||
|
},
|
||||||
|
"authGate": {
|
||||||
|
"title": "OpenCode 已锁定",
|
||||||
|
"subtitle": "请验证身份以访问您的会话",
|
||||||
|
"unlockButton": "解锁"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"permissionPrompt": {
|
||||||
|
"title": "需要权限",
|
||||||
|
"deny": "拒绝",
|
||||||
|
"always": "始终允许",
|
||||||
|
"allow": "允许"
|
||||||
|
},
|
||||||
|
"questionPrompt": {
|
||||||
|
"headerFallback": "问题",
|
||||||
|
"answerPlaceholder": "输入您的答案...",
|
||||||
|
"customAnswerLabel": "输入自定义答案",
|
||||||
|
"dismiss": "忽略",
|
||||||
|
"next": "下一个",
|
||||||
|
"submit": "提交"
|
||||||
|
},
|
||||||
|
"statusIndicator": {
|
||||||
|
"retrying": "正在重试(第 {{attempt}} 次)...",
|
||||||
|
"working": "处理中..."
|
||||||
|
},
|
||||||
|
"slashPopover": {
|
||||||
|
"customBadge": "命令"
|
||||||
|
},
|
||||||
|
"modelPicker": {
|
||||||
|
"title": "选择模型",
|
||||||
|
"searchPlaceholder": "搜索模型..."
|
||||||
|
},
|
||||||
|
"variantPicker": {
|
||||||
|
"title": "推理强度",
|
||||||
|
"autoLabel": "自动",
|
||||||
|
"autoDescription": "使用模型默认推理强度",
|
||||||
|
"effort": {
|
||||||
|
"low": "速度更快,推理较简略",
|
||||||
|
"medium": "推理与速度均衡",
|
||||||
|
"high": "深入、详尽的推理"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"directorySwitcher": {
|
||||||
|
"title": "切换项目",
|
||||||
|
"serverDefaultLabel": "服务器默认",
|
||||||
|
"browseLabel": "浏览…",
|
||||||
|
"recentProjectsLabel": "最近项目",
|
||||||
|
"usesServerDir": "使用服务器的工作目录"
|
||||||
|
},
|
||||||
|
"directoryBrowserSheet": {
|
||||||
|
"noActiveConnection": "没有活动连接",
|
||||||
|
"listFailed": "无法列出目录",
|
||||||
|
"title": "浏览文件夹",
|
||||||
|
"jumpPlaceholder": "跳转到路径...",
|
||||||
|
"noSubfolders": "此处没有子文件夹",
|
||||||
|
"enterPathHint": "在上方输入路径以开始浏览",
|
||||||
|
"useFolderButton": "使用 {{folder}}",
|
||||||
|
"thisFolderFallback": "此文件夹"
|
||||||
|
},
|
||||||
|
"reasoningBlock": {
|
||||||
|
"label": "思考中"
|
||||||
|
},
|
||||||
|
"toolCallCard": {
|
||||||
|
"fallbackTitle": "工具",
|
||||||
|
"patternOnly": "模式:{{pattern}}",
|
||||||
|
"patternWithPath": "模式:{{pattern}},路径:{{path}}"
|
||||||
|
},
|
||||||
|
"sessionInfo": {
|
||||||
|
"noUsageData": "暂无使用数据",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"loadAllMessages": "加载全部消息",
|
||||||
|
"jumpToBeginning": "跳转到开头",
|
||||||
|
"pills": {
|
||||||
|
"in": "输入",
|
||||||
|
"out": "输出",
|
||||||
|
"think": "思考",
|
||||||
|
"cacheRead": "缓存读",
|
||||||
|
"cacheWrite": "缓存写"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"created": "创建于",
|
||||||
|
"updated": "更新于",
|
||||||
|
"messages": "消息数",
|
||||||
|
"changes": "变更",
|
||||||
|
"shared": "已分享",
|
||||||
|
"yes": "是"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"minutesAgo": "{{count}} 分钟前",
|
||||||
|
"hoursAgo": "{{count}} 小时前",
|
||||||
|
"daysAgo": "{{count}} 天前"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,34 @@ import { Platform, AppState } from "react-native"
|
|||||||
export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const
|
export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const
|
||||||
export type Category = (typeof categories)[number]
|
export type Category = (typeof categories)[number]
|
||||||
|
|
||||||
// Per-category metadata used by both the notification module and the settings UI
|
// Per-category metadata used by both the notification module and the settings UI.
|
||||||
export const categoryMeta: Record<Category, { label: string; description: string; icon: string }> = {
|
// label/description are i18next keys (not display strings) — this module loads
|
||||||
|
// before i18next is guaranteed to be ready, so callers (e.g. settings.tsx) resolve
|
||||||
|
// them with t() at render time instead of reading literal text here.
|
||||||
|
export const categoryMeta: Record<Category, { labelKey: string; descriptionKey: string; icon: string }> = {
|
||||||
permissions: {
|
permissions: {
|
||||||
label: "Permission Requests",
|
labelKey: "notifications.categories.permissions.label",
|
||||||
description: "When a tool needs approval to proceed",
|
descriptionKey: "notifications.categories.permissions.description",
|
||||||
icon: "shield-checkmark",
|
icon: "shield-checkmark",
|
||||||
},
|
},
|
||||||
questions: {
|
questions: {
|
||||||
label: "Questions",
|
labelKey: "notifications.categories.questions.label",
|
||||||
description: "When the assistant needs your input",
|
descriptionKey: "notifications.categories.questions.description",
|
||||||
icon: "help-circle",
|
icon: "help-circle",
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: "Task Completed",
|
labelKey: "notifications.categories.completed.label",
|
||||||
description: "When a session finishes processing",
|
descriptionKey: "notifications.categories.completed.description",
|
||||||
icon: "checkmark-circle",
|
icon: "checkmark-circle",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
label: "Errors",
|
labelKey: "notifications.categories.errors.label",
|
||||||
description: "When a session encounters a problem",
|
descriptionKey: "notifications.categories.errors.description",
|
||||||
icon: "alert-circle",
|
icon: "alert-circle",
|
||||||
},
|
},
|
||||||
connection: {
|
connection: {
|
||||||
label: "Connection",
|
labelKey: "notifications.categories.connection.label",
|
||||||
description: "When the app loses connection for an extended period",
|
descriptionKey: "notifications.categories.connection.description",
|
||||||
icon: "wifi",
|
icon: "wifi",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user