diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aa5635cc..a7ca4f9aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,42 +23,42 @@ This allows you to test production-like builds with real users before releasing ```bash # Development variant (default) -npm run ios:dev +yarn ios:dev # Preview variant -npm run ios:preview +yarn ios:preview # Production variant -npm run ios:production +yarn ios:production ``` ### Android Development ```bash # Development variant -npm run android:dev +yarn android:dev # Preview variant -npm run android:preview +yarn android:preview # Production variant -npm run android:production +yarn android:production ``` ### macOS Desktop (Tauri) ```bash # Development variant - run with hot reload -npm run tauri:dev +yarn tauri:dev # Build development variant -npm run tauri:build:dev +yarn tauri:build:dev # Build preview variant -npm run tauri:build:preview +yarn tauri:build:preview # Build production variant -npm run tauri:build:production +yarn tauri:build:production ``` **How Tauri Variants Work:** @@ -71,13 +71,13 @@ npm run tauri:build:production ```bash # Start dev server for development variant -npm run start:dev +yarn start:dev # Start dev server for preview variant -npm run start:preview +yarn start:preview # Start dev server for production variant -npm run start:production +yarn start:production ``` ## Visual Differences @@ -95,7 +95,7 @@ This makes it easy to distinguish which version you're testing! 1. **Build development variant:** ```bash - npm run ios:dev + yarn ios:dev ``` 2. **Make your changes** to the code @@ -104,19 +104,19 @@ This makes it easy to distinguish which version you're testing! 4. **Rebuild if needed** for native changes: ```bash - npm run ios:dev + yarn ios:dev ``` ### Testing Preview (Pre-Release) 1. **Build preview variant:** ```bash - npm run ios:preview + yarn ios:preview ``` 2. **Test OTA updates:** ```bash - npm run ota # Publishes to preview branch + yarn ota # Publishes to preview branch ``` 3. **Verify** the preview build works as expected @@ -125,17 +125,17 @@ This makes it easy to distinguish which version you're testing! 1. **Build production variant:** ```bash - npm run ios:production + yarn ios:production ``` 2. **Submit to App Store:** ```bash - npm run submit + yarn submit ``` 3. **Deploy OTA updates:** ```bash - npm run ota:production + yarn ota:production ``` ## All Variants Simultaneously @@ -144,9 +144,9 @@ You can install all three variants on the same device: ```bash # Build all three variants -npm run ios:dev -npm run ios:preview -npm run ios:production +yarn ios:dev +yarn ios:preview +yarn ios:production ``` All three apps appear on your device with different icons and names! @@ -195,12 +195,12 @@ You can connect different variants to different Happy CLI instances: ```bash # Development app → Dev CLI daemon -npm run android:dev -# Connect to CLI running: npm run dev:daemon:start +yarn android:dev +# Connect to CLI running: yarn dev:daemon:start # Production app → Stable CLI daemon -npm run android:production -# Connect to CLI running: npm run stable:daemon:start +yarn android:production +# Connect to CLI running: yarn stable:daemon:start ``` Each app maintains separate authentication and sessions! @@ -210,7 +210,7 @@ Each app maintains separate authentication and sessions! To test with a local Happy server: ```bash -npm run start:local-server +yarn start:local-server ``` This sets: @@ -227,8 +227,8 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. Check `app.config.js` - verify `bundleId` is set correctly for the variant 2. Clean build: ```bash - npm run prebuild - npm run ios:dev # or whichever variant + yarn prebuild + yarn ios:dev # or whichever variant ``` ### App not updating after changes @@ -236,12 +236,12 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. **For JS changes**: Hot reload should work automatically 2. **For native changes**: Rebuild the variant: ```bash - npm run ios:dev # Force rebuild + yarn ios:dev # Force rebuild ``` 3. **For config changes**: Clean and prebuild: ```bash - npm run prebuild - npm run ios:dev + yarn prebuild + yarn ios:dev ``` ### All three apps look the same @@ -258,7 +258,7 @@ If they're all the same name, the variant might not be set correctly. Verify: echo $APP_ENV # Or look at the build output -npm run ios:dev # Should show "Happy (dev)" as the name +yarn ios:dev # Should show "Happy (dev)" as the name ``` ### Connected device not found @@ -270,7 +270,7 @@ For iOS connected device testing: xcrun devicectl list devices # Run on specific connected device -npm run ios:connected-device +yarn ios:connected-device ``` ## Tips diff --git a/app.config.js b/app.config.js index 655f8542a..112c9e725 100644 --- a/app.config.js +++ b/app.config.js @@ -1,14 +1,29 @@ const variant = process.env.APP_ENV || 'development'; -const name = { + +// Allow opt-in overrides for local dev tooling without changing upstream defaults. +const nameOverride = (process.env.EXPO_APP_NAME || '').trim(); +const bundleIdOverride = (process.env.EXPO_APP_BUNDLE_ID || '').trim(); + +const namesByVariant = { development: "Happy (dev)", preview: "Happy (preview)", production: "Happy" -}[variant]; -const bundleId = { +}; +const bundleIdsByVariant = { development: "com.slopus.happy.dev", preview: "com.slopus.happy.preview", production: "com.ex3ndr.happy" -}[variant]; +}; + +// If APP_ENV is unknown, fall back to development-safe defaults to avoid generating +// an invalid Expo config with undefined name/bundle id. +const name = nameOverride || namesByVariant[variant] || namesByVariant.development; +const bundleId = bundleIdOverride || bundleIdsByVariant[variant] || bundleIdsByVariant.development; +// NOTE: +// The URL scheme is used for deep linking *and* by the Expo development client launcher flow. +// Keep the default stable for upstream users, but allow opt-in overrides for local dev variants +// (e.g. to avoid iOS scheme collisions between multiple installs). +const scheme = (process.env.EXPO_APP_SCHEME || '').trim() || "happy"; export default { expo: { @@ -18,7 +33,7 @@ export default { runtimeVersion: "18", orientation: "default", icon: "./sources/assets/images/icon.png", - scheme: "happy", + scheme, userInterfaceStyle: "automatic", newArchEnabled: true, notification: { @@ -174,4 +189,4 @@ export default { }, owner: "bulkacorp" } -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index 591e10d3a..f7e4e46e1 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "@material/material-color-utilities": "^0.3.0", "@stablelib/hex": "^2.0.1", "@types/react": "~19.1.10", + "@types/react-test-renderer": "^19.1.0", "babel-plugin-transform-remove-console": "^6.9.4", "cross-env": "^10.1.0", "patch-package": "^8.0.0", diff --git a/patches/@more-tech+react-native-libsodium+1.5.5.patch b/patches/@more-tech+react-native-libsodium+1.5.5.patch new file mode 100644 index 000000000..dbd45c3a1 --- /dev/null +++ b/patches/@more-tech+react-native-libsodium+1.5.5.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec b/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec +index 5dbd9f1..bc3da26 100644 +--- a/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec ++++ b/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec +@@ -30,7 +30,14 @@ Pod::Spec.new do |s| + } + s.dependency "React-Codegen" + if ENV['RCT_USE_RN_DEP'] != '1' +- s.dependency 'RCT-Folly', folly_version ++ # `folly_version` is not always defined during podspec evaluation ++ # (e.g. Expo/RN >= 0.81), so fall back to an unpinned dependency. ++ folly_ver = defined?(folly_version) ? folly_version : nil ++ if folly_ver ++ s.dependency 'RCT-Folly', folly_ver ++ else ++ s.dependency 'RCT-Folly' ++ end + end + s.dependency "RCTRequired" + s.dependency "RCTTypeSafety" diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index 457419294..530c928dd 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -22,6 +22,7 @@ import { isRunningOnMac } from '@/utils/platform'; import { useDeviceType, useHeaderHeight, useIsLandscape, useIsTablet } from '@/utils/responsive'; import { formatPathRelativeToHome, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; +import type { ModelMode } from '@/sync/permissionTypes'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import * as React from 'react'; @@ -196,10 +197,24 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); + const CONFIGURABLE_MODEL_MODES = [ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + ] as const; + type ConfigurableModelMode = (typeof CONFIGURABLE_MODEL_MODES)[number]; + const isConfigurableModelMode = React.useCallback((mode: ModelMode): mode is ConfigurableModelMode => { + return (CONFIGURABLE_MODEL_MODES as readonly string[]).includes(mode); + }, []); + // Function to update model mode (for Gemini sessions) - const updateModelMode = React.useCallback((mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => { - storage.getState().updateSessionModelMode(sessionId, mode); - }, [sessionId]); + const updateModelMode = React.useCallback((mode: ModelMode) => { + // Only Gemini model modes are configurable from the UI today. + if (isConfigurableModelMode(mode)) { + storage.getState().updateSessionModelMode(sessionId, mode); + } + }, [isConfigurableModelMode, sessionId]); // Memoize header-dependent styles to prevent re-renders const headerDependentStyles = React.useMemo(() => ({ @@ -280,8 +295,8 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: sessionId={sessionId} permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} - modelMode={modelMode as any} - onModelModeChange={updateModelMode as any} + modelMode={modelMode} + onModelModeChange={updateModelMode} metadata={session.metadata} connectionStatus={{ text: sessionStatus.statusText, diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 408d7ad24..64367c054 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -117,6 +117,12 @@ export default function RootLayout() { headerTitle: t('settings.features'), }} /> + + ); diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 783dc2a19..7ebea7b9f 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native'; -import Constants from 'expo-constants'; +import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView } from 'react-native'; import { Typography } from '@/constants/Typography'; import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { t } from '@/text'; @@ -16,38 +15,33 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { machineSpawnNewSession } from '@/sync/ops'; import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; import { createWorktree } from '@/utils/createWorktree'; import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; -import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { mapPermissionModeAcrossAgents } from '@/sync/permissionMapping'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; -import { randomUUID } from 'expo-crypto'; import { useCLIDetection } from '@/hooks/useCLIDetection'; -import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; -import { formatPathRelativeToHome } from '@/utils/sessionUtils'; -import { resolveAbsolutePath } from '@/utils/pathUtils'; -import { MultiTextInput } from '@/components/MultiTextInput'; + import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; -import { SearchableListSelector, SelectorConfig } from '@/components/SearchableListSelector'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; - -// Simple temporary state for passing selections back from picker screens -let onMachineSelected: (machineId: string) => void = () => { }; -let onProfileSaved: (profile: AIBackendProfile) => void = () => { }; - -export const callbacks = { - onMachineSelected: (machineId: string) => { - onMachineSelected(machineId); - }, - onProfileSaved: (profile: AIBackendProfile) => { - onProfileSaved(profile); - } -} +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import { buildProfileActions } from '@/components/profileActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { consumeProfileIdParam } from '@/profileRouteParams'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -59,15 +53,15 @@ const useProfileMap = (profiles: AIBackendProfile[]) => { // Environment variable transformation helper // Returns ALL profile environment variables - daemon will use them as-is -const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => { +const transformProfileToEnvironmentVars = (profile: AIBackendProfile) => { // getProfileEnvironmentVariables already returns ALL env vars from profile - // including custom environmentVariables array and provider-specific configs + // including custom environmentVariables array return getProfileEnvironmentVariables(profile); }; // Helper function to get the most recent path for a machine // Returns the path from the most recently CREATED session for this machine -const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ machineId: string; path: string }>): string => { +const getRecentPathForMachine = (machineId: string | null): string => { if (!machineId) return ''; const machine = storage.getState().machines[machineId]; @@ -97,43 +91,56 @@ const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ const RECENT_PATHS_DEFAULT_VISIBLE = 5; const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 character spaces at 11px font -const styles = StyleSheet.create((theme, rt) => ({ + const styles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', paddingTop: Platform.OS === 'web' ? 0 : 40, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), }, scrollContainer: { flex: 1, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), }, - contentContainer: { - width: '100%', - alignSelf: 'center', - paddingTop: rt.insets.top, - paddingBottom: 16, - }, - wizardContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, - marginHorizontal: 16, - padding: 16, - marginBottom: 16, - }, - sectionHeader: { - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - marginTop: 12, - ...Typography.default('semiBold') - }, - sectionDescription: { - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 12, - lineHeight: 18, - ...Typography.default() - }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: rt.insets.top + 24, + paddingBottom: 16, + }, + wizardContainer: { + marginBottom: 16, + }, + wizardSectionHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + marginTop: 12, + paddingHorizontal: 16, + }, + sectionHeader: { + fontSize: 17, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + marginTop: 12, + ...Typography.default('semiBold') + }, + sectionDescription: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: 12, + lineHeight: 18, + paddingHorizontal: 16, + ...Typography.default() + }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -202,18 +209,6 @@ const styles = StyleSheet.create((theme, rt) => ({ flex: 1, ...Typography.default() }, - advancedHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 12, - }, - advancedHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.textSecondary, - ...Typography.default(), - }, permissionGrid: { flexDirection: 'row', flexWrap: 'wrap', @@ -256,13 +251,21 @@ const styles = StyleSheet.create((theme, rt) => ({ function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{ - prompt?: string; - dataId?: string; - machineId?: string; + const router = useRouter(); + const navigation = useNavigation(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const { width: screenWidth } = useWindowDimensions(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + + const newSessionSidePadding = 16; + const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ + prompt?: string; + dataId?: string; + machineId?: string; path?: string; + profileId?: string; }>(); // Try to get data from temporary store first @@ -284,13 +287,16 @@ function NewSessionWizard() { // Control A (false): Simpler AgentInput-driven layout // Variant B (true): Enhanced profile-first wizard with sections const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); + const useProfiles = useSetting('useProfiles'); const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -300,30 +306,60 @@ function NewSessionWizard() { }, [profiles]); const profileMap = useProfileMap(allProfiles); + + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }, [favoriteProfileIds, setFavoriteProfileIds]); const machines = useAllMachines(); const sessions = useSessions(); // Wizard state const [selectedProfileId, setSelectedProfileId] = React.useState(() => { + if (!useProfiles) { + return null; + } + const draftProfileId = persistedDraft?.selectedProfileId; + if (draftProfileId && profileMap.has(draftProfileId)) { + return draftProfileId; + } if (lastUsedProfile && profileMap.has(lastUsedProfile)) { return lastUsedProfile; } - return 'anthropic'; // Default to Anthropic + // Default to "no profile" so default session creation remains unchanged. + return null; }); + + React.useEffect(() => { + if (!useProfiles && selectedProfileId !== null) { + setSelectedProfileId(null); + } + }, [useProfiles, selectedProfileId]); + + const allowGemini = experimentsEnabled; + const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { // Check if agent type was provided in temp data if (tempSessionData?.agentType) { - // Only allow gemini if experiments are enabled - if (tempSessionData.agentType === 'gemini' && !experimentsEnabled) { + if (tempSessionData.agentType === 'gemini' && !allowGemini) { return 'claude'; } return tempSessionData.agentType; } - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - // Only allow gemini if experiments are enabled - if (lastUsedAgent === 'gemini' && experimentsEnabled) { + if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex' || lastUsedAgent === 'gemini') { + if (lastUsedAgent === 'gemini' && !allowGemini) { + return 'claude'; + } return lastUsedAgent; } return 'claude'; @@ -331,14 +367,14 @@ function NewSessionWizard() { // Agent cycling handler (for cycling through claude -> codex -> gemini) // Note: Does NOT persist immediately - persistence is handled by useEffect below - const handleAgentClick = React.useCallback(() => { + const handleAgentCycle = React.useCallback(() => { setAgentType(prev => { - // Cycle: claude -> codex -> gemini (if experiments) -> claude + // Cycle: claude -> codex -> (gemini?) -> claude if (prev === 'claude') return 'codex'; - if (prev === 'codex') return experimentsEnabled ? 'gemini' : 'claude'; + if (prev === 'codex') return allowGemini ? 'gemini' : 'claude'; return 'claude'; }); - }, [experimentsEnabled]); + }, [allowGemini]); // Persist agent selection changes (separate from setState to avoid race condition) // This runs after agentType state is updated, ensuring the value is stable @@ -366,23 +402,25 @@ function NewSessionWizard() { // which intelligently resets only when the current mode is invalid for the new agent type. // A duplicate unconditional reset here was removed to prevent race conditions. - const [modelMode, setModelMode] = React.useState(() => { + const [modelMode, setModelMode] = React.useState(() => { const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; // Note: 'default' is NOT valid for Gemini - we want explicit model selection const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; - if (lastUsedModelMode) { - if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'gemini' && validGeminiModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; + if (persistedDraft?.modelMode) { + const draftMode = persistedDraft.modelMode as ModelMode; + if (agentType === 'codex' && validCodexModes.includes(draftMode)) { + return draftMode; + } else if (agentType === 'claude' && validClaudeModes.includes(draftMode)) { + return draftMode; + } else if (agentType === 'gemini' && validGeminiModes.includes(draftMode)) { + return draftMode; } } - return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; - }); + return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; + }); + const modelOptions = React.useMemo(() => getModelOptionsForAgentType(agentType), [agentType]); // Session details state const [selectedMachineId, setSelectedMachineId] = React.useState(() => { @@ -399,24 +437,35 @@ function NewSessionWizard() { return null; }); - const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + const hasUserSelectedPermissionModeRef = React.useRef(false); + const permissionModeRef = React.useRef(permissionMode); + React.useEffect(() => { + permissionModeRef.current = permissionMode; + }, [permissionMode]); + + const applyPermissionMode = React.useCallback((mode: PermissionMode, source: 'user' | 'auto') => { setPermissionMode(mode); - // Save the new selection immediately sync.applySettings({ lastUsedPermissionMode: mode }); + if (source === 'user') { + hasUserSelectedPermissionModeRef.current = true; + } }, []); + const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + applyPermissionMode(mode, 'user'); + }, [applyPermissionMode]); + // // Path selection // const [selectedPath, setSelectedPath] = React.useState(() => { - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); + return getRecentPathForMachine(selectedMachineId); }); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); const [isCreating, setIsCreating] = React.useState(false); - const [showAdvanced, setShowAdvanced] = React.useState(false); // Handle machineId route param from picker screens (main's navigation pattern) React.useEffect(() => { @@ -428,11 +477,37 @@ function NewSessionWizard() { } if (machineIdParam !== selectedMachineId) { setSelectedMachineId(machineIdParam); - const bestPath = getRecentPathForMachine(machineIdParam, recentMachinePaths); + const bestPath = getRecentPathForMachine(machineIdParam); setSelectedPath(bestPath); } }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); + // Ensure a machine is pre-selected once machines have loaded (wizard expects this). + React.useEffect(() => { + if (selectedMachineId !== null) { + return; + } + if (machines.length === 0) { + return; + } + + let machineIdToUse: string | null = null; + if (recentMachinePaths.length > 0) { + for (const recent of recentMachinePaths) { + if (machines.find(m => m.id === recent.machineId)) { + machineIdToUse = recent.machineId; + break; + } + } + } + if (!machineIdToUse) { + machineIdToUse = machines[0].id; + } + + setSelectedMachineId(machineIdToUse); + setSelectedPath(getRecentPathForMachine(machineIdToUse)); + }, [machines, recentMachinePaths, selectedMachineId]); + // Handle path route param from picker screens (main's navigation pattern) React.useEffect(() => { if (typeof pathParam !== 'string') { @@ -447,11 +522,12 @@ function NewSessionWizard() { // Path selection state - initialize with formatted selected path // Refs for scrolling to sections - const scrollViewRef = React.useRef(null); - const profileSectionRef = React.useRef(null); - const machineSectionRef = React.useRef(null); - const pathSectionRef = React.useRef(null); - const permissionSectionRef = React.useRef(null); + const scrollViewRef = React.useRef(null); + const profileSectionRef = React.useRef(null); + const modelSectionRef = React.useRef(null); + const machineSectionRef = React.useRef(null); + const pathSectionRef = React.useRef(null); + const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine const cliAvailability = useCLIDetection(selectedMachineId); @@ -478,19 +554,6 @@ function NewSessionWizard() { } }, [cliAvailability.timestamp, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, agentType, experimentsEnabled]); - // Extract all ${VAR} references from profiles to query daemon environment - const envVarRefs = React.useMemo(() => { - const refs = new Set(); - allProfiles.forEach(profile => { - extractEnvVarReferences(profile.environmentVariables || []) - .forEach(ref => refs.add(ref)); - }); - return Array.from(refs); - }, [allProfiles]); - - // Query daemon environment for ${VAR} resolution - const { variables: daemonEnv } = useEnvironmentVariables(selectedMachineId, envVarRefs); - // Temporary banner dismissal (X button) - resets when component unmounts or machine changes const [hiddenBanners, setHiddenBanners] = React.useState<{ claude: boolean; codex: boolean; gemini: boolean }>({ claude: false, codex: false, gemini: false }); @@ -534,40 +597,43 @@ function NewSessionWizard() { } }, [selectedMachineId, dismissedCLIWarnings, setDismissedCLIWarnings]); - // Helper to check if profile is available (compatible + CLI detected) + // Helper to check if profile is available (CLI detected + experiments gating) const isProfileAvailable = React.useCallback((profile: AIBackendProfile): { available: boolean; reason?: string } => { - // Check profile compatibility with selected agent type - if (!validateProfileForAgent(profile, agentType)) { - // Build list of agents this profile supports (excluding current) - // Uses Object.entries to iterate over compatibility flags - scales automatically with new agents - const supportedAgents = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([agent, supported]) => supported && agent !== agentType) - .map(([agent]) => agent.charAt(0).toUpperCase() + agent.slice(1)); // 'claude' -> 'Claude' - const required = supportedAgents.join(' or ') || 'another agent'; + const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini'); + + const allowedCLIs = supportedCLIs.filter((cli) => cli !== 'gemini' || experimentsEnabled); + + if (allowedCLIs.length === 0) { return { available: false, - reason: `requires-agent:${required}`, + reason: 'no-supported-cli', }; } - // Check if required CLI is detected on machine (only if detection completed) - // Determine required CLI: if profile supports exactly one CLI, that CLI is required - // Uses Object.entries to iterate - scales automatically when new agents are added - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([, supported]) => supported) - .map(([agent]) => agent); - const requiredCLI = supportedCLIs.length === 1 ? supportedCLIs[0] as 'claude' | 'codex' | 'gemini' : null; + // If a profile requires exactly one CLI, enforce that one. + if (allowedCLIs.length === 1) { + const requiredCLI = allowedCLIs[0]; + if (cliAvailability[requiredCLI] === false) { + return { + available: false, + reason: `cli-not-detected:${requiredCLI}`, + }; + } + return { available: true }; + } - if (requiredCLI && cliAvailability[requiredCLI] === false) { + // Multi-CLI profiles: available if *any* supported CLI is available (or detection not finished). + const anyAvailable = allowedCLIs.some((cli) => cliAvailability[cli] !== false); + if (!anyAvailable) { return { available: false, - reason: `cli-not-detected:${requiredCLI}`, + reason: 'cli-not-detected:any', }; } - - // Optimistic: If detection hasn't completed (null) or profile supports both, assume available return { available: true }; - }, [agentType, cliAvailability]); + }, [cliAvailability, experimentsEnabled]); // Computed values const compatibleProfiles = React.useMemo(() => { @@ -591,6 +657,58 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); + const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => { + // Persist wizard state before navigating so selection doesn't reset on return. + saveNewSessionDraft({ + input: sessionPrompt, + selectedMachineId, + selectedPath, + selectedProfileId: useProfiles ? selectedProfileId : null, + agentType, + permissionMode, + modelMode, + sessionType, + updatedAt: Date.now(), + }); + + router.push({ + pathname: '/new/pick/profile-edit', + params: { + ...params, + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + } as any); + }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + + const handleAddProfile = React.useCallback(() => { + openProfileEdit({}); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + openProfileEdit({ cloneFromProfileId: profile.id }); + }, [openProfileEdit]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedProfileId === profile.id) { + setSelectedProfileId(null); + } + }, + }, + ], + ); + }, [profiles, selectedProfileId, setProfiles]); + // Get recent paths for the selected machine // Recent machines computed from sessions (for inline machine selection) const recentMachines = React.useMemo(() => { @@ -617,6 +735,10 @@ function NewSessionWizard() { .map(item => item.machine); }, [sessions, machines]); + const favoriteMachineItems = React.useMemo(() => { + return machines.filter(m => favoriteMachines.includes(m.id)); + }, [machines, favoriteMachines]); + const recentPaths = React.useMemo(() => { if (!selectedMachineId) return []; @@ -662,61 +784,120 @@ function NewSessionWizard() { // Validation const canCreate = React.useMemo(() => { - return ( - selectedProfileId !== null && - selectedMachineId !== null && - selectedPath.trim() !== '' - ); - }, [selectedProfileId, selectedMachineId, selectedPath]); + return selectedMachineId !== null && selectedPath.trim() !== ''; + }, [selectedMachineId, selectedPath]); const selectProfile = React.useCallback((profileId: string) => { + const prevSelectedProfileId = selectedProfileId; setSelectedProfileId(profileId); // Check both custom profiles and built-in profiles const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); if (profile) { - // Auto-select agent based on profile's EXCLUSIVE compatibility - // Only switch if profile supports exactly one CLI - scales automatically with new agents - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) .filter(([, supported]) => supported) - .map(([agent]) => agent); - - if (supportedCLIs.length === 1) { - const requiredAgent = supportedCLIs[0] as 'claude' | 'codex' | 'gemini'; - // Check if this agent is available and allowed - const isAvailable = cliAvailability[requiredAgent] !== false; - const isAllowed = requiredAgent !== 'gemini' || experimentsEnabled; + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); - if (isAvailable && isAllowed) { - setAgentType(requiredAgent); - } - // If the required CLI is unavailable or not allowed, keep current agent (profile will show as unavailable) + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? 'claude'); } - // If supportedCLIs.length > 1, profile supports multiple CLIs - don't force agent switch // Set session type from profile's default if (profile.defaultSessionType) { setSessionType(profile.defaultSessionType); } - // Set permission mode from profile's default - if (profile.defaultPermissionMode) { - setPermissionMode(profile.defaultPermissionMode as PermissionMode); + + // Apply permission defaults only on first selection (or if the user hasn't explicitly chosen one). + // Switching between profiles should not reset permissions when the backend stays the same. + if (!hasUserSelectedPermissionModeRef.current && profile.defaultPermissionMode) { + const nextMode = profile.defaultPermissionMode as PermissionMode; + // If the user is switching profiles (not initial selection), keep their current permissionMode. + const isInitialProfileSelection = prevSelectedProfileId === null; + if (isInitialProfileSelection) { + applyPermissionMode(nextMode, 'auto'); + } } } - }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); + }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]); - // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent + // Handle profile route param from picker screens React.useEffect(() => { + if (!useProfiles) { + return; + } + + const { nextSelectedProfileId, shouldClearParam } = consumeProfileIdParam({ + profileIdParam, + selectedProfileId, + }); + + if (nextSelectedProfileId === null) { + if (selectedProfileId !== null) { + setSelectedProfileId(null); + } + } else if (typeof nextSelectedProfileId === 'string') { + selectProfile(nextSelectedProfileId); + } + + if (shouldClearParam) { + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ profileId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: undefined } }, + } as never); + } + } + }, [navigation, profileIdParam, selectedProfileId, selectProfile, useProfiles]); + + // Keep agentType compatible with the currently selected profile. + React.useEffect(() => { + if (!useProfiles || selectedProfileId === null) { + return; + } + + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + if (!profile) { + return; + } + + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); + + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? 'claude'); + } + }, [agentType, allowGemini, profileMap, selectedProfileId, useProfiles]); + + const prevAgentTypeRef = React.useRef(agentType); + + // When agent type changes, keep the "permission level" consistent by mapping modes across backends. + React.useEffect(() => { + const prev = prevAgentTypeRef.current; + if (prev === agentType) { + return; + } + prevAgentTypeRef.current = agentType; + + const current = permissionModeRef.current; const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - const isValidForCurrentAgent = (agentType === 'codex' || agentType === 'gemini') - ? validCodexGeminiModes.includes(permissionMode) - : validClaudeModes.includes(permissionMode); + const isValidForNewAgent = (agentType === 'codex' || agentType === 'gemini') + ? validCodexGeminiModes.includes(current) + : validClaudeModes.includes(current); - if (!isValidForCurrentAgent) { - setPermissionMode('default'); + if (isValidForNewAgent) { + return; } - }, [agentType, permissionMode]); + + const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); + applyPermissionMode(mapped, 'auto'); + }, [agentType, applyPermissionMode]); // Reset model mode when agent type changes to appropriate default React.useEffect(() => { @@ -747,241 +928,217 @@ function NewSessionWizard() { }, [agentType, modelMode]); // Scroll to section helpers - for AgentInput button clicks - const scrollToSection = React.useCallback((ref: React.RefObject) => { - if (!ref.current || !scrollViewRef.current) return; - - // Use requestAnimationFrame to ensure layout is painted before measuring - requestAnimationFrame(() => { - if (ref.current && scrollViewRef.current) { - ref.current.measureLayout( - scrollViewRef.current as any, - (x, y) => { - scrollViewRef.current?.scrollTo({ y: y - 20, animated: true }); - }, - () => { - console.warn('measureLayout failed'); - } - ); - } - }); + const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; model?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); + const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + return (e: any) => { + wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; + }; + }, []); + const scrollToWizardSection = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + const y = wizardSectionOffsets.current[key]; + if (typeof y !== 'number' || !scrollViewRef.current) return; + scrollViewRef.current.scrollTo({ y: Math.max(0, y - 20), animated: true }); }, []); const handleAgentInputProfileClick = React.useCallback(() => { - scrollToSection(profileSectionRef); - }, [scrollToSection]); + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); + scrollToWizardSection('machine'); + }, [scrollToWizardSection]); const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); + scrollToWizardSection('path'); + }, [scrollToWizardSection]); - const handleAgentInputPermissionChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - scrollToSection(permissionSectionRef); - }, [scrollToSection]); + const handleAgentInputPermissionClick = React.useCallback(() => { + scrollToWizardSection('permission'); + }, [scrollToWizardSection]); const handleAgentInputAgentClick = React.useCallback(() => { - scrollToSection(profileSectionRef); // Agent tied to profile section - }, [scrollToSection]); + scrollToWizardSection('agent'); + }, [scrollToWizardSection]); - const handleAddProfile = React.useCallback(() => { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - const profileData = encodeURIComponent(JSON.stringify(newProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); - }, [router]); + const ignoreProfileRowPressRef = React.useRef(false); - const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { - const profileData = encodeURIComponent(JSON.stringify(profile)); - const machineId = selectedMachineId || ''; - router.push(`/new/pick/profile-edit?profileData=${profileData}&machineId=${machineId}`); - }, [router, selectedMachineId]); - - const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); - }, [router]); + const openProfileEnvVarsPreview = React.useCallback((profile: AIBackendProfile) => { + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: getProfileEnvironmentVariables(profile), + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: profile.name, + }, + }); + }, [selectedMachine, selectedMachineId]); + + const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { + return ; + }, []); + + const renderDefaultEnvironmentRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavoriteProfile(''), + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [isDefaultEnvironmentFavorite, selectedIndicatorColor, theme.colors.textSecondary, toggleFavoriteProfile]); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; + + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => openProfileEdit({ profileId: profile.id }), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => handleDeleteProfile(profile), + onViewEnvironmentVariables: envVarCount > 0 ? () => openProfileEnvVarsPreview(profile) : undefined, + }); + + return ( + + + + + 0 ? ['envVars'] : [])]} + iconSize={20} + onActionPressIn={() => { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEnvVarsPreview, + openProfileEdit, + screenWidth, + selectedIndicatorColor, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, + ]); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { const parts: string[] = []; const availability = isProfileAvailable(profile); - // Add "Built-in" indicator first for built-in profiles if (profile.isBuiltIn) { parts.push('Built-in'); } - // Add CLI type second (before warnings/availability) if (profile.compatibility.claude && profile.compatibility.codex) { - parts.push('Claude & Codex CLI'); + parts.push('Claude & Codex'); } else if (profile.compatibility.claude) { - parts.push('Claude CLI'); + parts.push('Claude'); } else if (profile.compatibility.codex) { - parts.push('Codex CLI'); + parts.push('Codex'); } - // Add availability warning if unavailable if (!availability.available && availability.reason) { if (availability.reason.startsWith('requires-agent:')) { const required = availability.reason.split(':')[1]; - parts.push(`⚠️ This profile uses ${required} CLI only`); + parts.push(`Requires ${required}`); } else if (availability.reason.startsWith('cli-not-detected:')) { const cli = availability.reason.split(':')[1]; - const cliName = cli === 'claude' ? 'Claude' : 'Codex'; - parts.push(`⚠️ ${cliName} CLI not detected (this profile needs it)`); - } - } - - // Get model name - check both anthropicConfig and environmentVariables - let modelName: string | undefined; - if (profile.anthropicConfig?.model) { - // User set in GUI - literal value, no evaluation needed - modelName = profile.anthropicConfig.model; - } else if (profile.openaiConfig?.model) { - modelName = profile.openaiConfig.model; - } else { - // Check environmentVariables - may need ${VAR} evaluation - const modelEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_MODEL'); - if (modelEnvVar) { - const resolved = resolveEnvVarSubstitution(modelEnvVar.value, daemonEnv); - if (resolved) { - // Show as "VARIABLE: value" when evaluated from ${VAR} - const varName = modelEnvVar.value.match(/^\$\{(.+)\}$/)?.[1]; - modelName = varName ? `${varName}: ${resolved}` : resolved; - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - modelName = modelEnvVar.value; - } - } - } - - if (modelName) { - parts.push(modelName); - } - - // Add base URL if exists in environmentVariables - const baseUrlEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_BASE_URL'); - if (baseUrlEnvVar) { - const resolved = resolveEnvVarSubstitution(baseUrlEnvVar.value, daemonEnv); - if (resolved) { - // Extract hostname and show with variable name - const varName = baseUrlEnvVar.value.match(/^\$\{([A-Z_][A-Z0-9_]*)/)?.[1]; - try { - const url = new URL(resolved); - const display = varName ? `${varName}: ${url.hostname}` : url.hostname; - parts.push(display); - } catch { - // Not a valid URL, show as-is with variable name - parts.push(varName ? `${varName}: ${resolved}` : resolved); - } - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - parts.push(baseUrlEnvVar.value); + parts.push(`${cli} CLI not detected`); } } - return parts.join(', '); - }, [agentType, isProfileAvailable, daemonEnv]); + return parts.join(' · '); + }, [isProfileAvailable]); - const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { - Modal.alert( - t('profiles.delete.title'), - t('profiles.delete.message', { name: profile.name }), - [ - { text: t('profiles.delete.cancel'), style: 'cancel' }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); // Use mutable setter for persistence - if (selectedProfileId === profile.id) { - setSelectedProfileId('anthropic'); // Default to Anthropic - } - } - } - ] - ); - }, [profiles, selectedProfileId, setProfiles]); - - // Handle machine and path selection callbacks - React.useEffect(() => { - let handler = (machineId: string) => { - let machine = storage.getState().machines[machineId]; - if (machine) { - setSelectedMachineId(machineId); - const bestPath = getRecentPathForMachine(machineId, recentMachinePaths); - setSelectedPath(bestPath); - } - }; - onMachineSelected = handler; - return () => { - onMachineSelected = () => { }; - }; - }, [recentMachinePaths]); - - React.useEffect(() => { - let handler = (savedProfile: AIBackendProfile) => { - // Handle saved profile from profile-edit screen - - // Check if this is a built-in profile being edited - const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === savedProfile.id); - let profileToSave = savedProfile; - - // For built-in profiles, create a new custom profile instead of modifying the built-in - if (isBuiltIn) { - profileToSave = { - ...savedProfile, - id: randomUUID(), // Generate new UUID for custom profile - isBuiltIn: false, - }; - } + const handleMachineClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); - const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); - let updatedProfiles: AIBackendProfile[]; + const handleProfileClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId]); - if (existingIndex >= 0) { - // Update existing profile - updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profileToSave; - } else { - // Add new profile - updatedProfiles = [...profiles, profileToSave]; + const handleAgentClick = React.useCallback(() => { + if (useProfiles && selectedProfileId !== null) { + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + const supportedAgents = profile + ? (Object.entries(profile.compatibility) as Array<[string, boolean]>) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini) + : []; + + if (supportedAgents.length <= 1) { + Modal.alert( + 'AI Backend', + 'AI backend is selected by your profile. To change it, select a different profile.', + [ + { text: t('common.ok'), style: 'cancel' }, + { text: 'Change Profile', onPress: handleProfileClick }, + ], + ); + return; } - setProfiles(updatedProfiles); // Use mutable setter for persistence - setSelectedProfileId(profileToSave.id); - }; - onProfileSaved = handler; - return () => { - onProfileSaved = () => { }; - }; - }, [profiles, setProfiles]); + const currentIndex = supportedAgents.indexOf(agentType); + const nextIndex = (currentIndex + 1) % supportedAgents.length; + setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? 'claude'); + return; + } - const handleMachineClick = React.useCallback(() => { - router.push('/new/pick/machine'); - }, [router]); + handleAgentCycle(); + }, [agentType, allowGemini, handleAgentCycle, handleProfileClick, profileMap, selectedProfileId, setAgentType, useProfiles]); const handlePathClick = React.useCallback(() => { if (selectedMachineId) { @@ -995,6 +1152,33 @@ function NewSessionWizard() { } }, [selectedMachineId, selectedPath, router]); + const selectedProfileForEnvVars = React.useMemo(() => { + if (!useProfiles || !selectedProfileId) return null; + return profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId) || null; + }, [profileMap, selectedProfileId, useProfiles]); + + const selectedProfileEnvVars = React.useMemo(() => { + if (!selectedProfileForEnvVars) return {}; + return transformProfileToEnvironmentVars(selectedProfileForEnvVars) ?? {}; + }, [selectedProfileForEnvVars]); + + const selectedProfileEnvVarsCount = React.useMemo(() => { + return Object.keys(selectedProfileEnvVars).length; + }, [selectedProfileEnvVars]); + + const handleEnvVarsClick = React.useCallback(() => { + if (!selectedProfileForEnvVars) return; + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: selectedProfileEnvVars, + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: selectedProfileForEnvVars.name, + }, + }); + }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]); + // Session creation const handleCreateSession = React.useCallback(async () => { if (!selectedMachineId) { @@ -1030,20 +1214,26 @@ function NewSessionWizard() { // Save settings const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10); - sync.applySettings({ + const profilesActive = useProfiles; + + // Keep prod session creation behavior unchanged: + // only persist/apply profiles & model when an explicit opt-in flag is enabled. + const settingsUpdate: Parameters[0] = { recentMachinePaths: updatedPaths, lastUsedAgent: agentType, - lastUsedProfile: selectedProfileId, lastUsedPermissionMode: permissionMode, - lastUsedModelMode: modelMode, - }); + }; + if (profilesActive) { + settingsUpdate.lastUsedProfile = selectedProfileId; + } + sync.applySettings(settingsUpdate); // Get environment variables from selected profile let environmentVariables = undefined; - if (selectedProfileId) { - const selectedProfile = profileMap.get(selectedProfileId); + if (profilesActive && selectedProfileId) { + const selectedProfile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); if (selectedProfile) { - environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); + environmentVariables = transformProfileToEnvironmentVars(selectedProfile); } } @@ -1052,6 +1242,7 @@ function NewSessionWizard() { directory: actualPath, approvedNewDirectoryCreation: true, agent: agentType, + profileId: profilesActive ? (selectedProfileId ?? '') : undefined, environmentVariables }); @@ -1093,9 +1284,24 @@ function NewSessionWizard() { Modal.alert(t('common.error'), errorMessage); setIsCreating(false); } - }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]); + }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); - const screenWidth = useWindowDimensions().width; + const showInlineClose = screenWidth < 520; + + const handleCloseModal = React.useCallback(() => { + // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. + // Fall back to home so the user always has an exit. + if (Platform.OS === 'web') { + if (typeof window !== 'undefined' && window.history.length > 1) { + router.back(); + } else { + router.replace('/'); + } + return; + } + + router.back(); + }, [router]); // Machine online status for AgentInput (DRY - reused in info box too) const connectionStatus = React.useMemo(() => { @@ -1118,6 +1324,20 @@ function NewSessionWizard() { }; }, [selectedMachine, selectedMachineId, cliAvailability, experimentsEnabled, theme]); + const persistDraftNow = React.useCallback(() => { + saveNewSessionDraft({ + input: sessionPrompt, + selectedMachineId, + selectedPath, + selectedProfileId: useProfiles ? selectedProfileId : null, + agentType, + permissionMode, + modelMode, + sessionType, + updatedAt: Date.now(), + }); + }, [agentType, modelMode, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + // Persist the current wizard state so it survives remounts and screen navigation // Uses debouncing to avoid excessive writes const draftSaveTimerRef = React.useRef | null>(null); @@ -1126,22 +1346,14 @@ function NewSessionWizard() { clearTimeout(draftSaveTimerRef.current); } draftSaveTimerRef.current = setTimeout(() => { - saveNewSessionDraft({ - input: sessionPrompt, - selectedMachineId, - selectedPath, - agentType, - permissionMode, - sessionType, - updatedAt: Date.now(), - }); + persistDraftNow(); }, 250); return () => { if (draftSaveTimerRef.current) { clearTimeout(draftSaveTimerRef.current); } }; - }, [sessionPrompt, selectedMachineId, selectedPath, agentType, permissionMode, sessionType]); + }, [persistDraftNow]); // ======================================================================== // CONTROL A: Simpler AgentInput-driven layout (flag OFF) @@ -1151,49 +1363,85 @@ function NewSessionWizard() { return ( - - {/* Session type selector only if experiments enabled */} - {experimentsEnabled && ( - 700 ? 16 : 8, marginBottom: 16 }}> - - - - - )} - - {/* AgentInput with inline chips - sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - []} - agentType={agentType} - onAgentClick={handleAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handlePermissionModeChange} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleMachineClick} - currentPath={selectedPath} - onPathClick={handlePathClick} - /> - - - + {showInlineClose && ( + + + + )} + + {/* Session type selector only if experiments enabled */} + {experimentsEnabled && ( + + + + + + + + )} + + {/* AgentInput with inline chips - sticky at bottom */} + + + + []} + agentType={agentType} + onAgentClick={handleAgentClick} + permissionMode={permissionMode} + onPermissionModeChange={handlePermissionModeChange} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleMachineClick} + currentPath={selectedPath} + onPathClick={handlePathClick} + contentPaddingHorizontal={0} + {...(useProfiles + ? { + profileId: selectedProfileId, + onProfileClick: handleProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } + : {})} + /> + + + + ); } @@ -1205,9 +1453,29 @@ function NewSessionWizard() { return ( + {showInlineClose && ( + + + + )} - 700 ? 16 : 8 } - ]}> + - - {/* CLI Detection Status Banner - shows after detection completes */} - {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - - - - - {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - - - - {connectionStatus.text} - - - - - {cliAvailability.claude ? '✓' : '✗'} - - - claude - - - - - {cliAvailability.codex ? '✓' : '✗'} - - - codex + + {/* CLI Detection Status Banner - shows after detection completes */} + {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( + + + + + + {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - {experimentsEnabled && ( - - {cliAvailability.gemini ? '✓' : '✗'} + + + {connectionStatus.text} + + + + + {cliAvailability.claude ? '✓' : '✗'} - - gemini + + claude - )} + + + {cliAvailability.codex ? '✓' : '✗'} + + + codex + + + {experimentsEnabled && ( + + + {cliAvailability.gemini ? '✓' : '✗'} + + + gemini + + + )} + )} - {/* Section 1: Profile Management */} - - 1. - - Choose AI Profile + {useProfiles && ( + <> + + + + Select AI Profile + + + + Select an AI profile to apply environment variables and defaults to your session. + + + {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( + + {isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setSelectedProfileId(null); + }} + rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} + showDivider={favoriteProfileItems.length > 0} + /> + )} + {favoriteProfileItems.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + { + if (!availability.available) return; + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + selectProfile(profile.id); + }} + rightElement={renderProfileRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + { + if (!availability.available) return; + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + selectProfile(profile.id); + }} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + {!isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setSelectedProfileId(null); + }} + rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + )} + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + { + if (!availability.available) return; + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + selectProfile(profile.id); + }} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + } + onPress={handleAddProfile} + showChevron={false} + showDivider={false} + /> + + + + + )} + + {/* Section: AI Backend */} + + + + + Select AI Backend + + - Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs. + {useProfiles && selectedProfileId + ? 'Limited by your selected profile and available CLIs on this machine.' + : 'Select which AI runs your session.'} {/* Missing CLI Installation Banners */} @@ -1434,7 +1862,7 @@ function NewSessionWizard() { )} - {selectedMachineId && cliAvailability.gemini === false && experimentsEnabled && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( + {selectedMachineId && cliAvailability.gemini === false && allowGemini && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( )} - {/* Custom profiles - show first */} - {profiles.map((profile) => { - const availability = isProfileAvailable(profile); - - return ( - availability.available && selectProfile(profile.id)} - disabled={!availability.available} - > - - - {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' : - profile.compatibility.claude ? '✳' : '꩜'} - - - - {profile.name} - - {getProfileSubtitle(profile)} - - - - {selectedProfileId === profile.id && ( - - )} - { - e.stopPropagation(); - handleDeleteProfile(profile); + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + {(() => { + const selectedProfile = useProfiles && selectedProfileId + ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) + : null; + + const options: Array<{ + key: 'claude' | 'codex' | 'gemini'; + title: string; + subtitle: string; + icon: React.ComponentProps['name']; + }> = [ + { key: 'claude', title: 'Claude', subtitle: 'Claude CLI', icon: 'sparkles-outline' }, + { key: 'codex', title: 'Codex', subtitle: 'Codex CLI', icon: 'terminal-outline' }, + ...(allowGemini ? [{ key: 'gemini' as const, title: 'Gemini', subtitle: 'Gemini CLI', icon: 'planet-outline' as const }] : []), + ]; + + return options.map((option, index) => { + const compatible = !selectedProfile || !!selectedProfile.compatibility?.[option.key]; + const cliOk = cliAvailability[option.key] !== false; + const disabledReason = !compatible + ? 'Not compatible with the selected profile.' + : !cliOk + ? `${option.title} CLI not detected on this machine.` + : null; + + const isSelected = agentType === option.key; + + return ( + } + selected={isSelected} + disabled={!!disabledReason} + onPress={() => { + if (disabledReason) { + Modal.alert( + 'AI Backend', + disabledReason, + compatible + ? [{ text: t('common.ok'), style: 'cancel' }] + : [ + { text: t('common.ok'), style: 'cancel' }, + ...(useProfiles && selectedProfileId ? [{ text: 'Change Profile', onPress: handleAgentInputProfileClick }] : []), + ], + ); + return; + } + setAgentType(option.key); }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - - - ); - })} - - {/* Built-in profiles - show after custom */} - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; - - const availability = isProfileAvailable(profile); - - return ( - availability.available && selectProfile(profile.id)} - disabled={!availability.available} - > - - - {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' : - profile.compatibility.claude ? '✳' : '꩜'} - - - - {profile.name} - - {getProfileSubtitle(profile)} - - - - {selectedProfileId === profile.id && ( - - )} - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - - - ); - })} - - {/* Profile Action Buttons */} - - - - - Add - - - selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - > - - - Duplicate - - - selectedProfile && !selectedProfile.isBuiltIn && handleDeleteProfile(selectedProfile)} - disabled={!selectedProfile || selectedProfile.isBuiltIn} - > - - - Delete - - - + rightElement={( + + + + )} + showChevron={false} + showDivider={index < options.length - 1} + /> + ); + }); + })()} + - {/* Section 2: Machine Selection */} - - - 2. - - Select Machine + {modelOptions.length > 0 && ( + + + + + Select AI Model + + + + Choose the model used by this session. + + + {modelOptions.map((option, index, options) => { + const isSelected = modelMode === option.value; + return ( + } + showChevron={false} + selected={isSelected} + onPress={() => setModelMode(option.value)} + rightElement={( + + + + )} + showDivider={index < options.length - 1} + /> + ); + })} + - + )} - - - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - - ), - getRecentItemIcon: (machine) => ( - - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: true, - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={machines.filter(m => favoriteMachines.includes(m.id))} - selectedItem={selectedMachine || null} - onSelect={(machine) => { - setSelectedMachineId(machine.id); - const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); - setSelectedPath(bestPath); - }} - onToggleFavorite={(machine) => { - const isInFavorites = favoriteMachines.includes(machine.id); - if (isInFavorites) { - setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); - } else { - setFavoriteMachines([...favoriteMachines, machine.id]); - } - }} - /> - + - {/* Section 3: Working Directory */} - - - 3. - - Select Working Directory - - + {/* Section 2: Machine Selection */} + + + + Select Machine + + + + Choose where this session runs. + - - config={{ - getItemId: (path) => path, - getItemTitle: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), - getItemSubtitle: undefined, - getItemIcon: (path) => ( - - ), - getRecentItemIcon: (path) => ( - - ), - getFavoriteItemIcon: (path) => ( - - ), - canRemoveFavorite: (path) => path !== selectedMachine?.metadata?.homeDir, - formatForDisplay: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), - parseFromDisplay: (text) => { - if (selectedMachine?.metadata?.homeDir) { - return resolveAbsolutePath(text, selectedMachine.metadata.homeDir); + { + setSelectedMachineId(machine.id); + const bestPath = getRecentPathForMachine(machine.id); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); } - return null; - }, - filterItem: (path, searchText) => { - const displayPath = formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir); - return displayPath.toLowerCase().includes(searchText.toLowerCase()); - }, - searchPlaceholder: "Type to filter or enter custom directory...", - recentSectionTitle: "Recent Directories", - favoritesSectionTitle: "Favorite Directories", - noItemsMessage: "No recent directories", - showFavorites: true, - showRecent: true, - showSearch: true, - allowCustomInput: true, - compactItems: true, - }} - items={recentPaths} - recentItems={recentPaths} - favoriteItems={(() => { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - // Include home directory plus user favorites - return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })()} - selectedItem={selectedPath} - onSelect={(path) => { - setSelectedPath(path); - }} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - // Don't allow removing home directory (handled by canRemoveFavorite) - if (path === homeDir) return; - - // Convert to relative format for storage - const relativePath = formatPathRelativeToHome(path, homeDir); - - // Check if already in favorites - const isInFavorites = favoriteDirectories.some(fav => - resolveAbsolutePath(fav, homeDir) === path - ); - - if (isInFavorites) { - // Remove from favorites - setFavoriteDirectories(favoriteDirectories.filter(fav => - resolveAbsolutePath(fav, homeDir) !== path - )); - } else { - // Add to favorites - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} - context={{ homeDir: selectedMachine?.metadata?.homeDir }} + }} /> - {/* Section 4: Permission Mode */} - - 4. Permission Mode + {/* Section 3: Working Directory */} + + + + Select Working Directory + + + + Pick the folder used for commands and context. + + + + - - {(agentType === 'codex' - ? [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: 'Read Only', description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: 'Safe YOLO', description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: 'YOLO', description: 'Full access, skip permissions', icon: 'flash-outline' }, - ] - : [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] - ).map((option, index, array) => ( - + + + Select Permission Mode + + + + Control how strictly actions require approval. + + + {(agentType === 'codex' || agentType === 'gemini' + ? [ + { value: 'default' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.default' : 'agentInput.geminiPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.readOnly' : 'agentInput.geminiPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.safeYolo' : 'agentInput.geminiPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.yolo' : 'agentInput.geminiPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: t('agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits' as PermissionMode, label: t('agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: t('agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: t('agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( + } rightElement={permissionMode === option.value ? ( - ) : null} - onPress={() => setPermissionMode(option.value)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - style={permissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: Platform.select({ ios: 10, default: 16 }), - } : undefined} - /> - ))} - - - {/* Section 5: Advanced Options (Collapsible) */} - {experimentsEnabled && ( - <> - setShowAdvanced(!showAdvanced)} - > - Advanced Options - - - - {showAdvanced && ( - - - - )} - - )} - + ) : null} + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> + ))} + + + + + {/* Section 5: Session Type */} + + + + Select Session Type + + + + Choose a simple session or one tied to a Git worktree. + + + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + + + + - - {/* Section 5: AgentInput - Sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - []} - agentType={agentType} - onAgentClick={handleAgentInputAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handleAgentInputPermissionChange} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleAgentInputMachineClick} - currentPath={selectedPath} - onPathClick={handleAgentInputPathClick} - profileId={selectedProfileId} - onProfileClick={handleAgentInputProfileClick} - /> - - + + {/* AgentInput - Sticky at bottom */} + + + + []} + agentType={agentType} + onAgentClick={handleAgentInputAgentClick} + permissionMode={permissionMode} + onPermissionClick={handleAgentInputPermissionClick} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleAgentInputMachineClick} + currentPath={selectedPath} + onPathClick={handleAgentInputPathClick} + contentPaddingHorizontal={0} + {...(useProfiles ? { + profileId: selectedProfileId, + onProfileClick: handleAgentInputProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + + + ); diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c02580e8d..8c5acc3a0 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -1,36 +1,14 @@ import React from 'react'; import { View, Text } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions } from '@/sync/storage'; -import { Ionicons } from '@expo/vector-icons'; -import { isMachineOnline } from '@/utils/machineUtils'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; -import { SearchableListSelector } from '@/components/SearchableListSelector'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, -})); - -export default function MachinePickerScreen() { +export default React.memo(function MachinePickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -38,6 +16,8 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -50,7 +30,8 @@ export default function MachinePickerScreen() { const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ - ...CommonActions.setParams({ machineId }), + type: 'SET_PARAMS', + payload: { params: { machineId } }, source: previousRoute.key, } as never); } @@ -65,14 +46,15 @@ export default function MachinePickerScreen() { sessions?.forEach(item => { if (typeof item === 'string') return; // Skip section headers - const session = item as any; - if (session.metadata?.machineId && !machineIds.has(session.metadata.machineId)) { - const machine = machines.find(m => m.id === session.metadata.machineId); + const session = item; + const machineId = session.metadata?.machineId; + if (machineId && !machineIds.has(machineId)) { + const machine = machines.find(m => m.id === machineId); if (machine) { machineIds.add(machine.id); machinesWithTimestamp.push({ machine, - timestamp: session.updatedAt || session.createdAt + timestamp: session.updatedAt || session.createdAt, }); } } @@ -89,14 +71,14 @@ export default function MachinePickerScreen() { - No machines available + {t('newSession.noMachinesFound')} @@ -109,68 +91,47 @@ export default function MachinePickerScreen() { - - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - - ), - getRecentItemIcon: (machine) => ( - - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: false, // Simpler modal experience - no favorites in modal - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={[]} - selectedItem={selectedMachine} + favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} + showFavorites={true} + showSearch={useMachinePickerSearch} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + setFavoriteMachines(isInFavorites + ? favoriteMachines.filter(id => id !== machine.id) + : [...favoriteMachines, machine.id] + ); + }} /> ); -} \ No newline at end of file +}); + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.groupped.background, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, +})); diff --git a/sources/app/(app)/new/pick/path.test.tsx b/sources/app/(app)/new/pick/path.test.tsx new file mode 100644 index 000000000..4f181a4a5 --- /dev/null +++ b/sources/app/(app)/new/pick/path.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import renderer from 'react-test-renderer'; + +let lastPathSelectorProps: any = null; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', +})); + +vi.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useRouter: () => ({ back: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }) }), + useLocalSearchParams: () => ({ machineId: 'm1', selectedPath: '/tmp' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } } }), + StyleSheet: { create: (fn: any) => fn({ colors: { textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } }) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/ItemList', () => ({ + ItemList: ({ children }: any) => <>{children}, +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 900 }, +})); + +vi.mock('@/components/SearchHeader', () => ({ + SearchHeader: () => null, +})); + +vi.mock('@/components/newSession/PathSelector', () => ({ + PathSelector: (props: any) => { + lastPathSelectorProps = props; + return null; + }, +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => [{ id: 'm1', metadata: { homeDir: '/home' } }], + useSessions: () => [], + useSetting: (key: string) => { + if (key === 'recentMachinePaths') return []; + if (key === 'usePathPickerSearch') return false; + return null; + }, + useSettingMutable: (key: string) => { + if (key === 'favoriteDirectories') return [undefined, vi.fn()]; + return [null, vi.fn()]; + }, +})); + +describe('PathPickerScreen', () => { + beforeEach(() => { + lastPathSelectorProps = null; + }); + + it('defaults favoriteDirectories to an empty array when setting is undefined', async () => { + const PathPickerScreen = (await import('./path')).default; + renderer.create(); + + expect(lastPathSelectorProps).toBeTruthy(); + expect(lastPathSelectorProps.favoriteDirectories).toEqual([]); + expect(typeof lastPathSelectorProps.onChangeFavoriteDirectories).toBe('function'); + }); +}); + diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b0214d6c6..3636143d7 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -1,64 +1,17 @@ -import React, { useState, useMemo, useRef } from 'react'; -import { View, Text, ScrollView, Pressable } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; +import React, { useState, useMemo } from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions, useSetting } from '@/sync/storage'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { layout } from '@/components/layout'; import { t } from '@/text'; -import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; - -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - alignItems: 'center', - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, - pathInputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 16, - }, - pathInput: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: 10, - paddingHorizontal: 12, - minHeight: 36, - position: 'relative', - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, -})); +import { ItemList } from '@/components/ItemList'; +import { layout } from '@/components/layout'; +import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; -export default function PathPickerScreen() { +export default React.memo(function PathPickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -66,10 +19,13 @@ export default function PathPickerScreen() { const params = useLocalSearchParams<{ machineId?: string; selectedPath?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const inputRef = useRef(null); const recentMachinePaths = useSetting('recentMachinePaths'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); + const [favoriteDirectoriesRaw, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const favoriteDirectories = favoriteDirectoriesRaw ?? []; const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [pathSearchQuery, setPathSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -121,14 +77,16 @@ export default function PathPickerScreen() { }, [sessions, params.machineId, recentMachinePaths]); - const handleSelectPath = React.useCallback(() => { - const pathToUse = customPath.trim() || machine?.metadata?.homeDir || '/home'; + const handleSelectPath = React.useCallback((pathOverride?: string) => { + const rawPath = typeof pathOverride === 'string' ? pathOverride : customPath; + const pathToUse = rawPath.trim() || machine?.metadata?.homeDir || '/home'; // Pass path back via navigation params (main's pattern, received by new/index.tsx) const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ - ...CommonActions.setParams({ path: pathToUse }), + type: 'SET_PARAMS', + payload: { params: { path: pathToUse } }, source: previousRoute.key, } as never); } @@ -141,11 +99,11 @@ export default function PathPickerScreen() { ( handleSelectPath()} disabled={!customPath.trim()} style={({ pressed }) => ({ marginRight: 16, @@ -162,13 +120,11 @@ export default function PathPickerScreen() { ) }} /> - + - - No machine selected - + {t('newSession.noMachineSelected')} - + ); } @@ -178,15 +134,15 @@ export default function PathPickerScreen() { ( - ({ - opacity: pressed ? 0.7 : 1, - padding: 4, + headerRight: () => ( + handleSelectPath()} + disabled={!customPath.trim()} + style={({ pressed }) => ({ + opacity: pressed ? 0.7 : 1, + padding: 4, })} > - - - - - - - - - - - - {recentPaths.length > 0 && ( - - {recentPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === recentPaths.length - 1; - - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={!isLast} - /> - ); - })} - - )} - - {recentPaths.length === 0 && ( - - {(() => { - const homeDir = machine.metadata?.homeDir || '/home'; - const suggestedPaths = [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop` - ]; - return suggestedPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < 3} - /> - ); - }); - })()} - - )} - - - + + {usePathPickerSearch && ( + + )} + + + + ); -} \ No newline at end of file +}); + +const stylesheet = StyleSheet.create((theme) => ({ + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, +})); diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..616857acb 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -9,48 +9,195 @@ import { t } from '@/text'; import { ProfileEditForm } from '@/components/ProfileEditForm'; import { AIBackendProfile } from '@/sync/settings'; import { layout } from '@/components/layout'; -import { callbacks } from '../index'; +import { useSettingMutable } from '@/sync/storage'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { Modal } from '@/modal'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; -export default function ProfileEditScreen() { +export default React.memo(function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); - const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ + profileId?: string | string[]; + cloneFromProfileId?: string | string[]; + profileData?: string | string[]; + machineId?: string | string[]; + }>(); + const profileIdParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const cloneFromProfileIdParam = Array.isArray(params.cloneFromProfileId) ? params.cloneFromProfileId[0] : params.cloneFromProfileId; + const profileDataParam = Array.isArray(params.profileData) ? params.profileData[0] : params.profileData; + const machineIdParam = Array.isArray(params.machineId) ? params.machineId[0] : params.machineId; const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [isDirty, setIsDirty] = React.useState(false); + const isDirtyRef = React.useRef(false); + const saveRef = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { - if (params.profileData) { + if (profileDataParam) { try { - return JSON.parse(decodeURIComponent(params.profileData)); + // Params may arrive already decoded (native) or URL-encoded (web / manual encodeURIComponent). + // Try raw JSON first, then fall back to decodeURIComponent. + try { + return JSON.parse(profileDataParam); + } catch { + return JSON.parse(decodeURIComponent(profileDataParam)); + } } catch (error) { console.error('Failed to parse profile data:', error); } } + const resolveById = (id: string) => profiles.find((p) => p.id === id) ?? getBuiltInProfile(id) ?? null; + + if (cloneFromProfileIdParam) { + const base = resolveById(cloneFromProfileIdParam); + if (base) { + return duplicateProfileForEdit(base, { copySuffix: t('profiles.copySuffix') }); + } + } + + if (profileIdParam) { + const existing = resolveById(profileIdParam); + if (existing) { + return existing; + } + } + // Return empty profile for new profile creation - return { - id: '', - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - }, [params.profileData]); + return createEmptyCustomProfile(); + }, [cloneFromProfileIdParam, profileDataParam, profileIdParam, profiles]); + + const confirmDiscard = React.useCallback(async () => { + const saveText = profile.isBuiltIn ? t('common.saveAs') : t('common.save'); + return promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), + { + title: t('common.discardChanges'), + message: t('common.unsavedChangesWarning'), + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), + }, + ); + }, [profile.isBuiltIn]); + + React.useEffect(() => { + const addListener = (navigation as any)?.addListener; + if (typeof addListener !== 'function') { + return; + } + + const subscription = addListener.call(navigation, 'beforeRemove', (e: any) => { + if (!isDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const decision = await confirmDiscard(); + if (decision === 'discard') { + isDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } else if (decision === 'save') { + saveRef.current?.(); + } + })(); + }); + + return () => subscription?.remove?.(); + }, [confirmDiscard, navigation]); const handleSave = (savedProfile: AIBackendProfile) => { - // Call the callback to notify wizard of saved profile - callbacks.onProfileSaved(savedProfile); - router.back(); - }; + if (!savedProfile.name || savedProfile.name.trim() === '') { + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return; + } + + const isBuiltIn = + savedProfile.isBuiltIn === true || + DEFAULT_PROFILES.some((bp) => bp.id === savedProfile.id) || + !!getBuiltInProfile(savedProfile.id); + + let profileToSave = savedProfile; + if (isBuiltIn) { + profileToSave = convertBuiltInProfileToCustom(savedProfile); + } + + // Duplicate name guard (same behavior as settings/profiles) + const isDuplicateName = profiles.some((p) => { + if (isBuiltIn) { + return p.name.trim() === profileToSave.name.trim(); + } + return p.id !== profileToSave.id && p.name.trim() === profileToSave.name.trim(); + }); + if (isDuplicateName) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return; + } - const handleCancel = () => { + const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id); + const isNewProfile = existingIndex < 0; + const updatedProfiles = existingIndex >= 0 + ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) + : [...profiles, profileToSave]; + + setProfiles(updatedProfiles); + + // Update last used profile for convenience in other screens. + if (isNewProfile) { + setLastUsedProfile(profileToSave.id); + // For newly created profiles (including "Save As" from a built-in profile), jump back to /new + // and pass the id through route params so it can be selected immediately. + // This avoids relying on intermediate picker screens to forward the selection. + isDirtyRef.current = false; + setIsDirty(false); + router.replace({ + pathname: '/new', + params: { profileId: profileToSave.id }, + } as any); + return; + } + + // Pass selection back to the /new screen via navigation params (unmount-safe). + const state = (navigation as any).getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + (navigation as any).dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: profileToSave.id } }, + source: previousRoute.key, + } as never); + } + // Prevent the unsaved-changes guard from triggering on successful save. + isDirtyRef.current = false; + setIsDirty(false); router.back(); }; + const handleCancel = React.useCallback(() => { + void (async () => { + if (!isDirtyRef.current) { + router.back(); + return; + } + const decision = await confirmDiscard(); + if (decision === 'discard') { + isDirtyRef.current = false; + router.back(); + } else if (decision === 'save') { + saveRef.current?.(); + } + })(); + }, [confirmDiscard, router]); + return ( ); -} +}); const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, - backgroundColor: theme.colors.surface, + backgroundColor: theme.colors.groupped.background, paddingTop: rt.insets.top, paddingBottom: rt.insets.bottom, }, diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx new file mode 100644 index 000000000..7e7a6bd3a --- /dev/null +++ b/sources/app/(app)/new/pick/profile.tsx @@ -0,0 +1,379 @@ +import React from 'react'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; +import { View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ItemList'; +import { useSetting, useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { AIBackendProfile } from '@/sync/settings'; +import { Modal } from '@/modal'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import { buildProfileActions } from '@/components/profileActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; + +export default React.memo(function ProfilePickerScreen() { + const { theme } = useUnistyles(); + const styles = stylesheet; + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); + const useProfiles = useSetting('useProfiles'); + const experimentsEnabled = useSetting('experiments'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); + + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const ignoreProfileRowPressRef = React.useRef(false); + + const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { + return ; + }, []); + + const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { + const parts: string[] = []; + if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); + if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); + if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); + return parts.length > 0 ? parts.join(' • ') : ''; + }, [experimentsEnabled]); + + const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { + const backend = getProfileBackendSubtitle(profile); + if (profile.isBuiltIn) { + const builtInLabel = t('profiles.builtIn'); + return backend ? `${builtInLabel} · ${backend}` : builtInLabel; + } + return backend; + }, [getProfileBackendSubtitle]); + + const setProfileParamAndClose = React.useCallback((profileId: string) => { + const state = navigation.getState(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId } }, + source: previousRoute.key, + } as never); + } + router.back(); + }, [navigation, router]); + + const handleProfileRowPress = React.useCallback((profileId: string) => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setProfileParamAndClose(profileId); + }, [setProfileParamAndClose]); + + React.useEffect(() => { + if (typeof profileId === 'string' && profileId.length > 0) { + setProfileParamAndClose(profileId); + } + }, [profileId, setProfileParamAndClose]); + + const openProfileCreate = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { machineId } : {}, + }); + }, [machineId, router]); + + const openProfileEdit = React.useCallback((profileId: string) => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { profileId, machineId } : { profileId }, + }); + }, [machineId, router]); + + const openProfileDuplicate = React.useCallback((cloneFromProfileId: string) => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { cloneFromProfileId, machineId } : { cloneFromProfileId }, + }); + }, [machineId, router]); + + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }, [favoriteProfileIds, setFavoriteProfileIds]); + + const handleAddProfile = React.useCallback(() => { + openProfileCreate(); + }, [openProfileCreate]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + // Only custom profiles live in `profiles` setting. + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedId === profile.id) { + setProfileParamAndClose(''); + } + }, + }, + ], + ); + }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); + + const renderProfileRowRightElement = React.useCallback( + (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: theme.colors.text, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => openProfileEdit(profile.id), + onDuplicate: () => openProfileDuplicate(profile.id), + onDelete: () => handleDeleteProfile(profile), + }); + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, + [ + handleDeleteProfile, + openProfileEdit, + openProfileDuplicate, + theme.colors.text, + theme.colors.textSecondary, + toggleFavoriteProfile, + ], + ); + + const renderDefaultEnvironmentRowRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavoriteProfile(''), + color: isFavorite ? theme.colors.text : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [isDefaultEnvironmentFavorite, theme.colors.text, theme.colors.textSecondary, toggleFavoriteProfile]); + + return ( + <> + + + + {!useProfiles ? ( + + } + showChevron={false} + /> + } + onPress={() => router.push('/settings/features')} + /> + + ) : ( + <> + {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( + + {isDefaultEnvironmentFavorite && ( + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} + showDivider={favoriteProfileItems.length > 0} + /> + )} + {favoriteProfileItems.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + {!isDefaultEnvironmentFavorite && ( + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + )} + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + + } + onPress={handleAddProfile} + showChevron={false} + /> + + + )} + + + ); +}); + +const stylesheet = StyleSheet.create(() => ({ + itemList: { + paddingTop: 0, + }, + rowRightElement: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + indicatorSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, + selectedIndicatorVisible: { + opacity: 1, + }, + selectedIndicatorHidden: { + opacity: 0, + }, +})); diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..bac2f1f5e 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -7,7 +7,7 @@ import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { Avatar } from '@/components/Avatar'; -import { useSession, useIsDataReady } from '@/sync/storage'; +import { useSession, useIsDataReady, useSetting } from '@/sync/storage'; import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId } from '@/utils/sessionUtils'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; @@ -20,6 +20,7 @@ import { CodeView } from '@/components/CodeView'; import { Session } from '@/sync/storageTypes'; import { useHappyAction } from '@/hooks/useHappyAction'; import { HappyError } from '@/utils/errors'; +import { getBuiltInProfile, getBuiltInProfileNameKey } from '@/sync/profileUtils'; // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -66,10 +67,27 @@ function SessionInfoContent({ session }: { session: Session }) { const devModeEnabled = __DEV__; const sessionName = getSessionName(session); const sessionStatus = useSessionStatus(session); - + const useProfiles = useSetting('useProfiles'); + const profiles = useSetting('profiles'); + // Check if CLI version is outdated const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION); + const profileLabel = React.useMemo(() => { + const profileId = session.metadata?.profileId; + if (profileId === null || profileId === '') return t('profiles.noProfile'); + if (typeof profileId !== 'string') return t('status.unknown'); + + const builtIn = getBuiltInProfile(profileId); + if (builtIn) { + const key = getBuiltInProfileNameKey(profileId); + return key ? t(key) : builtIn.name; + } + + const custom = profiles.find(p => p.id === profileId); + return custom?.name ?? t('status.unknown'); + }, [profiles, session.metadata?.profileId]); + const handleCopySessionId = useCallback(async () => { if (!session) return; try { @@ -198,10 +216,10 @@ function SessionInfoContent({ session }: { session: Session }) { )} - {/* Session Details */} - - + } onPress={handleCopySessionId} @@ -221,17 +239,17 @@ function SessionInfoContent({ session }: { session: Session }) { }} /> )} - } - showChevron={false} - /> - } - showChevron={false} + } + showChevron={false} + /> + } + showChevron={false} /> )} - { - const flavor = session.metadata.flavor || 'claude'; - if (flavor === 'claude') return 'Claude'; - if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; - if (flavor === 'gemini') return 'Gemini'; - return flavor; - })()} - icon={} - showChevron={false} - /> - {session.metadata.hostPid && ( - { + const flavor = session.metadata.flavor || 'claude'; + if (flavor === 'claude') return t('agentInput.agent.claude'); + if (flavor === 'gpt' || flavor === 'openai' || flavor === 'codex') return t('agentInput.agent.codex'); + if (flavor === 'gemini') return t('agentInput.agent.gemini'); + return flavor; + })()} + icon={} + showChevron={false} + /> + {useProfiles && session.metadata?.profileId !== undefined && ( + } + showChevron={false} + /> + )} + {session.metadata.hostPid && ( + } showChevron={false} /> diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index ac7261455..589e3e99b 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; @@ -7,13 +8,16 @@ import { useSettingMutable, useLocalSettingMutable } from '@/sync/storage'; import { Switch } from '@/components/Switch'; import { t } from '@/text'; -export default function FeaturesSettingsScreen() { +export default React.memo(function FeaturesSettingsScreen() { const [experiments, setExperiments] = useSettingMutable('experiments'); + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend'); const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled'); const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch'); return ( @@ -72,6 +76,34 @@ export default function FeaturesSettingsScreen() { } showChevron={false} /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={ + + } + showChevron={false} + /> {/* Web-only Features */} @@ -108,4 +140,4 @@ export default function FeaturesSettingsScreen() { )} ); -} +}); diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fa4522023..67b678450 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,25 +1,24 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { View, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSettingMutable } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; import { t } from '@/text'; -import { Modal as HappyModal } from '@/modal/ModalManager'; -import { layout } from '@/components/layout'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useWindowDimensions } from 'react-native'; +import { Modal } from '@/modal'; import { AIBackendProfile } from '@/sync/settings'; import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { ProfileEditForm } from '@/components/ProfileEditForm'; -import { randomUUID } from 'expo-crypto'; - -interface ProfileDisplay { - id: string; - name: string; - isBuiltIn: boolean; -} +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import { buildProfileActions } from '@/components/profileActions'; +import { Switch } from '@/components/Switch'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { useSetting } from '@/sync/storage'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -27,28 +26,25 @@ interface ProfileManagerProps { } // Profile utilities now imported from @/sync/profileUtils - -function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { - const { theme } = useUnistyles(); +const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); - const safeArea = useSafeAreaInsets(); - const screenWidth = useWindowDimensions().width; + const [isEditingDirty, setIsEditingDirty] = React.useState(false); + const isEditingDirtyRef = React.useRef(false); + const experimentsEnabled = useSetting('experiments'); + + React.useEffect(() => { + isEditingDirtyRef.current = isEditingDirty; + }, [isEditingDirty]); const handleAddProfile = () => { - setEditingProfile({ - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }); + setEditingProfile(createEmptyCustomProfile()); setShowAddForm(true); }; @@ -57,37 +53,55 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setShowAddForm(true); }; - const handleDeleteProfile = (profile: AIBackendProfile) => { - // Show confirmation dialog before deleting - Alert.alert( + const handleDuplicateProfile = (profile: AIBackendProfile) => { + setEditingProfile(duplicateProfileForEdit(profile, { copySuffix: t('profiles.copySuffix') })); + setShowAddForm(true); + }; + + const closeEditor = React.useCallback(() => { + setShowAddForm(false); + setEditingProfile(null); + setIsEditingDirty(false); + }, []); + + const requestCloseEditor = React.useCallback(() => { + void (async () => { + if (!isEditingDirtyRef.current) { + closeEditor(); + return; + } + const discard = await Modal.confirm( + t('common.discardChanges'), + t('common.unsavedChangesWarning'), + { destructive: true, confirmText: t('common.discard'), cancelText: t('common.keepEditing') }, + ); + if (discard) { + isEditingDirtyRef.current = false; + closeEditor(); + } + })(); + }, [closeEditor]); + + const handleDeleteProfile = async (profile: AIBackendProfile) => { + const confirmed = await Modal.confirm( t('profiles.delete.title'), t('profiles.delete.message', { name: profile.name }), - [ - { - text: t('profiles.delete.cancel'), - style: 'cancel', - }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); - - // Clear last used profile if it was deleted - if (lastUsedProfile === profile.id) { - setLastUsedProfile(null); - } - - // Notify parent if this was the selected profile - if (selectedProfileId === profile.id && onProfileSelect) { - onProfileSelect(null); - } - }, - }, - ], - { cancelable: true } + { cancelText: t('profiles.delete.cancel'), confirmText: t('profiles.delete.confirm'), destructive: true } ); + if (!confirmed) return; + + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + + // Clear last used profile if it was deleted + if (lastUsedProfile === profile.id) { + setLastUsedProfile(null); + } + + // Notify parent if this was the selected profile + if (selectedProfileId === profile.id && onProfileSelect) { + onProfileSelect(null); + } }; const handleSelectProfile = (profileId: string | null) => { @@ -110,9 +124,31 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setLastUsedProfile(profileId); }; + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const toggleFavoriteProfile = (profileId: string) => { + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }; + + const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { + const parts: string[] = []; + if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); + if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); + if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); + return parts.length > 0 ? parts.join(' • ') : ''; + }, [experimentsEnabled]); + const handleSaveProfile = (profile: AIBackendProfile) => { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { + Modal.alert(t('common.error'), t('profiles.nameRequired')); return; } @@ -121,16 +157,14 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr // For built-in profiles, create a new custom profile instead of modifying the built-in if (isBuiltIn) { - const newProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), // Generate new UUID for custom profile - }; + const newProfile = convertBuiltInProfileToCustom(profile); // Check for duplicate names (excluding the new profile) const isDuplicate = profiles.some(p => p.name.trim() === newProfile.name.trim() ); if (isDuplicate) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); return; } @@ -142,6 +176,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr p.id !== profile.id && p.name.trim() === profile.name.trim() ); if (isDuplicate) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); return; } @@ -151,7 +186,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr if (existingIndex >= 0) { // Update existing profile updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profile; + updatedProfiles[existingIndex] = { + ...profile, + updatedAt: Date.now(), + }; } else { // Add new profile updatedProfiles = [...profiles, profile]; @@ -160,257 +198,207 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setProfiles(updatedProfiles); } - setShowAddForm(false); - setEditingProfile(null); + closeEditor(); }; + if (!useProfiles) { + return ( + + + } + rightElement={ + + } + showChevron={false} + /> + + + ); + } + return ( - - 700 ? 16 : 8, - paddingBottom: safeArea.bottom + 100, - }} - > - - - {t('profiles.title')} - - - {/* None option - no profile */} - handleSelectProfile(null)} - > - - - - - - {t('profiles.noProfile')} - - - {t('profiles.noProfileDescription')} - - - {selectedProfileId === null && ( - - )} - + + + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> + ); + })} + + )} - {/* Built-in profiles */} - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> + ); + })} + + )} + + {nonFavoriteBuiltInProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + }); return ( - } onPress={() => handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || 'Default model'} - {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - - + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> ); })} + - {/* Custom profiles */} - {profiles.map((profile) => ( - handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || t('profiles.defaultModel')} - {profile.tmuxConfig?.sessionName && ` • tmux: ${profile.tmuxConfig.sessionName}`} - {profile.tmuxConfig?.tmpDir && ` • dir: ${profile.tmuxConfig.tmpDir}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} - > - - - - - ))} - - {/* Add profile button */} - + } onPress={handleAddProfile} - > - - - {t('profiles.addProfile')} - - - - + showChevron={false} + /> + + {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( - - + + { }}> { - setShowAddForm(false); - setEditingProfile(null); - }} + onCancel={requestCloseEditor} + onDirtyChange={setIsEditingDirty} /> - - + + )} ); -} +}); // ProfileEditForm now imported from @/components/ProfileEditForm @@ -428,9 +416,12 @@ const profileManagerStyles = StyleSheet.create((theme) => ({ }, modalContent: { width: '100%', - maxWidth: Math.min(layout.maxWidth, 600), + maxWidth: 600, maxHeight: '90%', + borderRadius: 16, + overflow: 'hidden', + backgroundColor: theme.colors.groupped.background, }, })); -export default ProfileManager; \ No newline at end of file +export default ProfileManager; diff --git a/sources/app/(app)/settings/voice/language.tsx b/sources/app/(app)/settings/voice/language.tsx index 74799de38..38ad5e0e8 100644 --- a/sources/app/(app)/settings/voice/language.tsx +++ b/sources/app/(app)/settings/voice/language.tsx @@ -1,17 +1,16 @@ import React, { useState, useMemo } from 'react'; -import { View, TextInput, FlatList } from 'react-native'; +import { FlatList } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; +import { SearchHeader } from '@/components/SearchHeader'; import { useSettingMutable } from '@/sync/storage'; -import { useUnistyles } from 'react-native-unistyles'; import { LANGUAGES, getLanguageDisplayName, type Language } from '@/constants/Languages'; import { t } from '@/text'; -export default function LanguageSelectionScreen() { - const { theme } = useUnistyles(); +export default React.memo(function LanguageSelectionScreen() { const router = useRouter(); const [voiceAssistantLanguage, setVoiceAssistantLanguage] = useSettingMutable('voiceAssistantLanguage'); const [searchQuery, setSearchQuery] = useState(''); @@ -37,52 +36,11 @@ export default function LanguageSelectionScreen() { return ( - {/* Search Header */} - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')} - style={{ marginLeft: 8 }} - /> - )} - - + {/* Language List */} ); -} +}); diff --git a/sources/auth/tokenStorage.test.ts b/sources/auth/tokenStorage.test.ts new file mode 100644 index 000000000..f0593e8d9 --- /dev/null +++ b/sources/auth/tokenStorage.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('react-native', () => ({ + Platform: { OS: 'web' }, +})); + +vi.mock('expo-secure-store', () => ({})); + +function installLocalStorage() { + const store = new Map(); + const getItem = vi.fn((key: string) => store.get(key) ?? null); + const setItem = vi.fn((key: string, value: string) => { + store.set(key, value); + }); + const removeItem = vi.fn((key: string) => { + store.delete(key); + }); + + Object.defineProperty(globalThis, 'localStorage', { + value: { getItem, setItem, removeItem }, + configurable: true, + }); + + return { store, getItem, setItem, removeItem }; +} + +describe('TokenStorage (web)', () => { + beforeEach(() => { + vi.resetModules(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when localStorage JSON is invalid', async () => { + const { setItem } = installLocalStorage(); + setItem('auth_credentials', '{not valid json'); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.getCredentials()).resolves.toBeNull(); + }); + + it('returns false when localStorage.setItem throws', async () => { + installLocalStorage(); + (globalThis.localStorage.setItem as any).mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.setCredentials({ token: 't', secret: 's' })).resolves.toBe(false); + }); + + it('returns false when localStorage.removeItem throws', async () => { + installLocalStorage(); + (globalThis.localStorage.removeItem as any).mockImplementation(() => { + throw new Error('SecurityError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.removeCredentials()).resolves.toBe(false); + }); + + it('calls localStorage.getItem at most once per getCredentials call', async () => { + const { getItem, setItem } = installLocalStorage(); + setItem('auth_credentials', JSON.stringify({ token: 't', secret: 's' })); + + const { TokenStorage } = await import('./tokenStorage'); + await TokenStorage.getCredentials(); + expect(getItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/sources/auth/tokenStorage.ts b/sources/auth/tokenStorage.ts index b69060ef9..a557a43aa 100644 --- a/sources/auth/tokenStorage.ts +++ b/sources/auth/tokenStorage.ts @@ -1,10 +1,17 @@ import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; const AUTH_KEY = 'auth_credentials'; +function getAuthKey(): string { + const scope = Platform.OS === 'web' ? null : readStorageScopeFromEnv(); + return scopedStorageId(AUTH_KEY, scope); +} + // Cache for synchronous access let credentialsCache: string | null = null; +let credentialsCacheKey: string | null = null; export interface AuthCredentials { token: string; @@ -13,13 +20,29 @@ export interface AuthCredentials { export const TokenStorage = { async getCredentials(): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - return localStorage.getItem(AUTH_KEY) ? JSON.parse(localStorage.getItem(AUTH_KEY)!) as AuthCredentials : null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as AuthCredentials; + } catch (error) { + console.error('Error getting credentials:', error); + return null; + } + } + if (credentialsCache && credentialsCacheKey === key) { + try { + return JSON.parse(credentialsCache) as AuthCredentials; + } catch { + // Ignore cache parse errors, fall through to secure store read. + } } try { - const stored = await SecureStore.getItemAsync(AUTH_KEY); + const stored = await SecureStore.getItemAsync(key); if (!stored) return null; credentialsCache = stored; // Update cache + credentialsCacheKey = key; return JSON.parse(stored) as AuthCredentials; } catch (error) { console.error('Error getting credentials:', error); @@ -28,14 +51,21 @@ export const TokenStorage = { }, async setCredentials(credentials: AuthCredentials): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.setItem(AUTH_KEY, JSON.stringify(credentials)); - return true; + try { + localStorage.setItem(key, JSON.stringify(credentials)); + return true; + } catch (error) { + console.error('Error setting credentials:', error); + return false; + } } try { const json = JSON.stringify(credentials); - await SecureStore.setItemAsync(AUTH_KEY, json); + await SecureStore.setItemAsync(key, json); credentialsCache = json; // Update cache + credentialsCacheKey = key; return true; } catch (error) { console.error('Error setting credentials:', error); @@ -44,17 +74,24 @@ export const TokenStorage = { }, async removeCredentials(): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.removeItem(AUTH_KEY); - return true; + try { + localStorage.removeItem(key); + return true; + } catch (error) { + console.error('Error removing credentials:', error); + return false; + } } try { - await SecureStore.deleteItemAsync(AUTH_KEY); + await SecureStore.deleteItemAsync(key); credentialsCache = null; // Clear cache + credentialsCacheKey = null; return true; } catch (error) { console.error('Error removing credentials:', error); return false; } }, -}; \ No newline at end of file +}; diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index a2481e38a..6ae21e859 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -1,11 +1,12 @@ import { Ionicons, Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, TouchableWithoutFeedback, Image as RNImage, Pressable } from 'react-native'; +import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Image as RNImage, Pressable } from 'react-native'; import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; -import { PermissionMode, ModelMode } from './PermissionModeSelector'; +import { normalizePermissionModeForAgentFlavor, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; import { hapticsLight, hapticsError } from './haptics'; import { Shaker, ShakeInstance } from './Shaker'; import { StatusDot } from './StatusDot'; @@ -35,6 +36,7 @@ interface AgentInputProps { isMicActive?: boolean; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; + onPermissionClick?: () => void; modelMode?: ModelMode; onModelModeChange?: (mode: ModelMode) => void; metadata?: Metadata | null; @@ -73,10 +75,19 @@ interface AgentInputProps { minHeight?: number; profileId?: string | null; onProfileClick?: () => void; + envVarsCount?: number; + onEnvVarsClick?: () => void; + contentPaddingHorizontal?: number; + panelStyle?: ViewStyle; } const MAX_CONTEXT_SIZE = 190000; +function truncateWithEllipsis(value: string, maxChars: number) { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}…`; +} + const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { alignItems: 'center', @@ -206,6 +217,9 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ fontSize: 11, ...Typography.default(), }, + statusDot: { + marginRight: 6, + }, permissionModeContainer: { flexDirection: 'column', alignItems: 'flex-end', @@ -223,15 +237,114 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ // Button styles actionButtonsContainer: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 0, }, + actionButtonsColumn: { + flexDirection: 'column', + flex: 1, + gap: 3, + }, + actionButtonsColumnNarrow: { + flexDirection: 'column', + flex: 1, + gap: 2, + }, + actionButtonsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + pathRow: { + flexDirection: 'row', + alignItems: 'center', + }, actionButtonsLeft: { flexDirection: 'row', - gap: 8, + columnGap: 6, + rowGap: 3, flex: 1, - overflow: 'hidden', + flexWrap: 'wrap', + overflow: 'visible', + }, + actionButtonsLeftNarrow: { + columnGap: 4, + }, + actionButtonsLeftNoFlex: { + flex: 0, + }, + actionChip: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + gap: 6, + }, + actionChipPressed: { + opacity: 0.7, + }, + actionChipText: { + fontSize: 13, + color: theme.colors.button.secondary.tint, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + overlayOptionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + }, + overlayOptionRowPressed: { + backgroundColor: theme.colors.surfacePressed, + }, + overlayRadioOuter: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + overlayRadioOuterSelected: { + borderColor: theme.colors.radio.active, + }, + overlayRadioOuterUnselected: { + borderColor: theme.colors.radio.inactive, + }, + overlayRadioInner: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.radio.dot, + }, + overlayOptionLabel: { + fontSize: 14, + color: theme.colors.text, + ...Typography.default(), + }, + overlayOptionLabelSelected: { + color: theme.colors.radio.active, + }, + overlayOptionLabelUnselected: { + color: theme.colors.text, + }, + overlayOptionDescription: { + fontSize: 11, + color: theme.colors.textSecondary, + ...Typography.default(), + }, + overlayEmptyText: { + fontSize: 13, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + ...Typography.default(), }, actionButton: { flexDirection: 'row', @@ -256,6 +369,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', flexShrink: 0, marginLeft: 8, + marginRight: 8, }, sendButtonActive: { backgroundColor: theme.colors.button.primary.background, @@ -300,14 +414,22 @@ export const AgentInput = React.memo(React.forwardRef 0; // Check if this is a Codex or Gemini session - // Use metadata.flavor for existing sessions, agentType prop for new sessions - const isCodex = props.metadata?.flavor === 'codex' || props.agentType === 'codex'; - const isGemini = props.metadata?.flavor === 'gemini' || props.agentType === 'gemini'; + const effectiveFlavor = props.metadata?.flavor ?? props.agentType; + const isCodex = effectiveFlavor === 'codex'; + const isGemini = effectiveFlavor === 'gemini'; + const modelOptions = React.useMemo(() => { + if (effectiveFlavor === 'claude' || effectiveFlavor === 'codex' || effectiveFlavor === 'gemini') { + return getModelOptionsForAgentType(effectiveFlavor); + } + return []; + }, [effectiveFlavor]); // Profile data const profiles = useSetting('profiles'); const currentProfile = React.useMemo(() => { - if (!props.profileId) return null; + if (props.profileId === undefined || props.profileId === null || props.profileId.trim() === '') { + return null; + } // Check custom profiles first const customProfile = profiles.find(p => p.id === props.profileId); if (customProfile) return customProfile; @@ -315,6 +437,25 @@ export const AgentInput = React.memo(React.forwardRef { + if (props.profileId === undefined) { + return null; + } + if (props.profileId === null || props.profileId.trim() === '') { + return t('profiles.noProfile'); + } + if (currentProfile) { + return currentProfile.name; + } + const shortId = props.profileId.length > 8 ? `${props.profileId.slice(0, 8)}…` : props.profileId; + return `${t('status.unknown')} (${shortId})`; + }, [props.profileId, currentProfile]); + + const profileIcon = React.useMemo(() => { + // Always show a stable "profile" icon so the chip reads as Profile selection (not "current provider"). + return 'person-circle-outline'; + }, []); + // Calculate context warning const contextWarning = props.usageData?.contextSize ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) @@ -339,7 +480,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('📝 Input state changed:', JSON.stringify(newState)); setInputState(newState); }, []); @@ -349,18 +489,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('🔍 Autocomplete Debug:', JSON.stringify({ - // value: props.value, - // inputState, - // activeWord, - // suggestionsCount: suggestions.length, - // selected, - // prefixes: props.autocompletePrefixes - // }, null, 2)); - // }, [props.value, inputState, activeWord, suggestions.length, selected]); - // Handle suggestion selection const handleSuggestionSelect = React.useCallback((index: number) => { if (!suggestions[index] || !inputRef.current) return; @@ -382,8 +510,6 @@ export const AgentInput = React.memo(React.forwardRef { + return normalizePermissionModeForAgentFlavor( + props.permissionMode ?? 'default', + isCodex ? 'codex' : isGemini ? 'gemini' : 'claude', + ); + }, [isCodex, isGemini, props.permissionMode]); + + const permissionChipLabel = React.useMemo(() => { + if (isCodex) { + return normalizedPermissionMode === 'default' + ? t('agentInput.codexPermissionMode.default') + : normalizedPermissionMode === 'read-only' + ? t('agentInput.codexPermissionMode.readOnly') + : normalizedPermissionMode === 'safe-yolo' + ? t('agentInput.codexPermissionMode.safeYolo') + : normalizedPermissionMode === 'yolo' + ? t('agentInput.codexPermissionMode.yolo') + : ''; + } + + if (isGemini) { + return normalizedPermissionMode === 'default' + ? t('agentInput.geminiPermissionMode.default') + : normalizedPermissionMode === 'read-only' + ? t('agentInput.geminiPermissionMode.readOnly') + : normalizedPermissionMode === 'safe-yolo' + ? t('agentInput.geminiPermissionMode.safeYolo') + : normalizedPermissionMode === 'yolo' + ? t('agentInput.geminiPermissionMode.yolo') + : ''; + } + + return normalizedPermissionMode === 'default' + ? t('agentInput.permissionMode.default') + : normalizedPermissionMode === 'acceptEdits' + ? t('agentInput.permissionMode.acceptEdits') + : normalizedPermissionMode === 'plan' + ? t('agentInput.permissionMode.plan') + : normalizedPermissionMode === 'bypassPermissions' + ? t('agentInput.permissionMode.bypassPermissions') + : ''; + }, [isCodex, isGemini, normalizedPermissionMode]); + // Handle settings button press const handleSettingsPress = React.useCallback(() => { hapticsLight(); setShowSettings(prev => !prev); }, []); + const showPermissionChip = Boolean(props.onPermissionModeChange || props.onPermissionClick); + // Handle settings selection const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { hapticsLight(); @@ -476,7 +647,9 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 16 : 8 } + { paddingHorizontal: props.contentPaddingHorizontal ?? (screenWidth > 700 ? 16 : 8) } ]}> - setShowSettings(false)}> - - + setShowSettings(false)} style={styles.overlayBackdrop} /> 700 ? 0 : 8 } @@ -556,44 +727,35 @@ export const AgentInput = React.memo(React.forwardRef handleSettingsSelect(mode)} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} > - + {isSelected && ( - + )} - + {config.label} @@ -602,96 +764,60 @@ export const AgentInput = React.memo(React.forwardRef {/* Divider */} - + {/* Model Section */} - - + + {t('agentInput.model.title')} - {isGemini ? ( - // Gemini model selector - (['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'] as const).map((model) => { - const modelConfig = { - 'gemini-2.5-pro': { label: 'Gemini 2.5 Pro', description: 'Most capable' }, - 'gemini-2.5-flash': { label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, - 'gemini-2.5-flash-lite': { label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, - }; - const config = modelConfig[model]; - const isSelected = props.modelMode === model; - + {modelOptions.length > 0 ? ( + modelOptions.map((option) => { + const isSelected = props.modelMode === option.value; return ( { hapticsLight(); - props.onModelModeChange?.(model); + props.onModelModeChange?.(option.value); }} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} > - + {isSelected && ( - + )} - - {config.label} + + {option.label} - - {config.description} + + {option.description} ); }) ) : ( - + {t('agentInput.model.configureInCli')} )} @@ -702,144 +828,67 @@ export const AgentInput = React.memo(React.forwardRef - + {(props.connectionStatus || contextWarning) && ( + + {props.connectionStatus && ( <> - - - - {props.connectionStatus.text} - - - {/* CLI Status - only shown when provided (wizard only) */} - {props.connectionStatus.cliStatus && ( - <> - - - {props.connectionStatus.cliStatus.claude ? '✓' : '✗'} - - - claude - - - - - {props.connectionStatus.cliStatus.codex ? '✓' : '✗'} - - - codex - - - {props.connectionStatus.cliStatus.gemini !== undefined && ( - - - {props.connectionStatus.cliStatus.gemini ? '✓' : '✗'} - - - gemini - - - )} - - )} + + + {props.connectionStatus.text} + )} {contextWarning && ( - + {props.connectionStatus ? '• ' : ''}{contextWarning.text} )} - + {props.permissionMode && ( - + {isCodex ? ( - props.permissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' + normalizedPermissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : + normalizedPermissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : + normalizedPermissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : + normalizedPermissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' ) : isGemini ? ( - props.permissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' + normalizedPermissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : + normalizedPermissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : + normalizedPermissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : + normalizedPermissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' ) : ( - props.permissionMode === 'default' ? t('agentInput.permissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' + normalizedPermissionMode === 'default' ? t('agentInput.permissionMode.default') : + normalizedPermissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : + normalizedPermissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : + normalizedPermissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' )} )} @@ -847,89 +896,8 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Box 1: Context Information (Machine + Path) - Only show if either exists */} - {(props.machineName !== undefined || props.currentPath) && ( - - {/* Machine chip */} - {props.machineName !== undefined && props.onMachineClick && ( - { - hapticsLight(); - props.onMachineClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} - - {/* Path chip */} - {props.currentPath && props.onPathClick && ( - { - hapticsLight(); - props.onPathClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.currentPath} - - - )} - - )} - {/* Box 2: Action Area (Input + Send) */} - + {/* Input field */} - + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - - + + + {/* Permission chip (popover in standard flow, scroll in wizard) */} + {showPermissionChip && ( + { + hapticsLight(); + if (props.onPermissionClick) { + props.onPermissionClick(); + return; + } + handleSettingsPress(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {permissionChipLabel} + + + )} - {/* Settings button */} - {props.onPermissionModeChange && ( - ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} - > - - - )} + {/* Profile selector button - FIRST */} + {props.onProfileClick && ( + { + hapticsLight(); + props.onProfileClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {profileLabel ?? t('profiles.noProfile')} + + + )} - {/* Profile selector button - FIRST */} - {props.profileId && props.onProfileClick && ( - { - hapticsLight(); - props.onProfileClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {currentProfile?.name || 'Select Profile'} - - - )} + {/* Env vars preview (standard flow) */} + {props.onEnvVarsClick && ( + { + hapticsLight(); + props.onEnvVarsClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} + + + )} - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( - { - hapticsLight(); - props.onAgentClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} - - - )} + {/* Agent selector button */} + {props.agentType && props.onAgentClick && ( + { + hapticsLight(); + props.onAgentClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.agentType === 'claude' + ? t('agentInput.agent.claude') + : props.agentType === 'codex' + ? t('agentInput.agent.codex') + : t('agentInput.agent.gemini')} + + + )} - {/* Abort button */} - {props.onAbort && ( - + {/* Machine selector button */} + {(props.machineName !== undefined) && props.onMachineClick && ( ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} + onPress={() => { + hapticsLight(); + props.onMachineClick?.(); + }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={handleAbortPress} - disabled={isAborting} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} > - {isAborting ? ( - - ) : ( - - )} + + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + - - )} + )} - {/* Git Status Badge */} - + {/* Abort button */} + {props.onAbort && ( + + [ + styles.actionButton, + p.pressed ? styles.actionButtonPressed : null, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleAbortPress} + disabled={isAborting} + > + {isAborting ? ( + + ) : ( + + )} + + + )} + + {/* Git Status Badge */} + {/* Send/Voice button - aligned with first row */} @@ -1096,13 +1093,10 @@ export const AgentInput = React.memo(React.forwardRef ({ - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - opacity: p.pressed ? 0.7 : 1, - })} + style={(p) => [ + styles.sendButtonInner, + p.pressed ? styles.sendButtonInnerPressed : null, + ]} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} onPress={() => { hapticsLight(); @@ -1132,10 +1126,7 @@ export const AgentInput = React.memo(React.forwardRef ) : ( @@ -1152,6 +1143,34 @@ export const AgentInput = React.memo(React.forwardRef + + {/* Row 2: Path selector (separate line to match pre-PR272 layout) */} + {props.currentPath && props.onPathClick && ( + + + { + hapticsLight(); + props.onPathClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.currentPath} + + + + + )} diff --git a/sources/components/CommandPalette/CommandPaletteProvider.tsx b/sources/components/CommandPalette/CommandPaletteProvider.tsx index 558241472..748250c28 100644 --- a/sources/components/CommandPalette/CommandPaletteProvider.tsx +++ b/sources/components/CommandPalette/CommandPaletteProvider.tsx @@ -121,7 +121,7 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode } return cmds; - }, [router, logout, sessions]); + }, [router, logout, sessions, navigateToSession]); const showCommandPalette = useCallback(() => { if (Platform.OS !== 'web' || !commandPaletteEnabled) return; @@ -131,11 +131,11 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode props: { commands, } - } as any); + }); }, [commands, commandPaletteEnabled]); // Set up global keyboard handler only if feature is enabled useGlobalKeyboard(commandPaletteEnabled ? showCommandPalette : () => {}); return <>{children}; -} \ No newline at end of file +} diff --git a/sources/components/EnvironmentVariableCard.test.ts b/sources/components/EnvironmentVariableCard.test.ts new file mode 100644 index 000000000..fa83f53d9 --- /dev/null +++ b/sources/components/EnvironmentVariableCard.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, + }, +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }), + }, +})); + +vi.mock('@/components/Switch', () => { + const React = require('react'); + return { + Switch: (props: unknown) => React.createElement('Switch', props), + }; +}); + +import { EnvironmentVariableCard } from './EnvironmentVariableCard'; + +describe('EnvironmentVariableCard', () => { + it('syncs remote-variable state when variable.value changes externally', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:-baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + expect(tree?.root.findByType('Switch' as any).props.value).toBe(true); + + act(() => { + tree?.update( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: 'literal' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + expect(tree?.root.findByType('Switch' as any).props.value).toBe(false); + }); + + it('adds a fallback operator when user enters a fallback for a template without one', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.('baz'); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR:-baz}'); + }); + + it('removes the operator when user clears the fallback value', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:=baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.(''); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR}'); + }); +}); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 2185e0b21..c5c5f45e6 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -1,19 +1,27 @@ import React from 'react'; -import { View, Text, TextInput, Pressable } from 'react-native'; +import { View, Text, TextInput, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; -import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { Switch } from '@/components/Switch'; +import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/envVarTemplate'; +import { t } from '@/text'; +import type { EnvPreviewSecretsPolicy, PreviewEnvValue } from '@/sync/ops'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; + index: number; machineId: string | null; + machineName?: string | null; + machineEnv?: Record; + machineEnvPolicy?: EnvPreviewSecretsPolicy | null; + isMachineEnvLoading?: boolean; expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) - onUpdate: (newValue: string) => void; - onDelete: () => void; - onDuplicate: () => void; + onUpdate: (index: number, newValue: string) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; } /** @@ -23,24 +31,15 @@ function parseVariableValue(value: string): { useRemoteVariable: boolean; remoteVariableName: string; defaultValue: string; + fallbackOperator: EnvVarTemplateOperator | null; } { - // Match: ${VARIABLE_NAME:-default_value} - const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); - if (matchWithFallback) { + const parsedTemplate = parseEnvVarTemplate(value); + if (parsedTemplate) { return { useRemoteVariable: true, - remoteVariableName: matchWithFallback[1], - defaultValue: matchWithFallback[2] - }; - } - - // Match: ${VARIABLE_NAME} (no fallback) - const matchNoFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); - if (matchNoFallback) { - return { - useRemoteVariable: true, - remoteVariableName: matchNoFallback[1], - defaultValue: '' + remoteVariableName: parsedTemplate.sourceVar, + defaultValue: parsedTemplate.fallback, + fallbackOperator: parsedTemplate.operator, }; } @@ -48,7 +47,8 @@ function parseVariableValue(value: string): { return { useRemoteVariable: false, remoteVariableName: '', - defaultValue: value + defaultValue: value, + fallbackOperator: null, }; } @@ -58,7 +58,12 @@ function parseVariableValue(value: string): { */ export function EnvironmentVariableCard({ variable, + index, machineId, + machineName, + machineEnv, + machineEnvPolicy = null, + isMachineEnvLoading = false, expectedValue, description, isSecret = false, @@ -67,68 +72,116 @@ export function EnvironmentVariableCard({ onDuplicate, }: EnvironmentVariableCardProps) { const { theme } = useUnistyles(); + const styles = stylesheet; // Parse current value - const parsed = parseVariableValue(variable.value); + const parsed = React.useMemo(() => parseVariableValue(variable.value), [variable.value]); const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); + const fallbackOperator = parsed.fallbackOperator; - // Query remote machine for variable value (only if checkbox enabled and not secret) - const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; - const { variables: remoteValues } = useEnvironmentVariables( - machineId, - shouldQueryRemote ? [remoteVariableName] : [] - ); + React.useEffect(() => { + setUseRemoteVariable(parsed.useRemoteVariable); + setRemoteVariableName(parsed.remoteVariableName); + setDefaultValue(parsed.defaultValue); + }, [parsed.defaultValue, parsed.remoteVariableName, parsed.useRemoteVariable]); - const remoteValue = remoteValues[remoteVariableName]; + const remoteEntry = remoteVariableName ? machineEnv?.[remoteVariableName] : undefined; + const remoteValue = remoteEntry?.value; + const hasFallback = defaultValue.trim() !== ''; + const computedOperator: EnvVarTemplateOperator | null = hasFallback ? (fallbackOperator ?? ':-') : null; + const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); + + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); // Update parent when local state changes React.useEffect(() => { const newValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) : defaultValue; if (newValue !== variable.value) { - onUpdate(newValue); + onUpdate(index, newValue); } - }, [useRemoteVariable, remoteVariableName, defaultValue, variable.value, onUpdate]); + }, [computedOperator, defaultValue, index, onUpdate, remoteVariableName, useRemoteVariable, variable.value]); // Determine status const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + const computedTemplateValue = + useRemoteVariable && remoteVariableName.trim() !== '' + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) + : defaultValue; + + const targetEntry = machineEnv?.[variable.name]; + const resolvedSessionValue = (() => { + // Prefer daemon-computed effective value for the target env var (matches spawn exactly). + if (machineId && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + return targetEntry.value ?? emptyValue; + } + if (targetEntry.display === 'hidden') { + return t('profiles.environmentVariables.preview.hiddenValue'); + } + return emptyValue; // unset + } + + // Fallback (no machine context / older daemon): best-effort preview. + if (isSecret) { + // If daemon policy is known and allows showing secrets, targetEntry would have handled it above. + // Otherwise, keep secrets hidden in UI. + if (useRemoteVariable && remoteVariableName) { + return t('profiles.environmentVariables.preview.secretValueHidden', { + value: formatEnvVarTemplate({ + sourceVar: remoteVariableName, + fallback: defaultValue !== '' ? '***' : '', + operator: computedOperator, + }), + }); + } + return defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue; + } + + if (useRemoteVariable && machineId && remoteEntry !== undefined) { + // Note: remoteEntry may be hidden/redacted by daemon policy. We do NOT treat hidden as missing. + if (remoteEntry.display === 'hidden') return t('profiles.environmentVariables.preview.hiddenValue'); + if (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') { + return hasFallback ? defaultValue : emptyValue; + } + return remoteValue; + } + + return computedTemplateValue || emptyValue; + })(); + return ( - + {/* Header row with variable name and action buttons */} - - + + {variable.name} {isSecret && ( - + )} - + onDelete(index)} > onDuplicate(index)} > @@ -137,108 +190,116 @@ export function EnvironmentVariableCard({ {/* Description */} {description && ( - + {description} )} - {/* Checkbox: First try copying variable from remote machine */} - setUseRemoteVariable(!useRemoteVariable)} - > - - {useRemoteVariable && ( - - )} - - - First try copying variable from remote machine: - - + {/* Value label */} + + {useRemoteVariable ? t('profiles.environmentVariables.card.fallbackValueLabel') : t('profiles.environmentVariables.card.valueLabel')} + - {/* Remote variable name input */} + {/* Value input */} - {/* Remote variable status */} + {/* Security message for secrets */} + {isSecret && (machineEnvPolicy === null || machineEnvPolicy === 'none') && ( + + {t('profiles.environmentVariables.card.secretNotRetrieved')} + + )} + + {/* Default override warning */} + {showDefaultOverrideWarning && !isSecret && ( + + {t('profiles.environmentVariables.card.overridingDefault', { expectedValue })} + + )} + + + + {/* Toggle: Use value from machine environment */} + + + {t('profiles.environmentVariables.card.useMachineEnvToggle')} + + + + + + {t('profiles.environmentVariables.card.resolvedOnSessionStart')} + + + {/* Source variable name input (only when enabled) */} + {useRemoteVariable && ( + <> + + {t('profiles.environmentVariables.card.sourceVariableLabel')} + + + setRemoteVariableName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + )} + + {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - - {remoteValue === undefined ? ( - - ⏳ Checking remote machine... + + {isMachineEnvLoading || remoteEntry === undefined ? ( + + {t('profiles.environmentVariables.card.checkingMachine', { machine: machineLabel })} - ) : remoteValue === null ? ( - - ✗ Value not found + ) : (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') ? ( + + {remoteValue === '' ? ( + hasFallback + ? t('profiles.environmentVariables.card.emptyOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.emptyOnMachine', { machine: machineLabel }) + ) : ( + hasFallback + ? t('profiles.environmentVariables.card.notFoundOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.notFoundOnMachine', { machine: machineLabel }) + )} ) : ( <> - - ✓ Value found: {remoteValue} + + {t('profiles.environmentVariables.card.valueFoundOnMachine', { machine: machineLabel })} {showRemoteDiffersWarning && ( - - ⚠️ Differs from documented value: {expectedValue} + + {t('profiles.environmentVariables.card.differsFromDocumented', { expectedValue })} )} @@ -246,91 +307,173 @@ export function EnvironmentVariableCard({ )} - {useRemoteVariable && !isSecret && !machineId && ( - - ℹ️ Select a machine to check if variable exists - - )} - - {/* Security message for secrets */} - {isSecret && ( - - 🔒 Secret value - not retrieved for security - - )} - - {/* Default value label */} - - Default value: - - - {/* Default value input */} - - - {/* Default override warning */} - {showDefaultOverrideWarning && !isSecret && ( - - ⚠️ Overriding documented default: {expectedValue} - - )} - {/* Session preview */} - - Session will receive: {variable.name} = { - isSecret - ? (useRemoteVariable && remoteVariableName - ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) - : (useRemoteVariable && remoteValue !== undefined && remoteValue !== null - ? remoteValue - : defaultValue || '(empty)') - } + + {t('profiles.environmentVariables.preview.sessionWillReceive', { + name: variable.name, + value: resolvedSessionValue ?? emptyValue, + })} ); } + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '100%', + backgroundColor: theme.colors.surface, + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + nameText: { + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + lockIcon: { + marginLeft: 4, + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.margins.md, + }, + secondaryText: { + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + descriptionText: { + color: theme.colors.textSecondary, + marginBottom: 8, + }, + labelText: { + color: theme.colors.textSecondary, + marginBottom: 4, + }, + valueInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 4, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + secretMessage: { + color: theme.colors.textSecondary, + marginBottom: 8, + fontStyle: 'italic', + }, + defaultOverrideWarning: { + color: theme.colors.textSecondary, + marginBottom: 8, + }, + divider: { + height: 1, + backgroundColor: theme.colors.divider, + marginVertical: 12, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 6, + }, + toggleLabelText: { + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + toggleLabel: { + flex: 1, + color: theme.colors.textSecondary, + }, + resolvedOnStartText: { + color: theme.colors.textSecondary, + marginBottom: 0, + }, + resolvedOnStartWithRemote: { + marginBottom: 10, + }, + sourceLabel: { + color: theme.colors.textSecondary, + marginBottom: 4, + }, + sourceInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 6, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + machineStatusContainer: { + marginBottom: 8, + }, + machineStatusLoading: { + color: theme.colors.textSecondary, + fontStyle: 'italic', + }, + machineStatusWarning: { + color: theme.colors.warning, + }, + machineStatusSuccess: { + color: theme.colors.success, + }, + machineStatusDiffers: { + color: theme.colors.textSecondary, + marginTop: 2, + }, + sessionPreview: { + color: theme.colors.textSecondary, + marginTop: 4, + }, +})); diff --git a/sources/components/EnvironmentVariablesList.test.ts b/sources/components/EnvironmentVariablesList.test.ts new file mode 100644 index 000000000..0385756c6 --- /dev/null +++ b/sources/components/EnvironmentVariablesList.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; +import type { ProfileDocumentation } from '@/sync/profileUtils'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, + }, +})); + +const useEnvironmentVariablesMock = vi.fn((_machineId: any, _refs: any, _options?: any) => ({ + variables: {}, + meta: {}, + policy: null, + isPreviewEnvSupported: false, + isLoading: false, +})); + +vi.mock('@/hooks/useEnvironmentVariables', () => ({ + useEnvironmentVariables: (machineId: any, refs: any, options?: any) => useEnvironmentVariablesMock(machineId, refs, options), +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }), + }, +})); + +vi.mock('@/components/Item', () => { + const React = require('react'); + return { + Item: (props: unknown) => React.createElement('Item', props), + }; +}); + +vi.mock('./EnvironmentVariableCard', () => { + const React = require('react'); + return { + EnvironmentVariableCard: (props: unknown) => React.createElement('EnvironmentVariableCard', props), + }; +}); + +import { EnvironmentVariablesList } from './EnvironmentVariablesList'; + +describe('EnvironmentVariablesList', () => { + beforeEach(() => { + useEnvironmentVariablesMock.mockClear(); + }); + + it('marks documented secret refs as sensitive hints (daemon-controlled disclosure)', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret but name is not secret-like', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + act(() => { + renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [ + { name: 'FOO', value: '${MAGIC}' }, + { name: 'BAR', value: '${HOME}' }, + ], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('FOO'); + expect(keys).toContain('BAR'); + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(options?.sensitiveHints?.MAGIC).toBe(true); + }); + + it('treats a documented-secret variable name as secret even when its value references another var', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + let tree: ReturnType | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [{ name: 'MAGIC', value: '${HOME}' }], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(options?.sensitiveHints?.MAGIC).toBe(true); + expect(options?.sensitiveHints?.HOME).toBe(true); + + const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.props.isSecret).toBe(true); + expect(cards?.[0]?.props.expectedValue).toBe('***'); + }); +}); diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index e42e61415..d52c132d9 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -1,18 +1,26 @@ import React from 'react'; -import { View, Text, Pressable, TextInput } from 'react-native'; +import { View, Text, Pressable, TextInput, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { EnvironmentVariableCard } from './EnvironmentVariableCard'; import type { ProfileDocumentation } from '@/sync/profileUtils'; +import { Item } from '@/components/Item'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string }>; machineId: string | null; + machineName?: string | null; profileDocs?: ProfileDocumentation | null; onChange: (newVariables: Array<{ name: string; value: string }>) => void; } +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + /** * Complete environment variables section with title, add button, and editable cards * Matches profile list pattern from index.tsx:1159-1308 @@ -20,10 +28,77 @@ export interface EnvironmentVariablesListProps { export function EnvironmentVariablesList({ environmentVariables, machineId, + machineName, profileDocs, onChange, }: EnvironmentVariablesListProps) { const { theme } = useUnistyles(); + const styles = stylesheet; + + const extractVarRefsFromValue = React.useCallback((value: string): string[] => { + const refs: string[] = []; + if (!value) return refs; + let match: RegExpExecArray | null; + // Reset regex state defensively (global regex). + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; + }, []); + + const documentedSecretNames = React.useMemo(() => { + if (!profileDocs) return new Set(); + + return new Set( + profileDocs.environmentVariables + .filter((envVar) => envVar.isSecret) + .map((envVar) => envVar.name), + ); + }, [profileDocs]); + + const { keysToQuery, extraEnv, sensitiveHints } = React.useMemo(() => { + const keys = new Set(); + const env: Record = {}; + const hints: Record = {}; + + const isSecretName = (name: string) => + documentedSecretNames.has(name) || SECRET_NAME_REGEX.test(name); + + environmentVariables.forEach((envVar) => { + keys.add(envVar.name); + env[envVar.name] = envVar.value; + + const valueRefs = extractVarRefsFromValue(envVar.value); + valueRefs.forEach((ref) => keys.add(ref)); + + // Mark sensitivity for both the target var and any referenced vars. + const isSensitive = isSecretName(envVar.name) || valueRefs.some(isSecretName); + if (isSensitive) { + hints[envVar.name] = true; + valueRefs.forEach((ref) => { hints[ref] = true; }); + } else { + // Still mark direct secret-like names as sensitive, even without docs. + if (SECRET_NAME_REGEX.test(envVar.name)) hints[envVar.name] = true; + valueRefs.forEach((ref) => { + if (SECRET_NAME_REGEX.test(ref)) hints[ref] = true; + }); + } + }); + + return { + keysToQuery: Array.from(keys), + extraEnv: env, + sensitiveHints: hints, + }; + }, [documentedSecretNames, environmentVariables, extractVarRefsFromValue]); + + const { meta: machineEnv, isLoading: isMachineEnvLoading, policy: machineEnvPolicy } = useEnvironmentVariables( + machineId, + keysToQuery, + { extraEnv, sensitiveHints }, + ); // Add variable inline form state const [showAddForm, setShowAddForm] = React.useState(false); @@ -42,12 +117,6 @@ export function EnvironmentVariablesList({ }; }, [profileDocs]); - // Extract variable name from value (for matching documentation) - const extractVarNameFromValue = React.useCallback((value: string): string | null => { - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/); - return match ? match[1] : null; - }, []); - const handleUpdateVariable = React.useCallback((index: number, newValue: string) => { const updated = [...environmentVariables]; updated[index] = { ...updated[index], value: newValue }; @@ -76,20 +145,29 @@ export function EnvironmentVariablesList({ }, [environmentVariables, onChange]); const handleAddVariable = React.useCallback(() => { - if (!newVarName.trim()) return; + const normalizedName = newVarName.trim().toUpperCase(); + if (!normalizedName) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.nameRequired')); + return; + } // Validate variable name format - if (!/^[A-Z_][A-Z0-9_]*$/.test(newVarName.trim())) { + if (!/^[A-Z_][A-Z0-9_]*$/.test(normalizedName)) { + Modal.alert( + t('common.error'), + t('profiles.environmentVariables.validation.invalidNameFormat'), + ); return; } // Check for duplicates - if (environmentVariables.some(v => v.name === newVarName.trim())) { + if (environmentVariables.some(v => v.name === normalizedName)) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.duplicateName')); return; } onChange([...environmentVariables, { - name: newVarName.trim(), + name: normalizedName, value: newVarValue.trim() || '' }]); @@ -97,162 +175,194 @@ export function EnvironmentVariablesList({ setNewVarName(''); setNewVarValue(''); setShowAddForm(false); - }, [newVarName, newVarValue, environmentVariables, onChange]); + }, [environmentVariables, newVarName, newVarValue, onChange]); return ( - - {/* Section header */} - - Environment Variables - - - {/* Add Variable Button */} - setShowAddForm(true)} - > - - - Add Variable + + + + {t('profiles.environmentVariables.title')} - - - {/* Add variable inline form */} - {showAddForm && ( - - - - - { - setShowAddForm(false); - setNewVarName(''); - setNewVarValue(''); - }} - > - - Cancel - - + + + {environmentVariables.length > 0 && ( + + {environmentVariables.map((envVar, index) => { + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0] ?? null; + const primaryDocs = getDocumentation(envVar.name); + const refDocs = primaryRef ? getDocumentation(primaryRef) : undefined; + const isSecret = + primaryDocs.isSecret || + refDocs?.isSecret || + SECRET_NAME_REGEX.test(envVar.name) || + refs.some((ref) => SECRET_NAME_REGEX.test(ref)); + const expectedValue = primaryDocs.expectedValue ?? refDocs?.expectedValue; + const description = primaryDocs.description ?? refDocs?.description; + + return ( + + ); + })} + + )} + + + + } + showChevron={false} + onPress={() => { + if (showAddForm) { + setShowAddForm(false); + setNewVarName(''); + setNewVarValue(''); + } else { + setShowAddForm(true); + } + }} + /> + + {showAddForm && ( + + + setNewVarName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + + + + + [ + styles.addButton, + { opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1 }, + ]} > - - Add + + {t('common.add')} - - )} - - {/* Variable cards */} - {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); - - // Auto-detect secrets if not explicitly documented - const isSecret = docs.isSecret || /TOKEN|KEY|SECRET|AUTH/i.test(envVar.name) || /TOKEN|KEY|SECRET|AUTH/i.test(varNameFromValue || ''); - - return ( - handleUpdateVariable(index, newValue)} - onDelete={() => handleDeleteVariable(index)} - onDuplicate={() => handleDuplicateVariable(index)} - /> - ); - })} + )} + ); } + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + marginBottom: 16, + }, + titleContainer: { + paddingTop: Platform.select({ ios: 35, default: 16 }), + paddingBottom: Platform.select({ ios: 6, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + titleText: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: '500', + }, + envVarListContainer: { + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + }, + addContainer: { + backgroundColor: theme.colors.surface, + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + borderRadius: Platform.select({ ios: 10, default: 16 }), + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + addFormContainer: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + addInputRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + marginBottom: 8, + }, + addInputRowLast: { + marginBottom: 12, + }, + addTextInput: { + flex: 1, + fontSize: 16, + color: theme.colors.input.text, + ...Typography.default('regular'), + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + addButton: { + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + }, + addButtonText: { + color: theme.colors.button.primary.tint, + ...Typography.default('semiBold'), + }, +})); diff --git a/sources/components/Item.tsx b/sources/components/Item.tsx index 379a815d4..9869a768b 100644 --- a/sources/components/Item.tsx +++ b/sources/components/Item.tsx @@ -15,6 +15,7 @@ import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; import { t } from '@/text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroupSelectionContext } from '@/components/ItemGroup'; export interface ItemProps { title: string; @@ -111,7 +112,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ export const Item = React.memo((props) => { const { theme } = useUnistyles(); const styles = stylesheet; - + const selectionContext = React.useContext(ItemGroupSelectionContext); + // Platform-specific measurements const isIOS = Platform.OS === 'ios'; const isAndroid = Platform.OS === 'android'; @@ -196,10 +198,11 @@ export const Item = React.memo((props) => { // If copy is enabled and no onPress is provided, don't set a regular press handler // The copy will be handled by long press instead const handlePress = onPress; - + const isInteractive = handlePress || onLongPress || (copy && !isWeb); const showAccessory = isInteractive && showChevron && !rightElement; const chevronSize = (isIOS && !isWeb) ? 17 : 24; + const showSelectedBackground = !!selected && ((selectionContext?.selectableItemCount ?? 2) > 1); const titleColor = destructive ? styles.titleDestructive : (selected ? styles.titleSelected : styles.titleNormal); const containerPadding = subtitle ? styles.containerWithSubtitle : styles.containerWithoutSubtitle; @@ -295,7 +298,9 @@ export const Item = React.memo((props) => { disabled={disabled || loading} style={({ pressed }) => [ { - backgroundColor: pressed && isIOS && !isWeb ? theme.colors.surfacePressedOverlay : 'transparent', + backgroundColor: pressed && isIOS && !isWeb + ? theme.colors.surfacePressedOverlay + : (showSelectedBackground ? theme.colors.surfaceSelected : 'transparent'), opacity: disabled ? 0.5 : 1 }, pressableStyle diff --git a/sources/components/ItemActionsMenuModal.tsx b/sources/components/ItemActionsMenuModal.tsx new file mode 100644 index 000000000..dc4cb1b42 --- /dev/null +++ b/sources/components/ItemActionsMenuModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; + +export type ItemAction = { + id: string; + title: string; + icon: React.ComponentProps['name']; + onPress: () => void; + destructive?: boolean; + color?: string; +}; + +export interface ItemActionsMenuModalProps { + title: string; + actions: ItemAction[]; + onClose: () => void; +} + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 420, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flexGrow: 0, + }, + scrollContent: { + paddingBottom: 12, + }, +})); + +export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const closeThen = React.useCallback((fn: () => void) => { + props.onClose(); + setTimeout(() => fn(), 0); + }, [props.onClose]); + + return ( + + + + {props.title} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + {props.actions.map((action, idx) => ( + + } + onPress={() => closeThen(action.onPress)} + showChevron={false} + showDivider={idx < props.actions.length - 1} + /> + ))} + + + + ); +} diff --git a/sources/components/ItemGroup.dividers.test.ts b/sources/components/ItemGroup.dividers.test.ts new file mode 100644 index 000000000..ad8161b9f --- /dev/null +++ b/sources/components/ItemGroup.dividers.test.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { withItemGroupDividers } from './ItemGroup.dividers'; + +type FragmentProps = { + children?: React.ReactNode; +}; + +function TestItem(_props: { id: string; showDivider?: boolean }) { + return null; +} + +function collectShowDividers(node: React.ReactNode): Array { + const values: Array = []; + + const walk = (n: React.ReactNode) => { + React.Children.forEach(n, (child) => { + if (!React.isValidElement(child)) return; + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + walk(fragment.props.children); + return; + } + if (child.type === TestItem) { + const element = child as React.ReactElement<{ showDivider?: boolean }>; + values.push(element.props.showDivider); + return; + } + // Ignore other element types. + }); + }; + + walk(node); + return values; +} + +describe('withItemGroupDividers', () => { + it('treats fragment children as part of the divider sequence', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a' }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([true, true, false]); + }); + + it('preserves explicit showDivider={false} overrides', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a', showDivider: false }), + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([false, true, false]); + }); +}); diff --git a/sources/components/ItemGroup.dividers.ts b/sources/components/ItemGroup.dividers.ts new file mode 100644 index 000000000..c14b531e0 --- /dev/null +++ b/sources/components/ItemGroup.dividers.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; + +type DividerChildProps = { + showDivider?: boolean; +}; + +type FragmentProps = { + children?: React.ReactNode; +}; + +export function withItemGroupDividers(children: React.ReactNode): React.ReactNode { + const countNonFragmentElements = (node: React.ReactNode): number => { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + return count + countNonFragmentElements(fragment.props.children); + } + return count + 1; + }, 0); + }; + + const total = countNonFragmentElements(children); + if (total === 0) return children; + + let index = 0; + const apply = (node: React.ReactNode): React.ReactNode => { + return React.Children.map(node, (child) => { + if (!React.isValidElement(child)) { + return child; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + return React.cloneElement(fragment, {}, apply(fragment.props.children)); + } + + const isLast = index === total - 1; + index += 1; + + const element = child as React.ReactElement; + const showDivider = !isLast && element.props.showDivider !== false; + return React.cloneElement(element, { showDivider }); + }); + }; + + return apply(children); +} diff --git a/sources/components/ItemGroup.selectableCount.test.ts b/sources/components/ItemGroup.selectableCount.test.ts new file mode 100644 index 000000000..ee7b0de51 --- /dev/null +++ b/sources/components/ItemGroup.selectableCount.test.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { countSelectableItems } from './ItemGroup.selectableCount'; + +function TestItem(_props: { title?: React.ReactNode; onPress?: () => void; onLongPress?: () => void }) { + return null; +} + +describe('countSelectableItems', () => { + it('counts items with ReactNode titles as selectable', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'X'), onPress: () => {} }), + React.createElement(TestItem, { title: 'Y', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(2); + }); + + it('does not count items with empty-string titles', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: '', onPress: () => {} }), + React.createElement(TestItem, { title: 'ok', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(1); + }); + + it('recurse-counts Fragment children', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: 'a', onPress: () => {} }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'b'), onPress: () => {} }), + React.createElement(TestItem, { title: undefined, onPress: () => {} }), + ), + ); + + expect(countSelectableItems(node)).toBe(2); + }); +}); diff --git a/sources/components/ItemGroup.selectableCount.ts b/sources/components/ItemGroup.selectableCount.ts new file mode 100644 index 000000000..1265140dc --- /dev/null +++ b/sources/components/ItemGroup.selectableCount.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; + +type ItemChildProps = { + title?: unknown; + onPress?: unknown; + onLongPress?: unknown; +}; + +export function countSelectableItems(node: React.ReactNode): number { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<{ children?: React.ReactNode }>; + return count + countSelectableItems(fragment.props.children); + } + const propsAny = (child as React.ReactElement).props as any; + const title = propsAny?.title; + const hasTitle = title !== null && title !== undefined && title !== ''; + const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function'; + return count + (hasTitle && isSelectable ? 1 : 0); + }, 0); +} diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index 0e046fb86..2cac16d5b 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -10,11 +10,12 @@ import { import { Typography } from '@/constants/Typography'; import { layout } from './layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { withItemGroupDividers } from './ItemGroup.dividers'; +import { countSelectableItems } from './ItemGroup.selectableCount'; -interface ItemChildProps { - showDivider?: boolean; - [key: string]: any; -} +export { withItemGroupDividers } from './ItemGroup.dividers'; + +export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); export interface ItemGroupProps { title?: string | React.ReactNode; @@ -95,6 +96,14 @@ export const ItemGroup = React.memo((props) => { containerStyle } = props; + const selectableItemCount = React.useMemo(() => { + return countSelectableItems(children); + }, [children]); + + const selectionContextValue = React.useMemo(() => { + return { selectableItemCount }; + }, [selectableItemCount]); + return ( @@ -116,21 +125,9 @@ export const ItemGroup = React.memo((props) => { {/* Content Container */} - {React.Children.map(children, (child, index) => { - if (React.isValidElement(child)) { - // Don't add props to React.Fragment - if (child.type === React.Fragment) { - return child; - } - const isLast = index === React.Children.count(children) - 1; - const childProps = child.props as ItemChildProps; - return React.cloneElement(child, { - ...childProps, - showDivider: !isLast && childProps.showDivider !== false - }); - } - return child; - })} + + {withItemGroupDividers(children)} + {/* Footer */} @@ -144,4 +141,4 @@ export const ItemGroup = React.memo((props) => { ); -}); \ No newline at end of file +}); diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx new file mode 100644 index 000000000..c039618bc --- /dev/null +++ b/sources/components/ItemRowActions.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { View, Pressable, useWindowDimensions, type GestureResponderEvent } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Modal } from '@/modal'; +import { ItemActionsMenuModal, type ItemAction } from '@/components/ItemActionsMenuModal'; + +export interface ItemRowActionsProps { + title: string; + actions: ItemAction[]; + compactThreshold?: number; + compactActionIds?: string[]; + iconSize?: number; + gap?: number; + onActionPressIn?: () => void; +} + +export function ItemRowActions(props: ItemRowActionsProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { width } = useWindowDimensions(); + const compact = width < (props.compactThreshold ?? 420); + + const compactIds = React.useMemo(() => new Set(props.compactActionIds ?? []), [props.compactActionIds]); + const inlineActions = React.useMemo(() => { + if (!compact) return props.actions; + return props.actions.filter((a) => compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + const overflowActions = React.useMemo(() => { + if (!compact) return []; + return props.actions.filter((a) => !compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + + const openMenu = React.useCallback(() => { + if (overflowActions.length === 0) return; + Modal.show({ + component: ItemActionsMenuModal, + props: { + title: props.title, + actions: overflowActions, + }, + }); + }, [overflowActions, props.title]); + + const iconSize = props.iconSize ?? 20; + const gap = props.gap ?? 16; + + return ( + + {inlineActions.map((action) => ( + props.onActionPressIn?.()} + onPress={(e: GestureResponderEvent) => { + e?.stopPropagation?.(); + action.onPress(); + }} + accessibilityRole="button" + accessibilityLabel={action.title} + > + + + ))} + + {compact && overflowActions.length > 0 && ( + props.onActionPressIn?.()} + onPress={(e: GestureResponderEvent) => { + e?.stopPropagation?.(); + openMenu(); + }} + accessibilityRole="button" + accessibilityLabel="More actions" + accessibilityHint="Opens a menu with more actions" + > + + + )} + + ); +} + +const stylesheet = StyleSheet.create(() => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, +})); diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx deleted file mode 100644 index ea556c99f..000000000 --- a/sources/components/NewSessionWizard.tsx +++ /dev/null @@ -1,1917 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { View, Text, Pressable, ScrollView, TextInput } from 'react-native'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { t } from '@/text'; -import { Ionicons } from '@expo/vector-icons'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; -import { PermissionModeSelector, PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { useAllMachines, useSessions, useSetting, storage } from '@/sync/storage'; -import { useRouter } from 'expo-router'; -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from '@/sync/settings'; -import { Modal } from '@/modal'; -import { sync } from '@/sync/sync'; -import { profileSyncService } from '@/sync/profileSync'; - -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - stepIndicator: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - stepDot: { - width: 8, - height: 8, - borderRadius: 4, - marginHorizontal: 4, - }, - stepDotActive: { - backgroundColor: theme.colors.button.primary.background, - }, - stepDotInactive: { - backgroundColor: theme.colors.divider, - }, - stepContent: { - flex: 1, - paddingHorizontal: 24, - paddingTop: 24, - paddingBottom: 0, // No bottom padding since footer is separate - }, - stepTitle: { - fontSize: 20, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold'), - }, - stepDescription: { - fontSize: 16, - color: theme.colors.textSecondary, - marginBottom: 24, - ...Typography.default(), - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - backgroundColor: theme.colors.surface, // Ensure footer has solid background - }, - button: { - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 8, - minWidth: 100, - alignItems: 'center', - justifyContent: 'center', - }, - buttonPrimary: { - backgroundColor: theme.colors.button.primary.background, - }, - buttonSecondary: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }, - buttonText: { - fontSize: 16, - fontWeight: '600', - ...Typography.default('semiBold'), - }, - buttonTextPrimary: { - color: '#FFFFFF', - }, - buttonTextSecondary: { - color: theme.colors.text, - }, - textInput: { - backgroundColor: theme.colors.input.background, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, - color: theme.colors.text, - borderWidth: 1, - borderColor: theme.colors.divider, - ...Typography.default(), - }, - agentOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 12, - borderWidth: 2, - marginBottom: 12, - }, - agentOptionSelected: { - borderColor: theme.colors.button.primary.background, - backgroundColor: theme.colors.input.background, - }, - agentOptionUnselected: { - borderColor: theme.colors.divider, - backgroundColor: theme.colors.input.background, - }, - agentIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.button.primary.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: 16, - }, - agentInfo: { - flex: 1, - }, - agentName: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - agentDescription: { - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 4, - ...Typography.default(), - }, -})); - -type WizardStep = 'profile' | 'profileConfig' | 'sessionType' | 'agent' | 'options' | 'machine' | 'path' | 'prompt'; - -// Profile selection item component with management actions -interface ProfileSelectionItemProps { - profile: AIBackendProfile; - isSelected: boolean; - onSelect: () => void; - onUseAsIs: () => void; - onEdit: () => void; - onDuplicate?: () => void; - onDelete?: () => void; - showManagementActions?: boolean; -} - -function ProfileSelectionItem({ profile, isSelected, onSelect, onUseAsIs, onEdit, onDuplicate, onDelete, showManagementActions = false }: ProfileSelectionItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - - {/* Profile Header */} - - - - - - - - {profile.name} - - - {profile.description} - - {profile.isBuiltIn && ( - - Built-in profile - - )} - - {isSelected && ( - - )} - - - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - - {/* Primary Actions */} - - - - - Use As-Is - - - - - - - Edit - - - - - {/* Management Actions - Only show for custom profiles */} - {showManagementActions && !profile.isBuiltIn && ( - - - - - Duplicate - - - - - - - Delete - - - - )} - - )} - - ); -} - -// Manual configuration item component -interface ManualConfigurationItemProps { - isSelected: boolean; - onSelect: () => void; - onUseCliVars: () => void; - onConfigureManually: () => void; -} - -function ManualConfigurationItem({ isSelected, onSelect, onUseCliVars, onConfigureManually }: ManualConfigurationItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - - {/* Profile Header */} - - - - - - - - Manual Configuration - - - Use CLI environment variables or configure manually - - - {isSelected && ( - - )} - - - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - - - - - Use CLI Vars - - - - - - - Configure - - - - )} - - ); -} - -interface NewSessionWizardProps { - onComplete: (config: { - sessionType: 'simple' | 'worktree'; - profileId: string | null; - agentType: 'claude' | 'codex'; - permissionMode: PermissionMode; - modelMode: ModelMode; - machineId: string; - path: string; - prompt: string; - environmentVariables?: Record; - }) => void; - onCancel: () => void; - initialPrompt?: string; -} - -export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: NewSessionWizardProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - const router = useRouter(); - const machines = useAllMachines(); - const sessions = useSessions(); - const experimentsEnabled = useSetting('experiments'); - const recentMachinePaths = useSetting('recentMachinePaths'); - const lastUsedAgent = useSetting('lastUsedAgent'); - const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); - const profiles = useSetting('profiles'); - const lastUsedProfile = useSetting('lastUsedProfile'); - - // Wizard state - const [currentStep, setCurrentStep] = useState('profile'); - const [sessionType, setSessionType] = useState<'simple' | 'worktree'>('simple'); - const [agentType, setAgentType] = useState<'claude' | 'codex'>(() => { - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - return 'claude'; - }); - const [permissionMode, setPermissionMode] = useState('default'); - const [modelMode, setModelMode] = useState('default'); - const [selectedProfileId, setSelectedProfileId] = useState(() => { - return lastUsedProfile; - }); - - // Built-in profiles - const builtInProfiles: AIBackendProfile[] = useMemo(() => [ - { - id: 'anthropic', - name: 'Anthropic (Default)', - description: 'Default Claude configuration', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'deepseek', - name: 'DeepSeek (Reasoner)', - description: 'DeepSeek reasoning model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.deepseek.com/anthropic', - model: 'deepseek-reasoner', - }, - environmentVariables: [ - { name: 'API_TIMEOUT_MS', value: '600000' }, - { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'deepseek-chat' }, - { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' }, - ], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'openai', - name: 'OpenAI (GPT-4/Codex)', - description: 'OpenAI GPT-4 and Codex models', - openaiConfig: { - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai-codex', - name: 'Azure OpenAI (Codex)', - description: 'Microsoft Azure OpenAI for Codex agents', - azureOpenAIConfig: { - endpoint: 'https://your-resource.openai.azure.com/', - apiVersion: '2024-02-15-preview', - deploymentName: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai', - name: 'Azure OpenAI', - description: 'Microsoft Azure OpenAI configuration', - azureOpenAIConfig: { - apiVersion: '2024-02-15-preview', - }, - environmentVariables: [ - { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, - ], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'zai', - name: 'Z.ai (GLM-4.6)', - description: 'Z.ai GLM-4.6 model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.z.ai/api/anthropic', - model: 'glm-4.6', - }, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Microsoft Azure AI services', - openaiConfig: { - baseUrl: 'https://api.openai.azure.com', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - ], []); - - // Combined profiles - const allProfiles = useMemo(() => { - return [...builtInProfiles, ...profiles]; - }, [profiles, builtInProfiles]); - - const [selectedMachineId, setSelectedMachineId] = useState(() => { - if (machines.length > 0) { - // Check if we have a recently used machine that's currently available - if (recentMachinePaths.length > 0) { - for (const recent of recentMachinePaths) { - if (machines.find(m => m.id === recent.machineId)) { - return recent.machineId; - } - } - } - return machines[0].id; - } - return ''; - }); - const [selectedPath, setSelectedPath] = useState(() => { - if (machines.length > 0 && selectedMachineId) { - const machine = machines.find(m => m.id === selectedMachineId); - return machine?.metadata?.homeDir || '/home'; - } - return '/home'; - }); - const [prompt, setPrompt] = useState(initialPrompt); - const [customPath, setCustomPath] = useState(''); - const [showCustomPathInput, setShowCustomPathInput] = useState(false); - - // Profile configuration state - const [profileApiKeys, setProfileApiKeys] = useState>>({}); - const [profileConfigs, setProfileConfigs] = useState>>({}); - - // Dynamic steps based on whether profile needs configuration - const steps: WizardStep[] = React.useMemo(() => { - const baseSteps: WizardStep[] = experimentsEnabled - ? ['profile', 'sessionType', 'agent', 'options', 'machine', 'path', 'prompt'] - : ['profile', 'agent', 'options', 'machine', 'path', 'prompt']; - - // Insert profileConfig step after profile if needed - if (profileNeedsConfiguration(selectedProfileId)) { - const profileIndex = baseSteps.indexOf('profile'); - const beforeProfile = baseSteps.slice(0, profileIndex + 1) as WizardStep[]; - const afterProfile = baseSteps.slice(profileIndex + 1) as WizardStep[]; - return [ - ...beforeProfile, - 'profileConfig', - ...afterProfile - ] as WizardStep[]; - } - - return baseSteps; - }, [experimentsEnabled, selectedProfileId]); - - // Helper function to check if profile needs API keys - const profileNeedsConfiguration = (profileId: string | null): boolean => { - if (!profileId) return false; // Manual configuration doesn't need API keys - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return false; - - // Check if profile is one that requires API keys - const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; - return profilesNeedingKeys.includes(profile.id); - }; - - // Get required fields for profile configuration - const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { - if (!profileId) return []; - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return []; - - switch (profile.id) { - case 'deepseek': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'DeepSeek API Key', placeholder: 'DEEPSEEK_API_KEY', isPassword: true } - ]; - case 'openai': - return [ - { key: 'OPENAI_API_KEY', label: 'OpenAI API Key', placeholder: 'sk-...', isPassword: true } - ]; - case 'azure-openai': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'zai': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'Z.ai API Key', placeholder: 'Z_AI_API_KEY', isPassword: true } - ]; - case 'microsoft': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure API Key', placeholder: 'Enter your Azure API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'azure-openai-codex': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - default: - return []; - } - }; - - // Auto-load profile settings and sync with CLI - React.useEffect(() => { - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Auto-select agent type based on profile compatibility - if (selectedProfile.compatibility.claude && !selectedProfile.compatibility.codex) { - setAgentType('claude'); - } else if (selectedProfile.compatibility.codex && !selectedProfile.compatibility.claude) { - setAgentType('codex'); - } - - // Sync active profile to CLI - profileSyncService.setActiveProfile(selectedProfileId).catch(error => { - console.error('[Wizard] Failed to sync active profile to CLI:', error); - }); - } - } - }, [selectedProfileId, allProfiles]); - - // Sync profiles with CLI on component mount and when profiles change - React.useEffect(() => { - const syncProfiles = async () => { - try { - await profileSyncService.bidirectionalSync(allProfiles); - } catch (error) { - console.error('[Wizard] Failed to sync profiles with CLI:', error); - // Continue without sync - profiles work locally - } - }; - - // Sync on mount - syncProfiles(); - - // Set up sync listener for profile changes - const handleSyncEvent = (event: any) => { - if (event.status === 'error') { - console.warn('[Wizard] Profile sync error:', event.error); - } - }; - - profileSyncService.addEventListener(handleSyncEvent); - - return () => { - profileSyncService.removeEventListener(handleSyncEvent); - }; - }, [allProfiles]); - - // Get recent paths for the selected machine - const recentPaths = useMemo(() => { - if (!selectedMachineId) return []; - - const paths: string[] = []; - const pathSet = new Set(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } - }); - - // Then add paths from sessions if we need more - if (sessions) { - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - - sessions.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - - const session = item as any; - if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } - - return paths; - }, [sessions, selectedMachineId, recentMachinePaths]); - - const currentStepIndex = steps.indexOf(currentStep); - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; - - // Handler for "Use Profile As-Is" - quick session creation - const handleUseProfileAsIs = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // Get environment variables from profile (no user configuration) - const environmentVariables = getProfileEnvironmentVariables(profile); - - // Complete wizard immediately with profile settings - onComplete({ - sessionType, - profileId: profile.id, - agentType: agentType || (profile.compatibility.claude ? 'claude' : 'codex'), - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - }; - - // Handler for "Edit Profile" - load profile and go to configuration step - const handleEditProfile = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // If profile needs configuration, go to profileConfig step - if (profileNeedsConfiguration(profile.id)) { - setCurrentStep('profileConfig'); - } else { - // If no configuration needed, proceed to next step in the normal flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - } - }; - - // Handler for "Create New Profile" - const handleCreateProfile = () => { - Modal.prompt( - 'Create New Profile', - 'Enter a name for your new profile:', - { - defaultValue: 'My Custom Profile', - confirmText: 'Create', - cancelText: 'Cancel' - } - ).then((profileName) => { - if (profileName && profileName.trim()) { - const newProfile: AIBackendProfile = { - id: crypto.randomUUID(), - name: profileName.trim(), - description: 'Custom AI profile', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, newProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync new profile with CLI:', error); - }); - - // Auto-select the newly created profile - setSelectedProfileId(newProfile.id); - } - }); - }; - - // Handler for "Duplicate Profile" - const handleDuplicateProfile = (profile: AIBackendProfile) => { - Modal.prompt( - 'Duplicate Profile', - `Enter a name for the duplicate of "${profile.name}":`, - { - defaultValue: `${profile.name} (Copy)`, - confirmText: 'Duplicate', - cancelText: 'Cancel' - } - ).then((newName) => { - if (newName && newName.trim()) { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: crypto.randomUUID(), - name: newName.trim(), - description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, duplicatedProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync duplicated profile with CLI:', error); - }); - } - }); - }; - - // Handler for "Delete Profile" - const handleDeleteProfile = (profile: AIBackendProfile) => { - Modal.confirm( - 'Delete Profile', - `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`, - { - confirmText: 'Delete', - destructive: true - } - ).then((confirmed) => { - if (confirmed) { - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = currentProfiles.filter(p => p.id !== profile.id); - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync profile deletion with CLI:', error); - }); - - // Clear selection if deleted profile was selected - if (selectedProfileId === profile.id) { - setSelectedProfileId(null); - } - } - }); - }; - - // Handler for "Use CLI Environment Variables" - quick session creation with CLI vars - const handleUseCliEnvironmentVariables = () => { - setSelectedProfileId(null); - - // Complete wizard immediately with no profile (rely on CLI environment variables) - onComplete({ - sessionType, - profileId: null, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables: undefined, // Let CLI handle environment variables - }); - }; - - // Handler for "Manual Configuration" - go through normal wizard flow - const handleManualConfiguration = () => { - setSelectedProfileId(null); - - // Proceed to next step in normal wizard flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - }; - - const handleNext = () => { - // Special handling for profileConfig step - skip if profile doesn't need configuration - if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { - setCurrentStep(steps[currentStepIndex + 1]); - return; - } - - if (isLastStep) { - // Get environment variables from selected profile with proper precedence handling - let environmentVariables: Record | undefined; - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Start with profile environment variables (base configuration) - environmentVariables = getProfileEnvironmentVariables(selectedProfile); - - // Only add user-provided API keys if they're non-empty - // This preserves CLI environment variable precedence when wizard fields are empty - const userApiKeys = profileApiKeys[selectedProfileId]; - if (userApiKeys) { - Object.entries(userApiKeys).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - - // Only add user configurations if they're non-empty - const userConfigs = profileConfigs[selectedProfileId]; - if (userConfigs) { - Object.entries(userConfigs).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - } - } - - onComplete({ - sessionType, - profileId: selectedProfileId, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - } else { - setCurrentStep(steps[currentStepIndex + 1]); - } - }; - - const handleBack = () => { - if (isFirstStep) { - onCancel(); - } else { - setCurrentStep(steps[currentStepIndex - 1]); - } - }; - - const canProceed = useMemo(() => { - switch (currentStep) { - case 'profile': - return true; // Always valid (profile can be null for manual config) - case 'profileConfig': - if (!selectedProfileId) return false; - const requiredFields = getProfileRequiredFields(selectedProfileId); - // Profile configuration step is always shown when needed - // Users can leave fields empty to preserve CLI environment variables - return true; - case 'sessionType': - return true; // Always valid - case 'agent': - return true; // Always valid - case 'options': - return true; // Always valid - case 'machine': - return selectedMachineId.length > 0; - case 'path': - return (selectedPath.trim().length > 0) || (showCustomPathInput && customPath.trim().length > 0); - case 'prompt': - return prompt.trim().length > 0; - default: - return false; - } - }, [currentStep, selectedMachineId, selectedPath, prompt, showCustomPathInput, customPath, selectedProfileId, profileApiKeys, profileConfigs, getProfileRequiredFields]); - - const renderStepContent = () => { - switch (currentStep) { - case 'profile': - return ( - - Choose AI Profile - - Select a pre-configured AI profile or set up manually - - - - {builtInProfiles.map((profile) => ( - setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - /> - ))} - - - {profiles.length > 0 && ( - - {profiles.map((profile) => ( - setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - onDuplicate={() => handleDuplicateProfile(profile)} - onDelete={() => handleDeleteProfile(profile)} - showManagementActions={true} - /> - ))} - - )} - - {/* Create New Profile Button */} - - - - - - - - Create New Profile - - - Set up a custom AI backend configuration - - - - - - - setSelectedProfileId(null)} - onUseCliVars={() => handleUseCliEnvironmentVariables()} - onConfigureManually={() => handleManualConfiguration()} - /> - - - - - 💡 **Profile Selection Options:** - - - • **Use As-Is**: Quick session creation with current profile settings - - - • **Edit**: Configure API keys and settings before session creation - - - • **Manual**: Use CLI environment variables without profile configuration - - - - ); - - case 'profileConfig': - if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // Skip configuration if no profile selected or profile doesn't need configuration - setCurrentStep(steps[currentStepIndex + 1]); - return null; - } - - return ( - - Configure {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Profile'} - - Enter your API keys and configuration details - - - - {getProfileRequiredFields(selectedProfileId).map((field) => ( - - - {field.label} - - { - if (field.isPassword) { - // API key - setProfileApiKeys(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record || {}), - [field.key]: text - } - })); - } else { - // Configuration field - setProfileConfigs(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record || {}), - [field.key]: text - } - })); - } - }} - secureTextEntry={field.isPassword} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - /> - - ))} - - - - - 💡 Tip: Your API keys are only used for this session and are not stored permanently - - - 📝 Note: Leave fields empty to use CLI environment variables if they're already set - - - - ); - - case 'sessionType': - return ( - - Choose AI Backend & Session Type - - Select your AI provider and how you want to work with your code - - - - {[ - { - id: 'anthropic', - name: 'Anthropic Claude', - description: 'Advanced reasoning and coding assistant', - icon: 'cube-outline', - agentType: 'claude' as const - }, - { - id: 'openai', - name: 'OpenAI GPT-5', - description: 'Specialized coding assistant', - icon: 'code-outline', - agentType: 'codex' as const - }, - { - id: 'deepseek', - name: 'DeepSeek Reasoner', - description: 'Advanced reasoning model', - icon: 'analytics-outline', - agentType: 'claude' as const - }, - { - id: 'zai', - name: 'Z.ai', - description: 'AI assistant for development', - icon: 'flash-outline', - agentType: 'claude' as const - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Enterprise AI services', - icon: 'cloud-outline', - agentType: 'codex' as const - }, - ].map((backend) => ( - - } - rightElement={agentType === backend.agentType ? ( - - ) : null} - onPress={() => setAgentType(backend.agentType)} - showChevron={false} - selected={agentType === backend.agentType} - showDivider={true} - /> - ))} - - - - - ); - - case 'agent': - return ( - - Choose AI Agent - - Select which AI assistant you want to use - - - {selectedProfileId && ( - - - Profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - - - {allProfiles.find(p => p.id === selectedProfileId)?.description} - - - )} - - p.id === selectedProfileId)?.compatibility.claude && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude) { - setAgentType('claude'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude)} - > - - C - - - Claude - - Anthropic's AI assistant, great for coding and analysis - - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && ( - - Not compatible with selected profile - - )} - - {agentType === 'claude' && ( - - )} - - - p.id === selectedProfileId)?.compatibility.codex && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex) { - setAgentType('codex'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex)} - > - - X - - - Codex - - OpenAI's specialized coding assistant - - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && ( - - Not compatible with selected profile - - )} - - {agentType === 'codex' && ( - - )} - - - ); - - case 'options': - return ( - - Agent Options - - Configure how the AI agent should behave - - - {selectedProfileId && ( - - - Using profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - - - Environment variables will be applied automatically - - - )} - - {([ - { value: 'default', label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits', label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan', label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions', label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] as const).map((option, index, array) => ( - - } - rightElement={permissionMode === option.value ? ( - - ) : null} - onPress={() => setPermissionMode(option.value as PermissionMode)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - - {(agentType === 'claude' ? [ - { value: 'default', label: 'Default', description: 'Balanced performance', icon: 'cube-outline' }, - { value: 'adaptiveUsage', label: 'Adaptive Usage', description: 'Automatically choose model', icon: 'analytics-outline' }, - { value: 'sonnet', label: 'Sonnet', description: 'Fast and efficient', icon: 'speedometer-outline' }, - { value: 'opus', label: 'Opus', description: 'Most capable model', icon: 'diamond-outline' }, - ] as const : [ - { value: 'gpt-5-codex-high', label: 'GPT-5 Codex High', description: 'Best for complex coding', icon: 'diamond-outline' }, - { value: 'gpt-5-codex-medium', label: 'GPT-5 Codex Medium', description: 'Balanced coding assistance', icon: 'cube-outline' }, - { value: 'gpt-5-codex-low', label: 'GPT-5 Codex Low', description: 'Fast coding help', icon: 'speedometer-outline' }, - ] as const).map((option, index, array) => ( - - } - rightElement={modelMode === option.value ? ( - - ) : null} - onPress={() => setModelMode(option.value as ModelMode)} - showChevron={false} - selected={modelMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - ); - - case 'machine': - return ( - - Select Machine - - Choose which machine to run your session on - - - - {machines.map((machine, index) => ( - - } - rightElement={selectedMachineId === machine.id ? ( - - ) : null} - onPress={() => { - setSelectedMachineId(machine.id); - // Update path when machine changes - const homeDir = machine.metadata?.homeDir || '/home'; - setSelectedPath(homeDir); - }} - showChevron={false} - selected={selectedMachineId === machine.id} - showDivider={index < machines.length - 1} - /> - ))} - - - ); - - case 'path': - return ( - - Working Directory - - Choose the directory to work in - - - {/* Recent Paths */} - {recentPaths.length > 0 && ( - - {recentPaths.map((path, index) => ( - - } - rightElement={selectedPath === path && !showCustomPathInput ? ( - - ) : null} - onPress={() => { - setSelectedPath(path); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === path && !showCustomPathInput} - showDivider={index < recentPaths.length - 1} - /> - ))} - - )} - - {/* Common Directories */} - - {(() => { - const machine = machines.find(m => m.id === selectedMachineId); - const homeDir = machine?.metadata?.homeDir || '/home'; - const pathOptions = [ - { value: homeDir, label: homeDir, description: 'Home directory' }, - { value: `${homeDir}/projects`, label: `${homeDir}/projects`, description: 'Projects folder' }, - { value: `${homeDir}/Documents`, label: `${homeDir}/Documents`, description: 'Documents folder' }, - { value: `${homeDir}/Desktop`, label: `${homeDir}/Desktop`, description: 'Desktop folder' }, - ]; - return pathOptions.map((option, index) => ( - - } - rightElement={selectedPath === option.value && !showCustomPathInput ? ( - - ) : null} - onPress={() => { - setSelectedPath(option.value); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === option.value && !showCustomPathInput} - showDivider={index < pathOptions.length - 1} - /> - )); - })()} - - - {/* Custom Path Option */} - - - } - rightElement={showCustomPathInput ? ( - - ) : null} - onPress={() => setShowCustomPathInput(true)} - showChevron={false} - selected={showCustomPathInput} - showDivider={false} - /> - {showCustomPathInput && ( - - - - )} - - - ); - - case 'prompt': - return ( - - Initial Message - - Write your first message to the AI agent - - - - - ); - - default: - return null; - } - }; - - return ( - - - New Session - - - - - - - {steps.map((step, index) => ( - - ))} - - - - {renderStepContent()} - - - - - - {isFirstStep ? 'Cancel' : 'Back'} - - - - - - {isLastStep ? 'Create Session' : 'Next'} - - - - - ); -} \ No newline at end of file diff --git a/sources/components/PermissionModeSelector.tsx b/sources/components/PermissionModeSelector.tsx deleted file mode 100644 index 5c9f0850e..000000000 --- a/sources/components/PermissionModeSelector.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import { Text, Pressable, Platform } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { Typography } from '@/constants/Typography'; -import { hapticsLight } from './haptics'; - -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; - -export type ModelMode = 'default' | 'adaptiveUsage' | 'sonnet' | 'opus' | 'gpt-5-codex-high' | 'gpt-5-codex-medium' | 'gpt-5-codex-low' | 'gpt-5-minimal' | 'gpt-5-low' | 'gpt-5-medium' | 'gpt-5-high' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; - -interface PermissionModeSelectorProps { - mode: PermissionMode; - onModeChange: (mode: PermissionMode) => void; - disabled?: boolean; -} - -const modeConfig = { - default: { - label: 'Default', - icon: 'shield-checkmark' as const, - description: 'Ask for permissions' - }, - acceptEdits: { - label: 'Accept Edits', - icon: 'create' as const, - description: 'Auto-approve edits' - }, - plan: { - label: 'Plan', - icon: 'list' as const, - description: 'Plan before executing' - }, - bypassPermissions: { - label: 'Yolo', - icon: 'flash' as const, - description: 'Skip all permissions' - }, - // Codex modes (not displayed in this component, but needed for type compatibility) - 'read-only': { - label: 'Read-only', - icon: 'eye' as const, - description: 'Read-only mode' - }, - 'safe-yolo': { - label: 'Safe YOLO', - icon: 'shield' as const, - description: 'Safe YOLO mode' - }, - 'yolo': { - label: 'YOLO', - icon: 'rocket' as const, - description: 'YOLO mode' - }, -}; - -const modeOrder: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - -export const PermissionModeSelector: React.FC = ({ - mode, - onModeChange, - disabled = false -}) => { - const currentConfig = modeConfig[mode]; - - const handleTap = () => { - hapticsLight(); - const currentIndex = modeOrder.indexOf(mode); - const nextIndex = (currentIndex + 1) % modeOrder.length; - onModeChange(modeOrder[nextIndex]); - }; - - return ( - - - {/* - {currentConfig.label} - */} - - ); -}; \ No newline at end of file diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 8a3864d44..bd09a0073 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,25 +1,94 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, TextInput, ViewStyle, Linking, Platform } from 'react-native'; +import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { AIBackendProfile } from '@/sync/settings'; -import { PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; +import { normalizeProfileDefaultPermissionMode, type PermissionMode } from '@/sync/permissionTypes'; import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; +import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; -import { useEnvironmentVariables, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; +import { useSetting, useAllMachines, useMachine, useSettingMutable } from '@/sync/storage'; +import { Modal } from '@/modal'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import type { Machine } from '@/sync/storageTypes'; export interface ProfileEditFormProps { profile: AIBackendProfile; machineId: string | null; onSave: (profile: AIBackendProfile) => void; onCancel: () => void; + onDirtyChange?: (isDirty: boolean) => void; containerStyle?: ViewStyle; + saveRef?: React.MutableRefObject<(() => void) | null>; +} + +interface MachinePreviewModalProps { + machines: Machine[]; + favoriteMachineIds: string[]; + selectedMachineId: string | null; + onSelect: (machineId: string) => void; + onToggleFavorite: (machineId: string) => void; + onClose: () => void; +} + +function MachinePreviewModal(props: MachinePreviewModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { height: windowHeight } = useWindowDimensions(); + + const selectedMachine = React.useMemo(() => { + if (!props.selectedMachineId) return null; + return props.machines.find((m) => m.id === props.selectedMachineId) ?? null; + }, [props.machines, props.selectedMachineId]); + + const favoriteMachines = React.useMemo(() => { + const byId = new Map(props.machines.map((m) => [m.id, m] as const)); + return props.favoriteMachineIds.map((id) => byId.get(id)).filter(Boolean) as Machine[]; + }, [props.favoriteMachineIds, props.machines]); + + const maxHeight = Math.min(720, Math.max(420, Math.floor(windowHeight * 0.85))); + + return ( + + + + {t('profiles.previewMachine.title')} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + 0} + showSearch + searchPlacement={favoriteMachines.length > 0 ? 'favorites' : 'all'} + onSelect={(machine) => { + props.onSelect(machine.id); + props.onClose(); + }} + onToggleFavorite={(machine) => props.onToggleFavorite(machine.id)} + /> + + + ); } export function ProfileEditForm({ @@ -27,554 +96,482 @@ export function ProfileEditForm({ machineId, onSave, onCancel, - containerStyle + onDirtyChange, + containerStyle, + saveRef, }: ProfileEditFormProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const styles = stylesheet; + const groupStyle = React.useMemo(() => ({ marginBottom: 12 }), []); + const experimentsEnabled = useSetting('experiments'); + const machines = useAllMachines(); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const routeMachine = machineId; + const [previewMachineId, setPreviewMachineId] = React.useState(routeMachine); + + React.useEffect(() => { + setPreviewMachineId(routeMachine); + }, [routeMachine]); + + const resolvedMachineId = routeMachine ?? previewMachineId; + const resolvedMachine = useMachine(resolvedMachineId ?? ''); + + const toggleFavoriteMachineId = React.useCallback((machineIdToToggle: string) => { + if (favoriteMachines.includes(machineIdToToggle)) { + setFavoriteMachines(favoriteMachines.filter((id) => id !== machineIdToToggle)); + } else { + setFavoriteMachines([machineIdToToggle, ...favoriteMachines]); + } + }, [favoriteMachines, setFavoriteMachines]); + + const MachinePreviewModalWrapper = React.useCallback(({ onClose }: { onClose: () => void }) => { + return ( + + ); + }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); + + const showMachinePreviewPicker = React.useCallback(() => { + Modal.show({ + component: MachinePreviewModalWrapper, + props: {}, + }); + }, [MachinePreviewModalWrapper]); - // Get documentation for built-in profiles const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; return getBuiltInProfileDocumentation(profile.id); - }, [profile.isBuiltIn, profile.id]); + }, [profile.id, profile.isBuiltIn]); - // Local state for environment variables (unified for all config) const [environmentVariables, setEnvironmentVariables] = React.useState>( - profile.environmentVariables || [] + profile.environmentVariables || [], ); - // Extract ${VAR} references from environmentVariables for querying daemon - const envVarNames = React.useMemo(() => { - return extractEnvVarReferences(environmentVariables); - }, [environmentVariables]); - - // Query daemon environment using hook - const { variables: actualEnvVars } = useEnvironmentVariables(machineId, envVarNames); - const [name, setName] = React.useState(profile.name || ''); const [useTmux, setUseTmux] = React.useState(profile.tmuxConfig?.sessionName !== undefined); const [tmuxSession, setTmuxSession] = React.useState(profile.tmuxConfig?.sessionName || ''); const [tmuxTmpDir, setTmuxTmpDir] = React.useState(profile.tmuxConfig?.tmpDir || ''); - const [useStartupScript, setUseStartupScript] = React.useState(!!profile.startupBashScript); - const [startupScript, setStartupScript] = React.useState(profile.startupBashScript || ''); - const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>(profile.defaultSessionType || 'simple'); - const [defaultPermissionMode, setDefaultPermissionMode] = React.useState((profile.defaultPermissionMode as PermissionMode) || 'default'); - const [agentType, setAgentType] = React.useState<'claude' | 'codex'>(() => { - if (profile.compatibility.claude && !profile.compatibility.codex) return 'claude'; - if (profile.compatibility.codex && !profile.compatibility.claude) return 'codex'; - return 'claude'; // Default to Claude if both or neither - }); - - const handleSave = () => { + const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>( + profile.defaultSessionType || 'simple', + ); + const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( + normalizeProfileDefaultPermissionMode(profile.defaultPermissionMode as PermissionMode), + ); + const [compatibility, setCompatibility] = React.useState>( + profile.compatibility || { claude: true, codex: true, gemini: true }, + ); + + const initialSnapshotRef = React.useRef(null); + if (initialSnapshotRef.current === null) { + initialSnapshotRef.current = JSON.stringify({ + name, + environmentVariables, + useTmux, + tmuxSession, + tmuxTmpDir, + defaultSessionType, + defaultPermissionMode, + compatibility, + }); + } + + const isDirty = React.useMemo(() => { + const currentSnapshot = JSON.stringify({ + name, + environmentVariables, + useTmux, + tmuxSession, + tmuxTmpDir, + defaultSessionType, + defaultPermissionMode, + compatibility, + }); + return currentSnapshot !== initialSnapshotRef.current; + }, [ + compatibility, + defaultPermissionMode, + defaultSessionType, + environmentVariables, + name, + tmuxSession, + tmuxTmpDir, + useTmux, + ]); + + React.useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + + const toggleCompatibility = React.useCallback((key: keyof AIBackendProfile['compatibility']) => { + setCompatibility((prev) => { + const next = { ...prev, [key]: !prev[key] }; + const enabledCount = Object.values(next).filter(Boolean).length; + if (enabledCount === 0) { + Modal.alert(t('common.error'), t('profiles.aiBackend.selectAtLeastOneError')); + return prev; + } + return next; + }); + }, []); + + const openSetupGuide = React.useCallback(async () => { + const url = profileDocs?.setupGuideUrl; + if (!url) return; + try { + if (Platform.OS === 'web') { + window.open(url, '_blank'); + } else { + await Linking.openURL(url); + } + } catch (error) { + console.error('Failed to open URL:', error); + } + }, [profileDocs?.setupGuideUrl]); + + const handleSave = React.useCallback(() => { if (!name.trim()) { - // Profile name validation - prevent saving empty profiles + Modal.alert(t('common.error'), t('profiles.nameRequired')); return; } onSave({ ...profile, name: name.trim(), - // Clear all config objects - ALL configuration now in environmentVariables - anthropicConfig: {}, - openaiConfig: {}, - azureOpenAIConfig: {}, - // Use environment variables from state (managed by EnvironmentVariablesList) environmentVariables, - // Keep non-env-var configuration - tmuxConfig: useTmux ? { - sessionName: tmuxSession.trim() || '', // Empty string = use current/most recent tmux session - tmpDir: tmuxTmpDir.trim() || undefined, - updateEnvironment: undefined, // Preserve schema compatibility, not used by daemon - } : { - sessionName: undefined, - tmpDir: undefined, - updateEnvironment: undefined, - }, - startupBashScript: useStartupScript ? (startupScript.trim() || undefined) : undefined, - defaultSessionType: defaultSessionType, - defaultPermissionMode: defaultPermissionMode, + tmuxConfig: useTmux + ? { + ...(profile.tmuxConfig ?? {}), + sessionName: tmuxSession.trim() || '', + tmpDir: tmuxTmpDir.trim() || undefined, + } + : undefined, + defaultSessionType, + defaultPermissionMode, + compatibility, updatedAt: Date.now(), }); - }; + }, [ + compatibility, + defaultPermissionMode, + defaultSessionType, + environmentVariables, + name, + onSave, + profile, + tmuxSession, + tmuxTmpDir, + useTmux, + ]); - return ( - - - {/* Profile Name */} - - {t('profiles.profileName')} - - - - {/* Built-in Profile Documentation - Setup Instructions */} - {profile.isBuiltIn && profileDocs && ( - - - - - Setup Instructions - - - - - {profileDocs.description} - + React.useEffect(() => { + if (!saveRef) { + return; + } + saveRef.current = handleSave; + return () => { + saveRef.current = null; + }; + }, [handleSave, saveRef]); - {profileDocs.setupGuideUrl && ( - { - try { - const url = profileDocs.setupGuideUrl!; - // On web/Tauri desktop, use window.open - if (Platform.OS === 'web') { - window.open(url, '_blank'); - } else { - // On native (iOS/Android), use Linking API - await Linking.openURL(url); - } - } catch (error) { - console.error('Failed to open URL:', error); - } - }} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.button.primary.background, - borderRadius: 8, - padding: 12, - marginBottom: 16, - }} - > - - - View Official Setup Guide - - - - )} - - )} - - {/* Session Type */} - - Default Session Type - - - + + + + + + - {/* Permission Mode */} - - Default Permission Mode - - - {[ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ].map((option, index, array) => ( - - } - rightElement={defaultPermissionMode === option.value ? ( - - ) : null} - onPress={() => setDefaultPermissionMode(option.value)} - showChevron={false} - selected={defaultPermissionMode === option.value} - showDivider={index < array.length - 1} - style={defaultPermissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: 8, - } : undefined} + {profile.isBuiltIn && profileDocs?.setupGuideUrl && ( + + } + onPress={() => void openSetupGuide()} + /> + + )} + + + + + + + {[ + { + value: 'default' as PermissionMode, + label: t('agentInput.permissionMode.default'), + description: t('profiles.defaultPermissionMode.descriptions.default'), + icon: 'shield-outline' + }, + { + value: 'acceptEdits' as PermissionMode, + label: t('agentInput.permissionMode.acceptEdits'), + description: t('profiles.defaultPermissionMode.descriptions.acceptEdits'), + icon: 'checkmark-outline' + }, + { + value: 'plan' as PermissionMode, + label: t('agentInput.permissionMode.plan'), + description: t('profiles.defaultPermissionMode.descriptions.plan'), + icon: 'list-outline' + }, + { + value: 'bypassPermissions' as PermissionMode, + label: t('agentInput.permissionMode.bypassPermissions'), + description: t('profiles.defaultPermissionMode.descriptions.bypassPermissions'), + icon: 'flash-outline' + }, + ].map((option, index, array) => ( + - ))} - - - - {/* Tmux Enable/Disable */} - - setUseTmux(!useTmux)} - > - - {useTmux && ( - - )} - - - - Spawn Sessions in Tmux - - - - {useTmux ? 'Sessions spawn in new tmux windows. Configure session name and temp directory below.' : 'Sessions spawn in regular shell (no tmux integration)'} - - - {/* Tmux Session Name */} - - Tmux Session Name ({t('common.optional')}) - - - Leave empty to use first existing tmux session (or create "happy" if none exist). Specify name (e.g., "my-work") for specific session. - - + ) : null + } + onPress={() => setDefaultPermissionMode(option.value)} + showChevron={false} + selected={defaultPermissionMode === option.value} + showDivider={index < array.length - 1} /> + ))} + - {/* Tmux Temp Directory */} - - Tmux Temp Directory ({t('common.optional')}) - - - Temporary directory for tmux session files. Leave empty for system default. - - + } + rightElement={ toggleCompatibility('claude')} />} + showChevron={false} + onPress={() => toggleCompatibility('claude')} + /> + } + rightElement={ toggleCompatibility('codex')} />} + showChevron={false} + onPress={() => toggleCompatibility('codex')} + /> + {experimentsEnabled && ( + } + rightElement={ toggleCompatibility('gemini')} />} + showChevron={false} + onPress={() => toggleCompatibility('gemini')} + showDivider={false} /> + )} + - {/* Startup Bash Script */} - - - setUseStartupScript(!useStartupScript)} - > - - {useStartupScript && ( - - )} - - - - Startup Bash Script - + + } + showChevron={false} + onPress={() => setUseTmux((v) => !v)} + /> + {useTmux && ( + + + {t('profiles.tmuxSession')} ({t('common.optional')}) + - - {useStartupScript - ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.' - : 'No startup script - sessions spawn directly'} - - + + {t('profiles.tmuxTempDir')} ({t('common.optional')}) - {useStartupScript && startupScript.trim() && ( - { - if (Platform.OS === 'web') { - navigator.clipboard.writeText(startupScript); - } - }} - > - - - )} - + + )} + - {/* Environment Variables Section - Unified configuration */} - + } + onPress={showMachinePreviewPicker} /> + + )} - {/* Action buttons */} - + + + + + + + ({ backgroundColor: theme.colors.surface, - borderRadius: 8, - padding: 12, + borderRadius: 10, + paddingVertical: 12, alignItems: 'center', - }} - onPress={onCancel} + opacity: pressed ? 0.85 : 1, + })} > - + {t('common.cancel')} - {profile.isBuiltIn ? ( - // For built-in profiles, show "Save As" button (creates custom copy) - - - {t('common.saveAs')} - - - ) : ( - // For custom profiles, show regular "Save" button - - - {t('common.save')} - - - )} + + + ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + + {profile.isBuiltIn ? t('common.saveAs') : t('common.save')} + + - + + ); } -const profileEditFormStyles = StyleSheet.create((theme, rt) => ({ - scrollView: { - flex: 1, +const stylesheet = StyleSheet.create((theme) => ({ + machinePreviewModalContainer: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + machinePreviewModalHeader: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + machinePreviewModalTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, }, - scrollContent: { - padding: 20, + selectorContainer: { + paddingHorizontal: 12, + paddingBottom: 4, }, - formContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, // Matches new session panel main container - padding: 20, - width: '100%', + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + multilineInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 14, + lineHeight: 20, + color: theme.colors.input.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + minHeight: 120, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), }, })); diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx new file mode 100644 index 000000000..458c26bad --- /dev/null +++ b/sources/components/SearchHeader.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { View, TextInput, Platform, Pressable, StyleProp, ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/layout'; +import { t } from '@/text'; + +export interface SearchHeaderProps { + value: string; + onChangeText: (text: string) => void; + placeholder: string; + containerStyle?: StyleProp; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + autoCorrect?: boolean; + inputRef?: React.Ref; + onFocus?: () => void; + onBlur?: () => void; +} + +const INPUT_BORDER_RADIUS = 10; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + content: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: INPUT_BORDER_RADIUS, + paddingHorizontal: 12, + paddingVertical: 8, + }, + textInput: { + flex: 1, + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + paddingVertical: 0, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + clearIcon: { + marginLeft: 8, + }, +})); + +export function SearchHeader({ + value, + onChangeText, + placeholder, + containerStyle, + autoCapitalize = 'none', + autoCorrect = false, + inputRef, + onFocus, + onBlur, +}: SearchHeaderProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + return ( + + + + + + {value.length > 0 && ( + onChangeText('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.clearSearch')} + > + + + )} + + + + ); +} diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index c81ba79e2..a95d6040c 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -5,10 +5,9 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { MultiTextInput } from '@/components/MultiTextInput'; -import { Modal } from '@/modal'; import { t } from '@/text'; import { StatusDot } from '@/components/StatusDot'; +import { SearchHeader } from '@/components/SearchHeader'; /** * Configuration object for customizing the SearchableListSelector component. @@ -40,12 +39,14 @@ export interface SelectorConfig { searchPlaceholder: string; recentSectionTitle: string; favoritesSectionTitle: string; + allSectionTitle?: string; noItemsMessage: string; // Optional features showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + showAll?: boolean; allowCustomInput?: boolean; // Item subtitle override (for recent items, e.g., "Recently used") @@ -59,9 +60,6 @@ export interface SelectorConfig { // Check if a favorite item can be removed (e.g., home directory can't be removed) canRemoveFavorite?: (item: T) => boolean; - - // Visual customization - compactItems?: boolean; // Use reduced padding for more compact lists (default: false) } /** @@ -75,142 +73,28 @@ export interface SearchableListSelectorProps { selectedItem: T | null; onSelect: (item: T) => void; onToggleFavorite?: (item: T) => void; - context?: any; // Additional context (e.g., homeDir for paths) + context?: any; // Additional context (e.g., homeDir for paths) // Optional overrides showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; - - // Controlled collapse states (optional - defaults to uncontrolled internal state) - collapsedSections?: { - recent?: boolean; - favorites?: boolean; - all?: boolean; - }; - onCollapsedSectionsChange?: (collapsed: { recent?: boolean; favorites?: boolean; all?: boolean }) => void; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; } const RECENT_ITEMS_DEFAULT_VISIBLE = 5; -// Spacing constants (match existing codebase patterns) -const STATUS_DOT_TEXT_GAP = 4; // Gap between StatusDot and text (used throughout app for status indicators) -const ITEM_SPACING_GAP = 4; // Gap between elements and spacing between items (compact) -const COMPACT_ITEM_PADDING = 4; // Vertical padding for compact lists -// Border radius constants (consistent rounding) -const INPUT_BORDER_RADIUS = 10; // Input field and containers -const BUTTON_BORDER_RADIUS = 8; // Buttons and actionable elements -// ITEM_BORDER_RADIUS must match ItemGroup's contentContainer borderRadius to prevent clipping -// ItemGroup uses Platform.select({ ios: 10, default: 16 }) -const ITEM_BORDER_RADIUS = Platform.select({ ios: 10, default: 16 }); // Match ItemGroup container radius +const STATUS_DOT_TEXT_GAP = 4; +const ITEM_SPACING_GAP = 16; const stylesheet = StyleSheet.create((theme) => ({ - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingBottom: 8, - }, - inputWrapper: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: INPUT_BORDER_RADIUS, - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, - inputInner: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - }, - inputField: { - flex: 1, - }, - clearButton: { - width: 20, - height: 20, - borderRadius: INPUT_BORDER_RADIUS, - backgroundColor: theme.colors.textSecondary, - justifyContent: 'center', - alignItems: 'center', - marginLeft: 8, - }, - favoriteButton: { - borderRadius: BUTTON_BORDER_RADIUS, - padding: 8, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 10, - }, - sectionHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.text, - ...Typography.default(), - }, - selectedItemStyle: { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: ITEM_BORDER_RADIUS, - }, - compactItemStyle: { - paddingVertical: COMPACT_ITEM_PADDING, - minHeight: 0, // Override Item's default minHeight (44-56px) for compact mode - }, - itemBackground: { - backgroundColor: theme.colors.input.background, - borderRadius: ITEM_BORDER_RADIUS, - marginBottom: ITEM_SPACING_GAP, - }, showMoreTitle: { textAlign: 'center', - color: theme.colors.button.primary.tint, + color: theme.colors.textLink, }, })); -/** - * Generic searchable list selector component with recent items, favorites, and filtering. - * - * Pattern extracted from Working Directory section in new session wizard. - * Supports any data type through TypeScript generics and configuration object. - * - * Features: - * - Search/filter with smart skip (doesn't filter when input matches selection) - * - Recent items with "Show More" toggle - * - Favorites with add/remove - * - Collapsible sections - * - Custom input support (optional) - * - * @example - * // For machines: - * - * config={machineConfig} - * items={machines} - * recentItems={recentMachines} - * favoriteItems={favoriteMachines} - * selectedItem={selectedMachine} - * onSelect={(machine) => setSelectedMachine(machine)} - * onToggleFavorite={(machine) => toggleFavorite(machine.id)} - * /> - * - * // For paths: - * - * config={pathConfig} - * items={allPaths} - * recentItems={recentPaths} - * favoriteItems={favoritePaths} - * selectedItem={selectedPath} - * onSelect={(path) => setSelectedPath(path)} - * onToggleFavorite={(path) => toggleFavorite(path)} - * context={{ homeDir }} - * /> - */ export function SearchableListSelector(props: SearchableListSelectorProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const styles = stylesheet; const { config, @@ -224,167 +108,51 @@ export function SearchableListSelector(props: SearchableListSelectorProps) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, - collapsedSections, - onCollapsedSectionsChange, + searchPlacement = 'header', } = props; + const showAll = config.showAll !== false; - // Use controlled state if provided, otherwise use internal state - const isControlled = collapsedSections !== undefined && onCollapsedSectionsChange !== undefined; - - // State management (matches Working Directory pattern) - const [inputText, setInputText] = React.useState(() => { - if (selectedItem) { - return config.formatForDisplay(selectedItem, context); - } - return ''; - }); + // Search query is intentionally decoupled from the selected value so pickers don't start pre-filtered. + const [inputText, setInputText] = React.useState(''); const [showAllRecent, setShowAllRecent] = React.useState(false); - // Internal uncontrolled state (used when not controlled from parent) - const [internalShowRecentSection, setInternalShowRecentSection] = React.useState(false); - const [internalShowFavoritesSection, setInternalShowFavoritesSection] = React.useState(false); - const [internalShowAllItemsSection, setInternalShowAllItemsSection] = React.useState(true); - - // Use controlled or uncontrolled state - const showRecentSection = isControlled ? !collapsedSections?.recent : internalShowRecentSection; - const showFavoritesSection = isControlled ? !collapsedSections?.favorites : internalShowFavoritesSection; - const showAllItemsSection = isControlled ? !collapsedSections?.all : internalShowAllItemsSection; - - // Toggle handlers that work for both controlled and uncontrolled - const toggleRecentSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, recent: !collapsedSections?.recent }); - } else { - setInternalShowRecentSection(!internalShowRecentSection); - } - }; - - const toggleFavoritesSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, favorites: !collapsedSections?.favorites }); - } else { - setInternalShowFavoritesSection(!internalShowFavoritesSection); - } - }; - - const toggleAllItemsSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, all: !collapsedSections?.all }); - } else { - setInternalShowAllItemsSection(!internalShowAllItemsSection); - } - }; - - // Track if user is actively typing (vs clicking from list) to control expansion behavior - const isUserTyping = React.useRef(false); + const favoriteIds = React.useMemo(() => { + return new Set(favoriteItems.map((item) => config.getItemId(item))); + }, [favoriteItems, config]); - // Update input text when selected item changes externally - React.useEffect(() => { - if (selectedItem && !isUserTyping.current) { - setInputText(config.formatForDisplay(selectedItem, context)); - } - }, [selectedItem, config, context]); - - // Filtering logic with smart skip (matches Working Directory pattern) - const filteredRecentItems = React.useMemo(() => { - if (!inputText.trim()) return recentItems; - - // Don't filter if text matches the currently selected item (user clicked from list) - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return recentItems; // Show all items, don't filter - } + const baseRecentItems = React.useMemo(() => { + return recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + }, [recentItems, favoriteIds, config]); - // User is typing - filter the list - return recentItems.filter(item => config.filterItem(item, inputText, context)); - }, [recentItems, inputText, selectedItem, config, context]); + const baseAllItems = React.useMemo(() => { + const recentIds = new Set(baseRecentItems.map((item) => config.getItemId(item))); + return items.filter((item) => !favoriteIds.has(config.getItemId(item)) && !recentIds.has(config.getItemId(item))); + }, [items, baseRecentItems, favoriteIds, config]); const filteredFavoriteItems = React.useMemo(() => { if (!inputText.trim()) return favoriteItems; + return favoriteItems.filter((item) => config.filterItem(item, inputText, context)); + }, [favoriteItems, inputText, config, context]); - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return favoriteItems; // Show all favorites, don't filter - } - - // Don't filter if text matches a favorite (user clicked from list) - if (favoriteItems.some(item => config.formatForDisplay(item, context) === inputText)) { - return favoriteItems; // Show all favorites, don't filter - } - - return favoriteItems.filter(item => config.filterItem(item, inputText, context)); - }, [favoriteItems, inputText, selectedItem, config, context]); - - // Check if current input can be added to favorites - const canAddToFavorites = React.useMemo(() => { - if (!onToggleFavorite || !inputText.trim()) return false; - - // Parse input to see if it's a valid item - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (!parsedItem) return false; + const filteredRecentItems = React.useMemo(() => { + if (!inputText.trim()) return baseRecentItems; + return baseRecentItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseRecentItems, inputText, config, context]); - // Check if already in favorites - const parsedId = config.getItemId(parsedItem); - return !favoriteItems.some(fav => config.getItemId(fav) === parsedId); - }, [inputText, favoriteItems, config, context, onToggleFavorite]); + const filteredItems = React.useMemo(() => { + if (!inputText.trim()) return baseAllItems; + return baseAllItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseAllItems, inputText, config, context]); - // Handle input text change const handleInputChange = (text: string) => { - isUserTyping.current = true; // User is actively typing setInputText(text); - // If allowCustomInput, try to parse and select if (config.allowCustomInput && text.trim()) { const parsedItem = config.parseFromDisplay(text.trim(), context); - if (parsedItem) { - onSelect(parsedItem); - } - } - }; - - // Handle item selection from list - const handleSelectItem = (item: T) => { - isUserTyping.current = false; // User clicked from list - setInputText(config.formatForDisplay(item, context)); - onSelect(item); - }; - - // Handle clear button - const handleClear = () => { - isUserTyping.current = false; - setInputText(''); - // Don't clear selection - just clear input - }; - - // Handle add to favorites - const handleAddToFavorites = () => { - if (!canAddToFavorites || !onToggleFavorite) return; - - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (parsedItem) { - onToggleFavorite(parsedItem); + if (parsedItem) onSelect(parsedItem); } }; - // Handle remove from favorites - const handleRemoveFavorite = (item: T) => { - if (!onToggleFavorite) return; - - Modal.alert( - 'Remove Favorite', - `Remove "${config.getItemTitle(item)}" from ${config.favoritesSectionTitle.toLowerCase()}?`, - [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: 'Remove', - style: 'destructive', - onPress: () => onToggleFavorite(item) - } - ] - ); - }; - - // Render status with StatusDot (DRY helper - matches Item.tsx detail style) const renderStatus = (status: { text: string; color: string; dotColor: string; isPulsing?: boolean } | null | undefined) => { if (!status) return null; return ( @@ -394,22 +162,50 @@ export function SearchableListSelector(props: SearchableListSelectorProps) isPulsing={status.isPulsing} size={6} /> - + {status.text} ); }; - // Render individual item (for recent items) - const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false) => { + const renderFavoriteToggle = (item: T, isFavorite: boolean) => { + if (!showFavorites || !onToggleFavorite) return null; + + const canRemove = config.canRemoveFavorite?.(item) ?? true; + const disabled = isFavorite && !canRemove; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const color = isFavorite ? selectedColor : theme.colors.textSecondary; + + return ( + { + e.stopPropagation(); + if (disabled) return; + onToggleFavorite(item); + }} + > + + + ); + }; + + const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false, forFavorite = false) => { const itemId = config.getItemId(item); const title = config.getItemTitle(item); const subtitle = forRecent && config.getRecentItemSubtitle @@ -417,8 +213,12 @@ export function SearchableListSelector(props: SearchableListSelectorProps) : config.getItemSubtitle?.(item); const icon = forRecent && config.getRecentItemIcon ? config.getRecentItemIcon(item) - : config.getItemIcon(item); + : forFavorite && config.getFavoriteItemIcon + ? config.getFavoriteItemIcon(item) + : config.getItemIcon(item); const status = config.getItemStatus?.(item, theme); + const isFavorite = favoriteIds.has(itemId) || forFavorite; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; return ( (props: SearchableListSelectorProps) subtitle={subtitle} subtitleLines={0} leftElement={icon} - rightElement={ + rightElement={( {renderStatus(status)} - {isSelected && ( + - )} + + {renderFavoriteToggle(item, isFavorite)} - } - onPress={() => handleSelectItem(item)} + )} + onPress={() => onSelect(item)} showChevron={false} selected={isSelected} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} /> ); }; - // "Show More" logic (matches Working Directory pattern) - const itemsToShow = (inputText.trim() && isUserTyping.current) || showAllRecent + const showAllRecentItems = showAllRecent || inputText.trim().length > 0; + const recentItemsToShow = showAllRecentItems ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); + const hasRecentGroupBase = showRecent && baseRecentItems.length > 0; + const hasFavoritesGroupBase = showFavorites && favoriteItems.length > 0; + const hasAllGroupBase = showAll && baseAllItems.length > 0; + + const effectiveSearchPlacement = React.useMemo(() => { + if (!showSearch) return 'header' as const; + if (searchPlacement === 'header') return 'header' as const; + + if (searchPlacement === 'favorites' && hasFavoritesGroupBase) return 'favorites' as const; + if (searchPlacement === 'recent' && hasRecentGroupBase) return 'recent' as const; + if (searchPlacement === 'all' && hasAllGroupBase) return 'all' as const; + + // Fall back to the first visible group so the search never disappears. + if (hasFavoritesGroupBase) return 'favorites' as const; + if (hasRecentGroupBase) return 'recent' as const; + if (hasAllGroupBase) return 'all' as const; + return 'header' as const; + }, [hasAllGroupBase, hasFavoritesGroupBase, hasRecentGroupBase, searchPlacement, showSearch]); + + const showNoMatches = inputText.trim().length > 0; + const shouldRenderRecentGroup = showRecent && (filteredRecentItems.length > 0 || (effectiveSearchPlacement === 'recent' && showSearch && hasRecentGroupBase)); + const shouldRenderFavoritesGroup = showFavorites && (filteredFavoriteItems.length > 0 || (effectiveSearchPlacement === 'favorites' && showSearch && hasFavoritesGroupBase)); + const shouldRenderAllGroup = showAll && (filteredItems.length > 0 || (effectiveSearchPlacement === 'all' && showSearch && hasAllGroupBase)); + + const searchNodeHeader = showSearch ? ( + + ) : null; + + const searchNodeEmbedded = showSearch ? ( + + ) : null; + + const renderEmptyRow = (title: string) => ( + + ); + return ( <> - {/* Search Input */} - {showSearch && ( - - - - - - - {inputText.trim() && ( - ([ - styles.clearButton, - { opacity: pressed ? 0.6 : 0.8 } - ])} - > - - - )} - - - {showFavorites && onToggleFavorite && ( - ([ - styles.favoriteButton, - { - backgroundColor: canAddToFavorites - ? theme.colors.button.primary.background - : theme.colors.divider, - opacity: pressed ? 0.7 : 1, - } - ])} - > - - + {effectiveSearchPlacement === 'header' && searchNodeHeader} + + {shouldRenderRecentGroup && ( + + {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} + {recentItemsToShow.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : recentItemsToShow.map((item, index, arr) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === arr.length - 1; + + const showDivider = !isLast || + (!inputText.trim() && + !showAllRecent && + filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); + + return renderItem(item, isSelected, isLast, showDivider, true, false); + })} + + {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && recentItemsToShow.length > 0 && ( + setShowAllRecent(!showAllRecent)} + showChevron={false} + showDivider={false} + titleStyle={styles.showMoreTitle} + /> )} - + )} - {/* Recent Items Section */} - {showRecent && filteredRecentItems.length > 0 && ( - <> - - {config.recentSectionTitle} - - - - {showRecentSection && ( - - {itemsToShow.map((item, index, arr) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === arr.length - 1; - - // Override divider logic for "Show More" button - const showDivider = !isLast || - (!(inputText.trim() && isUserTyping.current) && - !showAllRecent && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); - - return renderItem(item, isSelected, isLast, showDivider, true); - })} - - {/* Show More Button */} - {!(inputText.trim() && isUserTyping.current) && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && ( - setShowAllRecent(!showAllRecent)} - showChevron={false} - showDivider={false} - titleStyle={styles.showMoreTitle} - /> - )} - - )} - + {shouldRenderFavoritesGroup && ( + + {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} + {filteredFavoriteItems.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : filteredFavoriteItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredFavoriteItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, true); + })} + )} - {/* Favorites Section */} - {showFavorites && filteredFavoriteItems.length > 0 && ( - <> - - {config.favoritesSectionTitle} - - - - {showFavoritesSection && ( - - {filteredFavoriteItems.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === filteredFavoriteItems.length - 1; - - const title = config.getItemTitle(item); - const subtitle = config.getItemSubtitle?.(item); - const icon = config.getFavoriteItemIcon?.(item) || config.getItemIcon(item); - const status = config.getItemStatus?.(item, theme); - const canRemove = config.canRemoveFavorite?.(item) ?? true; - - return ( - - {renderStatus(status)} - {isSelected && ( - - )} - {onToggleFavorite && canRemove && ( - { - e.stopPropagation(); - handleRemoveFavorite(item); - }} - > - - - )} - - } - onPress={() => handleSelectItem(item)} - showChevron={false} - selected={isSelected} - showDivider={!isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} - /> - ); - })} - - )} - + {shouldRenderAllGroup && ( + + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} + {filteredItems.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : filteredItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, false); + })} + )} - {/* All Items Section - always shown when items provided */} - {items.length > 0 && ( - <> - - - {config.recentSectionTitle.replace('Recent ', 'All ')} - - - - - {showAllItemsSection && ( - - {items.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === items.length - 1; - - return renderItem(item, isSelected, isLast, !isLast, false); - })} - - )} - + {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && ( + + {effectiveSearchPlacement !== 'header' && searchNodeEmbedded} + {renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage)} + )} ); diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index 33aefd357..bc1f2d3c2 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -1,142 +1,81 @@ import React from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; import { t } from '@/text'; -interface SessionTypeSelectorProps { +export interface SessionTypeSelectorProps { value: 'simple' | 'worktree'; onChange: (value: 'simple' | 'worktree') => void; + title?: string | null; } const stylesheet = StyleSheet.create((theme) => ({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: Platform.select({ default: 12, android: 16 }), - marginBottom: 12, - overflow: 'hidden', - }, - title: { - fontSize: 13, - color: theme.colors.textSecondary, - marginBottom: 8, - marginLeft: 16, - marginTop: 12, - ...Typography.default('semiBold'), - }, - optionContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - minHeight: 44, - }, - optionPressed: { - backgroundColor: theme.colors.surfacePressed, - }, - radioButton: { + radioOuter: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center', - marginRight: 12, }, - radioButtonActive: { + radioActive: { borderColor: theme.colors.radio.active, }, - radioButtonInactive: { + radioInactive: { borderColor: theme.colors.radio.inactive, }, - radioButtonDot: { + radioDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.colors.radio.dot, }, - optionContent: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - optionLabel: { - fontSize: 16, - ...Typography.default('regular'), - }, - optionLabelActive: { - color: theme.colors.text, - }, - optionLabelInactive: { - color: theme.colors.text, - }, - divider: { - height: Platform.select({ ios: 0.33, default: 0.5 }), - backgroundColor: theme.colors.divider, - marginLeft: 48, - }, })); -export const SessionTypeSelector: React.FC = ({ value, onChange }) => { - const { theme } = useUnistyles(); +export function SessionTypeSelectorRows({ value, onChange }: Pick) { const styles = stylesheet; - const handlePress = (type: 'simple' | 'worktree') => { - onChange(type); - }; - return ( - - {t('newSession.sessionType.title')} - - handlePress('simple')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'simple' && } - - - - {t('newSession.sessionType.simple')} - - - + <> + + {value === 'simple' && } + + )} + selected={value === 'simple'} + onPress={() => onChange('simple')} + showChevron={false} + showDivider={true} + /> + + + {value === 'worktree' && } + + )} + selected={value === 'worktree'} + onPress={() => onChange('worktree')} + showChevron={false} + showDivider={false} + /> + + ); +} - +export function SessionTypeSelector({ value, onChange, title = t('newSession.sessionType.title') }: SessionTypeSelectorProps) { + if (title === null) { + return ; + } - handlePress('worktree')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'worktree' && } - - - - {t('newSession.sessionType.worktree')} - - - - + return ( + + + ); -}; \ No newline at end of file +} diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 249345e97..540603230 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -37,6 +37,7 @@ export const SettingsView = React.memo(function SettingsView() { const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled'); const isPro = __DEV__ || useEntitlement('pro'); const experiments = useSetting('experiments'); + const useProfiles = useSetting('useProfiles'); const isCustomServer = isUsingCustomServer(); const allMachines = useAllMachines(); const profile = useProfile(); @@ -110,7 +111,7 @@ export const SettingsView = React.memo(function SettingsView() { // Anthropic connection const [connectingAnthropic, connectAnthropic] = useHappyAction(async () => { - router.push('/settings/connect/claude'); + router.push('/(app)/settings/connect/claude'); }); // Anthropic disconnection @@ -302,38 +303,40 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.account')} subtitle={t('settings.accountSubtitle')} icon={} - onPress={() => router.push('/settings/account')} + onPress={() => router.push('/(app)/settings/account')} /> } - onPress={() => router.push('/settings/appearance')} + onPress={() => router.push('/(app)/settings/appearance')} /> } - onPress={() => router.push('/settings/voice')} + onPress={() => router.push('/(app)/settings/voice')} /> } - onPress={() => router.push('/settings/features')} - /> - } - onPress={() => router.push('/settings/profiles')} + onPress={() => router.push('/(app)/settings/features')} /> + {useProfiles && ( + } + onPress={() => router.push('/(app)/settings/profiles')} + /> + )} {experiments && ( } - onPress={() => router.push('/settings/usage')} + onPress={() => router.push('/(app)/settings/usage')} /> )} @@ -344,7 +347,7 @@ export const SettingsView = React.memo(function SettingsView() { } - onPress={() => router.push('/dev')} + onPress={() => router.push('/(app)/dev')} /> )} @@ -357,7 +360,7 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => { trackWhatsNewClicked(); - router.push('/changelog'); + router.push('/(app)/changelog'); }} /> ({ + track: { + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + padding: PADDING, + justifyContent: 'center', + }, + thumb: { + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2, + }, +})); + +export const Switch = ({ value, disabled, onValueChange, style, ...rest }: SwitchProps) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const translateX = value ? TRACK_WIDTH - THUMB_SIZE - PADDING * 2 : 0; + + return ( + onValueChange?.(!value)} + style={({ pressed }) => [ + style as any, + { opacity: disabled ? 0.6 : pressed ? 0.85 : 1 }, + ]} + > + + + + + ); +}; diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx new file mode 100644 index 000000000..b726056e0 --- /dev/null +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { t } from '@/text'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from '@/utils/envVarTemplate'; + +export interface EnvironmentVariablesPreviewModalProps { + environmentVariables: Record; + machineId: string | null; + machineName?: string | null; + profileName?: string | null; + onClose: () => void; +} + +function isSecretLike(name: string) { + return /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i.test(name); +} + +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + +function extractVarRefsFromValue(value: string): string[] { + const refs: string[] = []; + if (!value) return refs; + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingBottom: 16, + flexGrow: 1, + }, + section: { + paddingHorizontal: 16, + paddingTop: 12, + }, + descriptionText: { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + machineNameText: { + color: theme.colors.status.connected, + ...Typography.default('semiBold'), + }, + detailText: { + fontSize: 13, + ...Typography.default('semiBold'), + }, +})); + +export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { height: windowHeight } = useWindowDimensions(); + const scrollRef = React.useRef(null); + const scrollYRef = React.useRef(0); + + const handleScroll = React.useCallback((e: any) => { + scrollYRef.current = e?.nativeEvent?.contentOffset?.y ?? 0; + }, []); + + // On web, RN ScrollView inside a modal doesn't reliably respond to mouse wheel / trackpad scroll. + // Manually translate wheel deltas into scrollTo. + const handleWheel = React.useCallback((e: any) => { + if (Platform.OS !== 'web') return; + const deltaY = e?.deltaY; + if (typeof deltaY !== 'number' || Number.isNaN(deltaY)) return; + + if (e?.cancelable) { + e?.preventDefault?.(); + } + e?.stopPropagation?.(); + scrollRef.current?.scrollTo({ y: Math.max(0, scrollYRef.current + deltaY), animated: false }); + }, []); + + const envVarEntries = React.useMemo(() => { + return Object.entries(props.environmentVariables) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [props.environmentVariables]); + + const refsToQuery = React.useMemo(() => { + const refs = new Set(); + envVarEntries.forEach((envVar) => { + // Query both target keys and any referenced keys so preview can show the effective spawned value. + refs.add(envVar.name); + extractVarRefsFromValue(envVar.value).forEach((ref) => refs.add(ref)); + }); + return Array.from(refs); + }, [envVarEntries]); + + const sensitiveHints = React.useMemo(() => { + const hints: Record = {}; + envVarEntries.forEach((envVar) => { + const refs = extractVarRefsFromValue(envVar.value); + const isSensitive = isSecretLike(envVar.name) || refs.some(isSecretLike); + if (isSensitive) { + hints[envVar.name] = true; + refs.forEach((ref) => { hints[ref] = true; }); + } + }); + return hints; + }, [envVarEntries]); + + const { meta: machineEnv, policy: machineEnvPolicy } = useEnvironmentVariables( + props.machineId, + refsToQuery, + { extraEnv: props.environmentVariables, sensitiveHints }, + ); + + const title = props.profileName + ? t('profiles.environmentVariables.previewModal.titleWithProfile', { profileName: props.profileName }) + : t('profiles.environmentVariables.title'); + const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); + + return ( + + + + {title} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + + {t('profiles.environmentVariables.previewModal.descriptionPrefix')}{' '} + {props.machineName ? ( + + {props.machineName} + + ) : ( + t('profiles.environmentVariables.previewModal.descriptionFallbackMachine') + )} + {t('profiles.environmentVariables.previewModal.descriptionSuffix')} + + + + {envVarEntries.length === 0 ? ( + + + {t('profiles.environmentVariables.previewModal.emptyMessage')} + + + ) : ( + + {envVarEntries.map((envVar, idx) => { + const parsed = parseEnvVarTemplate(envVar.value); + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0]; + const secret = isSecretLike(envVar.name) || (primaryRef ? isSecretLike(primaryRef) : false); + + const hasMachineContext = Boolean(props.machineId); + const targetEntry = machineEnv?.[envVar.name]; + const resolvedValue = parsed?.sourceVar ? machineEnv?.[parsed.sourceVar] : undefined; + const isMachineBased = Boolean(refs.length > 0); + + let displayValue: string; + if (hasMachineContext && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + displayValue = targetEntry.value ?? emptyValue; + } else if (targetEntry.display === 'hidden') { + displayValue = '•••'; + } else { + displayValue = emptyValue; + } + } else if (secret) { + // If daemon policy is known and allows showing secrets, we would have used targetEntry above. + displayValue = machineEnvPolicy === 'full' || machineEnvPolicy === 'redacted' ? (envVar.value || emptyValue) : '•••'; + } else if (parsed) { + if (!hasMachineContext) { + displayValue = formatEnvVarTemplate(parsed); + } else if (resolvedValue === undefined) { + displayValue = `${formatEnvVarTemplate(parsed)} ${t('profiles.environmentVariables.previewModal.checkingSuffix')}`; + } else if (resolvedValue.display === 'hidden') { + displayValue = '•••'; + } else if (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '') { + displayValue = parsed.fallback ? parsed.fallback : emptyValue; + } else { + displayValue = resolvedValue.value ?? emptyValue; + } + } else { + displayValue = envVar.value || emptyValue; + } + + type DetailKind = 'fixed' | 'machine' | 'checking' | 'fallback' | 'missing'; + + const detailKind: DetailKind | undefined = (() => { + if (secret) return undefined; + if (!isMachineBased) return 'fixed'; + if (!hasMachineContext) return 'machine'; + if (parsed?.sourceVar && resolvedValue === undefined) return 'checking'; + if (parsed?.sourceVar && resolvedValue && (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '')) { + return parsed?.fallback ? 'fallback' : 'missing'; + } + return 'machine'; + })(); + + const detailLabel = (() => { + if (!detailKind) return undefined; + return detailKind === 'fixed' + ? t('profiles.environmentVariables.previewModal.detail.fixed') + : detailKind === 'machine' + ? t('profiles.environmentVariables.previewModal.detail.machine') + : detailKind === 'checking' + ? t('profiles.environmentVariables.previewModal.detail.checking') + : detailKind === 'fallback' + ? t('profiles.environmentVariables.previewModal.detail.fallback') + : t('profiles.environmentVariables.previewModal.detail.missing'); + })(); + + const detailColor = + detailKind === 'machine' + ? theme.colors.status.connected + : detailKind === 'fallback' || detailKind === 'missing' + ? theme.colors.warning + : theme.colors.textSecondary; + + const rightElement = (() => { + if (secret) return undefined; + if (!isMachineBased) return undefined; + if (!hasMachineContext || detailKind === 'checking') { + return ; + } + return ; + })(); + + const canCopy = (() => { + if (secret) return false; + return Boolean(displayValue); + })(); + + return ( + + ); + })} + + )} + + + ); +} diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx new file mode 100644 index 000000000..e2ef825d8 --- /dev/null +++ b/sources/components/newSession/MachineSelector.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; +import type { Machine } from '@/sync/storageTypes'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { t } from '@/text'; + +export interface MachineSelectorProps { + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines?: Machine[]; + favoriteMachines?: Machine[]; + onSelect: (machine: Machine) => void; + onToggleFavorite?: (machine: Machine) => void; + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + allSectionTitle?: string; + noItemsMessage?: string; +} + +export function MachineSelector({ + machines, + selectedMachine, + recentMachines = [], + favoriteMachines = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + searchPlacement = 'header', + searchPlaceholder: searchPlaceholderProp, + recentSectionTitle: recentSectionTitleProp, + favoritesSectionTitle: favoritesSectionTitleProp, + allSectionTitle: allSectionTitleProp, + noItemsMessage: noItemsMessageProp, +}: MachineSelectorProps) { + const { theme } = useUnistyles(); + + const searchPlaceholder = searchPlaceholderProp ?? t('newSession.machinePicker.searchPlaceholder'); + const recentSectionTitle = recentSectionTitleProp ?? t('newSession.machinePicker.recentTitle'); + const favoritesSectionTitle = favoritesSectionTitleProp ?? t('newSession.machinePicker.favoritesTitle'); + const allSectionTitle = allSectionTitleProp ?? t('newSession.machinePicker.allTitle'); + const noItemsMessage = noItemsMessageProp ?? t('newSession.machinePicker.emptyMessage'); + + return ( + + config={{ + getItemId: (machine) => machine.id, + getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + getItemSubtitle: undefined, + getItemIcon: () => ( + + ), + getRecentItemIcon: () => ( + + ), + getItemStatus: (machine) => { + const offline = !isMachineOnline(machine); + return { + text: offline ? t('status.offline') : t('status.online'), + color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + isPulsing: !offline, + }; + }, + formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + parseFromDisplay: (text) => { + return machines.find(m => + m.metadata?.displayName === text || m.metadata?.host === text || m.id === text + ) || null; + }, + filterItem: (machine, searchText) => { + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const id = machine.id.toLowerCase(); + const search = searchText.toLowerCase(); + return displayName.includes(search) || host.includes(search) || id.includes(search); + }, + searchPlaceholder, + recentSectionTitle, + favoritesSectionTitle, + allSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + allowCustomInput: false, + }} + items={machines} + recentItems={recentMachines} + favoriteItems={favoriteMachines} + selectedItem={selectedMachine} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + searchPlacement={searchPlacement} + /> + ); +} diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx new file mode 100644 index 000000000..72f071f52 --- /dev/null +++ b/sources/components/newSession/PathSelector.tsx @@ -0,0 +1,596 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, Pressable, TextInput, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { SearchHeader } from '@/components/SearchHeader'; +import { Typography } from '@/constants/Typography'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; +import { t } from '@/text'; + +type PathSelectorBaseProps = { + machineHomeDir: string; + selectedPath: string; + onChangeSelectedPath: (path: string) => void; + onSubmitSelectedPath?: (path: string) => void; + submitBehavior?: 'showRow' | 'confirm'; + recentPaths: string[]; + usePickerSearch: boolean; + searchVariant?: 'header' | 'group' | 'none'; + favoriteDirectories: string[]; + onChangeFavoriteDirectories: (dirs: string[]) => void; +}; + +type PathSelectorControlledSearchProps = { + searchQuery: string; + onChangeSearchQuery: (text: string) => void; +}; + +type PathSelectorUncontrolledSearchProps = { + searchQuery?: undefined; + onChangeSearchQuery?: undefined; +}; + +export type PathSelectorProps = + & PathSelectorBaseProps + & (PathSelectorControlledSearchProps | PathSelectorUncontrolledSearchProps); + +const ITEM_RIGHT_GAP = 16; + +const stylesheet = StyleSheet.create((theme) => ({ + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, + searchHeaderContainer: { + backgroundColor: 'transparent', + borderBottomWidth: 0, + }, + rightElementRow: { + flexDirection: 'row', + alignItems: 'center', + gap: ITEM_RIGHT_GAP, + }, + iconSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, +})); + +export function PathSelector({ + machineHomeDir, + selectedPath, + onChangeSelectedPath, + recentPaths, + usePickerSearch, + searchVariant = 'header', + searchQuery: controlledSearchQuery, + onChangeSearchQuery: onChangeSearchQueryProp, + favoriteDirectories, + onChangeFavoriteDirectories, + onSubmitSelectedPath, + submitBehavior = 'showRow', +}: PathSelectorProps) { + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const styles = stylesheet; + const inputRef = useRef(null); + const searchInputRef = useRef(null); + const searchWasFocusedRef = useRef(false); + + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); + const isSearchQueryControlled = controlledSearchQuery !== undefined && onChangeSearchQueryProp !== undefined; + const searchQuery = isSearchQueryControlled ? controlledSearchQuery : uncontrolledSearchQuery; + const setSearchQuery = isSearchQueryControlled ? onChangeSearchQueryProp : setUncontrolledSearchQuery; + const [submittedCustomPath, setSubmittedCustomPath] = useState(null); + + const suggestedPaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + }, [machineHomeDir]); + + const favoritePaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + const seen = new Set(); + const ordered: string[] = []; + for (const p of paths) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoriteDirectories, machineHomeDir]); + + const filteredFavoritePaths = useMemo(() => { + if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; + const query = searchQuery.toLowerCase(); + return favoritePaths.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, usePickerSearch]); + + const filteredRecentPaths = useMemo(() => { + const base = recentPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); + + const filteredSuggestedPaths = useMemo(() => { + const base = suggestedPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); + + const baseRecentPaths = useMemo(() => { + return recentPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, recentPaths]); + + const baseSuggestedPaths = useMemo(() => { + return suggestedPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, suggestedPaths]); + + const effectiveGroupSearchPlacement = useMemo(() => { + if (!usePickerSearch || searchVariant !== 'group') return null as null | 'favorites' | 'recent' | 'suggested' | 'fallback'; + const preferred: 'suggested' | 'recent' | 'favorites' | 'fallback' = + baseSuggestedPaths.length > 0 ? 'suggested' + : baseRecentPaths.length > 0 ? 'recent' + : favoritePaths.length > 0 ? 'favorites' + : 'fallback'; + + if (preferred === 'suggested') { + if (filteredSuggestedPaths.length > 0) return 'suggested'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + return 'suggested'; + } + + if (preferred === 'recent') { + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'recent'; + } + + if (preferred === 'favorites') { + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'favorites'; + } + + return 'fallback'; + }, [ + baseRecentPaths.length, + baseSuggestedPaths.length, + favoritePaths.length, + filteredFavoritePaths.length, + filteredRecentPaths.length, + filteredSuggestedPaths.length, + searchVariant, + usePickerSearch, + ]); + + useEffect(() => { + if (!usePickerSearch || searchVariant !== 'group') return; + if (!searchWasFocusedRef.current) return; + + const id = setTimeout(() => { + // Keep the search box usable while it moves between groups by restoring focus. + // (The underlying TextInput unmounts/remounts as placement changes.) + try { + searchInputRef.current?.focus?.(); + } catch { } + }, 0); + return () => clearTimeout(id); + }, [effectiveGroupSearchPlacement, searchVariant, usePickerSearch]); + + const showNoMatchesRow = usePickerSearch && searchQuery.trim().length > 0; + const shouldRenderFavoritesGroup = filteredFavoritePaths.length > 0 || effectiveGroupSearchPlacement === 'favorites'; + const shouldRenderRecentGroup = filteredRecentPaths.length > 0 || effectiveGroupSearchPlacement === 'recent'; + const shouldRenderSuggestedGroup = filteredSuggestedPaths.length > 0 || effectiveGroupSearchPlacement === 'suggested'; + const shouldRenderFallbackGroup = effectiveGroupSearchPlacement === 'fallback'; + + const toggleFavorite = React.useCallback((absolutePath: string) => { + const homeDir = machineHomeDir || '/home'; + + const relativePath = formatPathRelativeToHome(absolutePath, homeDir); + const resolved = resolveAbsolutePath(relativePath, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); + + onChangeFavoriteDirectories(isInFavorites + ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) + : [...favoriteDirectories, relativePath] + ); + }, [favoriteDirectories, machineHomeDir, onChangeFavoriteDirectories]); + + const handleChangeSelectedPath = React.useCallback((text: string) => { + onChangeSelectedPath(text); + if (submittedCustomPath && text.trim() !== submittedCustomPath) { + setSubmittedCustomPath(null); + } + }, [onChangeSelectedPath, submittedCustomPath]); + + const setPathAndFocus = React.useCallback((path: string) => { + onChangeSelectedPath(path); + setSubmittedCustomPath(null); + setTimeout(() => inputRef.current?.focus(), 50); + }, [onChangeSelectedPath]); + + const handleSubmitPath = React.useCallback(() => { + const trimmed = selectedPath.trim(); + if (!trimmed) return; + + if (trimmed !== selectedPath) { + onChangeSelectedPath(trimmed); + } + + onSubmitSelectedPath?.(trimmed); + if (submitBehavior !== 'confirm') { + setSubmittedCustomPath(trimmed); + } + }, [onChangeSelectedPath, onSubmitSelectedPath, selectedPath, submitBehavior]); + + const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + + ); + }, [selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const renderCustomRightElement = React.useCallback((absolutePath: string) => { + const isFavorite = favoritePaths.includes(absolutePath); + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + { + e.stopPropagation(); + setSubmittedCustomPath(null); + onChangeSelectedPath(''); + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + + + + ); + }, [favoritePaths, onChangeSelectedPath, selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const showSubmittedCustomPathRow = useMemo(() => { + if (!submittedCustomPath) return null; + const trimmed = selectedPath.trim(); + if (!trimmed) return null; + if (trimmed !== submittedCustomPath) return null; + + const visiblePaths = new Set([ + ...filteredFavoritePaths, + ...filteredRecentPaths, + ...filteredSuggestedPaths, + ]); + if (visiblePaths.has(trimmed)) return null; + + return trimmed; + }, [filteredFavoritePaths, filteredRecentPaths, filteredSuggestedPaths, selectedPath, submittedCustomPath]); + + return ( + <> + {usePickerSearch && searchVariant === 'header' && ( + + )} + + + + + + + + + + {showSubmittedCustomPathRow && ( + + } + onPress={() => setTimeout(() => inputRef.current?.focus(), 50)} + selected={true} + showChevron={false} + rightElement={renderCustomRightElement(showSubmittedCustomPathRow)} + showDivider={false} + /> + + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( + + {effectiveGroupSearchPlacement === 'recent' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredRecentPaths.length === 0 + ? ( + + ) + : filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {shouldRenderFavoritesGroup && ( + + {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredFavoritePaths.length === 0 + ? ( + + ) + : filteredFavoritePaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length > 0 && searchVariant !== 'group' && ( + + {filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && ( + + {effectiveGroupSearchPlacement === 'suggested' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredSuggestedPaths.length === 0 + ? ( + + ) + : filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && searchVariant !== 'group' && ( + + {filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && ( + + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + + + )} + + ); +} diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx new file mode 100644 index 000000000..2ca9f1bfe --- /dev/null +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Text, View, type ViewStyle } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import type { AIBackendProfile } from '@/sync/settings'; +import { useSetting } from '@/sync/storage'; + +type Props = { + profile: Pick; + size?: number; + style?: ViewStyle; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + stack: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0, + }, + glyph: { + color: theme.colors.textSecondary, + ...Typography.default(), + }, +})); + +export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { + useUnistyles(); // Subscribe to theme changes for re-render + const styles = stylesheet; + const experimentsEnabled = useSetting('experiments'); + + const hasClaude = !!profile.compatibility?.claude; + const hasCodex = !!profile.compatibility?.codex; + const hasGemini = experimentsEnabled && !!profile.compatibility?.gemini; + + const glyphs = React.useMemo(() => { + const items: Array<{ key: string; glyph: string; factor: number }> = []; + if (hasClaude) items.push({ key: 'claude', glyph: '✳', factor: 1.14 }); + if (hasCodex) items.push({ key: 'codex', glyph: '꩜', factor: 0.82 }); + if (hasGemini) items.push({ key: 'gemini', glyph: '✦', factor: 0.88 }); + if (items.length === 0) items.push({ key: 'none', glyph: '•', factor: 0.85 }); + return items; + }, [hasClaude, hasCodex, hasGemini]); + + const multiScale = glyphs.length === 1 ? 1 : glyphs.length === 2 ? 0.6 : 0.5; + + return ( + + {glyphs.length === 1 ? ( + + {glyphs[0].glyph} + + ) : ( + + {glyphs.map((item) => { + const fontSize = Math.round(size * multiScale * item.factor); + return ( + + {item.glyph} + + ); + })} + + )} + + ); +} diff --git a/sources/components/profileActions.ts b/sources/components/profileActions.ts new file mode 100644 index 000000000..7fcdf6275 --- /dev/null +++ b/sources/components/profileActions.ts @@ -0,0 +1,64 @@ +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import type { AIBackendProfile } from '@/sync/settings'; +import { t } from '@/text'; + +export function buildProfileActions(params: { + profile: AIBackendProfile; + isFavorite: boolean; + favoriteActionColor?: string; + nonFavoriteActionColor?: string; + onToggleFavorite: () => void; + onEdit: () => void; + onDuplicate: () => void; + onDelete?: () => void; + onViewEnvironmentVariables?: () => void; +}): ItemAction[] { + const actions: ItemAction[] = []; + + if (params.onViewEnvironmentVariables) { + actions.push({ + id: 'envVars', + title: t('profiles.actions.viewEnvironmentVariables'), + icon: 'list-outline', + onPress: params.onViewEnvironmentVariables, + }); + } + + const favoriteColor = params.isFavorite ? params.favoriteActionColor : params.nonFavoriteActionColor; + const favoriteAction: ItemAction = { + id: 'favorite', + title: params.isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: params.isFavorite ? 'star' : 'star-outline', + onPress: params.onToggleFavorite, + }; + if (favoriteColor) { + favoriteAction.color = favoriteColor; + } + actions.push(favoriteAction); + + actions.push({ + id: 'edit', + title: t('profiles.actions.editProfile'), + icon: 'create-outline', + onPress: params.onEdit, + }); + + actions.push({ + id: 'copy', + title: t('profiles.actions.duplicateProfile'), + icon: 'copy-outline', + onPress: params.onDuplicate, + }); + + if (!params.profile.isBuiltIn && params.onDelete) { + actions.push({ + id: 'delete', + title: t('profiles.actions.deleteProfile'), + icon: 'trash-outline', + destructive: true, + onPress: params.onDelete, + }); + } + + return actions; +} diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 696f8315e..55e991b08 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -181,9 +181,12 @@ export const knownTools = { return path; } // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } } return t('tools.names.readFile'); }, @@ -211,9 +214,12 @@ export const knownTools = { 'read': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } } if (typeof opts.tool.input.file_path === 'string') { const path = resolvePath(opts.tool.input.file_path, opts.metadata); @@ -592,7 +598,7 @@ export const knownTools = { } }, 'change_title': { - title: 'Change Title', + title: t('tools.names.changeTitle'), icon: ICON_EDIT, minimal: true, noStatus: true, @@ -617,15 +623,15 @@ export const knownTools = { let filePath: string | undefined; // 1. Check toolCall.content[0].path - if (opts.tool.input?.toolCall?.content?.[0]?.path) { + if (typeof opts.tool.input?.toolCall?.content?.[0]?.path === 'string') { filePath = opts.tool.input.toolCall.content[0].path; } // 2. Check toolCall.title (has nice "Writing to ..." format) - else if (opts.tool.input?.toolCall?.title) { + else if (typeof opts.tool.input?.toolCall?.title === 'string') { return opts.tool.input.toolCall.title; } // 3. Check input[0].path (array format) - else if (Array.isArray(opts.tool.input?.input) && opts.tool.input.input[0]?.path) { + else if (Array.isArray(opts.tool.input?.input) && typeof opts.tool.input.input[0]?.path === 'string') { filePath = opts.tool.input.input[0].path; } // 4. Check direct path field @@ -633,7 +639,7 @@ export const knownTools = { filePath = opts.tool.input.path; } - if (filePath) { + if (typeof filePath === 'string' && filePath.length > 0) { return resolvePath(filePath, opts.metadata); } return t('tools.names.editFile'); @@ -657,7 +663,7 @@ export const knownTools = { 'execute': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Gemini sends nice title in toolCall.title - if (opts.tool.input?.toolCall?.title) { + if (typeof opts.tool.input?.toolCall?.title === 'string') { // Title is like "rm file.txt [cwd /path] (description)" // Extract just the command part before [ const fullTitle = opts.tool.input.toolCall.title; @@ -674,7 +680,7 @@ export const knownTools = { input: z.object({}).partial().loose(), extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Extract description from parentheses at the end - if (opts.tool.input?.toolCall?.title) { + if (typeof opts.tool.input?.toolCall?.title === 'string') { const title = opts.tool.input.toolCall.title; const parenMatch = title.match(/\(([^)]+)\)$/); if (parenMatch) { diff --git a/sources/components/tools/views/GeminiExecuteView.tsx b/sources/components/tools/views/GeminiExecuteView.tsx index 86fe20e84..3101a78f9 100644 --- a/sources/components/tools/views/GeminiExecuteView.tsx +++ b/sources/components/tools/views/GeminiExecuteView.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../tools/ToolSectionView'; import { ToolViewProps } from './_all'; import { CodeView } from '@/components/CodeView'; +import { t } from '@/text'; /** * Extract execute command info from Gemini's nested input format. @@ -62,7 +63,7 @@ export const GeminiExecuteView = React.memo(({ tool }) => { {(description || cwd) && ( {cwd && ( - 📁 {cwd} + {t('tools.geminiExecute.cwd', { cwd })} )} {description && ( {description} @@ -89,4 +90,3 @@ const styles = StyleSheet.create((theme) => ({ fontStyle: 'italic', }, })); - diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index 325404655..e839a6b10 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -32,23 +32,27 @@ export function resolveEnvVarSubstitution( value: string, daemonEnv: EnvironmentVariables ): string | null { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset). // Group 1: Variable name (required) - // Group 2: Default value (optional) - includes the :- or := prefix - // Group 3: The actual default value without prefix (optional) - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-(.*))?(:=(.*))?}$/); + // Group 2: Default value (optional) + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=](.*))?\}$/); if (match) { const varName = match[1]; - const defaultValue = match[3] ?? match[5]; // :- default or := default + const defaultValue = match[2]; // :- default const daemonValue = daemonEnv[varName]; - if (daemonValue !== undefined && daemonValue !== null) { + // For ${VAR:-default} and ${VAR:=default}, treat empty string as "missing" (bash semantics). + // For plain ${VAR}, preserve empty string (it is an explicit value). + if (daemonValue !== undefined && daemonValue !== null && daemonValue !== '') { return daemonValue; } // Variable not set - use default if provided if (defaultValue !== undefined) { return defaultValue; } + if (daemonValue === '') { + return ''; + } return null; } // Not a substitution - return literal value @@ -76,9 +80,9 @@ export function extractEnvVarReferences( const refs = new Set(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR}, ${VAR:-default}, or ${VAR:=default} (bash parameter expansion subset). // Only capture the variable name, not the default value - const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*|:=.*)?\}$/); + const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=].*)?\}$/); if (match) { // Variable name is already validated by regex pattern [A-Z_][A-Z0-9_]* refs.add(match[1]); diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts index bda5c547b..7a839a9ae 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -1,6 +1,13 @@ import { useState, useEffect } from 'react'; import { machineBash } from '@/sync/ops'; +function debugLog(...args: unknown[]) { + if (__DEV__) { + // eslint-disable-next-line no-console + console.log(...args); + } +} + interface CLIAvailability { claude: boolean | null; // null = unknown/loading, true = installed, false = not installed codex: boolean | null; @@ -52,7 +59,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { const detectCLIs = async () => { // Set detecting flag (non-blocking - UI stays responsive) setAvailability(prev => ({ ...prev, isDetecting: true })); - console.log('[useCLIDetection] Starting detection for machineId:', machineId); + debugLog('[useCLIDetection] Starting detection for machineId:', machineId); try { // Use single bash command to check both CLIs efficiently @@ -66,7 +73,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { ); if (cancelled) return; - console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); + debugLog('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); if (result.success && result.exitCode === 0) { // Parse output: "claude:true\ncodex:false\ngemini:false" @@ -80,7 +87,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { } }); - console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + debugLog('[useCLIDetection] Parsed CLI status:', cliStatus); setAvailability({ claude: cliStatus.claude ?? null, codex: cliStatus.codex ?? null, @@ -90,7 +97,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { }); } else { // Detection command failed - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); + debugLog('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); setAvailability({ claude: null, codex: null, @@ -104,7 +111,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { if (cancelled) return; // Network/RPC error - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Network/RPC error:', error); + debugLog('[useCLIDetection] Network/RPC error:', error); setAvailability({ claude: null, codex: null, diff --git a/sources/hooks/useEnvironmentVariables.test.ts b/sources/hooks/useEnvironmentVariables.test.ts index e1bae6d24..45a978263 100644 --- a/sources/hooks/useEnvironmentVariables.test.ts +++ b/sources/hooks/useEnvironmentVariables.test.ts @@ -89,6 +89,10 @@ describe('resolveEnvVarSubstitution', () => { expect(resolveEnvVarSubstitution('${VAR:-fallback}', envWithNull)).toBe('fallback'); }); + it('returns default when VAR is empty string in ${VAR:-default}', () => { + expect(resolveEnvVarSubstitution('${EMPTY:-fallback}', daemonEnv)).toBe('fallback'); + }); + it('returns literal for non-substitution values', () => { expect(resolveEnvVarSubstitution('literal-value', daemonEnv)).toBe('literal-value'); }); diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 568bb0583..4caa6e6d1 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -1,18 +1,37 @@ import { useState, useEffect, useMemo } from 'react'; -import { machineBash } from '@/sync/ops'; +import { machineBash, machinePreviewEnv, type EnvPreviewSecretsPolicy, type PreviewEnvValue } from '@/sync/ops'; // Re-export pure utility functions from envVarUtils for backwards compatibility export { resolveEnvVarSubstitution, extractEnvVarReferences } from './envVarUtils'; +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + interface EnvironmentVariables { [varName: string]: string | null; // null = variable not set in daemon environment } interface UseEnvironmentVariablesResult { variables: EnvironmentVariables; + meta: Record; + policy: EnvPreviewSecretsPolicy | null; + isPreviewEnvSupported: boolean; isLoading: boolean; } +interface UseEnvironmentVariablesOptions { + /** + * When provided, the daemon will compute an effective spawn environment: + * effective = { ...daemon.process.env, ...expand(extraEnv) } + * This makes previews exactly match what sessions will receive. + */ + extraEnv?: Record; + /** + * Marks variables as sensitive (at minimum). The daemon may also treat vars as sensitive + * based on name heuristics (TOKEN/KEY/etc). + */ + sensitiveHints?: Record; +} + /** * Queries environment variable values from the daemon's process environment. * @@ -36,18 +55,33 @@ interface UseEnvironmentVariablesResult { */ export function useEnvironmentVariables( machineId: string | null, - varNames: string[] + varNames: string[], + options?: UseEnvironmentVariablesOptions ): UseEnvironmentVariablesResult { const [variables, setVariables] = useState({}); + const [meta, setMeta] = useState>({}); + const [policy, setPolicy] = useState(null); + const [isPreviewEnvSupported, setIsPreviewEnvSupported] = useState(false); const [isLoading, setIsLoading] = useState(false); // Memoize sorted var names for stable dependency (avoid unnecessary re-queries) const sortedVarNames = useMemo(() => [...varNames].sort().join(','), [varNames]); + const extraEnvKey = useMemo(() => { + const entries = Object.entries(options?.extraEnv ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.extraEnv]); + const sensitiveHintsKey = useMemo(() => { + const entries = Object.entries(options?.sensitiveHints ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.sensitiveHints]); useEffect(() => { // Early exit conditions if (!machineId || varNames.length === 0) { setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } @@ -57,6 +91,7 @@ export function useEnvironmentVariables( const fetchVars = async () => { const results: EnvironmentVariables = {}; + const metaResults: Record = {}; // SECURITY: Validate all variable names to prevent bash injection // Only accept valid environment variable names: [A-Z_][A-Z0-9_]* @@ -65,43 +100,151 @@ export function useEnvironmentVariables( if (validVarNames.length === 0) { // No valid variables to query setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } - // Build batched command: query all variables in single bash invocation - // Format: echo "VAR1=$VAR1" && echo "VAR2=$VAR2" && ... - // Using echo with variable expansion ensures we get daemon's environment - const command = validVarNames - .map(name => `echo "${name}=$${name}"`) - .join(' && '); + // Prefer daemon-native env preview if supported (more accurate + supports secret policy). + const preview = await machinePreviewEnv(machineId, { + keys: validVarNames, + extraEnv: options?.extraEnv, + sensitiveHints: options?.sensitiveHints, + }); + + if (cancelled) return; + + if (preview.supported) { + const response = preview.response; + validVarNames.forEach((name) => { + const entry = response.values[name]; + if (entry) { + metaResults[name] = entry; + results[name] = entry.value; + } else { + // Defensive fallback: treat as unset. + metaResults[name] = { value: null, isSet: false, isSensitive: false, display: 'unset' }; + results[name] = null; + } + }); + + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(response.policy); + setIsPreviewEnvSupported(true); + setIsLoading(false); + } + return; + } + + // Fallback (older daemon): use bash probing for non-sensitive variables only. + // Never fetch secret-like values into UI memory via bash. + const sensitiveHints = options?.sensitiveHints ?? {}; + const safeVarNames = validVarNames.filter((name) => !SECRET_NAME_REGEX.test(name) && sensitiveHints[name] !== true); + + // Mark excluded keys as hidden (conservative). + validVarNames.forEach((name) => { + if (safeVarNames.includes(name)) return; + metaResults[name] = { value: null, isSet: true, isSensitive: true, display: 'hidden' }; + results[name] = null; + }); + + // Query variables in a single machineBash() call. + // + // IMPORTANT: This runs inside the daemon process environment on the machine, because the + // RPC handler executes commands using Node's `exec()` without overriding `env`. + // That means this matches what `${VAR}` expansion uses when spawning sessions on the daemon + // (see happy-cli: expandEnvironmentVariables(..., process.env)). + // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty. + // Fallback to bash-only output if node isn't available. + const nodeScript = [ + // node -e sets argv[1] to "-e", so args start at argv[2] + "const keys = process.argv.slice(2);", + "const out = {};", + "for (const k of keys) {", + " out[k] = Object.prototype.hasOwnProperty.call(process.env, k) ? process.env[k] : null;", + "}", + "process.stdout.write(JSON.stringify(out));", + ].join(""); + const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${safeVarNames.join(' ')}`; + // Shell fallback uses `printenv` to distinguish unset vs empty via exit code. + // Note: values containing newlines may not round-trip here; the node/JSON path preserves them. + const shellFallback = [ + `for name in ${safeVarNames.join(' ')}; do`, + `if printenv "$name" >/dev/null 2>&1; then`, + `printf "%s=%s\\n" "$name" "$(printenv "$name")";`, + `else`, + `printf "%s=__HAPPY_UNSET__\\n" "$name";`, + `fi;`, + `done`, + ].join(' '); + + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; try { + if (safeVarNames.length === 0) { + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); + setIsLoading(false); + } + return; + } + const result = await machineBash(machineId, command, '/'); if (cancelled) return; if (result.success && result.exitCode === 0) { - // Parse output: "VAR1=value1\nVAR2=value2\nVAR3=" - const lines = result.stdout.trim().split('\n'); - lines.forEach(line => { - const equalsIndex = line.indexOf('='); - if (equalsIndex !== -1) { - const name = line.substring(0, equalsIndex); - const value = line.substring(equalsIndex + 1); - results[name] = value || null; // Empty string → null (not set) + const stdout = result.stdout; + + // JSON protocol: {"VAR":"value","MISSING":null} + // Be resilient to any stray output (log lines, warnings) by extracting the last JSON object. + const trimmed = stdout.trim(); + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1); + try { + const parsed = JSON.parse(jsonSlice) as Record; + safeVarNames.forEach((name) => { + results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; + }); + } catch { + // Fall through to line parser if JSON is malformed. } - }); + } + + // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__" + if (Object.keys(results).length === 0) { + // Do not trim each line: it can corrupt values with meaningful whitespace. + const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0); + lines.forEach((line) => { + // Ignore unrelated output (warnings, prompts, etc). + if (!/^[A-Z_][A-Z0-9_]*=/.test(line)) return; + const equalsIndex = line.indexOf('='); + if (equalsIndex !== -1) { + const name = line.substring(0, equalsIndex); + const value = line.substring(equalsIndex + 1); + results[name] = value === '__HAPPY_UNSET__' ? null : value; + } + }); + } // Ensure all requested variables have entries (even if missing from output) - validVarNames.forEach(name => { + safeVarNames.forEach(name => { if (!(name in results)) { results[name] = null; } }); } else { // Bash command failed - mark all variables as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } @@ -109,13 +252,25 @@ export function useEnvironmentVariables( if (cancelled) return; // RPC error (network, encryption, etc.) - mark all as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } if (!cancelled) { + safeVarNames.forEach((name) => { + const value = results[name]; + metaResults[name] = { + value, + isSet: value !== null, + isSensitive: false, + display: value === null ? 'unset' : 'full', + }; + }); setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); } }; @@ -126,7 +281,7 @@ export function useEnvironmentVariables( return () => { cancelled = true; }; - }, [machineId, sortedVarNames]); + }, [extraEnvKey, machineId, sensitiveHintsKey, sortedVarNames]); - return { variables, isLoading }; + return { variables, meta, policy, isPreviewEnvSupported, isLoading }; } diff --git a/sources/modal/ModalManager.ts b/sources/modal/ModalManager.ts index 1e0cf0aaf..d94423d7c 100644 --- a/sources/modal/ModalManager.ts +++ b/sources/modal/ModalManager.ts @@ -1,6 +1,6 @@ import { Platform, Alert } from 'react-native'; import { t } from '@/text'; -import { AlertButton, ModalConfig, CustomModalConfig, IModal } from './types'; +import { AlertButton, ModalConfig, CustomModalConfig, IModal, type CustomModalInjectedProps } from './types'; class ModalManagerClass implements IModal { private showModalFn: ((config: Omit) => string) | null = null; @@ -95,16 +95,22 @@ class ModalManagerClass implements IModal { } } - show(config: Omit): string { + show

(config: { + component: CustomModalConfig

['component']; + props?: CustomModalConfig

['props']; + }): string { if (!this.showModalFn) { console.error('ModalManager not initialized. Make sure ModalProvider is mounted.'); return ''; } - return this.showModalFn({ - ...config, - type: 'custom' - }); + const modalConfig: Omit = { + type: 'custom', + component: config.component as unknown as CustomModalConfig['component'], + props: config.props as unknown as CustomModalConfig['props'], + }; + + return this.showModalFn(modalConfig); } hide(id: string): void { diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx index 48ff2ab08..3d5702f5a 100644 --- a/sources/modal/components/BaseModal.tsx +++ b/sources/modal/components/BaseModal.tsx @@ -4,10 +4,17 @@ import { Modal, TouchableWithoutFeedback, Animated, - StyleSheet, KeyboardAvoidingView, Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; + +// On web, stop events from propagating to expo-router's modal overlay +// which intercepts clicks when it applies pointer-events: none to body +const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation(); +const webEventHandlers = Platform.OS === 'web' + ? { onClick: stopPropagation, onPointerDown: stopPropagation, onTouchStart: stopPropagation } + : {}; interface BaseModalProps { visible: boolean; @@ -57,9 +64,10 @@ export function BaseModal({ animationType={animationType} onRequestClose={onClose} > - void; } +type CommandPaletteExternalProps = Omit, 'onClose'>; + export function CustomModal({ config, onClose }: CustomModalProps) { const Component = config.component; @@ -27,6 +29,7 @@ export function CustomModal({ config, onClose }: CustomModalProps) { // Helper component to manage CommandPalette animation state function CommandPaletteWithAnimation({ config, onClose }: CustomModalProps) { const [isClosing, setIsClosing] = React.useState(false); + const commandPaletteProps = (config.props as CommandPaletteExternalProps | undefined) ?? { commands: [] }; const handleClose = React.useCallback(() => { setIsClosing(true); @@ -35,8 +38,8 @@ function CommandPaletteWithAnimation({ config, onClose }: CustomModalProps) { }, [onClose]); return ( - - + + ); -} \ No newline at end of file +} diff --git a/sources/modal/components/WebAlertModal.tsx b/sources/modal/components/WebAlertModal.tsx index 67e61ae43..7bf0c3b4e 100644 --- a/sources/modal/components/WebAlertModal.tsx +++ b/sources/modal/components/WebAlertModal.tsx @@ -3,8 +3,8 @@ import { View, Text, Pressable } from 'react-native'; import { BaseModal } from './BaseModal'; import { AlertModalConfig, ConfirmModalConfig } from '../types'; import { Typography } from '@/constants/Typography'; -import { StyleSheet } from 'react-native'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; interface WebAlertModalProps { config: AlertModalConfig | ConfirmModalConfig; @@ -12,8 +12,85 @@ interface WebAlertModalProps { onConfirm?: (value: boolean) => void; } +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + borderRadius: 14, + width: 270, + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + content: { + paddingHorizontal: 16, + paddingTop: 20, + paddingBottom: 16, + alignItems: 'center', + }, + title: { + fontSize: 17, + textAlign: 'center', + color: theme.colors.text, + marginBottom: 4, + }, + message: { + fontSize: 13, + textAlign: 'center', + color: theme.colors.text, + marginTop: 4, + lineHeight: 18, + }, + buttonContainer: { + borderTopWidth: 1, + borderTopColor: theme.colors.divider, + }, + buttonRow: { + flexDirection: 'row', + }, + buttonColumn: { + flexDirection: 'column', + }, + button: { + flex: 1, + paddingVertical: 11, + alignItems: 'center', + justifyContent: 'center', + }, + buttonPressed: { + backgroundColor: theme.colors.divider, + }, + separatorVertical: { + width: 1, + backgroundColor: theme.colors.divider, + }, + separatorHorizontal: { + height: 1, + backgroundColor: theme.colors.divider, + }, + buttonText: { + fontSize: 17, + color: theme.colors.textLink, + }, + primaryText: { + color: theme.colors.text, + }, + cancelText: { + fontWeight: '400', + }, + destructiveText: { + color: theme.colors.textDestructive, + }, +})); + export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps) { - const { theme } = useUnistyles(); + useUnistyles(); + const styles = stylesheet; const isConfirm = config.type === 'confirm'; const handleButtonPress = (buttonIndex: number) => { @@ -27,74 +104,12 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps const buttons = isConfirm ? [ - { text: config.cancelText || 'Cancel', style: 'cancel' as const }, - { text: config.confirmText || 'OK', style: config.destructive ? 'destructive' as const : 'default' as const } + { text: config.cancelText || t('common.cancel'), style: 'cancel' as const }, + { text: config.confirmText || t('common.ok'), style: config.destructive ? 'destructive' as const : 'default' as const } ] - : config.buttons || [{ text: 'OK', style: 'default' as const }]; + : config.buttons || [{ text: t('common.ok'), style: 'default' as const }]; - const styles = StyleSheet.create({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: 14, - width: 270, - overflow: 'hidden', - shadowColor: theme.colors.shadow.color, - shadowOffset: { - width: 0, - height: 2 - }, - shadowOpacity: 0.25, - shadowRadius: 4, - elevation: 5 - }, - content: { - paddingHorizontal: 16, - paddingTop: 20, - paddingBottom: 16, - alignItems: 'center' - }, - title: { - fontSize: 17, - textAlign: 'center', - color: theme.colors.text, - marginBottom: 4 - }, - message: { - fontSize: 13, - textAlign: 'center', - color: theme.colors.text, - marginTop: 4, - lineHeight: 18 - }, - buttonContainer: { - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - flexDirection: 'row' - }, - button: { - flex: 1, - paddingVertical: 11, - alignItems: 'center', - justifyContent: 'center' - }, - buttonPressed: { - backgroundColor: theme.colors.divider - }, - buttonSeparator: { - width: 1, - backgroundColor: theme.colors.divider - }, - buttonText: { - fontSize: 17, - color: theme.colors.textLink - }, - cancelText: { - fontWeight: '400' - }, - destructiveText: { - color: theme.colors.textDestructive - } - }); + const buttonLayout = buttons.length === 3 ? 'twoPlusOne' : buttons.length > 3 ? 'column' : 'row'; return ( @@ -110,30 +125,100 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps )} - - {buttons.map((button, index) => ( - - {index > 0 && } + {buttonLayout === 'twoPlusOne' ? ( + + [ styles.button, pressed && styles.buttonPressed ]} - onPress={() => handleButtonPress(index)} + onPress={() => handleButtonPress(0)} > - {button.text} + {buttons[0]?.text} - - ))} - + + + + [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(2)} + > + + {buttons[2]?.text} + + + + + + + [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(1)} + > + + {buttons[1]?.text} + + + + ) : ( + + {buttons.map((button, index) => ( + + {index > 0 && ( + + )} + [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(index)} + > + + {button.text} + + + + ))} + + )} ); -} \ No newline at end of file +} diff --git a/sources/modal/types.ts b/sources/modal/types.ts index c9cfdc640..e169c3658 100644 --- a/sources/modal/types.ts +++ b/sources/modal/types.ts @@ -40,13 +40,17 @@ export interface PromptModalConfig extends BaseModalConfig { inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; } -export interface CustomModalConfig extends BaseModalConfig { +export type CustomModalInjectedProps = Readonly<{ + onClose: () => void; +}>; + +export interface CustomModalConfig

extends BaseModalConfig { type: 'custom'; - component: ComponentType; - props?: any; + component: ComponentType

; + props?: Omit; } -export type ModalConfig = AlertModalConfig | ConfirmModalConfig | PromptModalConfig | CustomModalConfig; +export type ModalConfig = AlertModalConfig | ConfirmModalConfig | PromptModalConfig | CustomModalConfig; export interface ModalState { modals: ModalConfig[]; @@ -73,7 +77,10 @@ export interface IModal { confirmText?: string; inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; }): Promise; - show(config: Omit): string; + show

(config: { + component: ComponentType

; + props?: Omit; + }): string; hide(id: string): void; hideAll(): void; -} \ No newline at end of file +} diff --git a/sources/profileRouteParams.test.ts b/sources/profileRouteParams.test.ts new file mode 100644 index 000000000..166f0d0b3 --- /dev/null +++ b/sources/profileRouteParams.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { consumeProfileIdParam } from './profileRouteParams'; + +describe('consumeProfileIdParam', () => { + it('does nothing when param is missing', () => { + expect(consumeProfileIdParam({ profileIdParam: undefined, selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); + + it('clears param and deselects when param is empty string', () => { + expect(consumeProfileIdParam({ profileIdParam: '', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: null, + shouldClearParam: true, + }); + }); + + it('clears param without changing selection when it matches current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'abc', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: true, + }); + }); + + it('clears param and selects when it differs from current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'next', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('accepts array params and uses the first value', () => { + expect(consumeProfileIdParam({ profileIdParam: ['next', 'ignored'], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('treats empty array params as missing', () => { + expect(consumeProfileIdParam({ profileIdParam: [], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); +}); diff --git a/sources/profileRouteParams.ts b/sources/profileRouteParams.ts new file mode 100644 index 000000000..99eae054a --- /dev/null +++ b/sources/profileRouteParams.ts @@ -0,0 +1,32 @@ +export function normalizeOptionalParam(value?: string | string[]) { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +export function consumeProfileIdParam(params: { + profileIdParam?: string | string[]; + selectedProfileId: string | null; +}): { + nextSelectedProfileId: string | null | undefined; + shouldClearParam: boolean; +} { + const nextProfileIdFromParams = normalizeOptionalParam(params.profileIdParam); + + if (typeof nextProfileIdFromParams !== 'string') { + return { nextSelectedProfileId: undefined, shouldClearParam: false }; + } + + if (nextProfileIdFromParams === '') { + return { nextSelectedProfileId: null, shouldClearParam: true }; + } + + if (nextProfileIdFromParams === params.selectedProfileId) { + // Nothing to do, but still clear it so it doesn't lock the selection. + return { nextSelectedProfileId: undefined, shouldClearParam: true }; + } + + return { nextSelectedProfileId: nextProfileIdFromParams, shouldClearParam: true }; +} + diff --git a/sources/realtime/RealtimeVoiceSession.tsx b/sources/realtime/RealtimeVoiceSession.tsx index da558e1ec..71445ca04 100644 --- a/sources/realtime/RealtimeVoiceSession.tsx +++ b/sources/realtime/RealtimeVoiceSession.tsx @@ -9,6 +9,11 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { @@ -93,18 +98,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: (data) => { - console.log('Realtime session connected:', data); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -116,10 +121,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -129,7 +134,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -157,4 +162,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/sources/realtime/RealtimeVoiceSession.web.tsx b/sources/realtime/RealtimeVoiceSession.web.tsx index 54edb4672..1aa82a06d 100644 --- a/sources/realtime/RealtimeVoiceSession.web.tsx +++ b/sources/realtime/RealtimeVoiceSession.web.tsx @@ -9,11 +9,16 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { async startSession(config: VoiceSessionConfig): Promise { - console.log('[RealtimeVoiceSessionImpl] conversationInstance:', conversationInstance); + debugLog('[RealtimeVoiceSessionImpl] startSession'); if (!conversationInstance) { console.warn('Realtime voice session not initialized - conversationInstance is null'); return; @@ -55,7 +60,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { const conversationId = await conversationInstance.startSession(sessionConfig); - console.log('Started conversation with ID:', conversationId); + debugLog('Started conversation'); } catch (error) { console.error('Failed to start realtime session:', error); storage.getState().setRealtimeStatus('error'); @@ -98,18 +103,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: () => { - console.log('Realtime session connected'); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -121,10 +126,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -134,7 +139,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -142,16 +147,16 @@ export const RealtimeVoiceSession: React.FC = () => { useEffect(() => { // Store the conversation instance globally - console.log('[RealtimeVoiceSession] Setting conversationInstance:', conversation); + debugLog('[RealtimeVoiceSession] Setting conversationInstance'); conversationInstance = conversation; // Register the voice session once if (!hasRegistered.current) { try { - console.log('[RealtimeVoiceSession] Registering voice session'); + debugLog('[RealtimeVoiceSession] Registering voice session'); registerVoiceSession(new RealtimeVoiceSessionImpl()); hasRegistered.current = true; - console.log('[RealtimeVoiceSession] Voice session registered successfully'); + debugLog('[RealtimeVoiceSession] Voice session registered successfully'); } catch (error) { console.error('Failed to register voice session:', error); } @@ -165,4 +170,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/sources/sync/messageMeta.test.ts b/sources/sync/messageMeta.test.ts new file mode 100644 index 000000000..558485cc4 --- /dev/null +++ b/sources/sync/messageMeta.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { buildOutgoingMessageMeta } from './messageMeta'; + +describe('buildOutgoingMessageMeta', () => { + it('does not include model fields by default', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.sentFrom).toBe('web'); + expect(meta.permissionMode).toBe('default'); + expect(meta.appendSystemPrompt).toBe('PROMPT'); + expect('model' in meta).toBe(false); + expect('fallbackModel' in meta).toBe(false); + }); + + it('includes model when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + model: 'gemini-2.5-pro', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.model).toBe('gemini-2.5-pro'); + expect('model' in meta).toBe(true); + }); + + it('includes displayText when explicitly provided (including empty string)', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + displayText: '', + }); + + expect('displayText' in meta).toBe(true); + expect(meta.displayText).toBe(''); + }); + + it('includes fallbackModel when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + fallbackModel: 'gemini-2.5-flash', + }); + + expect('fallbackModel' in meta).toBe(true); + expect(meta.fallbackModel).toBe('gemini-2.5-flash'); + }); +}); diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts new file mode 100644 index 000000000..d97b22055 --- /dev/null +++ b/sources/sync/messageMeta.ts @@ -0,0 +1,19 @@ +import type { MessageMeta } from './typesMessageMeta'; + +export function buildOutgoingMessageMeta(params: { + sentFrom: string; + permissionMode: NonNullable; + model?: MessageMeta['model']; + fallbackModel?: MessageMeta['fallbackModel']; + appendSystemPrompt: string; + displayText?: string; +}): MessageMeta { + return { + sentFrom: params.sentFrom, + permissionMode: params.permissionMode, + appendSystemPrompt: params.appendSystemPrompt, + ...(params.displayText !== undefined ? { displayText: params.displayText } : {}), + ...(params.model !== undefined ? { model: params.model } : {}), + ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}), + }; +} diff --git a/sources/sync/modelOptions.ts b/sources/sync/modelOptions.ts new file mode 100644 index 000000000..0278fd621 --- /dev/null +++ b/sources/sync/modelOptions.ts @@ -0,0 +1,33 @@ +import type { ModelMode } from './permissionTypes'; +import { t } from '@/text'; + +export type AgentType = 'claude' | 'codex' | 'gemini'; + +export type ModelOption = Readonly<{ + value: ModelMode; + label: string; + description: string; +}>; + +export function getModelOptionsForAgentType(agentType: AgentType): readonly ModelOption[] { + if (agentType === 'gemini') { + return [ + { + value: 'gemini-2.5-pro', + label: t('agentInput.geminiModel.gemini25Pro.label'), + description: t('agentInput.geminiModel.gemini25Pro.description'), + }, + { + value: 'gemini-2.5-flash', + label: t('agentInput.geminiModel.gemini25Flash.label'), + description: t('agentInput.geminiModel.gemini25Flash.description'), + }, + { + value: 'gemini-2.5-flash-lite', + label: t('agentInput.geminiModel.gemini25FlashLite.label'), + description: t('agentInput.geminiModel.gemini25FlashLite.description'), + }, + ]; + } + return []; +} diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index 07f70e694..eeb6bbccc 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -139,6 +139,8 @@ export interface SpawnSessionOptions { approvedNewDirectoryCreation?: boolean; token?: string; agent?: 'codex' | 'claude' | 'gemini'; + // Session-scoped profile identity (non-secret). Empty string means "no profile". + profileId?: string; // Environment variables from AI backend profile // Accepts any environment variables - daemon will pass them to the agent process // Common variables include: @@ -146,7 +148,7 @@ export interface SpawnSessionOptions { // - OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_TIMEOUT_MS // - AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME // - TOGETHER_API_KEY, TOGETHER_MODEL - // - TMUX_SESSION_NAME, TMUX_TMPDIR, TMUX_UPDATE_ENVIRONMENT + // - TMUX_SESSION_NAME, TMUX_TMPDIR // - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC // - Custom variables (DEEPSEEK_*, Z_AI_*, etc.) environmentVariables?: Record; @@ -159,7 +161,7 @@ export interface SpawnSessionOptions { */ export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise { - const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables } = options; + const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables, profileId } = options; try { const result = await apiSocket.machineRPC; }>( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, environmentVariables } + { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, profileId, environmentVariables } ); return result; } catch (error) { @@ -234,6 +237,83 @@ export async function machineBash( } } +export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full'; + +export interface PreviewEnvValue { + value: string | null; + isSet: boolean; + isSensitive: boolean; + display: 'full' | 'redacted' | 'hidden' | 'unset'; +} + +export interface PreviewEnvResponse { + policy: EnvPreviewSecretsPolicy; + values: Record; +} + +interface PreviewEnvRequest { + keys: string[]; + extraEnv?: Record; + sensitiveHints?: Record; +} + +export type MachinePreviewEnvResult = + | { supported: true; response: PreviewEnvResponse } + | { supported: false }; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Preview environment variables exactly as the daemon will spawn them. + * + * This calls the daemon's `preview-env` RPC (if supported). The daemon computes: + * - effective env = { ...daemon.process.env, ...expand(extraEnv) } + * - applies `HAPPY_ENV_PREVIEW_SECRETS` policy for sensitive variables + * + * If the daemon is old and doesn't support `preview-env`, returns `{ supported: false }`. + */ +export async function machinePreviewEnv( + machineId: string, + params: PreviewEnvRequest +): Promise { + try { + const result = await apiSocket.machineRPC( + machineId, + 'preview-env', + params + ); + + if (isPlainObject(result) && typeof result.error === 'string') { + // Older daemons (or errors) return an encrypted `{ error: ... }` payload. + // Treat method-not-found as “unsupported” and fallback to bash-based probing. + if (result.error === 'Method not found') { + return { supported: false }; + } + // For any other error, degrade gracefully in UI by using fallback behavior. + return { supported: false }; + } + + // Basic shape validation (be defensive for mixed daemon versions). + if ( + !isPlainObject(result) || + (result.policy !== 'none' && result.policy !== 'redacted' && result.policy !== 'full') || + !isPlainObject(result.values) + ) { + return { supported: false }; + } + + const response: PreviewEnvResponse = { + policy: result.policy as EnvPreviewSecretsPolicy, + values: result.values as unknown as Record, + }; + return { supported: true, response }; + } catch { + return { supported: false }; + } +} + /** * Update machine metadata with optimistic concurrency control and automatic retry */ @@ -532,4 +612,4 @@ export type { TreeNode, SessionRipgrepResponse, SessionKillResponse -}; \ No newline at end of file +}; diff --git a/sources/sync/permissionMapping.test.ts b/sources/sync/permissionMapping.test.ts new file mode 100644 index 000000000..52bc50c20 --- /dev/null +++ b/sources/sync/permissionMapping.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { mapPermissionModeAcrossAgents } from './permissionMapping'; + +describe('mapPermissionModeAcrossAgents', () => { + it('returns the same mode when from and to are the same', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'claude')).toBe('plan'); + }); + + it('maps Claude plan to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Claude bypassPermissions to Gemini yolo', () => { + expect(mapPermissionModeAcrossAgents('bypassPermissions', 'claude', 'gemini')).toBe('yolo'); + }); + + it('maps Claude acceptEdits to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('acceptEdits', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Codex yolo to Claude bypassPermissions', () => { + expect(mapPermissionModeAcrossAgents('yolo', 'codex', 'claude')).toBe('bypassPermissions'); + }); + + it('maps Gemini safe-yolo to Claude plan', () => { + expect(mapPermissionModeAcrossAgents('safe-yolo', 'gemini', 'claude')).toBe('plan'); + }); + + it('preserves read-only across agents', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'claude', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('read-only', 'codex', 'claude')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'claude')).toBe('read-only'); + }); + + it('keeps Codex/Gemini modes unchanged when switching between them', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('safe-yolo', 'codex', 'gemini')).toBe('safe-yolo'); + }); +}); diff --git a/sources/sync/permissionMapping.ts b/sources/sync/permissionMapping.ts new file mode 100644 index 000000000..5330454c6 --- /dev/null +++ b/sources/sync/permissionMapping.ts @@ -0,0 +1,52 @@ +import type { PermissionMode } from './permissionTypes'; +import type { AgentType } from './modelOptions'; + +function isCodexLike(agent: AgentType) { + return agent === 'codex' || agent === 'gemini'; +} + +export function mapPermissionModeAcrossAgents( + mode: PermissionMode, + from: AgentType, + to: AgentType, +): PermissionMode { + if (from === to) return mode; + + const fromCodexLike = isCodexLike(from); + const toCodexLike = isCodexLike(to); + + // Codex <-> Gemini uses the same permission mode set. + if (fromCodexLike && toCodexLike) return mode; + + if (!fromCodexLike && toCodexLike) { + // Claude -> Codex/Gemini + switch (mode) { + case 'bypassPermissions': + return 'yolo'; + case 'plan': + return 'safe-yolo'; + case 'acceptEdits': + return 'safe-yolo'; + case 'read-only': + return 'read-only'; + case 'default': + return 'default'; + default: + return 'default'; + } + } + + // Codex/Gemini -> Claude + switch (mode) { + case 'yolo': + return 'bypassPermissions'; + case 'safe-yolo': + return 'plan'; + case 'read-only': + return 'read-only'; + case 'default': + return 'default'; + default: + return 'default'; + } +} diff --git a/sources/sync/permissionTypes.test.ts b/sources/sync/permissionTypes.test.ts new file mode 100644 index 000000000..c585b4c41 --- /dev/null +++ b/sources/sync/permissionTypes.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import type { PermissionMode } from './permissionTypes'; +import { + isModelMode, + isPermissionMode, + normalizePermissionModeForAgentFlavor, + normalizeProfileDefaultPermissionMode, +} from './permissionTypes'; + +describe('normalizePermissionModeForAgentFlavor', () => { + it('clamps non-codex permission modes to default for codex', () => { + expect(normalizePermissionModeForAgentFlavor('plan', 'codex')).toBe('default'); + }); + + it('clamps codex-like permission modes to default for claude', () => { + expect(normalizePermissionModeForAgentFlavor('read-only', 'claude')).toBe('default'); + }); + + it('preserves codex-like modes for gemini', () => { + expect(normalizePermissionModeForAgentFlavor('safe-yolo', 'gemini')).toBe('safe-yolo'); + expect(normalizePermissionModeForAgentFlavor('yolo', 'gemini')).toBe('yolo'); + }); + + it('preserves claude modes for claude', () => { + const modes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + for (const mode of modes) { + expect(normalizePermissionModeForAgentFlavor(mode, 'claude')).toBe(mode); + } + }); +}); + +describe('isPermissionMode', () => { + it('returns true for valid permission modes', () => { + expect(isPermissionMode('default')).toBe(true); + expect(isPermissionMode('read-only')).toBe(true); + expect(isPermissionMode('plan')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isPermissionMode('bogus')).toBe(false); + expect(isPermissionMode(null)).toBe(false); + expect(isPermissionMode(123)).toBe(false); + }); +}); + +describe('normalizeProfileDefaultPermissionMode', () => { + it('clamps codex-like modes to default for profile defaultPermissionMode', () => { + expect(normalizeProfileDefaultPermissionMode('read-only')).toBe('default'); + expect(normalizeProfileDefaultPermissionMode('safe-yolo')).toBe('default'); + expect(normalizeProfileDefaultPermissionMode('yolo')).toBe('default'); + }); +}); + +describe('isModelMode', () => { + it('returns true for valid model modes', () => { + expect(isModelMode('default')).toBe(true); + expect(isModelMode('adaptiveUsage')).toBe(true); + expect(isModelMode('gemini-2.5-pro')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isModelMode('bogus')).toBe(false); + expect(isModelMode(null)).toBe(false); + }); +}); diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts new file mode 100644 index 000000000..b85972a1d --- /dev/null +++ b/sources/sync/permissionTypes.ts @@ -0,0 +1,62 @@ +export type PermissionMode = + | 'default' + | 'acceptEdits' + | 'bypassPermissions' + | 'plan' + | 'read-only' + | 'safe-yolo' + | 'yolo'; + +const ALL_PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + +export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const; +export const CODEX_LIKE_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const; + +export type AgentFlavor = 'claude' | 'codex' | 'gemini'; + +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === 'string' && (ALL_PERMISSION_MODES as readonly string[]).includes(value); +} + +export function normalizePermissionModeForAgentFlavor(mode: PermissionMode, flavor: AgentFlavor): PermissionMode { + if (flavor === 'codex' || flavor === 'gemini') { + return (CODEX_LIKE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; + } + return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; +} + +export function normalizeProfileDefaultPermissionMode(mode: PermissionMode | null | undefined): PermissionMode { + if (!mode) return 'default'; + return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; +} + +export const MODEL_MODES = [ + 'default', + 'adaptiveUsage', + 'sonnet', + 'opus', + 'gpt-5-codex-high', + 'gpt-5-codex-medium', + 'gpt-5-codex-low', + 'gpt-5-minimal', + 'gpt-5-low', + 'gpt-5-medium', + 'gpt-5-high', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +] as const; + +export type ModelMode = (typeof MODEL_MODES)[number]; + +export function isModelMode(value: unknown): value is ModelMode { + return typeof value === 'string' && (MODEL_MODES as readonly string[]).includes(value); +} diff --git a/sources/sync/persistence.test.ts b/sources/sync/persistence.test.ts new file mode 100644 index 000000000..0e15b8c3c --- /dev/null +++ b/sources/sync/persistence.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = new Map(); + +vi.mock('react-native-mmkv', () => { + class MMKV { + getString(key: string) { + return store.get(key); + } + + set(key: string, value: string) { + store.set(key, value); + } + + delete(key: string) { + store.delete(key); + } + + clearAll() { + store.clear(); + } + } + + return { MMKV }; +}); + +import { clearPersistence, loadNewSessionDraft, loadSessionModelModes, saveSessionModelModes } from './persistence'; + +describe('persistence', () => { + beforeEach(() => { + clearPersistence(); + }); + + describe('session model modes', () => { + it('returns an empty object when nothing is persisted', () => { + expect(loadSessionModelModes()).toEqual({}); + }); + + it('roundtrips session model modes', () => { + saveSessionModelModes({ abc: 'gemini-2.5-pro' }); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + + it('filters out invalid persisted model modes', () => { + store.set( + 'session-model-modes', + JSON.stringify({ abc: 'gemini-2.5-pro', bad: 'adaptiveUsage' }), + ); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + }); + + describe('new session draft', () => { + it('preserves valid non-session modelMode values', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'claude', + permissionMode: 'default', + modelMode: 'adaptiveUsage', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('adaptiveUsage'); + }); + + it('clamps invalid permissionMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'bogus', + modelMode: 'default', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.permissionMode).toBe('default'); + }); + + it('clamps invalid modelMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'default', + modelMode: 'not-a-real-model', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('default'); + }); + }); +}); diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 2f9367523..afe07faca 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -3,20 +3,42 @@ import { Settings, settingsDefaults, settingsParse, SettingsSchema } from './set import { LocalSettings, localSettingsDefaults, localSettingsParse } from './localSettings'; import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { Session } from './storageTypes'; +import { isModelMode, isPermissionMode, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; -const mmkv = new MMKV(); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const storageScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const mmkv = storageScope ? new MMKV({ id: scopedStorageId('default', storageScope) }) : new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; export type NewSessionAgentType = 'claude' | 'codex' | 'gemini'; export type NewSessionSessionType = 'simple' | 'worktree'; +type SessionModelMode = NonNullable; + +// NOTE: +// This set must stay in sync with the configurable Session model modes. +// TypeScript will catch invalid entries here, but it won't force adding new Session modes. +const SESSION_MODEL_MODES = new Set([ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +]); + +function isSessionModelMode(value: unknown): value is SessionModelMode { + return typeof value === 'string' && SESSION_MODEL_MODES.has(value as SessionModelMode); +} + export interface NewSessionDraft { input: string; selectedMachineId: string | null; selectedPath: string | null; + selectedProfileId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; + modelMode: ModelMode; sessionType: NewSessionSessionType; updatedAt: number; } @@ -26,7 +48,8 @@ export function loadSettings(): { settings: Settings, version: number | null } { if (settings) { try { const parsed = JSON.parse(settings); - return { settings: settingsParse(parsed.settings), version: parsed.version }; + const version = typeof parsed.version === 'number' ? parsed.version : null; + return { settings: settingsParse(parsed.settings), version }; } catch (e) { console.error('Failed to parse settings', e); return { settings: { ...settingsDefaults }, version: null }; @@ -139,11 +162,15 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const input = typeof parsed.input === 'string' ? parsed.input : ''; const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null; const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null; + const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null; const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' ? parsed.agentType : 'claude'; - const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string' - ? (parsed.permissionMode as PermissionMode) + const permissionMode: PermissionMode = isPermissionMode(parsed.permissionMode) + ? parsed.permissionMode + : 'default'; + const modelMode: ModelMode = isModelMode(parsed.modelMode) + ? parsed.modelMode : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(); @@ -152,8 +179,10 @@ export function loadNewSessionDraft(): NewSessionDraft | null { input, selectedMachineId, selectedPath, + selectedProfileId, agentType, permissionMode, + modelMode, sessionType, updatedAt, }; @@ -188,6 +217,34 @@ export function saveSessionPermissionModes(modes: Record mmkv.set('session-permission-modes', JSON.stringify(modes)); } +export function loadSessionModelModes(): Record { + const modes = mmkv.getString('session-model-modes'); + if (modes) { + try { + const parsed: unknown = JSON.parse(modes); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + + const result: Record = {}; + Object.entries(parsed as Record).forEach(([sessionId, mode]) => { + if (isSessionModelMode(mode)) { + result[sessionId] = mode; + } + }); + return result; + } catch (e) { + console.error('Failed to parse session model modes', e); + return {}; + } + } + return {}; +} + +export function saveSessionModelModes(modes: Record) { + mmkv.set('session-model-modes', JSON.stringify(modes)); +} + export function loadProfile(): Profile { const profile = mmkv.getString('profile'); if (profile) { @@ -225,4 +282,4 @@ export function retrieveTempText(id: string): string | null { export function clearPersistence() { mmkv.clearAll(); -} \ No newline at end of file +} diff --git a/sources/sync/profileGrouping.test.ts b/sources/sync/profileGrouping.test.ts new file mode 100644 index 000000000..5a08b3ac5 --- /dev/null +++ b/sources/sync/profileGrouping.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildProfileGroups, toggleFavoriteProfileId } from './profileGrouping'; + +describe('toggleFavoriteProfileId', () => { + it('adds the profile id to the front when missing', () => { + expect(toggleFavoriteProfileId([], 'anthropic')).toEqual(['anthropic']); + }); + + it('removes the profile id when already present', () => { + expect(toggleFavoriteProfileId(['anthropic', 'openai'], 'anthropic')).toEqual(['openai']); + }); + + it('supports favoriting the default environment (empty profile id)', () => { + expect(toggleFavoriteProfileId(['anthropic'], '')).toEqual(['', 'anthropic']); + expect(toggleFavoriteProfileId(['', 'anthropic'], '')).toEqual(['anthropic']); + }); +}); + +describe('buildProfileGroups', () => { + it('filters favoriteIds to resolvable profiles (preserves default environment favorite)', () => { + const customProfiles = [ + { + id: 'custom-profile', + name: 'Custom Profile', + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + ]; + + const groups = buildProfileGroups({ + customProfiles, + favoriteProfileIds: ['', 'anthropic', 'missing-profile', 'custom-profile'], + }); + + expect(groups.favoriteIds.has('')).toBe(true); + expect(groups.favoriteIds.has('anthropic')).toBe(true); + expect(groups.favoriteIds.has('custom-profile')).toBe(true); + expect(groups.favoriteIds.has('missing-profile')).toBe(false); + }); +}); diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts new file mode 100644 index 000000000..d493bc7d9 --- /dev/null +++ b/sources/sync/profileGrouping.ts @@ -0,0 +1,67 @@ +import { AIBackendProfile } from '@/sync/settings'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; + +export interface ProfileGroups { + favoriteProfiles: AIBackendProfile[]; + customProfiles: AIBackendProfile[]; + builtInProfiles: AIBackendProfile[]; + favoriteIds: Set; + builtInIds: Set; +} + +function isProfile(profile: AIBackendProfile | null | undefined): profile is AIBackendProfile { + return Boolean(profile); +} + +export function toggleFavoriteProfileId(favoriteProfileIds: string[], profileId: string): string[] { + const normalized: string[] = []; + const seen = new Set(); + for (const id of favoriteProfileIds) { + if (seen.has(id)) continue; + seen.add(id); + normalized.push(id); + } + + if (seen.has(profileId)) { + return normalized.filter((id) => id !== profileId); + } + + return [profileId, ...normalized]; +} + +export function buildProfileGroups({ + customProfiles, + favoriteProfileIds, +}: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; +}): ProfileGroups { + const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id)); + + const customById = new Map(customProfiles.map((profile) => [profile.id, profile] as const)); + + const favoriteProfiles = favoriteProfileIds + .map((id) => customById.get(id) ?? getBuiltInProfile(id)) + .filter(isProfile); + + const favoriteIds = new Set(favoriteProfiles.map((profile) => profile.id)); + // Preserve "default environment" favorite marker (not a real profile object). + if (favoriteProfileIds.includes('')) { + favoriteIds.add(''); + } + + const nonFavoriteCustomProfiles = customProfiles.filter((profile) => !favoriteIds.has(profile.id)); + + const nonFavoriteBuiltInProfiles = DEFAULT_PROFILES + .map((profile) => getBuiltInProfile(profile.id)) + .filter(isProfile) + .filter((profile) => !favoriteIds.has(profile.id)); + + return { + favoriteProfiles, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds, + builtInIds, + }; +} diff --git a/sources/sync/profileMutations.ts b/sources/sync/profileMutations.ts new file mode 100644 index 000000000..340093911 --- /dev/null +++ b/sources/sync/profileMutations.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'expo-crypto'; +import { AIBackendProfile } from '@/sync/settings'; + +export function createEmptyCustomProfile(): AIBackendProfile { + return { + id: randomUUID(), + name: '', + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; +} + +export function duplicateProfileForEdit(profile: AIBackendProfile, opts?: { copySuffix?: string }): AIBackendProfile { + const suffix = opts?.copySuffix ?? '(Copy)'; + const separator = profile.name.trim().length > 0 ? ' ' : ''; + return { + ...profile, + id: randomUUID(), + name: `${profile.name}${separator}${suffix}`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function convertBuiltInProfileToCustom(profile: AIBackendProfile): AIBackendProfile { + return { + ...profile, + id: randomUUID(), + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} diff --git a/sources/sync/profileSync.ts b/sources/sync/profileSync.ts deleted file mode 100644 index 694ea1410..000000000 --- a/sources/sync/profileSync.ts +++ /dev/null @@ -1,453 +0,0 @@ -/** - * Profile Synchronization Service - * - * Handles bidirectional synchronization of profiles between GUI and CLI storage. - * Ensures consistent profile data across both systems with proper conflict resolution. - */ - -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from './settings'; -import { sync } from './sync'; -import { storage } from './storage'; -import { apiSocket } from './apiSocket'; -import { Modal } from '@/modal'; - -// Profile sync status types -export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; -export type SyncDirection = 'gui-to-cli' | 'cli-to-gui' | 'bidirectional'; - -// Profile sync conflict resolution strategies -export type ConflictResolution = 'gui-wins' | 'cli-wins' | 'most-recent' | 'merge'; - -// Profile sync event data -export interface ProfileSyncEvent { - direction: SyncDirection; - status: SyncStatus; - profilesSynced?: number; - error?: string; - timestamp: number; - message?: string; - warning?: string; -} - -// Profile sync configuration -export interface ProfileSyncConfig { - autoSync: boolean; - conflictResolution: ConflictResolution; - syncOnProfileChange: boolean; - syncOnAppStart: boolean; -} - -// Default sync configuration -const DEFAULT_SYNC_CONFIG: ProfileSyncConfig = { - autoSync: true, - conflictResolution: 'most-recent', - syncOnProfileChange: true, - syncOnAppStart: true, -}; - -class ProfileSyncService { - private static instance: ProfileSyncService; - private syncStatus: SyncStatus = 'idle'; - private lastSyncTime: number = 0; - private config: ProfileSyncConfig = DEFAULT_SYNC_CONFIG; - private eventListeners: Array<(event: ProfileSyncEvent) => void> = []; - - private constructor() { - // Private constructor for singleton - } - - public static getInstance(): ProfileSyncService { - if (!ProfileSyncService.instance) { - ProfileSyncService.instance = new ProfileSyncService(); - } - return ProfileSyncService.instance; - } - - /** - * Add event listener for sync events - */ - public addEventListener(listener: (event: ProfileSyncEvent) => void): void { - this.eventListeners.push(listener); - } - - /** - * Remove event listener - */ - public removeEventListener(listener: (event: ProfileSyncEvent) => void): void { - const index = this.eventListeners.indexOf(listener); - if (index > -1) { - this.eventListeners.splice(index, 1); - } - } - - /** - * Emit sync event to all listeners - */ - private emitEvent(event: ProfileSyncEvent): void { - this.eventListeners.forEach(listener => { - try { - listener(event); - } catch (error) { - console.error('[ProfileSync] Event listener error:', error); - } - }); - } - - /** - * Update sync configuration - */ - public updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - /** - * Get current sync configuration - */ - public getConfig(): ProfileSyncConfig { - return { ...this.config }; - } - - /** - * Get current sync status - */ - public getSyncStatus(): SyncStatus { - return this.syncStatus; - } - - /** - * Get last sync time - */ - public getLastSyncTime(): number { - return this.lastSyncTime; - } - - /** - * Sync profiles from GUI to CLI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncGuiToCli(profiles: AIBackendProfile[]): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'gui-to-cli', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Profiles are stored in GUI settings and available through existing Happy sync system - // CLI daemon reads profiles from GUI settings via existing channels - // TODO: Implement machine RPC endpoints for profile management in CLI daemon - console.log(`[ProfileSync] GUI profiles stored in Happy settings. CLI access via existing infrastructure.`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'success', - profilesSynced: profiles.length, - timestamp: Date.now(), - message: 'Profiles available through Happy settings system' - }); - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Sync profiles from CLI to GUI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncCliToGui(): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'cli-to-gui', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // CLI profiles are accessed through Happy settings system, not direct file access - // Return profiles from current GUI settings - const currentProfiles = storage.getState().settings.profiles || []; - - console.log(`[ProfileSync] Retrieved ${currentProfiles.length} profiles from Happy settings`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'success', - profilesSynced: currentProfiles.length, - timestamp: Date.now(), - message: 'Profiles retrieved from Happy settings system' - }); - - return currentProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Perform bidirectional sync with conflict resolution - */ - public async bidirectionalSync(guiProfiles: AIBackendProfile[]): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'bidirectional', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Get CLI profiles - const cliProfiles = await this.syncCliToGui(); - - // Resolve conflicts based on configuration - const resolvedProfiles = await this.resolveConflicts(guiProfiles, cliProfiles); - - // Update CLI with resolved profiles - await this.syncGuiToCli(resolvedProfiles); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'success', - profilesSynced: resolvedProfiles.length, - timestamp: Date.now(), - }); - - return resolvedProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Resolve conflicts between GUI and CLI profiles - */ - private async resolveConflicts( - guiProfiles: AIBackendProfile[], - cliProfiles: AIBackendProfile[] - ): Promise { - const { conflictResolution } = this.config; - const resolvedProfiles: AIBackendProfile[] = []; - const processedIds = new Set(); - - // Process profiles that exist in both GUI and CLI - for (const guiProfile of guiProfiles) { - const cliProfile = cliProfiles.find(p => p.id === guiProfile.id); - - if (cliProfile) { - let resolvedProfile: AIBackendProfile; - - switch (conflictResolution) { - case 'gui-wins': - resolvedProfile = { ...guiProfile, updatedAt: Date.now() }; - break; - case 'cli-wins': - resolvedProfile = { ...cliProfile, updatedAt: Date.now() }; - break; - case 'most-recent': - resolvedProfile = guiProfile.updatedAt! >= cliProfile.updatedAt! - ? { ...guiProfile } - : { ...cliProfile }; - break; - case 'merge': - resolvedProfile = await this.mergeProfiles(guiProfile, cliProfile); - break; - default: - resolvedProfile = { ...guiProfile }; - } - - resolvedProfiles.push(resolvedProfile); - processedIds.add(guiProfile.id); - } else { - // Profile exists only in GUI - resolvedProfiles.push({ ...guiProfile, updatedAt: Date.now() }); - processedIds.add(guiProfile.id); - } - } - - // Add profiles that exist only in CLI - for (const cliProfile of cliProfiles) { - if (!processedIds.has(cliProfile.id)) { - resolvedProfiles.push({ ...cliProfile, updatedAt: Date.now() }); - } - } - - return resolvedProfiles; - } - - /** - * Merge two profiles, preferring non-null values from both - */ - private async mergeProfiles( - guiProfile: AIBackendProfile, - cliProfile: AIBackendProfile - ): Promise { - const merged: AIBackendProfile = { - id: guiProfile.id, - name: guiProfile.name || cliProfile.name, - description: guiProfile.description || cliProfile.description, - anthropicConfig: { ...cliProfile.anthropicConfig, ...guiProfile.anthropicConfig }, - openaiConfig: { ...cliProfile.openaiConfig, ...guiProfile.openaiConfig }, - azureOpenAIConfig: { ...cliProfile.azureOpenAIConfig, ...guiProfile.azureOpenAIConfig }, - togetherAIConfig: { ...cliProfile.togetherAIConfig, ...guiProfile.togetherAIConfig }, - tmuxConfig: { ...cliProfile.tmuxConfig, ...guiProfile.tmuxConfig }, - environmentVariables: this.mergeEnvironmentVariables( - cliProfile.environmentVariables || [], - guiProfile.environmentVariables || [] - ), - compatibility: { ...cliProfile.compatibility, ...guiProfile.compatibility }, - isBuiltIn: guiProfile.isBuiltIn || cliProfile.isBuiltIn, - createdAt: Math.min(guiProfile.createdAt || 0, cliProfile.createdAt || 0), - updatedAt: Math.max(guiProfile.updatedAt || 0, cliProfile.updatedAt || 0), - version: guiProfile.version || cliProfile.version || '1.0.0', - }; - - return merged; - } - - /** - * Merge environment variables from two profiles - */ - private mergeEnvironmentVariables( - cliVars: Array<{ name: string; value: string }>, - guiVars: Array<{ name: string; value: string }> - ): Array<{ name: string; value: string }> { - const mergedVars = new Map(); - - // Add CLI variables first - cliVars.forEach(v => mergedVars.set(v.name, v.value)); - - // Override with GUI variables - guiVars.forEach(v => mergedVars.set(v.name, v.value)); - - return Array.from(mergedVars.entries()).map(([name, value]) => ({ name, value })); - } - - /** - * Set active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async setActiveProfile(profileId: string): Promise { - try { - // Store in GUI settings using Happy's settings system - sync.applySettings({ lastUsedProfile: profileId }); - - console.log(`[ProfileSync] Set active profile ${profileId} in Happy settings`); - - // Note: CLI daemon accesses active profile through Happy settings system - // TODO: Implement machine RPC endpoint for setting active profile in CLI daemon - } catch (error) { - console.error('[ProfileSync] Failed to set active profile:', error); - throw error; - } - } - - /** - * Get active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async getActiveProfile(): Promise { - try { - // Get active profile from Happy settings system - const lastUsedProfileId = storage.getState().settings.lastUsedProfile; - - if (!lastUsedProfileId) { - return null; - } - - const profiles = storage.getState().settings.profiles || []; - const activeProfile = profiles.find((p: AIBackendProfile) => p.id === lastUsedProfileId); - - if (activeProfile) { - console.log(`[ProfileSync] Retrieved active profile ${activeProfile.name} from Happy settings`); - return activeProfile; - } - - return null; - } catch (error) { - console.error('[ProfileSync] Failed to get active profile:', error); - return null; - } - } - - /** - * Auto-sync if enabled and conditions are met - */ - public async autoSyncIfNeeded(guiProfiles: AIBackendProfile[]): Promise { - if (!this.config.autoSync) { - return; - } - - const timeSinceLastSync = Date.now() - this.lastSyncTime; - const AUTO_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes - - if (timeSinceLastSync > AUTO_SYNC_INTERVAL) { - try { - await this.bidirectionalSync(guiProfiles); - } catch (error) { - console.error('[ProfileSync] Auto-sync failed:', error); - // Don't throw for auto-sync failures - } - } - } -} - -// Export singleton instance -export const profileSyncService = ProfileSyncService.getInstance(); - -// Export convenience functions -export const syncGuiToCli = (profiles: AIBackendProfile[]) => profileSyncService.syncGuiToCli(profiles); -export const syncCliToGui = () => profileSyncService.syncCliToGui(); -export const bidirectionalSync = (guiProfiles: AIBackendProfile[]) => profileSyncService.bidirectionalSync(guiProfiles); -export const setActiveProfile = (profileId: string) => profileSyncService.setActiveProfile(profileId); -export const getActiveProfile = () => profileSyncService.getActiveProfile(); \ No newline at end of file diff --git a/sources/sync/profileUtils.test.ts b/sources/sync/profileUtils.test.ts new file mode 100644 index 000000000..f6f1553c8 --- /dev/null +++ b/sources/sync/profileUtils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getBuiltInProfileNameKey, getProfilePrimaryCli } from './profileUtils'; + +describe('getProfilePrimaryCli', () => { + it('ignores unknown compatibility keys', () => { + const profile = { + compatibility: { unknownCli: true }, + } as any; + + expect(getProfilePrimaryCli(profile)).toBe('none'); + }); +}); + +describe('getBuiltInProfileNameKey', () => { + it('returns the translation key for known built-in profile ids', () => { + expect(getBuiltInProfileNameKey('anthropic')).toBe('profiles.builtInNames.anthropic'); + expect(getBuiltInProfileNameKey('deepseek')).toBe('profiles.builtInNames.deepseek'); + expect(getBuiltInProfileNameKey('zai')).toBe('profiles.builtInNames.zai'); + expect(getBuiltInProfileNameKey('openai')).toBe('profiles.builtInNames.openai'); + expect(getBuiltInProfileNameKey('azure-openai')).toBe('profiles.builtInNames.azureOpenai'); + }); + + it('returns null for unknown ids', () => { + expect(getBuiltInProfileNameKey('unknown')).toBeNull(); + }); +}); diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index d90a98a93..ca04c41bb 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -1,5 +1,47 @@ import { AIBackendProfile } from './settings'; +export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none'; + +export type BuiltInProfileId = 'anthropic' | 'deepseek' | 'zai' | 'openai' | 'azure-openai'; + +export type BuiltInProfileNameKey = + | 'profiles.builtInNames.anthropic' + | 'profiles.builtInNames.deepseek' + | 'profiles.builtInNames.zai' + | 'profiles.builtInNames.openai' + | 'profiles.builtInNames.azureOpenai'; + +const ALLOWED_PROFILE_CLIS = new Set(['claude', 'codex', 'gemini']); + +export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { + if (!profile) return 'none'; + const supported = Object.entries(profile.compatibility ?? {}) + .filter(([, isSupported]) => isSupported) + .map(([cli]) => cli) + .filter((cli): cli is 'claude' | 'codex' | 'gemini' => ALLOWED_PROFILE_CLIS.has(cli)); + + if (supported.length === 0) return 'none'; + if (supported.length === 1) return supported[0]; + return 'multi'; +} + +export function getBuiltInProfileNameKey(id: string): BuiltInProfileNameKey | null { + switch (id as BuiltInProfileId) { + case 'anthropic': + return 'profiles.builtInNames.anthropic'; + case 'deepseek': + return 'profiles.builtInNames.deepseek'; + case 'zai': + return 'profiles.builtInNames.zai'; + case 'openai': + return 'profiles.builtInNames.openai'; + case 'azure-openai': + return 'profiles.builtInNames.azureOpenai'; + default: + return null; + } +} + /** * Documentation and expected values for built-in profiles. * These help users understand what environment variables to set and their expected values. @@ -242,7 +284,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'anthropic', name: 'Anthropic (Default)', - anthropicConfig: {}, environmentVariables: [], defaultPermissionMode: 'default', compatibility: { claude: true, codex: false, gemini: false }, @@ -256,11 +297,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Launch daemon with: DEEPSEEK_AUTH_TOKEN=sk-... DEEPSEEK_BASE_URL=https://api.deepseek.com/anthropic // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden (getProfileEnvironmentVariables priority) + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'deepseek', name: 'DeepSeek (Reasoner)', - anthropicConfig: {}, environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${DEEPSEEK_AUTH_TOKEN}' }, // Secret - no fallback @@ -282,11 +322,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Model mappings: Z_AI_OPUS_MODEL=GLM-4.6, Z_AI_SONNET_MODEL=GLM-4.6, Z_AI_HAIKU_MODEL=GLM-4.5-Air // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'zai', name: 'Z.AI (GLM-4.6)', - anthropicConfig: {}, environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' }, // Secret - no fallback @@ -307,7 +346,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'openai', name: 'OpenAI (GPT-5)', - openaiConfig: {}, environmentVariables: [ { name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' }, @@ -326,7 +364,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'azure-openai', name: 'Azure OpenAI', - azureOpenAIConfig: {}, environmentVariables: [ { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, { name: 'AZURE_OPENAI_DEPLOYMENT_NAME', value: 'gpt-5-codex' }, diff --git a/sources/sync/reducer/phase0-skipping.spec.ts b/sources/sync/reducer/phase0-skipping.spec.ts index 5e005ab59..c1bb0e2ff 100644 --- a/sources/sync/reducer/phase0-skipping.spec.ts +++ b/sources/sync/reducer/phase0-skipping.spec.ts @@ -93,12 +93,6 @@ describe('Phase 0 permission skipping issue', () => { // Process messages and AgentState together (simulates opening chat) const result = reducer(state, toolMessages, agentState); - // Log what happened (for debugging) - console.log('Result messages:', result.messages.length); - console.log('Permission mappings:', { - toolIdToMessageId: Array.from(state.toolIdToMessageId.entries()) - }); - // Find the tool messages in the result const webFetchTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'WebFetch'); const writeTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'Write'); @@ -203,4 +197,4 @@ describe('Phase 0 permission skipping issue', () => { expect(toolAfterPermission?.tool?.permission?.id).toBe('tool1'); expect(toolAfterPermission?.tool?.permission?.status).toBe('approved'); }); -}); \ No newline at end of file +}); diff --git a/sources/sync/serverConfig.ts b/sources/sync/serverConfig.ts index fedea04df..b52f452d0 100644 --- a/sources/sync/serverConfig.ts +++ b/sources/sync/serverConfig.ts @@ -1,7 +1,10 @@ import { MMKV } from 'react-native-mmkv'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; // Separate MMKV instance for server config that persists across logouts -const serverConfigStorage = new MMKV({ id: 'server-config' }); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const serverConfigScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const serverConfigStorage = new MMKV({ id: scopedStorageId('server-config', serverConfigScope) }); const SERVER_KEY = 'custom-server-url'; const DEFAULT_SERVER_URL = 'https://api.cluster-fluster.com'; diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 4f36ce46f..1f38fef48 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -89,6 +89,37 @@ describe('settings', () => { } }); }); + + it('should migrate legacy provider config objects into environmentVariables', () => { + const settingsWithLegacyProfileConfig: any = { + profiles: [ + { + id: 'legacy-profile', + name: 'Legacy Profile', + isBuiltIn: false, + compatibility: { claude: true, codex: true, gemini: true }, + environmentVariables: [{ name: 'FOO', value: 'bar' }], + openaiConfig: { + apiKey: 'sk-test', + baseUrl: 'https://example.com', + model: 'gpt-test', + }, + }, + ], + }; + + const parsed = settingsParse(settingsWithLegacyProfileConfig); + expect(parsed.profiles).toHaveLength(1); + + const profile = parsed.profiles[0]!; + expect(profile.environmentVariables).toEqual(expect.arrayContaining([ + { name: 'FOO', value: 'bar' }, + { name: 'OPENAI_API_KEY', value: 'sk-test' }, + { name: 'OPENAI_BASE_URL', value: 'https://example.com' }, + { name: 'OPENAI_MODEL', value: 'gpt-test' }, + ])); + expect((profile as any).openaiConfig).toBeUndefined(); + }); }); describe('applySettings', () => { @@ -103,7 +134,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -122,6 +157,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -137,7 +173,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -156,6 +196,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); @@ -171,7 +212,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -190,6 +235,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = {}; @@ -207,7 +253,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -226,6 +276,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -248,7 +299,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -267,6 +322,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; expect(applySettings(currentSettings, {})).toEqual(currentSettings); @@ -298,7 +354,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -317,6 +377,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: any = { @@ -360,8 +421,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, alwaysShowContextSize: false, - avatarStyle: 'brutalist', + useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, + avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, agentInputEnterToSend: true, @@ -376,10 +442,10 @@ describe('settings', () => { lastUsedModelMode: null, profiles: [], lastUsedProfile: null, - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, - useEnhancedSessionWizard: false, }); }); @@ -560,7 +626,6 @@ describe('settings', () => { { id: 'server-profile', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -578,7 +643,6 @@ describe('settings', () => { { id: 'local-profile', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -680,7 +744,6 @@ describe('settings', () => { profiles: [{ id: 'test-profile', name: 'Test', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -713,7 +776,6 @@ describe('settings', () => { profiles: [{ id: 'device-b-profile', name: 'Device B Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -825,7 +887,6 @@ describe('settings', () => { profiles: [{ id: 'server-profile-1', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -844,7 +905,6 @@ describe('settings', () => { profiles: [{ id: 'local-profile-1', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 5746c863d..c42eb8391 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -4,77 +4,10 @@ import * as z from 'zod'; // Configuration Profile Schema (for environment variable profiles) // -// Environment variable schemas for different AI providers -// Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings -const AnthropicConfigSchema = z.object({ - baseUrl: z.string().refine( - (val) => { - if (!val) return true; // Optional - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - // Otherwise validate as URL - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - authToken: z.string().optional(), - model: z.string().optional(), -}); - -const OpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - baseUrl: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - model: z.string().optional(), -}); - -const AzureOpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - endpoint: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - apiVersion: z.string().optional(), - deploymentName: z.string().optional(), -}); - -const TogetherAIConfigSchema = z.object({ - apiKey: z.string().optional(), - model: z.string().optional(), -}); - // Tmux configuration schema const TmuxConfigSchema = z.object({ sessionName: z.string().optional(), tmpDir: z.string().optional(), - updateEnvironment: z.boolean().optional(), }); // Environment variables schema with validation @@ -97,18 +30,9 @@ export const AIBackendProfileSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - // Agent-specific configurations - anthropicConfig: AnthropicConfigSchema.optional(), - openaiConfig: OpenAIConfigSchema.optional(), - azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), - togetherAIConfig: TogetherAIConfigSchema.optional(), - // Tmux configuration tmuxConfig: TmuxConfigSchema.optional(), - // Startup bash script (executed before spawning session) - startupBashScript: z.string().optional(), - // Environment variables (validated) environmentVariables: z.array(EnvironmentVariableSchema).default([]), @@ -140,6 +64,61 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud return profile.compatibility[agent]; } +function mergeEnvironmentVariables( + existing: unknown, + additions: Record +): Array<{ name: string; value: string }> { + const map = new Map(); + + if (Array.isArray(existing)) { + for (const entry of existing) { + if (!entry || typeof entry !== 'object') continue; + const name = (entry as any).name; + const value = (entry as any).value; + if (typeof name !== 'string' || typeof value !== 'string') continue; + map.set(name, value); + } + } + + for (const [name, value] of Object.entries(additions)) { + if (typeof value !== 'string') continue; + if (!map.has(name)) { + map.set(name, value); + } + } + + return Array.from(map.entries()).map(([name, value]) => ({ name, value })); +} + +function normalizeLegacyProfileConfig(profile: unknown): unknown { + if (!profile || typeof profile !== 'object') return profile; + + const raw = profile as Record; + const additions: Record = { + ANTHROPIC_BASE_URL: raw.anthropicConfig?.baseUrl, + ANTHROPIC_AUTH_TOKEN: raw.anthropicConfig?.authToken, + ANTHROPIC_MODEL: raw.anthropicConfig?.model, + OPENAI_API_KEY: raw.openaiConfig?.apiKey, + OPENAI_BASE_URL: raw.openaiConfig?.baseUrl, + OPENAI_MODEL: raw.openaiConfig?.model, + AZURE_OPENAI_API_KEY: raw.azureOpenAIConfig?.apiKey, + AZURE_OPENAI_ENDPOINT: raw.azureOpenAIConfig?.endpoint, + AZURE_OPENAI_API_VERSION: raw.azureOpenAIConfig?.apiVersion, + AZURE_OPENAI_DEPLOYMENT_NAME: raw.azureOpenAIConfig?.deploymentName, + TOGETHER_API_KEY: raw.togetherAIConfig?.apiKey, + TOGETHER_MODEL: raw.togetherAIConfig?.model, + }; + + const environmentVariables = mergeEnvironmentVariables(raw.environmentVariables, additions); + + // Remove legacy provider config objects. Any values are preserved via environmentVariables migration above. + const { anthropicConfig, openaiConfig, azureOpenAIConfig, togetherAIConfig, ...rest } = raw; + return { + ...rest, + environmentVariables, + }; +} + /** * Converts a profile into environment variables for session spawning. * @@ -157,8 +136,8 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * Sent: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} (literal string with placeholder) * * 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session: - * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching - * - Non-tmux mode: Node.js spawn with env: { ...process.env, ...profileEnvVars } (shell expansion in child) + * - Tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before launching (shells do not expand placeholders inside env values automatically) + * - Non-tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before calling spawn() (Node does not expand placeholders) * * 5. SESSION RECEIVES actual expanded values: * ANTHROPIC_AUTH_TOKEN=sk-real-key (expanded from daemon's Z_AI_AUTH_TOKEN, not literal ${Z_AI_AUTH_TOKEN}) @@ -172,7 +151,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * - Each session uses its selected backend for its entire lifetime (no mid-session switching) * - Keep secrets in shell environment, not in GUI/profile storage * - * PRIORITY ORDER when spawning (daemon/run.ts): + * PRIORITY ORDER when spawning: * Final env = { ...daemon.process.env, ...expandedProfileVars, ...authVars } * authVars override profile, profile overrides daemon.process.env */ @@ -184,43 +163,12 @@ export function getProfileEnvironmentVariables(profile: AIBackendProfile): Recor envVars[envVar.name] = envVar.value; }); - // Add Anthropic config - if (profile.anthropicConfig) { - if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; - if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; - if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; - } - - // Add OpenAI config - if (profile.openaiConfig) { - if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; - if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; - if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; - } - - // Add Azure OpenAI config - if (profile.azureOpenAIConfig) { - if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; - if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; - if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; - if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; - } - - // Add Together AI config - if (profile.togetherAIConfig) { - if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; - if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; - } - // Add Tmux config if (profile.tmuxConfig) { // Empty string means "use current/most recent session", so include it if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; // Empty string may be valid for tmpDir to use tmux defaults if (profile.tmuxConfig.tmpDir !== undefined) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; - if (profile.tmuxConfig.updateEnvironment !== undefined) { - envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); - } } return envVars; @@ -249,6 +197,8 @@ export function isProfileVersionCompatible(profileVersion: string, requiredVersi // // Current schema version for backward compatibility +// NOTE: This schemaVersion is for the Happy app's settings blob (synced via the server). +// happy-cli maintains its own local settings schemaVersion separately. export const SUPPORTED_SCHEMA_VERSION = 2; export const SettingsSchema = z.object({ @@ -263,7 +213,12 @@ export const SettingsSchema = z.object({ wrapLinesInDiffs: z.boolean().describe('Whether to wrap long lines in diff views'), analyticsOptOut: z.boolean().describe('Whether to opt out of anonymous analytics'), experiments: z.boolean().describe('Whether to enable experimental features'), + useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), + // Legacy combined toggle (kept for backward compatibility; see settingsParse migration) + usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs (legacy combined toggle)'), + useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), + usePathPickerSearch: z.boolean().describe('Whether to show search in path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), avatarStyle: z.string().describe('Avatar display style'), @@ -288,6 +243,8 @@ export const SettingsSchema = z.object({ favoriteDirectories: z.array(z.string()).describe('User-defined favorite directories for quick access in path selection'), // Favorite machines for quick machine selection favoriteMachines: z.array(z.string()).describe('User-defined favorite machines (machine IDs) for quick access in machine selection'), + // Favorite profiles for quick profile selection (built-in or custom profile IDs) + favoriteProfiles: z.array(z.string()).describe('User-defined favorite profiles (profile IDs) for quick access in profile selection'), // Dismissed CLI warning banners (supports both per-machine and global dismissal) dismissedCLIWarnings: z.object({ perMachine: z.record(z.string(), z.object({ @@ -332,7 +289,11 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -350,10 +311,12 @@ export const settingsDefaults: Settings = { // Profile management defaults profiles: [], lastUsedProfile: null, - // Default favorite directories (real common directories on Unix-like systems) - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + // Favorite directories (empty by default) + favoriteDirectories: [], // Favorite machines (empty by default) favoriteMachines: [], + // Favorite profiles (empty by default) + favoriteProfiles: [], // Dismissed CLI warnings (empty by default) dismissedCLIWarnings: { perMachine: {}, global: {} }, }; @@ -369,28 +332,75 @@ export function settingsParse(settings: unknown): Settings { return { ...settingsDefaults }; } - const parsed = SettingsSchemaPartial.safeParse(settings); - if (!parsed.success) { - // For invalid settings, preserve unknown fields but use defaults for known fields - const unknownFields = { ...(settings as any) }; - // Remove all known schema fields from unknownFields - const knownFields = Object.keys(SettingsSchema.shape); - knownFields.forEach(key => delete unknownFields[key]); - return { ...settingsDefaults, ...unknownFields }; - } + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + + // IMPORTANT: be tolerant of partially-invalid settings objects. + // A single invalid field (e.g. one malformed profile) must not reset all other known settings to defaults. + const input = settings as Record; + const result: any = { ...settingsDefaults }; + + // Parse known fields individually to avoid whole-object failure. + (Object.keys(SettingsSchema.shape) as Array).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(input, key)) return; + + // Special-case profiles: validate per profile entry, keep valid ones. + if (key === 'profiles') { + const profilesValue = input[key]; + if (Array.isArray(profilesValue)) { + const parsedProfiles: AIBackendProfile[] = []; + for (const rawProfile of profilesValue) { + const parsedProfile = AIBackendProfileSchema.safeParse(normalizeLegacyProfileConfig(rawProfile)); + if (parsedProfile.success) { + parsedProfiles.push(parsedProfile.data); + } else if (isDev) { + console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues); + } + } + result.profiles = parsedProfiles; + } + return; + } + + const schema = SettingsSchema.shape[key]; + const parsedField = schema.safeParse(input[key]); + if (parsedField.success) { + result[key] = parsedField.data; + } else if (isDev) { + console.warn(`[settingsParse] Invalid settings field "${String(key)}" - using default`, parsedField.error.issues); + } + }); // Migration: Convert old 'zh' language code to 'zh-Hans' - if (parsed.data.preferredLanguage === 'zh') { - console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); - parsed.data.preferredLanguage = 'zh-Hans'; + if (result.preferredLanguage === 'zh') { + result.preferredLanguage = 'zh-Hans'; } - // Merge defaults, parsed settings, and preserve unknown fields - const unknownFields = { ...(settings as any) }; - // Remove known fields from unknownFields to preserve only the unknown ones - Object.keys(parsed.data).forEach(key => delete unknownFields[key]); + // Migration: Convert legacy combined picker-search toggle into per-picker toggles. + // Only apply if new fields were not present in persisted settings. + const hasMachineSearch = 'useMachinePickerSearch' in input; + const hasPathSearch = 'usePathPickerSearch' in input; + if (!hasMachineSearch && !hasPathSearch) { + const legacy = SettingsSchema.shape.usePickerSearch.safeParse(input.usePickerSearch); + if (legacy.success && legacy.data === true) { + result.useMachinePickerSearch = true; + result.usePathPickerSearch = true; + } + } + + // Preserve unknown fields (forward compatibility). + for (const [key, value] of Object.entries(input)) { + if (key === '__proto__') continue; + if (!Object.prototype.hasOwnProperty.call(SettingsSchema.shape, key)) { + Object.defineProperty(result, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + } - return { ...settingsDefaults, ...parsed.data, ...unknownFields }; + return result as Settings; } // diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index 48e7ab771..83d5c716d 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -11,8 +11,8 @@ import { Purchases, customerInfoToPurchases } from "./purchases"; import { TodoState } from "../-zen/model/ops"; import { Profile } from "./profile"; import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; -import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence"; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionModelModes, saveSessionModelModes } from "./persistence"; +import type { PermissionMode } from '@/sync/permissionTypes'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; import { sync } from "./sync"; @@ -46,6 +46,8 @@ function isSessionActive(session: { active: boolean; activeAt: number }): boolea // Known entitlement IDs export type KnownEntitlements = 'pro'; +type SessionModelMode = NonNullable; + interface SessionMessages { messages: Message[]; messagesMap: Record; @@ -102,6 +104,7 @@ interface StorageState { applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[], hasReadyEvent: boolean }; applyMessagesLoaded: (sessionId: string) => void; applySettings: (settings: Settings, version: number) => void; + replaceSettings: (settings: Settings, version: number) => void; applySettingsLocal: (settings: Partial) => void; applyLocalSettings: (settings: Partial) => void; applyPurchases: (customerInfo: CustomerInfo) => void; @@ -250,6 +253,7 @@ export const storage = create()((set, get) => { let profile = loadProfile(); let sessionDrafts = loadSessionDrafts(); let sessionPermissionModes = loadSessionPermissionModes(); + let sessionModelModes = loadSessionModelModes(); return { settings, settingsVersion: version, @@ -303,6 +307,7 @@ export const storage = create()((set, get) => { // Load drafts and permission modes if sessions are empty (initial load) const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {}; const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {}; + const savedModelModes = Object.keys(state.sessions).length === 0 ? sessionModelModes : {}; // Merge new sessions with existing ones const mergedSessions: Record = { ...state.sessions }; @@ -317,11 +322,14 @@ export const storage = create()((set, get) => { const savedDraft = savedDrafts[session.id]; const existingPermissionMode = state.sessions[session.id]?.permissionMode; const savedPermissionMode = savedPermissionModes[session.id]; + const existingModelMode = state.sessions[session.id]?.modelMode; + const savedModelMode = savedModelModes[session.id]; mergedSessions[session.id] = { ...session, presence, draft: existingDraft || savedDraft || session.draft || null, - permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default' + permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default', + modelMode: existingModelMode || savedModelMode || session.modelMode || 'default', }; }); @@ -366,8 +374,6 @@ export const storage = create()((set, get) => { listData.push(...inactiveSessions); } - // console.log(`📊 Storage: applySessions called with ${sessions.length} sessions, active: ${activeSessions.length}, inactive: ${inactiveSessions.length}`); - // Process AgentState updates for sessions that already have messages loaded const updatedSessionMessages = { ...state.sessionMessages }; @@ -384,15 +390,6 @@ export const storage = create()((set, get) => { const currentRealtimeSessionId = getCurrentRealtimeSessionId(); const voiceSession = getVoiceSession(); - // console.log('[REALTIME DEBUG] Permission check:', { - // currentRealtimeSessionId, - // sessionId: session.id, - // match: currentRealtimeSessionId === session.id, - // hasVoiceSession: !!voiceSession, - // oldRequests: Object.keys(oldSession?.agentState?.requests || {}), - // newRequests: Object.keys(newSession.agentState?.requests || {}) - // }); - if (currentRealtimeSessionId === session.id && voiceSession) { const oldRequests = oldSession?.agentState?.requests || {}; const newRequests = newSession.agentState?.requests || {}; @@ -402,7 +399,6 @@ export const storage = create()((set, get) => { if (!oldRequests[requestId]) { // This is a NEW permission request const toolName = request.tool; - // console.log('[REALTIME DEBUG] Sending permission notification for:', toolName); voiceSession.sendTextMessage( `Claude is requesting permission to use the ${toolName} tool` ); @@ -629,7 +625,7 @@ export const storage = create()((set, get) => { }; }), applySettings: (settings: Settings, version: number) => set((state) => { - if (state.settingsVersion === null || state.settingsVersion < version) { + if (state.settingsVersion == null || state.settingsVersion < version) { saveSettings(settings, version); return { ...state, @@ -640,6 +636,14 @@ export const storage = create()((set, get) => { return state; } }), + replaceSettings: (settings: Settings, version: number) => set((state) => { + saveSettings(settings, version); + return { + ...state, + settings, + settingsVersion: version + }; + }), applyLocalSettings: (delta: Partial) => set((state) => { const updatedLocalSettings = applyLocalSettings(state.localSettings, delta); saveLocalSettings(updatedLocalSettings); @@ -821,6 +825,16 @@ export const storage = create()((set, get) => { } }; + // Collect all model modes for persistence (only non-default values to save space) + const allModes: Record = {}; + Object.entries(updatedSessions).forEach(([id, sess]) => { + if (sess.modelMode && sess.modelMode !== 'default') { + allModes[id] = sess.modelMode; + } + }); + + saveSessionModelModes(allModes); + // No need to rebuild sessionListViewData since model mode doesn't affect the list display return { ...state, @@ -871,12 +885,10 @@ export const storage = create()((set, get) => { }), // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => set((state) => { - console.log(`🗂️ Storage.applyArtifacts: Applying ${artifacts.length} artifacts`); const mergedArtifacts = { ...state.artifacts }; artifacts.forEach(artifact => { mergedArtifacts[artifact.id] = artifact; }); - console.log(`🗂️ Storage.applyArtifacts: Total artifacts after merge: ${Object.keys(mergedArtifacts).length}`); return { ...state, @@ -931,6 +943,10 @@ export const storage = create()((set, get) => { const modes = loadSessionPermissionModes(); delete modes[sessionId]; saveSessionPermissionModes(modes); + + const modelModes = loadSessionModelModes(); + delete modelModes[sessionId]; + saveSessionModelModes(modelModes); // Rebuild sessionListViewData without the deleted session const sessionListViewData = buildSessionListViewData(remainingSessions); diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index 82fedb5c1..a42b46cd1 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -10,6 +10,7 @@ export const MetadataSchema = z.object({ version: z.string().optional(), name: z.string().optional(), os: z.string().optional(), + profileId: z.string().nullable().optional(), // Session-scoped profile identity (non-secret) summary: z.object({ text: z.string(), updatedAt: z.number() @@ -69,8 +70,8 @@ export interface Session { id: string; }>; draft?: string | null; // Local draft message, not synced to server - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server - modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; // Local permission mode, not synced to server + modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; // Local model mode, not synced to server // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. // We store it directly on Session to ensure it's available immediately on load. // Do NOT store reducerState itself on Session - it's mutable and should only exist in SessionMessages. @@ -153,4 +154,4 @@ export interface GitStatus { aheadCount?: number; // Commits ahead of upstream behindCount?: number; // Commits behind upstream stashCount?: number; // Number of stash entries -} \ No newline at end of file +} diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 5393a3651..e2c43a708 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -39,6 +39,7 @@ import { fetchFeed } from './apiFeed'; import { FeedItem } from './feedTypes'; import { UserProfile } from './friendTypes'; import { initializeTodoSync } from '../-zen/model/ops'; +import { buildOutgoingMessageMeta } from './messageMeta'; class Sync { // Spawned agents (especially in spawn mode) can take noticeable time to connect. @@ -251,14 +252,7 @@ class Sync { sentFrom = 'web'; // fallback } - // Model settings - for Gemini, we pass the selected model; for others, CLI handles it - let model: string | null = null; - if (isGemini && modelMode !== 'default') { - // For Gemini ACP, pass the selected model to CLI - model = modelMode; - } - const fallbackModel: string | null = null; - + const model = isGemini && modelMode !== 'default' ? modelMode : undefined; // Create user message content with metadata const content: RawRecord = { role: 'user', @@ -266,14 +260,13 @@ class Sync { type: 'text', text }, - meta: { + meta: buildOutgoingMessageMeta({ sentFrom, permissionMode: permissionMode || 'default', model, - fallbackModel, appendSystemPrompt: systemPrompt, - ...(displayText && { displayText }) // Add displayText if provided - } + displayText, + }) }; const encryptedRawRecord = await encryption.encryptRawRecord(content); @@ -843,7 +836,6 @@ class Sync { private fetchMachines = async () => { if (!this.credentials) return; - console.log('📊 Sync: Fetching machines...'); const API_ENDPOINT = getServerUrl(); const response = await fetch(`${API_ENDPOINT}/v1/machines`, { headers: { @@ -858,7 +850,6 @@ class Sync { } const data = await response.json(); - console.log(`📊 Sync: Fetched ${Array.isArray(data) ? data.length : 0} machines from server`); const machines = data as Array<{ id: string; metadata: string; @@ -1189,11 +1180,6 @@ class Sync { } // Log and retry - console.log('settings version-mismatch, retrying', { - serverVersion: data.currentVersion, - retry: retryCount + 1, - pendingKeys: Object.keys(this.pendingSettings) - }); retryCount++; continue; } else { @@ -1230,12 +1216,6 @@ class Sync { parsedSettings = { ...settingsDefaults }; } - // Log - console.log('settings', JSON.stringify({ - settings: parsedSettings, - version: data.settingsVersion - })); - // Apply settings to storage storage.getState().applySettings(parsedSettings, data.settingsVersion); @@ -1267,16 +1247,6 @@ class Sync { const data = await response.json(); const parsedProfile = profileParse(data); - // Log profile data for debugging - console.log('profile', JSON.stringify({ - id: parsedProfile.id, - timestamp: parsedProfile.timestamp, - firstName: parsedProfile.firstName, - lastName: parsedProfile.lastName, - hasAvatar: !!parsedProfile.avatar, - hasGitHub: !!parsedProfile.github - })); - // Apply profile to storage storage.getState().applyProfile(parsedProfile); } @@ -1314,12 +1284,11 @@ class Sync { }); if (!response.ok) { - console.log(`[fetchNativeUpdate] Request failed: ${response.status}`); + log.log(`[fetchNativeUpdate] Request failed: ${response.status}`); return; } const data = await response.json(); - console.log('[fetchNativeUpdate] Data:', data); // Apply update status to storage if (data.update_required && data.update_url) { @@ -1333,7 +1302,7 @@ class Sync { }); } } catch (error) { - console.log('[fetchNativeUpdate] Error:', error); + console.error('[fetchNativeUpdate] Error:', error); storage.getState().applyNativeUpdateStatus(null); } } @@ -1354,7 +1323,6 @@ class Sync { } if (!apiKey) { - console.log(`RevenueCat: No API key found for platform ${Platform.OS}`); return; } @@ -1371,7 +1339,6 @@ class Sync { }); this.revenueCatInitialized = true; - console.log('RevenueCat initialized successfully'); } // Sync purchases @@ -1438,9 +1405,6 @@ class Sync { } } } - console.log('Batch decrypted and normalized messages in', Date.now() - start, 'ms'); - console.log('normalizedMessages', JSON.stringify(normalizedMessages)); - // console.log('messages', JSON.stringify(normalizedMessages)); // Apply to storage this.applyMessages(sessionId, normalizedMessages); @@ -1467,7 +1431,7 @@ class Sync { log.log('finalStatus: ' + JSON.stringify(finalStatus)); if (finalStatus !== 'granted') { - console.log('Failed to get push token for push notification!'); + log.log('Failed to get push token for push notification!'); return; } @@ -1515,15 +1479,12 @@ class Sync { } private handleUpdate = async (update: unknown) => { - console.log('🔄 Sync: handleUpdate called with:', JSON.stringify(update).substring(0, 300)); const validatedUpdate = ApiUpdateContainerSchema.safeParse(update); if (!validatedUpdate.success) { - console.log('❌ Sync: Invalid update received:', validatedUpdate.error); console.error('❌ Sync: Invalid update data:', update); return; } const updateData = validatedUpdate.data; - console.log(`🔄 Sync: Validated update type: ${updateData.body.t}`); if (updateData.body.t === 'new-message') { @@ -1549,7 +1510,8 @@ class Sync { const dataType = rawContent?.content?.data?.type; // Debug logging to trace lifecycle events - if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + if (isDev && (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started')) { console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`); } @@ -1560,7 +1522,7 @@ class Sync { const isTaskStarted = ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'); - if (isTaskComplete || isTaskStarted) { + if (isDev && (isTaskComplete || isTaskStarted)) { console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`); } @@ -1582,7 +1544,6 @@ class Sync { // Update messages if (lastMessage) { - console.log('🔄 Sync: Applying message:', JSON.stringify(lastMessage)); this.applyMessages(updateData.body.sid, [lastMessage]); let hasMutableTool = false; if (lastMessage.role === 'agent' && lastMessage.content[0] && lastMessage.content[0].type === 'tool-result') { @@ -1968,7 +1929,6 @@ class Sync { } if (sessions.length > 0) { - // console.log('flushing activity updates ' + sessions.length); this.applySessions(sessions); // log.log(`🔄 Activity updates flushed - updated ${sessions.length} sessions`); } @@ -1977,17 +1937,13 @@ class Sync { private handleEphemeralUpdate = (update: unknown) => { const validatedUpdate = ApiEphemeralUpdateSchema.safeParse(update); if (!validatedUpdate.success) { - console.log('Invalid ephemeral update received:', validatedUpdate.error); console.error('Invalid ephemeral update received:', update); return; - } else { - // console.log('Ephemeral update received:', update); } const updateData = validatedUpdate.data; // Process activity updates through smart debounce accumulator if (updateData.type === 'activity') { - // console.log('adding activity update ' + updateData.id); this.activityAccumulator.addUpdate(updateData); } diff --git a/sources/sync/typesRaw.spec.ts b/sources/sync/typesRaw.spec.ts index 29178a25d..55851f426 100644 --- a/sources/sync/typesRaw.spec.ts +++ b/sources/sync/typesRaw.spec.ts @@ -1489,4 +1489,136 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } }); }); + + describe('ACP tool result normalization', () => { + it('normalizes ACP tool-result output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-1', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-1', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('hello'); + } + } + }); + + it('normalizes ACP tool-call-result output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-call-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-2', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-2', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('hello'); + } + } + }); + + it('normalizes ACP tool-result string output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: 'direct string', + id: 'acp-msg-3', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-3', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('direct string'); + } + } + }); + + it('normalizes ACP tool-result object output to JSON text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: { key: 'value' }, + id: 'acp-msg-4', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-4', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe(JSON.stringify({ key: 'value' })); + } + } + }); + + it('normalizes ACP tool-result null output to empty text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: null, + id: 'acp-msg-5', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-5', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe(''); + } + } + }); + }); }); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index aa7b2ed82..b408a9053 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -47,7 +47,9 @@ export type RawToolUseContent = z.infer; const rawToolResultContentSchema = z.object({ type: z.literal('tool_result'), tool_use_id: z.string(), - content: z.union([z.array(z.object({ type: z.literal('text'), text: z.string() })), z.string()]), + // Tool results can be strings, Claude-style arrays of text blocks, or structured JSON (Codex/Gemini). + // We accept any here and normalize later for display. + content: z.any(), is_error: z.boolean().optional(), permissions: z.object({ date: z.number(), @@ -246,13 +248,13 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ oldContent: z.string().optional(), newContent: z.string().optional(), id: z.string() - }), + }).passthrough(), // Terminal/command output z.object({ type: z.literal('terminal-output'), data: z.string(), callId: z.string() - }), + }).passthrough(), // Task lifecycle events z.object({ type: z.literal('task_started'), id: z.string() }), z.object({ type: z.literal('task_complete'), id: z.string() }), @@ -264,7 +266,7 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ toolName: z.string(), description: z.string(), options: z.any().optional() - }), + }).passthrough(), // Usage/metrics z.object({ type: z.literal('token_count') }).passthrough() ]) @@ -402,13 +404,46 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA // Zod transform handles normalization during validation let parsed = rawRecordSchema.safeParse(raw); if (!parsed.success) { - console.error('=== VALIDATION ERROR ==='); - console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); - console.error('Raw message:', JSON.stringify(raw, null, 2)); - console.error('=== END ERROR ==='); + // Never log full raw messages in production: tool outputs and user text may contain secrets. + // Keep enough context for debugging in dev builds only. + console.error(`[typesRaw] Message validation failed (id=${id})`); + if (__DEV__) { + console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); + console.error('Raw summary:', { + role: raw?.role, + contentType: (raw as any)?.content?.type, + }); + } return null; } raw = parsed.data; + + const toolResultContentToText = (content: unknown): string => { + if (content === null || content === undefined) return ''; + if (typeof content === 'string') return content; + + // Claude sometimes sends tool_result.content as [{ type: 'text', text: '...' }] + if (Array.isArray(content)) { + const maybeTextBlocks = content as Array<{ type?: unknown; text?: unknown }>; + const isTextBlocks = maybeTextBlocks.every((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string'); + if (isTextBlocks) { + return maybeTextBlocks.map((b) => b.text as string).join(''); + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + }; + if (raw.role === 'user') { return { id, @@ -525,10 +560,11 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } else { for (let c of raw.content.data.message.content) { if (c.type === 'tool_result') { + const rawResultContent = raw.content.data.toolUseResult ?? c.content; content.push({ ...c, // WOLOG: Preserve all fields including unknown ones type: 'tool-result', - content: raw.content.data.toolUseResult ? raw.content.data.toolUseResult : (typeof c.content === 'string' ? c.content : c.content[0].text), + content: toolResultContentToText(rawResultContent), is_error: c.is_error || false, uuid: raw.content.data.uuid, parentUUID: raw.content.data.parentUuid ?? null, @@ -630,7 +666,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null @@ -702,7 +738,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: raw.content.data.isError ?? false, uuid: raw.content.data.id, parentUUID: null @@ -721,7 +757,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null @@ -815,4 +851,4 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } } return null; -} \ No newline at end of file +} diff --git a/sources/text/README.md b/sources/text/README.md index 09128f3ef..38551135d 100644 --- a/sources/text/README.md +++ b/sources/text/README.md @@ -82,8 +82,8 @@ t('invalid.key') // Error: Key doesn't exist ## Files Structure -### `_default.ts` -Contains the main translation object with mixed string/function values: +### `translations/en.ts` +Contains the canonical English translation object with mixed string/function values: ```typescript export const en = { @@ -97,6 +97,13 @@ export const en = { } as const; ``` +### `_types.ts` +Contains the TypeScript types derived from the English translation structure. + +This keeps the canonical translation object (`translations/en.ts`) separate from the type-level API: +- `Translations` / `TranslationStructure` are derived from `en` and used to type-check other locales. +- `TranslationKey` / `TranslationParams` are derived from `Translations` (in `index.ts`) to type `t(...)`. + ### `index.ts` Main module with the `t` function and utilities: - `t()` - Main translation function with strict typing @@ -164,7 +171,7 @@ The API stays the same, but you get: ## Adding New Translations -1. **Add to `_default.ts`**: +1. **Add to `translations/en.ts`**: ```typescript // String constant newConstant: 'My New Text', @@ -215,9 +222,9 @@ statusMessage: ({ files, online, syncing }: { ## Future Expansion To add more languages: -1. Create new translation files (e.g., `_spanish.ts`) +1. Create new translation files (e.g., `translations/es.ts`) 2. Update types to include new locales 3. Add locale switching logic 4. All existing type safety is preserved -This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. \ No newline at end of file +This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. diff --git a/sources/text/_default.ts b/sources/text/_default.ts deleted file mode 100644 index 0a94f0590..000000000 --- a/sources/text/_default.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * English translations for the Happy app - * Values can be: - * - String constants for static text - * - Functions with typed object parameters for dynamic text - */ - -/** - * English plural helper function - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - -export const en = { - tabs: { - // Tab navigation labels - inbox: 'Inbox', - sessions: 'Terminals', - settings: 'Settings', - }, - - inbox: { - // Inbox screen - emptyTitle: 'Empty Inbox', - emptyDescription: 'Connect with friends to start sharing sessions', - updates: 'Updates', - }, - - common: { - // Simple string constants - cancel: 'Cancel', - authenticate: 'Authenticate', - save: 'Save', - saveAs: 'Save As', - error: 'Error', - success: 'Success', - ok: 'OK', - continue: 'Continue', - back: 'Back', - create: 'Create', - rename: 'Rename', - reset: 'Reset', - logout: 'Logout', - yes: 'Yes', - no: 'No', - discard: 'Discard', - version: 'Version', - copied: 'Copied', - copy: 'Copy', - scanning: 'Scanning...', - urlPlaceholder: 'https://example.com', - home: 'Home', - message: 'Message', - files: 'Files', - fileViewer: 'File Viewer', - loading: 'Loading...', - retry: 'Retry', - delete: 'Delete', - optional: 'optional', - }, - - profile: { - userProfile: 'User Profile', - details: 'Details', - firstName: 'First Name', - lastName: 'Last Name', - username: 'Username', - status: 'Status', - }, - - status: { - connected: 'connected', - connecting: 'connecting', - disconnected: 'disconnected', - error: 'error', - online: 'online', - offline: 'offline', - lastSeen: ({ time }: { time: string }) => `last seen ${time}`, - permissionRequired: 'permission required', - activeNow: 'Active now', - unknown: 'unknown', - }, - - time: { - justNow: 'just now', - minutesAgo: ({ count }: { count: number }) => `${count} minute${count !== 1 ? 's' : ''} ago`, - hoursAgo: ({ count }: { count: number }) => `${count} hour${count !== 1 ? 's' : ''} ago`, - }, - - connect: { - restoreAccount: 'Restore Account', - enterSecretKey: 'Please enter a secret key', - invalidSecretKey: 'Invalid secret key. Please check and try again.', - enterUrlManually: 'Enter URL manually', - }, - - settings: { - title: 'Settings', - connectedAccounts: 'Connected Accounts', - connectAccount: 'Connect account', - github: 'GitHub', - machines: 'Machines', - features: 'Features', - social: 'Social', - account: 'Account', - accountSubtitle: 'Manage your account details', - appearance: 'Appearance', - appearanceSubtitle: 'Customize how the app looks', - voiceAssistant: 'Voice Assistant', - voiceAssistantSubtitle: 'Configure voice interaction preferences', - featuresTitle: 'Features', - featuresSubtitle: 'Enable or disable app features', - developer: 'Developer', - developerTools: 'Developer Tools', - about: 'About', - aboutFooter: 'Happy Coder is a Codex and Claude Code mobile client. It\'s fully end-to-end encrypted and your account is stored only on your device. Not affiliated with Anthropic.', - whatsNew: 'What\'s New', - whatsNewSubtitle: 'See the latest updates and improvements', - reportIssue: 'Report an Issue', - privacyPolicy: 'Privacy Policy', - termsOfService: 'Terms of Service', - eula: 'EULA', - supportUs: 'Support us', - supportUsSubtitlePro: 'Thank you for your support!', - supportUsSubtitle: 'Support project development', - scanQrCodeToAuthenticate: 'Scan QR code to authenticate', - githubConnected: ({ login }: { login: string }) => `Connected as @${login}`, - connectGithubAccount: 'Connect your GitHub account', - claudeAuthSuccess: 'Successfully connected to Claude', - exchangingTokens: 'Exchanging tokens...', - usage: 'Usage', - usageSubtitle: 'View your API usage and costs', - profiles: 'Profiles', - profilesSubtitle: 'Manage environment variable profiles for sessions', - - // Dynamic settings messages - accountConnected: ({ service }: { service: string }) => `${service} account connected`, - machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} is ${status}`, - featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => - `${feature} ${enabled ? 'enabled' : 'disabled'}`, - }, - - settingsAppearance: { - // Appearance settings screen - theme: 'Theme', - themeDescription: 'Choose your preferred color scheme', - themeOptions: { - adaptive: 'Adaptive', - light: 'Light', - dark: 'Dark', - }, - themeDescriptions: { - adaptive: 'Match system settings', - light: 'Always use light theme', - dark: 'Always use dark theme', - }, - display: 'Display', - displayDescription: 'Control layout and spacing', - inlineToolCalls: 'Inline Tool Calls', - inlineToolCallsDescription: 'Display tool calls directly in chat messages', - expandTodoLists: 'Expand Todo Lists', - expandTodoListsDescription: 'Show all todos instead of just changes', - showLineNumbersInDiffs: 'Show Line Numbers in Diffs', - showLineNumbersInDiffsDescription: 'Display line numbers in code diffs', - showLineNumbersInToolViews: 'Show Line Numbers in Tool Views', - showLineNumbersInToolViewsDescription: 'Display line numbers in tool view diffs', - wrapLinesInDiffs: 'Wrap Lines in Diffs', - wrapLinesInDiffsDescription: 'Wrap long lines instead of horizontal scrolling in diff views', - alwaysShowContextSize: 'Always Show Context Size', - alwaysShowContextSizeDescription: 'Display context usage even when not near limit', - avatarStyle: 'Avatar Style', - avatarStyleDescription: 'Choose session avatar appearance', - avatarOptions: { - pixelated: 'Pixelated', - gradient: 'Gradient', - brutalist: 'Brutalist', - }, - showFlavorIcons: 'Show AI Provider Icons', - showFlavorIconsDescription: 'Display AI provider icons on session avatars', - compactSessionView: 'Compact Session View', - compactSessionViewDescription: 'Show active sessions in a more compact layout', - }, - - settingsFeatures: { - // Features settings screen - experiments: 'Experiments', - experimentsDescription: 'Enable experimental features that are still in development. These features may be unstable or change without notice.', - experimentalFeatures: 'Experimental Features', - experimentalFeaturesEnabled: 'Experimental features enabled', - experimentalFeaturesDisabled: 'Using stable features only', - webFeatures: 'Web Features', - webFeaturesDescription: 'Features available only in the web version of the app.', - enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)', - enterToSendDisabled: 'Enter inserts a new line', - commandPalette: 'Command Palette', - commandPaletteEnabled: 'Press ⌘K to open', - commandPaletteDisabled: 'Quick command access disabled', - markdownCopyV2: 'Markdown Copy v2', - markdownCopyV2Subtitle: 'Long press opens copy modal', - hideInactiveSessions: 'Hide inactive sessions', - hideInactiveSessionsSubtitle: 'Show only active chats in your list', - enhancedSessionWizard: 'Enhanced Session Wizard', - enhancedSessionWizardEnabled: 'Profile-first session launcher active', - enhancedSessionWizardDisabled: 'Using standard session launcher', - }, - - errors: { - networkError: 'Network error occurred', - serverError: 'Server error occurred', - unknownError: 'An unknown error occurred', - connectionTimeout: 'Connection timed out', - authenticationFailed: 'Authentication failed', - permissionDenied: 'Permission denied', - fileNotFound: 'File not found', - invalidFormat: 'Invalid format', - operationFailed: 'Operation failed', - tryAgain: 'Please try again', - contactSupport: 'Contact support if the problem persists', - sessionNotFound: 'Session not found', - voiceSessionFailed: 'Failed to start voice session', - voiceServiceUnavailable: 'Voice service is temporarily unavailable', - oauthInitializationFailed: 'Failed to initialize OAuth flow', - tokenStorageFailed: 'Failed to store authentication tokens', - oauthStateMismatch: 'Security validation failed. Please try again', - tokenExchangeFailed: 'Failed to exchange authorization code', - oauthAuthorizationDenied: 'Authorization was denied', - webViewLoadFailed: 'Failed to load authentication page', - failedToLoadProfile: 'Failed to load user profile', - userNotFound: 'User not found', - sessionDeleted: 'Session has been deleted', - sessionDeletedDescription: 'This session has been permanently removed', - - // Error functions with context - fieldError: ({ field, reason }: { field: string; reason: string }) => - `${field}: ${reason}`, - validationError: ({ field, min, max }: { field: string; min: number; max: number }) => - `${field} must be between ${min} and ${max}`, - retryIn: ({ seconds }: { seconds: number }) => - `Retry in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`, - errorWithCode: ({ message, code }: { message: string; code: number | string }) => - `${message} (Error ${code})`, - disconnectServiceFailed: ({ service }: { service: string }) => - `Failed to disconnect ${service}`, - connectServiceFailed: ({ service }: { service: string }) => - `Failed to connect ${service}. Please try again.`, - failedToLoadFriends: 'Failed to load friends list', - failedToAcceptRequest: 'Failed to accept friend request', - failedToRejectRequest: 'Failed to reject friend request', - failedToRemoveFriend: 'Failed to remove friend', - searchFailed: 'Search failed. Please try again.', - failedToSendRequest: 'Failed to send friend request', - }, - - newSession: { - // Used by new-session screen and launch flows - title: 'Start New Session', - noMachinesFound: 'No machines found. Start a Happy session on your computer first.', - allMachinesOffline: 'All machines appear offline', - machineDetails: 'View machine details →', - directoryDoesNotExist: 'Directory Not Found', - createDirectoryConfirm: ({ directory }: { directory: string }) => `The directory ${directory} does not exist. Do you want to create it?`, - sessionStarted: 'Session Started', - sessionStartedMessage: 'The session has been started successfully.', - sessionSpawningFailed: 'Session spawning failed - no session ID returned.', - startingSession: 'Starting session...', - startNewSessionInFolder: 'New session here', - failedToStart: 'Failed to start session. Make sure the daemon is running on the target machine.', - sessionTimeout: 'Session startup timed out. The machine may be slow or the daemon may not be responding.', - notConnectedToServer: 'Not connected to server. Check your internet connection.', - noMachineSelected: 'Please select a machine to start the session', - noPathSelected: 'Please select a directory to start the session in', - sessionType: { - title: 'Session Type', - simple: 'Simple', - worktree: 'Worktree', - comingSoon: 'Coming soon', - }, - worktree: { - creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`, - notGitRepo: 'Worktrees require a git repository', - failed: ({ error }: { error: string }) => `Failed to create worktree: ${error}`, - success: 'Worktree created successfully', - } - }, - - sessionHistory: { - // Used by session history screen - title: 'Session History', - empty: 'No sessions found', - today: 'Today', - yesterday: 'Yesterday', - daysAgo: ({ count }: { count: number }) => `${count} ${count === 1 ? 'day' : 'days'} ago`, - viewAll: 'View all sessions', - }, - - session: { - inputPlaceholder: 'Type a message ...', - }, - - commandPalette: { - placeholder: 'Type a command or search...', - }, - - server: { - // Used by Server Configuration screen (app/(app)/server.tsx) - serverConfiguration: 'Server Configuration', - enterServerUrl: 'Please enter a server URL', - notValidHappyServer: 'Not a valid Happy Server', - changeServer: 'Change Server', - continueWithServer: 'Continue with this server?', - resetToDefault: 'Reset to Default', - resetServerDefault: 'Reset server to default?', - validating: 'Validating...', - validatingServer: 'Validating server...', - serverReturnedError: 'Server returned an error', - failedToConnectToServer: 'Failed to connect to server', - currentlyUsingCustomServer: 'Currently using custom server', - customServerUrlLabel: 'Custom Server URL', - advancedFeatureFooter: "This is an advanced feature. Only change the server if you know what you're doing. You will need to log out and log in again after changing servers." - }, - - sessionInfo: { - // Used by Session Info screen (app/(app)/session/[id]/info.tsx) - killSession: 'Kill Session', - killSessionConfirm: 'Are you sure you want to terminate this session?', - archiveSession: 'Archive Session', - archiveSessionConfirm: 'Are you sure you want to archive this session?', - happySessionIdCopied: 'Happy Session ID copied to clipboard', - failedToCopySessionId: 'Failed to copy Happy Session ID', - happySessionId: 'Happy Session ID', - claudeCodeSessionId: 'Claude Code Session ID', - claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', - aiProvider: 'AI Provider', - failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', - metadataCopied: 'Metadata copied to clipboard', - failedToCopyMetadata: 'Failed to copy metadata', - failedToKillSession: 'Failed to kill session', - failedToArchiveSession: 'Failed to archive session', - connectionStatus: 'Connection Status', - created: 'Created', - lastUpdated: 'Last Updated', - sequence: 'Sequence', - quickActions: 'Quick Actions', - viewMachine: 'View Machine', - viewMachineSubtitle: 'View machine details and sessions', - killSessionSubtitle: 'Immediately terminate the session', - archiveSessionSubtitle: 'Archive this session and stop it', - metadata: 'Metadata', - host: 'Host', - path: 'Path', - operatingSystem: 'Operating System', - processId: 'Process ID', - happyHome: 'Happy Home', - copyMetadata: 'Copy Metadata', - agentState: 'Agent State', - controlledByUser: 'Controlled by User', - pendingRequests: 'Pending Requests', - activity: 'Activity', - thinking: 'Thinking', - thinkingSince: 'Thinking Since', - cliVersion: 'CLI Version', - cliVersionOutdated: 'CLI Update Required', - cliVersionOutdatedMessage: ({ currentVersion, requiredVersion }: { currentVersion: string; requiredVersion: string }) => - `Version ${currentVersion} installed. Update to ${requiredVersion} or later`, - updateCliInstructions: 'Please run npm install -g happy-coder@latest', - deleteSession: 'Delete Session', - deleteSessionSubtitle: 'Permanently remove this session', - deleteSessionConfirm: 'Delete Session Permanently?', - deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', - failedToDeleteSession: 'Failed to delete session', - sessionDeleted: 'Session deleted successfully', - - }, - - components: { - emptyMainScreen: { - // Used by EmptyMainScreen component - readyToCode: 'Ready to code?', - installCli: 'Install the Happy CLI', - runIt: 'Run it', - scanQrCode: 'Scan the QR code', - openCamera: 'Open Camera', - }, - }, - - agentInput: { - permissionMode: { - title: 'PERMISSION MODE', - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - agent: { - claude: 'Claude', - codex: 'Codex', - gemini: 'Gemini', - }, - model: { - title: 'MODEL', - configureInCli: 'Configure models in CLI settings', - }, - codexPermissionMode: { - title: 'CODEX PERMISSION MODE', - default: 'CLI Settings', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', - }, - codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', - }, - geminiPermissionMode: { - title: 'GEMINI PERMISSION MODE', - default: 'Default', - readOnly: 'Read Only', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', - }, - context: { - remaining: ({ percent }: { percent: number }) => `${percent}% left`, - }, - suggestion: { - fileLabel: 'FILE', - folderLabel: 'FOLDER', - }, - noMachinesAvailable: 'No machines', - }, - - machineLauncher: { - showLess: 'Show less', - showAll: ({ count }: { count: number }) => `Show all (${count} paths)`, - enterCustomPath: 'Enter custom path', - offlineUnableToSpawn: 'Unable to spawn new session, offline', - }, - - sidebar: { - sessionsTitle: 'Happy', - }, - - toolView: { - input: 'Input', - output: 'Output', - }, - - tools: { - fullView: { - description: 'Description', - inputParams: 'Input Parameters', - output: 'Output', - error: 'Error', - completed: 'Tool completed successfully', - noOutput: 'No output was produced', - running: 'Tool is running...', - rawJsonDevMode: 'Raw JSON (Dev Mode)', - }, - taskView: { - initializing: 'Initializing agent...', - moreTools: ({ count }: { count: number }) => `+${count} more ${plural({ count, singular: 'tool', plural: 'tools' })}`, - }, - multiEdit: { - editNumber: ({ index, total }: { index: number; total: number }) => `Edit ${index} of ${total}`, - replaceAll: 'Replace All', - }, - names: { - task: 'Task', - terminal: 'Terminal', - searchFiles: 'Search Files', - search: 'Search', - searchContent: 'Search Content', - listFiles: 'List Files', - planProposal: 'Plan proposal', - readFile: 'Read File', - editFile: 'Edit File', - writeFile: 'Write File', - fetchUrl: 'Fetch URL', - readNotebook: 'Read Notebook', - editNotebook: 'Edit Notebook', - todoList: 'Todo List', - webSearch: 'Web Search', - reasoning: 'Reasoning', - applyChanges: 'Update file', - viewDiff: 'Current file changes', - question: 'Question', - }, - askUserQuestion: { - submit: 'Submit Answer', - multipleQuestions: ({ count }: { count: number }) => `${count} questions`, - }, - desc: { - terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, - searchPattern: ({ pattern }: { pattern: string }) => `Search(pattern: ${pattern})`, - searchPath: ({ basename }: { basename: string }) => `Search(path: ${basename})`, - fetchUrlHost: ({ host }: { host: string }) => `Fetch URL(url: ${host})`, - editNotebookMode: ({ path, mode }: { path: string; mode: string }) => `Edit Notebook(file: ${path}, mode: ${mode})`, - todoListCount: ({ count }: { count: number }) => `Todo List(count: ${count})`, - webSearchQuery: ({ query }: { query: string }) => `Web Search(query: ${query})`, - grepPattern: ({ pattern }: { pattern: string }) => `grep(pattern: ${pattern})`, - multiEditEdits: ({ path, count }: { path: string; count: number }) => `${path} (${count} edits)`, - readingFile: ({ file }: { file: string }) => `Reading ${file}`, - writingFile: ({ file }: { file: string }) => `Writing ${file}`, - modifyingFile: ({ file }: { file: string }) => `Modifying ${file}`, - modifyingFiles: ({ count }: { count: number }) => `Modifying ${count} files`, - modifyingMultipleFiles: ({ file, count }: { file: string; count: number }) => `${file} and ${count} more`, - showingDiff: 'Showing changes', - } - }, - - files: { - searchPlaceholder: 'Search files...', - detachedHead: 'detached HEAD', - summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `${staged} staged • ${unstaged} unstaged`, - notRepo: 'Not a git repository', - notUnderGit: 'This directory is not under git version control', - searching: 'Searching files...', - noFilesFound: 'No files found', - noFilesInProject: 'No files in project', - tryDifferentTerm: 'Try a different search term', - searchResults: ({ count }: { count: number }) => `Search Results (${count})`, - projectRoot: 'Project root', - stagedChanges: ({ count }: { count: number }) => `Staged Changes (${count})`, - unstagedChanges: ({ count }: { count: number }) => `Unstaged Changes (${count})`, - // File viewer strings - loadingFile: ({ fileName }: { fileName: string }) => `Loading ${fileName}...`, - binaryFile: 'Binary File', - cannotDisplayBinary: 'Cannot display binary file content', - diff: 'Diff', - file: 'File', - fileEmpty: 'File is empty', - noChanges: 'No changes to display', - }, - - settingsVoice: { - // Voice settings screen - languageTitle: 'Language', - languageDescription: 'Choose your preferred language for voice assistant interactions. This setting syncs across all your devices.', - preferredLanguage: 'Preferred Language', - preferredLanguageSubtitle: 'Language used for voice assistant responses', - language: { - searchPlaceholder: 'Search languages...', - title: 'Languages', - footer: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'language', plural: 'languages' })} available`, - autoDetect: 'Auto-detect', - } - }, - - settingsAccount: { - // Account settings screen - accountInformation: 'Account Information', - status: 'Status', - statusActive: 'Active', - statusNotAuthenticated: 'Not Authenticated', - anonymousId: 'Anonymous ID', - publicId: 'Public ID', - notAvailable: 'Not available', - linkNewDevice: 'Link New Device', - linkNewDeviceSubtitle: 'Scan QR code to link device', - profile: 'Profile', - name: 'Name', - github: 'GitHub', - tapToDisconnect: 'Tap to disconnect', - server: 'Server', - backup: 'Backup', - backupDescription: 'Your secret key is the only way to recover your account. Save it in a secure place like a password manager.', - secretKey: 'Secret Key', - tapToReveal: 'Tap to reveal', - tapToHide: 'Tap to hide', - secretKeyLabel: 'SECRET KEY (TAP TO COPY)', - secretKeyCopied: 'Secret key copied to clipboard. Store it in a safe place!', - secretKeyCopyFailed: 'Failed to copy secret key', - privacy: 'Privacy', - privacyDescription: 'Help improve the app by sharing anonymous usage data. No personal information is collected.', - analytics: 'Analytics', - analyticsDisabled: 'No data is shared', - analyticsEnabled: 'Anonymous usage data is shared', - dangerZone: 'Danger Zone', - logout: 'Logout', - logoutSubtitle: 'Sign out and clear local data', - logoutConfirm: 'Are you sure you want to logout? Make sure you have backed up your secret key!', - }, - - settingsLanguage: { - // Language settings screen - title: 'Language', - description: 'Choose your preferred language for the app interface. This will sync across all your devices.', - currentLanguage: 'Current Language', - automatic: 'Automatic', - automaticSubtitle: 'Detect from device settings', - needsRestart: 'Language Changed', - needsRestartMessage: 'The app needs to restart to apply the new language setting.', - restartNow: 'Restart Now', - }, - - connectButton: { - authenticate: 'Authenticate Terminal', - authenticateWithUrlPaste: 'Authenticate Terminal with URL paste', - pasteAuthUrl: 'Paste the auth URL from your terminal', - }, - - updateBanner: { - updateAvailable: 'Update available', - pressToApply: 'Press to apply the update', - whatsNew: "What's new", - seeLatest: 'See the latest updates and improvements', - nativeUpdateAvailable: 'App Update Available', - tapToUpdateAppStore: 'Tap to update in App Store', - tapToUpdatePlayStore: 'Tap to update in Play Store', - }, - - changelog: { - // Used by the changelog screen - version: ({ version }: { version: number }) => `Version ${version}`, - noEntriesAvailable: 'No changelog entries available.', - }, - - terminal: { - // Used by terminal connection screens - webBrowserRequired: 'Web Browser Required', - webBrowserRequiredDescription: 'Terminal connection links can only be opened in a web browser for security reasons. Please use the QR code scanner or open this link on a computer.', - processingConnection: 'Processing connection...', - invalidConnectionLink: 'Invalid Connection Link', - invalidConnectionLinkDescription: 'The connection link is missing or invalid. Please check the URL and try again.', - connectTerminal: 'Connect Terminal', - terminalRequestDescription: 'A terminal is requesting to connect to your Happy Coder account. This will allow the terminal to send and receive messages securely.', - connectionDetails: 'Connection Details', - publicKey: 'Public Key', - encryption: 'Encryption', - endToEndEncrypted: 'End-to-end encrypted', - acceptConnection: 'Accept Connection', - connecting: 'Connecting...', - reject: 'Reject', - security: 'Security', - securityFooter: 'This connection link was processed securely in your browser and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - securityFooterDevice: 'This connection was processed securely on your device and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - clientSideProcessing: 'Client-Side Processing', - linkProcessedLocally: 'Link processed locally in browser', - linkProcessedOnDevice: 'Link processed locally on device', - }, - - modals: { - // Used across connect flows and settings - authenticateTerminal: 'Authenticate Terminal', - pasteUrlFromTerminal: 'Paste the authentication URL from your terminal', - deviceLinkedSuccessfully: 'Device linked successfully', - terminalConnectedSuccessfully: 'Terminal connected successfully', - invalidAuthUrl: 'Invalid authentication URL', - developerMode: 'Developer Mode', - developerModeEnabled: 'Developer mode enabled', - developerModeDisabled: 'Developer mode disabled', - disconnectGithub: 'Disconnect GitHub', - disconnectGithubConfirm: 'Are you sure you want to disconnect your GitHub account?', - disconnectService: ({ service }: { service: string }) => - `Disconnect ${service}`, - disconnectServiceConfirm: ({ service }: { service: string }) => - `Are you sure you want to disconnect ${service} from your account?`, - disconnect: 'Disconnect', - failedToConnectTerminal: 'Failed to connect terminal', - cameraPermissionsRequiredToConnectTerminal: 'Camera permissions are required to connect terminal', - failedToLinkDevice: 'Failed to link device', - cameraPermissionsRequiredToScanQr: 'Camera permissions are required to scan QR codes' - }, - - navigation: { - // Navigation titles and screen headers - connectTerminal: 'Connect Terminal', - linkNewDevice: 'Link New Device', - restoreWithSecretKey: 'Restore with Secret Key', - whatsNew: "What's New", - friends: 'Friends', - }, - - welcome: { - // Main welcome screen for unauthenticated users - title: 'Codex and Claude Code mobile client', - subtitle: 'End-to-end encrypted and your account is stored only on your device.', - createAccount: 'Create account', - linkOrRestoreAccount: 'Link or restore account', - loginWithMobileApp: 'Login with mobile app', - }, - - review: { - // Used by utils/requestReview.ts - enjoyingApp: 'Enjoying the app?', - feedbackPrompt: "We'd love to hear your feedback!", - yesILoveIt: 'Yes, I love it!', - notReally: 'Not really' - }, - - items: { - // Used by Item component for copy toast - copiedToClipboard: ({ label }: { label: string }) => `${label} copied to clipboard` - }, - - machine: { - launchNewSessionInDirectory: 'Launch New Session in Directory', - offlineUnableToSpawn: 'Launcher disabled while machine is offline', - offlineHelp: '• Make sure your computer is online\n• Run `happy daemon status` to diagnose\n• Are you running the latest CLI version? Upgrade with `npm install -g happy-coder@latest`', - daemon: 'Daemon', - status: 'Status', - stopDaemon: 'Stop Daemon', - lastKnownPid: 'Last Known PID', - lastKnownHttpPort: 'Last Known HTTP Port', - startedAt: 'Started At', - cliVersion: 'CLI Version', - daemonStateVersion: 'Daemon State Version', - activeSessions: ({ count }: { count: number }) => `Active Sessions (${count})`, - machineGroup: 'Machine', - host: 'Host', - machineId: 'Machine ID', - username: 'Username', - homeDirectory: 'Home Directory', - platform: 'Platform', - architecture: 'Architecture', - lastSeen: 'Last Seen', - never: 'Never', - metadataVersion: 'Metadata Version', - untitledSession: 'Untitled Session', - back: 'Back', - }, - - message: { - switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, - unknownEvent: 'Unknown event', - usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, - unknownTime: 'unknown time', - }, - - codex: { - // Codex permission dialog buttons - permissions: { - yesForSession: "Yes, and don't ask for a session", - stopAndExplain: 'Stop, and explain what to do', - } - }, - - claude: { - // Claude permission dialog buttons - permissions: { - yesAllowAllEdits: 'Yes, allow all edits during this session', - yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and provide feedback', - } - }, - - textSelection: { - // Text selection screen - selectText: 'Select text range', - title: 'Select Text', - noTextProvided: 'No text provided', - textNotFound: 'Text not found or expired', - textCopied: 'Text copied to clipboard', - failedToCopy: 'Failed to copy text to clipboard', - noTextToCopy: 'No text available to copy', - }, - - markdown: { - // Markdown copy functionality - codeCopied: 'Code copied', - copyFailed: 'Copy failed', - mermaidRenderFailed: 'Failed to render mermaid diagram', - }, - - artifacts: { - // Artifacts feature - title: 'Artifacts', - countSingular: '1 artifact', - countPlural: ({ count }: { count: number }) => `${count} artifacts`, - empty: 'No artifacts yet', - emptyDescription: 'Create your first artifact to get started', - new: 'New Artifact', - edit: 'Edit Artifact', - delete: 'Delete', - updateError: 'Failed to update artifact. Please try again.', - notFound: 'Artifact not found', - discardChanges: 'Discard changes?', - discardChangesDescription: 'You have unsaved changes. Are you sure you want to discard them?', - deleteConfirm: 'Delete artifact?', - deleteConfirmDescription: 'This action cannot be undone', - titleLabel: 'TITLE', - titlePlaceholder: 'Enter a title for your artifact', - bodyLabel: 'CONTENT', - bodyPlaceholder: 'Write your content here...', - emptyFieldsError: 'Please enter a title or content', - createError: 'Failed to create artifact. Please try again.', - save: 'Save', - saving: 'Saving...', - loading: 'Loading artifacts...', - error: 'Failed to load artifact', - }, - - friends: { - // Friends feature - title: 'Friends', - manageFriends: 'Manage your friends and connections', - searchTitle: 'Find Friends', - pendingRequests: 'Friend Requests', - myFriends: 'My Friends', - noFriendsYet: "You don't have any friends yet", - findFriends: 'Find Friends', - remove: 'Remove', - pendingRequest: 'Pending', - sentOn: ({ date }: { date: string }) => `Sent on ${date}`, - accept: 'Accept', - reject: 'Reject', - addFriend: 'Add Friend', - alreadyFriends: 'Already Friends', - requestPending: 'Request Pending', - searchInstructions: 'Enter a username to search for friends', - searchPlaceholder: 'Enter username...', - searching: 'Searching...', - userNotFound: 'User not found', - noUserFound: 'No user found with that username', - checkUsername: 'Please check the username and try again', - howToFind: 'How to Find Friends', - findInstructions: 'Search for friends by their username. Both you and your friend need to have GitHub connected to send friend requests.', - requestSent: 'Friend request sent!', - requestAccepted: 'Friend request accepted!', - requestRejected: 'Friend request rejected', - friendRemoved: 'Friend removed', - confirmRemove: 'Remove Friend', - confirmRemoveMessage: 'Are you sure you want to remove this friend?', - cannotAddYourself: 'You cannot send a friend request to yourself', - bothMustHaveGithub: 'Both users must have GitHub connected to become friends', - status: { - none: 'Not connected', - requested: 'Request sent', - pending: 'Request pending', - friend: 'Friends', - rejected: 'Rejected', - }, - acceptRequest: 'Accept Request', - removeFriend: 'Remove Friend', - removeFriendConfirm: ({ name }: { name: string }) => `Are you sure you want to remove ${name} as a friend?`, - requestSentDescription: ({ name }: { name: string }) => `Your friend request has been sent to ${name}`, - requestFriendship: 'Request friendship', - cancelRequest: 'Cancel friendship request', - cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, - denyRequest: 'Deny friendship', - nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, - }, - - usage: { - // Usage panel strings - today: 'Today', - last7Days: 'Last 7 days', - last30Days: 'Last 30 days', - totalTokens: 'Total Tokens', - totalCost: 'Total Cost', - tokens: 'Tokens', - cost: 'Cost', - usageOverTime: 'Usage over time', - byModel: 'By Model', - noData: 'No usage data available', - }, - - feed: { - // Feed notifications for friend requests and acceptances - friendRequestFrom: ({ name }: { name: string }) => `${name} sent you a friend request`, - friendRequestGeneric: 'New friend request', - friendAccepted: ({ name }: { name: string }) => `You are now friends with ${name}`, - friendAcceptedGeneric: 'Friend request accepted', - }, - - profiles: { - // Profile management feature - title: 'Profiles', - subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', - defaultModel: 'Default Model', - addProfile: 'Add Profile', - profileName: 'Profile Name', - enterName: 'Enter profile name', - baseURL: 'Base URL', - authToken: 'Auth Token', - enterToken: 'Enter auth token', - model: 'Model', - tmuxSession: 'Tmux Session', - enterTmuxSession: 'Enter tmux session name', - tmuxTempDir: 'Tmux Temp Directory', - enterTmuxTempDir: 'Enter temp directory path', - tmuxUpdateEnvironment: 'Update environment automatically', - nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', - editProfile: 'Edit Profile', - addProfileTitle: 'Add New Profile', - delete: { - title: 'Delete Profile', - message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, - confirm: 'Delete', - cancel: 'Cancel', - }, - } -} as const; - -export type Translations = typeof en; - -/** - * Generic translation type that matches the structure of Translations - * but allows different string values (for other languages) - */ -export type TranslationStructure = { - readonly [K in keyof Translations]: { - readonly [P in keyof Translations[K]]: Translations[K][P] extends string - ? string - : Translations[K][P] extends (...args: any[]) => string - ? Translations[K][P] - : Translations[K][P] extends object - ? { - readonly [Q in keyof Translations[K][P]]: Translations[K][P][Q] extends string - ? string - : Translations[K][P][Q] - } - : Translations[K][P] - } -}; diff --git a/sources/text/_types.ts b/sources/text/_types.ts new file mode 100644 index 000000000..435f5471e --- /dev/null +++ b/sources/text/_types.ts @@ -0,0 +1,3 @@ +export type { TranslationStructure } from './translations/en'; + +export type Translations = import('./translations/en').TranslationStructure; diff --git a/sources/text/index.ts b/sources/text/index.ts index e627bb855..a05afb9d6 100644 --- a/sources/text/index.ts +++ b/sources/text/index.ts @@ -1,4 +1,5 @@ -import { en, type Translations, type TranslationStructure } from './_default'; +import { en } from './translations/en'; +import type { Translations, TranslationStructure } from './_types'; import { ru } from './translations/ru'; import { pl } from './translations/pl'; import { es } from './translations/es'; @@ -98,13 +99,11 @@ let found = false; if (settings.settings.preferredLanguage && settings.settings.preferredLanguage in translations) { currentLanguage = settings.settings.preferredLanguage as SupportedLanguage; found = true; - console.log(`[i18n] Using preferred language: ${currentLanguage}`); } // Read from device if (!found) { let locales = Localization.getLocales(); - console.log(`[i18n] Device locales:`, locales.map(l => l.languageCode)); for (let l of locales) { if (l.languageCode) { // Expo added special handling for Chinese variants using script code https://github.com/expo/expo/pull/34984 @@ -114,35 +113,26 @@ if (!found) { // We only have translations for simplified Chinese right now, but looking for help with traditional Chinese. if (l.languageScriptCode === 'Hans') { chineseVariant = 'zh-Hans'; - // } else if (l.languageScriptCode === 'Hant') { - // chineseVariant = 'zh-Hant'; } - console.log(`[i18n] Chinese script code: ${l.languageScriptCode} -> ${chineseVariant}`); - if (chineseVariant && chineseVariant in translations) { currentLanguage = chineseVariant as SupportedLanguage; - console.log(`[i18n] Using Chinese variant: ${currentLanguage}`); break; } currentLanguage = 'zh-Hans'; - console.log(`[i18n] Falling back to simplified Chinese: zh-Hans`); break; } // Direct match for non-Chinese languages if (l.languageCode in translations) { currentLanguage = l.languageCode as SupportedLanguage; - console.log(`[i18n] Using device locale: ${currentLanguage}`); break; } } } } -console.log(`[i18n] Final language: ${currentLanguage}`); - /** * Main translation function with strict typing * diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 46f9d4f9c..ee8d1b1af 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Catalan plural helper function @@ -31,6 +31,8 @@ export const ca: TranslationStructure = { common: { // Simple string constants + add: 'Afegeix', + actions: 'Accions', cancel: 'Cancel·la', authenticate: 'Autentica', save: 'Desa', @@ -47,6 +49,9 @@ export const ca: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descarta', + discardChanges: 'Descarta els canvis', + unsavedChangesWarning: 'Tens canvis sense desar.', + keepEditing: 'Continua editant', version: 'Versió', copied: 'Copiat', copy: 'Copiar', @@ -60,6 +65,10 @@ export const ca: TranslationStructure = { retry: 'Torna-ho a provar', delete: 'Elimina', optional: 'Opcional', + noMatches: 'Sense coincidències', + all: 'All', + machine: 'màquina', + clearSearch: 'Clear search', }, profile: { @@ -208,6 +217,15 @@ export const ca: TranslationStructure = { enhancedSessionWizard: 'Assistent de sessió millorat', enhancedSessionWizardEnabled: 'Llançador de sessió amb perfil actiu', enhancedSessionWizardDisabled: 'Usant el llançador de sessió estàndard', + profiles: 'Perfils d\'IA', + profilesEnabled: 'Selecció de perfils activada', + profilesDisabled: 'Selecció de perfils desactivada', + pickerSearch: 'Cerca als selectors', + pickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquina i camí', + machinePickerSearch: 'Cerca de màquines', + machinePickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquines', + pathPickerSearch: 'Cerca de camins', + pathPickerSearchSubtitle: 'Mostra un camp de cerca als selectors de camins', }, errors: { @@ -260,6 +278,9 @@ export const ca: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Inicia una nova sessió', + selectMachineTitle: 'Selecciona màquina', + selectPathTitle: 'Selecciona camí', + searchPathsPlaceholder: 'Cerca camins...', noMachinesFound: 'No s\'han trobat màquines. Inicia una sessió de Happy al teu ordinador primer.', allMachinesOffline: 'Totes les màquines estan fora de línia', machineDetails: 'Veure detalls de la màquina →', @@ -275,6 +296,26 @@ export const ca: TranslationStructure = { startNewSessionInFolder: 'Nova sessió aquí', noMachineSelected: 'Si us plau, selecciona una màquina per iniciar la sessió', noPathSelected: 'Si us plau, selecciona un directori per iniciar la sessió', + machinePicker: { + searchPlaceholder: 'Cerca màquines...', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + allTitle: 'Totes', + emptyMessage: 'No hi ha màquines disponibles', + }, + pathPicker: { + enterPathTitle: 'Introdueix el camí', + enterPathPlaceholder: 'Introdueix un camí...', + customPathTitle: 'Camí personalitzat', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + suggestedTitle: 'Suggerits', + allTitle: 'Totes', + emptyRecent: 'No hi ha camins recents', + emptyFavorites: 'No hi ha camins preferits', + emptySuggested: 'No hi ha camins suggerits', + emptyAll: 'No hi ha camins', + }, sessionType: { title: 'Tipus de sessió', simple: 'Simple', @@ -336,6 +377,7 @@ export const ca: TranslationStructure = { happySessionId: 'ID de la sessió de Happy', claudeCodeSessionId: 'ID de la sessió de Claude Code', claudeCodeSessionIdCopied: 'ID de la sessió de Claude Code copiat al porta-retalls', + aiProfile: 'Perfil d\'IA', aiProvider: 'Proveïdor d\'IA', failedToCopyClaudeCodeSessionId: 'Ha fallat copiar l\'ID de la sessió de Claude Code', metadataCopied: 'Metadades copiades al porta-retalls', @@ -390,6 +432,10 @@ export const ca: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Variables d\'entorn', + titleWithCount: ({ count }: { count: number }) => `Variables d'entorn (${count})`, + }, permissionMode: { title: 'MODE DE PERMISOS', default: 'Per defecte', @@ -430,14 +476,29 @@ export const ca: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODE DE PERMISOS', + title: 'MODE DE PERMISOS GEMINI', default: 'Per defecte', - acceptEdits: 'Accepta edicions', - plan: 'Mode de planificació', - bypassPermissions: 'Mode Yolo', - badgeAcceptAllEdits: 'Accepta totes les edicions', - badgeBypassAllPermissions: 'Omet tots els permisos', - badgePlanMode: 'Mode de planificació', + readOnly: 'Només lectura', + safeYolo: 'YOLO segur', + yolo: 'YOLO', + badgeReadOnly: 'Només lectura', + badgeSafeYolo: 'YOLO segur', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Més capaç', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Ràpid i eficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Més ràpid', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restant`, @@ -504,6 +565,10 @@ export const ca: TranslationStructure = { applyChanges: 'Actualitza fitxer', viewDiff: 'Canvis del fitxer actual', question: 'Pregunta', + changeTitle: 'Canvia el títol', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -894,8 +959,120 @@ export const ca: TranslationStructure = { tmuxTempDir: 'Directori temporal tmux', enterTmuxTempDir: 'Introdueix el directori temporal tmux', tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux', - deleteConfirm: 'Segur que vols eliminar aquest perfil?', + deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`, nameRequired: 'El nom del perfil és obligatori', + builtIn: 'Integrat', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Preferits', + custom: 'Els teus perfils', + builtIn: 'Perfils integrats', + }, + actions: { + viewEnvironmentVariables: 'Variables d\'entorn', + addToFavorites: 'Afegeix als preferits', + removeFromFavorites: 'Treu dels preferits', + editProfile: 'Edita el perfil', + duplicateProfile: 'Duplica el perfil', + deleteProfile: 'Elimina el perfil', + }, + copySuffix: '(Copy)', + duplicateName: 'Ja existeix un perfil amb aquest nom', + setupInstructions: { + title: 'Instruccions de configuració', + viewOfficialGuide: 'Veure la guia oficial de configuració', + }, + defaultSessionType: 'Tipus de sessió predeterminat', + defaultPermissionMode: { + title: 'Mode de permisos predeterminat', + descriptions: { + default: 'Demana permisos', + acceptEdits: 'Aprova edicions automàticament', + plan: 'Planifica abans d\'executar', + bypassPermissions: 'Salta tots els permisos', + }, + }, + aiBackend: { + title: 'Backend d\'IA', + selectAtLeastOneError: 'Selecciona com a mínim un backend d\'IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Inicia sessions a Tmux', + spawnSessionsEnabledSubtitle: 'Les sessions s\'inicien en noves finestres de tmux.', + spawnSessionsDisabledSubtitle: 'Les sessions s\'inicien en un shell normal (sense integració amb tmux)', + sessionNamePlaceholder: 'Buit = sessió actual/més recent', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Previsualitza màquina', + selectMachine: 'Selecciona màquina', + resolveSubtitle: 'Resol variables d\'entorn de la màquina per a aquest perfil.', + selectSubtitle: 'Selecciona una màquina per previsualitzar els valors resolts.', + }, + environmentVariables: { + title: 'Variables d\'entorn', + addVariable: 'Afegeix variable', + namePlaceholder: 'Nom de variable (p. ex., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ex., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Introdueix un nom de variable.', + invalidNameFormat: 'Els noms de variable han de ser lletres majúscules, números i guions baixos, i no poden començar amb un número.', + duplicateName: 'Aquesta variable ja existeix.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de reserva:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor per defecte', + secretNotRetrieved: 'Valor secret - no es recupera per seguretat', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `S'està substituint el valor predeterminat documentat: ${expectedValue}`, + useMachineEnvToggle: 'Utilitza el valor de l\'entorn de la màquina', + resolvedOnSessionStart: 'Es resol quan la sessió s\'inicia a la màquina seleccionada.', + sourceVariableLabel: 'Variable d\'origen', + sourceVariablePlaceholder: 'Nom de variable d\'origen (p. ex., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Comprovant ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Buit a ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Buit a ${machine} (utilitzant reserva)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No trobat a ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No trobat a ${machine} (utilitzant reserva)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor trobat a ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiereix del valor documentat: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ocult per seguretat`, + hiddenValue: '***ocult***', + emptyValue: '(buit)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessió rebrà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variables d'entorn · ${profileName}`, + descriptionPrefix: 'Aquestes variables d\'entorn s\'envien en iniciar la sessió. Els valors es resolen usant el dimoni a', + descriptionFallbackMachine: 'la màquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hi ha variables d\'entorn configurades per a aquest perfil.', + checkingSuffix: '(comprovant…)', + detail: { + fixed: 'Fix', + machine: 'Màquina', + checking: 'Comprovant', + fallback: 'Reserva', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `Estàs segur que vols eliminar "${name}"? Aquesta acció no es pot desfer.`, diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 7bddc729b..6ed9244c7 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -1,5 +1,3 @@ -import type { TranslationStructure } from '../_default'; - /** * English plural helper function * English has 2 plural forms: singular, plural @@ -14,10 +12,10 @@ function plural({ count, singular, plural }: { count: number; singular: string; * ENGLISH TRANSLATIONS - DEDICATED FILE * * This file represents the new translation architecture where each language - * has its own dedicated file instead of being embedded in _default.ts. + * has its own dedicated file instead of being embedded in _types.ts. * * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects + * - Previously: All languages in a single default file * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) * - Benefit: Better maintainability, smaller files, easier language management * @@ -29,7 +27,7 @@ function plural({ count, singular, plural }: { count: number; singular: string; * - Type safety enforced by TranslationStructure interface * - New translation keys must be added to ALL language files */ -export const en: TranslationStructure = { +export const en = { tabs: { // Tab navigation labels inbox: 'Inbox', @@ -46,6 +44,8 @@ export const en: TranslationStructure = { common: { // Simple string constants + add: 'Add', + actions: 'Actions', cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', @@ -62,6 +62,9 @@ export const en: TranslationStructure = { yes: 'Yes', no: 'No', discard: 'Discard', + discardChanges: 'Discard changes', + unsavedChangesWarning: 'You have unsaved changes.', + keepEditing: 'Keep editing', version: 'Version', copy: 'Copy', copied: 'Copied', @@ -75,6 +78,10 @@ export const en: TranslationStructure = { retry: 'Retry', delete: 'Delete', optional: 'optional', + noMatches: 'No matches', + all: 'All', + machine: 'machine', + clearSearch: 'Clear search', }, profile: { @@ -211,8 +218,8 @@ export const en: TranslationStructure = { webFeatures: 'Web Features', webFeaturesDescription: 'Features available only in the web version of the app.', enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send messages', - enterToSendDisabled: 'Press ⌘+Enter to send messages', + enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)', + enterToSendDisabled: 'Enter inserts a new line', commandPalette: 'Command Palette', commandPaletteEnabled: 'Press ⌘K to open', commandPaletteDisabled: 'Quick command access disabled', @@ -223,6 +230,15 @@ export const en: TranslationStructure = { enhancedSessionWizard: 'Enhanced Session Wizard', enhancedSessionWizardEnabled: 'Profile-first session launcher active', enhancedSessionWizardDisabled: 'Using standard session launcher', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { @@ -275,6 +291,9 @@ export const en: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Start New Session', + selectMachineTitle: 'Select Machine', + selectPathTitle: 'Select Path', + searchPathsPlaceholder: 'Search paths...', noMachinesFound: 'No machines found. Start a Happy session on your computer first.', allMachinesOffline: 'All machines appear offline', machineDetails: 'View machine details →', @@ -290,6 +309,26 @@ export const en: TranslationStructure = { notConnectedToServer: 'Not connected to server. Check your internet connection.', noMachineSelected: 'Please select a machine to start the session', noPathSelected: 'Please select a directory to start the session in', + machinePicker: { + searchPlaceholder: 'Search machines...', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + allTitle: 'All', + emptyMessage: 'No machines available', + }, + pathPicker: { + enterPathTitle: 'Enter Path', + enterPathPlaceholder: 'Enter a path...', + customPathTitle: 'Custom Path', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + suggestedTitle: 'Suggested', + allTitle: 'All', + emptyRecent: 'No recent paths', + emptyFavorites: 'No favorite paths', + emptySuggested: 'No suggested paths', + emptyAll: 'No paths', + }, sessionType: { title: 'Session Type', simple: 'Simple', @@ -315,7 +354,7 @@ export const en: TranslationStructure = { }, session: { - inputPlaceholder: 'Type a message ...', + inputPlaceholder: 'What would you like to work on?', }, commandPalette: { @@ -351,6 +390,7 @@ export const en: TranslationStructure = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', + aiProfile: 'AI Profile', aiProvider: 'AI Provider', failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', metadataCopied: 'Metadata copied to clipboard', @@ -405,6 +445,10 @@ export const en: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Env Vars', + titleWithCount: ({ count }: { count: number }) => `Env Vars (${count})`, + }, permissionMode: { title: 'PERMISSION MODE', default: 'Default', @@ -454,6 +498,21 @@ export const en: TranslationStructure = { badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Most capable', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Fast & efficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Fastest', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, }, @@ -519,6 +578,10 @@ export const en: TranslationStructure = { applyChanges: 'Update file', viewDiff: 'Current file changes', question: 'Question', + changeTitle: 'Change Title', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, askUserQuestion: { submit: 'Submit Answer', @@ -902,8 +965,8 @@ export const en: TranslationStructure = { // Profile management feature title: 'Profiles', subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', + noProfile: 'Default Environment', + noProfileDescription: 'Use the machine environment without profile variables', defaultModel: 'Default Model', addProfile: 'Add Profile', profileName: 'Profile Name', @@ -918,9 +981,121 @@ export const en: TranslationStructure = { enterTmuxTempDir: 'Enter temp directory path', tmuxUpdateEnvironment: 'Update environment automatically', nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`, editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', + builtIn: 'Built-in', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Favorites', + custom: 'Your Profiles', + builtIn: 'Built-in Profiles', + }, + actions: { + viewEnvironmentVariables: 'Environment Variables', + addToFavorites: 'Add to favorites', + removeFromFavorites: 'Remove from favorites', + editProfile: 'Edit profile', + duplicateProfile: 'Duplicate profile', + deleteProfile: 'Delete profile', + }, + copySuffix: '(Copy)', + duplicateName: 'A profile with this name already exists', + setupInstructions: { + title: 'Setup Instructions', + viewOfficialGuide: 'View Official Setup Guide', + }, + defaultSessionType: 'Default Session Type', + defaultPermissionMode: { + title: 'Default Permission Mode', + descriptions: { + default: 'Ask for permissions', + acceptEdits: 'Auto-approve edits', + plan: 'Plan before executing', + bypassPermissions: 'Skip all permissions', + }, + }, + aiBackend: { + title: 'AI Backend', + selectAtLeastOneError: 'Select at least one AI backend.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Spawn Sessions in Tmux', + spawnSessionsEnabledSubtitle: 'Sessions spawn in new tmux windows.', + spawnSessionsDisabledSubtitle: 'Sessions spawn in regular shell (no tmux integration)', + sessionNamePlaceholder: 'Empty = current/most recent session', + tempDirPlaceholder: '/tmp (optional)', + }, + previewMachine: { + title: 'Preview Machine', + selectMachine: 'Select machine', + resolveSubtitle: 'Resolve machine environment variables for this profile.', + selectSubtitle: 'Select a machine to preview resolved values.', + }, + environmentVariables: { + title: 'Environment Variables', + addVariable: 'Add Variable', + namePlaceholder: 'Variable name (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Value (e.g., my-value or ${MY_VAR})', + validation: { + nameRequired: 'Enter a variable name.', + invalidNameFormat: 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.', + duplicateName: 'That variable already exists.', + }, + card: { + valueLabel: 'Value:', + fallbackValueLabel: 'Fallback value:', + valueInputPlaceholder: 'Value', + defaultValueInputPlaceholder: 'Default value', + secretNotRetrieved: 'Secret value - not retrieved for security', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Overriding documented default: ${expectedValue}`, + useMachineEnvToggle: 'Use value from machine environment', + resolvedOnSessionStart: 'Resolved when the session starts on the selected machine.', + sourceVariableLabel: 'Source variable', + sourceVariablePlaceholder: 'Source variable name (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Checking ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Empty on ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Empty on ${machine} (using fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Not found on ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Not found on ${machine} (using fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Value found on ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Differs from documented value: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - hidden for security`, + hiddenValue: '***hidden***', + emptyValue: '(empty)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Session will receive: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Env Vars · ${profileName}`, + descriptionPrefix: 'These environment variables are sent when starting the session. Values are resolved using the daemon on', + descriptionFallbackMachine: 'the selected machine', + descriptionSuffix: '.', + emptyMessage: 'No environment variables are set for this profile.', + checkingSuffix: '(checking…)', + detail: { + fixed: 'Fixed', + machine: 'Machine', + checking: 'Checking', + fallback: 'Fallback', + missing: 'Missing', + }, + }, + }, delete: { title: 'Delete Profile', message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, @@ -928,6 +1103,8 @@ export const en: TranslationStructure = { cancel: 'Cancel', }, } -} as const; +}; + +export type TranslationStructure = typeof en; -export type TranslationsEn = typeof en; \ No newline at end of file +export type TranslationsEn = typeof en; diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index a79953775..9a2af7b1a 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Spanish plural helper function @@ -31,6 +31,8 @@ export const es: TranslationStructure = { common: { // Simple string constants + add: 'Añadir', + actions: 'Acciones', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Guardar', @@ -47,6 +49,9 @@ export const es: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descartar', + discardChanges: 'Descartar cambios', + unsavedChangesWarning: 'Tienes cambios sin guardar.', + keepEditing: 'Seguir editando', version: 'Versión', copied: 'Copiado', copy: 'Copiar', @@ -60,6 +65,10 @@ export const es: TranslationStructure = { retry: 'Reintentar', delete: 'Eliminar', optional: 'opcional', + noMatches: 'Sin coincidencias', + all: 'All', + machine: 'máquina', + clearSearch: 'Clear search', }, profile: { @@ -208,6 +217,15 @@ export const es: TranslationStructure = { enhancedSessionWizard: 'Asistente de sesión mejorado', enhancedSessionWizardEnabled: 'Lanzador de sesión con perfil activo', enhancedSessionWizardDisabled: 'Usando el lanzador de sesión estándar', + profiles: 'Perfiles de IA', + profilesEnabled: 'Selección de perfiles habilitada', + profilesDisabled: 'Selección de perfiles deshabilitada', + pickerSearch: 'Búsqueda en selectores', + pickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquina y ruta', + machinePickerSearch: 'Búsqueda de máquinas', + machinePickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquinas', + pathPickerSearch: 'Búsqueda de rutas', + pathPickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de rutas', }, errors: { @@ -260,6 +278,9 @@ export const es: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nueva sesión', + selectMachineTitle: 'Seleccionar máquina', + selectPathTitle: 'Seleccionar ruta', + searchPathsPlaceholder: 'Buscar rutas...', noMachinesFound: 'No se encontraron máquinas. Inicia una sesión de Happy en tu computadora primero.', allMachinesOffline: 'Todas las máquinas están desconectadas', machineDetails: 'Ver detalles de la máquina →', @@ -275,6 +296,26 @@ export const es: TranslationStructure = { startNewSessionInFolder: 'Nueva sesión aquí', noMachineSelected: 'Por favor, selecciona una máquina para iniciar la sesión', noPathSelected: 'Por favor, selecciona un directorio para iniciar la sesión', + machinePicker: { + searchPlaceholder: 'Buscar máquinas...', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'No hay máquinas disponibles', + }, + pathPicker: { + enterPathTitle: 'Ingresar ruta', + enterPathPlaceholder: 'Ingresa una ruta...', + customPathTitle: 'Ruta personalizada', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridas', + allTitle: 'Todas', + emptyRecent: 'No hay rutas recientes', + emptyFavorites: 'No hay rutas favoritas', + emptySuggested: 'No hay rutas sugeridas', + emptyAll: 'No hay rutas', + }, sessionType: { title: 'Tipo de sesión', simple: 'Simple', @@ -336,6 +377,7 @@ export const es: TranslationStructure = { happySessionId: 'ID de sesión de Happy', claudeCodeSessionId: 'ID de sesión de Claude Code', claudeCodeSessionIdCopied: 'ID de sesión de Claude Code copiado al portapapeles', + aiProfile: 'Perfil de IA', aiProvider: 'Proveedor de IA', failedToCopyClaudeCodeSessionId: 'Falló al copiar ID de sesión de Claude Code', metadataCopied: 'Metadatos copiados al portapapeles', @@ -390,6 +432,10 @@ export const es: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Variables de entorno', + titleWithCount: ({ count }: { count: number }) => `Variables de entorno (${count})`, + }, permissionMode: { title: 'MODO DE PERMISOS', default: 'Por defecto', @@ -430,14 +476,29 @@ export const es: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODO DE PERMISOS', + title: 'MODO DE PERMISOS GEMINI', default: 'Por defecto', - acceptEdits: 'Aceptar ediciones', - plan: 'Modo de planificación', - bypassPermissions: 'Modo Yolo', - badgeAcceptAllEdits: 'Aceptar todas las ediciones', - badgeBypassAllPermissions: 'Omitir todos los permisos', - badgePlanMode: 'Modo de planificación', + readOnly: 'Solo lectura', + safeYolo: 'YOLO seguro', + yolo: 'YOLO', + badgeReadOnly: 'Solo lectura', + badgeSafeYolo: 'YOLO seguro', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Más capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido y eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Más rápido', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -504,6 +565,10 @@ export const es: TranslationStructure = { applyChanges: 'Actualizar archivo', viewDiff: 'Cambios del archivo actual', question: 'Pregunta', + changeTitle: 'Cambiar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -903,9 +968,121 @@ export const es: TranslationStructure = { enterTmuxTempDir: 'Ingrese la ruta del directorio temporal', tmuxUpdateEnvironment: 'Actualizar entorno automáticamente', nameRequired: 'El nombre del perfil es requerido', - deleteConfirm: '¿Estás seguro de que quieres eliminar el perfil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar el perfil "${name}"?`, editProfile: 'Editar Perfil', addProfileTitle: 'Agregar Nuevo Perfil', + builtIn: 'Integrado', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Favoritos', + custom: 'Tus perfiles', + builtIn: 'Perfiles integrados', + }, + actions: { + viewEnvironmentVariables: 'Variables de entorno', + addToFavorites: 'Agregar a favoritos', + removeFromFavorites: 'Quitar de favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Eliminar perfil', + }, + copySuffix: '(Copy)', + duplicateName: 'Ya existe un perfil con este nombre', + setupInstructions: { + title: 'Instrucciones de configuración', + viewOfficialGuide: 'Ver la guía oficial de configuración', + }, + defaultSessionType: 'Tipo de sesión predeterminado', + defaultPermissionMode: { + title: 'Modo de permisos predeterminado', + descriptions: { + default: 'Pedir permisos', + acceptEdits: 'Aprobar ediciones automáticamente', + plan: 'Planificar antes de ejecutar', + bypassPermissions: 'Omitir todos los permisos', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecciona al menos un backend de IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sesiones en Tmux', + spawnSessionsEnabledSubtitle: 'Las sesiones se abren en nuevas ventanas de tmux.', + spawnSessionsDisabledSubtitle: 'Las sesiones se abren en una shell normal (sin integración con tmux)', + sessionNamePlaceholder: 'Vacío = sesión actual/más reciente', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Vista previa de la máquina', + selectMachine: 'Seleccionar máquina', + resolveSubtitle: 'Resolver variables de entorno de la máquina para este perfil.', + selectSubtitle: 'Selecciona una máquina para previsualizar los valores resueltos.', + }, + environmentVariables: { + title: 'Variables de entorno', + addVariable: 'Añadir variable', + namePlaceholder: 'Nombre de variable (p. ej., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ej., mi-valor o ${MY_VAR})', + validation: { + nameRequired: 'Introduce un nombre de variable.', + invalidNameFormat: 'Los nombres de variables deben ser letras mayúsculas, números y guiones bajos, y no pueden empezar por un número.', + duplicateName: 'Esa variable ya existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de respaldo:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor predeterminado', + secretNotRetrieved: 'Valor secreto: no se recupera por seguridad', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sobrescribiendo el valor documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor del entorno de la máquina', + resolvedOnSessionStart: 'Se resuelve al iniciar la sesión en la máquina seleccionada.', + sourceVariableLabel: 'Variable de origen', + sourceVariablePlaceholder: 'Nombre de variable de origen (p. ej., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vacío en ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vacío en ${machine} (usando respaldo)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No encontrado en ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No encontrado en ${machine} (usando respaldo)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado en ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiere del valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por seguridad`, + hiddenValue: '***oculto***', + emptyValue: '(vacío)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sesión recibirá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de entorno · ${profileName}`, + descriptionPrefix: 'Estas variables de entorno se envían al iniciar la sesión. Los valores se resuelven usando el daemon en', + descriptionFallbackMachine: 'la máquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hay variables de entorno configuradas para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fijo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Respaldo', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar "${name}"? Esta acción no se puede deshacer.`, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index bfa52467a..6ec7548dc 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Italian plural helper function @@ -31,6 +31,8 @@ export const it: TranslationStructure = { common: { // Simple string constants + add: 'Aggiungi', + actions: 'Azioni', cancel: 'Annulla', authenticate: 'Autentica', save: 'Salva', @@ -46,6 +48,9 @@ export const it: TranslationStructure = { yes: 'Sì', no: 'No', discard: 'Scarta', + discardChanges: 'Scarta modifiche', + unsavedChangesWarning: 'Hai modifiche non salvate.', + keepEditing: 'Continua a modificare', version: 'Versione', copied: 'Copiato', copy: 'Copia', @@ -59,6 +64,10 @@ export const it: TranslationStructure = { retry: 'Riprova', delete: 'Elimina', optional: 'opzionale', + noMatches: 'Nessuna corrispondenza', + all: 'All', + machine: 'macchina', + clearSearch: 'Clear search', saveAs: 'Salva con nome', }, @@ -90,9 +99,121 @@ export const it: TranslationStructure = { enterTmuxTempDir: 'Inserisci percorso directory temporanea', tmuxUpdateEnvironment: 'Aggiorna ambiente automaticamente', nameRequired: 'Il nome del profilo è obbligatorio', - deleteConfirm: 'Sei sicuro di voler eliminare il profilo "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Sei sicuro di voler eliminare il profilo "${name}"?`, editProfile: 'Modifica profilo', addProfileTitle: 'Aggiungi nuovo profilo', + builtIn: 'Integrato', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Preferiti', + custom: 'I tuoi profili', + builtIn: 'Profili integrati', + }, + actions: { + viewEnvironmentVariables: 'Variabili ambiente', + addToFavorites: 'Aggiungi ai preferiti', + removeFromFavorites: 'Rimuovi dai preferiti', + editProfile: 'Modifica profilo', + duplicateProfile: 'Duplica profilo', + deleteProfile: 'Elimina profilo', + }, + copySuffix: '(Copy)', + duplicateName: 'Esiste già un profilo con questo nome', + setupInstructions: { + title: 'Istruzioni di configurazione', + viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione', + }, + defaultSessionType: 'Tipo di sessione predefinito', + defaultPermissionMode: { + title: 'Modalità di permesso predefinita', + descriptions: { + default: 'Chiedi permessi', + acceptEdits: 'Approva automaticamente le modifiche', + plan: 'Pianifica prima di eseguire', + bypassPermissions: 'Salta tutti i permessi', + }, + }, + aiBackend: { + title: 'Backend IA', + selectAtLeastOneError: 'Seleziona almeno un backend IA.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (sperimentale)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Avvia sessioni in Tmux', + spawnSessionsEnabledSubtitle: 'Le sessioni vengono avviate in nuove finestre di tmux.', + spawnSessionsDisabledSubtitle: 'Le sessioni vengono avviate in una shell normale (senza integrazione tmux)', + sessionNamePlaceholder: 'Vuoto = sessione corrente/più recente', + tempDirPlaceholder: '/tmp (opzionale)', + }, + previewMachine: { + title: 'Anteprima macchina', + selectMachine: 'Seleziona macchina', + resolveSubtitle: 'Risolvi le variabili ambiente della macchina per questo profilo.', + selectSubtitle: 'Seleziona una macchina per visualizzare l\'anteprima dei valori risolti.', + }, + environmentVariables: { + title: 'Variabili ambiente', + addVariable: 'Aggiungi variabile', + namePlaceholder: 'Nome variabile (es., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valore (es., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Inserisci un nome variabile.', + invalidNameFormat: 'I nomi delle variabili devono usare lettere maiuscole, numeri e underscore e non possono iniziare con un numero.', + duplicateName: 'Questa variabile esiste già.', + }, + card: { + valueLabel: 'Valore:', + fallbackValueLabel: 'Valore di fallback:', + valueInputPlaceholder: 'Valore', + defaultValueInputPlaceholder: 'Valore predefinito', + secretNotRetrieved: 'Valore segreto - non recuperato per sicurezza', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sostituzione del valore predefinito documentato: ${expectedValue}`, + useMachineEnvToggle: 'Usa valore dall\'ambiente della macchina', + resolvedOnSessionStart: 'Risolto quando la sessione viene avviata sulla macchina selezionata.', + sourceVariableLabel: 'Variabile sorgente', + sourceVariablePlaceholder: 'Nome variabile sorgente (es., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verifica ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vuoto su ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vuoto su ${machine} (uso fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Non trovato su ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Non trovato su ${machine} (uso fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valore trovato su ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diverso dal valore documentato: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - nascosto per sicurezza`, + hiddenValue: '***nascosto***', + emptyValue: '(vuoto)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessione riceverà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variabili ambiente · ${profileName}`, + descriptionPrefix: 'Queste variabili ambiente vengono inviate all\'avvio della sessione. I valori vengono risolti dal daemon su', + descriptionFallbackMachine: 'la macchina selezionata', + descriptionSuffix: '.', + emptyMessage: 'Nessuna variabile ambiente è impostata per questo profilo.', + checkingSuffix: '(verifica…)', + detail: { + fixed: 'Fisso', + machine: 'Macchina', + checking: 'Verifica', + fallback: 'Fallback', + missing: 'Mancante', + }, + }, + }, delete: { title: 'Elimina profilo', message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`, @@ -237,6 +358,15 @@ export const it: TranslationStructure = { enhancedSessionWizard: 'Wizard sessione avanzato', enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo', enhancedSessionWizardDisabled: 'Usando avvio sessioni standard', + profiles: 'Profili IA', + profilesEnabled: 'Selezione profili abilitata', + profilesDisabled: 'Selezione profili disabilitata', + pickerSearch: 'Ricerca nei selettori', + pickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchina e percorso', + machinePickerSearch: 'Ricerca macchine', + machinePickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchine', + pathPickerSearch: 'Ricerca percorsi', + pathPickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di percorsi', }, errors: { @@ -289,6 +419,9 @@ export const it: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Avvia nuova sessione', + selectMachineTitle: 'Seleziona macchina', + selectPathTitle: 'Seleziona percorso', + searchPathsPlaceholder: 'Cerca percorsi...', noMachinesFound: 'Nessuna macchina trovata. Avvia prima una sessione Happy sul tuo computer.', allMachinesOffline: 'Tutte le macchine sembrano offline', machineDetails: 'Visualizza dettagli macchina →', @@ -304,6 +437,26 @@ export const it: TranslationStructure = { notConnectedToServer: 'Non connesso al server. Controlla la tua connessione Internet.', noMachineSelected: 'Seleziona una macchina per avviare la sessione', noPathSelected: 'Seleziona una directory in cui avviare la sessione', + machinePicker: { + searchPlaceholder: 'Cerca macchine...', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + allTitle: 'Tutte', + emptyMessage: 'Nessuna macchina disponibile', + }, + pathPicker: { + enterPathTitle: 'Inserisci percorso', + enterPathPlaceholder: 'Inserisci un percorso...', + customPathTitle: 'Percorso personalizzato', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + suggestedTitle: 'Suggeriti', + allTitle: 'Tutte', + emptyRecent: 'Nessun percorso recente', + emptyFavorites: 'Nessun percorso preferito', + emptySuggested: 'Nessun percorso suggerito', + emptyAll: 'Nessun percorso', + }, sessionType: { title: 'Tipo di sessione', simple: 'Semplice', @@ -365,6 +518,7 @@ export const it: TranslationStructure = { happySessionId: 'ID sessione Happy', claudeCodeSessionId: 'ID sessione Claude Code', claudeCodeSessionIdCopied: 'ID sessione Claude Code copiato negli appunti', + aiProfile: 'Profilo IA', aiProvider: 'Provider IA', failedToCopyClaudeCodeSessionId: 'Impossibile copiare l\'ID sessione Claude Code', metadataCopied: 'Metadati copiati negli appunti', @@ -419,6 +573,10 @@ export const it: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Var env', + titleWithCount: ({ count }: { count: number }) => `Var env (${count})`, + }, permissionMode: { title: 'MODALITÀ PERMESSI', default: 'Predefinito', @@ -461,12 +619,27 @@ export const it: TranslationStructure = { geminiPermissionMode: { title: 'MODALITÀ PERMESSI GEMINI', default: 'Predefinito', - acceptEdits: 'Accetta modifiche', - plan: 'Modalità piano', - bypassPermissions: 'Modalità YOLO', - badgeAcceptAllEdits: 'Accetta tutte le modifiche', - badgeBypassAllPermissions: 'Bypassa tutti i permessi', - badgePlanMode: 'Modalità piano', + readOnly: 'Modalità sola lettura', + safeYolo: 'YOLO sicuro', + yolo: 'YOLO', + badgeReadOnly: 'Modalità sola lettura', + badgeSafeYolo: 'YOLO sicuro', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODELLO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Il più potente', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Veloce ed efficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Il più veloce', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -537,6 +710,10 @@ export const it: TranslationStructure = { applyChanges: 'Aggiorna file', viewDiff: 'Modifiche file attuali', question: 'Domanda', + changeTitle: 'Cambia titolo', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminale(cmd: ${cmd})`, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index fe1007884..6dad8ea86 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -5,17 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; - -/** - * Japanese plural helper function - * Japanese doesn't have grammatical plurals, so this just returns the appropriate form - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} +import type { TranslationStructure } from '../_types'; export const ja: TranslationStructure = { tabs: { @@ -34,6 +24,8 @@ export const ja: TranslationStructure = { common: { // Simple string constants + add: '追加', + actions: '操作', cancel: 'キャンセル', authenticate: '認証', save: '保存', @@ -49,6 +41,9 @@ export const ja: TranslationStructure = { yes: 'はい', no: 'いいえ', discard: '破棄', + discardChanges: '変更を破棄', + unsavedChangesWarning: '未保存の変更があります。', + keepEditing: '編集を続ける', version: 'バージョン', copied: 'コピーしました', copy: 'コピー', @@ -62,6 +57,10 @@ export const ja: TranslationStructure = { retry: '再試行', delete: '削除', optional: '任意', + noMatches: '一致するものがありません', + all: 'All', + machine: 'マシン', + clearSearch: 'Clear search', saveAs: '名前を付けて保存', }, @@ -93,9 +92,121 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', + builtIn: '組み込み', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'お気に入り', + custom: 'あなたのプロファイル', + builtIn: '組み込みプロファイル', + }, + actions: { + viewEnvironmentVariables: '環境変数', + addToFavorites: 'お気に入りに追加', + removeFromFavorites: 'お気に入りから削除', + editProfile: 'プロファイルを編集', + duplicateProfile: 'プロファイルを複製', + deleteProfile: 'プロファイルを削除', + }, + copySuffix: '(Copy)', + duplicateName: '同じ名前のプロファイルが既に存在します', + setupInstructions: { + title: 'セットアップ手順', + viewOfficialGuide: '公式セットアップガイドを表示', + }, + defaultSessionType: 'デフォルトのセッションタイプ', + defaultPermissionMode: { + title: 'デフォルトの権限モード', + descriptions: { + default: '権限を要求する', + acceptEdits: '編集を自動承認', + plan: '実行前に計画', + bypassPermissions: 'すべての権限をスキップ', + }, + }, + aiBackend: { + title: 'AIバックエンド', + selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI(実験)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Tmuxでセッションを起動', + spawnSessionsEnabledSubtitle: 'セッションは新しいtmuxウィンドウで起動します。', + spawnSessionsDisabledSubtitle: 'セッションは通常のシェルで起動します(tmux連携なし)', + sessionNamePlaceholder: '空 = 現在/最近のセッション', + tempDirPlaceholder: '/tmp(任意)', + }, + previewMachine: { + title: 'マシンをプレビュー', + selectMachine: 'マシンを選択', + resolveSubtitle: 'このプロファイルのマシン環境変数を解決します。', + selectSubtitle: '解決後の値をプレビューするマシンを選択してください。', + }, + environmentVariables: { + title: '環境変数', + addVariable: '変数を追加', + namePlaceholder: '変数名(例: MY_CUSTOM_VAR)', + valuePlaceholder: '値(例: my-value または ${MY_VAR})', + validation: { + nameRequired: '変数名を入力してください。', + invalidNameFormat: '変数名は大文字、数字、アンダースコアのみで、数字から始めることはできません。', + duplicateName: 'その変数は既に存在します。', + }, + card: { + valueLabel: '値:', + fallbackValueLabel: 'フォールバック値:', + valueInputPlaceholder: '値', + defaultValueInputPlaceholder: 'デフォルト値', + secretNotRetrieved: 'シークレット値 — セキュリティのため取得しません', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `ドキュメントのデフォルト値を上書き: ${expectedValue}`, + useMachineEnvToggle: 'マシン環境から値を使用', + resolvedOnSessionStart: '選択したマシンでセッション開始時に解決されます。', + sourceVariableLabel: '参照元変数', + sourceVariablePlaceholder: '参照元変数名(例: Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `${machine} を確認中...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} では空です`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} では空です(フォールバック使用)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で見つかりません`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} で見つかりません(フォールバック使用)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で値を確認`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `ドキュメント値と異なります: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - セキュリティのため非表示`, + hiddenValue: '***非表示***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `セッションに渡される値: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `環境変数 · ${profileName}`, + descriptionPrefix: 'これらの環境変数はセッション開始時に送信されます。値はデーモンが', + descriptionFallbackMachine: '選択したマシン', + descriptionSuffix: 'で解決します。', + emptyMessage: 'このプロファイルには環境変数が設定されていません。', + checkingSuffix: '(確認中…)', + detail: { + fixed: '固定', + machine: 'マシン', + checking: '確認中', + fallback: 'フォールバック', + missing: '未設定', + }, + }, + }, delete: { title: 'プロファイルを削除', message: ({ name }: { name: string }) => `「${name}」を削除してもよろしいですか?この操作は元に戻せません。`, @@ -240,6 +351,15 @@ export const ja: TranslationStructure = { enhancedSessionWizard: '拡張セッションウィザード', enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', enhancedSessionWizardDisabled: '標準セッションランチャーを使用', + profiles: 'AIプロファイル', + profilesEnabled: 'プロファイル選択を有効化', + profilesDisabled: 'プロファイル選択を無効化', + pickerSearch: 'ピッカー検索', + pickerSearchSubtitle: 'マシンとパスのピッカーに検索欄を表示', + machinePickerSearch: 'マシン検索', + machinePickerSearchSubtitle: 'マシンピッカーに検索欄を表示', + pathPickerSearch: 'パス検索', + pathPickerSearchSubtitle: 'パスピッカーに検索欄を表示', }, errors: { @@ -292,6 +412,9 @@ export const ja: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '新しいセッションを開始', + selectMachineTitle: 'マシンを選択', + selectPathTitle: 'パスを選択', + searchPathsPlaceholder: 'パスを検索...', noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。', allMachinesOffline: 'すべてのマシンがオフラインです', machineDetails: 'マシンの詳細を表示 →', @@ -307,6 +430,26 @@ export const ja: TranslationStructure = { notConnectedToServer: 'サーバーに接続されていません。インターネット接続を確認してください。', noMachineSelected: 'セッションを開始するマシンを選択してください', noPathSelected: 'セッションを開始するディレクトリを選択してください', + machinePicker: { + searchPlaceholder: 'マシンを検索...', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + allTitle: 'すべて', + emptyMessage: '利用可能なマシンがありません', + }, + pathPicker: { + enterPathTitle: 'パスを入力', + enterPathPlaceholder: 'パスを入力...', + customPathTitle: 'カスタムパス', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + suggestedTitle: 'おすすめ', + allTitle: 'すべて', + emptyRecent: '最近のパスはありません', + emptyFavorites: 'お気に入りのパスはありません', + emptySuggested: 'おすすめのパスはありません', + emptyAll: 'パスがありません', + }, sessionType: { title: 'セッションタイプ', simple: 'シンプル', @@ -368,6 +511,7 @@ export const ja: TranslationStructure = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました', + aiProfile: 'AIプロファイル', aiProvider: 'AIプロバイダー', failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました', metadataCopied: 'メタデータがクリップボードにコピーされました', @@ -422,6 +566,10 @@ export const ja: TranslationStructure = { }, agentInput: { + envVars: { + title: '環境変数', + titleWithCount: ({ count }: { count: number }) => `環境変数 (${count})`, + }, permissionMode: { title: '権限モード', default: 'デフォルト', @@ -464,12 +612,27 @@ export const ja: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI権限モード', default: 'デフォルト', - acceptEdits: '編集を許可', - plan: 'プランモード', - bypassPermissions: 'Yoloモード', - badgeAcceptAllEdits: 'すべての編集を許可', - badgeBypassAllPermissions: 'すべての権限をバイパス', - badgePlanMode: 'プランモード', + readOnly: '読み取り専用モード', + safeYolo: 'セーフYOLO', + yolo: 'YOLO', + badgeReadOnly: '読み取り専用モード', + badgeSafeYolo: 'セーフYOLO', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'GEMINIモデル', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最高性能', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '高速・効率的', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最速', + }, }, context: { remaining: ({ percent }: { percent: number }) => `残り ${percent}%`, @@ -540,6 +703,10 @@ export const ja: TranslationStructure = { applyChanges: 'ファイルを更新', viewDiff: '現在のファイル変更', question: '質問', + changeTitle: 'タイトルを変更', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 1c8e2f087..a40e122cc 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Polish plural helper function @@ -42,6 +42,8 @@ export const pl: TranslationStructure = { common: { // Simple string constants + add: 'Dodaj', + actions: 'Akcje', cancel: 'Anuluj', authenticate: 'Uwierzytelnij', save: 'Zapisz', @@ -58,6 +60,9 @@ export const pl: TranslationStructure = { yes: 'Tak', no: 'Nie', discard: 'Odrzuć', + discardChanges: 'Odrzuć zmiany', + unsavedChangesWarning: 'Masz niezapisane zmiany.', + keepEditing: 'Kontynuuj edycję', version: 'Wersja', copied: 'Skopiowano', copy: 'Kopiuj', @@ -71,6 +76,10 @@ export const pl: TranslationStructure = { retry: 'Ponów', delete: 'Usuń', optional: 'opcjonalnie', + noMatches: 'Brak dopasowań', + all: 'All', + machine: 'maszyna', + clearSearch: 'Clear search', }, profile: { @@ -219,6 +228,15 @@ export const pl: TranslationStructure = { enhancedSessionWizard: 'Ulepszony kreator sesji', enhancedSessionWizardEnabled: 'Aktywny launcher z profilem', enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji', + profiles: 'Profile AI', + profilesEnabled: 'Wybór profili włączony', + profilesDisabled: 'Wybór profili wyłączony', + pickerSearch: 'Wyszukiwanie w selektorach', + pickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn i ścieżek', + machinePickerSearch: 'Wyszukiwanie maszyn', + machinePickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn', + pathPickerSearch: 'Wyszukiwanie ścieżek', + pathPickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach ścieżek', }, errors: { @@ -271,6 +289,9 @@ export const pl: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Rozpocznij nową sesję', + selectMachineTitle: 'Wybierz maszynę', + selectPathTitle: 'Wybierz ścieżkę', + searchPathsPlaceholder: 'Szukaj ścieżek...', noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.', allMachinesOffline: 'Wszystkie maszyny są offline', machineDetails: 'Zobacz szczegóły maszyny →', @@ -286,6 +307,26 @@ export const pl: TranslationStructure = { startNewSessionInFolder: 'Nowa sesja tutaj', noMachineSelected: 'Proszę wybrać maszynę do rozpoczęcia sesji', noPathSelected: 'Proszę wybrać katalog do rozpoczęcia sesji', + machinePicker: { + searchPlaceholder: 'Szukaj maszyn...', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + allTitle: 'Wszystkie', + emptyMessage: 'Brak dostępnych maszyn', + }, + pathPicker: { + enterPathTitle: 'Wpisz ścieżkę', + enterPathPlaceholder: 'Wpisz ścieżkę...', + customPathTitle: 'Niestandardowa ścieżka', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + suggestedTitle: 'Sugerowane', + allTitle: 'Wszystkie', + emptyRecent: 'Brak ostatnich ścieżek', + emptyFavorites: 'Brak ulubionych ścieżek', + emptySuggested: 'Brak sugerowanych ścieżek', + emptyAll: 'Brak ścieżek', + }, sessionType: { title: 'Typ sesji', simple: 'Prosta', @@ -347,6 +388,7 @@ export const pl: TranslationStructure = { happySessionId: 'ID sesji Happy', claudeCodeSessionId: 'ID sesji Claude Code', claudeCodeSessionIdCopied: 'ID sesji Claude Code skopiowane do schowka', + aiProfile: 'Profil AI', aiProvider: 'Dostawca AI', failedToCopyClaudeCodeSessionId: 'Nie udało się skopiować ID sesji Claude Code', metadataCopied: 'Metadane skopiowane do schowka', @@ -400,6 +442,10 @@ export const pl: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Zmienne środowiskowe', + titleWithCount: ({ count }: { count: number }) => `Zmienne środowiskowe (${count})`, + }, permissionMode: { title: 'TRYB UPRAWNIEŃ', default: 'Domyślny', @@ -440,14 +486,29 @@ export const pl: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'TRYB UPRAWNIEŃ', + title: 'TRYB UPRAWNIEŃ GEMINI', default: 'Domyślny', - acceptEdits: 'Akceptuj edycje', - plan: 'Tryb planowania', - bypassPermissions: 'Tryb YOLO', - badgeAcceptAllEdits: 'Akceptuj wszystkie edycje', - badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia', - badgePlanMode: 'Tryb planowania', + readOnly: 'Tylko do odczytu', + safeYolo: 'Bezpieczne YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Tylko do odczytu', + badgeSafeYolo: 'Bezpieczne YOLO', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Najbardziej zaawansowany', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Szybki i wydajny', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Najszybszy', + }, }, context: { remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`, @@ -514,6 +575,10 @@ export const pl: TranslationStructure = { applyChanges: 'Zaktualizuj plik', viewDiff: 'Bieżące zmiany pliku', question: 'Pytanie', + changeTitle: 'Zmień tytuł', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -926,9 +991,121 @@ export const pl: TranslationStructure = { enterTmuxTempDir: 'Wprowadź ścieżkę do katalogu tymczasowego', tmuxUpdateEnvironment: 'Aktualizuj środowisko automatycznie', nameRequired: 'Nazwa profilu jest wymagana', - deleteConfirm: 'Czy na pewno chcesz usunąć profil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć profil "${name}"?`, editProfile: 'Edytuj Profil', addProfileTitle: 'Dodaj Nowy Profil', + builtIn: 'Wbudowane', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Ulubione', + custom: 'Twoje profile', + builtIn: 'Profile wbudowane', + }, + actions: { + viewEnvironmentVariables: 'Zmienne środowiskowe', + addToFavorites: 'Dodaj do ulubionych', + removeFromFavorites: 'Usuń z ulubionych', + editProfile: 'Edytuj profil', + duplicateProfile: 'Duplikuj profil', + deleteProfile: 'Usuń profil', + }, + copySuffix: '(Copy)', + duplicateName: 'Profil o tej nazwie już istnieje', + setupInstructions: { + title: 'Instrukcje konfiguracji', + viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji', + }, + defaultSessionType: 'Domyślny typ sesji', + defaultPermissionMode: { + title: 'Domyślny tryb uprawnień', + descriptions: { + default: 'Pytaj o uprawnienia', + acceptEdits: 'Automatycznie zatwierdzaj edycje', + plan: 'Zaplanuj przed wykonaniem', + bypassPermissions: 'Pomiń wszystkie uprawnienia', + }, + }, + aiBackend: { + title: 'Backend AI', + selectAtLeastOneError: 'Wybierz co najmniej jeden backend AI.', + claudeSubtitle: 'CLI Claude', + codexSubtitle: 'CLI Codex', + geminiSubtitleExperimental: 'CLI Gemini (eksperymentalne)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Uruchamiaj sesje w Tmux', + spawnSessionsEnabledSubtitle: 'Sesje uruchamiają się w nowych oknach tmux.', + spawnSessionsDisabledSubtitle: 'Sesje uruchamiają się w zwykłej powłoce (bez integracji z tmux)', + sessionNamePlaceholder: 'Puste = bieżąca/najnowsza sesja', + tempDirPlaceholder: '/tmp (opcjonalne)', + }, + previewMachine: { + title: 'Podgląd maszyny', + selectMachine: 'Wybierz maszynę', + resolveSubtitle: 'Rozwiąż zmienne środowiskowe maszyny dla tego profilu.', + selectSubtitle: 'Wybierz maszynę, aby podejrzeć rozwiązane wartości.', + }, + environmentVariables: { + title: 'Zmienne środowiskowe', + addVariable: 'Dodaj zmienną', + namePlaceholder: 'Nazwa zmiennej (np. MY_CUSTOM_VAR)', + valuePlaceholder: 'Wartość (np. my-value lub ${MY_VAR})', + validation: { + nameRequired: 'Wprowadź nazwę zmiennej.', + invalidNameFormat: 'Nazwy zmiennych muszą zawierać wielkie litery, cyfry i podkreślenia oraz nie mogą zaczynać się od cyfry.', + duplicateName: 'Taka zmienna już istnieje.', + }, + card: { + valueLabel: 'Wartość:', + fallbackValueLabel: 'Wartość fallback:', + valueInputPlaceholder: 'Wartość', + defaultValueInputPlaceholder: 'Wartość domyślna', + secretNotRetrieved: 'Wartość sekretna - nie jest pobierana ze względów bezpieczeństwa', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Nadpisywanie udokumentowanej wartości domyślnej: ${expectedValue}`, + useMachineEnvToggle: 'Użyj wartości ze środowiska maszyny', + resolvedOnSessionStart: 'Rozwiązywane podczas uruchamiania sesji na wybranej maszynie.', + sourceVariableLabel: 'Zmienna źródłowa', + sourceVariablePlaceholder: 'Nazwa zmiennej źródłowej (np. Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Sprawdzanie ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Pusto na ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Pusto na ${machine} (używam fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine} (używam fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Znaleziono wartość na ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Różni się od udokumentowanej wartości: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ukryte ze względów bezpieczeństwa`, + hiddenValue: '***ukryte***', + emptyValue: '(puste)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Sesja otrzyma: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Zmienne środowiskowe · ${profileName}`, + descriptionPrefix: 'Te zmienne środowiskowe są wysyłane podczas uruchamiania sesji. Wartości są rozwiązywane przez daemon na', + descriptionFallbackMachine: 'wybranej maszynie', + descriptionSuffix: '.', + emptyMessage: 'Dla tego profilu nie ustawiono zmiennych środowiskowych.', + checkingSuffix: '(sprawdzanie…)', + detail: { + fixed: 'Stała', + machine: 'Maszyna', + checking: 'Sprawdzanie', + fallback: 'Fallback', + missing: 'Brak', + }, + }, + }, delete: { title: 'Usuń Profil', message: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć "${name}"? Tej czynności nie można cofnąć.`, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 859a7ae8b..9e33b159c 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Portuguese plural helper function @@ -31,6 +31,8 @@ export const pt: TranslationStructure = { common: { // Simple string constants + add: 'Adicionar', + actions: 'Ações', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Salvar', @@ -47,6 +49,9 @@ export const pt: TranslationStructure = { yes: 'Sim', no: 'Não', discard: 'Descartar', + discardChanges: 'Descartar alterações', + unsavedChangesWarning: 'Você tem alterações não salvas.', + keepEditing: 'Continuar editando', version: 'Versão', copied: 'Copiado', copy: 'Copiar', @@ -60,6 +65,10 @@ export const pt: TranslationStructure = { retry: 'Tentar novamente', delete: 'Excluir', optional: 'Opcional', + noMatches: 'Nenhuma correspondência', + all: 'All', + machine: 'máquina', + clearSearch: 'Clear search', }, profile: { @@ -208,6 +217,15 @@ export const pt: TranslationStructure = { enhancedSessionWizard: 'Assistente de sessão aprimorado', enhancedSessionWizardEnabled: 'Lançador de sessão com perfil ativo', enhancedSessionWizardDisabled: 'Usando o lançador de sessão padrão', + profiles: 'Perfis de IA', + profilesEnabled: 'Seleção de perfis ativada', + profilesDisabled: 'Seleção de perfis desativada', + pickerSearch: 'Busca nos seletores', + pickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquina e caminho', + machinePickerSearch: 'Busca de máquinas', + machinePickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquinas', + pathPickerSearch: 'Busca de caminhos', + pathPickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de caminhos', }, errors: { @@ -260,6 +278,9 @@ export const pt: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nova sessão', + selectMachineTitle: 'Selecionar máquina', + selectPathTitle: 'Selecionar caminho', + searchPathsPlaceholder: 'Pesquisar caminhos...', noMachinesFound: 'Nenhuma máquina encontrada. Inicie uma sessão Happy no seu computador primeiro.', allMachinesOffline: 'Todas as máquinas estão offline', machineDetails: 'Ver detalhes da máquina →', @@ -275,6 +296,26 @@ export const pt: TranslationStructure = { startNewSessionInFolder: 'Nova sessão aqui', noMachineSelected: 'Por favor, selecione uma máquina para iniciar a sessão', noPathSelected: 'Por favor, selecione um diretório para iniciar a sessão', + machinePicker: { + searchPlaceholder: 'Pesquisar máquinas...', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'Nenhuma máquina disponível', + }, + pathPicker: { + enterPathTitle: 'Inserir caminho', + enterPathPlaceholder: 'Insira um caminho...', + customPathTitle: 'Caminho personalizado', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridos', + allTitle: 'Todas', + emptyRecent: 'Nenhum caminho recente', + emptyFavorites: 'Nenhum caminho favorito', + emptySuggested: 'Nenhum caminho sugerido', + emptyAll: 'Nenhum caminho', + }, sessionType: { title: 'Tipo de sessão', simple: 'Simples', @@ -336,6 +377,7 @@ export const pt: TranslationStructure = { happySessionId: 'ID da sessão Happy', claudeCodeSessionId: 'ID da sessão Claude Code', claudeCodeSessionIdCopied: 'ID da sessão Claude Code copiado para a área de transferência', + aiProfile: 'Perfil de IA', aiProvider: 'Provedor de IA', failedToCopyClaudeCodeSessionId: 'Falha ao copiar ID da sessão Claude Code', metadataCopied: 'Metadados copiados para a área de transferência', @@ -390,6 +432,10 @@ export const pt: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Vars env', + titleWithCount: ({ count }: { count: number }) => `Vars env (${count})`, + }, permissionMode: { title: 'MODO DE PERMISSÃO', default: 'Padrão', @@ -430,14 +476,29 @@ export const pt: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODO DE PERMISSÃO', + title: 'MODO DE PERMISSÃO GEMINI', default: 'Padrão', - acceptEdits: 'Aceitar edições', - plan: 'Modo de planejamento', - bypassPermissions: 'Modo Yolo', - badgeAcceptAllEdits: 'Aceitar todas as edições', - badgeBypassAllPermissions: 'Ignorar todas as permissões', - badgePlanMode: 'Modo de planejamento', + readOnly: 'Somente leitura', + safeYolo: 'YOLO seguro', + yolo: 'YOLO', + badgeReadOnly: 'Somente leitura', + badgeSafeYolo: 'YOLO seguro', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Mais capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido e eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Mais rápido', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -504,6 +565,10 @@ export const pt: TranslationStructure = { applyChanges: 'Atualizar arquivo', viewDiff: 'Alterações do arquivo atual', question: 'Pergunta', + changeTitle: 'Alterar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -894,8 +959,120 @@ export const pt: TranslationStructure = { tmuxTempDir: 'Diretório temporário tmux', enterTmuxTempDir: 'Digite o diretório temporário tmux', tmuxUpdateEnvironment: 'Atualizar ambiente tmux', - deleteConfirm: 'Tem certeza de que deseja excluir este perfil?', + deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`, nameRequired: 'O nome do perfil é obrigatório', + builtIn: 'Integrado', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Favoritos', + custom: 'Seus perfis', + builtIn: 'Perfis integrados', + }, + actions: { + viewEnvironmentVariables: 'Variáveis de ambiente', + addToFavorites: 'Adicionar aos favoritos', + removeFromFavorites: 'Remover dos favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Excluir perfil', + }, + copySuffix: '(Copy)', + duplicateName: 'Já existe um perfil com este nome', + setupInstructions: { + title: 'Instruções de configuração', + viewOfficialGuide: 'Ver guia oficial de configuração', + }, + defaultSessionType: 'Tipo de sessão padrão', + defaultPermissionMode: { + title: 'Modo de permissão padrão', + descriptions: { + default: 'Solicitar permissões', + acceptEdits: 'Aprovar edições automaticamente', + plan: 'Planejar antes de executar', + bypassPermissions: 'Ignorar todas as permissões', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecione pelo menos um backend de IA.', + claudeSubtitle: 'CLI do Claude', + codexSubtitle: 'CLI do Codex', + geminiSubtitleExperimental: 'CLI do Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sessões no Tmux', + spawnSessionsEnabledSubtitle: 'As sessões são iniciadas em novas janelas do tmux.', + spawnSessionsDisabledSubtitle: 'As sessões são iniciadas no shell comum (sem integração com tmux)', + sessionNamePlaceholder: 'Vazio = sessão atual/mais recente', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Pré-visualizar máquina', + selectMachine: 'Selecionar máquina', + resolveSubtitle: 'Resolver variáveis de ambiente da máquina para este perfil.', + selectSubtitle: 'Selecione uma máquina para pré-visualizar os valores resolvidos.', + }, + environmentVariables: { + title: 'Variáveis de ambiente', + addVariable: 'Adicionar variável', + namePlaceholder: 'Nome da variável (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (e.g., my-value ou ${MY_VAR})', + validation: { + nameRequired: 'Digite um nome de variável.', + invalidNameFormat: 'Os nomes das variáveis devem conter letras maiúsculas, números e sublinhados, e não podem começar com um número.', + duplicateName: 'Essa variável já existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de fallback:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor padrão', + secretNotRetrieved: 'Valor secreto - não é recuperado por segurança', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Substituindo o valor padrão documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor do ambiente da máquina', + resolvedOnSessionStart: 'Resolvido quando a sessão começa na máquina selecionada.', + sourceVariableLabel: 'Variável de origem', + sourceVariablePlaceholder: 'Nome da variável de origem (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vazio em ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vazio em ${machine} (usando fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Não encontrado em ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Não encontrado em ${machine} (usando fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado em ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diferente do valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por segurança`, + hiddenValue: '***oculto***', + emptyValue: '(vazio)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `A sessão receberá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de ambiente · ${profileName}`, + descriptionPrefix: 'Estas variáveis de ambiente são enviadas ao iniciar a sessão. Os valores são resolvidos usando o daemon em', + descriptionFallbackMachine: 'a máquina selecionada', + descriptionSuffix: '.', + emptyMessage: 'Nenhuma variável de ambiente está definida para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fixo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Fallback', + missing: 'Ausente', + }, + }, + }, delete: { title: 'Excluir Perfil', message: ({ name }: { name: string }) => `Tem certeza de que deseja excluir "${name}"? Esta ação não pode ser desfeita.`, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index aa533ea82..c05ed4fac 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Russian plural helper function @@ -42,6 +42,8 @@ export const ru: TranslationStructure = { common: { // Simple string constants + add: 'Добавить', + actions: 'Действия', cancel: 'Отмена', authenticate: 'Авторизация', save: 'Сохранить', @@ -58,6 +60,9 @@ export const ru: TranslationStructure = { yes: 'Да', no: 'Нет', discard: 'Отменить', + discardChanges: 'Отменить изменения', + unsavedChangesWarning: 'У вас есть несохранённые изменения.', + keepEditing: 'Продолжить редактирование', version: 'Версия', copied: 'Скопировано', copy: 'Копировать', @@ -71,6 +76,10 @@ export const ru: TranslationStructure = { retry: 'Повторить', delete: 'Удалить', optional: 'необязательно', + noMatches: 'Нет совпадений', + all: 'All', + machine: 'машина', + clearSearch: 'Clear search', }, connect: { @@ -190,6 +199,15 @@ export const ru: TranslationStructure = { enhancedSessionWizard: 'Улучшенный мастер сессий', enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', + profiles: 'Профили ИИ', + profilesEnabled: 'Выбор профилей включён', + profilesDisabled: 'Выбор профилей отключён', + pickerSearch: 'Поиск в выборе', + pickerSearchSubtitle: 'Показывать поле поиска в выборе машины и пути', + machinePickerSearch: 'Поиск машин', + machinePickerSearchSubtitle: 'Показывать поле поиска при выборе машины', + pathPickerSearch: 'Поиск путей', + pathPickerSearchSubtitle: 'Показывать поле поиска при выборе пути', }, errors: { @@ -242,6 +260,9 @@ export const ru: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Начать новую сессию', + selectMachineTitle: 'Выбрать машину', + selectPathTitle: 'Выбрать путь', + searchPathsPlaceholder: 'Поиск путей...', noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.', allMachinesOffline: 'Все машины находятся offline', machineDetails: 'Посмотреть детали машины →', @@ -257,6 +278,26 @@ export const ru: TranslationStructure = { startNewSessionInFolder: 'Новая сессия здесь', noMachineSelected: 'Пожалуйста, выберите машину для запуска сессии', noPathSelected: 'Пожалуйста, выберите директорию для запуска сессии', + machinePicker: { + searchPlaceholder: 'Поиск машин...', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + allTitle: 'Все', + emptyMessage: 'Нет доступных машин', + }, + pathPicker: { + enterPathTitle: 'Введите путь', + enterPathPlaceholder: 'Введите путь...', + customPathTitle: 'Пользовательский путь', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + suggestedTitle: 'Рекомендуемые', + allTitle: 'Все', + emptyRecent: 'Нет недавних путей', + emptyFavorites: 'Нет избранных путей', + emptySuggested: 'Нет рекомендуемых путей', + emptyAll: 'Нет путей', + }, sessionType: { title: 'Тип сессии', simple: 'Простая', @@ -310,6 +351,7 @@ export const ru: TranslationStructure = { happySessionId: 'ID сессии Happy', claudeCodeSessionId: 'ID сессии Claude Code', claudeCodeSessionIdCopied: 'ID сессии Claude Code скопирован в буфер обмена', + aiProfile: 'Профиль ИИ', aiProvider: 'Поставщик ИИ', failedToCopyClaudeCodeSessionId: 'Не удалось скопировать ID сессии Claude Code', metadataCopied: 'Метаданные скопированы в буфер обмена', @@ -400,6 +442,10 @@ export const ru: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Переменные окружения', + titleWithCount: ({ count }: { count: number }) => `Переменные окружения (${count})`, + }, permissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', @@ -449,6 +495,21 @@ export const ru: TranslationStructure = { badgeSafeYolo: 'Безопасный YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Самая мощная', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Быстро и эффективно', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Самая быстрая', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`, }, @@ -514,6 +575,10 @@ export const ru: TranslationStructure = { applyChanges: 'Обновить файл', viewDiff: 'Текущие изменения файла', question: 'Вопрос', + changeTitle: 'Изменить заголовок', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Терминал(команда: ${cmd})`, @@ -925,9 +990,123 @@ export const ru: TranslationStructure = { enterTmuxTempDir: 'Введите путь к временному каталогу', tmuxUpdateEnvironment: 'Обновлять окружение автоматически', nameRequired: 'Имя профиля обязательно', - deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`, editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', + builtIn: 'Встроенный', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Избранное', + custom: 'Ваши профили', + builtIn: 'Встроенные профили', + }, + actions: { + viewEnvironmentVariables: 'Переменные окружения', + addToFavorites: 'Добавить в избранное', + removeFromFavorites: 'Убрать из избранного', + editProfile: 'Редактировать профиль', + duplicateProfile: 'Дублировать профиль', + deleteProfile: 'Удалить профиль', + }, + copySuffix: '(Copy)', + duplicateName: 'Профиль с таким названием уже существует', + setupInstructions: { + title: 'Инструкции по настройке', + viewOfficialGuide: 'Открыть официальное руководство', + }, + defaultSessionType: 'Тип сессии по умолчанию', + defaultPermissionMode: { + title: 'Режим разрешений по умолчанию', + descriptions: { + default: 'Запрашивать разрешения', + acceptEdits: 'Авто-одобрять правки', + plan: 'Планировать перед выполнением', + bypassPermissions: 'Пропускать все разрешения', + }, + }, + aiBackend: { + title: 'Бекенд ИИ', + selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (экспериментально)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Запускать сессии в Tmux', + spawnSessionsEnabledSubtitle: 'Сессии запускаются в новых окнах tmux.', + spawnSessionsDisabledSubtitle: 'Сессии запускаются в обычной оболочке (без интеграции с tmux)', + sessionNamePlaceholder: 'Пусто = текущая/последняя сессия', + tempDirPlaceholder: '/tmp (необязательно)', + }, + previewMachine: { + title: 'Предпросмотр машины', + selectMachine: 'Выбрать машину', + resolveSubtitle: 'Разрешить переменные окружения машины для этого профиля.', + selectSubtitle: 'Выберите машину, чтобы просмотреть вычисленные значения.', + }, + environmentVariables: { + title: 'Переменные окружения', + addVariable: 'Добавить переменную', + namePlaceholder: 'Имя переменной (например, MY_CUSTOM_VAR)', + valuePlaceholder: 'Значение (например, my-value или ${MY_VAR})', + validation: { + nameRequired: 'Введите имя переменной.', + invalidNameFormat: 'Имена переменных должны содержать заглавные буквы, цифры и подчёркивания и не могут начинаться с цифры.', + duplicateName: 'Такая переменная уже существует.', + }, + card: { + valueLabel: 'Значение:', + fallbackValueLabel: 'Значение по умолчанию:', + valueInputPlaceholder: 'Значение', + defaultValueInputPlaceholder: 'Значение по умолчанию', + secretNotRetrieved: 'Секретное значение — не извлекается из соображений безопасности', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Переопределение документированного значения: ${expectedValue}`, + useMachineEnvToggle: 'Использовать значение из окружения машины', + resolvedOnSessionStart: 'Разрешается при запуске сессии на выбранной машине.', + sourceVariableLabel: 'Переменная-источник', + sourceVariablePlaceholder: 'Имя переменной-источника (например, Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Проверка ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Пусто на ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Пусто на ${machine} (используется значение по умолчанию)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Не найдено на ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Не найдено на ${machine} (используется значение по умолчанию)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Значение найдено на ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Отличается от документированного значения: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} — скрыто из соображений безопасности`, + hiddenValue: '***скрыто***', + emptyValue: '(пусто)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Сессия получит: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Переменные окружения · ${profileName}`, + descriptionPrefix: 'Эти переменные окружения отправляются при запуске сессии. Значения разрешаются демоном на', + descriptionFallbackMachine: 'выбранной машине', + descriptionSuffix: '.', + emptyMessage: 'Для этого профиля не заданы переменные окружения.', + checkingSuffix: '(проверка…)', + detail: { + fixed: 'Фиксированное', + machine: 'Машина', + checking: 'Проверка', + fallback: 'По умолчанию', + missing: 'Отсутствует', + }, + }, + }, delete: { title: 'Удалить Профиль', message: ({ name }: { name: string }) => `Вы уверены, что хотите удалить "${name}"? Это действие нельзя отменить.`, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index b77851fde..d78e41b61 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -5,7 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; +import type { TranslationStructure } from '../_types'; /** * Chinese plural helper function @@ -33,6 +33,8 @@ export const zhHans: TranslationStructure = { common: { // Simple string constants + add: '添加', + actions: '操作', cancel: '取消', authenticate: '认证', save: '保存', @@ -49,6 +51,9 @@ export const zhHans: TranslationStructure = { yes: '是', no: '否', discard: '放弃', + discardChanges: '放弃更改', + unsavedChangesWarning: '你有未保存的更改。', + keepEditing: '继续编辑', version: '版本', copied: '已复制', copy: '复制', @@ -62,6 +67,10 @@ export const zhHans: TranslationStructure = { retry: '重试', delete: '删除', optional: '可选的', + noMatches: '无匹配结果', + all: 'All', + machine: '机器', + clearSearch: 'Clear search', }, profile: { @@ -210,6 +219,15 @@ export const zhHans: TranslationStructure = { enhancedSessionWizard: '增强会话向导', enhancedSessionWizardEnabled: '配置文件优先启动器已激活', enhancedSessionWizardDisabled: '使用标准会话启动器', + profiles: 'AI 配置文件', + profilesEnabled: '已启用配置文件选择', + profilesDisabled: '已禁用配置文件选择', + pickerSearch: '选择器搜索', + pickerSearchSubtitle: '在设备和路径选择器中显示搜索框', + machinePickerSearch: '设备搜索', + machinePickerSearchSubtitle: '在设备选择器中显示搜索框', + pathPickerSearch: '路径搜索', + pathPickerSearchSubtitle: '在路径选择器中显示搜索框', }, errors: { @@ -262,6 +280,9 @@ export const zhHans: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '启动新会话', + selectMachineTitle: '选择设备', + selectPathTitle: '选择路径', + searchPathsPlaceholder: '搜索路径...', noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。', allMachinesOffline: '所有设备似乎都已离线', machineDetails: '查看设备详情 →', @@ -277,6 +298,26 @@ export const zhHans: TranslationStructure = { notConnectedToServer: '未连接到服务器。请检查您的网络连接。', noMachineSelected: '请选择一台设备以启动会话', noPathSelected: '请选择一个目录以启动会话', + machinePicker: { + searchPlaceholder: '搜索设备...', + recentTitle: '最近', + favoritesTitle: '收藏', + allTitle: '全部', + emptyMessage: '没有可用设备', + }, + pathPicker: { + enterPathTitle: '输入路径', + enterPathPlaceholder: '输入路径...', + customPathTitle: '自定义路径', + recentTitle: '最近', + favoritesTitle: '收藏', + suggestedTitle: '推荐', + allTitle: '全部', + emptyRecent: '没有最近的路径', + emptyFavorites: '没有收藏的路径', + emptySuggested: '没有推荐的路径', + emptyAll: '没有路径', + }, sessionType: { title: '会话类型', simple: '简单', @@ -338,6 +379,7 @@ export const zhHans: TranslationStructure = { happySessionId: 'Happy 会话 ID', claudeCodeSessionId: 'Claude Code 会话 ID', claudeCodeSessionIdCopied: 'Claude Code 会话 ID 已复制到剪贴板', + aiProfile: 'AI 配置文件', aiProvider: 'AI 提供商', failedToCopyClaudeCodeSessionId: '复制 Claude Code 会话 ID 失败', metadataCopied: '元数据已复制到剪贴板', @@ -392,6 +434,10 @@ export const zhHans: TranslationStructure = { }, agentInput: { + envVars: { + title: '环境变量', + titleWithCount: ({ count }: { count: number }) => `环境变量 (${count})`, + }, permissionMode: { title: '权限模式', default: '默认', @@ -432,14 +478,29 @@ export const zhHans: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: '权限模式', + title: 'GEMINI 权限模式', default: '默认', - acceptEdits: '接受编辑', - plan: '计划模式', - bypassPermissions: 'Yolo 模式', - badgeAcceptAllEdits: '接受所有编辑', - badgeBypassAllPermissions: '绕过所有权限', - badgePlanMode: '计划模式', + readOnly: '只读', + safeYolo: '安全 YOLO', + yolo: 'YOLO', + badgeReadOnly: '只读', + badgeSafeYolo: '安全 YOLO', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'GEMINI 模型', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最强能力', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '快速且高效', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最快', + }, }, context: { remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`, @@ -506,6 +567,10 @@ export const zhHans: TranslationStructure = { applyChanges: '更新文件', viewDiff: '当前文件更改', question: '问题', + changeTitle: '更改标题', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `终端(命令: ${cmd})`, @@ -896,8 +961,120 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', + builtIn: '内置', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: '收藏', + custom: '你的配置文件', + builtIn: '内置配置文件', + }, + actions: { + viewEnvironmentVariables: '环境变量', + addToFavorites: '添加到收藏', + removeFromFavorites: '从收藏中移除', + editProfile: '编辑配置文件', + duplicateProfile: '复制配置文件', + deleteProfile: '删除配置文件', + }, + copySuffix: '(Copy)', + duplicateName: '已存在同名配置文件', + setupInstructions: { + title: '设置说明', + viewOfficialGuide: '查看官方设置指南', + }, + defaultSessionType: '默认会话类型', + defaultPermissionMode: { + title: '默认权限模式', + descriptions: { + default: '询问权限', + acceptEdits: '自动批准编辑', + plan: '执行前先规划', + bypassPermissions: '跳过所有权限', + }, + }, + aiBackend: { + title: 'AI 后端', + selectAtLeastOneError: '至少选择一个 AI 后端。', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI(实验)', + }, + tmux: { + title: 'tmux', + spawnSessionsTitle: '在 tmux 中启动会话', + spawnSessionsEnabledSubtitle: '会话将在新的 tmux 窗口中启动。', + spawnSessionsDisabledSubtitle: '会话将在普通 shell 中启动(无 tmux 集成)', + sessionNamePlaceholder: '留空 = 当前/最近会话', + tempDirPlaceholder: '/tmp(可选)', + }, + previewMachine: { + title: '预览设备', + selectMachine: '选择设备', + resolveSubtitle: '为此配置文件解析设备环境变量。', + selectSubtitle: '选择设备以预览解析后的值。', + }, + environmentVariables: { + title: '环境变量', + addVariable: '添加变量', + namePlaceholder: '变量名(例如 MY_CUSTOM_VAR)', + valuePlaceholder: '值(例如 my-value 或 ${MY_VAR})', + validation: { + nameRequired: '请输入变量名。', + invalidNameFormat: '变量名必须由大写字母、数字和下划线组成,且不能以数字开头。', + duplicateName: '该变量已存在。', + }, + card: { + valueLabel: '值:', + fallbackValueLabel: '备用值:', + valueInputPlaceholder: '值', + defaultValueInputPlaceholder: '默认值', + secretNotRetrieved: '秘密值——出于安全原因不会读取', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `正在覆盖文档默认值:${expectedValue}`, + useMachineEnvToggle: '使用设备环境中的值', + resolvedOnSessionStart: '会话在所选设备上启动时解析。', + sourceVariableLabel: '来源变量', + sourceVariablePlaceholder: '来源变量名(例如 Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `正在检查 ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} 上为空`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} 上为空(使用备用值)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上未找到`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `在 ${machine} 上未找到(使用备用值)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上找到值`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `与文档值不同:${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - 出于安全已隐藏`, + hiddenValue: '***已隐藏***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `会话将收到:${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `环境变量 · ${profileName}`, + descriptionPrefix: '这些环境变量会在启动会话时发送。值会通过守护进程解析于', + descriptionFallbackMachine: '所选设备', + descriptionSuffix: '。', + emptyMessage: '该配置文件未设置环境变量。', + checkingSuffix: '(检查中…)', + detail: { + fixed: '固定', + machine: '设备', + checking: '检查中', + fallback: '备用', + missing: '缺失', + }, + }, + }, delete: { title: '删除配置', message: ({ name }: { name: string }) => `确定要删除"${name}"吗?此操作无法撤销。`, diff --git a/sources/theme.css b/sources/theme.css index 7e241b5ae..7bc81abac 100644 --- a/sources/theme.css +++ b/sources/theme.css @@ -33,6 +33,18 @@ scrollbar-color: var(--colors-divider) var(--colors-surface-high); } +/* Expo Router (web) modal sizing + - Expo Router uses a Vaul/Radix drawer for `presentation: 'modal'` on web. + - Default sizing is a bit short on large screens; override via attribute selectors + so we don't rely on hashed classnames. */ +@media (min-width: 700px) { + [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] { + height: min(820px, calc(100vh - 96px)) !important; + max-height: min(820px, calc(100vh - 96px)) !important; + min-height: min(820px, calc(100vh - 96px)) !important; + } +} + /* Ensure scrollbars are visible on hover for macOS */ ::-webkit-scrollbar:horizontal { height: 12px; @@ -40,4 +52,4 @@ ::-webkit-scrollbar:vertical { width: 12px; -} \ No newline at end of file +} diff --git a/sources/utils/envVarTemplate.test.ts b/sources/utils/envVarTemplate.test.ts new file mode 100644 index 000000000..52ca30646 --- /dev/null +++ b/sources/utils/envVarTemplate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from './envVarTemplate'; + +describe('envVarTemplate', () => { + it('preserves := operator during parse/format round-trip', () => { + const input = '${FOO:=bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':=', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('preserves :- operator during parse/format round-trip', () => { + const input = '${FOO:-bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':-', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('round-trips templates without a fallback', () => { + const input = '${FOO}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: null, fallback: '' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('formats an empty fallback when operator is explicitly provided', () => { + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':=', fallback: '' })).toBe('${FOO:=}'); + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':-', fallback: '' })).toBe('${FOO:-}'); + }); +}); + diff --git a/sources/utils/envVarTemplate.ts b/sources/utils/envVarTemplate.ts new file mode 100644 index 000000000..493ca41eb --- /dev/null +++ b/sources/utils/envVarTemplate.ts @@ -0,0 +1,40 @@ +export type EnvVarTemplateOperator = ':-' | ':='; + +export type EnvVarTemplate = Readonly<{ + sourceVar: string; + fallback: string; + operator: EnvVarTemplateOperator | null; +}>; + +export function parseEnvVarTemplate(value: string): EnvVarTemplate | null { + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-|:=)(.*)\}$/); + if (withFallback) { + return { + sourceVar: withFallback[1], + operator: withFallback[2] as EnvVarTemplateOperator, + fallback: withFallback[3], + }; + } + + const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + if (noFallback) { + return { + sourceVar: noFallback[1], + operator: null, + fallback: '', + }; + } + + return null; +} + +export function formatEnvVarTemplate(params: { + sourceVar: string; + fallback: string; + operator?: EnvVarTemplateOperator | null; +}): string { + const operator: EnvVarTemplateOperator | null = params.operator ?? (params.fallback !== '' ? ':-' : null); + const suffix = operator ? `${operator}${params.fallback}` : ''; + return `\${${params.sourceVar}${suffix}}`; +} + diff --git a/sources/utils/ignoreNextRowPress.test.ts b/sources/utils/ignoreNextRowPress.test.ts new file mode 100644 index 000000000..807780c5b --- /dev/null +++ b/sources/utils/ignoreNextRowPress.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ignoreNextRowPress } from './ignoreNextRowPress'; + +describe('ignoreNextRowPress', () => { + it('resets the ignore flag on the next tick', () => { + vi.useFakeTimers(); + try { + const ref = { current: false }; + + ignoreNextRowPress(ref); + expect(ref.current).toBe(true); + + vi.runAllTimers(); + expect(ref.current).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/sources/utils/ignoreNextRowPress.ts b/sources/utils/ignoreNextRowPress.ts new file mode 100644 index 000000000..55c95e473 --- /dev/null +++ b/sources/utils/ignoreNextRowPress.ts @@ -0,0 +1,7 @@ +export function ignoreNextRowPress(ref: { current: boolean }): void { + ref.current = true; + setTimeout(() => { + ref.current = false; + }, 0); +} + diff --git a/sources/utils/promptUnsavedChangesAlert.test.ts b/sources/utils/promptUnsavedChangesAlert.test.ts new file mode 100644 index 000000000..85daab85f --- /dev/null +++ b/sources/utils/promptUnsavedChangesAlert.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { AlertButton } from '@/modal/types'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; + +const basePromptOptions = { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', +} as const; + +function createPromptHarness() { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, basePromptOptions); + + function press(text: string) { + const button = lastButtons?.find((b) => b.text === text); + expect(button).toBeDefined(); + button?.onPress?.(); + } + + return { promise, press }; +} + +describe('promptUnsavedChangesAlert', () => { + it('resolves to save when the Save button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Save'); + + await expect(promise).resolves.toBe('save'); + }); + + it('resolves to discard when the Discard button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Discard'); + + await expect(promise).resolves.toBe('discard'); + }); + + it('resolves to keepEditing when the Keep editing button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Keep editing'); + + await expect(promise).resolves.toBe('keepEditing'); + }); +}); diff --git a/sources/utils/promptUnsavedChangesAlert.ts b/sources/utils/promptUnsavedChangesAlert.ts new file mode 100644 index 000000000..867580f3a --- /dev/null +++ b/sources/utils/promptUnsavedChangesAlert.ts @@ -0,0 +1,35 @@ +import type { AlertButton } from '@/modal/types'; + +export type UnsavedChangesDecision = 'discard' | 'save' | 'keepEditing'; + +export function promptUnsavedChangesAlert( + alert: (title: string, message?: string, buttons?: AlertButton[]) => void, + params: { + title: string; + message: string; + discardText: string; + saveText: string; + keepEditingText: string; + }, +): Promise { + return new Promise((resolve) => { + alert(params.title, params.message, [ + { + text: params.discardText, + style: 'destructive', + onPress: () => resolve('discard'), + }, + { + text: params.saveText, + style: 'default', + onPress: () => resolve('save'), + }, + { + text: params.keepEditingText, + style: 'cancel', + onPress: () => resolve('keepEditing'), + }, + ]); + }); +} + diff --git a/sources/utils/storageScope.test.ts b/sources/utils/storageScope.test.ts new file mode 100644 index 000000000..bb2d15354 --- /dev/null +++ b/sources/utils/storageScope.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR, + normalizeStorageScope, + readStorageScopeFromEnv, + scopedStorageId, +} from './storageScope'; + +describe('storageScope', () => { + describe('normalizeStorageScope', () => { + it('returns null for non-strings and empty strings', () => { + expect(normalizeStorageScope(undefined)).toBeNull(); + expect(normalizeStorageScope(null)).toBeNull(); + expect(normalizeStorageScope(123)).toBeNull(); + expect(normalizeStorageScope('')).toBeNull(); + expect(normalizeStorageScope(' ')).toBeNull(); + }); + + it('sanitizes unsafe characters and clamps length', () => { + expect(normalizeStorageScope(' pr272-107 ')).toBe('pr272-107'); + expect(normalizeStorageScope('a/b:c')).toBe('a_b_c'); + expect(normalizeStorageScope('a__b')).toBe('a_b'); + + const long = 'x'.repeat(100); + expect(normalizeStorageScope(long)?.length).toBe(64); + }); + }); + + describe('readStorageScopeFromEnv', () => { + it('reads from EXPO_PUBLIC_HAPPY_STORAGE_SCOPE', () => { + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: 'stack-1' })).toBe('stack-1'); + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: ' ' })).toBeNull(); + }); + }); + + describe('scopedStorageId', () => { + it('returns baseId when scope is null', () => { + expect(scopedStorageId('auth_credentials', null)).toBe('auth_credentials'); + }); + + it('namespaces when scope is present', () => { + expect(scopedStorageId('auth_credentials', 'stack-1')).toBe('auth_credentials::stack-1'); + }); + }); +}); + diff --git a/sources/utils/storageScope.ts b/sources/utils/storageScope.ts new file mode 100644 index 000000000..bce4620d3 --- /dev/null +++ b/sources/utils/storageScope.ts @@ -0,0 +1,32 @@ +export const EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR = 'EXPO_PUBLIC_HAPPY_STORAGE_SCOPE'; + +/** + * Returns a sanitized storage scope suitable for identifiers/keys, or null. + * + * Notes: + * - This is intentionally conservative (stable, URL/key friendly). + * - If unset/empty, callers should behave exactly as they did before (no scoping). + */ +export function normalizeStorageScope(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + // Keep only safe characters to avoid backend/storage quirks (keychain, MMKV id, etc.) + // Replace everything else with '_' for stability. + const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_'); + const collapsed = sanitized.replace(/_+/g, '_'); + const clamped = collapsed.slice(0, 64); + return clamped || null; +} + +export function readStorageScopeFromEnv( + env: Record = process.env, +): string | null { + return normalizeStorageScope(env[EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]); +} + +export function scopedStorageId(baseId: string, scope: string | null): string { + return scope ? `${baseId}::${scope}` : baseId; +} + diff --git a/yarn.lock b/yarn.lock index ce5b12ad1..f2481eef6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3109,6 +3109,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@^19.1.0": + version "19.1.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10" + integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "19.1.8" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.8.tgz#ff8395f2afb764597265ced15f8dddb0720ae1c3"