From f945cab5c613dcec953c765e76764417b614a74a Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:52:41 -0700 Subject: [PATCH] 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 --- app/(tabs)/_layout.tsx | 8 +- app/(tabs)/connections.tsx | 53 ++- app/(tabs)/index.tsx | 125 +++--- app/(tabs)/settings.tsx | 4 +- app/_layout.tsx | 9 +- app/connection/[id].tsx | 96 +++-- app/connection/add.tsx | 165 ++++---- app/session/[id].tsx | 73 ++-- src/components/AuthGate.tsx | 8 +- src/components/ErrorBoundary.tsx | 23 +- src/components/TelemetryConsentModal.tsx | 51 ++- src/components/chat/DirectoryBrowserSheet.tsx | 20 +- src/components/chat/DirectorySwitcher.tsx | 18 +- src/components/chat/ModelPicker.tsx | 6 +- src/components/chat/PermissionPrompt.tsx | 10 +- src/components/chat/QuestionPrompt.tsx | 14 +- src/components/chat/ReasoningBlock.tsx | 4 +- src/components/chat/SessionInfo.tsx | 55 ++- src/components/chat/SlashPopover.tsx | 4 +- src/components/chat/StatusIndicator.tsx | 5 +- src/components/chat/ToolCallCard.tsx | 10 +- src/components/chat/VariantPicker.tsx | 32 +- src/lib/i18n/catalog-parity.test.ts | 46 +++ src/lib/i18n/en.json | 360 +++++++++++++++++- src/lib/i18n/zh-Hans.json | 360 +++++++++++++++++- src/lib/notifications.ts | 27 +- 26 files changed, 1249 insertions(+), 337 deletions(-) create mode 100644 src/lib/i18n/catalog-parity.test.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index a3881f5..af6b490 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( , }} /> , }} /> , }} /> diff --git a/app/(tabs)/connections.tsx b/app/(tabs)/connections.tsx index 68ea71f..a407935 100644 --- a/app/(tabs)/connections.tsx +++ b/app/(tabs)/connections.tsx @@ -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({ {isActive && ( - Active + + {t("connectionsList.activeBadge")} + )} @@ -72,7 +76,7 @@ function ConnectionItem({ {connection.lastConnected && ( - Last connected: {new Date(connection.lastConnected).toLocaleDateString()} + {t("connectionsList.lastConnected", { date: new Date(connection.lastConnected).toLocaleDateString() })} )} @@ -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={ - No Connections + {t("connectionsList.empty.title")} - Add a connection to your OpenCode server + {t("connectionsList.empty.subtitle")} } ListHeaderComponent={ - Tap to switch, long press for options + {t("connectionsList.header")} } ListFooterComponent={ - Preferences + + {t("connectionsList.preferences.title")} + - Messages per page + + {t("connectionsList.preferences.pageSizeLabel")} + {PAGE_SIZE_OPTIONS.map((size) => ( @@ -164,7 +177,7 @@ export default function ConnectionsScreen() { - How many messages to load when opening a session. Lower = faster. + {t("connectionsList.preferences.pageSizeHint")} } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 1a41f73..38f2460 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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): 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({ - {session.title || "Untitled Session"} + {session.title || t("sessionsList.untitledSession")} - {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 })}`} {shortDir && ( @@ -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 ( - No Connection - Add a server connection to get started + {t("sessionsList.empty.noConnectionTitle")} + + {t("sessionsList.empty.noConnectionSubtitle")} + router.push("/connection/add")} testID="add-connection-button" > - Add Connection + + {t("sessionsList.empty.addConnectionButton")} + ) @@ -434,9 +447,9 @@ export default function SessionsScreen() { return ( - Authentication Failed + {t("sessionsList.empty.authFailedTitle")} - {activeConnection.name} rejected your credentials. Check the username and password to reconnect. + {t("sessionsList.empty.authFailedSubtitle", { name: activeConnection.name })} router.push(`/connection/${activeConnection.id}`)} testID="fix-connection-button" > - Check Credentials + + {t("sessionsList.empty.checkCredentialsButton")} + - Retry + {t("common.retry")} @@ -523,7 +538,7 @@ export default function SessionsScreen() { ) : ( - No sessions yet + {t("sessionsList.empty.noSessions")} ) } @@ -547,7 +562,7 @@ export default function SessionsScreen() { setShowNewSession(false)} /> - New Session + {t("sessionsList.newSessionModal.title")} setShowNewSession(false)}> @@ -555,7 +570,9 @@ export default function SessionsScreen() { {/* Current directory — tapping creates session immediately */} - Current Project + + {t("sessionsList.newSessionModal.currentProjectLabel")} + onCreateInDirectory()} @@ -563,7 +580,7 @@ export default function SessionsScreen() { > - {currentProject?.path?.absolute || activeConnection?.directory || "Server default"} + {currentProject?.path?.absolute || activeConnection?.directory || t("sessionsList.newSessionModal.serverDefault")} @@ -572,7 +589,7 @@ export default function SessionsScreen() { {recentDirectories.length > 0 && ( <> - Recent Projects + {t("sessionsList.newSessionModal.recentProjectsLabel")} {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 && ( <> - Server Projects + {t("sessionsList.newSessionModal.serverProjectsLabel")} {serverProjects .filter((p) => p.path?.absolute !== currentProject?.path?.absolute) @@ -661,9 +678,11 @@ export default function SessionsScreen() { > - Browse Folders… + + {t("sessionsList.newSessionModal.browseFoldersLabel")} + - Explore the server's filesystem + {t("sessionsList.newSessionModal.browseFoldersHint")} @@ -671,7 +690,7 @@ export default function SessionsScreen() { {/* Manual path input fallback */} - Enter Path Manually + {t("sessionsList.newSessionModal.enterPathLabel")} ) : ( - Create in {customDir.split("/").filter(Boolean).pop() || customDir} + {t("sessionsList.newSessionModal.createInButton", { + dir: customDir.split("/").filter(Boolean).pop() || customDir, + })} )} @@ -745,7 +766,7 @@ export default function SessionsScreen() { ) : ( - Create Session + {t("sessionsList.newSessionModal.createSessionButton")} )} @@ -763,7 +784,7 @@ export default function SessionsScreen() { > setRenaming(null)} /> - Rename Session + {t("sessionsList.renameModal.title")} setRenaming(null)}> - Cancel + {t("common.cancel")} - Save + + {t("common.save")} + diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index d936f37..f694ddd 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -191,8 +191,8 @@ export default function SettingsScreen() { diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 79feebe..a846d50 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -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 ( - Connection not found + {t("connection.edit.notFound")} ) } 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 */} - Connection Type + {t("connection.shared.connectionType")} - {CONNECTION_TYPES.map((t) => ( + {CONNECTION_TYPES.map((opt) => ( setType(t.type)} + onPress={() => setType(opt.type)} > - {t.label} + {t(opt.labelKey)} ))} {/* Name */} - Name + {t("connection.shared.name")} {/* URL */} - Server URL + {t("connection.shared.serverUrl")} {/* Directory */} - Project Directory (Optional) + {t("connection.shared.directoryOptional")} - - Leave empty to use the server's current directory. Sessions will be created in this folder. - + {t("connection.edit.directoryHint")} {/* Auth */} - Authentication + {t("connection.shared.authentication")} - Username + {t("connection.shared.username")} - Password (leave empty to keep existing) + {t("connection.edit.passwordLabel")} - Test Connection + {t("connection.edit.testButton")} )} @@ -282,13 +292,15 @@ export default function EditConnectionScreen() { {isSaving ? ( ) : ( - Save Changes + + {t("connection.edit.saveButton")} + )} - Delete Connection + {t("connection.edit.deleteButton")} diff --git a/app/connection/add.tsx b/app/connection/add.tsx index a92d06a..30c0036 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -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() { > - Connect to OpenCode - - Enter your computer's IP address to connect - + {t("connection.add.quick.title")} + {t("connection.add.quick.subtitle")} {/* IP Address */} - IP Address + {t("connection.add.quick.ipAddressLabel")} {/* Optional name */} - Name (optional) + {t("connection.add.quick.nameOptionalLabel")} {/* Password if needed */} - Password (if set on server) + {t("connection.add.quick.passwordIfSetLabel")} - Username defaults to opencode. Custom username? Use{" "} + {t("connection.add.quick.usernameHintPrefix")} + opencode + {t("connection.add.quick.usernameHintMiddle")} setMode("advanced")}> - Advanced options + {t("connection.add.quick.advancedOptionsLink")} - . + {t("connection.add.quick.usernameHintSuffix")} {/* Connect button */} @@ -294,30 +304,38 @@ export default function AddConnectionScreen() { ) : ( <> - Connect + + {t("connection.add.quick.connectButton")} + )} {/* Help text */} - How to find your IP: + {t("connection.add.quick.helpTitle")} - On your Mac, run:{"\n"} + {t("connection.add.quick.helpMacPrefix")} + {"\n"} ipconfig getifaddr en0 - Tailscale examples:{"\n"} + {t("connection.add.quick.helpTailscalePrefix")} + {"\n"} http://100.64.12.34:4096 {"\n"} http://my-mac.tailnet.ts.net:4096 - Use http:// for local and Tailscale addresses unless TLS is configured, - then use https://. + {t("connection.add.quick.helpProtocolPrefix")} + http:// + {t("connection.add.quick.helpProtocolMiddle")} + https:// + {t("connection.add.quick.helpProtocolSuffix")} - Make sure OpenCode is running:{"\n"} + {t("connection.add.quick.helpRunningPrefix")} + {"\n"} opencode serve --hostname 0.0.0.0 @@ -327,21 +345,22 @@ export default function AddConnectionScreen() { - OpenCode Connect + + {t("connection.add.quick.connectCardTitle")} + - Coming Soon + {t("connection.add.quick.connectCardBadge")} - Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from - anywhere. + {t("connection.add.quick.connectCardDesc")} {waitlistState === "joined" ? ( - You're on the list — we'll email you when OpenCode Connect is ready. + {t("connection.add.waitlist.successText")} ) : ( @@ -369,7 +388,7 @@ export default function AddConnectionScreen() { ) : ( <> - Join Waitlist + {t("connection.add.waitlist.joinButton")} )} @@ -380,7 +399,7 @@ export default function AddConnectionScreen() { {/* Advanced mode link */} setMode("advanced")}> - Advanced options (tunnels, cloud, auth) + {t("connection.add.quick.advancedLink")} @@ -397,58 +416,58 @@ export default function AddConnectionScreen() { > setMode("quick")}> - Simple mode + {t("connection.add.advanced.backToQuick")} {/* Connection Type */} - Connection Type + {t("connection.shared.connectionType")} {[ - { 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) => ( setType(t.type)} + onPress={() => setType(opt.type)} > - {t.label} + {opt.label} ))} {/* Name */} - Name + {t("connection.shared.name")} {/* URL */} - Server URL + {t("connection.shared.serverUrl")} - Local/Tailscale examples: http://100.64.12.34:4096 or{" "} - http://my-mac.tailnet.ts.net:4096. Use https:// - only when TLS is configured. + {t("connection.add.advanced.urlHintPrefix")} + http://100.64.12.34:4096 + {t("connection.add.advanced.urlHintOr")} + http://my-mac.tailnet.ts.net:4096 + {t("connection.add.advanced.urlHintUse")} + https:// + {t("connection.add.advanced.urlHintSuffix")} {/* Directory */} - Project Directory (Optional) + {t("connection.shared.directoryOptional")} - - Leave empty to use the server's current directory, or specify a path to work in a different folder. - + {t("connection.add.advanced.directoryHint")} {/* Auth */} - Authentication + {t("connection.shared.authentication")} - Username + {t("connection.shared.username")} - Password + {t("connection.shared.password")} ) : ( - Save Connection + + {t("connection.add.advanced.saveButton")} + )} diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 08cdeef..7bba1ea 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -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(null) const modelSheetRef = useRef(null) @@ -190,14 +192,11 @@ export default function SessionScreen() { const applyRevertResult = useCallback((result: Awaited>) => { 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() { <> ( {shortDir && ( @@ -595,12 +594,12 @@ export default function SessionScreen() { {/* SSE reconnect/connected banner */} {reconnectAttempts > 0 && ( - Reconnecting\u2026 (attempt {reconnectAttempts}) + {t("session.banners.reconnecting", { attempt: reconnectAttempts })} )} {showConnectedFlash && reconnectAttempts === 0 && ( - Connected ✓ + {t("session.banners.connected")} )} @@ -608,9 +607,9 @@ export default function SessionScreen() { cleaned up by the next prompt. */} {revertMessageID && ( - Message reverted — resend to confirm + {t("session.banners.reverted")} unrevertSession()} hitSlop={8}> - Undo + {t("session.banners.undo")} )} @@ -645,7 +644,7 @@ export default function SessionScreen() { loadingMore ? ( - Loading older messages... + {t("session.loadingOlder")} ) : null } @@ -655,8 +654,8 @@ export default function SessionScreen() { {messageData.length === 0 && ( - Start a conversation - Type / for commands + {t("session.empty.title")} + {t("session.empty.hint")} )} {showScrollButton && ( @@ -726,7 +725,7 @@ export default function SessionScreen() { > - {variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Auto"} + {variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : t("session.toolbar.auto")} )} @@ -752,7 +751,13 @@ export default function SessionScreen() { - OpenCode Locked - Authenticate to access your sessions + {t("authGate.title")} + {t("authGate.subtitle")} {error && {error}} - Unlock + {t("authGate.unlockButton")} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 52f28e8..07f05b8 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -10,6 +10,10 @@ import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-nati import { captureException } from "../lib/sentry" import { buildCrashReport, shareReport } from "../lib/diagnostics" 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 { children: React.ReactNode @@ -52,21 +56,18 @@ export class ErrorBoundary extends React.Component { const { error, componentStack } = this.state 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 compStack = (componentStack ?? "").split("\n").slice(0, 6).join("\n") return ( - Something went wrong - - The app hit an unexpected error. It has been reported automatically. You can share a detailed report to help us - fix it faster. - + {i18n.t("errorBoundary.title")} + {i18n.t("errorBoundary.subtitle")} - Error + {i18n.t("errorBoundary.errorLabel")} {message} @@ -74,7 +75,7 @@ export class ErrorBoundary extends React.Component { {stack ? ( - Stack + {i18n.t("errorBoundary.stackLabel")} {stack} @@ -83,7 +84,7 @@ export class ErrorBoundary extends React.Component { {compStack ? ( - Component + {i18n.t("errorBoundary.componentLabel")} {compStack} @@ -92,10 +93,10 @@ export class ErrorBoundary extends React.Component { - Share Report + {i18n.t("errorBoundary.shareButton")} - Try Again + {i18n.t("errorBoundary.tryAgainButton")} diff --git a/src/components/TelemetryConsentModal.tsx b/src/components/TelemetryConsentModal.tsx index 265df18..8267ad0 100644 --- a/src/components/TelemetryConsentModal.tsx +++ b/src/components/TelemetryConsentModal.tsx @@ -11,6 +11,7 @@ import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Modal, Linking } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" import { PRIVACY_POLICY_URL } from "../lib/links" interface Props { @@ -22,6 +23,7 @@ interface Props { export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" + const { t } = useTranslation() return ( @@ -33,32 +35,43 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { {/* Title */} - Help improve OpenCode? + {t("telemetryConsent.title")} {/* 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. - + {t("telemetryConsent.body")} {/* Detail bullets */} - - + - - + + + {/* Privacy policy link */} Linking.openURL(PRIVACY_POLICY_URL)}> - Read our full privacy policy + {t("telemetryConsent.privacyLink")} {/* Actions */} @@ -66,24 +79,24 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { - No thanks + + {t("telemetryConsent.declineButton")} + - Allow + {t("telemetryConsent.allowButton")} - - You can change this at any time in Settings → Privacy. - + {t("telemetryConsent.footnote")} diff --git a/src/components/chat/DirectoryBrowserSheet.tsx b/src/components/chat/DirectoryBrowserSheet.tsx index 6dca1eb..abd8236 100644 --- a/src/components/chat/DirectoryBrowserSheet.tsx +++ b/src/components/chat/DirectoryBrowserSheet.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react" import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native" import { Ionicons } from "@expo/vector-icons" import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet" +import { useTranslation } from "react-i18next" import type { Client, FileEntry } from "../../lib/sdk" import { parentOf, nameOf } from "../../lib/path-utils" import { normalizeRoots, type FileRoot } from "../../lib/file-roots" @@ -27,6 +28,7 @@ export function DirectoryBrowserSheet({ onSelect, onDismiss, }: Props) { + const { t } = useTranslation() const [browseDir, setBrowseDir] = useState(null) const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(false) @@ -48,7 +50,7 @@ export function DirectoryBrowserSheet({ if (!client) { setEntries([]) setLoading(false) - setError("No active connection") + setError(t("chat.directoryBrowserSheet.noActiveConnection")) return } client.file @@ -60,13 +62,13 @@ export function DirectoryBrowserSheet({ .catch((err) => { if (loadToken.current !== token) return setEntries([]) - setError(err instanceof Error ? err.message : "Failed to list directory") + setError(err instanceof Error ? err.message : t("chat.directoryBrowserSheet.listFailed")) }) .finally(() => { if (loadToken.current === token) setLoading(false) }) }, - [clientForDirectory], + [clientForDirectory, t], ) const enter = useCallback( @@ -164,7 +166,7 @@ export function DirectoryBrowserSheet({ onChange={handleSheetChange} > - Browse Folders + {t("chat.directoryBrowserSheet.title")} - {browseDir ? "No subfolders here" : "Enter a path above to start browsing"} + {browseDir + ? t("chat.directoryBrowserSheet.noSubfolders") + : t("chat.directoryBrowserSheet.enterPathHint")} ) : null } @@ -278,7 +282,9 @@ export function DirectoryBrowserSheet({ > - Use {browseDir ? nameOf(browseDir) : "this folder"} + {t("chat.directoryBrowserSheet.useFolderButton", { + folder: browseDir ? nameOf(browseDir) : t("chat.directoryBrowserSheet.thisFolderFallback"), + })} diff --git a/src/components/chat/DirectorySwitcher.tsx b/src/components/chat/DirectorySwitcher.tsx index a92fe3a..ebad0b1 100644 --- a/src/components/chat/DirectorySwitcher.tsx +++ b/src/components/chat/DirectorySwitcher.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from "react" import { View, Text, TouchableOpacity, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet" +import { useTranslation } from "react-i18next" interface Props { sheetRef: React.RefObject @@ -16,6 +17,7 @@ interface Props { } export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch, onBrowse }: Props) { + const { t } = useTranslation() const [custom, setCustom] = useState("") const handleSelect = useCallback( @@ -36,7 +38,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa // Build list: server default + recents (excluding current) const items = useMemo(() => { 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) { if (dir === current) continue @@ -44,7 +46,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa list.push({ label: short, dir, active: false }) } return list - }, [recents, current]) + }, [recents, current, t]) const shortCurrent = current ? current.split("/").filter(Boolean).pop() || current : null @@ -67,7 +69,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa }} > - Switch Project + {t("chat.directorySwitcher.title")} {shortCurrent && ( @@ -124,7 +126,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa }} > - Browse… + {t("chat.directorySwitcher.browseLabel")} )} @@ -155,14 +157,18 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa {item.dir} )} - {!item.dir && Uses server's working directory} + {!item.dir && ( + {t("chat.directorySwitcher.usesServerDir")} + )} {item.active && } )} contentContainerStyle={s.list} ListHeaderComponent={ - items.length > 1 ? Recent Projects : null + items.length > 1 ? ( + {t("chat.directorySwitcher.recentProjectsLabel")} + ) : null } /> diff --git a/src/components/chat/ModelPicker.tsx b/src/components/chat/ModelPicker.tsx index b5113b5..f2845a8 100644 --- a/src/components/chat/ModelPicker.tsx +++ b/src/components/chat/ModelPicker.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react" import { View, Text, TouchableOpacity, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" import BottomSheet, { BottomSheetBackdrop, BottomSheetSectionList, BottomSheetTextInput } from "@gorhom/bottom-sheet" +import { useTranslation } from "react-i18next" interface ModelItem { providerID: string @@ -25,6 +26,7 @@ interface Props { } export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: Props) { + const { t } = useTranslation() const [search, setSearch] = useState("") const sections = useMemo(() => { @@ -96,10 +98,10 @@ export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: }} > - Select Model + {t("chat.modelPicker.title")} - Permission Required + {t("chat.permissionPrompt.title")} {permission.permission}: {permission.patterns.join(", ")} onReply("reject")}> - Deny + {t("chat.permissionPrompt.deny")} onReply("always")}> - Always + {t("chat.permissionPrompt.always")} onReply("once")}> - Allow + {t("chat.permissionPrompt.allow")} diff --git a/src/components/chat/QuestionPrompt.tsx b/src/components/chat/QuestionPrompt.tsx index e5c1b67..1f8973a 100644 --- a/src/components/chat/QuestionPrompt.tsx +++ b/src/components/chat/QuestionPrompt.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" interface QuestionOption { label: string @@ -26,6 +27,7 @@ interface Props { } export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { + const { t } = useTranslation() const [answers, setAnswers] = useState(request.questions.map(() => [])) const [custom, setCustom] = useState("") const [showCustom, setShowCustom] = useState(false) @@ -66,7 +68,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { - {q.header || "Question"} + {q.header || t("chat.questionPrompt.headerFallback")} {q.question} @@ -95,7 +97,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { ) : ( setShowCustom(true)}> - Type your own answer + {t("chat.questionPrompt.customAnswerLabel")} ))} - Dismiss + {t("chat.questionPrompt.dismiss")} {(request.questions.length > 1 || q.multiple) && ( - {current < request.questions.length - 1 ? "Next" : "Submit"} + + {current < request.questions.length - 1 ? t("chat.questionPrompt.next") : t("chat.questionPrompt.submit")} + )} diff --git a/src/components/chat/ReasoningBlock.tsx b/src/components/chat/ReasoningBlock.tsx index 0063e90..c34a402 100644 --- a/src/components/chat/ReasoningBlock.tsx +++ b/src/components/chat/ReasoningBlock.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { View, Text, TouchableOpacity, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" interface Props { text: string @@ -8,6 +9,7 @@ interface Props { } export function ReasoningBlock({ text, isDark }: Props) { + const { t } = useTranslation() const [expanded, setExpanded] = useState(false) return ( @@ -18,7 +20,7 @@ export function ReasoningBlock({ text, isDark }: Props) { > - Thinking + {t("chat.reasoningBlock.label")} {expanded && ( diff --git a/src/components/chat/SessionInfo.tsx b/src/components/chat/SessionInfo.tsx index 68d4289..bb80b53 100644 --- a/src/components/chat/SessionInfo.tsx +++ b/src/components/chat/SessionInfo.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react" import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" import type { Message, Session } from "../../lib/sdk" 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) } -function formatTime(ts: number): string { +function formatTime(ts: number, t: (key: string, opts?: Record) => string): string { const diff = Date.now() - ts const mins = Math.floor(diff / 60_000) - if (mins < 1) return "just now" - if (mins < 60) return `${mins}m ago` + if (mins < 1) return t("chat.sessionInfo.time.justNow") + if (mins < 60) return t("chat.sessionInfo.time.minutesAgo", { count: mins }) 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) - 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" }) } @@ -51,6 +52,7 @@ export function SessionInfo({ onScrollToTop, onClose, }: Props) { + const { t } = useTranslation() // Match TUI: last assistant message tokens (context window), cumulative cost const stats = useMemo(() => { let cost = 0 @@ -104,7 +106,9 @@ export function SessionInfo({ )} {hasCost && ({formatCost(stats.cost)})} - {!hasTokens && !hasCost && No usage data yet} + {!hasTokens && !hasCost && ( + {t("chat.sessionInfo.noUsageData")} + )} @@ -127,37 +131,50 @@ export function SessionInfo({ {/* Token breakdown pills */} {hasTokens && ( - - - {stats.reasoning > 0 && } - {stats.cacheRead > 0 && } + + + {stats.reasoning > 0 && ( + + )} + {stats.cacheRead > 0 && ( + + )} {stats.cacheWrite > 0 && ( - + )} )} {/* Session metadata */} - {created && } + {created && ( + + )} {updated && updated !== created && ( - + )} {summary && summary.files > 0 && ( )} - {session?.share?.url && } + {session?.share?.url && ( + + )} {/* Navigation actions */} @@ -169,13 +186,15 @@ export function SessionInfo({ ) : ( )} - {loadingAll ? "Loading..." : "Load all messages"} + + {loadingAll ? t("chat.sessionInfo.loading") : t("chat.sessionInfo.loadAllMessages")} + )} {messages.length > 0 && ( - Jump to beginning + {t("chat.sessionInfo.jumpToBeginning")} )} diff --git a/src/components/chat/SlashPopover.tsx b/src/components/chat/SlashPopover.tsx index ba3dea2..8760fc4 100644 --- a/src/components/chat/SlashPopover.tsx +++ b/src/components/chat/SlashPopover.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react" import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Keyboard, Platform } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" export interface SlashCommand { trigger: string @@ -18,6 +19,7 @@ interface Props { } export function SlashPopover({ query, commands, isDark, onSelect }: Props) { + const { t } = useTranslation() const filtered = useMemo(() => { const q = query.toLowerCase() 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) { {cmd.type === "custom" && ( - cmd + {t("chat.slashPopover.customBadge")} )} diff --git a/src/components/chat/StatusIndicator.tsx b/src/components/chat/StatusIndicator.tsx index cbc4c37..1027c22 100644 --- a/src/components/chat/StatusIndicator.tsx +++ b/src/components/chat/StatusIndicator.tsx @@ -1,4 +1,5 @@ import { View, Text, StyleSheet, ActivityIndicator } from "react-native" +import { useTranslation } from "react-i18next" import { useEvents } from "../../stores/events" import { useSessions } from "../../stores/sessions" @@ -8,6 +9,7 @@ interface Props { } export function StatusIndicator({ sessionID, isDark }: Props) { + const { t } = useTranslation() const status = useEvents((s) => s.sessionStatus[sessionID]) const text = useEvents((s) => s.statusText[sessionID]) const optimistic = useSessions((s) => s.sending[sessionID]) @@ -19,7 +21,8 @@ export function StatusIndicator({ sessionID, isDark }: Props) { const busy = sseBusy || (optimistic && !status) 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 ( diff --git a/src/components/chat/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx index 90dbf6e..b7361c1 100644 --- a/src/components/chat/ToolCallCard.tsx +++ b/src/components/chat/ToolCallCard.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from "react" import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, Platform } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" import type { Part } from "../../lib/sdk" 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 }) { + const { t } = useTranslation() const pattern = typeof input === "object" && input !== null ? (input as Record).pattern : undefined const path = typeof input === "object" && input !== null ? (input as Record).path : undefined const results = typeof output === "string" ? output : undefined @@ -158,8 +160,9 @@ function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unk {typeof pattern === "string" && ( - Pattern: {pattern} - {typeof path === "string" ? ` in ${path}` : ""} + {typeof path === "string" + ? t("chat.toolCallCard.patternWithPath", { pattern, path }) + : t("chat.toolCallCard.patternOnly", { pattern })} )} {results && results.length > 0 && ( @@ -310,6 +313,7 @@ interface Props { } export function ToolCallCard({ tool, isDark }: Props) { + const { t } = useTranslation() const [expanded, setExpanded] = useState(false) const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline" const status = tool.state?.status || "pending" @@ -338,7 +342,7 @@ export function ToolCallCard({ tool, isDark }: Props) { - {tool.state?.title || tool.tool || "Tool"} + {tool.state?.title || tool.tool || t("chat.toolCallCard.fallbackTitle")} {elapsed && {elapsed}} diff --git a/src/components/chat/VariantPicker.tsx b/src/components/chat/VariantPicker.tsx index e832d24..fd64fd6 100644 --- a/src/components/chat/VariantPicker.tsx +++ b/src/components/chat/VariantPicker.tsx @@ -1,6 +1,7 @@ import { View, Text, TouchableOpacity, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList } from "@gorhom/bottom-sheet" +import { useTranslation } from "react-i18next" interface VariantOption { id: string | null @@ -16,25 +17,26 @@ interface Props { sheetRef: React.RefObject } -const AUTO_OPTION: VariantOption = { - id: null, - label: "Auto", - description: "Use model default reasoning", -} - -const EFFORT_DESCRIPTIONS: Record = { - low: "Faster, less thorough reasoning", - medium: "Balanced reasoning and speed", - high: "Deep, thorough reasoning", -} - export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }: Props) { + const { t } = useTranslation() + + const effortDescriptions: Record = { + 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[] = [ - AUTO_OPTION, + autoOption, ...Object.keys(variants || {}).map((id) => ({ id, 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 } )} > - Reasoning Effort + {t("chat.variantPicker.title")} ["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)) { + 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((acc, part) => (acc as Record)?.[part], catalog) + assert.notEqual(value, "", `${name}.json: "${key}" is an empty string`) + } + } +}) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 9148fa0..6d00b74 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -1,6 +1,12 @@ { "common": { - "cancel": "Cancel" + "cancel": "Cancel", + "ok": "OK", + "error": "Error", + "delete": "Delete", + "retry": "Retry", + "save": "Save", + "shareReport": "Share report" }, "settings": { "sections": { @@ -65,5 +71,357 @@ "notificationsDisabledTitle": "Notifications Disabled", "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" + } + } } } diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 200bb09..e6cef3c 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -1,6 +1,12 @@ { "common": { - "cancel": "取消" + "cancel": "取消", + "ok": "确定", + "error": "错误", + "delete": "删除", + "retry": "重试", + "save": "保存", + "shareReport": "分享报告" }, "settings": { "sections": { @@ -65,5 +71,357 @@ "notificationsDisabledTitle": "通知已关闭", "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}} 天前" + } + } } } diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index b0f371c..07c5219 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -9,31 +9,34 @@ import { Platform, AppState } from "react-native" export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const export type Category = (typeof categories)[number] -// Per-category metadata used by both the notification module and the settings UI -export const categoryMeta: Record = { +// Per-category metadata used by both the notification module and the settings UI. +// 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 = { permissions: { - label: "Permission Requests", - description: "When a tool needs approval to proceed", + labelKey: "notifications.categories.permissions.label", + descriptionKey: "notifications.categories.permissions.description", icon: "shield-checkmark", }, questions: { - label: "Questions", - description: "When the assistant needs your input", + labelKey: "notifications.categories.questions.label", + descriptionKey: "notifications.categories.questions.description", icon: "help-circle", }, completed: { - label: "Task Completed", - description: "When a session finishes processing", + labelKey: "notifications.categories.completed.label", + descriptionKey: "notifications.categories.completed.description", icon: "checkmark-circle", }, errors: { - label: "Errors", - description: "When a session encounters a problem", + labelKey: "notifications.categories.errors.label", + descriptionKey: "notifications.categories.errors.description", icon: "alert-circle", }, connection: { - label: "Connection", - description: "When the app loses connection for an extended period", + labelKey: "notifications.categories.connection.label", + descriptionKey: "notifications.categories.connection.description", icon: "wifi", }, }