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/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index e93fdd4eb..457419294 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -168,6 +168,9 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: const shouldShowCliWarning = isCliOutdated && !isAcknowledged; // Get permission mode from session object, default to 'default' const permissionMode = session.permissionMode || 'default'; + // Get model mode from session object - for Gemini sessions use explicit model, default to gemini-2.5-pro + const isGeminiSession = session.metadata?.flavor === 'gemini'; + const modelMode = session.modelMode || (isGeminiSession ? 'gemini-2.5-pro' : 'default'); const sessionStatus = useSessionStatus(session); const sessionUsage = useSessionUsage(sessionId); const alwaysShowContextSize = useSetting('alwaysShowContextSize'); @@ -193,6 +196,11 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); + // 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]); + // Memoize header-dependent styles to prevent re-renders const headerDependentStyles = React.useMemo(() => ({ contentContainer: { @@ -272,6 +280,8 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: sessionId={sessionId} permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} + modelMode={modelMode as any} + onModelModeChange={updateModelMode as any} 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 c8d76009f..031c7af34 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,6 +1,5 @@ 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'; @@ -16,38 +15,28 @@ 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 { SessionTypeSelector, 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 { 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 } from '@/sync/profileGrouping'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -61,7 +50,7 @@ const useProfileMap = (profiles: AIBackendProfile[]) => { // Returns ALL profile environment variables - daemon will use them as-is const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => { // getProfileEnvironmentVariables already returns ALL env vars from profile - // including custom environmentVariables array and provider-specific configs + // including custom environmentVariables array return getProfileEnvironmentVariables(profile); }; @@ -97,43 +86,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 +204,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 +246,19 @@ 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 safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const { width: screenWidth } = useWindowDimensions(); + + 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 +280,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 +299,61 @@ 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 toggleFavoriteProfile = React.useCallback((profileId: string) => { + if (favoriteProfileIdSet.has(profileId)) { + setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); + } else { + setFavoriteProfileIds([profileId, ...favoriteProfileIds]); + } + }, [favoriteProfileIdSet, 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 +361,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 @@ -349,13 +379,13 @@ function NewSessionWizard() { const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>('simple'); const [permissionMode, setPermissionMode] = React.useState(() => { // Initialize with last used permission mode if valid, otherwise default to 'default' - const validClaudeGeminiModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; if (lastUsedPermissionMode) { - if (agentType === 'codex' && validCodexModes.includes(lastUsedPermissionMode as PermissionMode)) { + if ((agentType === 'codex' || agentType === 'gemini') && validCodexGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) { return lastUsedPermissionMode as PermissionMode; - } else if ((agentType === 'claude' || agentType === 'gemini') && validClaudeGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) { + } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedPermissionMode as PermissionMode)) { return lastUsedPermissionMode as PermissionMode; } } @@ -368,19 +398,21 @@ function NewSessionWizard() { 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', 'default', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; - const validGeminiModes: ModelMode[] = ['default']; - - 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; + 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 (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' : 'default'; + return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; }); // Session details state @@ -398,12 +430,24 @@ 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 // @@ -415,7 +459,6 @@ function NewSessionWizard() { 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(() => { @@ -432,6 +475,32 @@ function NewSessionWizard() { } }, [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, recentMachinePaths)); + }, [machines, recentMachinePaths, selectedMachineId]); + // Handle path route param from picker screens (main's navigation pattern) React.useEffect(() => { if (typeof pathParam !== 'string') { @@ -477,19 +546,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 }); @@ -533,40 +589,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(() => { @@ -590,6 +649,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(() => { @@ -616,6 +727,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 []; @@ -661,298 +776,376 @@ 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); + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); - 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; - - 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'); + } + } + } + }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]); + + // Handle profile route param from picker screens + React.useEffect(() => { + if (!useProfiles) { + return; + } + + const nextProfileIdFromParams = Array.isArray(profileIdParam) ? profileIdParam[0] : profileIdParam; + if (typeof nextProfileIdFromParams !== 'string') { + return; + } + if (nextProfileIdFromParams === '') { + if (selectedProfileId !== null) { + setSelectedProfileId(null); + } + return; + } + if (nextProfileIdFromParams !== selectedProfileId) { + selectProfile(nextProfileIdFromParams); + } + }, [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); + + const mapPermissionModeAcrossAgents = React.useCallback((mode: PermissionMode, from: 'claude' | 'codex' | 'gemini', to: 'claude' | 'codex' | 'gemini'): PermissionMode => { + if (from === to) return mode; + + const toCodex = to === 'codex'; + if (toCodex) { + // Claude/Gemini -> Codex + switch (mode) { + case 'bypassPermissions': + return 'yolo'; + case 'plan': + return 'safe-yolo'; + case 'acceptEdits': + return 'safe-yolo'; + case 'default': + return 'default'; + default: + return 'default'; } } - }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); - // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent + // Codex -> Claude/Gemini + switch (mode) { + case 'yolo': + return 'bypassPermissions'; + case 'safe-yolo': + return 'plan'; + case 'read-only': + return 'default'; + case 'default': + return 'default'; + default: + return 'default'; + } + }, []); + + // 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 validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + + const isValidForNewAgent = (agentType === 'codex' || agentType === 'gemini') + ? validCodexGeminiModes.includes(current) + : validClaudeModes.includes(current); + + if (isValidForNewAgent) { + return; + } - const isValidForCurrentAgent = agentType === 'codex' - ? validCodexModes.includes(permissionMode) - : validClaudeModes.includes(permissionMode); + const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); + applyPermissionMode(mapped, 'auto'); + }, [agentType, applyPermissionMode, mapPermissionModeAcrossAgents]); + + // Reset model mode when agent type changes to appropriate default + React.useEffect(() => { + 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']; + + let isValidForCurrentAgent = false; + if (agentType === 'codex') { + isValidForCurrentAgent = validCodexModes.includes(modelMode); + } else if (agentType === 'gemini') { + isValidForCurrentAgent = validGeminiModes.includes(modelMode); + } else { + isValidForCurrentAgent = validClaudeModes.includes(modelMode); + } if (!isValidForCurrentAgent) { - setPermissionMode('default'); + // Set appropriate default for each agent type + if (agentType === 'codex') { + setModelMode('gpt-5-codex-high'); + } else if (agentType === 'gemini') { + setModelMode('gemini-2.5-pro'); + } else { + setModelMode('default'); + } } - }, [agentType, permissionMode]); + }, [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; 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, + }, + } as any); + }, [selectedMachine, selectedMachineId]); + + const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { + return ; + }, []); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; + + const actions: ItemAction[] = []; + if (envVarCount > 0) { + actions.push({ + id: 'envVars', + title: 'View environment variables', + icon: 'list-outline', + onPress: () => openProfileEnvVarsPreview(profile), + }); + } + actions.push({ + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }); + actions.push({ + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit({ profileId: profile.id }), + }); + actions.push({ + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }); + if (!profile.isBuiltIn) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => handleDeleteProfile(profile), + }); + } + + return ( + + + + + 0 ? ['envVars'] : []} + iconSize={20} + onActionPressIn={() => { + ignoreProfileRowPressRef.current = true; + }} + /> + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEnvVarsPreview, + openProfileEdit, + screenWidth, + theme.colors.button.primary.background, + 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]); + const handleMachineClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); - // 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]); + const handleProfileClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId]); - 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 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; } - const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); - let updatedProfiles: AIBackendProfile[]; + const currentIndex = supportedAgents.indexOf(agentType); + const nextIndex = (currentIndex + 1) % supportedAgents.length; + setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? 'claude'); + return; + } - if (existingIndex >= 0) { - // Update existing profile - updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profileToSave; - } else { - // Add new profile - updatedProfiles = [...profiles, profileToSave]; - } - - setProfiles(updatedProfiles); // Use mutable setter for persistence - setSelectedProfileId(profileToSave.id); - }; - onProfileSaved = handler; - return () => { - onProfileSaved = () => { }; - }; - }, [profiles, setProfiles]); - - 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) { @@ -966,6 +1159,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, agentType) ?? {}; + }, [agentType, 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, + }, + } as any); + }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]); + // Session creation const handleCreateSession = React.useCallback(async () => { if (!selectedMachineId) { @@ -1001,18 +1221,24 @@ 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); } @@ -1023,6 +1249,7 @@ function NewSessionWizard() { directory: actualPath, approvedNewDirectoryCreation: true, agent: agentType, + profileId: profilesActive ? (selectedProfileId ?? '') : undefined, environmentVariables }); @@ -1032,8 +1259,11 @@ function NewSessionWizard() { await sync.refreshSessions(); - // Set permission mode on the session + // Set permission mode and model mode on the session storage.getState().updateSessionPermissionMode(result.sessionId, permissionMode); + if (agentType === 'gemini' && modelMode && modelMode !== 'default') { + storage.getState().updateSessionModelMode(result.sessionId, modelMode as 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'); + } // Send initial message if provided if (sessionPrompt.trim()) { @@ -1061,9 +1291,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 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; + } - const screenWidth = useWindowDimensions().width; + router.back(); + }, [router]); // Machine online status for AgentInput (DRY - reused in info box too) const connectionStatus = React.useMemo(() => { @@ -1086,6 +1331,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); @@ -1094,22 +1353,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) @@ -1119,48 +1370,90 @@ function NewSessionWizard() { return ( + {showInlineClose && ( + + + + )} - {/* 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} - /> - - + onMachineClick={handleMachineClick} + currentPath={selectedPath} + onPathClick={handlePathClick} + contentPaddingHorizontal={0} + {...(useProfiles ? { + profileId: selectedProfileId, + onProfileClick: handleProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + + + ); @@ -1173,9 +1466,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} - - gemini + + + + {cliAvailability.claude ? '✓' : '✗'} + + + 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. + + + {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} + /> + ); + })} + + )} + + + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + rightElement={!selectedProfileId + ? ( + + + + ) + : null} + 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 */} @@ -1402,7 +1859,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); - }} - > - - - { - 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); + } 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); }} - > - - - - - ); - })} - - {/* 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 - - 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]); - } - }} + { + 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 - - 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); - } - 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 - - - {(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 + + + + {(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 + + + + + } 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..3568fc3d2 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -3,13 +3,11 @@ import { View, Text } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; import { CommonActions, useNavigation } from '@react-navigation/native'; 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: { @@ -38,6 +36,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; @@ -65,14 +65,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, }); } } @@ -114,63 +115,23 @@ 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 +} diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b0214d6c6..4762b9f28 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -1,32 +1,18 @@ import React, { useState, useMemo, useRef } from 'react'; -import { View, Text, ScrollView, Pressable } from 'react-native'; +import { View, Text, 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 { 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'; +import { ItemList } from '@/components/ItemList'; +import { layout } from '@/components/layout'; +import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; 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', @@ -39,6 +25,11 @@ const stylesheet = StyleSheet.create((theme) => ({ textAlign: 'center', ...Typography.default(), }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, pathInputContainer: { flexDirection: 'row', alignItems: 'center', @@ -66,10 +57,12 @@ 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 [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [pathSearchQuery, setPathSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -162,13 +155,11 @@ export default function PathPickerScreen() { ) }} /> - + - - No machine selected - + No machine selected - + ); } @@ -198,104 +189,29 @@ export default function PathPickerScreen() { ) }} /> - - - - - - - - - - - - {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 +} diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..8fe47f83e 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { CommonActions, useNavigation } from '@react-navigation/native'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -9,48 +10,167 @@ 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'; export default 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); + + 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); + } + } + + 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 () => { + return Modal.confirm( + 'Discard changes?', + 'You have unsaved changes. Discard them?', + { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' }, + ); + }, []); + + React.useEffect(() => { + const subscription = (navigation as any)?.addListener?.('beforeRemove', (e: any) => { + if (!isDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const discard = await confirmDiscard(); + if (discard) { + isDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } + })(); + }); + + return subscription; + }, [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'), 'Enter a profile name.'); + 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'), 'A profile with that name already exists.'); + return; + } + + 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]; - const handleCancel = () => { + setProfiles(updatedProfiles); + + // Update last used profile for convenience in other screens. + if (isNewProfile) { + setLastUsedProfile(profileToSave.id); + } + + // 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({ + ...CommonActions.setParams({ 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 discard = await confirmDiscard(); + if (discard) { + isDirtyRef.current = false; + router.back(); + } + })(); + }, [confirmDiscard, router]); + return ( @@ -84,7 +205,7 @@ export default function ProfileEditScreen() { 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..5f82486df --- /dev/null +++ b/sources/app/(app)/new/pick/profile.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Pressable, 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 { useUnistyles } from 'react-native-unistyles'; +import { AIBackendProfile } from '@/sync/settings'; +import { Modal } from '@/modal'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; + +export default function ProfilePickerScreen() { + const { theme } = useUnistyles(); + 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 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) { + return backend ? `Built-in · ${backend}` : 'Built-in'; + } + 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({ + ...CommonActions.setParams({ profileId }), + source: previousRoute.key, + } as never); + } + router.back(); + }, [navigation, router]); + + React.useEffect(() => { + if (typeof profileId === 'string' && profileId.length > 0) { + setProfileParamAndClose(profileId); + } + }, [profileId, setProfileParamAndClose]); + + const openProfileCreate = React.useCallback(() => { + const base = '/new/pick/profile-edit'; + router.push(machineId ? `${base}?machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + const openProfileEdit = React.useCallback((profileId: string) => { + const base = `/new/pick/profile-edit?profileId=${encodeURIComponent(profileId)}`; + router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + const openProfileDuplicate = React.useCallback((cloneFromProfileId: string) => { + const base = `/new/pick/profile-edit?cloneFromProfileId=${encodeURIComponent(cloneFromProfileId)}`; + router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + if (favoriteProfileIdSet.has(profileId)) { + setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); + } else { + setFavoriteProfileIds([profileId, ...favoriteProfileIds]); + } + }, [favoriteProfileIdSet, 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: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit(profile.id), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => openProfileDuplicate(profile.id), + }, + ]; + if (!profile.isBuiltIn) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => handleDeleteProfile(profile), + }); + } + + return ( + + + + + + + ); + }, [ + handleDeleteProfile, + openProfileEdit, + openProfileDuplicate, + theme.colors.button.primary.background, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, + ]); + + return ( + <> + + + + {!useProfiles ? ( + + } + showChevron={false} + /> + } + onPress={() => router.push('/settings/features')} + /> + + ) : ( + <> + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + setProfileParamAndClose(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 ( + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + } + onPress={() => setProfileParamAndClose('')} + showChevron={false} + selected={selectedId === ''} + rightElement={selectedId === '' + ? + : null} + 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 ( + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + + } + onPress={handleAddProfile} + showChevron={false} + /> + + + )} + + + ); +} diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..e1cf603d2 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 } from '@/sync/profileUtils'; // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -66,10 +67,24 @@ 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) return 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 +213,10 @@ function SessionInfoContent({ session }: { session: Session }) { )} - {/* Session Details */} - - + } onPress={handleCopySessionId} @@ -221,17 +236,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 'Claude'; + if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; + if (flavor === 'gemini') return '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..c701ef908 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -9,11 +9,14 @@ import { t } from '@/text'; export default 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 +75,34 @@ export default function FeaturesSettingsScreen() { } showChevron={false} /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={ + + } + showChevron={false} + /> {/* Web-only Features */} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fa4522023..8b80f011b 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 type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { Switch } from '@/components/Switch'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } 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,48 @@ interface ProfileManagerProps { } // Profile utilities now imported from @/sync/profileUtils - -function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { +const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { const { theme } = useUnistyles(); + 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]); + + if (!useProfiles) { + return ( + + + } + rightElement={ + + } + showChevron={false} + /> + + + ); + } 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 +76,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)); + 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( + 'Discard changes?', + 'You have unsaved changes. Discard them?', + { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' }, + ); + 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 +147,35 @@ 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) => { + if (favoriteProfileIdSet.has(profileId)) { + setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); + } else { + setFavoriteProfileIds([profileId, ...favoriteProfileIds]); + } + }; + + 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'), 'Enter a profile name.'); return; } @@ -121,16 +184,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'), 'A profile with that name already exists.'); return; } @@ -142,6 +203,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr p.id !== profile.id && p.name.trim() === profile.name.trim() ); if (isDuplicate) { + Modal.alert(t('common.error'), 'A profile with that name already exists.'); return; } @@ -151,7 +213,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 +225,233 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setProfiles(updatedProfiles); } - setShowAddForm(false); - setEditingProfile(null); + closeEditor(); }; 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: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => handleEditProfile(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + ]; + if (!profile.isBuiltIn) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => { 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: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => handleEditProfile(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + { + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => { void handleDeleteProfile(profile); }, + }, + ]; + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> + ); + })} + + )} - return ( - + {nonFavoriteBuiltInProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => handleEditProfile(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + ]; + return ( + handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || 'Default model'} - {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - - - ); - })} - - {/* 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={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> + ); + })} + + + + } 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 +469,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..7f95d9eba 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(); 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 */} void; + onPermissionClick?: () => void; modelMode?: ModelMode; onModelModeChange?: (mode: ModelMode) => void; metadata?: Metadata | null; @@ -73,10 +74,18 @@ interface AgentInputProps { minHeight?: number; profileId?: string | null; onProfileClick?: () => void; + envVarsCount?: number; + onEnvVarsClick?: () => void; + contentPaddingHorizontal?: number; } 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', @@ -223,16 +232,18 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ // Button styles actionButtonsContainer: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 0, }, - actionButtonsLeft: { - flexDirection: 'row', - gap: 8, - flex: 1, - overflow: 'hidden', - }, + actionButtonsLeft: { + flexDirection: 'row', + columnGap: 6, + rowGap: 3, + flex: 1, + flexWrap: 'wrap', + overflow: 'visible', + }, actionButton: { flexDirection: 'row', alignItems: 'center', @@ -256,6 +267,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', flexShrink: 0, marginLeft: 8, + marginRight: 8, }, sendButtonActive: { backgroundColor: theme.colors.button.primary.background, @@ -300,13 +312,16 @@ export const AgentInput = React.memo(React.forwardRef 0; // Check if this is a Codex or Gemini session - const isCodex = props.metadata?.flavor === 'codex'; - const isGemini = props.metadata?.flavor === 'gemini'; + const effectiveFlavor = props.metadata?.flavor ?? props.agentType; + const isCodex = effectiveFlavor === 'codex'; + const isGemini = effectiveFlavor === 'gemini'; // 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; @@ -314,6 +329,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) @@ -338,7 +372,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('📝 Input state changed:', JSON.stringify(newState)); setInputState(newState); }, []); @@ -348,18 +381,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; @@ -381,8 +402,6 @@ export const AgentInput = React.memo(React.forwardRef { + const mode = props.permissionMode ?? 'default'; + + if (isCodex) { + return mode === 'default' ? 'CLI' : + mode === 'read-only' ? 'RO' : + mode === 'safe-yolo' ? 'Safe' : + mode === 'yolo' ? 'YOLO' : ''; + } + + if (isGemini) { + return mode === 'default' ? t('agentInput.geminiPermissionMode.default') : + mode === 'acceptEdits' ? 'Accept' : + mode === 'bypassPermissions' ? 'YOLO' : + mode === 'plan' ? 'Plan' : ''; + } + + return mode === 'default' ? t('agentInput.permissionMode.default') : + mode === 'acceptEdits' ? 'Accept' : + mode === 'bypassPermissions' ? 'YOLO' : + mode === 'plan' ? 'Plan' : ''; + }, [isCodex, isGemini, props.permissionMode]); + // 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(); @@ -493,7 +537,7 @@ 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 } ]}> - + {/* Permission Mode Section */} {isCodex ? t('agentInput.codexPermissionMode.title') : isGemini ? t('agentInput.geminiPermissionMode.title') : t('agentInput.permissionMode.title')} - {(isCodex + {((isCodex || isGemini) ? (['default', 'read-only', 'safe-yolo', 'yolo'] as const) - : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const) // Claude and Gemini share same modes + : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const) ).map((mode) => { const modeConfig = isCodex ? { 'default': { label: t('agentInput.codexPermissionMode.default') }, @@ -543,10 +585,10 @@ export const AgentInput = React.memo(React.forwardRef {t('agentInput.model.title')} - - {t('agentInput.model.configureInCli')} - + {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; + + return ( + { + hapticsLight(); + props.onModelModeChange?.(model); + }} + style={({ pressed }) => ({ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' + })} + > + + {isSelected && ( + + )} + + + + {config.label} + + + {config.description} + + + + ); + }) + ) : ( + + {t('agentInput.model.configureInCli')} + + )} @@ -635,23 +743,23 @@ export const AgentInput = React.memo(React.forwardRef - + {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 - - - )} - - )} )} {contextWarning && ( @@ -765,9 +805,9 @@ 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', + {/* Box 2: Action Area (Input + Send) */} + + {/* Input field */} + + + + + {/* Action buttons below input */} + + + {/* 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) => ({ + flexDirection: 'row', + alignItems: 'center', borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, + paddingHorizontal: 10, paddingVertical: 6, + justifyContent: 'center', height: 32, opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} + gap: 6, + })} + > + + + {permissionChipLabel} + + + )} - {/* Path chip */} - {props.currentPath && props.onPathClick && ( + {/* Profile selector button - FIRST */} + {props.onProfileClick && ( { hapticsLight(); - props.onPathClick?.(); + props.onProfileClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -838,83 +898,70 @@ export const AgentInput = React.memo(React.forwardRef - + - {props.currentPath} + {profileLabel ?? t('profiles.noProfile')} - + )} - - )} - - {/* Box 2: Action Area (Input + Send) */} - - {/* Input field */} - - - - - {/* Action buttons below input */} - - - {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - - - {/* Settings button */} - {props.onPermissionModeChange && ( + {/* Env vars preview (standard flow) */} + {props.onEnvVarsClick && ( { + hapticsLight(); + props.onEnvVarsClick?.(); + }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ flexDirection: 'row', alignItems: 'center', borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, + paddingHorizontal: 10, paddingVertical: 6, justifyContent: 'center', height: 32, opacity: p.pressed ? 0.7 : 1, + gap: 6, })} > - + + + {props.envVarsCount ? `Env Vars (${props.envVarsCount})` : 'Env Vars'} + )} - {/* Profile selector button - FIRST */} - {props.profileId && props.onProfileClick && ( + {/* Agent selector button */} + {props.agentType && props.onAgentClick && ( { hapticsLight(); - props.onProfileClick?.(); + props.onAgentClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -929,28 +976,28 @@ export const AgentInput = React.memo(React.forwardRef - + - {currentProfile?.name || 'Select Profile'} + {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 && ( + {/* Machine selector button */} + {(props.machineName !== undefined) && props.onMachineClick && ( { hapticsLight(); - props.onAgentClick?.(); + props.onMachineClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -965,21 +1012,23 @@ export const AgentInput = React.memo(React.forwardRef - - - {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} - - - )} + + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + + + )} {/* Abort button */} {props.onAbort && ( @@ -1085,6 +1134,46 @@ 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) => ({ + 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.currentPath} + + + + + )} diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 2185e0b21..26647598e 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -1,13 +1,16 @@ 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 { Typography } from '@/constants/Typography'; -import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { Switch } from '@/components/Switch'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; machineId: string | null; + machineName?: string | null; + machineEnv?: Record; + isMachineEnvLoading?: boolean; expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) @@ -24,8 +27,8 @@ function parseVariableValue(value: string): { remoteVariableName: string; defaultValue: string; } { - // Match: ${VARIABLE_NAME:-default_value} - const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); + // Match: ${VARIABLE_NAME:-default_value} or ${VARIABLE_NAME:=default_value} + const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/); if (matchWithFallback) { return { useRemoteVariable: true, @@ -59,6 +62,9 @@ function parseVariableValue(value: string): { export function EnvironmentVariableCard({ variable, machineId, + machineName, + machineEnv, + isMachineEnvLoading = false, expectedValue, description, isSecret = false, @@ -68,20 +74,42 @@ export function EnvironmentVariableCard({ }: EnvironmentVariableCardProps) { const { theme } = useUnistyles(); + const webNoOutline = React.useMemo(() => (Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), []); + + const secondaryTextStyle = React.useMemo(() => ({ + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }), []); + + const remoteToggleLabelStyle = React.useMemo(() => ({ + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }), []); + // Parse current value const parsed = parseVariableValue(variable.value); const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); - // 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] : [] - ); - - const remoteValue = remoteValues[remoteVariableName]; + const remoteValue = machineEnv?.[remoteVariableName]; + const hasFallback = defaultValue.trim() !== ''; + const machineLabel = machineName?.trim() ? machineName.trim() : 'machine'; // Update parent when local state changes React.useEffect(() => { @@ -98,18 +126,39 @@ export function EnvironmentVariableCard({ const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + const computedTemplateValue = + useRemoteVariable && remoteVariableName.trim() !== '' + ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + : defaultValue; + + const resolvedSessionValue = + isSecret + ? (useRemoteVariable && remoteVariableName + ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` + : (defaultValue ? '***hidden***' : '(empty)')) + : (useRemoteVariable && machineId && remoteValue !== undefined + ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : '(empty)') : remoteValue) + : (computedTemplateValue || '(empty)')); + return ( {/* Header row with variable name and action buttons */} @@ -138,107 +187,171 @@ 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 ? 'Fallback value:' : 'Value:'} + - {/* Remote variable name input */} + {/* Value input */} - {/* Remote variable status */} + {/* Security message for secrets */} + {isSecret && ( + + Secret value - not retrieved for security + + )} + + {/* Default override warning */} + {showDefaultOverrideWarning && !isSecret && ( + + Overriding documented default: {expectedValue} + + )} + + + + {/* Toggle: Use value from machine environment */} + + + Use value from machine environment + + + + + + Resolved when the session starts on the selected machine. + + + {/* Source variable name input (only when enabled) */} + {useRemoteVariable && ( + <> + + Source variable + + + setRemoteVariableName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + )} + + {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - {remoteValue === undefined ? ( + {isMachineEnvLoading || remoteValue === undefined ? ( - ⏳ Checking remote machine... + Checking {machineLabel}... - ) : remoteValue === null ? ( + ) : (remoteValue === null || remoteValue === '') ? ( - ✗ Value not found + {remoteValue === '' ? ( + hasFallback ? `Empty on ${machineLabel} (using fallback)` : `Empty on ${machineLabel}` + ) : ( + hasFallback ? `Not found on ${machineLabel} (using fallback)` : `Not found on ${machineLabel}` + )} ) : ( <> - ✓ Value found: {remoteValue} + Value found on {machineLabel} {showRemoteDiffersWarning && ( - ⚠️ Differs from documented value: {expectedValue} + Differs from documented value: {expectedValue} )} @@ -246,90 +359,13 @@ 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)') - } + Session will receive: {variable.name} = {resolvedSessionValue} ); diff --git a/sources/components/EnvironmentVariablesList.keys.test.ts b/sources/components/EnvironmentVariablesList.keys.test.ts new file mode 100644 index 000000000..814e3e8db --- /dev/null +++ b/sources/components/EnvironmentVariablesList.keys.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('EnvironmentVariablesList item keys', () => { + it('does not key EnvironmentVariableCard by array index', () => { + const file = join(process.cwd(), 'sources/components/EnvironmentVariablesList.tsx'); + const content = readFileSync(file, 'utf8'); + + expect(content).not.toContain('key={index}'); + expect(content).toContain('key={envVar.name}'); + }); +}); + diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index e42e61415..9b92e2198 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -1,14 +1,19 @@ 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 { 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; } @@ -20,16 +25,55 @@ export interface EnvironmentVariablesListProps { export function EnvironmentVariablesList({ environmentVariables, machineId, + machineName, profileDocs, onChange, }: EnvironmentVariablesListProps) { const { theme } = useUnistyles(); + // Extract variable name from a template value (for matching documentation / machine env lookup) + const extractVarNameFromValue = React.useCallback((value: string): string | null => { + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/); + return match ? match[1] : null; + }, []); + + const SECRET_NAME_REGEX = React.useMemo(() => /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i, []); + + const resolvedEnvVarRefs = React.useMemo(() => { + const refs = new Set(); + environmentVariables.forEach((envVar) => { + const ref = extractVarNameFromValue(envVar.value); + if (!ref) return; + // Don't query secret-like env vars from the machine. + if (SECRET_NAME_REGEX.test(ref) || SECRET_NAME_REGEX.test(envVar.name)) return; + refs.add(ref); + }); + return Array.from(refs); + }, [SECRET_NAME_REGEX, environmentVariables, extractVarNameFromValue]); + + const { variables: machineEnv, isLoading: isMachineEnvLoading } = useEnvironmentVariables( + machineId, + resolvedEnvVarRefs, + ); + // Add variable inline form state const [showAddForm, setShowAddForm] = React.useState(false); const [newVarName, setNewVarName] = React.useState(''); const [newVarValue, setNewVarValue] = React.useState(''); + const webNoOutline = React.useMemo(() => (Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), []); + // Helper to get expected value and description from documentation const getDocumentation = React.useCallback((varName: string) => { if (!profileDocs) return { expectedValue: undefined, description: undefined, isSecret: false }; @@ -42,12 +86,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 +114,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'), 'Enter a variable name.'); + 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'), + 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.', + ); return; } // Check for duplicates - if (environmentVariables.some(v => v.name === newVarName.trim())) { + if (environmentVariables.some(v => v.name === normalizedName)) { + Modal.alert(t('common.error'), 'That variable already exists.'); return; } onChange([...environmentVariables, { - name: newVarName.trim(), + name: normalizedName, value: newVarValue.trim() || '' }]); @@ -97,162 +144,150 @@ 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 + Environment Variables - + + + {environmentVariables.length > 0 && ( + + {environmentVariables.map((envVar, index) => { + const varNameFromValue = extractVarNameFromValue(envVar.value); + const docs = getDocumentation(varNameFromValue || envVar.name); + const isSecret = + docs.isSecret || + SECRET_NAME_REGEX.test(envVar.name) || + SECRET_NAME_REGEX.test(varNameFromValue || ''); + + return ( + handleUpdateVariable(index, newValue)} + onDelete={() => handleDeleteVariable(index)} + onDuplicate={() => handleDuplicateVariable(index)} + /> + ); + })} + + )} + + + + } + showChevron={false} + onPress={() => { + if (showAddForm) { + setShowAddForm(false); + setNewVarName(''); + setNewVarValue(''); + } else { + setShowAddForm(true); + } + }} + /> + + {showAddForm && ( + + + setNewVarName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + + + + - {/* Add variable inline form */} - {showAddForm && ( - - - - - { - setShowAddForm(false); - setNewVarName(''); - setNewVarValue(''); - }} - > - - Cancel - - ({ backgroundColor: theme.colors.button.primary.background, - borderRadius: 6, - padding: theme.margins.sm, + borderRadius: 10, + paddingVertical: 10, alignItems: 'center', - }} - onPress={handleAddVariable} + opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1, + })} > - + 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)} - /> - ); - })} + )} + ); } diff --git a/sources/components/Item.tsx b/sources/components/Item.tsx index 379a815d4..e761b3f98 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'; @@ -197,9 +199,10 @@ export const Item = React.memo((props) => { // 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 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; @@ -285,21 +288,23 @@ export const Item = React.memo((props) => { ); - if (isInteractive) { - return ( - [ - { - backgroundColor: pressed && isIOS && !isWeb ? theme.colors.surfacePressedOverlay : 'transparent', - opacity: disabled ? 0.5 : 1 - }, - pressableStyle - ]} + disabled={disabled || loading} + style={({ pressed }) => [ + { + backgroundColor: pressed && isIOS && !isWeb + ? theme.colors.surfacePressedOverlay + : (showSelectedBackground ? theme.colors.surfaceSelected : 'transparent'), + opacity: disabled ? 0.5 : 1 + }, + pressableStyle + ]} android_ripple={(isAndroid || isWeb) ? { color: theme.colors.surfaceRipple, borderless: false, diff --git a/sources/components/ItemActionsMenuModal.tsx b/sources/components/ItemActionsMenuModal.tsx new file mode 100644 index 000000000..387a94b60 --- /dev/null +++ b/sources/components/ItemActionsMenuModal.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; + +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; +} + +export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) { + const { theme } = useUnistyles(); + + const closeThen = React.useCallback((fn: () => void) => { + props.onClose(); + setTimeout(() => fn(), 0); + }, [props]); + + 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.tsx b/sources/components/ItemGroup.tsx index 0e046fb86..006f534d3 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -11,6 +11,8 @@ import { Typography } from '@/constants/Typography'; import { layout } from './layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); + interface ItemChildProps { showDivider?: boolean; [key: string]: any; @@ -95,6 +97,25 @@ export const ItemGroup = React.memo((props) => { containerStyle } = props; + const selectableItemCount = React.useMemo(() => { + const countSelectable = (node: React.ReactNode): number => { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + return count + countSelectable(child.props.children); + } + const propsAny = child.props as any; + const hasTitle = typeof propsAny?.title === 'string'; + const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function'; + return count + (hasTitle && isSelectable ? 1 : 0); + }, 0); + }; + + return countSelectable(children); + }, [children]); + return ( @@ -116,21 +137,23 @@ 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; + + {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 + }); } - 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; - })} + return child; + })} + {/* Footer */} @@ -144,4 +167,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..9d2789411 --- /dev/null +++ b/sources/components/ItemRowActions.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { View, Pressable, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { 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 { 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, + }, + } as any); + }, [overflowActions, props.title]); + + const iconSize = props.iconSize ?? 20; + const gap = props.gap ?? 16; + + return ( + + {inlineActions.map((action) => ( + props.onActionPressIn?.()} + onPress={(e: any) => { + e?.stopPropagation?.(); + action.onPress(); + }} + > + + + ))} + + {compact && overflowActions.length > 0 && ( + props.onActionPressIn?.()} + onPress={(e: any) => { + e?.stopPropagation?.(); + openMenu(); + }} + > + + + )} + + ); +} 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 da96dd234..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'; - -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..5f543fdf1 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,580 +1,535 @@ 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 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; } +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 { 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 ( + + + + Preview Machine + + + ({ 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({ profile, machineId, onSave, onCancel, - containerStyle + onDirtyChange, + containerStyle, }: ProfileEditFormProps) { const { theme } = useUnistyles(); + 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 showMachinePreviewPicker = React.useCallback(() => { + Modal.show({ + component: MachinePreviewModal, + props: { + machines, + favoriteMachineIds: favoriteMachines, + selectedMachineId: previewMachineId, + onSelect: setPreviewMachineId, + onToggleFavorite: toggleFavoriteMachineId, + }, + } as any); + }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); - // 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( + (profile.defaultPermissionMode as PermissionMode) || 'default', + ); + 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'), 'Select at least one AI backend.'); + 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'), 'Enter a profile name.'); 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} - - - {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: '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) => ( + - ))} - - - - {/* 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 && ( + + + Tmux Session Name ({t('common.optional')}) + - - {useStartupScript - ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.' - : 'No startup script - sessions spawn directly'} - - + + Tmux Temp Directory ({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) => ({ + 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..511de8705 --- /dev/null +++ b/sources/components/SearchHeader.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { View, TextInput, Platform, 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'; + +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.trim().length > 0 && ( + onChangeText('')} + style={styles.clearIcon} + /> + )} + + + + ); +} diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index c81ba79e2..5d93bb0eb 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,140 +73,26 @@ 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 styles = stylesheet; @@ -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,49 @@ 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 color = isFavorite ? theme.colors.button.primary.background : 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 +212,11 @@ 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; 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 ? 'No matches' : 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 ? 'No matches' : 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 ? 'No matches' : 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 ? 'No matches' : config.noItemsMessage)} + )} ); diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index 33aefd357..d7489d4ec 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -1,142 +1,82 @@ 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 }) => { +export function SessionTypeSelectorRows({ value, onChange }: Pick) { const { theme } = useUnistyles(); 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..955b66f49 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -37,6 +37,8 @@ export const SettingsView = React.memo(function SettingsView() { const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled'); const isPro = __DEV__ || useEntitlement('pro'); const experiments = useSetting('experiments'); + const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); + const useProfiles = useSetting('useProfiles'); const isCustomServer = isUsingCustomServer(); const allMachines = useAllMachines(); const profile = useProfile(); @@ -110,7 +112,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,19 +304,19 @@ 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')} - /> + {useProfiles && ( + } + onPress={() => router.push('/settings/profiles')} + /> + )} {experiments && ( } - onPress={() => router.push('/dev')} + onPress={() => router.push('/(app)/dev')} /> )} @@ -357,7 +361,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 }: SwitchProps) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const translateX = value ? TRACK_WIDTH - THUMB_SIZE - PADDING * 2 : 0; + + return ( + onValueChange?.(!value)} + style={({ pressed }) => ({ + opacity: disabled ? 0.6 : pressed ? 0.85 : 1, + })} + > + + + + + ); +}; + diff --git a/sources/components/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx new file mode 100644 index 000000000..6c66fabe4 --- /dev/null +++ b/sources/components/newSession/DirectorySelector.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; + +export interface DirectorySelectorProps { + machineHomeDir?: string | null; + selectedPath: string; + recentPaths: string[]; + suggestedPaths?: string[]; + favoritePaths?: string[]; + onSelect: (path: string) => void; + onToggleFavorite?: (path: string) => void; + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + allSectionTitle?: string; + noItemsMessage?: string; +} + +export function DirectorySelector({ + machineHomeDir, + selectedPath, + recentPaths, + suggestedPaths = [], + favoritePaths = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + searchPlaceholder = 'Type to filter directories...', + recentSectionTitle = 'Recent Directories', + favoritesSectionTitle = 'Favorite Directories', + allSectionTitle = 'All Directories', + noItemsMessage = 'No recent directories', +}: DirectorySelectorProps) { + const { theme } = useUnistyles(); + const homeDir = machineHomeDir || undefined; + const recentOrSuggestedPaths = recentPaths.length > 0 ? recentPaths : suggestedPaths; + const recentTitle = recentPaths.length > 0 ? recentSectionTitle : 'Suggested Directories'; + + const allPaths = React.useMemo(() => { + const seen = new Set(); + const ordered: string[] = []; + for (const p of [...favoritePaths, ...recentOrSuggestedPaths]) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoritePaths, recentOrSuggestedPaths]); + + return ( + + config={{ + getItemId: (path) => path, + getItemTitle: (path) => formatPathRelativeToHome(path, homeDir), + getItemSubtitle: undefined, + getItemIcon: () => ( + + ), + getRecentItemIcon: () => ( + + ), + formatForDisplay: (path) => formatPathRelativeToHome(path, homeDir), + parseFromDisplay: (text) => { + const trimmed = text.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('/')) return trimmed; + if (homeDir) return resolveAbsolutePath(trimmed, homeDir); + return null; + }, + filterItem: (path, searchText) => { + const displayPath = formatPathRelativeToHome(path, homeDir); + return displayPath.toLowerCase().includes(searchText.toLowerCase()); + }, + searchPlaceholder, + recentSectionTitle: recentTitle, + favoritesSectionTitle, + allSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + showAll: favoritePaths.length > 0, + allowCustomInput: true, + }} + items={allPaths} + recentItems={recentOrSuggestedPaths} + favoriteItems={favoritePaths} + selectedItem={selectedPath || null} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + /> + ); +} diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx new file mode 100644 index 000000000..dac0c164a --- /dev/null +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { 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'; + +export interface EnvironmentVariablesPreviewModalProps { + environmentVariables: Record; + machineId: string | null; + machineName?: string | null; + profileName?: string | null; + onClose: () => void; +} + +function parseTemplateValue(value: string): { sourceVar: string; fallback: string } | null { + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/); + if (withFallback) { + return { sourceVar: withFallback[1], fallback: withFallback[2] }; + } + const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + if (noFallback) { + return { sourceVar: noFallback[1], fallback: '' }; + } + return null; +} + +function isSecretLike(name: string) { + return /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i.test(name); +} + +export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { + const { theme } = useUnistyles(); + 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) => { + const parsed = parseTemplateValue(envVar.value); + if (parsed?.sourceVar) { + // Never fetch secret-like values into UI memory. + if (isSecretLike(envVar.name) || isSecretLike(parsed.sourceVar)) return; + refs.add(parsed.sourceVar); + } + }); + return Array.from(refs); + }, [envVarEntries]); + + const { variables: machineEnv } = useEnvironmentVariables(props.machineId, refsToQuery); + + const title = props.profileName ? `Env Vars · ${props.profileName}` : 'Environment Variables'; + const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); + + return ( + + + + {title} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + + These environment variables are sent when starting the session. Values are resolved using the daemon on{' '} + {props.machineName ? ( + + {props.machineName} + + ) : ( + 'the selected machine' + )} + . + + + + {envVarEntries.length === 0 ? ( + + + No environment variables are set for this profile. + + + ) : ( + + {envVarEntries.map((envVar, idx) => { + const parsed = parseTemplateValue(envVar.value); + const secret = isSecretLike(envVar.name) || (parsed?.sourceVar ? isSecretLike(parsed.sourceVar) : false); + + const hasMachineContext = Boolean(props.machineId); + const resolvedValue = parsed?.sourceVar ? machineEnv[parsed.sourceVar] : undefined; + const isMachineBased = Boolean(parsed?.sourceVar); + + let displayValue: string; + if (secret) { + displayValue = '•••'; + } else if (parsed) { + if (!hasMachineContext) { + displayValue = `\${${parsed.sourceVar}${parsed.fallback ? `:-${parsed.fallback}` : ''}}`; + } else if (resolvedValue === undefined) { + displayValue = `\${${parsed.sourceVar}${parsed.fallback ? `:-${parsed.fallback}` : ''}} (checking…)`; + } else if (resolvedValue === null || resolvedValue === '') { + displayValue = parsed.fallback ? parsed.fallback : '(empty)'; + } else { + displayValue = resolvedValue; + } + } else { + displayValue = envVar.value || '(empty)'; + } + + const detailLabel = (() => { + if (secret) return undefined; + if (!isMachineBased) return 'Fixed'; + if (!hasMachineContext) return 'Machine'; + if (resolvedValue === undefined) return 'Checking'; + if (resolvedValue === null || resolvedValue === '') return parsed?.fallback ? 'Fallback' : 'Missing'; + return 'Machine'; + })(); + + const detailColor = (() => { + if (!detailLabel) return theme.colors.textSecondary; + if (detailLabel === 'Machine') return theme.colors.status.connected; + if (detailLabel === 'Fallback' || detailLabel === 'Missing') return theme.colors.warning; + return theme.colors.textSecondary; + })(); + + const rightElement = (() => { + if (secret) return undefined; + if (!isMachineBased) return undefined; + if (!hasMachineContext || detailLabel === 'Checking') { + return ; + } + return ; + })(); + + return ( + + ); + })} + + )} + + + ); +} diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx new file mode 100644 index 000000000..402637f3e --- /dev/null +++ b/sources/components/newSession/MachineSelector.tsx @@ -0,0 +1,105 @@ +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'; + +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 = 'Type to filter machines...', + recentSectionTitle = 'Recent Machines', + favoritesSectionTitle = 'Favorite Machines', + allSectionTitle = 'All Machines', + noItemsMessage = 'No machines available', +}: MachineSelectorProps) { + const { theme } = useUnistyles(); + + 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 ? '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, + 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..6ab44d019 --- /dev/null +++ b/sources/components/newSession/PathSelector.tsx @@ -0,0 +1,456 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, Pressable } 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 { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; + +export interface PathSelectorProps { + machineHomeDir: string; + selectedPath: string; + onChangeSelectedPath: (path: string) => void; + recentPaths: string[]; + usePickerSearch: boolean; + searchVariant?: 'header' | 'group' | 'none'; + searchQuery?: string; + onChangeSearchQuery?: (text: string) => void; + favoriteDirectories: string[]; + onChangeFavoriteDirectories: (dirs: string[]) => void; +} + +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, + }, +})); + +const ITEM_RIGHT_GAP = 16; + +export function PathSelector({ + machineHomeDir, + selectedPath, + onChangeSelectedPath, + recentPaths, + usePickerSearch, + searchVariant = 'header', + searchQuery: controlledSearchQuery, + onChangeSearchQuery: onChangeSearchQueryProp, + favoriteDirectories, + onChangeFavoriteDirectories, +}: PathSelectorProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const inputRef = useRef(null); + const searchInputRef = useRef(null); + const searchWasFocusedRef = useRef(false); + + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); + const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; + const setSearchQuery = onChangeSearchQueryProp ?? setUncontrolledSearchQuery; + + 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 setPathAndFocus = React.useCallback((path: string) => { + onChangeSelectedPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }, [onChangeSelectedPath]); + + const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + + ); + }, [theme.colors.button.primary.background, theme.colors.textSecondary, toggleFavorite]); + + return ( + <> + {usePickerSearch && searchVariant === 'header' && ( + + )} + + + + + + + + + + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( + + {effectiveGroupSearchPlacement === 'recent' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} + /> + )} + {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={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} + /> + )} + {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={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} + /> + )} + {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={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} + /> + + + )} + + ); +} diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx new file mode 100644 index 000000000..dfaac7f8a --- /dev/null +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Text, View, type ViewStyle } from 'react-native'; +import { 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; +}; + +export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { + const { theme } = useUnistyles(); + 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/tools/ToolView.tsx b/sources/components/tools/ToolView.tsx index 98e42d879..15b8c8567 100644 --- a/sources/components/tools/ToolView.tsx +++ b/sources/components/tools/ToolView.tsx @@ -50,6 +50,14 @@ export const ToolView = React.memo((props) => { let icon = ; let noStatus = false; let hideDefaultError = false; + + // For Gemini: unknown tools should be rendered as minimal (hidden) + // This prevents showing raw INPUT/OUTPUT for internal Gemini tools + // that we haven't explicitly added to knownTools + const isGemini = props.metadata?.flavor === 'gemini'; + if (!knownTool && isGemini) { + minimal = true; + } // Extract status first to potentially use as title if (knownTool && typeof knownTool.extractStatus === 'function') { diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 0405806e3..696f8315e 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -180,6 +180,11 @@ export const knownTools = { const path = resolvePath(opts.tool.input.file_path, opts.metadata); 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; + } return t('tools.names.readFile'); }, minimal: true, @@ -187,7 +192,10 @@ export const knownTools = { input: z.object({ file_path: z.string().describe('The absolute path to the file to read'), limit: z.number().optional().describe('The number of lines to read'), - offset: z.number().optional().describe('The line number to start reading from') + offset: z.number().optional().describe('The line number to start reading from'), + // Gemini format + items: z.array(z.any()).optional(), + locations: z.array(z.object({ path: z.string() }).loose()).optional() }).partial().loose(), result: z.object({ file: z.object({ @@ -199,6 +207,28 @@ export const knownTools = { }).loose().optional() }).partial().loose() }, + // Gemini uses lowercase 'read' + '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 (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + return path; + } + return t('tools.names.readFile'); + }, + minimal: true, + icon: ICON_READ, + input: z.object({ + items: z.array(z.any()).optional(), + locations: z.array(z.object({ path: z.string() }).loose()).optional(), + file_path: z.string().optional() + }).partial().loose() + }, 'Edit': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { if (typeof opts.tool.input.file_path === 'string') { @@ -510,6 +540,150 @@ export const knownTools = { return t('tools.names.reasoning'); } }, + 'GeminiReasoning': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().describe('The title of the reasoning') + }).partial().loose(), + result: z.object({ + content: z.string().describe('The reasoning content'), + status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status of the reasoning') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, + 'think': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().optional().describe('The title of the thinking'), + items: z.array(z.any()).optional().describe('Items to think about'), + locations: z.array(z.any()).optional().describe('Locations to consider') + }).partial().loose(), + result: z.object({ + content: z.string().optional().describe('The reasoning content'), + text: z.string().optional().describe('The reasoning text'), + status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, + 'change_title': { + title: 'Change Title', + icon: ICON_EDIT, + minimal: true, + noStatus: true, + input: z.object({ + title: z.string().optional().describe('New session title') + }).partial().loose(), + result: z.object({}).partial().loose() + }, + // Gemini internal tools - should be hidden (minimal) + 'search': { + title: t('tools.names.search'), + icon: ICON_SEARCH, + minimal: true, + input: z.object({ + items: z.array(z.any()).optional(), + locations: z.array(z.any()).optional() + }).partial().loose() + }, + 'edit': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Gemini sends data in nested structure, try multiple locations + let filePath: string | undefined; + + // 1. Check toolCall.content[0].path + if (opts.tool.input?.toolCall?.content?.[0]?.path) { + filePath = opts.tool.input.toolCall.content[0].path; + } + // 2. Check toolCall.title (has nice "Writing to ..." format) + else if (opts.tool.input?.toolCall?.title) { + 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) { + filePath = opts.tool.input.input[0].path; + } + // 4. Check direct path field + else if (typeof opts.tool.input?.path === 'string') { + filePath = opts.tool.input.path; + } + + if (filePath) { + return resolvePath(filePath, opts.metadata); + } + return t('tools.names.editFile'); + }, + icon: ICON_EDIT, + isMutable: true, + input: z.object({ + path: z.string().describe('The file path to edit'), + oldText: z.string().describe('The text to replace'), + newText: z.string().describe('The new text'), + type: z.string().optional().describe('Type of edit (diff)') + }).partial().loose() + }, + 'shell': { + title: t('tools.names.terminal'), + icon: ICON_TERMINAL, + minimal: true, + isMutable: true, + input: z.object({}).partial().loose() + }, + 'execute': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Gemini sends nice title in toolCall.title + if (opts.tool.input?.toolCall?.title) { + // Title is like "rm file.txt [cwd /path] (description)" + // Extract just the command part before [ + const fullTitle = opts.tool.input.toolCall.title; + const bracketIdx = fullTitle.indexOf(' ['); + if (bracketIdx > 0) { + return fullTitle.substring(0, bracketIdx); + } + return fullTitle; + } + return t('tools.names.terminal'); + }, + icon: ICON_TERMINAL, + isMutable: true, + 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) { + const title = opts.tool.input.toolCall.title; + const parenMatch = title.match(/\(([^)]+)\)$/); + if (parenMatch) { + return parenMatch[1]; + } + } + return null; + } + }, 'CodexPatch': { title: t('tools.names.applyChanges'), icon: ICON_EDIT, @@ -564,6 +738,83 @@ export const knownTools = { return t('tools.names.applyChanges'); } }, + 'GeminiBash': { + title: t('tools.names.terminal'), + icon: ICON_TERMINAL, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + command: z.array(z.string()).describe('The command array to execute'), + cwd: z.string().optional().describe('Current working directory') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) { + let cmdArray = opts.tool.input.command; + // Remove shell wrapper prefix if present (bash/zsh with -lc flag) + if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') { + return cmdArray[2]; + } + return cmdArray.join(' '); + } + return null; + } + }, + 'GeminiPatch': { + title: t('tools.names.applyChanges'), + icon: ICON_EDIT, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'), + changes: z.record(z.string(), z.object({ + add: z.object({ + content: z.string() + }).optional(), + modify: z.object({ + old_content: z.string(), + new_content: z.string() + }).optional(), + delete: z.object({ + content: z.string() + }).optional() + }).loose()).describe('File changes to apply') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the first file being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + if (files.length > 0) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + if (files.length > 1) { + return t('tools.desc.modifyingMultipleFiles', { + file: fileName, + count: files.length - 1 + }); + } + return fileName; + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the number of files being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + const fileCount = files.length; + if (fileCount === 1) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + return t('tools.desc.modifyingFile', { file: fileName }); + } else if (fileCount > 1) { + return t('tools.desc.modifyingFiles', { count: fileCount }); + } + } + return t('tools.names.applyChanges'); + } + }, 'CodexDiff': { title: t('tools.names.viewDiff'), icon: ICON_EDIT, @@ -594,6 +845,43 @@ export const knownTools = { return t('tools.desc.showingDiff'); } }, + 'GeminiDiff': { + title: t('tools.names.viewDiff'), + icon: ICON_EDIT, + minimal: false, // Show full diff view + hideDefaultError: true, + noStatus: true, // Always successful, stateless like Task + input: z.object({ + unified_diff: z.string().optional().describe('Unified diff content'), + filePath: z.string().optional().describe('File path'), + description: z.string().optional().describe('Edit description') + }).partial().loose(), + result: z.object({ + status: z.literal('completed').describe('Always completed') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Try to extract filename from filePath first + if (opts.tool.input?.filePath && typeof opts.tool.input.filePath === 'string') { + const basename = opts.tool.input.filePath.split('/').pop() || opts.tool.input.filePath; + return basename; + } + // Fall back to extracting from unified diff + if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') { + const diffLines = opts.tool.input.unified_diff.split('\n'); + for (const line of diffLines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + const fileName = line.replace(/^\+\+\+ (b\/)?/, ''); + const basename = fileName.split('/').pop() || fileName; + return basename; + } + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + return t('tools.desc.showingDiff'); + } + }, 'AskUserQuestion': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Use first question header as title if available diff --git a/sources/components/tools/views/GeminiEditView.tsx b/sources/components/tools/views/GeminiEditView.tsx new file mode 100644 index 000000000..9a4255634 --- /dev/null +++ b/sources/components/tools/views/GeminiEditView.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { ToolSectionView } from '../../tools/ToolSectionView'; +import { ToolViewProps } from './_all'; +import { ToolDiffView } from '@/components/tools/ToolDiffView'; +import { trimIdent } from '@/utils/trimIdent'; +import { useSetting } from '@/sync/storage'; + +/** + * Extract edit content from Gemini's nested input format. + * + * Gemini sends data in nested structure: + * - tool.input.toolCall.content[0] + * - tool.input.input[0] + * - tool.input (direct fields) + */ +function extractEditContent(input: any): { oldText: string; newText: string; path: string } { + // Try various locations where Gemini might put the edit data + + // 1. Check tool.input.toolCall.content[0] + if (input?.toolCall?.content?.[0]) { + const content = input.toolCall.content[0]; + return { + oldText: content.oldText || '', + newText: content.newText || '', + path: content.path || '' + }; + } + + // 2. Check tool.input.input[0] (array format) + if (Array.isArray(input?.input) && input.input[0]) { + const content = input.input[0]; + return { + oldText: content.oldText || '', + newText: content.newText || '', + path: content.path || '' + }; + } + + // 3. Check direct fields (simple format) + return { + oldText: input?.oldText || input?.old_string || '', + newText: input?.newText || input?.new_string || '', + path: input?.path || input?.file_path || '' + }; +} + +/** + * Gemini Edit View + * + * Handles Gemini's edit tool format which uses: + * - oldText (instead of old_string) + * - newText (instead of new_string) + * - path (instead of file_path) + */ +export const GeminiEditView = React.memo(({ tool }) => { + const showLineNumbersInToolViews = useSetting('showLineNumbersInToolViews'); + + const { oldText, newText } = extractEditContent(tool.input); + const oldString = trimIdent(oldText); + const newString = trimIdent(newText); + + return ( + <> + + + + + ); +}); + diff --git a/sources/components/tools/views/GeminiExecuteView.tsx b/sources/components/tools/views/GeminiExecuteView.tsx new file mode 100644 index 000000000..86fe20e84 --- /dev/null +++ b/sources/components/tools/views/GeminiExecuteView.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { ToolSectionView } from '../../tools/ToolSectionView'; +import { ToolViewProps } from './_all'; +import { CodeView } from '@/components/CodeView'; + +/** + * Extract execute command info from Gemini's nested input format. + */ +function extractExecuteInfo(input: any): { command: string; description: string; cwd: string } { + let command = ''; + let description = ''; + let cwd = ''; + + // Try to get title from toolCall.title + // Format: "rm file.txt [current working directory /path] (description)" + if (input?.toolCall?.title) { + const fullTitle = input.toolCall.title; + + // Extract command (before [) + const bracketIdx = fullTitle.indexOf(' ['); + if (bracketIdx > 0) { + command = fullTitle.substring(0, bracketIdx); + } else { + command = fullTitle; + } + + // Extract cwd from [current working directory /path] + const cwdMatch = fullTitle.match(/\[current working directory ([^\]]+)\]/); + if (cwdMatch) { + cwd = cwdMatch[1]; + } + + // Extract description from (...) + const descMatch = fullTitle.match(/\(([^)]+)\)$/); + if (descMatch) { + description = descMatch[1]; + } + } + + return { command, description, cwd }; +} + +/** + * Gemini Execute View + * + * Displays shell/terminal commands from Gemini's execute tool. + */ +export const GeminiExecuteView = React.memo(({ tool }) => { + const { command, description, cwd } = extractExecuteInfo(tool.input); + + if (!command) { + return null; + } + + return ( + <> + + + + {(description || cwd) && ( + + {cwd && ( + 📁 {cwd} + )} + {description && ( + {description} + )} + + )} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + infoContainer: { + paddingHorizontal: 12, + paddingBottom: 8, + }, + cwdText: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: 4, + }, + descriptionText: { + fontSize: 13, + color: theme.colors.textSecondary, + fontStyle: 'italic', + }, +})); + diff --git a/sources/components/tools/views/_all.tsx b/sources/components/tools/views/_all.tsx index 64190c0d1..fe087a377 100644 --- a/sources/components/tools/views/_all.tsx +++ b/sources/components/tools/views/_all.tsx @@ -15,6 +15,8 @@ import { CodexBashView } from './CodexBashView'; import { CodexPatchView } from './CodexPatchView'; import { CodexDiffView } from './CodexDiffView'; import { AskUserQuestionView } from './AskUserQuestionView'; +import { GeminiEditView } from './GeminiEditView'; +import { GeminiExecuteView } from './GeminiExecuteView'; export type ToolViewProps = { tool: ToolCall; @@ -39,7 +41,10 @@ export const toolViewRegistry: Record = { exit_plan_mode: ExitPlanToolView, MultiEdit: MultiEditView, Task: TaskView, - AskUserQuestion: AskUserQuestionView + AskUserQuestion: AskUserQuestionView, + // Gemini tools (lowercase) + edit: GeminiEditView, + execute: GeminiExecuteView, }; export const toolFullViewRegistry: Record = { @@ -71,3 +76,5 @@ export { ExitPlanToolView } from './ExitPlanToolView'; export { MultiEditView } from './MultiEditView'; export { TaskView } from './TaskView'; export { AskUserQuestionView } from './AskUserQuestionView'; +export { GeminiEditView } from './GeminiEditView'; +export { GeminiExecuteView } from './GeminiExecuteView'; 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..97f8f1cb0 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -52,7 +52,9 @@ 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); + if (__DEV__) { + console.log('[useCLIDetection] Starting detection for machineId:', machineId); + } try { // Use single bash command to check both CLIs efficiently @@ -66,7 +68,9 @@ 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 }); + if (__DEV__) { + console.log('[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 +84,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { } }); - console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + if (__DEV__) { + console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + } setAvailability({ claude: cliStatus.claude ?? null, codex: cliStatus.codex ?? null, @@ -90,7 +96,9 @@ 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); + if (__DEV__) { + console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); + } setAvailability({ claude: null, codex: null, @@ -104,7 +112,9 @@ 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); + if (__DEV__) { + console.log('[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..a012c46af 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -69,12 +69,37 @@ export function useEnvironmentVariables( 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(' && '); + // 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, "'\\''")}' ${validVarNames.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 ${validVarNames.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 { const result = await machineBash(machineId, command, '/'); @@ -82,16 +107,40 @@ export function useEnvironmentVariables( 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; + validVarNames.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 => { diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx index 48ff2ab08..8ff4ab56f 100644 --- a/sources/modal/components/BaseModal.tsx +++ b/sources/modal/components/BaseModal.tsx @@ -9,6 +9,13 @@ import { Platform } from 'react-native'; +// 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; onClose?: () => void; @@ -57,9 +64,10 @@ export function BaseModal({ animationType={animationType} onRequestClose={onClose} > - { + it('does not serialize full profile JSON into profile-edit URL params', () => { + const file = join(process.cwd(), 'sources/app/(app)/new/pick/profile.tsx'); + const content = readFileSync(file, 'utf8'); + + expect(content).not.toContain('profileData='); + expect(content).not.toContain('encodeURIComponent(profileData)'); + expect(content).not.toContain('JSON.stringify(profile)'); + }); + + it('consumes returned profileId param from profile-edit to auto-select and close', () => { + const file = join(process.cwd(), 'sources/app/(app)/new/pick/profile.tsx'); + const content = readFileSync(file, 'utf8'); + + // When profile-edit navigates back, it returns selection via navigation params. + // The picker must read that param and forward it back to /new. + expect(content).toMatch(/profileId\?:/); + expect(content).toContain('setProfileParamAndClose'); + expect(content).toContain('params.profileId'); + }); +}); 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..a7f11a90e --- /dev/null +++ b/sources/sync/messageMeta.test.ts @@ -0,0 +1,30 @@ +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); + }); +}); diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts new file mode 100644 index 000000000..ab3c0b61a --- /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 ? { displayText: params.displayText } : {}), + ...(params.model !== undefined ? { model: params.model } : {}), + ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}), + }; +} diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index 07f70e694..510cfe104 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: @@ -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) { diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts new file mode 100644 index 000000000..c42e082d9 --- /dev/null +++ b/sources/sync/permissionTypes.ts @@ -0,0 +1,24 @@ +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'; diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 2f9367523..4baef71a7 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -3,7 +3,7 @@ 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 { PermissionMode, ModelMode } from '@/sync/permissionTypes'; const mmkv = new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; @@ -15,8 +15,10 @@ 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 +28,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,12 +142,16 @@ 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) : 'default'; + const modelMode: ModelMode = typeof parsed.modelMode === 'string' + ? (parsed.modelMode as ModelMode) + : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(); @@ -152,8 +159,10 @@ export function loadNewSessionDraft(): NewSessionDraft | null { input, selectedMachineId, selectedPath, + selectedProfileId, agentType, permissionMode, + modelMode, sessionType, updatedAt, }; @@ -225,4 +234,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.ts b/sources/sync/profileGrouping.ts new file mode 100644 index 000000000..59b0eb551 --- /dev/null +++ b/sources/sync/profileGrouping.ts @@ -0,0 +1,46 @@ +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 buildProfileGroups({ + customProfiles, + favoriteProfileIds, +}: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; +}): ProfileGroups { + const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id)); + const favoriteIds = new Set(favoriteProfileIds); + + 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 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..2f7ab69be --- /dev/null +++ b/sources/sync/profileMutations.ts @@ -0,0 +1,36 @@ +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): AIBackendProfile { + return { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + 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.ts b/sources/sync/profileUtils.ts index d90a98a93..aed7c68ae 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -1,5 +1,18 @@ import { AIBackendProfile } from './settings'; +export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none'; + +export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { + if (!profile) return 'none'; + const supported = Object.entries(profile.compatibility ?? {}) + .filter(([, isSupported]) => isSupported) + .map(([cli]) => cli as 'claude' | 'codex' | 'gemini'); + + if (supported.length === 0) return 'none'; + if (supported.length === 1) return supported[0]; + return 'multi'; +} + /** * Documentation and expected values for built-in profiles. * These help users understand what environment variables to set and their expected values. @@ -242,7 +255,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 +268,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 +293,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 +317,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 +335,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/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..ce7e3669e 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -4,72 +4,6 @@ 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(), @@ -97,18 +31,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 +65,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 +137,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 +152,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,34 +164,6 @@ 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 @@ -249,6 +201,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 +217,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 +247,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 +293,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 +315,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 +336,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 f1fe413f6..8c76be584 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -12,7 +12,7 @@ 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 type { PermissionMode } from '@/sync/permissionTypes'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; import { sync } from "./sync"; @@ -102,6 +102,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; @@ -117,7 +118,7 @@ interface StorageState { getActiveSessions: () => Session[]; updateSessionDraft: (sessionId: string, draft: string | null) => void; updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => void; - updateSessionModelMode: (sessionId: string, mode: 'default') => void; + updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => void; // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => void; addArtifact: (artifact: DecryptedArtifact) => void; @@ -366,8 +367,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 +383,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 +392,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 +618,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 +629,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); @@ -808,7 +805,7 @@ export const storage = create()((set, get) => { sessions: updatedSessions }; }), - updateSessionModelMode: (sessionId: string, mode: 'default') => set((state) => { + updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => set((state) => { const session = state.sessions[sessionId]; if (!session) return state; @@ -871,12 +868,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, diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bff187d04..f57b41c41 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() @@ -70,7 +71,7 @@ export interface Session { }>; 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' | null; // Local model mode, not synced to server (models configured in CLI) + modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // 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. diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index fde7d5b02..e3b5c0df5 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. @@ -223,9 +224,13 @@ class Sync { return; } - // Read permission mode and model mode from session state + // Read permission mode from session state const permissionMode = session.permissionMode || 'default'; - const modelMode = session.modelMode || 'default'; + + // Read model mode - for Gemini, default to gemini-2.5-pro if not set + const flavor = session.metadata?.flavor; + const isGemini = flavor === 'gemini'; + const modelMode = session.modelMode || (isGemini ? 'gemini-2.5-pro' : 'default'); // Generate local ID const localId = randomUUID(); @@ -247,10 +252,7 @@ class Sync { sentFrom = 'web'; // fallback } - // Model settings - models are configured in CLI settings - const model: string | null = null; - const fallbackModel: string | null = null; - + const model = isGemini && modelMode !== 'default' ? modelMode : undefined; // Create user message content with metadata const content: RawRecord = { role: 'user', @@ -258,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); @@ -835,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: { @@ -850,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; @@ -1173,7 +1172,7 @@ class Sync { const mergedSettings = applySettings(serverSettings, this.pendingSettings); // Update local storage with merged result at server's version - storage.getState().applySettings(mergedSettings, data.currentVersion); + storage.getState().replaceSettings(mergedSettings, data.currentVersion); // Sync tracking state with merged settings if (tracking) { @@ -1181,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 { @@ -1222,14 +1216,8 @@ 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); + storage.getState().replaceSettings(parsedSettings, data.settingsVersion); // Sync PostHog opt-out state with settings if (tracking) { @@ -1259,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); } @@ -1306,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) { @@ -1325,7 +1302,7 @@ class Sync { }); } } catch (error) { - console.log('[fetchNativeUpdate] Error:', error); + console.error('[fetchNativeUpdate] Error:', error); storage.getState().applyNativeUpdateStatus(null); } } @@ -1346,7 +1323,6 @@ class Sync { } if (!apiKey) { - console.log(`RevenueCat: No API key found for platform ${Platform.OS}`); return; } @@ -1363,7 +1339,6 @@ class Sync { }); this.revenueCatInitialized = true; - console.log('RevenueCat initialized successfully'); } // Sync purchases @@ -1430,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); @@ -1459,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; } @@ -1507,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') { @@ -1534,13 +1503,38 @@ class Sync { if (decrypted) { lastMessage = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content); + // Check for task lifecycle events to update thinking state + // This ensures UI updates even if volatile activity updates are lost + const rawContent = decrypted.content as { role?: string; content?: { type?: string; data?: { type?: string } } } | null; + const contentType = rawContent?.content?.type; + const dataType = rawContent?.content?.data?.type; + + // Debug logging to trace lifecycle events + if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { + console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`); + } + + const isTaskComplete = + ((contentType === 'acp' || contentType === 'codex') && + (dataType === 'task_complete' || dataType === 'turn_aborted')); + + const isTaskStarted = + ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'); + + if (isTaskComplete || isTaskStarted) { + console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`); + } + // Update session const session = storage.getState().sessions[updateData.body.sid]; if (session) { this.applySessions([{ ...session, updatedAt: updateData.createdAt, - seq: updateData.seq + seq: updateData.seq, + // Update thinking state based on task lifecycle events + ...(isTaskComplete ? { thinking: false } : {}), + ...(isTaskStarted ? { thinking: true } : {}) }]) } else { // Fetch sessions again if we don't have this session @@ -1549,7 +1543,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') { @@ -1935,7 +1928,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`); } @@ -1944,17 +1936,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.ts b/sources/sync/typesRaw.ts index 4dde5e855..7921435b2 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(), @@ -206,6 +208,68 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ id: z.string() }) ]) +}), z.object({ + // ACP (Agent Communication Protocol) - unified format for all agent providers + type: z.literal('acp'), + provider: z.enum(['gemini', 'codex', 'claude', 'opencode']), + data: z.discriminatedUnion('type', [ + // Core message types + z.object({ type: z.literal('reasoning'), message: z.string() }), + z.object({ type: z.literal('message'), message: z.string() }), + z.object({ type: z.literal('thinking'), text: z.string() }), + // Tool interactions + z.object({ + type: z.literal('tool-call'), + callId: z.string(), + input: z.any(), + name: z.string(), + id: z.string() + }), + z.object({ + type: z.literal('tool-result'), + callId: z.string(), + output: z.any(), + id: z.string(), + isError: z.boolean().optional() + }), + // Hyphenated tool-call-result (for backwards compatibility with CLI) + z.object({ + type: z.literal('tool-call-result'), + callId: z.string(), + output: z.any(), + id: z.string() + }), + // File operations + z.object({ + type: z.literal('file-edit'), + description: z.string(), + filePath: z.string(), + diff: z.string().optional(), + oldContent: z.string().optional(), + newContent: z.string().optional(), + id: z.string() + }), + // Terminal/command output + z.object({ + type: z.literal('terminal-output'), + data: z.string(), + callId: z.string() + }), + // Task lifecycle events + z.object({ type: z.literal('task_started'), id: z.string() }), + z.object({ type: z.literal('task_complete'), id: z.string() }), + z.object({ type: z.literal('turn_aborted'), id: z.string() }), + // Permissions + z.object({ + type: z.literal('permission-request'), + permissionId: z.string(), + toolName: z.string(), + description: z.string(), + options: z.any().optional() + }), + // Usage/metrics + z.object({ type: z.literal('token_count') }).passthrough() + ]) })]); /** @@ -340,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, @@ -463,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, @@ -559,6 +657,97 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } if (raw.content.data.type === 'tool-call-result') { // Cast tool call results to agent tool-result messages + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: toolResultContentToText(raw.content.data.output), + is_error: false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + } + // ACP (Agent Communication Protocol) - unified format for all agent providers + if (raw.content.type === 'acp') { + if (raw.content.data.type === 'message') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'reasoning') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-call') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.callId, + name: raw.content.data.name || 'unknown', + input: raw.content.data.input, + description: null, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-result') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: raw.content.data.output, + is_error: raw.content.data.isError ?? false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + // Handle hyphenated tool-call-result (backwards compatibility) + if (raw.content.data.type === 'tool-call-result') { return { id, localId, @@ -576,6 +765,89 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA meta: raw.meta } satisfies NormalizedMessage; } + if (raw.content.data.type === 'thinking') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'thinking', + thinking: raw.content.data.text, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'file-edit') { + // Map file-edit to tool-call for UI rendering + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.id, + name: 'file-edit', + input: { + filePath: raw.content.data.filePath, + description: raw.content.data.description, + diff: raw.content.data.diff, + oldContent: raw.content.data.oldContent, + newContent: raw.content.data.newContent + }, + description: raw.content.data.description, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'terminal-output') { + // Map terminal-output to tool-result + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: raw.content.data.data, + is_error: false, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'permission-request') { + // Map permission-request to tool-call for UI to show permission dialog + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.permissionId, + name: raw.content.data.toolName, + input: raw.content.data.options ?? {}, + description: raw.content.data.description, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + // Task lifecycle events (task_started, task_complete, turn_aborted) and token_count + // are status/metrics - skip normalization, they don't need UI rendering } } return null; diff --git a/sources/text/README.md b/sources/text/README.md index 09128f3ef..9c40002fc 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,9 @@ export const en = { } as const; ``` +### `_types.ts` +Contains the TypeScript types derived from the English translation structure. + ### `index.ts` Main module with the `t` function and utilities: - `t()` - Main translation function with strict typing @@ -164,7 +167,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', @@ -220,4 +223,4 @@ To add more languages: 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 66ed2cbee..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', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - 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 tell Claude what to do differently', - } - }, - - 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..4234daee3 --- /dev/null +++ b/sources/text/_types.ts @@ -0,0 +1,24 @@ +import { en } from './translations/en'; + +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/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 e27bdba63..4067d6861 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 @@ -208,6 +208,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: '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: { @@ -430,14 +439,14 @@ 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: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restant`, @@ -760,7 +769,7 @@ export const ca: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sí, permet totes les edicions durant aquesta sessió', yesForTool: 'Sí, no tornis a preguntar per aquesta eina', - noTellClaude: 'No, i digues a Claude què fer diferent', + noTellClaude: 'No, proporciona comentaris', } }, @@ -894,7 +903,7 @@ 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', delete: { title: 'Eliminar Perfil', diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 201e9c0ec..2ef8d1025 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', @@ -211,8 +209,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 +221,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: { @@ -447,12 +454,12 @@ export const en: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI PERMISSION MODE', default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', + readOnly: 'Read Only', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, @@ -775,7 +782,7 @@ export const en: TranslationStructure = { permissions: { yesAllowAllEdits: 'Yes, allow all edits during this session', yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and tell Claude what to do differently', + noTellClaude: 'No, and provide feedback', } }, @@ -918,7 +925,7 @@ 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', delete: { @@ -930,4 +937,4 @@ export const en: TranslationStructure = { } } as const; -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 387d726cc..3272ca282 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 @@ -208,6 +208,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: '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: { @@ -430,14 +439,14 @@ 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: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -760,7 +769,7 @@ export const es: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sí, permitir todas las ediciones durante esta sesión', yesForTool: 'Sí, no volver a preguntar para esta herramienta', - noTellClaude: 'No, y decirle a Claude qué hacer diferente', + noTellClaude: 'No, proporcionar comentarios', } }, @@ -903,7 +912,7 @@ 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', delete: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 4743fdd6e..c1c31981e 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 @@ -90,7 +90,7 @@ 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', delete: { @@ -237,6 +237,15 @@ export const it: TranslationStructure = { enhancedSessionWizard: 'Wizard sessione avanzato', enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo', enhancedSessionWizardDisabled: 'Usando avvio sessioni standard', + 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: { @@ -461,12 +470,12 @@ 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', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -789,7 +798,7 @@ export const it: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sì, consenti tutte le modifiche durante questa sessione', yesForTool: 'Sì, non chiedere più per questo strumento', - noTellClaude: 'No, e di a Claude cosa fare diversamente', + noTellClaude: 'No, fornisci feedback', } }, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index c8af39724..34d6d8047 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -5,7 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; +import type { TranslationStructure } from '../_types'; /** * Japanese plural helper function @@ -93,7 +93,7 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', delete: { @@ -240,6 +240,15 @@ export const ja: TranslationStructure = { enhancedSessionWizard: '拡張セッションウィザード', enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', enhancedSessionWizardDisabled: '標準セッションランチャーを使用', + 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: { @@ -464,12 +473,12 @@ 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', }, context: { remaining: ({ percent }: { percent: number }) => `残り ${percent}%`, @@ -792,7 +801,7 @@ export const ja: TranslationStructure = { permissions: { yesAllowAllEdits: 'はい、このセッション中のすべての編集を許可', yesForTool: "はい、このツールについては確認しない", - noTellClaude: 'いいえ、Claudeに別の方法を伝える', + noTellClaude: 'いいえ、フィードバックを提供', } }, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4d24cd147..436e9a4ee 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 @@ -219,6 +219,15 @@ export const pl: TranslationStructure = { enhancedSessionWizard: 'Ulepszony kreator sesji', enhancedSessionWizardEnabled: 'Aktywny launcher z profilem', enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji', + 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: { @@ -440,14 +449,14 @@ 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: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`, @@ -770,7 +779,7 @@ export const pl: TranslationStructure = { permissions: { yesAllowAllEdits: 'Tak, zezwól na wszystkie edycje podczas tej sesji', yesForTool: 'Tak, nie pytaj ponownie dla tego narzędzia', - noTellClaude: 'Nie, i powiedz Claude co zrobić inaczej', + noTellClaude: 'Nie, przekaż opinię', } }, @@ -926,7 +935,7 @@ 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', delete: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 7a8508f9b..080db2565 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 @@ -208,6 +208,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: '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: { @@ -430,14 +439,14 @@ 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: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -760,7 +769,7 @@ export const pt: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sim, permitir todas as edições durante esta sessão', yesForTool: 'Sim, não perguntar novamente para esta ferramenta', - noTellClaude: 'Não, e dizer ao Claude o que fazer diferente', + noTellClaude: 'Não, fornecer feedback', } }, @@ -894,7 +903,7 @@ 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', delete: { title: 'Excluir Perfil', diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 238ce60be..d42e4bb20 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 @@ -190,6 +190,15 @@ export const ru: TranslationStructure = { enhancedSessionWizard: 'Улучшенный мастер сессий', enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', + 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: { @@ -442,12 +451,12 @@ export const ru: TranslationStructure = { geminiPermissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', - acceptEdits: 'Принимать правки', - plan: 'Режим планирования', - bypassPermissions: 'YOLO режим', - badgeAcceptAllEdits: 'Принимать все правки', - badgeBypassAllPermissions: 'Обход всех разрешений', - badgePlanMode: 'Режим планирования', + readOnly: 'Только чтение', + safeYolo: 'Безопасный YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Только чтение', + badgeSafeYolo: 'Безопасный YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`, @@ -758,7 +767,7 @@ export const ru: TranslationStructure = { permissions: { yesAllowAllEdits: 'Да, разрешить все правки в этой сессии', yesForTool: 'Да, больше не спрашивать для этого инструмента', - noTellClaude: 'Нет, и сказать Claude что делать по-другому', + noTellClaude: 'Нет, дать обратную связь', } }, @@ -925,7 +934,7 @@ export const ru: TranslationStructure = { enterTmuxTempDir: 'Введите путь к временному каталогу', tmuxUpdateEnvironment: 'Обновлять окружение автоматически', nameRequired: 'Имя профиля обязательно', - deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`, editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', delete: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 630414316..fc33c96f9 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 @@ -210,6 +210,15 @@ export const zhHans: TranslationStructure = { enhancedSessionWizard: '增强会话向导', enhancedSessionWizardEnabled: '配置文件优先启动器已激活', enhancedSessionWizardDisabled: '使用标准会话启动器', + 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: { @@ -432,14 +441,14 @@ export const zhHans: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: '权限模式', + title: 'GEMINI 权限模式', default: '默认', - acceptEdits: '接受编辑', - plan: '计划模式', - bypassPermissions: 'Yolo 模式', - badgeAcceptAllEdits: '接受所有编辑', - badgeBypassAllPermissions: '绕过所有权限', - badgePlanMode: '计划模式', + readOnly: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`, @@ -762,7 +771,7 @@ export const zhHans: TranslationStructure = { permissions: { yesAllowAllEdits: '是,允许本次会话的所有编辑', yesForTool: '是,不再询问此工具', - noTellClaude: '否,并告诉 Claude 该如何不同地操作', + noTellClaude: '否,提供反馈', } }, @@ -896,7 +905,7 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', delete: { title: '删除配置', diff --git a/sources/theme.css b/sources/theme.css index 7e241b5ae..2c80b7222 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 - 48px)) !important; + max-height: min(820px, calc(100vh - 48px)) !important; + min-height: min(820px, calc(100vh - 48px)) !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 +}