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:
Den
2026-07-17 10:52:41 -07:00
committed by GitHub
parent 0cac46cb36
commit f945cab5c6
26 changed files with 1249 additions and 337 deletions

View File

@@ -1,10 +1,12 @@
import { Tabs } from "expo-router"
import { useColorScheme } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
export default function TabLayout() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
return (
<Tabs
@@ -24,21 +26,21 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: "Sessions",
title: t("nav.sessionsTab"),
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="connections"
options={{
title: "Connections",
title: t("nav.connectionsTab"),
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
title: t("nav.settingsTab"),
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/>

View File

@@ -1,6 +1,7 @@
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { useConnections } from "../../src/stores/connections"
import { useSettings } from "../../src/stores/settings"
import type { ServerConnection } from "../../src/lib/types"
@@ -22,13 +23,14 @@ function ConnectionItem({
onEdit: () => void
onDelete: () => void
}) {
const { t } = useTranslation()
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
const handleLongPress = () => {
Alert.alert(connection.name, "What would you like to do?", [
{ text: "Cancel", style: "cancel" },
{ text: "Edit", onPress: onEdit },
{ text: "Delete", style: "destructive", onPress: onDelete },
Alert.alert(connection.name, t("connectionsList.actionsAlert.message"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("connectionsList.actionsAlert.edit"), onPress: onEdit },
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
])
}
@@ -60,7 +62,9 @@ function ConnectionItem({
</Text>
{isActive && (
<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>
@@ -72,7 +76,7 @@ function ConnectionItem({
</Text>
{connection.lastConnected && (
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
{t("connectionsList.lastConnected", { date: new Date(connection.lastConnected).toLocaleDateString() })}
</Text>
)}
</View>
@@ -86,19 +90,24 @@ function ConnectionItem({
export default function ConnectionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
const { pageSize, setPageSize } = useSettings()
const handleDelete = (connection: ServerConnection) => {
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => removeConnection(connection.id),
},
])
Alert.alert(
t("connection.edit.alerts.deleteTitle"),
t("connection.edit.alerts.deleteMessage", { name: connection.name }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: () => removeConnection(connection.id),
},
],
)
}
return (
@@ -119,24 +128,28 @@ export default function ConnectionsScreen() {
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("connectionsList.empty.title")}</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
Add a connection to your OpenCode server
{t("connectionsList.empty.subtitle")}
</Text>
</View>
}
ListHeaderComponent={
<View style={[styles.header, isDark && styles.headerDark]}>
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
<Text style={[styles.headerText, isDark && styles.metaDark]}>{t("connectionsList.header")}</Text>
</View>
}
ListFooterComponent={
<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.settingLabel}>
<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 style={styles.pagePicker}>
{PAGE_SIZE_OPTIONS.map((size) => (
@@ -164,7 +177,7 @@ export default function ConnectionsScreen() {
</View>
</View>
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
How many messages to load when opening a session. Lower = faster.
{t("connectionsList.preferences.pageSizeHint")}
</Text>
</View>
}

View File

@@ -17,6 +17,7 @@ import {
} from "react-native"
import { router, useFocusEffect } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { useSessions } from "../../src/stores/sessions"
import { useConnections } from "../../src/stores/connections"
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 { 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 now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return "Just now"
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
if (diff < 60000) return t("sessionsList.time.justNow")
if (diff < 3600000) return t("sessionsList.time.minutesAgo", { count: Math.floor(diff / 60000) })
if (diff < 86400000) return t("sessionsList.time.hoursAgo", { count: Math.floor(diff / 3600000) })
if (diff < 604800000) return t("sessionsList.time.daysAgo", { count: Math.floor(diff / 86400000) })
return date.toLocaleDateString()
}
@@ -51,6 +52,8 @@ function SessionItem({
onRename: () => void
onDelete: () => void
}) {
const { t } = useTranslation()
const onPress = () => {
router.push({
pathname: `/session/[id]`,
@@ -59,10 +62,10 @@ function SessionItem({
}
const onLongPress = () => {
Alert.alert(session.title || "Untitled Session", undefined, [
{ text: "Cancel", style: "cancel" },
{ text: "Rename", onPress: onRename },
{ text: "Delete", style: "destructive", onPress: onDelete },
Alert.alert(session.title || t("sessionsList.untitledSession"), undefined, [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("sessionsList.actions.rename"), onPress: onRename },
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
])
}
@@ -79,16 +82,17 @@ function SessionItem({
<View style={styles.sessionContent}>
<View style={styles.sessionHeader}>
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
{session.title || "Untitled Session"}
{session.title || t("sessionsList.untitledSession")}
</Text>
</View>
<View style={styles.sessionMetaRow}>
<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
server populates it — only show the count when it's meaningful,
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>
{shortDir && (
<View style={styles.sessionDirBadge}>
@@ -153,6 +157,7 @@ function getShortPath(
export default function SessionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
const [showNewSession, setShowNewSession] = useState(false)
const [customDir, setCustomDir] = useState("")
const [isCreating, setIsCreating] = useState(false)
@@ -277,31 +282,35 @@ export default function SessionsScreen() {
loadSessions()
} catch (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 {
renamingInFlight.current = false
}
}, [renaming, renameText, client, clientForDirectory, loadSessions])
}, [renaming, renameText, client, clientForDirectory, loadSessions, t])
const handleDelete = useCallback(
(session: Session) => {
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await deleteSession(session.id)
} catch (err) {
console.error("Delete failed:", err)
Alert.alert("Delete failed", "Could not delete the session. Please try again.")
}
Alert.alert(
t("sessionsList.alerts.deleteTitle"),
t("sessionsList.alerts.deleteMessage", { title: session.title || t("sessionsList.untitledSession") }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
try {
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 () => {
@@ -341,7 +350,7 @@ export default function SessionsScreen() {
}
} catch (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)
}
return
@@ -413,14 +422,18 @@ export default function SessionsScreen() {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.noConnectionTitle")}</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
{t("sessionsList.empty.noConnectionSubtitle")}
</Text>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => router.push("/connection/add")}
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>
</View>
)
@@ -434,9 +447,9 @@ export default function SessionsScreen() {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<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]}>
{activeConnection.name} rejected your credentials. Check the username and password to reconnect.
{t("sessionsList.empty.authFailedSubtitle", { name: activeConnection.name })}
</Text>
<View style={styles.authErrorButtonRow}>
<TouchableOpacity
@@ -444,7 +457,9 @@ export default function SessionsScreen() {
onPress={() => router.push(`/connection/${activeConnection.id}`)}
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
style={[styles.addButton, isDark && styles.addButtonDark]}
@@ -456,7 +471,7 @@ export default function SessionsScreen() {
}}
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>
</View>
</View>
@@ -523,7 +538,7 @@ export default function SessionsScreen() {
</View>
) : (
<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>
)
}
@@ -547,7 +562,7 @@ export default function SessionsScreen() {
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
<Text style={[styles.modalTitle, isDark && styles.textDark]}>{t("sessionsList.newSessionModal.title")}</Text>
<TouchableOpacity onPress={() => setShowNewSession(false)}>
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
@@ -555,7 +570,9 @@ export default function SessionsScreen() {
<ScrollView style={styles.modalScrollBody} keyboardShouldPersistTaps="handled">
{/* 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
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
onPress={() => onCreateInDirectory()}
@@ -563,7 +580,7 @@ export default function SessionsScreen() {
>
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<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>
<Ionicons name="arrow-forward-circle" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
</TouchableOpacity>
@@ -572,7 +589,7 @@ export default function SessionsScreen() {
{recentDirectories.length > 0 && (
<>
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
Recent Projects
{t("sessionsList.newSessionModal.recentProjectsLabel")}
</Text>
{recentDirectories.map((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 && (
<>
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
Server Projects
{t("sessionsList.newSessionModal.serverProjectsLabel")}
</Text>
{serverProjects
.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"} />
<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]}>
Explore the server's filesystem
{t("sessionsList.newSessionModal.browseFoldersHint")}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
@@ -671,7 +690,7 @@ export default function SessionsScreen() {
{/* Manual path input fallback */}
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
Enter Path Manually
{t("sessionsList.newSessionModal.enterPathLabel")}
</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
@@ -726,7 +745,9 @@ export default function SessionsScreen() {
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<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>
)}
</TouchableOpacity>
@@ -745,7 +766,7 @@ export default function SessionsScreen() {
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
Create Session
{t("sessionsList.newSessionModal.createSessionButton")}
</Text>
)}
</TouchableOpacity>
@@ -763,7 +784,7 @@ export default function SessionsScreen() {
>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
<Text style={[styles.renameTitle, isDark && styles.textDark]}>{t("sessionsList.renameModal.title")}</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
value={renameText}
@@ -777,14 +798,16 @@ export default function SessionsScreen() {
/>
<View style={styles.renameActions}>
<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
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
onPress={submitRename}
disabled={!renameText.trim()}
>
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
{t("common.save")}
</Text>
</TouchableOpacity>
</View>
</View>

View File

@@ -191,8 +191,8 @@ export default function SettingsScreen() {
<SettingRow
key={category}
icon={meta.icon as keyof typeof Ionicons.glyphMap}
label={meta.label}
description={meta.description}
label={t(meta.labelKey)}
description={t(meta.descriptionKey)}
isDark={isDark}
right={
<Switch

View File

@@ -5,7 +5,7 @@ import { useColorScheme, View, ActivityIndicator } from "react-native"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
import { I18nextProvider } from "react-i18next"
import { I18nextProvider, useTranslation } from "react-i18next"
import i18n from "../src/lib/i18n/config"
import { useAuth } from "../src/stores/auth"
import { useConnections } from "../src/stores/connections"
@@ -25,6 +25,7 @@ const queryClient = new QueryClient()
function RootLayout() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
const { initialize: initAuth, isLoading: authLoading } = useAuth()
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
@@ -129,21 +130,21 @@ function RootLayout() {
<Stack.Screen
name="session/[id]"
options={{
title: "Session",
title: t("session.titleFallback"),
presentation: "card",
}}
/>
<Stack.Screen
name="connection/add"
options={{
title: "Add Connection",
title: t("nav.addConnectionTitle"),
presentation: "modal",
}}
/>
<Stack.Screen
name="connection/[id]"
options={{
title: "Edit Connection",
title: t("nav.editConnectionTitle"),
presentation: "modal",
}}
/>

View File

@@ -12,6 +12,7 @@ import {
} from "react-native"
import { router, useLocalSearchParams } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { useConnections } from "../../src/stores/connections"
import { useEvents } from "../../src/stores/events"
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 { 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<{
type: ConnectionType
label: string
labelKey: string
icon: keyof typeof Ionicons.glyphMap
}> = [
{ type: "local", label: "Local", icon: "wifi" },
{ type: "tunnel", label: "Tunnel", icon: "globe" },
{ type: "cloud", label: "Cloud", icon: "cloud" },
{ type: "local", labelKey: "connection.shared.types.local", icon: "wifi" },
{ type: "tunnel", labelKey: "connection.shared.types.tunnel", icon: "globe" },
{ type: "cloud", labelKey: "connection.shared.types.cloud", icon: "cloud" },
]
export default function EditConnectionScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
const { connections, updateConnection, removeConnection, testConnection } = useConnections()
@@ -60,18 +65,18 @@ export default function EditConnectionScreen() {
if (!connection) {
return (
<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>
)
}
const handleTest = async () => {
if (!url.trim()) {
Alert.alert("Error", "Please enter a server URL")
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
return
}
if (!parseUrl(url).valid) {
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
return
}
@@ -91,7 +96,7 @@ export default function EditConnectionScreen() {
if (result.ok) {
setIsTesting(false)
Alert.alert("Success", "Connection successful!")
Alert.alert(t("connection.edit.alerts.successTitle"), t("connection.edit.alerts.successMessage"))
return
}
@@ -103,23 +108,30 @@ export default function EditConnectionScreen() {
captureDiagnostic(report)
setIsTesting(false)
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [
{ text: "OK", style: "cancel" },
{ text: "Share report", onPress: () => shareReport(report) },
])
Alert.alert(
t("connection.shared.alerts.connectionFailedTitle"),
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 () => {
if (!name.trim()) {
Alert.alert("Error", "Please enter a connection name")
Alert.alert(t("common.error"), t("connection.shared.alerts.enterName"))
return
}
if (!url.trim()) {
Alert.alert("Error", "Please enter a server URL")
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
return
}
if (!parseUrl(url).valid) {
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
return
}
@@ -143,10 +155,10 @@ export default function EditConnectionScreen() {
}
const handleDelete = () => {
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
{ text: "Cancel", style: "cancel" },
Alert.alert(t("connection.edit.alerts.deleteTitle"), t("connection.edit.alerts.deleteMessage", { name: connection.name }), [
{ text: t("common.cancel"), style: "cancel" },
{
text: "Delete",
text: t("common.delete"),
style: "destructive",
onPress: async () => {
await removeConnection(connection.id)
@@ -163,50 +175,50 @@ export default function EditConnectionScreen() {
keyboardShouldPersistTaps="handled"
>
{/* 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}>
{CONNECTION_TYPES.map((t) => (
{CONNECTION_TYPES.map((opt) => (
<TouchableOpacity
key={t.type}
key={opt.type}
style={[
styles.typeOption,
isDark && styles.typeOptionDark,
type === t.type && styles.typeOptionSelected,
type === t.type && isDark && styles.typeOptionSelectedDark,
type === opt.type && styles.typeOptionSelected,
type === opt.type && isDark && styles.typeOptionSelectedDark,
]}
onPress={() => setType(t.type)}
onPress={() => setType(opt.type)}
>
<Ionicons
name={t.icon}
name={opt.icon}
size={20}
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
color={type === opt.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
/>
<Text
style={[
styles.typeLabel,
isDark && styles.textDark,
type === t.type && styles.typeLabelSelected,
type === t.type && isDark && styles.typeLabelSelectedDark,
type === opt.type && styles.typeLabelSelected,
type === opt.type && isDark && styles.typeLabelSelectedDark,
]}
>
{t.label}
{t(opt.labelKey)}
</Text>
</TouchableOpacity>
))}
</View>
{/* Name */}
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.name")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="My Server"
placeholder={t("connection.shared.namePlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={name}
onChangeText={setName}
/>
{/* URL */}
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="http://192.168.1.100:4096"
@@ -219,7 +231,7 @@ export default function EditConnectionScreen() {
/>
{/* 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
style={[styles.input, isDark && styles.inputDark]}
placeholder="/path/to/project"
@@ -229,14 +241,12 @@ export default function EditConnectionScreen() {
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={[styles.hint, isDark && styles.hintDark]}>
Leave empty to use the server's current directory. Sessions will be created in this folder.
</Text>
<Text style={[styles.hint, isDark && styles.hintDark]}>{t("connection.edit.directoryHint")}</Text>
{/* 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
style={[styles.input, isDark && styles.inputDark]}
placeholder="admin"
@@ -247,7 +257,7 @@ export default function EditConnectionScreen() {
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
style={[styles.input, isDark && styles.inputDark]}
placeholder="••••••••"
@@ -269,7 +279,7 @@ export default function EditConnectionScreen() {
) : (
<>
<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>
@@ -282,13 +292,15 @@ export default function EditConnectionScreen() {
{isSaving ? (
<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 style={styles.deleteButton} onPress={handleDelete}>
<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>
</View>
</ScrollView>

View File

@@ -13,6 +13,7 @@ import {
} from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { useConnections } from "../../src/stores/connections"
import type { ConnectionType } from "../../src/lib/types"
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
@@ -24,6 +25,7 @@ import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/wait
export default function AddConnectionScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
const { addConnection, testConnection } = useConnections()
@@ -66,7 +68,7 @@ export default function AddConnectionScreen() {
const handleQuickConnect = async () => {
const serverUrl = buildUrl()
if (!serverUrl) {
Alert.alert("Error", "Please enter your computer's IP address")
Alert.alert(t("common.error"), t("connection.add.alerts.enterIp"))
return
}
@@ -77,7 +79,7 @@ export default function AddConnectionScreen() {
const result = await testConnection(
{
id: "",
name: name || "My Server",
name: name || t("connection.shared.namePlaceholder"),
type: "local",
url: serverUrl,
username: username.trim() || undefined,
@@ -90,7 +92,7 @@ export default function AddConnectionScreen() {
// Save and go back
await addConnection(
{
name: name.trim() || "My Server",
name: name.trim() || t("connection.shared.namePlaceholder"),
type: "local",
url: serverUrl,
username: username.trim() || undefined,
@@ -108,11 +110,15 @@ export default function AddConnectionScreen() {
captureDiagnostic(report)
setIsConnecting(false)
Alert.alert(
"Connection Failed",
`${report.summary}\n\nTarget: ${serverUrl}\nError: ${result.error || "Unknown error"}`,
t("connection.shared.alerts.connectionFailedTitle"),
t("connection.add.alerts.connectionFailedMessage", {
summary: report.summary,
target: serverUrl,
error: result.error || t("connection.shared.alerts.unknownError"),
}),
[
{ text: "OK", style: "cancel" },
{ text: "Share report", onPress: () => shareReport(report) },
{ text: t("common.ok"), style: "cancel" },
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
],
)
}
@@ -120,15 +126,15 @@ export default function AddConnectionScreen() {
const handleAdvancedSave = async () => {
if (!name.trim()) {
Alert.alert("Error", "Please enter a connection name")
Alert.alert(t("common.error"), t("connection.shared.alerts.enterName"))
return
}
if (!url.trim()) {
Alert.alert("Error", "Please enter a server URL")
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
return
}
if (!parseUrl(url).valid) {
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
return
}
@@ -179,11 +185,15 @@ export default function AddConnectionScreen() {
captureDiagnostic(report)
setIsConnecting(false)
Alert.alert(
"Connection Failed",
`${report.summary}\n\nTarget: ${url.trim()}\nError: ${result.error || "Unknown error"}`,
t("connection.shared.alerts.connectionFailedTitle"),
t("connection.add.alerts.connectionFailedMessage", {
summary: report.summary,
target: url.trim(),
error: result.error || t("connection.shared.alerts.unknownError"),
}),
[
{ text: "OK", style: "cancel" },
{ text: "Share report", onPress: () => shareReport(report) },
{ text: t("common.ok"), style: "cancel" },
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
],
)
}
@@ -204,10 +214,10 @@ export default function AddConnectionScreen() {
await Linking.openURL(buildWaitlistMailtoUrl(result.email))
} catch {
// 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 {
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}>
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
<Text style={[styles.quickTitle, isDark && styles.textDark]}>Connect to OpenCode</Text>
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>
Enter your computer's IP address to connect
</Text>
<Text style={[styles.quickTitle, isDark && styles.textDark]}>{t("connection.add.quick.title")}</Text>
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>{t("connection.add.quick.subtitle")}</Text>
</View>
{/* 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}>
<TextInput
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
@@ -254,20 +262,20 @@ export default function AddConnectionScreen() {
</View>
{/* 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
style={[styles.input, isDark && styles.inputDark]}
placeholder="My Mac"
placeholder={t("connection.add.quick.namePlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={name}
onChangeText={setName}
/>
{/* 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
style={[styles.input, isDark && styles.inputDark]}
placeholder="Leave empty if none"
placeholder={t("connection.add.quick.passwordPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={password}
onChangeText={setPassword}
@@ -275,11 +283,13 @@ export default function AddConnectionScreen() {
testID="connect-password-input"
/>
<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")}>
Advanced options
{t("connection.add.quick.advancedOptionsLink")}
</Text>
.
{t("connection.add.quick.usernameHintSuffix")}
</Text>
{/* Connect button */}
@@ -294,30 +304,38 @@ export default function AddConnectionScreen() {
) : (
<>
<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>
{/* Help text */}
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
<Text style={[styles.helpTitle, isDark && styles.textDark]}>How to find your IP:</Text>
<Text style={[styles.helpTitle, isDark && styles.textDark]}>{t("connection.add.quick.helpTitle")}</Text>
<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>
<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>
{"\n"}
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
</Text>
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
Use <Text style={styles.code}>http://</Text> for local and Tailscale addresses unless TLS is configured,
then use <Text style={styles.code}>https://</Text>.
{t("connection.add.quick.helpProtocolPrefix")}
<Text style={styles.code}>http://</Text>
{t("connection.add.quick.helpProtocolMiddle")}
<Text style={styles.code}>https://</Text>
{t("connection.add.quick.helpProtocolSuffix")}
</Text>
<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>
</View>
@@ -327,21 +345,22 @@ export default function AddConnectionScreen() {
<View style={styles.connectCardHeader}>
<Ionicons name="cloud-done-outline" size={28} color="#6366f1" />
<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}>
<Text style={styles.connectCardBadgeText}>Coming Soon</Text>
<Text style={styles.connectCardBadgeText}>{t("connection.add.quick.connectCardBadge")}</Text>
</View>
</View>
</View>
<Text style={[styles.connectCardDesc, isDark && styles.hintDark]}>
Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from
anywhere.
{t("connection.add.quick.connectCardDesc")}
</Text>
{waitlistState === "joined" ? (
<View style={styles.waitlistSuccess} testID="waitlist-success">
<Ionicons name="checkmark-circle" size={20} color="#22c55e" />
<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>
</View>
) : (
@@ -369,7 +388,7 @@ export default function AddConnectionScreen() {
) : (
<>
<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>
@@ -380,7 +399,7 @@ export default function AddConnectionScreen() {
{/* Advanced mode link */}
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
Advanced options (tunnels, cloud, auth)
{t("connection.add.quick.advancedLink")}
</Text>
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
@@ -397,58 +416,58 @@ export default function AddConnectionScreen() {
>
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>Simple mode</Text>
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>{t("connection.add.advanced.backToQuick")}</Text>
</TouchableOpacity>
{/* 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}>
{[
{ type: "local" as const, label: "Local", icon: "wifi" as const },
{ type: "tunnel" as const, label: "Tunnel", icon: "globe" as const },
{ type: "cloud" as const, label: "Cloud", icon: "cloud" as const },
].map((t) => (
{ type: "local" as const, label: t("connection.shared.types.local"), icon: "wifi" as const },
{ type: "tunnel" as const, label: t("connection.shared.types.tunnel"), icon: "globe" as const },
{ type: "cloud" as const, label: t("connection.shared.types.cloud"), icon: "cloud" as const },
].map((opt) => (
<TouchableOpacity
key={t.type}
key={opt.type}
style={[
styles.typeOption,
isDark && styles.typeOptionDark,
type === t.type && styles.typeOptionSelected,
type === t.type && isDark && styles.typeOptionSelectedDark,
type === opt.type && styles.typeOptionSelected,
type === opt.type && isDark && styles.typeOptionSelectedDark,
]}
onPress={() => setType(t.type)}
onPress={() => setType(opt.type)}
>
<Ionicons
name={t.icon}
name={opt.icon}
size={20}
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
color={type === opt.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
/>
<Text
style={[
styles.typeLabel,
isDark && styles.textDark,
type === t.type && styles.typeLabelSelected,
type === t.type && isDark && styles.typeLabelSelectedDark,
type === opt.type && styles.typeLabelSelected,
type === opt.type && isDark && styles.typeLabelSelectedDark,
]}
>
{t.label}
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Name */}
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.name")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="My Server"
placeholder={t("connection.shared.namePlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={name}
onChangeText={setName}
/>
{/* URL */}
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder={
@@ -466,13 +485,17 @@ export default function AddConnectionScreen() {
keyboardType="url"
/>
<Text style={[styles.hint, isDark && styles.hintDark]}>
Local/Tailscale examples: <Text style={styles.code}>http://100.64.12.34:4096</Text> or{" "}
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>. Use <Text style={styles.code}>https://</Text>
only when TLS is configured.
{t("connection.add.advanced.urlHintPrefix")}
<Text style={styles.code}>http://100.64.12.34:4096</Text>
{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>
{/* 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
style={[styles.input, isDark && styles.inputDark]}
placeholder="/path/to/project"
@@ -482,14 +505,12 @@ export default function AddConnectionScreen() {
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={[styles.hint, isDark && styles.hintDark]}>
Leave empty to use the server's current directory, or specify a path to work in a different folder.
</Text>
<Text style={[styles.hint, isDark && styles.hintDark]}>{t("connection.add.advanced.directoryHint")}</Text>
{/* 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
style={[styles.input, isDark && styles.inputDark]}
placeholder="admin"
@@ -500,7 +521,7 @@ export default function AddConnectionScreen() {
autoCorrect={false}
/>
<Text style={[styles.label, isDark && styles.labelDark]}>Password</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.password")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="password"
@@ -519,7 +540,9 @@ export default function AddConnectionScreen() {
{isConnecting ? (
<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>
</ScrollView>

View File

@@ -15,6 +15,7 @@ import {
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useTranslation } from "react-i18next"
import * as ImagePicker from "expo-image-picker"
import * as ImageManipulator from "expo-image-manipulator"
import * as Clipboard from "expo-clipboard"
@@ -76,6 +77,7 @@ export default function SessionScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const flatListRef = useRef<FlatList>(null)
const modelSheetRef = useRef<BottomSheet>(null)
@@ -190,14 +192,11 @@ export default function SessionScreen() {
const applyRevertResult = useCallback((result: Awaited<ReturnType<typeof revertToMessage>>) => {
if (!result.ok) {
if (result.reason === "unsupported") {
Alert.alert(
"Not supported",
"Editing sent messages needs a newer opencode server. Please update the server and try again.",
)
Alert.alert(t("session.alerts.notSupportedTitle"), t("session.alerts.notSupportedMessage"))
} 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 {
Alert.alert("Edit failed", "Could not revert to this message. Please try again.")
Alert.alert(t("session.alerts.editFailedTitle"), t("session.alerts.editFailedMessage"))
}
return
}
@@ -209,16 +208,16 @@ export default function SessionScreen() {
.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 })),
)
}, [])
}, [t])
// Stable across renders (reads fresh state via getState() rather than
// closing over props) so MessageBubble's custom memo comparator can bail
// safely without risking a stale handler.
const handleMessageLongPress = useCallback((messageID: string) => {
Alert.alert("Message actions", undefined, [
{ text: "Cancel", style: "cancel" },
Alert.alert(t("session.alerts.messageActionsTitle"), undefined, [
{ text: t("common.cancel"), style: "cancel" },
{
text: "Edit message",
text: t("session.actions.editMessage"),
onPress: () => {
const doRevert = async () => {
const result = await useSessions.getState().revertToMessage(messageID)
@@ -228,11 +227,11 @@ export default function SessionScreen() {
// in-progress unsent draft.
if (inputRef.current.trim()) {
Alert.alert(
"Replace draft?",
"You have an unsent message in the composer. Editing this message will replace it.",
t("session.alerts.replaceDraftTitle"),
t("session.alerts.replaceDraftMessage"),
[
{ text: "Cancel", style: "cancel" },
{ text: "Replace", style: "destructive", onPress: doRevert },
{ text: t("common.cancel"), style: "cancel" },
{ text: t("session.actions.replace"), style: "destructive", onPress: doRevert },
],
{ cancelable: false },
)
@@ -242,7 +241,7 @@ export default function SessionScreen() {
},
},
])
}, [applyRevertResult])
}, [applyRevertResult, t])
const scrollToBottom = useCallback((animated = true) => {
flatListRef.current?.scrollToOffset({ offset: 0, animated })
@@ -337,7 +336,7 @@ export default function SessionScreen() {
const pickFromCamera = useCallback(async () => {
const perm = await ImagePicker.requestCameraPermissionsAsync()
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
}
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
@@ -345,7 +344,7 @@ export default function SessionScreen() {
const a = result.assets[0]
const item = await toJpeg(a.uri, a.width, a.height)
setAttachments((prev) => [...prev, item])
}, [])
}, [t])
const pasteFromClipboard = useCallback(async () => {
// Try image first
@@ -376,8 +375,8 @@ export default function SessionScreen() {
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) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
@@ -388,7 +387,7 @@ export default function SessionScreen() {
if (!input.trim() && attachments.length === 0) return
const authenticated = await authenticateForMessage()
if (!authenticated) {
Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.")
Alert.alert(t("session.alerts.authRequiredTitle"), t("session.alerts.authRequiredMessage"))
return
}
@@ -424,7 +423,7 @@ export default function SessionScreen() {
// Restore the user's text and attachments so their input isn't lost.
setInput((prev) => (prev ? prev : text))
setAttachments((prev) => (prev.length ? prev : files))
Alert.alert("Message not sent", "Could not send your message. Check your connection and try again.")
Alert.alert(t("session.alerts.sendFailedTitle"), t("session.alerts.sendFailedMessage"))
}
}
@@ -480,7 +479,7 @@ export default function SessionScreen() {
useEvents.setState((state) => ({
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) => ({
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) => ({
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
options={{
title: currentSession?.title || "Session",
title: currentSession?.title || t("session.titleFallback"),
headerRight: () => (
<View style={s.headerRight}>
{shortDir && (
@@ -595,12 +594,12 @@ export default function SessionScreen() {
{/* SSE reconnect/connected banner */}
{reconnectAttempts > 0 && (
<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>
)}
{showConnectedFlash && reconnectAttempts === 0 && (
<View style={[s.banner, s.bannerConnected]}>
<Text style={s.bannerText}>Connected ✓</Text>
<Text style={s.bannerText}>{t("session.banners.connected")}</Text>
</View>
)}
@@ -608,9 +607,9 @@ export default function SessionScreen() {
cleaned up by the next prompt. */}
{revertMessageID && (
<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}>
<Text style={s.bannerAction}>Undo</Text>
<Text style={s.bannerAction}>{t("session.banners.undo")}</Text>
</TouchableOpacity>
</View>
)}
@@ -645,7 +644,7 @@ export default function SessionScreen() {
loadingMore ? (
<View style={s.loadingMore}>
<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>
) : null
}
@@ -655,8 +654,8 @@ export default function SessionScreen() {
{messageData.length === 0 && (
<View style={s.emptyOverlay} pointerEvents="none">
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
<Text style={[s.emptyText, isDark && s.metaDark]}>{t("session.empty.title")}</Text>
<Text style={[s.emptyHint, isDark && s.metaDark]}>{t("session.empty.hint")}</Text>
</View>
)}
{showScrollButton && (
@@ -726,7 +725,7 @@ export default function SessionScreen() {
>
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
<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>
</TouchableOpacity>
)}
@@ -752,7 +751,13 @@ export default function SessionScreen() {
<TextInput
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"}
value={speech.listening ? speech.transcript : input}
onChangeText={speech.listening ? undefined : setInput}