From 2ba649e9fdf52ec7e4072f5fd84a6777b1fb1478 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 16:13:56 +0100 Subject: [PATCH 001/106] fix: upstream sync regressions (wizard, i18n, profiles, routing) - Fix NewSessionWizard runtime issues (expo-crypto UUID, TDZ, no setState-in-render) - Fix built-in profile duplication: ensure isBuiltIn=false + reset timestamps - Fix i18n drift: profiles.deleteConfirm is a function across languages; prevent en.ts drift via re-export - Fix expo-router typed routes for profile edit + settings/profiles; harden profileData parsing - Misc: remove unnecessary any; docs: CONTRIBUTING uses yarn --- CONTRIBUTING.md | 70 +- sources/app/(app)/new/index.tsx | 26 +- sources/app/(app)/new/pick/machine.tsx | 9 +- sources/app/(app)/new/pick/profile-edit.tsx | 8 +- sources/app/(app)/settings/profiles.tsx | 3 + sources/components/NewSessionWizard.tsx | 42 +- sources/components/SettingsView.tsx | 2 +- sources/sync/settings.ts | 4 +- sources/text/_default.ts | 2 +- sources/text/translations/ca.ts | 2 +- sources/text/translations/en.ts | 936 +------------------- sources/text/translations/es.ts | 2 +- sources/text/translations/it.ts | 2 +- sources/text/translations/ja.ts | 2 +- sources/text/translations/pl.ts | 2 +- sources/text/translations/pt.ts | 2 +- sources/text/translations/ru.ts | 2 +- sources/text/translations/zh-Hans.ts | 2 +- 18 files changed, 117 insertions(+), 1001 deletions(-) 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/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c8d76009f..c1af93ee9 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -770,14 +770,23 @@ function NewSessionWizard() { updatedAt: Date.now(), version: '1.0.0', }; - const profileData = encodeURIComponent(JSON.stringify(newProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); + const profileData = JSON.stringify(newProfile); + router.push({ + pathname: '/(app)/new/pick/profile-edit', + params: { profileData }, + }); }, [router]); 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}`); + const profileData = JSON.stringify(profile); + const params: { profileData: string; machineId?: string } = { profileData }; + if (selectedMachineId) { + params.machineId = selectedMachineId; + } + router.push({ + pathname: '/(app)/new/pick/profile-edit', + params, + }); }, [router, selectedMachineId]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { @@ -789,8 +798,11 @@ function NewSessionWizard() { createdAt: Date.now(), updatedAt: Date.now(), }; - const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); + const profileData = JSON.stringify(duplicatedProfile); + router.push({ + pathname: '/(app)/new/pick/profile-edit', + params: { profileData }, + }); }, [router]); // Helper to get meaningful subtitle text for profiles diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c02580e8d..e5c35236d 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -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, }); } } diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..a72f629d2 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -22,7 +22,13 @@ export default function ProfileEditScreen() { const profile: AIBackendProfile = React.useMemo(() => { if (params.profileData) { 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(params.profileData); + } catch { + return JSON.parse(decodeURIComponent(params.profileData)); + } } catch (error) { console.error('Failed to parse profile data:', error); } diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fa4522023..50150523b 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -124,6 +124,9 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr const newProfile: AIBackendProfile = { ...profile, id: randomUUID(), // Generate new UUID for custom profile + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), }; // Check for duplicate names (excluding the new profile) diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx index ea556c99f..a18e38157 100644 --- a/sources/components/NewSessionWizard.tsx +++ b/sources/components/NewSessionWizard.tsx @@ -14,6 +14,7 @@ import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariabl import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; import { profileSyncService } from '@/sync/profileSync'; +import { randomUUID } from 'expo-crypto'; const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -700,6 +701,16 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N const [profileApiKeys, setProfileApiKeys] = useState>>({}); const [profileConfigs, setProfileConfigs] = useState>>({}); + function 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); + } + // Dynamic steps based on whether profile needs configuration const steps: WizardStep[] = React.useMemo(() => { const baseSteps: WizardStep[] = experimentsEnabled @@ -719,18 +730,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N } 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); - }; + }, [experimentsEnabled, selectedProfileId, allProfiles]); // Get required fields for profile configuration const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { @@ -870,6 +870,17 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; + React.useEffect(() => { + // Guard: if the user changes profiles such that profileConfig is no longer required, + // advance to the next step (or reset to the first step if currentStep is invalid). + if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { + const nextStep = steps[currentStepIndex + 1] ?? steps[0] ?? 'profile'; + if (nextStep !== currentStep) { + setCurrentStep(nextStep); + } + } + }, [currentStep, currentStepIndex, selectedProfileId, steps]); + // Handler for "Use Profile As-Is" - quick session creation const handleUseProfileAsIs = (profile: AIBackendProfile) => { setSelectedProfileId(profile.id); @@ -932,7 +943,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N ).then((profileName) => { if (profileName && profileName.trim()) { const newProfile: AIBackendProfile = { - id: crypto.randomUUID(), + id: randomUUID(), name: profileName.trim(), description: 'Custom AI profile', anthropicConfig: {}, @@ -976,7 +987,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N if (newName && newName.trim()) { const duplicatedProfile: AIBackendProfile = { ...profile, - id: crypto.randomUUID(), + id: randomUUID(), name: newName.trim(), description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', isBuiltIn: false, @@ -1287,8 +1298,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N case 'profileConfig': if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // Skip configuration if no profile selected or profile doesn't need configuration - setCurrentStep(steps[currentStepIndex + 1]); + // No profile configuration needed; navigation effect will auto-advance. return null; } diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 249345e97..70e085e0c 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -326,7 +326,7 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.profiles')} subtitle={t('settings.profilesSubtitle')} icon={} - onPress={() => router.push('/settings/profiles')} + onPress={() => router.push('/(app)/settings/profiles')} /> {experiments && ( `Are you sure you want to delete the profile "${name}"?`, editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', delete: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e27bdba63..5138685c6 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -894,7 +894,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..b3afb1bea 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -1,933 +1,17 @@ -import type { TranslationStructure } from '../_default'; +import { en as defaultEn, type TranslationStructure } from '../_default'; /** - * English plural helper function - * English has 2 plural forms: singular, plural - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on English plural rules - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - -/** - * 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. + * English translations (temporary re-export). * - * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects - * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) - * - Benefit: Better maintainability, smaller files, easier language management + * `_default.ts` is currently the canonical source of truth for the English + * translation structure and is used at runtime by `sources/text/index.ts`. * - * This file contains the complete English translation structure and serves as - * the reference implementation for all other language files. + * This file exists for the “dedicated translations per language file” migration + * and for tooling/scripts that import `text/translations/en`. * - * ARCHITECTURE NOTES: - * - All translation keys must match across all language files - * - Type safety enforced by TranslationStructure interface - * - New translation keys must be added to ALL language files + * Re-exporting prevents drift and ensures this file always matches + * `TranslationStructure` without duplicating the full object. */ -export const en: TranslationStructure = { - 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', - copy: 'Copy', - copied: 'Copied', - 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 messages', - enterToSendDisabled: 'Press ⌘+Enter to send messages', - 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: 'Failed to copy', - 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 const en: TranslationStructure = defaultEn; -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..477bae10f 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -903,7 +903,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..55224e47d 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -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: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index c8af39724..394090e9f 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -93,7 +93,7 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', delete: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4d24cd147..c4da73780 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -926,7 +926,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..d0d5b9b7e 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -894,7 +894,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..5ce577666 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -925,7 +925,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..4737d8a74 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -896,7 +896,7 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', delete: { title: '删除配置', From dbd5c59b9e675d2732a46bed7ed4c66f670a2607 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 16:54:26 +0100 Subject: [PATCH 002/106] refactor: normalize settings navigation routes - Use explicit /(app) prefixes for settings/dev/changelog routes for consistency (CodeRabbit feedback on PR #7) --- sources/components/SettingsView.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 70e085e0c..19f54b144 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -110,7 +110,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,25 +302,25 @@ 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('/(app)/settings/features')} /> } - onPress={() => router.push('/settings/usage')} + onPress={() => router.push('/(app)/settings/usage')} /> )} @@ -344,7 +344,7 @@ export const SettingsView = React.memo(function SettingsView() { } - onPress={() => router.push('/dev')} + onPress={() => router.push('/(app)/dev')} /> )} @@ -357,7 +357,7 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => { trackWhatsNewClicked(); - router.push('/changelog'); + router.push('/(app)/changelog'); }} /> Date: Mon, 12 Jan 2026 17:34:09 +0100 Subject: [PATCH 003/106] fix: follow modal guidelines for profile deletion - Replace direct React Native Alert usage with Modal.confirm - DRY URL/template-string validation helper used by provider schemas (CodeRabbit feedback on PR #7) --- sources/app/(app)/settings/profiles.tsx | 51 ++++++++------------ sources/sync/settings.ts | 64 ++++++++----------------- 2 files changed, 41 insertions(+), 74 deletions(-) diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 50150523b..38b807fbd 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { View, Text, Pressable, ScrollView } 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 { Modal } from '@/modal'; import { layout } from '@/components/layout'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useWindowDimensions } from 'react-native'; @@ -57,37 +57,26 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setShowAddForm(true); }; - const handleDeleteProfile = (profile: AIBackendProfile) => { - // Show confirmation dialog before deleting - Alert.alert( + 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) => { @@ -359,7 +348,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr handleDeleteProfile(profile)} + onPress={() => void handleDeleteProfile(profile)} style={{ marginLeft: 16 }} > diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 2491cb3c5..57a563958 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -6,61 +6,39 @@ import * as z from 'zod'; // Environment variable schemas for different AI providers // Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings +const URL_OR_TEMPLATE_REGEX = /^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/; +const URL_OR_TEMPLATE_ERROR = 'Must be a valid URL or ${VAR} or ${VAR:-default} template string'; + +function isUrlOrTemplateString(val: string): boolean { + if (!val) return true; // Optional or empty string + if (URL_OR_TEMPLATE_REGEX.test(val)) return true; + try { + new URL(val); + return true; + } catch { + return false; + } +} + +function urlOrTemplateStringOptional() { + return z.string().refine(isUrlOrTemplateString, { message: URL_OR_TEMPLATE_ERROR }).optional(); +} + 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(), + baseUrl: urlOrTemplateStringOptional(), 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(), + baseUrl: urlOrTemplateStringOptional(), 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(), + endpoint: urlOrTemplateStringOptional(), apiVersion: z.string().optional(), deploymentName: z.string().optional(), }); From 2e58f8a96eadc5cfea3147b36f0d72911cfd5419 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 17:45:52 +0100 Subject: [PATCH 004/106] fix: update profile timestamps + memoize profiles page - Refresh updatedAt when saving edits to existing custom profiles - Wrap settings profiles page component in React.memo per guidelines --- sources/app/(app)/settings/profiles.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 38b807fbd..d56a11839 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -27,8 +27,7 @@ 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 [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); @@ -143,7 +142,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]; @@ -402,7 +404,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr )} ); -} +}); // ProfileEditForm now imported from @/components/ProfileEditForm From 2a0316d9e5b1e49daece8a31ad396e3676daeaf0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 21:36:31 +0100 Subject: [PATCH 005/106] fix(router): align typedRoutes navigation with expo-router - Use typedRoutes-friendly push targets for settings + profile-edit navigation - Avoids TS errors without changing runtime routing --- sources/app/(app)/new/index.tsx | 19 +++++-------------- sources/app/(app)/new/pick/profile-edit.tsx | 2 +- sources/components/SettingsView.tsx | 6 +++--- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c1af93ee9..fd115776d 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -771,22 +771,16 @@ function NewSessionWizard() { version: '1.0.0', }; const profileData = JSON.stringify(newProfile); - router.push({ - pathname: '/(app)/new/pick/profile-edit', - params: { profileData }, - }); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router]); const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { const profileData = JSON.stringify(profile); - const params: { profileData: string; machineId?: string } = { profileData }; if (selectedMachineId) { - params.machineId = selectedMachineId; + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}&machineId=${encodeURIComponent(selectedMachineId)}` as any); + return; } - router.push({ - pathname: '/(app)/new/pick/profile-edit', - params, - }); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router, selectedMachineId]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { @@ -799,10 +793,7 @@ function NewSessionWizard() { updatedAt: Date.now(), }; const profileData = JSON.stringify(duplicatedProfile); - router.push({ - pathname: '/(app)/new/pick/profile-edit', - params: { profileData }, - }); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router]); // Helper to get meaningful subtitle text for profiles diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index a72f629d2..b6cbb91ac 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -27,7 +27,7 @@ export default function ProfileEditScreen() { try { return JSON.parse(params.profileData); } catch { - return JSON.parse(decodeURIComponent(params.profileData)); + return JSON.parse(decodeURIComponent(params.profileData)); } } catch (error) { console.error('Failed to parse profile data:', error); diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 19f54b144..37b1fc9e5 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -320,20 +320,20 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.featuresTitle')} subtitle={t('settings.featuresSubtitle')} icon={} - onPress={() => router.push('/(app)/settings/features')} + onPress={() => router.push('/settings/features')} /> } - onPress={() => router.push('/(app)/settings/profiles')} + onPress={() => router.push('/settings/profiles')} /> {experiments && ( } - onPress={() => router.push('/(app)/settings/usage')} + onPress={() => router.push('/settings/usage')} /> )} From ec4298259ddd88c8e2653782506675056e745f31 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 16:13:56 +0100 Subject: [PATCH 006/106] fix: upstream sync regressions (wizard, i18n, profiles, routing) - Fix NewSessionWizard runtime issues (expo-crypto UUID, TDZ, no setState-in-render) - Fix built-in profile duplication: ensure isBuiltIn=false + reset timestamps - Fix i18n drift: profiles.deleteConfirm is a function across languages; prevent en.ts drift via re-export - Fix expo-router typed routes for profile edit + settings/profiles; harden profileData parsing - Misc: remove unnecessary any; docs: CONTRIBUTING uses yarn --- CONTRIBUTING.md | 70 +- sources/app/(app)/new/index.tsx | 17 +- sources/app/(app)/new/pick/machine.tsx | 9 +- sources/app/(app)/new/pick/profile-edit.tsx | 6 + sources/app/(app)/settings/profiles.tsx | 62 +- sources/components/NewSessionWizard.tsx | 42 +- sources/components/SettingsView.tsx | 12 +- sources/sync/settings.ts | 68 +- sources/text/_default.ts | 2 +- sources/text/translations/ca.ts | 2 +- sources/text/translations/en.ts | 936 +------------------- sources/text/translations/es.ts | 2 +- sources/text/translations/it.ts | 2 +- sources/text/translations/ja.ts | 2 +- sources/text/translations/pl.ts | 2 +- sources/text/translations/pt.ts | 2 +- sources/text/translations/ru.ts | 2 +- sources/text/translations/zh-Hans.ts | 2 +- 18 files changed, 158 insertions(+), 1082 deletions(-) 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/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c8d76009f..fd115776d 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -770,14 +770,17 @@ function NewSessionWizard() { updatedAt: Date.now(), version: '1.0.0', }; - const profileData = encodeURIComponent(JSON.stringify(newProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); + const profileData = JSON.stringify(newProfile); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router]); 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}`); + const profileData = JSON.stringify(profile); + if (selectedMachineId) { + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}&machineId=${encodeURIComponent(selectedMachineId)}` as any); + return; + } + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router, selectedMachineId]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { @@ -789,8 +792,8 @@ function NewSessionWizard() { createdAt: Date.now(), updatedAt: Date.now(), }; - const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); + const profileData = JSON.stringify(duplicatedProfile); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router]); // Helper to get meaningful subtitle text for profiles diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c02580e8d..e5c35236d 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -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, }); } } diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..b6cbb91ac 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -22,7 +22,13 @@ export default function ProfileEditScreen() { const profile: AIBackendProfile = React.useMemo(() => { if (params.profileData) { try { + // 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(params.profileData); + } catch { return JSON.parse(decodeURIComponent(params.profileData)); + } } catch (error) { console.error('Failed to parse profile data:', error); } diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fa4522023..d56a11839 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { View, Text, Pressable, ScrollView } 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 { Modal } from '@/modal'; import { layout } from '@/components/layout'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useWindowDimensions } from 'react-native'; @@ -27,8 +27,7 @@ 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 [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); @@ -57,37 +56,26 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setShowAddForm(true); }; - const handleDeleteProfile = (profile: AIBackendProfile) => { - // Show confirmation dialog before deleting - Alert.alert( + 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); + { cancelText: t('profiles.delete.cancel'), confirmText: t('profiles.delete.confirm'), destructive: true } + ); + if (!confirmed) return; - // Clear last used profile if it was deleted - if (lastUsedProfile === profile.id) { - setLastUsedProfile(null); - } + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); - // Notify parent if this was the selected profile - if (selectedProfileId === profile.id && onProfileSelect) { - onProfileSelect(null); - } - }, - }, - ], - { cancelable: true } - ); + // 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) => { @@ -124,6 +112,9 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr const newProfile: AIBackendProfile = { ...profile, id: randomUUID(), // Generate new UUID for custom profile + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), }; // Check for duplicate names (excluding the new profile) @@ -151,7 +142,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]; @@ -356,7 +350,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr handleDeleteProfile(profile)} + onPress={() => void handleDeleteProfile(profile)} style={{ marginLeft: 16 }} > @@ -410,7 +404,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr )} ); -} +}); // ProfileEditForm now imported from @/components/ProfileEditForm diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx index ea556c99f..a18e38157 100644 --- a/sources/components/NewSessionWizard.tsx +++ b/sources/components/NewSessionWizard.tsx @@ -14,6 +14,7 @@ import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariabl import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; import { profileSyncService } from '@/sync/profileSync'; +import { randomUUID } from 'expo-crypto'; const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -700,6 +701,16 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N const [profileApiKeys, setProfileApiKeys] = useState>>({}); const [profileConfigs, setProfileConfigs] = useState>>({}); + function 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); + } + // Dynamic steps based on whether profile needs configuration const steps: WizardStep[] = React.useMemo(() => { const baseSteps: WizardStep[] = experimentsEnabled @@ -719,18 +730,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N } 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); - }; + }, [experimentsEnabled, selectedProfileId, allProfiles]); // Get required fields for profile configuration const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { @@ -870,6 +870,17 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; + React.useEffect(() => { + // Guard: if the user changes profiles such that profileConfig is no longer required, + // advance to the next step (or reset to the first step if currentStep is invalid). + if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { + const nextStep = steps[currentStepIndex + 1] ?? steps[0] ?? 'profile'; + if (nextStep !== currentStep) { + setCurrentStep(nextStep); + } + } + }, [currentStep, currentStepIndex, selectedProfileId, steps]); + // Handler for "Use Profile As-Is" - quick session creation const handleUseProfileAsIs = (profile: AIBackendProfile) => { setSelectedProfileId(profile.id); @@ -932,7 +943,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N ).then((profileName) => { if (profileName && profileName.trim()) { const newProfile: AIBackendProfile = { - id: crypto.randomUUID(), + id: randomUUID(), name: profileName.trim(), description: 'Custom AI profile', anthropicConfig: {}, @@ -976,7 +987,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N if (newName && newName.trim()) { const duplicatedProfile: AIBackendProfile = { ...profile, - id: crypto.randomUUID(), + id: randomUUID(), name: newName.trim(), description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', isBuiltIn: false, @@ -1287,8 +1298,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N case 'profileConfig': if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // Skip configuration if no profile selected or profile doesn't need configuration - setCurrentStep(steps[currentStepIndex + 1]); + // No profile configuration needed; navigation effect will auto-advance. return null; } diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 249345e97..37b1fc9e5 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -110,7 +110,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 +302,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('/dev')} + onPress={() => router.push('/(app)/dev')} /> )} @@ -357,7 +357,7 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => { trackWhatsNewClicked(); - router.push('/changelog'); + router.push('/(app)/changelog'); }} /> { - 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(), + baseUrl: urlOrTemplateStringOptional(), 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(), + baseUrl: urlOrTemplateStringOptional(), 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(), + endpoint: urlOrTemplateStringOptional(), apiVersion: z.string().optional(), deploymentName: z.string().optional(), }); @@ -158,7 +136,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * * 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) + * - Non-tmux mode: daemon must interpolate ${VAR} / ${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 +150,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 */ diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 66ed2cbee..4c69aa745 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -902,7 +902,7 @@ export const en = { 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: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e27bdba63..5138685c6 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -894,7 +894,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..b3afb1bea 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -1,933 +1,17 @@ -import type { TranslationStructure } from '../_default'; +import { en as defaultEn, type TranslationStructure } from '../_default'; /** - * English plural helper function - * English has 2 plural forms: singular, plural - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on English plural rules - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - -/** - * 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. + * English translations (temporary re-export). * - * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects - * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) - * - Benefit: Better maintainability, smaller files, easier language management + * `_default.ts` is currently the canonical source of truth for the English + * translation structure and is used at runtime by `sources/text/index.ts`. * - * This file contains the complete English translation structure and serves as - * the reference implementation for all other language files. + * This file exists for the “dedicated translations per language file” migration + * and for tooling/scripts that import `text/translations/en`. * - * ARCHITECTURE NOTES: - * - All translation keys must match across all language files - * - Type safety enforced by TranslationStructure interface - * - New translation keys must be added to ALL language files + * Re-exporting prevents drift and ensures this file always matches + * `TranslationStructure` without duplicating the full object. */ -export const en: TranslationStructure = { - 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', - copy: 'Copy', - copied: 'Copied', - 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 messages', - enterToSendDisabled: 'Press ⌘+Enter to send messages', - 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: 'Failed to copy', - 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 const en: TranslationStructure = defaultEn; -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..477bae10f 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -903,7 +903,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..55224e47d 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -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: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index c8af39724..394090e9f 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -93,7 +93,7 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', delete: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4d24cd147..c4da73780 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -926,7 +926,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..d0d5b9b7e 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -894,7 +894,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..5ce577666 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -925,7 +925,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..4737d8a74 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -896,7 +896,7 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', delete: { title: '删除配置', From 2e6532246d288ddd72fd6827d89289753c6a120f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 07:49:41 +0100 Subject: [PATCH 007/106] feat(settings): add profiles feature flag --- sources/-session/SessionView.tsx | 11 + sources/app/(app)/new/index.tsx | 566 +++++--------- sources/app/(app)/new/pick/profile-edit.tsx | 2 +- sources/app/(app)/new/pick/profile.tsx | 224 ++++++ sources/app/(app)/settings/features.tsx | 15 + sources/app/(app)/settings/profiles.tsx | 353 ++++----- sources/components/AgentInput.tsx | 239 +++--- .../components/EnvironmentVariableCard.tsx | 136 ++-- .../components/EnvironmentVariablesList.tsx | 246 +++---- sources/components/ProfileEditForm.tsx | 693 +++++------------- sources/components/SettingsView.tsx | 16 +- sources/components/Switch.web.tsx | 63 ++ sources/sync/ops.ts | 7 +- sources/sync/settings.spec.ts | 6 + sources/sync/settings.ts | 5 +- sources/sync/storageTypes.ts | 1 + sources/text/_default.ts | 3 + sources/text/translations/ca.ts | 3 + sources/text/translations/es.ts | 3 + sources/text/translations/it.ts | 3 + sources/text/translations/ja.ts | 3 + sources/text/translations/pl.ts | 3 + sources/text/translations/pt.ts | 3 + sources/text/translations/ru.ts | 3 + sources/text/translations/zh-Hans.ts | 3 + 25 files changed, 1112 insertions(+), 1498 deletions(-) create mode 100644 sources/app/(app)/new/pick/profile.tsx create mode 100644 sources/components/Switch.web.tsx diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index e93fdd4eb..d7f032ccd 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -273,6 +273,17 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} metadata={session.metadata} + profileId={session.metadata?.profileId ?? undefined} + onProfileClick={session.metadata?.profileId !== undefined ? () => { + const profileId = session.metadata?.profileId; + const profileInfo = (profileId === null || (typeof profileId === 'string' && profileId.trim() === '')) + ? t('profiles.noProfile') + : (typeof profileId === 'string' ? profileId : t('status.unknown')); + Modal.alert( + t('profiles.title'), + `This session uses: ${profileInfo}\n\nProfiles are fixed per session. To use a different profile, start a new session.`, + ); + } : undefined} connectionStatus={{ text: sessionStatus.statusText, color: sessionStatus.statusColor, diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index fd115776d..3f1678f2a 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 { Typography } from '@/constants/Typography'; import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; @@ -33,7 +32,8 @@ 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 { MachineSelector } from '@/components/newSession/MachineSelector'; +import { DirectorySelector } from '@/components/newSession/DirectorySelector'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; // Simple temporary state for passing selections back from picker screens @@ -258,11 +258,13 @@ function NewSessionWizard() { const { theme, rt } = useUnistyles(); const router = useRouter(); const safeArea = useSafeAreaInsets(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{ + const headerHeight = useHeaderHeight(); + 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,6 +286,7 @@ 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'); @@ -305,11 +308,21 @@ function NewSessionWizard() { // Wizard state const [selectedProfileId, setSelectedProfileId] = React.useState(() => { + if (!useProfiles) { + return null; + } 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 [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { // Check if agent type was provided in temp data if (tempSessionData?.agentType) { @@ -661,12 +674,8 @@ 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) => { setSelectedProfileId(profileId); @@ -703,6 +712,25 @@ function NewSessionWizard() { } }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); + // Handle profile route param from picker screens + React.useEffect(() => { + if (!useProfiles) { + return; + } + if (typeof profileIdParam !== 'string') { + return; + } + if (profileIdParam === '') { + if (selectedProfileId !== null) { + setSelectedProfileId(null); + } + return; + } + if (profileIdParam !== selectedProfileId) { + selectProfile(profileIdParam); + } + }, [profileIdParam, selectedProfileId, selectProfile, useProfiles]); + // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent React.useEffect(() => { const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; @@ -738,8 +766,17 @@ function NewSessionWizard() { }, []); const handleAgentInputProfileClick = React.useCallback(() => { - scrollToSection(profileSectionRef); - }, [scrollToSection]); + if (!useProfiles) { + return; + } + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId, useProfiles]); const handleAgentInputMachineClick = React.useCallback(() => { scrollToSection(machineSectionRef); @@ -749,53 +786,10 @@ function NewSessionWizard() { scrollToSection(pathSectionRef); }, [scrollToSection]); - const handleAgentInputPermissionChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - scrollToSection(permissionSectionRef); - }, [scrollToSection]); - const handleAgentInputAgentClick = React.useCallback(() => { scrollToSection(profileSectionRef); // Agent tied to profile section }, [scrollToSection]); - 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 = JSON.stringify(newProfile); - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); - }, [router]); - - const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { - const profileData = JSON.stringify(profile); - if (selectedMachineId) { - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}&machineId=${encodeURIComponent(selectedMachineId)}` as any); - return; - } - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); - }, [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 = JSON.stringify(duplicatedProfile); - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); - }, [router]); - // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { const parts: string[] = []; @@ -878,27 +872,6 @@ function NewSessionWizard() { return parts.join(', '); }, [agentType, isProfileAvailable, daemonEnv]); - const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { - Modal.alert( - t('profiles.delete.title'), - t('profiles.delete.message', { name: profile.name }), - [ - { text: t('profiles.delete.cancel'), style: 'cancel' }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); // Use mutable setter for persistence - if (selectedProfileId === profile.id) { - setSelectedProfileId('anthropic'); // Default to Anthropic - } - } - } - ] - ); - }, [profiles, selectedProfileId, setProfiles]); - // Handle machine and path selection callbacks React.useEffect(() => { let handler = (machineId: string) => { @@ -954,8 +927,21 @@ function NewSessionWizard() { }, [profiles, setProfiles]); const handleMachineClick = React.useCallback(() => { - router.push('/new/pick/machine'); - }, [router]); + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); + + const handleProfileClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId]); const handlePathClick = React.useCallback(() => { if (selectedMachineId) { @@ -1004,17 +990,26 @@ function NewSessionWizard() { // Save settings const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10); - sync.applySettings({ + const profilesActive = useProfiles; + + // Keep prod session creation behavior unchanged: + // only persist/apply profiles & model when an explicit opt-in flag is enabled. + const settingsUpdate: Parameters[0] = { recentMachinePaths: updatedPaths, lastUsedAgent: agentType, - lastUsedProfile: selectedProfileId, lastUsedPermissionMode: permissionMode, - lastUsedModelMode: modelMode, - }); + }; + if (profilesActive) { + settingsUpdate.lastUsedProfile = selectedProfileId; + } + if (useEnhancedSessionWizard) { + settingsUpdate.lastUsedModelMode = modelMode; + } + sync.applySettings(settingsUpdate); // Get environment variables from selected profile let environmentVariables = undefined; - if (selectedProfileId) { + if (profilesActive && selectedProfileId) { const selectedProfile = profileMap.get(selectedProfileId); if (selectedProfile) { environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); @@ -1026,6 +1021,7 @@ function NewSessionWizard() { directory: actualPath, approvedNewDirectoryCreation: true, agent: agentType, + profileId: profilesActive ? (selectedProfileId ?? '') : undefined, environmentVariables }); @@ -1064,7 +1060,7 @@ function NewSessionWizard() { Modal.alert(t('common.error'), errorMessage); setIsCreating(false); } - }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]); + }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); const screenWidth = useWindowDimensions().width; @@ -1122,7 +1118,7 @@ function NewSessionWizard() { return ( @@ -1147,20 +1143,19 @@ function NewSessionWizard() { onSend={handleCreateSession} isSendDisabled={!canCreate} isSending={isCreating} - placeholder="What would you like to work on?" + placeholder={t('session.inputPlaceholder')} autocompletePrefixes={[]} autocompleteSuggestions={async () => []} 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} + {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleProfileClick } : {})} /> @@ -1176,7 +1171,7 @@ function NewSessionWizard() { return ( @@ -1253,11 +1248,15 @@ function NewSessionWizard() { {/* Section 1: Profile Management */} 1. - - Choose AI Profile + + + {useProfiles ? 'Choose AI Profile' : 'Select AI'} + - Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs. + {useProfiles + ? 'Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' + : 'Choose which AI runs your session.'} {/* Missing CLI Installation Banners */} @@ -1477,157 +1476,52 @@ function NewSessionWizard() { )} - {/* 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); - }} - > - - - - - ); - })} - - {/* Profile Action Buttons */} - - - - - Add - - - selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - > - - - Duplicate - - - selectedProfile && !selectedProfile.isBuiltIn && handleDeleteProfile(selectedProfile)} - disabled={!selectedProfile || selectedProfile.isBuiltIn} - > - - - Delete - - - + {useProfiles ? ( + + + } + onPress={handleProfileClick} + /> + + ) : ( + + } + selected={agentType === 'claude'} + onPress={() => setAgentType('claude')} + showChevron={false} + /> + } + selected={agentType === 'codex'} + onPress={() => setAgentType('codex')} + showChevron={false} + /> + {experimentsEnabled && ( + } + selected={agentType === 'gemini'} + onPress={() => setAgentType('gemini')} + showChevron={false} + showDivider={false} + /> + )} + + )} {/* Section 2: Machine Selection */} @@ -1639,61 +1533,13 @@ function NewSessionWizard() { - - 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) => { + favoriteMachines.includes(m.id))} + showFavorites={true} + onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); setSelectedPath(bestPath); @@ -1719,92 +1565,33 @@ function NewSessionWizard() { - - 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); + { + if (!selectedMachine?.metadata?.homeDir) return []; + const homeDir = selectedMachine.metadata.homeDir; + return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; + })()} + onSelect={(path) => setSelectedPath(path)} + onToggleFavorite={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return; + if (path === homeDir) return; + + const relativePath = formatPathRelativeToHome(path, homeDir); + const isInFavorites = favoriteDirectories.some(fav => + resolveAbsolutePath(fav, homeDir) === path + ); + if (isInFavorites) { + setFavoriteDirectories(favoriteDirectories.filter(fav => + resolveAbsolutePath(fav, homeDir) !== path + )); + } else { + setFavoriteDirectories([...favoriteDirectories, relativePath]); } - 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 }} + }} /> @@ -1835,14 +1622,14 @@ function NewSessionWizard() { } rightElement={permissionMode === option.value ? ( ) : null} onPress={() => setPermissionMode(option.value)} @@ -1851,7 +1638,7 @@ function NewSessionWizard() { showDivider={index < array.length - 1} style={permissionMode === option.value ? { borderWidth: 2, - borderColor: theme.colors.button.primary.tint, + borderColor: theme.colors.button.primary.background, borderRadius: Platform.select({ ios: 10, default: 16 }), } : undefined} /> @@ -1897,13 +1684,11 @@ function NewSessionWizard() { onSend={handleCreateSession} isSendDisabled={!canCreate} isSending={isCreating} - placeholder="What would you like to work on?" + placeholder={t('session.inputPlaceholder')} autocompletePrefixes={[]} autocompleteSuggestions={async () => []} agentType={agentType} onAgentClick={handleAgentInputAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handleAgentInputPermissionChange} modelMode={modelMode} onModelModeChange={setModelMode} connectionStatus={connectionStatus} @@ -1911,8 +1696,7 @@ function NewSessionWizard() { onMachineClick={handleAgentInputMachineClick} currentPath={selectedPath} onPathClick={handleAgentInputPathClick} - profileId={selectedProfileId} - onProfileClick={handleAgentInputProfileClick} + {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleAgentInputProfileClick } : {})} /> diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index b6cbb91ac..7710ab643 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -90,7 +90,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..8c67d6651 --- /dev/null +++ b/sources/app/(app)/new/pick/profile.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { CommonActions, useNavigation } from '@react-navigation/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 { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { useUnistyles } from 'react-native-unistyles'; +import { randomUUID } from 'expo-crypto'; +import { AIBackendProfile } from '@/sync/settings'; +import { Modal } from '@/modal'; + +export default function ProfilePickerScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string }>(); + const useProfiles = useSetting('useProfiles'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + + 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]); + + const allProfiles = React.useMemo(() => { + const builtIns = DEFAULT_PROFILES + .map(bp => getBuiltInProfile(bp.id)) + .filter(Boolean) as AIBackendProfile[]; + return [...builtIns, ...profiles]; + }, [profiles]); + + const selectedProfile = React.useMemo(() => { + if (!selectedId) return null; + return allProfiles.find(p => p.id === selectedId) || null; + }, [allProfiles, selectedId]); + + const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { + const profileData = JSON.stringify(profile); + const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; + router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + 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', + }; + openProfileEdit(newProfile); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + const duplicated: AIBackendProfile = { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + openProfileEdit(duplicated); + }, [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: () => { + // 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]); + + return ( + <> + + + + {!useProfiles ? ( + + } + showChevron={false} + /> + } + onPress={() => router.push('/settings/features')} + /> + + ) : ( + <> + + } + onPress={handleAddProfile} + /> + } + onPress={() => selectedProfile && openProfileEdit(selectedProfile)} + disabled={!selectedProfile} + /> + } + onPress={() => selectedProfile && handleDuplicateProfile(selectedProfile)} + disabled={!selectedProfile} + /> + } + onPress={() => selectedProfile && handleDeleteProfile(selectedProfile)} + destructive={true} + disabled={!selectedProfile || selectedProfile.isBuiltIn} + /> + + + + } + onPress={() => setProfileParamAndClose('')} + showChevron={false} + selected={selectedId === ''} + rightElement={selectedId === '' + ? + : null} + /> + + + + {DEFAULT_PROFILES.map((profileDisplay) => { + const profile = getBuiltInProfile(profileDisplay.id); + if (!profile) return null; + + const isSelected = selectedId === profile.id; + return ( + } + onPress={() => setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={isSelected + ? + : null} + /> + ); + })} + + {profiles.map((profile) => { + const isSelected = selectedId === profile.id; + return ( + } + onPress={() => setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={isSelected + ? + : null} + /> + ); + })} + + + )} + + + ); +} + diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index ac7261455..9d7e96411 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -9,6 +9,7 @@ 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'); @@ -72,6 +73,20 @@ export default function FeaturesSettingsScreen() { } 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 d56a11839..75fbd1b1a 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,25 +1,19 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView } 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 } from '@/modal'; -import { layout } from '@/components/layout'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useWindowDimensions } from 'react-native'; 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 { Switch } from '@/components/Switch'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -29,12 +23,35 @@ interface ProfileManagerProps { // Profile utilities now imported from @/sync/profileUtils 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 [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); - const safeArea = useSafeAreaInsets(); - const screenWidth = useWindowDimensions().width; + + if (!useProfiles) { + return ( + + + } + rightElement={ + + } + showChevron={false} + /> + + + ); + } const handleAddProfile = () => { setEditingProfile({ @@ -159,232 +176,111 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel }; return ( - - 700 ? 16 : 8, - paddingBottom: safeArea.bottom + 100, - }} - > - - - {t('profiles.title')} - - - {/* None option - no profile */} - + + + } onPress={() => handleSelectProfile(null)} - > - - - - - - {t('profiles.noProfile')} - - - {t('profiles.noProfileDescription')} - - - {selectedProfileId === null && ( - - )} - + showChevron={false} + selected={selectedProfileId === null} + rightElement={selectedProfileId === null + ? + : null} + /> + - {/* Built-in profiles */} + {DEFAULT_PROFILES.map((profileDisplay) => { const profile = getBuiltInProfile(profileDisplay.id); if (!profile) return null; + const isSelected = selectedProfileId === profile.id; return ( - } onPress={() => handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || 'Default model'} - {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - - + showChevron={false} + selected={isSelected} + rightElement={ + + {isSelected && ( + + )} + 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)} - > - - - void handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} - > - - - - - ))} + {profiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const subtitleParts: string[] = [t('profiles.defaultModel')]; + if (profile.tmuxConfig?.sessionName) subtitleParts.push(`tmux: ${profile.tmuxConfig.sessionName}`); + if (profile.tmuxConfig?.tmpDir) subtitleParts.push(`dir: ${profile.tmuxConfig.tmpDir}`); - {/* Add profile button */} - } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={ + + {isSelected && ( + + )} + handleEditProfile(profile)} + > + + + void handleDeleteProfile(profile)} + style={{ marginLeft: 16 }} + > + + + + } + /> + ); + })} + + } onPress={handleAddProfile} - > - - - {t('profiles.addProfile')} - - - - + showChevron={false} + /> + + {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( @@ -422,9 +318,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/components/AgentInput.tsx b/sources/components/AgentInput.tsx index e406b8725..893f26d4c 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -306,7 +306,9 @@ export const AgentInput = React.memo(React.forwardRef { - 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 +316,20 @@ 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]); + // Calculate context warning const contextWarning = props.usageData?.contextSize ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) @@ -644,14 +660,14 @@ 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 && ( @@ -780,56 +728,60 @@ 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 && ( + {/* 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 && ( { - hapticsLight(); - props.onMachineClick?.(); - }} + onPress={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: 8, paddingVertical: 6, + justifyContent: 'center', height: 32, opacity: p.pressed ? 0.7 : 1, - gap: 6, })} > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - )} - {/* 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 +790,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 && ( + {/* Agent selector button */} + {props.agentType && props.onAgentClick && ( { + hapticsLight(); + props.onAgentClick?.(); + }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ flexDirection: 'row', alignItems: 'center', borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, + paddingHorizontal: 10, paddingVertical: 6, justifyContent: 'center', height: 32, opacity: p.pressed ? 0.7 : 1, + gap: 6, })} > + + {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} + )} - {/* Profile selector button - FIRST */} - {props.profileId && props.onProfileClick && ( + {/* Machine selector button */} + {(props.machineName !== undefined) && props.onMachineClick && ( { hapticsLight(); - props.onProfileClick?.(); + props.onMachineClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -930,7 +869,7 @@ export const AgentInput = React.memo(React.forwardRef @@ -940,17 +879,17 @@ export const AgentInput = React.memo(React.forwardRef - {currentProfile?.name || 'Select Profile'} + {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} )} - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( + {/* Path selector button */} + {props.currentPath && props.onPathClick && ( { hapticsLight(); - props.onAgentClick?.(); + props.onPathClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -965,8 +904,8 @@ export const AgentInput = React.memo(React.forwardRef - @@ -976,7 +915,7 @@ 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.currentPath} )} diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 2185e0b21..fc2bccdc3 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -1,9 +1,10 @@ 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 }; @@ -74,7 +75,7 @@ export function EnvironmentVariableCard({ 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) + // Query remote machine for variable value (only if toggle enabled and not secret) const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; const { variables: remoteValues } = useEnvironmentVariables( machineId, @@ -100,16 +101,21 @@ export function EnvironmentVariableCard({ return ( {/* Header row with variable name and action buttons */} @@ -147,60 +153,44 @@ export function EnvironmentVariableCard({ )} - {/* Checkbox: First try copying variable from remote machine */} - setUseRemoteVariable(!useRemoteVariable)} - > - - {useRemoteVariable && ( - - )} - + {/* Toggle: Copy from remote machine */} + - First try copying variable from remote machine: + Copy from remote machine - + + - {/* Remote variable name input */} - + {/* Remote variable name input (only when enabled) */} + {useRemoteVariable && ( + + )} {/* Remote variable status */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( @@ -212,7 +202,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...Typography.default() }}> - ⏳ Checking remote machine... + Checking remote machine... ) : remoteValue === null ? ( - ✗ Value not found + Value not found ) : ( <> @@ -229,7 +219,7 @@ export function EnvironmentVariableCard({ color: theme.colors.success, ...Typography.default() }}> - ✓ Value found: {remoteValue} + Value found {showRemoteDiffersWarning && ( - ⚠️ Differs from documented value: {expectedValue} + Differs from documented value: {expectedValue} )} @@ -254,7 +244,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...Typography.default() }}> - ℹ️ Select a machine to check if variable exists + Select a machine to check if variable exists )} @@ -267,33 +257,35 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...Typography.default() }}> - 🔒 Secret value - not retrieved for security + Secret value - not retrieved for security )} - {/* Default value label */} + {/* Value label */} - Default value: + {useRemoteVariable ? 'Default value:' : 'Value:'} {/* Default value input */} - ⚠️ Overriding documented default: {expectedValue} + Overriding documented default: {expectedValue} )} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index e42e61415..fd66b9067 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -5,6 +5,9 @@ import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { EnvironmentVariableCard } from './EnvironmentVariableCard'; import type { ProfileDocumentation } from '@/sync/profileUtils'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { layout } from '@/components/layout'; export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string }>; @@ -101,158 +104,115 @@ export function EnvironmentVariablesList({ return ( - {/* Section header */} - - Environment Variables - + + + } + showChevron={false} + onPress={() => { + if (showAddForm) { + setShowAddForm(false); + setNewVarName(''); + setNewVarValue(''); + } else { + setShowAddForm(true); + } + }} + /> + + {showAddForm && ( + + + + + + + + - {/* Add Variable Button */} - setShowAddForm(true)} - > - - - Add Variable - - - - {/* 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)} - /> - ); - })} + )} + + + + {environmentVariables.map((envVar, index) => { + const varNameFromValue = extractVarNameFromValue(envVar.value); + const docs = getDocumentation(varNameFromValue || envVar.name); + + const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + 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)} + /> + ); + })} + ); } diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 8a3864d44..3cc69d908 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,17 +1,18 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, TextInput, ViewStyle, Linking, Platform } from 'react-native'; +import { View, Text, TextInput, ViewStyle, Linking, Platform } 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 { PermissionMode } from '@/components/PermissionModeSelector'; 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'; export interface ProfileEditFormProps { @@ -27,554 +28,240 @@ export function ProfileEditForm({ machineId, onSave, onCancel, - containerStyle + containerStyle, }: ProfileEditFormProps) { const { theme } = useUnistyles(); + const styles = stylesheet; - // 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 [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>( + profile.defaultSessionType || 'simple', + ); + const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( + (profile.defaultPermissionMode as PermissionMode) || 'default', + ); + + 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 = () => { + const handleSave = React.useCallback(() => { if (!name.trim()) { - // Profile name validation - prevent saving empty profiles 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 + ? { + sessionName: tmuxSession.trim() || '', + tmpDir: tmuxTmpDir.trim() || undefined, + updateEnvironment: undefined, + } + : { + sessionName: undefined, + tmpDir: undefined, + updateEnvironment: undefined, + }, + defaultSessionType, + defaultPermissionMode, updatedAt: Date.now(), }); - }; + }, [ + 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()} + /> + + )} - {/* 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. - - + + - {/* Tmux Temp Directory */} - - Tmux Temp Directory ({t('common.optional')}) - - - Temporary directory for tmux session files. Leave empty for system default. - - + {[ + { 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} /> + ))} + - {/* 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 */} - + - {/* Action buttons */} - - - - {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')} - - - )} - - - + + + + + ); } -const profileEditFormStyles = StyleSheet.create((theme, rt) => ({ - scrollView: { - flex: 1, +const stylesheet = StyleSheet.create((theme) => ({ + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + selectorContainer: { + paddingHorizontal: 12, + paddingBottom: 4, + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, }, - scrollContent: { - padding: 20, + 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, }, - formContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, // Matches new session panel main container - padding: 20, - width: '100%', + 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, }, })); diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 37b1fc9e5..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(); @@ -322,12 +324,14 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => router.push('/settings/features')} /> - } - onPress={() => router.push('/settings/profiles')} - /> + {useProfiles && ( + } + onPress={() => router.push('/settings/profiles')} + /> + )} {experiments && ( ({ + 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/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/settings.spec.ts b/sources/sync/settings.spec.ts index 4f36ce46f..f6942f5b0 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -103,6 +103,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -137,6 +138,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -171,6 +173,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -207,6 +210,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -248,6 +252,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -298,6 +303,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 57a563958..2e78ec19b 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -84,9 +84,6 @@ export const AIBackendProfileSchema = z.object({ // Tmux configuration tmuxConfig: TmuxConfigSchema.optional(), - // Startup bash script (executed before spawning session) - startupBashScript: z.string().optional(), - // Environment variables (validated) environmentVariables: z.array(EnvironmentVariableSchema).default([]), @@ -241,6 +238,7 @@ 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'), 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)'), @@ -310,6 +308,7 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bff187d04..318ed9fb2 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() diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 4c69aa745..a09a8ba81 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -207,6 +207,9 @@ export const en = { 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', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 5138685c6..260dc0b0c 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -208,6 +208,9 @@ 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', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 477bae10f..382c663a2 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -208,6 +208,9 @@ 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', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 55224e47d..25e1e4879 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -237,6 +237,9 @@ 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', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 394090e9f..9657b3d37 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -240,6 +240,9 @@ export const ja: TranslationStructure = { enhancedSessionWizard: '拡張セッションウィザード', enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', enhancedSessionWizardDisabled: '標準セッションランチャーを使用', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index c4da73780..dcc489b23 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -219,6 +219,9 @@ 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', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index d0d5b9b7e..4cacd94f7 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -208,6 +208,9 @@ 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', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 5ce577666..940635c3a 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -190,6 +190,9 @@ export const ru: TranslationStructure = { enhancedSessionWizard: 'Улучшенный мастер сессий', enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 4737d8a74..45147cb4b 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -210,6 +210,9 @@ export const zhHans: TranslationStructure = { enhancedSessionWizard: '增强会话向导', enhancedSessionWizardEnabled: '配置文件优先启动器已激活', enhancedSessionWizardDisabled: '使用标准会话启动器', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { From ba866b80b7a433e3cd6956b25118c8a2b63ce74a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 23:11:56 +0100 Subject: [PATCH 008/106] fix(new-session): restore legacy session creation UI --- sources/app/(app)/_layout.tsx | 8 + sources/app/(app)/new/index.tsx | 189 +- sources/app/(app)/new/pick/machine.tsx | 64 +- sources/app/(app)/new/pick/path.tsx | 208 +- sources/app/(app)/new/pick/profile.tsx | 119 +- sources/components/AgentInput.tsx | 48 +- sources/components/NewSessionWizard.tsx | 1927 ----------------- .../newSession/DirectorySelector.tsx | 115 + .../components/newSession/MachineSelector.tsx | 103 + 9 files changed, 544 insertions(+), 2237 deletions(-) delete mode 100644 sources/components/NewSessionWizard.tsx create mode 100644 sources/components/newSession/DirectorySelector.tsx create mode 100644 sources/components/newSession/MachineSelector.tsx diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 408d7ad24..1c5edb274 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -311,6 +311,13 @@ export default function RootLayout() { headerBackTitle: t('common.back'), }} /> + ({ + const styles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', @@ -112,28 +112,33 @@ const styles = StyleSheet.create((theme, rt) => ({ 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() - }, + wizardContainer: { + marginBottom: 16, + }, + wizardSectionHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + marginTop: 12, + paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + }, + 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, + paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + ...Typography.default() + }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -290,6 +295,10 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); + const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); + const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); @@ -323,20 +332,20 @@ function NewSessionWizard() { 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'; @@ -346,12 +355,12 @@ function NewSessionWizard() { // Note: Does NOT persist immediately - persistence is handled by useEffect below const handleAgentClick = 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 @@ -1010,7 +1019,7 @@ function NewSessionWizard() { // Get environment variables from selected profile let environmentVariables = undefined; if (profilesActive && selectedProfileId) { - const selectedProfile = profileMap.get(selectedProfileId); + const selectedProfile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); if (selectedProfile) { environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); } @@ -1063,6 +1072,11 @@ function NewSessionWizard() { }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); const screenWidth = useWindowDimensions().width; + const showInlineClose = screenWidth < 520; + + const handleCloseModal = React.useCallback(() => { + router.back(); + }, [router]); // Machine online status for AgentInput (DRY - reused in info box too) const connectionStatus = React.useMemo(() => { @@ -1121,6 +1135,23 @@ function NewSessionWizard() { keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight + safeArea.bottom + 16 : 0} style={styles.container} > + {showInlineClose && ( + + + + )} {/* Session type selector only if experiments enabled */} {experimentsEnabled && ( @@ -1174,6 +1205,23 @@ function NewSessionWizard() { keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight + safeArea.bottom + 16 : 0} style={styles.container} > + {showInlineClose && ( + + + + )} - 700 ? 16 : 8 } - ]}> + @@ -1246,7 +1292,7 @@ function NewSessionWizard() { )} {/* Section 1: Profile Management */} - + 1. @@ -1483,8 +1529,8 @@ function NewSessionWizard() { subtitle={selectedProfile ? getProfileSubtitle(selectedProfile) : t('profiles.noProfileDescription')} leftElement={ } @@ -1502,14 +1548,14 @@ function NewSessionWizard() { showChevron={false} /> } - selected={agentType === 'codex'} - onPress={() => setAgentType('codex')} - showChevron={false} - /> - {experimentsEnabled && ( + title="Codex" + subtitle="Codex CLI" + leftElement={} + selected={agentType === 'codex'} + onPress={() => setAgentType('codex')} + showChevron={false} + /> + {allowGemini && ( - + 2. Select Machine @@ -1537,27 +1583,28 @@ function NewSessionWizard() { machines={machines} selectedMachine={selectedMachine || null} recentMachines={recentMachines} - favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} - showFavorites={true} + favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} + showFavorites={useMachinePickerFavorites} + showSearch={useMachinePickerSearch} onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); setSelectedPath(bestPath); }} - onToggleFavorite={(machine) => { + onToggleFavorite={useMachinePickerFavorites ? ((machine) => { const isInFavorites = favoriteMachines.includes(machine.id); if (isInFavorites) { setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); } else { setFavoriteMachines([...favoriteMachines, machine.id]); } - }} + }) : undefined} /> {/* Section 3: Working Directory */} - + 3. Select Working Directory @@ -1569,13 +1616,15 @@ function NewSessionWizard() { machineHomeDir={selectedMachine?.metadata?.homeDir} selectedPath={selectedPath} recentPaths={recentPaths} - favoritePaths={(() => { + favoritePaths={useDirectoryPickerFavorites ? (() => { if (!selectedMachine?.metadata?.homeDir) return []; const homeDir = selectedMachine.metadata.homeDir; return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })()} + })() : []} + showFavorites={useDirectoryPickerFavorites} + showSearch={useDirectoryPickerSearch} onSelect={(path) => setSelectedPath(path)} - onToggleFavorite={(path) => { + onToggleFavorite={useDirectoryPickerFavorites ? ((path) => { const homeDir = selectedMachine?.metadata?.homeDir; if (!homeDir) return; if (path === homeDir) return; @@ -1591,21 +1640,25 @@ function NewSessionWizard() { } else { setFavoriteDirectories([...favoriteDirectories, relativePath]); } - }} + }) : undefined} /> {/* Section 4: Permission Mode */} - 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: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, ] : [ { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, @@ -1622,25 +1675,21 @@ function NewSessionWizard() { } rightElement={permissionMode === option.value ? ( ) : null} onPress={() => setPermissionMode(option.value)} showChevron={false} selected={permissionMode === option.value} + pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={index < array.length - 1} - style={permissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.background, - borderRadius: Platform.select({ ios: 10, default: 16 }), - } : undefined} /> ))} diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index e5c35236d..f8d89d57e 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -4,12 +4,10 @@ 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 { 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: { @@ -115,61 +113,13 @@ 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} + diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b0214d6c6..08bc93cca 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -1,32 +1,19 @@ 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 { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { layout } from '@/components/layout'; import { t } from '@/text'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { layout } from '@/components/layout'; import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - alignItems: 'center', - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - }, emptyContainer: { flex: 1, justifyContent: 'center', @@ -39,6 +26,11 @@ const stylesheet = StyleSheet.create((theme) => ({ textAlign: 'center', ...Typography.default(), }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, pathInputContainer: { flexDirection: 'row', alignItems: 'center', @@ -66,8 +58,8 @@ 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 inputRef = useRef(null); const [customPath, setCustomPath] = useState(params.selectedPath || ''); @@ -135,6 +127,17 @@ export default function PathPickerScreen() { router.back(); }, [customPath, router, machine, navigation]); + const suggestedPaths = useMemo(() => { + if (!machine) return []; + const homeDir = machine.metadata?.homeDir || '/home'; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + }, [machine]); + if (!machine) { return ( <> @@ -162,13 +165,11 @@ export default function PathPickerScreen() { ) }} /> - + - - No machine selected - + No machine selected - + ); } @@ -198,104 +199,73 @@ 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; + {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} + /> + ); + })} + + )} - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < 3} - /> - ); - }); - })()} - - )} - - - + {recentPaths.length === 0 && suggestedPaths.length > 0 && ( + + {suggestedPaths.map((path, index) => { + const isSelected = customPath.trim() === path; + const isLast = index === suggestedPaths.length - 1; + return ( + } + onPress={() => { + setCustomPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + showDivider={!isLast} + /> + ); + })} + + )} + + ); } \ No newline at end of file diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 8c67d6651..0219f0ab9 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -1,6 +1,7 @@ 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'; @@ -36,18 +37,6 @@ export default function ProfilePickerScreen() { router.back(); }, [navigation, router]); - const allProfiles = React.useMemo(() => { - const builtIns = DEFAULT_PROFILES - .map(bp => getBuiltInProfile(bp.id)) - .filter(Boolean) as AIBackendProfile[]; - return [...builtIns, ...profiles]; - }, [profiles]); - - const selectedProfile = React.useMemo(() => { - if (!selectedId) return null; - return allProfiles.find(p => p.id === selectedId) || null; - }, [allProfiles, selectedId]); - const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { const profileData = JSON.stringify(profile); const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; @@ -136,28 +125,7 @@ export default function ProfilePickerScreen() { title={t('profiles.addProfile')} icon={} onPress={handleAddProfile} - /> - } - onPress={() => selectedProfile && openProfileEdit(selectedProfile)} - disabled={!selectedProfile} - /> - } - onPress={() => selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - /> - } - onPress={() => selectedProfile && handleDeleteProfile(selectedProfile)} - destructive={true} - disabled={!selectedProfile || selectedProfile.isBuiltIn} + showChevron={false} /> @@ -165,12 +133,12 @@ export default function ProfilePickerScreen() { } + icon={} onPress={() => setProfileParamAndClose('')} showChevron={false} selected={selectedId === ''} rightElement={selectedId === '' - ? + ? : null} /> @@ -190,9 +158,37 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} - rightElement={isSelected - ? - : null} + rightElement={ + + {isSelected && ( + + )} + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + style={{ marginLeft: 16 }} + > + + + + } /> ); })} @@ -208,9 +204,47 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} - rightElement={isSelected - ? - : null} + rightElement={ + + {isSelected && ( + + )} + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + style={{ marginLeft: 16 }} + > + + + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + style={{ marginLeft: 16 }} + > + + + + } /> ); })} @@ -221,4 +255,3 @@ export default function ProfilePickerScreen() { ); } - diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 893f26d4c..6520a73b9 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -1,6 +1,6 @@ import { Ionicons, Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, TouchableWithoutFeedback, Image as RNImage, Pressable } from 'react-native'; +import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Image as RNImage, Pressable } from 'react-native'; import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; @@ -223,7 +223,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ // Button styles actionButtonsContainer: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 0, }, @@ -231,7 +231,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ flexDirection: 'row', gap: 8, flex: 1, - overflow: 'hidden', + flexWrap: 'wrap', + overflow: 'visible', }, actionButton: { flexDirection: 'row', @@ -300,8 +301,9 @@ 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'); @@ -316,19 +318,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'); - } + const profileLabel = React.useMemo(() => { + 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]); + }, [props.profileId, currentProfile]); + + const profileIcon = React.useMemo(() => { + if (props.profileId === null) return 'radio-button-off-outline'; + if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; + return 'person-outline'; + }, [props.profileId]); // Calculate context warning const contextWarning = props.usageData?.contextSize @@ -536,9 +544,7 @@ export const AgentInput = React.memo(React.forwardRef - setShowSettings(false)}> - - + setShowSettings(false)} style={styles.overlayBackdrop} /> 700 ? 0 : 8 } @@ -796,11 +802,11 @@ export const AgentInput = React.memo(React.forwardRef - + ({ - 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>>({}); - - function 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); - } - - // 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, allProfiles]); - - // 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; - - React.useEffect(() => { - // Guard: if the user changes profiles such that profileConfig is no longer required, - // advance to the next step (or reset to the first step if currentStep is invalid). - if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { - const nextStep = steps[currentStepIndex + 1] ?? steps[0] ?? 'profile'; - if (nextStep !== currentStep) { - setCurrentStep(nextStep); - } - } - }, [currentStep, currentStepIndex, selectedProfileId, steps]); - - // 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: 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: 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)) { - // No profile configuration needed; navigation effect will auto-advance. - 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/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx new file mode 100644 index 000000000..9f5cd1bd0 --- /dev/null +++ b/sources/components/newSession/DirectorySelector.tsx @@ -0,0 +1,115 @@ +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[]; + 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, + 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 allPaths = React.useMemo(() => { + const seen = new Set(); + const ordered: string[] = []; + for (const p of [...favoritePaths, ...recentPaths]) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoritePaths, recentPaths]); + + return ( + + config={{ + getItemId: (path) => path, + getItemTitle: (path) => formatPathRelativeToHome(path, homeDir), + getItemSubtitle: undefined, + getItemIcon: () => ( + + ), + getRecentItemIcon: () => ( + + ), + getFavoriteItemIcon: (path) => ( + + ), + canRemoveFavorite: (path) => path !== homeDir, + 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, + favoritesSectionTitle, + allSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + showAll: favoritePaths.length > 0, + allowCustomInput: true, + }} + items={allPaths} + recentItems={recentPaths} + favoriteItems={favoritePaths} + selectedItem={selectedPath || null} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + /> + ); +} diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx new file mode 100644 index 000000000..7d5286345 --- /dev/null +++ b/sources/components/newSession/MachineSelector.tsx @@ -0,0 +1,103 @@ +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; + compactItems?: boolean; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + noItemsMessage?: string; +} + +export function MachineSelector({ + machines, + selectedMachine, + recentMachines = [], + favoriteMachines = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + compactItems = true, + searchPlaceholder = 'Type to filter machines...', + recentSectionTitle = 'Recent Machines', + favoritesSectionTitle = 'Favorite 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, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + allowCustomInput: false, + compactItems, + }} + items={machines} + recentItems={recentMachines} + favoriteItems={favoriteMachines} + selectedItem={selectedMachine} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + /> + ); +} + From 8b37d10f1002a4e088d49da96e7fcb5f8d811aac Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 12:08:42 +0100 Subject: [PATCH 009/106] refactor(pickers): unify selector layout and selection UI --- sources/app/(app)/_layout.tsx | 3 +- sources/app/(app)/new/pick/machine.tsx | 19 +- sources/app/(app)/new/pick/path.tsx | 161 ++++- sources/app/(app)/settings/features.tsx | 32 + sources/app/(app)/settings/voice/language.tsx | 56 +- sources/components/SearchHeader.tsx | 90 +++ sources/components/SearchableListSelector.tsx | 614 ++++-------------- .../components/newSession/MachineSelector.tsx | 11 +- sources/sync/settings.spec.ts | 31 +- sources/sync/settings.ts | 8 + sources/text/_default.ts | 8 + sources/text/translations/ca.ts | 8 + sources/text/translations/es.ts | 8 + sources/text/translations/it.ts | 8 + sources/text/translations/ja.ts | 8 + sources/text/translations/pl.ts | 8 + sources/text/translations/pt.ts | 8 + sources/text/translations/ru.ts | 8 + sources/text/translations/zh-Hans.ts | 8 + 19 files changed, 538 insertions(+), 559 deletions(-) create mode 100644 sources/components/SearchHeader.tsx diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 1c5edb274..97735e214 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -329,7 +329,8 @@ export default function RootLayout() { name="new/index" options={{ headerTitle: t('newSession.title'), - headerBackTitle: t('common.back'), + headerShown: true, + headerBackTitle: t('common.cancel'), presentation: 'modal', }} /> diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index f8d89d57e..c327ed3c2 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -3,7 +3,7 @@ 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 { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; @@ -36,6 +36,9 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -117,11 +120,19 @@ export default function MachinePickerScreen() { machines={machines} selectedMachine={selectedMachine} recentMachines={recentMachines} - favoriteMachines={[]} + favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} onSelect={handleSelectMachine} - showFavorites={false} + showFavorites={useMachinePickerFavorites} + showSearch={useMachinePickerSearch} + onToggleFavorite={useMachinePickerFavorites ? ((machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + setFavoriteMachines(isInFavorites + ? favoriteMachines.filter(id => id !== machine.id) + : [...favoriteMachines, machine.id] + ); + }) : undefined} /> ); -} \ 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 08bc93cca..5db8062ff 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -3,7 +3,7 @@ import { View, Text, Pressable } 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, 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 { t } from '@/text'; @@ -12,6 +12,9 @@ import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { layout } from '@/components/layout'; import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; +import { SearchHeader } from '@/components/SearchHeader'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; const stylesheet = StyleSheet.create((theme) => ({ emptyContainer: { @@ -59,9 +62,13 @@ export default function PathPickerScreen() { const machines = useAllMachines(); const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); + const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); + const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); + const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const inputRef = useRef(null); const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [searchQuery, setSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -138,6 +145,61 @@ export default function PathPickerScreen() { ]; }, [machine]); + const favoritePaths = useMemo(() => { + if (!useDirectoryPickerFavorites || !machine) return []; + const homeDir = machine.metadata?.homeDir || '/home'; + const paths = [homeDir, ...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, machine, useDirectoryPickerFavorites]); + + const filteredRecentPaths = useMemo(() => { + const base = useDirectoryPickerFavorites + ? recentPaths.filter((p) => !favoritePaths.includes(p)) + : recentPaths; + if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, recentPaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + + const filteredSuggestedPaths = useMemo(() => { + const base = useDirectoryPickerFavorites + ? suggestedPaths.filter((p) => !favoritePaths.includes(p)) + : suggestedPaths; + if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, suggestedPaths, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + + const filteredFavoritePaths = useMemo(() => { + if (!useDirectoryPickerFavorites) return []; + if (!useDirectoryPickerSearch || !searchQuery.trim()) return favoritePaths; + const query = searchQuery.toLowerCase(); + return favoritePaths.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + + const toggleFavorite = React.useCallback((absolutePath: string) => { + if (!machine) return; + const homeDir = machine.metadata?.homeDir || '/home'; + if (absolutePath === homeDir) return; + + const relativePath = formatPathRelativeToHome(absolutePath, homeDir); + const resolved = resolveAbsolutePath(relativePath, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); + + setFavoriteDirectories(isInFavorites + ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) + : [...favoriteDirectories, relativePath] + ); + }, [favoriteDirectories, machine, setFavoriteDirectories]); + if (!machine) { return ( <> @@ -199,7 +261,14 @@ export default function PathPickerScreen() { ) }} /> - + + {useDirectoryPickerSearch && ( + + )} @@ -217,11 +286,54 @@ export default function PathPickerScreen() { - {recentPaths.length > 0 && ( + {useDirectoryPickerFavorites && filteredFavoritePaths.length > 0 && ( + + {filteredFavoritePaths.map((path, index) => { + const isSelected = customPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + const isHome = machine?.metadata?.homeDir ? path === machine.metadata.homeDir : false; + const favoriteIconName = isHome ? 'home-outline' : 'star'; + return ( + } + onPress={() => { + setCustomPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={useDirectoryPickerFavorites ? ( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + ) : null} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length > 0 && ( - {recentPaths.map((path, index) => { + {filteredRecentPaths.map((path, index) => { const isSelected = customPath.trim() === path; - const isLast = index === recentPaths.length - 1; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); return ( { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + ) : null} showDivider={!isLast} /> ); @@ -241,11 +368,12 @@ export default function PathPickerScreen() { )} - {recentPaths.length === 0 && suggestedPaths.length > 0 && ( + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( - {suggestedPaths.map((path, index) => { + {filteredSuggestedPaths.map((path, index) => { const isSelected = customPath.trim() === path; - const isLast = index === suggestedPaths.length - 1; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); return ( { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + ) : null} showDivider={!isLast} /> ); @@ -268,4 +411,4 @@ export default function PathPickerScreen() { ); -} \ No newline at end of file +} diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index 9d7e96411..b01619e6c 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -15,6 +15,10 @@ export default function FeaturesSettingsScreen() { const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [useMachinePickerFavorites, setUseMachinePickerFavorites] = useSettingMutable('useMachinePickerFavorites'); + const [useDirectoryPickerSearch, setUseDirectoryPickerSearch] = useSettingMutable('useDirectoryPickerSearch'); + const [useDirectoryPickerFavorites, setUseDirectoryPickerFavorites] = useSettingMutable('useDirectoryPickerFavorites'); return ( @@ -73,6 +77,34 @@ export default function FeaturesSettingsScreen() { } showChevron={false} /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> - {/* Search Header */} - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')} - style={{ marginLeft: 8 }} - /> - )} - - + {/* Language List */} void; + placeholder: string; + containerStyle?: StyleProp; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + autoCorrect?: boolean; +} + +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, + }, + 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, + }, + clearIcon: { + marginLeft: 8, + }, +})); + +export function SearchHeader({ + value, + onChangeText, + placeholder, + containerStyle, + autoCapitalize = 'none', + autoCorrect = false, +}: 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..ca4f03d83 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,25 @@ 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; } 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 = 4; 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 +107,43 @@ export function SearchableListSelector(props: SearchableListSelectorProps) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, - collapsedSections, - onCollapsedSectionsChange, } = 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); - - // 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 - } - - // User is typing - filter the list - return recentItems.filter(item => config.filterItem(item, inputText, context)); - }, [recentItems, inputText, selectedItem, config, context]); + const favoriteIds = React.useMemo(() => { + return new Set(favoriteItems.map((item) => config.getItemId(item))); + }, [favoriteItems, 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(() => { + const base = recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + if (!inputText.trim()) return base; + return base.filter((item) => config.filterItem(item, inputText, context)); + }, [recentItems, favoriteIds, 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(() => { + const base = items.filter((item) => !favoriteIds.has(config.getItemId(item))); + if (!inputText.trim()) return base; + return base.filter((item) => config.filterItem(item, inputText, context)); + }, [items, favoriteIds, 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); - } + 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); - } - }; - - // 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 +153,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 +203,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)} + {renderFavoriteToggle(item, isFavorite)} {isSelected && ( )} - } - 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); 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, - } - ])} - > - - - )} - + )} - {/* Recent Items Section */} {showRecent && filteredRecentItems.length > 0 && ( - <> - - {config.recentSectionTitle} - + {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 && ( + setShowAllRecent(!showAllRecent)} + showChevron={false} + showDivider={false} + titleStyle={styles.showMoreTitle} /> - - - {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} - /> - )} - )} - + )} - {/* 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 - ]} - /> - ); - })} - - )} - + + {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); + })} + )} - {/* 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); - })} - - )} - + {showAll && filteredItems.length > 0 && ( + + {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); + })} + )} ); diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 7d5286345..710816564 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -15,10 +15,10 @@ export interface MachineSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; - compactItems?: boolean; searchPlaceholder?: string; recentSectionTitle?: string; favoritesSectionTitle?: string; + allSectionTitle?: string; noItemsMessage?: string; } @@ -32,10 +32,10 @@ export function MachineSelector({ showFavorites = true, showRecent = true, showSearch = true, - compactItems = true, searchPlaceholder = 'Type to filter machines...', recentSectionTitle = 'Recent Machines', favoritesSectionTitle = 'Favorite Machines', + allSectionTitle = 'All Machines', noItemsMessage = 'No machines available', }: MachineSelectorProps) { const { theme } = useUnistyles(); @@ -49,14 +49,14 @@ export function MachineSelector({ getItemIcon: () => ( ), getRecentItemIcon: () => ( ), @@ -84,12 +84,12 @@ export function MachineSelector({ searchPlaceholder, recentSectionTitle, favoritesSectionTitle, + allSectionTitle, noItemsMessage, showFavorites, showRecent, showSearch, allowCustomInput: false, - compactItems, }} items={machines} recentItems={recentMachines} @@ -100,4 +100,3 @@ export function MachineSelector({ /> ); } - diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index f6942f5b0..d29db73e9 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -105,6 +105,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -140,6 +144,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -175,6 +183,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -212,6 +224,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -254,6 +270,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -305,6 +325,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -366,7 +390,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, alwaysShowContextSize: false, + useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, @@ -385,7 +415,6 @@ describe('settings', () => { favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], favoriteMachines: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, - useEnhancedSessionWizard: false, }); }); diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 2e78ec19b..9b0ad6234 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -240,6 +240,10 @@ export const SettingsSchema = z.object({ 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'), + useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), + useMachinePickerFavorites: z.boolean().describe('Whether to show favorites in machine picker UIs'), + useDirectoryPickerSearch: z.boolean().describe('Whether to show search in directory/path picker UIs'), + useDirectoryPickerFavorites: z.boolean().describe('Whether to show favorites in directory/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'), @@ -310,6 +314,10 @@ export const settingsDefaults: Settings = { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', diff --git a/sources/text/_default.ts b/sources/text/_default.ts index a09a8ba81..0de923d71 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -210,6 +210,14 @@ export const en = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 260dc0b0c..383862944 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -211,6 +211,14 @@ export const ca: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 382c663a2..439aaf22e 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -211,6 +211,14 @@ export const es: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 25e1e4879..662309434 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -240,6 +240,14 @@ export const it: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 9657b3d37..a8df99f08 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -243,6 +243,14 @@ export const ja: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index dcc489b23..abee98f60 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -222,6 +222,14 @@ export const pl: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 4cacd94f7..bfd1baec8 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -211,6 +211,14 @@ export const pt: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 940635c3a..b1ba00f45 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -193,6 +193,14 @@ export const ru: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 45147cb4b..f4b3905a4 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -213,6 +213,14 @@ export const zhHans: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { From 306cd717d46a2d548074683f38b155a264170466 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 13:15:28 +0100 Subject: [PATCH 010/106] fix(sync): prevent settings version-mismatch retry loop --- sources/sync/persistence.ts | 5 +-- sources/sync/settings.ts | 64 +++++++++++++++++++++++++++---------- sources/sync/storage.ts | 11 ++++++- sources/sync/sync.ts | 4 +-- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 2f9367523..36db3482b 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -26,7 +26,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 }; @@ -225,4 +226,4 @@ export function retrieveTempText(id: string): string | null { export function clearPersistence() { mmkv.clearAll(); -} \ No newline at end of file +} diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 9b0ad6234..992d61136 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -354,28 +354,58 @@ 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 }; - } + // 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 (!(key in input)) 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(rawProfile); + if (parsedProfile.success) { + parsedProfiles.push(parsedProfile.data); + } else if (__DEV__) { + 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 (__DEV__) { + 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') { + if (__DEV__) { + console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); + } + 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]); + // Preserve unknown fields (forward compatibility). + for (const [key, value] of Object.entries(input)) { + if (!(key in SettingsSchema.shape)) { + result[key] = value; + } + } - return { ...settingsDefaults, ...parsed.data, ...unknownFields }; + return result as Settings; } // diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index f1fe413f6..d95e186d2 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -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; @@ -629,7 +630,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 +641,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); diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index fde7d5b02..18a238f03 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1173,7 +1173,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) { @@ -1229,7 +1229,7 @@ class Sync { })); // 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) { From 7b937d8b9aceca4c904aa62c09d5ac68076d92d7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 13:28:22 +0100 Subject: [PATCH 011/106] fix(security): avoid logging sensitive sync/message data --- sources/hooks/envVarUtils.ts | 13 +- sources/hooks/useCLIDetection.ts | 20 +- sources/hooks/useEnvironmentVariables.ts | 68 +++- sources/sync/profileSync.ts | 453 ----------------------- sources/sync/sync.ts | 30 +- sources/sync/typesRaw.ts | 50 ++- 6 files changed, 133 insertions(+), 501 deletions(-) delete mode 100644 sources/sync/profileSync.ts diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index 325404655..d3bc26823 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -32,14 +32,13 @@ 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) { @@ -76,9 +75,9 @@ export function extractEnvVarReferences( const refs = new Set(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR} 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.ts b/sources/hooks/useEnvironmentVariables.ts index 568bb0583..5dcd0b3af 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -69,12 +69,33 @@ 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. + // 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(' ')}`; + // Bash fallback uses indirect expansion to avoid eval and to distinguish unset vs empty. + // IMPORTANT: avoid embedding literal `${...}` inside this TypeScript template string (it would be parsed as JS interpolation). + const bashIsSetExpr = '\\$' + '{!name+x}'; + const bashValueExpr = '\\$' + '{!name}'; + const bashFallback = [ + `for name in ${validVarNames.join(' ')}; do`, + `if [ -n "${bashIsSetExpr}" ]; then`, + `printf "%s=%s\\n" "$name" "${bashValueExpr}";`, + `else`, + `printf "%s=__HAPPY_UNSET__\\n" "$name";`, + `fi;`, + `done`, + ].join(' '); + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${bashFallback}; fi`; try { const result = await machineBash(machineId, command, '/'); @@ -82,16 +103,33 @@ 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} + if (stdout.trim().startsWith('{')) { + try { + const parsed = JSON.parse(stdout) 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: it can corrupt values with meaningful whitespace. + const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0); + 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 === '__HAPPY_UNSET__' ? null : value; + } + }); + } // Ensure all requested variables have entries (even if missing from output) validVarNames.forEach(name => { 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/sync.ts b/sources/sync/sync.ts index 18a238f03..2b136f1a6 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1222,11 +1222,13 @@ class Sync { parsedSettings = { ...settingsDefaults }; } - // Log - console.log('settings', JSON.stringify({ - settings: parsedSettings, - version: data.settingsVersion - })); + // Avoid logging full settings in production (may contain secrets like API keys / profile env vars). + if (__DEV__) { + console.log('settings', { + version: data.settingsVersion, + schemaVersion: parsedSettings.schemaVersion, + }); + } // Apply settings to storage storage.getState().replaceSettings(parsedSettings, data.settingsVersion); @@ -1259,15 +1261,15 @@ 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 - })); + // Keep debug logs dev-only (avoid leaking PII/noise in prod logs). + if (__DEV__) { + console.log('profile', { + id: parsedProfile.id, + timestamp: parsedProfile.timestamp, + hasAvatar: !!parsedProfile.avatar, + hasGitHub: !!parsedProfile.github, + }); + } // Apply profile to storage storage.getState().applyProfile(parsedProfile); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 4dde5e855..904ea0412 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(), @@ -340,13 +342,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 +498,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, @@ -568,7 +604,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null From e25e11baec67a243bb5262c86e2981eb705a850c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:38:49 +0100 Subject: [PATCH 012/106] refactor(pickers): consolidate search toggle and remove default favorites --- sources/app/(app)/new/index.tsx | 30 +++++----- sources/app/(app)/new/pick/machine.tsx | 13 ++--- sources/app/(app)/new/pick/path.tsx | 58 ++++++++----------- sources/app/(app)/settings/features.tsx | 32 ++-------- .../newSession/DirectorySelector.tsx | 8 --- sources/sync/settings.spec.ts | 35 +++-------- sources/sync/settings.ts | 14 ++--- sources/text/_default.ts | 10 +--- sources/text/translations/ca.ts | 10 +--- sources/text/translations/es.ts | 10 +--- sources/text/translations/it.ts | 10 +--- sources/text/translations/ja.ts | 10 +--- sources/text/translations/pl.ts | 10 +--- sources/text/translations/pt.ts | 10 +--- sources/text/translations/ru.ts | 10 +--- sources/text/translations/zh-Hans.ts | 10 +--- 16 files changed, 76 insertions(+), 204 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 38e07f02a..11aa808f8 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -295,10 +295,7 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); - const useMachinePickerSearch = useSetting('useMachinePickerSearch'); - const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); - const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); - const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); + const usePickerSearch = useSetting('usePickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); @@ -1583,22 +1580,22 @@ function NewSessionWizard() { machines={machines} selectedMachine={selectedMachine || null} recentMachines={recentMachines} - favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} - showFavorites={useMachinePickerFavorites} - showSearch={useMachinePickerSearch} + favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} + showFavorites={true} + showSearch={usePickerSearch} onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); setSelectedPath(bestPath); }} - onToggleFavorite={useMachinePickerFavorites ? ((machine) => { + onToggleFavorite={(machine) => { const isInFavorites = favoriteMachines.includes(machine.id); if (isInFavorites) { setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); } else { setFavoriteMachines([...favoriteMachines, machine.id]); } - }) : undefined} + }} /> @@ -1616,18 +1613,17 @@ function NewSessionWizard() { machineHomeDir={selectedMachine?.metadata?.homeDir} selectedPath={selectedPath} recentPaths={recentPaths} - favoritePaths={useDirectoryPickerFavorites ? (() => { + favoritePaths={(() => { if (!selectedMachine?.metadata?.homeDir) return []; const homeDir = selectedMachine.metadata.homeDir; - return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })() : []} - showFavorites={useDirectoryPickerFavorites} - showSearch={useDirectoryPickerSearch} + return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + })()} + showFavorites={true} + showSearch={usePickerSearch} onSelect={(path) => setSelectedPath(path)} - onToggleFavorite={useDirectoryPickerFavorites ? ((path) => { + onToggleFavorite={(path) => { const homeDir = selectedMachine?.metadata?.homeDir; if (!homeDir) return; - if (path === homeDir) return; const relativePath = formatPathRelativeToHome(path, homeDir); const isInFavorites = favoriteDirectories.some(fav => @@ -1640,7 +1636,7 @@ function NewSessionWizard() { } else { setFavoriteDirectories([...favoriteDirectories, relativePath]); } - }) : undefined} + }} /> diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c327ed3c2..8cff81e78 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -36,8 +36,7 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const useMachinePickerSearch = useSetting('useMachinePickerSearch'); - const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); + const usePickerSearch = useSetting('usePickerSearch'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -120,17 +119,17 @@ export default function MachinePickerScreen() { machines={machines} selectedMachine={selectedMachine} recentMachines={recentMachines} - favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} + favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} - showFavorites={useMachinePickerFavorites} - showSearch={useMachinePickerSearch} - onToggleFavorite={useMachinePickerFavorites ? ((machine) => { + showFavorites={true} + showSearch={usePickerSearch} + onToggleFavorite={(machine) => { const isInFavorites = favoriteMachines.includes(machine.id); setFavoriteMachines(isInFavorites ? favoriteMachines.filter(id => id !== machine.id) : [...favoriteMachines, machine.id] ); - }) : undefined} + }} /> diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index 5db8062ff..b1fb5238c 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -62,8 +62,7 @@ export default function PathPickerScreen() { const machines = useAllMachines(); const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); - const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); - const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); + const usePickerSearch = useSetting('usePickerSearch'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const inputRef = useRef(null); @@ -146,9 +145,9 @@ export default function PathPickerScreen() { }, [machine]); const favoritePaths = useMemo(() => { - if (!useDirectoryPickerFavorites || !machine) return []; + if (!machine) return []; const homeDir = machine.metadata?.homeDir || '/home'; - const paths = [homeDir, ...favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir))]; + const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); const seen = new Set(); const ordered: string[] = []; for (const p of paths) { @@ -158,37 +157,31 @@ export default function PathPickerScreen() { ordered.push(p); } return ordered; - }, [favoriteDirectories, machine, useDirectoryPickerFavorites]); + }, [favoriteDirectories, machine]); const filteredRecentPaths = useMemo(() => { - const base = useDirectoryPickerFavorites - ? recentPaths.filter((p) => !favoritePaths.includes(p)) - : recentPaths; - if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + 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, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); const filteredSuggestedPaths = useMemo(() => { - const base = useDirectoryPickerFavorites - ? suggestedPaths.filter((p) => !favoritePaths.includes(p)) - : suggestedPaths; - if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + 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, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); const filteredFavoritePaths = useMemo(() => { - if (!useDirectoryPickerFavorites) return []; - if (!useDirectoryPickerSearch || !searchQuery.trim()) return favoritePaths; + if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; const query = searchQuery.toLowerCase(); return favoritePaths.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + }, [favoritePaths, searchQuery, usePickerSearch]); const toggleFavorite = React.useCallback((absolutePath: string) => { if (!machine) return; const homeDir = machine.metadata?.homeDir || '/home'; - if (absolutePath === homeDir) return; const relativePath = formatPathRelativeToHome(absolutePath, homeDir); const resolved = resolveAbsolutePath(relativePath, homeDir); @@ -262,7 +255,7 @@ export default function PathPickerScreen() { }} /> - {useDirectoryPickerSearch && ( + {usePickerSearch && ( - {useDirectoryPickerFavorites && filteredFavoritePaths.length > 0 && ( + {filteredFavoritePaths.length > 0 && ( {filteredFavoritePaths.map((path, index) => { const isSelected = customPath.trim() === path; const isLast = index === filteredFavoritePaths.length - 1; - const isHome = machine?.metadata?.homeDir ? path === machine.metadata.homeDir : false; - const favoriteIconName = isHome ? 'home-outline' : 'star'; return ( { e.stopPropagation(); toggleFavorite(path); }} > - ) : null} + )} showDivider={!isLast} /> ); @@ -333,7 +323,7 @@ export default function PathPickerScreen() { {filteredRecentPaths.map((path, index) => { const isSelected = customPath.trim() === path; const isLast = index === filteredRecentPaths.length - 1; - const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); + const isFavorite = favoritePaths.includes(path); return ( { @@ -360,7 +350,7 @@ export default function PathPickerScreen() { color={isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary} /> - ) : null} + )} showDivider={!isLast} /> ); @@ -373,7 +363,7 @@ export default function PathPickerScreen() { {filteredSuggestedPaths.map((path, index) => { const isSelected = customPath.trim() === path; const isLast = index === filteredSuggestedPaths.length - 1; - const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); + const isFavorite = favoritePaths.includes(path); return ( { @@ -400,7 +390,7 @@ export default function PathPickerScreen() { color={isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary} /> - ) : null} + )} showDivider={!isLast} /> ); diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index b01619e6c..19ecdef28 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -15,10 +15,7 @@ export default function FeaturesSettingsScreen() { const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); - const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); - const [useMachinePickerFavorites, setUseMachinePickerFavorites] = useSettingMutable('useMachinePickerFavorites'); - const [useDirectoryPickerSearch, setUseDirectoryPickerSearch] = useSettingMutable('useDirectoryPickerSearch'); - const [useDirectoryPickerFavorites, setUseDirectoryPickerFavorites] = useSettingMutable('useDirectoryPickerFavorites'); + const [usePickerSearch, setUsePickerSearch] = useSettingMutable('usePickerSearch'); return ( @@ -78,31 +75,10 @@ export default function FeaturesSettingsScreen() { showChevron={false} /> } - rightElement={} - showChevron={false} - /> - } - rightElement={} - showChevron={false} - /> - } - rightElement={} - showChevron={false} - /> - } - rightElement={} + rightElement={} showChevron={false} /> ), - getFavoriteItemIcon: (path) => ( - - ), - canRemoveFavorite: (path) => path !== homeDir, formatForDisplay: (path) => formatPathRelativeToHome(path, homeDir), parseFromDisplay: (text) => { const trimmed = text.trim(); diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index d29db73e9..0f8d49fe7 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -105,10 +105,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -144,10 +141,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -183,10 +177,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -224,10 +215,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -270,10 +258,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -325,10 +310,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -393,10 +375,7 @@ describe('settings', () => { useProfiles: false, alwaysShowContextSize: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 992d61136..45ac8bf12 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -240,10 +240,7 @@ export const SettingsSchema = z.object({ 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'), - useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), - useMachinePickerFavorites: z.boolean().describe('Whether to show favorites in machine picker UIs'), - useDirectoryPickerSearch: z.boolean().describe('Whether to show search in directory/path picker UIs'), - useDirectoryPickerFavorites: z.boolean().describe('Whether to show favorites in directory/path picker UIs'), + usePickerSearch: z.boolean().describe('Whether to show search in machine/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'), @@ -314,10 +311,7 @@ export const settingsDefaults: Settings = { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -335,8 +329,8 @@ 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: [], // Dismissed CLI warnings (empty by default) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 0de923d71..d6b14fa86 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -210,14 +210,8 @@ export const en = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 383862944..b440c2b72 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -211,14 +211,8 @@ export const ca: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 439aaf22e..7a899354c 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -211,14 +211,8 @@ export const es: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 662309434..60922056d 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -240,14 +240,8 @@ export const it: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index a8df99f08..041064dfc 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -243,14 +243,8 @@ export const ja: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index abee98f60..48e0e9282 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -222,14 +222,8 @@ export const pl: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index bfd1baec8..d4fa5a2fa 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -211,14 +211,8 @@ export const pt: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index b1ba00f45..2983f2ac2 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -193,14 +193,8 @@ export const ru: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index f4b3905a4..b60d797f2 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -213,14 +213,8 @@ export const zhHans: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { From 0cfafbcfdf45fcad0bc8add3df6aa0d0c1f26544 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:46:36 +0100 Subject: [PATCH 013/106] fix(new-session): restore chip layout and mobile close --- sources/app/(app)/new/index.tsx | 22 +++-- sources/components/AgentInput.tsx | 150 +++++++++++++++--------------- 2 files changed, 87 insertions(+), 85 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 11aa808f8..55eb53ac5 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1139,14 +1139,15 @@ function NewSessionWizard() { style={{ position: 'absolute', top: safeArea.top + 8, - left: 8, + right: 8, zIndex: 1000, - padding: 8, - borderRadius: 16, - backgroundColor: theme.colors.surface, + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as any) : null), }} > - + )} @@ -1209,14 +1210,15 @@ function NewSessionWizard() { style={{ position: 'absolute', top: safeArea.top + 8, - left: 8, + right: 8, zIndex: 1000, - padding: 8, - borderRadius: 16, - backgroundColor: theme.colors.surface, + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as any) : null), }} > - + )} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 6520a73b9..2fbc2918b 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -414,6 +414,29 @@ export const AgentInput = React.memo(React.forwardRef { + const mode = props.permissionMode ?? 'default'; + + if (isCodex) { + return mode === 'default' ? t('agentInput.codexPermissionMode.default') : + mode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : + mode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : + mode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : ''; + } + + if (isGemini) { + return mode === 'default' ? t('agentInput.geminiPermissionMode.default') : + mode === 'acceptEdits' ? t('agentInput.geminiPermissionMode.badgeAcceptAllEdits') : + mode === 'bypassPermissions' ? t('agentInput.geminiPermissionMode.badgeBypassAllPermissions') : + mode === 'plan' ? t('agentInput.geminiPermissionMode.badgePlanMode') : ''; + } + + return mode === 'default' ? t('agentInput.permissionMode.default') : + mode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : + mode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : + mode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : ''; + }, [isCodex, isGemini, props.permissionMode]); + // Handle settings button press const handleSettingsPress = React.useCallback(() => { hapticsLight(); @@ -657,11 +680,11 @@ export const AgentInput = React.memo(React.forwardRef )} - - {props.permissionMode && ( - - {isCodex ? ( - props.permissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' - ) : isGemini ? ( - props.permissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.geminiPermissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.geminiPermissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.geminiPermissionMode.badgePlanMode') : '' - ) : ( - props.permissionMode === 'default' ? t('agentInput.permissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' - )} - - )} - )} @@ -767,11 +754,12 @@ export const AgentInput = React.memo(React.forwardRef + + {permissionBadgeLabel} + )} @@ -890,42 +886,6 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Path selector button */} - {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} - - - )} - {/* Abort button */} {props.onAbort && ( @@ -1030,6 +990,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} + + + + + )} From ec01fadf2962c44a01f38a92c206e8142c0b67ca Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:49:44 +0100 Subject: [PATCH 014/106] fix(profiles): improve tmux and env var spacing --- sources/components/EnvironmentVariablesList.tsx | 2 +- sources/components/ProfileEditForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index fd66b9067..bbebb2a4a 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -187,7 +187,7 @@ export function EnvironmentVariablesList({ )} - + {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 3cc69d908..fa8e7d99a 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -191,7 +191,7 @@ export function ProfileEditForm({ onChangeText={setTmuxSession} /> - + Tmux Temp Directory ({t('common.optional')}) Date: Tue, 13 Jan 2026 23:52:10 +0100 Subject: [PATCH 015/106] fix(ui): align SearchHeader with maxWidth layout --- sources/components/SearchHeader.tsx | 51 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 6122613e2..7b9b9976f 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -3,6 +3,7 @@ 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; @@ -23,6 +24,11 @@ const stylesheet = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.divider, }, + content: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, inputWrapper: { flexDirection: 'row', alignItems: 'center', @@ -58,33 +64,34 @@ export function SearchHeader({ return ( - - - - {value.trim().length > 0 && ( + + onChangeText('')} - style={styles.clearIcon} + style={{ marginRight: 8 }} + /> + - )} + {value.trim().length > 0 && ( + onChangeText('')} + style={styles.clearIcon} + /> + )} + ); } - From 5d4f1f9bdcb2b32e0e35280d6aa03201973fdc78 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:59:04 +0100 Subject: [PATCH 016/106] refactor(wizard): use machine and path picker modals --- sources/app/(app)/new/index.tsx | 112 +++++++++----------------------- 1 file changed, 31 insertions(+), 81 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 55eb53ac5..8b4211cae 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -32,8 +32,6 @@ import { resolveAbsolutePath } from '@/utils/pathUtils'; import { MultiTextInput } from '@/components/MultiTextInput'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; -import { MachineSelector } from '@/components/newSession/MachineSelector'; -import { DirectorySelector } from '@/components/newSession/DirectorySelector'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; // Simple temporary state for passing selections back from picker screens @@ -295,11 +293,8 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); - const usePickerSearch = useSetting('usePickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); - const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); - const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -467,8 +462,6 @@ function NewSessionWizard() { // Refs for scrolling to sections const scrollViewRef = React.useRef(null); const profileSectionRef = React.useRef(null); - const machineSectionRef = React.useRef(null); - const pathSectionRef = React.useRef(null); const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine @@ -785,12 +778,24 @@ function NewSessionWizard() { }, [router, selectedMachineId, selectedProfileId, useProfiles]); const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); + if (!selectedMachineId) { + return; + } + router.push({ + pathname: '/new/pick/path', + params: { + machineId: selectedMachineId, + selectedPath, + }, + }); + }, [router, selectedMachineId, selectedPath]); const handleAgentInputAgentClick = React.useCallback(() => { scrollToSection(profileSectionRef); // Agent tied to profile section @@ -1568,79 +1573,24 @@ function NewSessionWizard() { )} - {/* Section 2: Machine Selection */} - - - 2. - - Select Machine - - - - - favoriteMachines.includes(m.id))} - showFavorites={true} - showSearch={usePickerSearch} - 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]); - } - }} + + } + onPress={handleMachineClick} /> - - - {/* Section 3: Working Directory */} - - - 3. - - Select Working Directory - - + - - { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); - })()} - showFavorites={true} - showSearch={usePickerSearch} - onSelect={(path) => setSelectedPath(path)} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - const relativePath = formatPathRelativeToHome(path, homeDir); - const isInFavorites = favoriteDirectories.some(fav => - resolveAbsolutePath(fav, homeDir) === path - ); - if (isInFavorites) { - setFavoriteDirectories(favoriteDirectories.filter(fav => - resolveAbsolutePath(fav, homeDir) !== path - )); - } else { - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} + + } + onPress={handlePathClick} + disabled={!selectedMachineId} /> - + {/* Section 4: Permission Mode */} From 98db44faa0a595ee9405f072b11cf286e0f3f208 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 07:54:46 +0100 Subject: [PATCH 017/106] refactor(wizard): restore inline machine/path selectors --- sources/app/(app)/new/index.tsx | 122 +++++++++++++----- .../newSession/DirectorySelector.tsx | 12 +- 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 8b4211cae..d013472a3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -33,6 +33,8 @@ import { MultiTextInput } from '@/components/MultiTextInput'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { DirectorySelector } from '@/components/newSession/DirectorySelector'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -293,8 +295,11 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const usePickerSearch = useSetting('usePickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); + const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -462,6 +467,8 @@ function NewSessionWizard() { // Refs for scrolling to sections const scrollViewRef = React.useRef(null); const profileSectionRef = React.useRef(null); + const machineSectionRef = React.useRef(null); + const pathSectionRef = React.useRef(null); const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine @@ -778,24 +785,12 @@ function NewSessionWizard() { }, [router, selectedMachineId, selectedProfileId, useProfiles]); const handleAgentInputMachineClick = React.useCallback(() => { - router.push({ - pathname: '/new/pick/machine', - params: selectedMachineId ? { selectedId: selectedMachineId } : {}, - }); - }, [router, selectedMachineId]); + scrollToSection(machineSectionRef); + }, [scrollToSection]); const handleAgentInputPathClick = React.useCallback(() => { - if (!selectedMachineId) { - return; - } - router.push({ - pathname: '/new/pick/path', - params: { - machineId: selectedMachineId, - selectedPath, - }, - }); - }, [router, selectedMachineId, selectedPath]); + scrollToSection(pathSectionRef); + }, [scrollToSection]); const handleAgentInputAgentClick = React.useCallback(() => { scrollToSection(profileSectionRef); // Agent tied to profile section @@ -1573,24 +1568,89 @@ function NewSessionWizard() { )} - - } - onPress={handleMachineClick} + {/* Section 2: Machine Selection */} + + + 2. + + Select Machine + + + + + favoriteMachines.includes(m.id))} + showFavorites={true} + showSearch={usePickerSearch} + onSelect={(machine) => { + setSelectedMachineId(machine.id); + const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); + } + }} /> - + + + {/* Section 3: Working Directory */} + + + 3. + + Select Working Directory + + - - } - onPress={handlePathClick} - disabled={!selectedMachineId} + + { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return []; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + })()} + favoritePaths={(() => { + if (!selectedMachine?.metadata?.homeDir) return []; + const homeDir = selectedMachine.metadata.homeDir; + return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + })()} + showFavorites={true} + showSearch={usePickerSearch} + onSelect={(path) => setSelectedPath(path)} + onToggleFavorite={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return; + + const relativePath = formatPathRelativeToHome(path, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => + resolveAbsolutePath(fav, homeDir) === path + ); + if (isInFavorites) { + setFavoriteDirectories( + favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== path) + ); + } else { + setFavoriteDirectories([...favoriteDirectories, relativePath]); + } + }} /> - + {/* Section 4: Permission Mode */} diff --git a/sources/components/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx index 977eff0cc..75b43d220 100644 --- a/sources/components/newSession/DirectorySelector.tsx +++ b/sources/components/newSession/DirectorySelector.tsx @@ -9,6 +9,7 @@ export interface DirectorySelectorProps { machineHomeDir?: string | null; selectedPath: string; recentPaths: string[]; + suggestedPaths?: string[]; favoritePaths?: string[]; onSelect: (path: string) => void; onToggleFavorite?: (path: string) => void; @@ -26,6 +27,7 @@ export function DirectorySelector({ machineHomeDir, selectedPath, recentPaths, + suggestedPaths = [], favoritePaths = [], onSelect, onToggleFavorite, @@ -40,18 +42,20 @@ export function DirectorySelector({ }: 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, ...recentPaths]) { + for (const p of [...favoritePaths, ...recentOrSuggestedPaths]) { if (!p) continue; if (seen.has(p)) continue; seen.add(p); ordered.push(p); } return ordered; - }, [favoritePaths, recentPaths]); + }, [favoritePaths, recentOrSuggestedPaths]); return ( @@ -86,7 +90,7 @@ export function DirectorySelector({ return displayPath.toLowerCase().includes(searchText.toLowerCase()); }, searchPlaceholder, - recentSectionTitle, + recentSectionTitle: recentTitle, favoritesSectionTitle, allSectionTitle, noItemsMessage, @@ -97,7 +101,7 @@ export function DirectorySelector({ allowCustomInput: true, }} items={allPaths} - recentItems={recentPaths} + recentItems={recentOrSuggestedPaths} favoriteItems={favoritePaths} selectedItem={selectedPath || null} onSelect={onSelect} From 33f305a549d23d0ddd546a8089ffc95353b7db62 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 08:08:08 +0100 Subject: [PATCH 018/106] fix(wizard): always show working directory input --- sources/app/(app)/new/index.tsx | 72 ++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d013472a3..d0b3b478e 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -139,6 +139,23 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 paddingHorizontal: Platform.select({ ios: 32, default: 28 }), ...Typography.default() }, + wizardInputWrapper: { + paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + marginBottom: 12, + }, + wizardTextInput: { + ...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, + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -430,6 +447,10 @@ function NewSessionWizard() { const [selectedPath, setSelectedPath] = React.useState(() => { return getRecentPathForMachine(selectedMachineId, recentMachinePaths); }); + const [workingDirInput, setWorkingDirInput] = React.useState(() => { + return getRecentPathForMachine(selectedMachineId, recentMachinePaths); + }); + const [isEditingWorkingDir, setIsEditingWorkingDir] = React.useState(false); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); @@ -609,6 +630,14 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); + React.useEffect(() => { + if (isEditingWorkingDir) { + return; + } + const homeDir = selectedMachine?.metadata?.homeDir; + setWorkingDirInput(selectedPath ? formatPathRelativeToHome(selectedPath, homeDir) : ''); + }, [isEditingWorkingDir, selectedMachine?.metadata?.homeDir, selectedPath]); + // Get recent paths for the selected machine // Recent machines computed from sessions (for inline machine selection) const recentMachines = React.useMemo(() => { @@ -1611,6 +1640,40 @@ function NewSessionWizard() { + + setIsEditingWorkingDir(true)} + onBlur={() => { + setIsEditingWorkingDir(false); + const trimmed = workingDirInput.trim(); + if (!trimmed) { + setSelectedPath(''); + return; + } + const homeDir = selectedMachine?.metadata?.homeDir; + const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; + setSelectedPath(resolved); + }} + onChangeText={(text) => { + setWorkingDirInput(text); + const trimmed = text.trim(); + if (!trimmed) { + setSelectedPath(''); + return; + } + const homeDir = selectedMachine?.metadata?.homeDir; + const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; + setSelectedPath(resolved); + }} + placeholder="Enter directory (e.g. ~/src or /Users/you/project)" + placeholderTextColor={theme.colors.input.placeholder} + autoCapitalize="none" + autoCorrect={false} + style={styles.wizardTextInput} + /> + + resolveAbsolutePath(fav, homeDir)); })()} showFavorites={true} - showSearch={usePickerSearch} - onSelect={(path) => setSelectedPath(path)} + showSearch={false} + onSelect={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + setIsEditingWorkingDir(false); + setSelectedPath(path); + setWorkingDirInput(formatPathRelativeToHome(path, homeDir)); + }} onToggleFavorite={(path) => { const homeDir = selectedMachine?.metadata?.homeDir; if (!homeDir) return; From c276449dde78637110edc7a7868bd02edf32f84a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 08:25:45 +0100 Subject: [PATCH 019/106] fix(wizard): embed profile list and stabilize picker row icons --- sources/app/(app)/new/index.tsx | 243 +++++++++--------- sources/components/SearchableListSelector.tsx | 9 +- 2 files changed, 126 insertions(+), 126 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d0b3b478e..1494a1fbf 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -121,7 +121,7 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 gap: 8, marginBottom: 8, marginTop: 12, - paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + paddingHorizontal: 16, }, sectionHeader: { fontSize: 14, @@ -136,11 +136,11 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 color: theme.colors.textSecondary, marginBottom: 12, lineHeight: 18, - paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + paddingHorizontal: 16, ...Typography.default() }, wizardInputWrapper: { - paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + paddingHorizontal: 16, marginBottom: 12, }, wizardTextInput: { @@ -830,82 +830,30 @@ function NewSessionWizard() { 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)`); + parts.push(`${cli} CLI not detected`); } } - // 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); - } - } - - return parts.join(', '); - }, [agentType, isProfileAvailable, daemonEnv]); + return parts.join(' · '); + }, [isProfileAvailable]); // Handle machine and path selection callbacks React.useEffect(() => { @@ -1264,57 +1212,59 @@ function NewSessionWizard() { {/* CLI Detection Status Banner - shows after detection completes */} {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - - - - - {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - - - - {connectionStatus.text} + + + + + + {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - - - {cliAvailability.claude ? '✓' : '✗'} - - - claude - - - - - {cliAvailability.codex ? '✓' : '✗'} - - - codex - - - {experimentsEnabled && ( - - {cliAvailability.gemini ? '✓' : '✗'} + + + {connectionStatus.text} - - gemini + + + + {cliAvailability.claude ? '✓' : '✗'} + + + claude + + + + + {cliAvailability.codex ? '✓' : '✗'} + + + codex - )} + {experimentsEnabled && ( + + + {cliAvailability.gemini ? '✓' : '✗'} + + + gemini + + + )} + )} @@ -1551,20 +1501,69 @@ function NewSessionWizard() { )} {useProfiles ? ( - - - } - onPress={handleProfileClick} - /> - + <> + + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + /> + + + {DEFAULT_PROFILES.map((profileDisplay, index) => { + const profile = getBuiltInProfile(profileDisplay.id); + if (!profile) return null; + + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === DEFAULT_PROFILES.length - 1 && profiles.length === 0; + + return ( + } + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => selectProfile(profile.id)} + showDivider={!isLast} + /> + ); + })} + {profiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === profiles.length - 1; + + return ( + } + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => selectProfile(profile.id)} + showDivider={!isLast} + /> + ); + })} + + + } + onPress={handleProfileClick} + /> + + ) : ( { const RECENT_ITEMS_DEFAULT_VISIBLE = 5; const STATUS_DOT_TEXT_GAP = 4; -const ITEM_SPACING_GAP = 4; +const ITEM_SPACING_GAP = 8; const stylesheet = StyleSheet.create((theme) => ({ showMoreTitle: { @@ -219,14 +219,15 @@ export function SearchableListSelector(props: SearchableListSelectorProps) rightElement={( {renderStatus(status)} - {renderFavoriteToggle(item, isFavorite)} - {isSelected && ( + - )} + + {renderFavoriteToggle(item, isFavorite)} )} onPress={() => onSelect(item)} From 4f49ef4eb8943319632977c7c92c4d147c8d3929 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 08:49:52 +0100 Subject: [PATCH 020/106] feat(wizard): embed profiles and use searchable directory picker --- sources/app/(app)/new/index.tsx | 207 ++++++++++++++++++++------------ 1 file changed, 132 insertions(+), 75 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 1494a1fbf..087fda848 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native'; +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'; @@ -139,23 +139,6 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 paddingHorizontal: 16, ...Typography.default() }, - wizardInputWrapper: { - paddingHorizontal: 16, - marginBottom: 12, - }, - wizardTextInput: { - ...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, - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -447,10 +430,6 @@ function NewSessionWizard() { const [selectedPath, setSelectedPath] = React.useState(() => { return getRecentPathForMachine(selectedMachineId, recentMachinePaths); }); - const [workingDirInput, setWorkingDirInput] = React.useState(() => { - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); - }); - const [isEditingWorkingDir, setIsEditingWorkingDir] = React.useState(false); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); @@ -630,13 +609,59 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); - React.useEffect(() => { - if (isEditingWorkingDir) { - return; - } - const homeDir = selectedMachine?.metadata?.homeDir; - setWorkingDirInput(selectedPath ? formatPathRelativeToHome(selectedPath, homeDir) : ''); - }, [isEditingWorkingDir, selectedMachine?.metadata?.homeDir, selectedPath]); + const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { + const profileData = JSON.stringify(profile); + const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; + router.push(selectedMachineId ? `${base}&machineId=${encodeURIComponent(selectedMachineId)}` as any : base as any); + }, [router, selectedMachineId]); + + 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', + }; + openProfileEdit(newProfile); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + const duplicated: AIBackendProfile = { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + openProfileEdit(duplicated); + }, [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) @@ -1503,6 +1528,13 @@ function NewSessionWizard() { {useProfiles ? ( <> + } + onPress={handleAddProfile} + showChevron={false} + /> selectProfile(profile.id)} + rightElement={ + + + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + + } showDivider={!isLast} /> ); @@ -1550,19 +1612,50 @@ function NewSessionWizard() { selected={isSelected} disabled={!availability.available} onPress={() => selectProfile(profile.id)} + rightElement={ + + + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + > + + + + } showDivider={!isLast} /> ); })} - - } - onPress={handleProfileClick} - /> - ) : ( @@ -1639,40 +1732,6 @@ function NewSessionWizard() { - - setIsEditingWorkingDir(true)} - onBlur={() => { - setIsEditingWorkingDir(false); - const trimmed = workingDirInput.trim(); - if (!trimmed) { - setSelectedPath(''); - return; - } - const homeDir = selectedMachine?.metadata?.homeDir; - const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; - setSelectedPath(resolved); - }} - onChangeText={(text) => { - setWorkingDirInput(text); - const trimmed = text.trim(); - if (!trimmed) { - setSelectedPath(''); - return; - } - const homeDir = selectedMachine?.metadata?.homeDir; - const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; - setSelectedPath(resolved); - }} - placeholder="Enter directory (e.g. ~/src or /Users/you/project)" - placeholderTextColor={theme.colors.input.placeholder} - autoCapitalize="none" - autoCorrect={false} - style={styles.wizardTextInput} - /> - - resolveAbsolutePath(fav, homeDir)); })()} showFavorites={true} - showSearch={false} + showSearch={true} + searchPlaceholder="Type to filter or enter custom directory..." onSelect={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - setIsEditingWorkingDir(false); setSelectedPath(path); - setWorkingDirInput(formatPathRelativeToHome(path, homeDir)); }} onToggleFavorite={(path) => { const homeDir = selectedMachine?.metadata?.homeDir; From 01281fb27955d419b32e3a900f693192012f1524 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 09:24:09 +0100 Subject: [PATCH 021/106] refactor(wizard): reuse path picker UI and fix profile availability --- sources/app/(app)/new/index.tsx | 182 +++++-------- sources/app/(app)/new/pick/path.tsx | 219 +-------------- .../components/newSession/PathSelector.tsx | 255 ++++++++++++++++++ 3 files changed, 337 insertions(+), 319 deletions(-) create mode 100644 sources/components/newSession/PathSelector.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 087fda848..c4b18ebe7 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -27,14 +27,12 @@ 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 { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; import { MachineSelector } from '@/components/newSession/MachineSelector'; -import { DirectorySelector } from '@/components/newSession/DirectorySelector'; +import { PathSelector } from '@/components/newSession/PathSelector'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -207,18 +205,6 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 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', @@ -434,7 +420,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(() => { @@ -451,6 +436,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') { @@ -552,40 +563,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(() => { @@ -1528,13 +1542,6 @@ function NewSessionWizard() { {useProfiles ? ( <> - } - onPress={handleAddProfile} - showChevron={false} - /> + + } + onPress={handleAddProfile} + showChevron={false} + showDivider={false} + /> + ) : ( @@ -1732,47 +1749,14 @@ function NewSessionWizard() { - { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return []; - return [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop`, - ]; - })()} - favoritePaths={(() => { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); - })()} - showFavorites={true} - showSearch={true} - searchPlaceholder="Type to filter or enter custom directory..." - onSelect={(path) => { - setSelectedPath(path); - }} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - const relativePath = formatPathRelativeToHome(path, homeDir); - const isInFavorites = favoriteDirectories.some((fav) => - resolveAbsolutePath(fav, homeDir) === path - ); - if (isInFavorites) { - setFavoriteDirectories( - favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== path) - ); - } else { - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} + usePickerSearch={usePickerSearch} + favoriteDirectories={favoriteDirectories} + onChangeFavoriteDirectories={setFavoriteDirectories} /> @@ -1826,31 +1810,9 @@ function NewSessionWizard() { ))} - {/* Section 5: Advanced Options (Collapsible) */} - {experimentsEnabled && ( - <> - setShowAdvanced(!showAdvanced)} - > - Advanced Options - - - - {showAdvanced && ( - - - - )} - - )} + + + diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b1fb5238c..f5fe4e8ca 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -8,13 +8,8 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; import { layout } from '@/components/layout'; -import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; -import { SearchHeader } from '@/components/SearchHeader'; -import { formatPathRelativeToHome } from '@/utils/sessionUtils'; -import { resolveAbsolutePath } from '@/utils/pathUtils'; +import { PathSelector } from '@/components/newSession/PathSelector'; const stylesheet = StyleSheet.create((theme) => ({ emptyContainer: { @@ -64,10 +59,8 @@ export default function PathPickerScreen() { const recentMachinePaths = useSetting('recentMachinePaths'); const usePickerSearch = useSetting('usePickerSearch'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); - const inputRef = useRef(null); const [customPath, setCustomPath] = useState(params.selectedPath || ''); - const [searchQuery, setSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -133,66 +126,6 @@ export default function PathPickerScreen() { router.back(); }, [customPath, router, machine, navigation]); - const suggestedPaths = useMemo(() => { - if (!machine) return []; - const homeDir = machine.metadata?.homeDir || '/home'; - return [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop`, - ]; - }, [machine]); - - const favoritePaths = useMemo(() => { - if (!machine) return []; - const homeDir = machine.metadata?.homeDir || '/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, machine]); - - 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 filteredFavoritePaths = useMemo(() => { - if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; - const query = searchQuery.toLowerCase(); - return favoritePaths.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, searchQuery, usePickerSearch]); - - const toggleFavorite = React.useCallback((absolutePath: string) => { - if (!machine) return; - const homeDir = machine.metadata?.homeDir || '/home'; - - const relativePath = formatPathRelativeToHome(absolutePath, homeDir); - const resolved = resolveAbsolutePath(relativePath, homeDir); - const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); - - setFavoriteDirectories(isInFavorites - ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) - : [...favoriteDirectories, relativePath] - ); - }, [favoriteDirectories, machine, setFavoriteDirectories]); - if (!machine) { return ( <> @@ -255,148 +188,16 @@ export default function PathPickerScreen() { }} /> - {usePickerSearch && ( - - )} - - - - - - - - - {filteredFavoritePaths.length > 0 && ( - - {filteredFavoritePaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === filteredFavoritePaths.length - 1; - return ( - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} - showDivider={!isLast} - /> - ); - })} - - )} - - {filteredRecentPaths.length > 0 && ( - - {filteredRecentPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === filteredRecentPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} - showDivider={!isLast} - /> - ); - })} - - )} - - {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( - - {filteredSuggestedPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === filteredSuggestedPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} - showDivider={!isLast} - /> - ); - })} - - )} + diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx new file mode 100644 index 000000000..0f2c6e4d9 --- /dev/null +++ b/sources/components/newSession/PathSelector.tsx @@ -0,0 +1,255 @@ +import React, { 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; + 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, + }, +})); + +export function PathSelector({ + machineHomeDir, + selectedPath, + onChangeSelectedPath, + recentPaths, + usePickerSearch, + favoriteDirectories, + onChangeFavoriteDirectories, +}: PathSelectorProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + + 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 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]); + + return ( + <> + {usePickerSearch && ( + + )} + + + + + + + + + + {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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + )} + showDivider={!isLast} + /> + ); + })} + + )} + + {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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + )} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length === 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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + )} + showDivider={!isLast} + /> + ); + })} + + )} + + ); +} From c6f3441f9529ab147bab179cf0ccf30102db1dcd Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 10:07:18 +0100 Subject: [PATCH 022/106] refactor(wizard): unify bottom chips and profile icons - Add permission chip + scroll-to-section behavior in wizard\n- Unify profile icons across wizard, chip bar, and picker\n- Align session type selector with ItemGroup styling --- sources/app/(app)/new/index.tsx | 144 +++++++++++-------- sources/app/(app)/new/pick/profile.tsx | 30 +++- sources/components/AgentInput.tsx | 53 ++++--- sources/components/ProfileEditForm.tsx | 6 +- sources/components/SessionTypeSelector.tsx | 159 +++++++-------------- sources/sync/profileUtils.ts | 13 ++ 6 files changed, 209 insertions(+), 196 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c4b18ebe7..0c1eff5b6 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -21,7 +21,7 @@ import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector'; 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'; @@ -820,49 +820,65 @@ function NewSessionWizard() { }, [agentType, permissionMode]); // 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; machine?: number; path?: number; permission?: 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(() => { - if (!useProfiles) { - return; - } - router.push({ - pathname: '/new/pick/profile', - params: { - ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), - ...(selectedMachineId ? { machineId: selectedMachineId } : {}), - }, - }); - }, [router, selectedMachineId, selectedProfileId, useProfiles]); + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); + scrollToWizardSection('machine'); + }, [scrollToWizardSection]); const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); + scrollToWizardSection('path'); + }, [scrollToWizardSection]); + + const handleAgentInputPermissionClick = React.useCallback(() => { + scrollToWizardSection('permission'); + }, [scrollToWizardSection]); const handleAgentInputAgentClick = React.useCallback(() => { - scrollToSection(profileSectionRef); // Agent tied to profile section - }, [scrollToSection]); + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); + + const profileIconContainerStyle = React.useMemo(() => ({ + width: 29, + height: 29, + borderRadius: 14.5, + backgroundColor: theme.colors.surfacePressed, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }), [theme.colors.surfacePressed]); + + const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { + const primary = getProfilePrimaryCli(profile); + const iconName = + primary === 'claude' ? 'cloud-outline' : + primary === 'codex' ? 'terminal-outline' : + primary === 'gemini' ? 'planet-outline' : + primary === 'multi' ? 'sparkles-outline' : + 'person-outline'; + return ( + + + + ); + }, [profileIconContainerStyle, theme.colors.textSecondary]); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { @@ -1248,7 +1264,7 @@ function NewSessionWizard() { - + {/* CLI Detection Status Banner - shows after detection completes */} {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( @@ -1565,7 +1581,7 @@ function NewSessionWizard() { key={profile.id} title={profile.name} subtitle={getProfileSubtitle(profile)} - leftElement={} + leftElement={renderProfileLeftElement(profile)} showChevron={false} selected={isSelected} disabled={!availability.available} @@ -1614,7 +1630,7 @@ function NewSessionWizard() { key={profile.id} title={profile.name} subtitle={getProfileSubtitle(profile)} - leftElement={} + leftElement={renderProfileLeftElement(profile)} showChevron={false} selected={isSelected} disabled={!availability.available} @@ -1706,8 +1722,10 @@ function NewSessionWizard() { )} + + {/* Section 2: Machine Selection */} - + 2. @@ -1740,7 +1758,7 @@ function NewSessionWizard() { {/* Section 3: Working Directory */} - + 3. @@ -1761,7 +1779,7 @@ function NewSessionWizard() { {/* Section 4: Permission Mode */} - + 4. @@ -1810,7 +1828,9 @@ function NewSessionWizard() { ))} - + + + @@ -1821,26 +1841,28 @@ function NewSessionWizard() { {/* Section 5: AgentInput - Sticky at bottom */} 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - []} - agentType={agentType} - onAgentClick={handleAgentInputAgentClick} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleAgentInputMachineClick} - currentPath={selectedPath} - onPathClick={handleAgentInputPathClick} + []} + 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} {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleAgentInputProfileClick } : {})} - /> + /> diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 0219f0ab9..5dc51e4be 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -8,7 +8,7 @@ import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { useUnistyles } from 'react-native-unistyles'; import { randomUUID } from 'expo-crypto'; import { AIBackendProfile } from '@/sync/settings'; @@ -25,6 +25,30 @@ export default function ProfilePickerScreen() { const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + const profileIconContainerStyle = React.useMemo(() => ({ + width: 29, + height: 29, + borderRadius: 14.5, + backgroundColor: theme.colors.surfacePressed, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }), [theme.colors.surfacePressed]); + + const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { + const primary = getProfilePrimaryCli(profile); + const iconName = + primary === 'claude' ? 'cloud-outline' : + primary === 'codex' ? 'terminal-outline' : + primary === 'gemini' ? 'planet-outline' : + primary === 'multi' ? 'sparkles-outline' : + 'person-outline'; + return ( + + + + ); + }, [profileIconContainerStyle, theme.colors.textSecondary]); + const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; @@ -154,7 +178,7 @@ export default function ProfilePickerScreen() { key={profile.id} title={profile.name} subtitle={t('profiles.defaultModel')} - icon={} + icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} @@ -200,7 +224,7 @@ export default function ProfilePickerScreen() { key={profile.id} title={profile.name} subtitle={t('profiles.defaultModel')} - icon={} + icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 2fbc2918b..c11500d48 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -22,7 +22,7 @@ import { Theme } from '@/theme'; import { t } from '@/text'; import { Metadata } from '@/sync/storageTypes'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile } from '@/sync/profileUtils'; +import { getBuiltInProfile, getProfilePrimaryCli } from '@/sync/profileUtils'; interface AgentInputProps { value: string; @@ -35,6 +35,7 @@ interface AgentInputProps { isMicActive?: boolean; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; + onPermissionClick?: () => void; modelMode?: ModelMode; onModelModeChange?: (mode: ModelMode) => void; metadata?: Metadata | null; @@ -332,11 +333,16 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === null) return 'radio-button-off-outline'; - if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; - return 'person-outline'; - }, [props.profileId]); + const profileIcon = React.useMemo(() => { + if (props.profileId === null) return 'radio-button-off-outline'; + if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; + const primary = getProfilePrimaryCli(currentProfile); + if (primary === 'claude') return 'cloud-outline'; + if (primary === 'codex') return 'terminal-outline'; + if (primary === 'gemini') return 'planet-outline'; + if (primary === 'multi') return 'sparkles-outline'; + return 'person-outline'; + }, [currentProfile, props.profileId]); // Calculate context warning const contextWarning = props.usageData?.contextSize @@ -443,6 +449,8 @@ export const AgentInput = React.memo(React.forwardRef !prev); }, []); + const showPermissionChip = Boolean(props.onPermissionModeChange || props.onPermissionClick); + // Handle settings selection const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { hapticsLight(); @@ -745,14 +753,21 @@ export const AgentInput = React.memo(React.forwardRef - {/* Settings button */} - {props.onPermissionModeChange && ( - ({ - flexDirection: 'row', - alignItems: 'center', + {/* 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, paddingVertical: 6, @@ -760,13 +775,13 @@ export const AgentInput = React.memo(React.forwardRef + })} + > + /> {permissionBadgeLabel} - - )} + + )} {/* Profile selector button - FIRST */} {props.onProfileClick && ( diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index fa8e7d99a..b43bc1297 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -133,11 +133,7 @@ export function ProfileEditForm({ )} - - - - - + diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index 33aefd357..b4420235a 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -1,142 +1,85 @@ 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'} + pressableStyle={value === 'simple' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + onPress={() => onChange('simple')} + showChevron={false} + showDivider={true} + /> - + + {value === 'worktree' && } + + )} + selected={value === 'worktree'} + pressableStyle={value === 'worktree' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + onPress={() => onChange('worktree')} + showChevron={false} + showDivider={false} + /> + + ); +} - handlePress('worktree')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'worktree' && } - - - - {t('newSession.sessionType.worktree')} - - - - +export function SessionTypeSelector({ value, onChange, title = t('newSession.sessionType.title') }: SessionTypeSelectorProps) { + if (title === null) { + return ; + } + + return ( + + + ); -}; \ No newline at end of file +} + diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index d90a98a93..fc0668a74 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. From da8a1ef74aaf906db7b56d077fa4cd395f16052b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 10:33:16 +0100 Subject: [PATCH 023/106] fix(new-session): improve close and picker affordances - Add accessible close button with web fallback navigation\n- Align picker right-side spacing for status/check/favorite\n- Add consistent selected indicator for path rows --- sources/app/(app)/new/index.tsx | 29 +++++-- sources/components/SearchableListSelector.tsx | 2 +- .../components/newSession/PathSelector.tsx | 78 ++++++++----------- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 0c1eff5b6..9de54f2f1 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1104,6 +1104,17 @@ function NewSessionWizard() { const showInlineClose = screenWidth < 520; const handleCloseModal = React.useCallback(() => { + // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. + // Fall back to home so the user always has an exit. + if (Platform.OS === 'web') { + if (typeof window !== 'undefined' && window.history.length > 1) { + router.back(); + } else { + router.replace('/'); + } + return; + } + router.back(); }, [router]); @@ -1166,6 +1177,8 @@ function NewSessionWizard() { > {showInlineClose && ( - {showInlineClose && ( - { const RECENT_ITEMS_DEFAULT_VISIBLE = 5; const STATUS_DOT_TEXT_GAP = 4; -const ITEM_SPACING_GAP = 8; +const ITEM_SPACING_GAP = 16; const stylesheet = StyleSheet.create((theme) => ({ showMoreTitle: { diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 0f2c6e4d9..393f02268 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -39,6 +39,8 @@ const stylesheet = StyleSheet.create((theme) => ({ }, })); +const ITEM_RIGHT_GAP = 16; + export function PathSelector({ machineHomeDir, selectedPath, @@ -115,6 +117,34 @@ export function PathSelector({ 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 && ( @@ -155,21 +185,7 @@ export function PathSelector({ selected={isSelected} showChevron={false} pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} + rightElement={renderRightElement(path, isSelected, true)} showDivider={!isLast} /> ); @@ -192,21 +208,7 @@ export function PathSelector({ selected={isSelected} showChevron={false} pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} + rightElement={renderRightElement(path, isSelected, isFavorite)} showDivider={!isLast} /> ); @@ -229,21 +231,7 @@ export function PathSelector({ selected={isSelected} showChevron={false} pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} + rightElement={renderRightElement(path, isSelected, isFavorite)} showDivider={!isLast} /> ); From cf835048e9b2a360fc3d20f7fb131cfb5e72a9ab Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 11:21:32 +0100 Subject: [PATCH 024/106] refactor(pickers): split search toggles and unify wizard inputs - Move wizard machine search into a list-style group and filter inline\n- Render path picker search bar full-width (match machine picker)\n- Show selection indicator for No Profile row\n- Add separate settings toggles for machine/path picker search\n- Align env var card width with ItemGroup --- sources/app/(app)/new/index.tsx | 98 ++++++++++++++++--- sources/app/(app)/new/pick/machine.tsx | 4 +- sources/app/(app)/new/pick/path.tsx | 16 ++- sources/app/(app)/settings/features.tsx | 16 ++- .../components/EnvironmentVariableCard.tsx | 2 +- .../components/newSession/PathSelector.tsx | 31 +++++- sources/sync/settings.spec.ts | 20 +++- sources/sync/settings.ts | 19 +++- sources/text/_default.ts | 4 + sources/text/translations/ca.ts | 4 + sources/text/translations/es.ts | 4 + sources/text/translations/it.ts | 4 + sources/text/translations/ja.ts | 4 + sources/text/translations/pl.ts | 4 + sources/text/translations/pt.ts | 4 + sources/text/translations/ru.ts | 4 + sources/text/translations/zh-Hans.ts | 4 + 17 files changed, 211 insertions(+), 31 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 9de54f2f1..75f3e1c76 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -33,6 +33,7 @@ import { StatusDot } from '@/components/StatusDot'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -281,7 +282,8 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); - const usePickerSearch = useSetting('usePickerSearch'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); @@ -703,6 +705,40 @@ function NewSessionWizard() { .map(item => item.machine); }, [sessions, machines]); + const [wizardMachineSearchQuery, setWizardMachineSearchQuery] = React.useState(''); + const normalizedWizardMachineSearchQuery = React.useMemo(() => wizardMachineSearchQuery.trim().toLowerCase(), [wizardMachineSearchQuery]); + + const favoriteMachineItems = React.useMemo(() => { + return machines.filter(m => favoriteMachines.includes(m.id)); + }, [machines, favoriteMachines]); + + const wizardMachineMatchesSearch = React.useCallback((machine: (typeof machines)[number]) => { + if (!useMachinePickerSearch || !normalizedWizardMachineSearchQuery) return true; + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const id = (machine.id || '').toLowerCase(); + const query = normalizedWizardMachineSearchQuery; + return displayName.includes(query) || host.includes(query) || id.includes(query); + }, [normalizedWizardMachineSearchQuery, useMachinePickerSearch]); + + const wizardMachines = React.useMemo(() => { + return useMachinePickerSearch && normalizedWizardMachineSearchQuery + ? machines.filter(wizardMachineMatchesSearch) + : machines; + }, [machines, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); + + const wizardRecentMachines = React.useMemo(() => { + return useMachinePickerSearch && normalizedWizardMachineSearchQuery + ? recentMachines.filter(wizardMachineMatchesSearch) + : recentMachines; + }, [normalizedWizardMachineSearchQuery, recentMachines, useMachinePickerSearch, wizardMachineMatchesSearch]); + + const wizardFavoriteMachineItems = React.useMemo(() => { + return useMachinePickerSearch && normalizedWizardMachineSearchQuery + ? favoriteMachineItems.filter(wizardMachineMatchesSearch) + : favoriteMachineItems; + }, [favoriteMachineItems, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); + const recentPaths = React.useMemo(() => { if (!selectedMachineId) return []; @@ -1580,9 +1616,17 @@ function NewSessionWizard() { showChevron={false} selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} + rightElement={ + + + + } /> - - {DEFAULT_PROFILES.map((profileDisplay, index) => { const profile = getBuiltInProfile(profileDisplay.id); if (!profile) return null; @@ -1749,13 +1793,34 @@ function NewSessionWizard() { + {useMachinePickerSearch && ( + <> + + + + + + + + )} + favoriteMachines.includes(m.id))} + recentMachines={wizardRecentMachines} + favoriteMachines={wizardFavoriteMachineItems} showFavorites={true} - showSearch={usePickerSearch} + showSearch={false} onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); @@ -1782,15 +1847,16 @@ function NewSessionWizard() { - + {/* Section 4: Permission Mode */} diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index 8cff81e78..3568fc3d2 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -36,7 +36,7 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const usePickerSearch = useSetting('usePickerSearch'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -122,7 +122,7 @@ export default function MachinePickerScreen() { favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} showFavorites={true} - showSearch={usePickerSearch} + showSearch={useMachinePickerSearch} onToggleFavorite={(machine) => { const isInFavorites = favoriteMachines.includes(machine.id); setFavoriteMachines(isInFavorites diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index f5fe4e8ca..4762b9f28 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -10,6 +10,7 @@ import { t } from '@/text'; 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) => ({ emptyContainer: { @@ -57,10 +58,11 @@ export default function PathPickerScreen() { const machines = useAllMachines(); const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); - const usePickerSearch = useSetting('usePickerSearch'); + 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(() => { @@ -188,13 +190,23 @@ export default function PathPickerScreen() { }} /> + {usePathPickerSearch && ( + + )} diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index 19ecdef28..c701ef908 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -15,7 +15,8 @@ export default function FeaturesSettingsScreen() { const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); - const [usePickerSearch, setUsePickerSearch] = useSettingMutable('usePickerSearch'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch'); return ( @@ -75,10 +76,17 @@ export default function FeaturesSettingsScreen() { showChevron={false} /> } - rightElement={} + rightElement={} + showChevron={false} + /> + } + rightElement={} showChevron={false} /> void; recentPaths: string[]; usePickerSearch: boolean; + searchVariant?: 'header' | 'group' | 'none'; + searchQuery?: string; + onChangeSearchQuery?: (text: string) => void; favoriteDirectories: string[]; onChangeFavoriteDirectories: (dirs: string[]) => void; } @@ -47,13 +50,19 @@ export function PathSelector({ onChangeSelectedPath, recentPaths, usePickerSearch, + searchVariant = 'header', + searchQuery: controlledSearchQuery, + onChangeSearchQuery: onChangeSearchQueryProp, favoriteDirectories, onChangeFavoriteDirectories, }: PathSelectorProps) { const { theme } = useUnistyles(); const styles = stylesheet; const inputRef = useRef(null); - const [searchQuery, setSearchQuery] = useState(''); + + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); + const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; + const setSearchQuery = onChangeSearchQueryProp ?? setUncontrolledSearchQuery; const suggestedPaths = useMemo(() => { const homeDir = machineHomeDir || '/home'; @@ -147,7 +156,7 @@ export function PathSelector({ return ( <> - {usePickerSearch && ( + {usePickerSearch && searchVariant === 'header' && ( + {usePickerSearch && searchVariant === 'group' && ( + + + + + + )} + {filteredFavoritePaths.length > 0 && ( {filteredFavoritePaths.map((path, index) => { diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 0f8d49fe7..a7ca52f12 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -106,6 +106,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -142,6 +144,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -178,6 +182,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -216,6 +222,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -259,6 +267,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -311,6 +321,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -374,9 +386,11 @@ describe('settings', () => { experiments: false, useProfiles: false, alwaysShowContextSize: false, - useEnhancedSessionWizard: false, - usePickerSearch: false, - avatarStyle: 'brutalist', + useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, + avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, agentInputEnterToSend: true, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 45ac8bf12..139bd2f76 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -240,7 +240,10 @@ export const SettingsSchema = z.object({ 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'), - usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs'), + // 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'), @@ -312,6 +315,8 @@ export const settingsDefaults: Settings = { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -392,6 +397,18 @@ export function settingsParse(settings: unknown): Settings { result.preferredLanguage = 'zh-Hans'; } + // 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 in SettingsSchema.shape)) { diff --git a/sources/text/_default.ts b/sources/text/_default.ts index d6b14fa86..dfacda70f 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -212,6 +212,10 @@ export const en = { 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: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index b440c2b72..4a55163eb 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -213,6 +213,10 @@ export const ca: TranslationStructure = { 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: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 7a899354c..75d5eaa44 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -213,6 +213,10 @@ export const es: TranslationStructure = { 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: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 60922056d..1387529c0 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -242,6 +242,10 @@ export const it: TranslationStructure = { 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: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 041064dfc..72f3aa402 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -245,6 +245,10 @@ export const ja: TranslationStructure = { 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: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 48e0e9282..d44114e02 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -224,6 +224,10 @@ export const pl: TranslationStructure = { 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: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index d4fa5a2fa..5e6e625be 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -213,6 +213,10 @@ export const pt: TranslationStructure = { 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: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 2983f2ac2..49d57231e 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -195,6 +195,10 @@ export const ru: TranslationStructure = { 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: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index b60d797f2..bdbb5f19e 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -215,6 +215,10 @@ export const zhHans: TranslationStructure = { 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: { From b4c2f260f9212b3c48692f29e7239a32c0f3c662 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 11:59:13 +0100 Subject: [PATCH 025/106] fix(ui): align wizard search and env var cards --- sources/app/(app)/new/index.tsx | 61 ++-------------- .../components/EnvironmentVariableCard.tsx | 4 +- .../components/EnvironmentVariablesList.tsx | 54 +++++++------- sources/components/SearchableListSelector.tsx | 39 +++++++--- .../components/newSession/MachineSelector.tsx | 3 + .../components/newSession/PathSelector.tsx | 72 ++++++++++++++----- 6 files changed, 123 insertions(+), 110 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 75f3e1c76..86f2114e9 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -705,40 +705,10 @@ function NewSessionWizard() { .map(item => item.machine); }, [sessions, machines]); - const [wizardMachineSearchQuery, setWizardMachineSearchQuery] = React.useState(''); - const normalizedWizardMachineSearchQuery = React.useMemo(() => wizardMachineSearchQuery.trim().toLowerCase(), [wizardMachineSearchQuery]); - const favoriteMachineItems = React.useMemo(() => { return machines.filter(m => favoriteMachines.includes(m.id)); }, [machines, favoriteMachines]); - const wizardMachineMatchesSearch = React.useCallback((machine: (typeof machines)[number]) => { - if (!useMachinePickerSearch || !normalizedWizardMachineSearchQuery) return true; - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const id = (machine.id || '').toLowerCase(); - const query = normalizedWizardMachineSearchQuery; - return displayName.includes(query) || host.includes(query) || id.includes(query); - }, [normalizedWizardMachineSearchQuery, useMachinePickerSearch]); - - const wizardMachines = React.useMemo(() => { - return useMachinePickerSearch && normalizedWizardMachineSearchQuery - ? machines.filter(wizardMachineMatchesSearch) - : machines; - }, [machines, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); - - const wizardRecentMachines = React.useMemo(() => { - return useMachinePickerSearch && normalizedWizardMachineSearchQuery - ? recentMachines.filter(wizardMachineMatchesSearch) - : recentMachines; - }, [normalizedWizardMachineSearchQuery, recentMachines, useMachinePickerSearch, wizardMachineMatchesSearch]); - - const wizardFavoriteMachineItems = React.useMemo(() => { - return useMachinePickerSearch && normalizedWizardMachineSearchQuery - ? favoriteMachineItems.filter(wizardMachineMatchesSearch) - : favoriteMachineItems; - }, [favoriteMachineItems, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); - const recentPaths = React.useMemo(() => { if (!selectedMachineId) return []; @@ -1793,34 +1763,15 @@ function NewSessionWizard() { - {useMachinePickerSearch && ( - <> - - - - - - - - )} - { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 692461848..689a290e6 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -104,7 +104,6 @@ export function EnvironmentVariableCard({ backgroundColor: theme.colors.surface, borderRadius: 16, padding: 16, - marginHorizontal: Platform.select({ ios: 16, default: 12 }), marginBottom: 12, shadowColor: theme.colors.shadow.color, shadowOffset: { width: 0, height: 0.33 }, @@ -156,7 +155,8 @@ export function EnvironmentVariableCard({ {/* Toggle: Copy from remote machine */} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index bbebb2a4a..f18e301a7 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -1,5 +1,5 @@ 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'; @@ -187,31 +187,33 @@ export function EnvironmentVariablesList({ )} - - {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); - - const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; - 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)} - /> - ); - })} + + + {environmentVariables.map((envVar, index) => { + const varNameFromValue = extractVarNameFromValue(envVar.value); + const docs = getDocumentation(varNameFromValue || envVar.name); + + const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + 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)} + /> + ); + })} + ); diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 75f916d91..27c595bf0 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -79,6 +79,7 @@ export interface SearchableListSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; } const RECENT_ITEMS_DEFAULT_VISIBLE = 5; @@ -107,6 +108,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, + searchPlacement = 'header', } = props; const showAll = config.showAll !== false; @@ -243,18 +245,33 @@ export function SearchableListSelector(props: SearchableListSelectorProps) ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); + const hasRecentGroup = showRecent && filteredRecentItems.length > 0; + const hasFavoritesGroup = showFavorites && filteredFavoriteItems.length > 0; + const hasAllGroup = showAll && filteredItems.length > 0; + + const effectiveSearchPlacement = React.useMemo(() => { + if (!showSearch) return 'header' as const; + if (searchPlacement === 'recent' && !hasRecentGroup) return 'header' as const; + if (searchPlacement === 'favorites' && !hasFavoritesGroup) return 'header' as const; + if (searchPlacement === 'all' && !hasAllGroup) return 'header' as const; + return searchPlacement; + }, [hasAllGroup, hasFavoritesGroup, hasRecentGroup, searchPlacement, showSearch]); + + const searchNode = showSearch ? ( + + ) : null; + return ( <> - {showSearch && ( - - )} + {effectiveSearchPlacement === 'header' && searchNode} - {showRecent && filteredRecentItems.length > 0 && ( + {hasRecentGroup && ( + {effectiveSearchPlacement === 'recent' && searchNode} {recentItemsToShow.map((item, index, arr) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -284,8 +301,9 @@ export function SearchableListSelector(props: SearchableListSelectorProps) )} - {showFavorites && filteredFavoriteItems.length > 0 && ( + {hasFavoritesGroup && ( + {effectiveSearchPlacement === 'favorites' && searchNode} {filteredFavoriteItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -296,8 +314,9 @@ export function SearchableListSelector(props: SearchableListSelectorProps) )} - {showAll && filteredItems.length > 0 && ( + {hasAllGroup && ( + {effectiveSearchPlacement === 'all' && searchNode} {filteredItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 710816564..689bd146c 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -15,6 +15,7 @@ export interface MachineSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; searchPlaceholder?: string; recentSectionTitle?: string; favoritesSectionTitle?: string; @@ -32,6 +33,7 @@ export function MachineSelector({ showFavorites = true, showRecent = true, showSearch = true, + searchPlacement = 'header', searchPlaceholder = 'Type to filter machines...', recentSectionTitle = 'Recent Machines', favoritesSectionTitle = 'Favorite Machines', @@ -97,6 +99,7 @@ export function MachineSelector({ selectedItem={selectedMachine} onSelect={onSelect} onToggleFavorite={onToggleFavorite} + searchPlacement={searchPlacement} /> ); } diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 32f3d61e1..2f506a37f 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -180,21 +180,31 @@ export function PathSelector({ - {usePickerSearch && searchVariant === 'group' && ( - - - - + {usePickerSearch && searchVariant === 'group' && 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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} )} @@ -220,7 +230,7 @@ export function PathSelector({ )} - {filteredRecentPaths.length > 0 && ( + {filteredRecentPaths.length > 0 && searchVariant !== 'group' && ( {filteredRecentPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; @@ -243,7 +253,35 @@ export function PathSelector({ )} - {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( + {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length === 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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && searchVariant !== 'group' && ( {filteredSuggestedPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; From f0929d13536c7000598ecda90d20563c4a353329 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 12:27:32 +0100 Subject: [PATCH 026/106] fix(ui): unify picker selection and profile icons --- sources/app/(app)/new/index.tsx | 28 +++++-------- sources/app/(app)/new/pick/profile.tsx | 20 +++------- sources/components/AgentInput.tsx | 4 +- .../components/EnvironmentVariableCard.tsx | 40 +++++++++---------- sources/components/ProfileEditForm.tsx | 26 +++++++----- sources/components/SearchableListSelector.tsx | 23 ++++++++--- .../components/newSession/PathSelector.tsx | 8 ++++ sources/text/_default.ts | 2 +- 8 files changed, 78 insertions(+), 73 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 86f2114e9..8c7280f63 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -858,15 +858,6 @@ function NewSessionWizard() { scrollToWizardSection('profile'); }, [scrollToWizardSection]); - const profileIconContainerStyle = React.useMemo(() => ({ - width: 29, - height: 29, - borderRadius: 14.5, - backgroundColor: theme.colors.surfacePressed, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }), [theme.colors.surfacePressed]); - const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { const primary = getProfilePrimaryCli(profile); const iconName = @@ -876,15 +867,13 @@ function NewSessionWizard() { primary === 'multi' ? 'sparkles-outline' : 'person-outline'; return ( - - - + ); - }, [profileIconContainerStyle, theme.colors.textSecondary]); + }, [theme.colors.textSecondary]); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { @@ -1582,10 +1571,11 @@ function NewSessionWizard() { } + leftElement={} showChevron={false} selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} + pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={ selectProfile(profile.id)} rightElement={ @@ -1662,6 +1653,7 @@ function NewSessionWizard() { leftElement={renderProfileLeftElement(profile)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} disabled={!availability.available} onPress={() => selectProfile(profile.id)} rightElement={ diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 5dc51e4be..5c543b3b4 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -25,15 +25,6 @@ export default function ProfilePickerScreen() { const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; - const profileIconContainerStyle = React.useMemo(() => ({ - width: 29, - height: 29, - borderRadius: 14.5, - backgroundColor: theme.colors.surfacePressed, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }), [theme.colors.surfacePressed]); - const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { const primary = getProfilePrimaryCli(profile); const iconName = @@ -43,11 +34,9 @@ export default function ProfilePickerScreen() { primary === 'multi' ? 'sparkles-outline' : 'person-outline'; return ( - - - + ); - }, [profileIconContainerStyle, theme.colors.textSecondary]); + }, [theme.colors.textSecondary]); const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); @@ -157,10 +146,11 @@ export default function ProfilePickerScreen() { } + icon={} onPress={() => setProfileParamAndClose('')} showChevron={false} selected={selectedId === ''} + pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={selectedId === '' ? : null} @@ -182,6 +172,7 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={ {isSelected && ( @@ -228,6 +219,7 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={ {isSelected && ( diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index c11500d48..124578271 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -334,8 +334,8 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === null) return 'radio-button-off-outline'; - if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; + if (props.profileId === null) return 'settings-outline'; + if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'settings-outline'; const primary = getProfilePrimaryCli(currentProfile); if (primary === 'claude') return 'cloud-outline'; if (primary === 'codex') return 'terminal-outline'; diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 689a290e6..f06be84a5 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -69,6 +69,13 @@ export function EnvironmentVariableCard({ }: EnvironmentVariableCardProps) { const { theme } = useUnistyles(); + const secondaryTextStyle = React.useMemo(() => ({ + fontSize: Platform.select({ ios: 15, default: 14 }), + 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); @@ -143,10 +150,9 @@ export function EnvironmentVariableCard({ {/* Description */} {description && ( {description} @@ -156,9 +162,8 @@ export function EnvironmentVariableCard({ Copy from remote machine @@ -197,36 +202,32 @@ export function EnvironmentVariableCard({ {remoteValue === undefined ? ( Checking remote machine... ) : remoteValue === null ? ( Value not found ) : ( <> Value found {showRemoteDiffersWarning && ( Differs from documented value: {expectedValue} @@ -238,11 +239,10 @@ export function EnvironmentVariableCard({ {useRemoteVariable && !isSecret && !machineId && ( Select a machine to check if variable exists @@ -251,11 +251,10 @@ export function EnvironmentVariableCard({ {/* Security message for secrets */} {isSecret && ( Secret value - not retrieved for security @@ -263,10 +262,9 @@ export function EnvironmentVariableCard({ {/* Value label */} {useRemoteVariable ? 'Default value:' : 'Value:'} @@ -297,10 +295,9 @@ export function EnvironmentVariableCard({ {/* Default override warning */} {showDefaultOverrideWarning && !isSecret && ( Overriding documented default: {expectedValue} @@ -308,10 +305,9 @@ export function EnvironmentVariableCard({ {/* Session preview */} Session will receive: {variable.name} = { isSecret diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index b43bc1297..803e4b99e 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -32,6 +32,7 @@ export function ProfileEditForm({ }: ProfileEditFormProps) { const { theme } = useUnistyles(); const styles = stylesheet; + const groupStyle = React.useMemo(() => ({ marginBottom: 8 }), []); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -108,7 +109,7 @@ export function ProfileEditForm({ return ( - + {profile.isBuiltIn && profileDocs?.setupGuideUrl && ( - + } @@ -132,11 +133,11 @@ export function ProfileEditForm({ )} - + - + {[ { 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' }, @@ -162,12 +163,13 @@ export function ProfileEditForm({ onPress={() => setDefaultPermissionMode(option.value)} showChevron={false} selected={defaultPermissionMode === option.value} + pressableStyle={defaultPermissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={index < array.length - 1} /> ))} - + - + + + diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 27c595bf0..5aa021cfa 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -235,6 +235,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) onPress={() => onSelect(item)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} /> ); @@ -257,7 +258,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) return searchPlacement; }, [hasAllGroup, hasFavoritesGroup, hasRecentGroup, searchPlacement, showSearch]); - const searchNode = showSearch ? ( + const searchNodeHeader = showSearch ? ( (props: SearchableListSelectorProps) /> ) : null; + const searchNodeEmbedded = showSearch ? ( + + ) : null; + return ( <> - {effectiveSearchPlacement === 'header' && searchNode} + {effectiveSearchPlacement === 'header' && searchNodeHeader} {hasRecentGroup && ( - {effectiveSearchPlacement === 'recent' && searchNode} + {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} {recentItemsToShow.map((item, index, arr) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -303,7 +316,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {hasFavoritesGroup && ( - {effectiveSearchPlacement === 'favorites' && searchNode} + {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} {filteredFavoriteItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -316,7 +329,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {hasAllGroup && ( - {effectiveSearchPlacement === 'all' && searchNode} + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} {filteredItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 2f506a37f..1fde87ad0 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -186,6 +186,10 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + containerStyle={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} /> {filteredRecentPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; @@ -259,6 +263,10 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + containerStyle={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} /> {filteredSuggestedPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; diff --git a/sources/text/_default.ts b/sources/text/_default.ts index dfacda70f..024c7f6eb 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -895,7 +895,7 @@ export const en = { // Profile management feature title: 'Profiles', subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', + noProfile: 'Default', noProfileDescription: 'Use default environment settings', defaultModel: 'Default Model', addProfile: 'Add Profile', From ecc62469175b7331756dfc32c0eb35d13434de90 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 13:34:50 +0100 Subject: [PATCH 027/106] fix(ui): use profile compatibility glyph icons --- sources/app/(app)/new/index.tsx | 20 ++------- sources/app/(app)/new/pick/profile.tsx | 16 ++----- .../newSession/ProfileCompatibilityIcon.tsx | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 sources/components/newSession/ProfileCompatibilityIcon.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 8c7280f63..e039f020b 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -34,6 +34,7 @@ import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from ' import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -859,21 +860,8 @@ function NewSessionWizard() { }, [scrollToWizardSection]); const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { - const primary = getProfilePrimaryCli(profile); - const iconName = - primary === 'claude' ? 'cloud-outline' : - primary === 'codex' ? 'terminal-outline' : - primary === 'gemini' ? 'planet-outline' : - primary === 'multi' ? 'sparkles-outline' : - 'person-outline'; - return ( - - ); - }, [theme.colors.textSecondary]); + return ; + }, []); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { @@ -1339,7 +1327,7 @@ function NewSessionWizard() { 1. - {useProfiles ? 'Choose AI Profile' : 'Select AI'} + {useProfiles ? 'Choose AI Profile & Backend' : 'Select AI'} diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 5c543b3b4..65148168c 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -8,11 +8,12 @@ import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; -import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { useUnistyles } from 'react-native-unistyles'; import { randomUUID } from 'expo-crypto'; import { AIBackendProfile } from '@/sync/settings'; import { Modal } from '@/modal'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; export default function ProfilePickerScreen() { const { theme } = useUnistyles(); @@ -26,17 +27,8 @@ export default function ProfilePickerScreen() { const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { - const primary = getProfilePrimaryCli(profile); - const iconName = - primary === 'claude' ? 'cloud-outline' : - primary === 'codex' ? 'terminal-outline' : - primary === 'gemini' ? 'planet-outline' : - primary === 'multi' ? 'sparkles-outline' : - 'person-outline'; - return ( - - ); - }, [theme.colors.textSecondary]); + return ; + }, []); const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx new file mode 100644 index 000000000..08d536bf5 --- /dev/null +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -0,0 +1,43 @@ +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'; + +type Props = { + profile: Pick; + size?: number; + style?: ViewStyle; +}; + +export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { + const { theme } = useUnistyles(); + + const glyph = + profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : + profile.compatibility?.claude ? '✳' : + profile.compatibility?.codex ? '꩜' : + '•'; + + return ( + + + {glyph} + + + ); +} + From 3debc6ca129a27329c438fb06f00cc150b849d41 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:32:19 +0100 Subject: [PATCH 028/106] fix(pickers): keep search visible with zero matches --- sources/components/SearchableListSelector.tsx | 137 ++++++++++++------ 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 5aa021cfa..2c4308c17 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -120,22 +120,29 @@ export function SearchableListSelector(props: SearchableListSelectorProps) return new Set(favoriteItems.map((item) => config.getItemId(item))); }, [favoriteItems, config]); + const baseRecentItems = React.useMemo(() => { + return recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + }, [recentItems, favoriteIds, config]); + + 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 filteredRecentItems = React.useMemo(() => { - const base = recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); - if (!inputText.trim()) return base; - return base.filter((item) => config.filterItem(item, inputText, context)); - }, [recentItems, favoriteIds, inputText, config, context]); + if (!inputText.trim()) return baseRecentItems; + return baseRecentItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseRecentItems, inputText, config, context]); const filteredItems = React.useMemo(() => { - const base = items.filter((item) => !favoriteIds.has(config.getItemId(item))); - if (!inputText.trim()) return base; - return base.filter((item) => config.filterItem(item, inputText, context)); - }, [items, favoriteIds, inputText, config, context]); + if (!inputText.trim()) return baseAllItems; + return baseAllItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseAllItems, inputText, config, context]); const handleInputChange = (text: string) => { setInputText(text); @@ -246,17 +253,29 @@ export function SearchableListSelector(props: SearchableListSelectorProps) ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); - const hasRecentGroup = showRecent && filteredRecentItems.length > 0; - const hasFavoritesGroup = showFavorites && filteredFavoriteItems.length > 0; - const hasAllGroup = showAll && filteredItems.length > 0; + 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 === 'recent' && !hasRecentGroup) return 'header' as const; - if (searchPlacement === 'favorites' && !hasFavoritesGroup) return 'header' as const; - if (searchPlacement === 'all' && !hasAllGroup) return 'header' as const; - return searchPlacement; - }, [hasAllGroup, hasFavoritesGroup, hasRecentGroup, searchPlacement, showSearch]); + 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 ? ( (props: SearchableListSelectorProps) /> ) : null; + const renderEmptyRow = (title: string) => ( + + ); + return ( <> {effectiveSearchPlacement === 'header' && searchNodeHeader} - {hasRecentGroup && ( + {shouldRenderRecentGroup && ( {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} - {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 + ? 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 && ( (props: SearchableListSelectorProps) )} - {hasFavoritesGroup && ( + {shouldRenderFavoritesGroup && ( {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} - {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); - })} + {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); + })} )} - {hasAllGroup && ( + {shouldRenderAllGroup && ( {effectiveSearchPlacement === 'all' && searchNodeEmbedded} - {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); - })} + {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); + })} + + )} + + {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && ( + + {effectiveSearchPlacement !== 'header' && searchNodeEmbedded} + {renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)} )} From 3ca8e85ca9eb16f7f480bf06135c6573849b3bae Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:32:28 +0100 Subject: [PATCH 029/106] fix(ui): stabilize chip icons --- sources/components/AgentInput.tsx | 24 +++++++------------ .../newSession/ProfileCompatibilityIcon.tsx | 6 +---- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 124578271..71ff147f6 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -22,7 +22,7 @@ import { Theme } from '@/theme'; import { t } from '@/text'; import { Metadata } from '@/sync/storageTypes'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile, getProfilePrimaryCli } from '@/sync/profileUtils'; +import { getBuiltInProfile } from '@/sync/profileUtils'; interface AgentInputProps { value: string; @@ -334,15 +334,9 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === null) return 'settings-outline'; - if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'settings-outline'; - const primary = getProfilePrimaryCli(currentProfile); - if (primary === 'claude') return 'cloud-outline'; - if (primary === 'codex') return 'terminal-outline'; - if (primary === 'gemini') return 'planet-outline'; - if (primary === 'multi') return 'sparkles-outline'; - return 'person-outline'; - }, [currentProfile, props.profileId]); + // 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 @@ -777,11 +771,11 @@ export const AgentInput = React.memo(React.forwardRef - + - + {glyph} ); } - From f6ce61ce64a33f7319054118753af3953b52b865 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:33:08 +0100 Subject: [PATCH 030/106] feat(profiles): add favorites and group lists --- sources/app/(app)/new/index.tsx | 269 ++++++++++++----------- sources/app/(app)/new/pick/profile.tsx | 271 ++++++++++++----------- sources/app/(app)/settings/profiles.tsx | 274 ++++++++++++++++++------ sources/sync/profileGrouping.ts | 46 ++++ sources/sync/profileMutations.ts | 38 ++++ sources/sync/settings.spec.ts | 7 + sources/sync/settings.ts | 4 + 7 files changed, 597 insertions(+), 312 deletions(-) create mode 100644 sources/sync/profileGrouping.ts create mode 100644 sources/sync/profileMutations.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index e039f020b..8792f7bb2 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -24,7 +24,6 @@ import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAge 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'; @@ -35,6 +34,8 @@ import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -289,6 +290,7 @@ function NewSessionWizard() { 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) @@ -298,6 +300,23 @@ 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(); @@ -633,30 +652,11 @@ function NewSessionWizard() { }, [router, selectedMachineId]); 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', - }; - openProfileEdit(newProfile); + openProfileEdit(createEmptyCustomProfile()); }, [openProfileEdit]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicated: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - openProfileEdit(duplicated); + openProfileEdit(duplicateProfileForEdit(profile)); }, [openProfileEdit]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { @@ -863,6 +863,72 @@ function NewSessionWizard() { return ; }, []); + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + {!profile.isBuiltIn && ( + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + > + + + )} + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEdit, + 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[] = []; @@ -919,11 +985,7 @@ function NewSessionWizard() { // 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, - }; + profileToSave = convertBuiltInProfileToCustom(savedProfile); } const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); @@ -1555,7 +1617,7 @@ function NewSessionWizard() { {useProfiles ? ( <> - + } /> - {DEFAULT_PROFILES.map((profileDisplay, index) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === DEFAULT_PROFILES.length - 1 && profiles.length === 0; + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} - return ( - selectProfile(profile.id)} - rightElement={ - - - - - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - - } - showDivider={!isLast} - /> - ); - })} - {profiles.map((profile, index) => { + {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 ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + {nonFavoriteBuiltInProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; - const isLast = index === profiles.length - 1; - + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); return ( selectProfile(profile.id)} - rightElement={ - - - - - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - - } + onPress={() => availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} showDivider={!isLast} /> ); diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 65148168c..d3617b37b 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -8,12 +8,12 @@ import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { useUnistyles } from 'react-native-unistyles'; -import { randomUUID } from 'expo-crypto'; import { AIBackendProfile } from '@/sync/settings'; import { Modal } from '@/modal'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; export default function ProfilePickerScreen() { const { theme } = useUnistyles(); @@ -22,6 +22,7 @@ export default function ProfilePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string; machineId?: string }>(); const useProfiles = useSetting('useProfiles'); 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; @@ -48,31 +49,29 @@ export default function ProfilePickerScreen() { 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(() => { - 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', - }; - openProfileEdit(newProfile); + openProfileEdit(createEmptyCustomProfile()); }, [openProfileEdit]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicated: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - openProfileEdit(duplicated); + openProfileEdit(duplicateProfileForEdit(profile)); }, [openProfileEdit]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { @@ -97,6 +96,72 @@ export default function ProfilePickerScreen() { ); }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); + const renderProfileRowRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + {!profile.isBuiltIn && ( + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + > + + + )} + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEdit, + theme.colors.button.primary.background, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, + ]); + return ( <> ) : ( <> - - } - onPress={handleAddProfile} - showChevron={false} - /> - - - - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + {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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} - const isSelected = selectedId === profile.id; - return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={ - - {isSelected && ( - - )} - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - style={{ marginLeft: 16 }} - > - - - - } - /> - ); - })} + {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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} - {profiles.map((profile) => { + + {nonFavoriteBuiltInProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - {isSelected && ( - - )} - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - style={{ marginLeft: 16 }} - > - - - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - style={{ marginLeft: 16 }} - > - - - - } + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} /> ); })} + + + } + onPress={handleAddProfile} + showChevron={false} + /> + )} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 75fbd1b1a..64460faba 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -9,11 +9,13 @@ 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'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { Switch } from '@/components/Switch'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -26,6 +28,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel 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); @@ -54,17 +57,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } 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); }; @@ -73,6 +66,11 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setShowAddForm(true); }; + const handleDuplicateProfile = (profile: AIBackendProfile) => { + setEditingProfile(duplicateProfileForEdit(profile)); + setShowAddForm(true); + }; + const handleDeleteProfile = async (profile: AIBackendProfile) => { const confirmed = await Modal.confirm( t('profiles.delete.title'), @@ -115,6 +113,23 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel 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 handleSaveProfile = (profile: AIBackendProfile) => { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { @@ -126,13 +141,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel // 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 - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; + const newProfile = convertBuiltInProfileToCustom(profile); // Check for duplicate names (excluding the new profile) const isDuplicate = profiles.some(p => @@ -186,93 +195,224 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel onPress={() => handleSelectProfile(null)} showChevron={false} selected={selectedProfileId === null} + pressableStyle={selectedProfileId === null ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={selectedProfileId === null ? : null} /> - - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + handleEditProfile(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + {!profile.isBuiltIn && ( + { + e.stopPropagation(); + void handleDeleteProfile(profile); + }} + > + + + )} + + )} + /> + ); + })} + + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + handleEditProfile(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + { + e.stopPropagation(); + void handleDeleteProfile(profile); + }} + > + + + + )} + /> + ); + })} + + )} + + {nonFavoriteBuiltInProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); return ( } + leftElement={} onPress={() => handleSelectProfile(profile.id)} showChevron={false} selected={isSelected} - rightElement={ - - {isSelected && ( + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + + - )} + handleEditProfile(profile)} + onPress={(e) => { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} > - - - - } - /> - ); - })} - - {profiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const subtitleParts: string[] = [t('profiles.defaultModel')]; - if (profile.tmuxConfig?.sessionName) subtitleParts.push(`tmux: ${profile.tmuxConfig.sessionName}`); - if (profile.tmuxConfig?.tmpDir) subtitleParts.push(`dir: ${profile.tmuxConfig.tmpDir}`); - - return ( - } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={ - - {isSelected && ( - )} + handleEditProfile(profile)} + onPress={(e) => { + e.stopPropagation(); + handleEditProfile(profile); + }} > void handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} + onPress={(e) => { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} > - + - } + )} /> ); })} + + } 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..736a0dad3 --- /dev/null +++ b/sources/sync/profileMutations.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'expo-crypto'; +import { AIBackendProfile } from '@/sync/settings'; + +export function createEmptyCustomProfile(): AIBackendProfile { + return { + id: randomUUID(), + name: '', + anthropicConfig: {}, + 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/settings.spec.ts b/sources/sync/settings.spec.ts index a7ca52f12..43c615afc 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -126,6 +126,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -164,6 +165,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); @@ -202,6 +204,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = {}; @@ -242,6 +245,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -287,6 +291,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; expect(applySettings(currentSettings, {})).toEqual(currentSettings); @@ -341,6 +346,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: any = { @@ -407,6 +413,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 139bd2f76..66a9625c0 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -268,6 +268,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({ @@ -338,6 +340,8 @@ export const settingsDefaults: Settings = { favoriteDirectories: [], // Favorite machines (empty by default) favoriteMachines: [], + // Favorite profiles (empty by default) + favoriteProfiles: [], // Dismissed CLI warnings (empty by default) dismissedCLIWarnings: { perMachine: {}, global: {} }, }; From 80c1abdd5e13572f7de0094379980afb4702ba58 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:49:45 +0100 Subject: [PATCH 031/106] fix(paths): keep search visible in path picker --- .../components/newSession/PathSelector.tsx | 247 +++++++++++++----- 1 file changed, 182 insertions(+), 65 deletions(-) diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 1fde87ad0..97ff61022 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -108,6 +108,61 @@ export function PathSelector({ 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, + ]); + + 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'; @@ -180,57 +235,88 @@ export function PathSelector({ - {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length > 0 && ( + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( - - {filteredRecentPaths.map((path, index) => { - const isSelected = selectedPath.trim() === path; - const isLast = index === filteredRecentPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( + {effectiveGroupSearchPlacement === 'recent' && ( + + )} + {filteredRecentPaths.length === 0 + ? ( } - onPress={() => setPathAndFocus(path)} - selected={isSelected} + title={showNoMatchesRow ? 'No matches' : 'No recent paths'} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} + showDivider={false} + disabled={true} /> - ); - })} + ) + : 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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} )} - {filteredFavoritePaths.length > 0 && ( + {shouldRenderFavoritesGroup && ( - {filteredFavoritePaths.map((path, index) => { - const isSelected = selectedPath.trim() === path; - const isLast = index === filteredFavoritePaths.length - 1; - return ( + {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && ( + + )} + {filteredFavoritePaths.length === 0 + ? ( } - onPress={() => setPathAndFocus(path)} - selected={isSelected} + title={showNoMatchesRow ? 'No matches' : 'No favorite paths'} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, true)} - showDivider={!isLast} + showDivider={false} + disabled={true} /> - ); - })} + ) + : filteredFavoritePaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> + ); + })} )} @@ -257,35 +343,46 @@ export function PathSelector({ )} - {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( + {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && ( - - {filteredSuggestedPaths.map((path, index) => { - const isSelected = selectedPath.trim() === path; - const isLast = index === filteredSuggestedPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( + {effectiveGroupSearchPlacement === 'suggested' && ( + + )} + {filteredSuggestedPaths.length === 0 + ? ( } - onPress={() => setPathAndFocus(path)} - selected={isSelected} + title={showNoMatchesRow ? 'No matches' : 'No suggested paths'} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} + showDivider={false} + disabled={true} /> - ); - })} + ) + : 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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} )} @@ -311,6 +408,26 @@ export function PathSelector({ })} )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && ( + + + + + )} ); } From 5c9702cb78638e6c988a1f93ce8747dbde0fb0d5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:49:53 +0100 Subject: [PATCH 032/106] fix(ui): adjust icons --- sources/components/AgentInput.tsx | 2 +- sources/components/newSession/ProfileCompatibilityIcon.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 71ff147f6..713286947 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -772,7 +772,7 @@ export const AgentInput = React.memo(React.forwardRef diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index fd6eb49aa..d8ba1bf08 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -12,6 +12,7 @@ type Props = { export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { const { theme } = useUnistyles(); + const glyphSize = Math.round(size * 0.75); const glyph = profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : @@ -31,7 +32,7 @@ export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { style, ]} > - + {glyph} From a9eb77cdaa3a17276f3a3a558aa2efab4e6fe7a8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:55:56 +0100 Subject: [PATCH 033/106] fix(paths): preserve search focus when moving --- sources/components/SearchHeader.tsx | 9 ++++++ .../components/newSession/PathSelector.tsx | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 7b9b9976f..5bd43bbb7 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -12,6 +12,9 @@ export interface SearchHeaderProps { containerStyle?: StyleProp; autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; autoCorrect?: boolean; + inputRef?: React.Ref; + onFocus?: () => void; + onBlur?: () => void; } const INPUT_BORDER_RADIUS = 10; @@ -58,6 +61,9 @@ export function SearchHeader({ containerStyle, autoCapitalize = 'none', autoCorrect = false, + inputRef, + onFocus, + onBlur, }: SearchHeaderProps) { const { theme } = useUnistyles(); const styles = stylesheet; @@ -73,12 +79,15 @@ export function SearchHeader({ style={{ marginRight: 8 }} /> {value.trim().length > 0 && ( diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 97ff61022..c22c8b98a 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from 'react'; +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'; @@ -59,6 +59,8 @@ export function PathSelector({ 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; @@ -157,6 +159,20 @@ export function PathSelector({ 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'; @@ -242,6 +258,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, @@ -285,6 +304,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, @@ -350,6 +372,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, @@ -415,6 +440,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, From 7c5b888215deb8d4d5e9ab73f441d93e372961f6 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:56:05 +0100 Subject: [PATCH 034/106] fix(ui): tweak profile glyph sizing --- sources/components/newSession/ProfileCompatibilityIcon.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index d8ba1bf08..3869bc6ff 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -12,7 +12,6 @@ type Props = { export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { const { theme } = useUnistyles(); - const glyphSize = Math.round(size * 0.75); const glyph = profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : @@ -20,6 +19,11 @@ export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { profile.compatibility?.codex ? '꩜' : '•'; + const glyphSize = + glyph === '✳' ? Math.round(size * 0.85) : + glyph === '✳꩜' ? Math.round(size * 0.7) : + Math.round(size * 0.75); + return ( Date: Wed, 14 Jan 2026 15:13:40 +0100 Subject: [PATCH 035/106] fix(ui): polish wizard labels and focus styles --- sources/app/(app)/new/index.tsx | 93 +++++++++++-------- sources/components/ProfileEditForm.tsx | 2 +- sources/components/SearchHeader.tsx | 9 ++ .../newSession/ProfileCompatibilityIcon.tsx | 2 +- 4 files changed, 63 insertions(+), 43 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 8792f7bb2..edda7b53f 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -15,7 +15,7 @@ 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'; @@ -124,14 +124,14 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 marginTop: 12, paddingHorizontal: 16, }, - sectionHeader: { - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - marginTop: 12, - ...Typography.default('semiBold') - }, + sectionHeader: { + fontSize: 17, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + marginTop: 12, + ...Typography.default('semiBold') + }, sectionDescription: { fontSize: 12, color: theme.colors.textSecondary, @@ -827,7 +827,7 @@ function NewSessionWizard() { }, [agentType, permissionMode]); // Scroll to section helpers - for AgentInput button clicks - const wizardSectionOffsets = React.useRef<{ profile?: number; machine?: number; path?: number; permission?: number }>({}); + const wizardSectionOffsets = React.useRef<{ profile?: 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; @@ -1389,13 +1389,13 @@ function NewSessionWizard() { 1. - {useProfiles ? 'Choose AI Profile & Backend' : 'Select AI'} + {useProfiles ? 'Select AI Profile & Backend' : 'Select AI'} {useProfiles - ? 'Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' - : 'Choose which AI runs your session.'} + ? 'Select which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' + : 'Select which AI runs your session.'} {/* Missing CLI Installation Banners */} @@ -1816,28 +1816,28 @@ function NewSessionWizard() { {/* Section 4: Permission Mode */} - - - 4. - - Permission Mode - - - - {(agentType === 'codex' - ? [ - { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.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) => ( + + + 4. + + Select Permission Mode + + + + {(agentType === 'codex' + ? [ + { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-half-outline' }, + { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-half-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) => ( - + - - - - + {/* Section 5: Session Type */} + + + 5. + + Select Session Type + + + + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + + + + - {/* Section 5: AgentInput - Sticky at bottom */} + {/* AgentInput - Sticky at bottom */} 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> {[ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-half-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' }, diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 5bd43bbb7..0e3c2103f 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -48,6 +48,15 @@ const stylesheet = StyleSheet.create((theme) => ({ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, paddingVertical: 0, + ...(Platform.select({ + web: { + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + }, + default: {}, + }) as object), }, clearIcon: { marginLeft: 8, diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index 3869bc6ff..ce2fb9b86 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -20,7 +20,7 @@ export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { '•'; const glyphSize = - glyph === '✳' ? Math.round(size * 0.85) : + glyph === '✳' ? Math.round(size * 0.9) : glyph === '✳꩜' ? Math.round(size * 0.7) : Math.round(size * 0.75); From d30d5db7a25f20877c56405232dfd1f66d7ba4b7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 15:28:31 +0100 Subject: [PATCH 036/106] fix(ui): simplify wizard headers and list icons --- sources/app/(app)/new/index.tsx | 57 ++++++-------- sources/app/(app)/new/pick/profile.tsx | 30 ++++--- sources/app/(app)/settings/profiles.tsx | 78 +++++++++---------- sources/components/SearchableListSelector.tsx | 2 +- .../components/newSession/MachineSelector.tsx | 4 +- .../components/newSession/PathSelector.tsx | 2 +- 6 files changed, 82 insertions(+), 91 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index edda7b53f..b85d582e8 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -883,7 +883,7 @@ function NewSessionWizard() { > @@ -1386,7 +1386,6 @@ function NewSessionWizard() { {/* Section 1: Profile Management */} - 1. {useProfiles ? 'Select AI Profile & Backend' : 'Select AI'} @@ -1617,28 +1616,6 @@ function NewSessionWizard() { {useProfiles ? ( <> - - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={ - - - - } - /> - - {favoriteProfileItems.length > 0 && ( {favoriteProfileItems.map((profile, index) => { @@ -1691,6 +1668,26 @@ function NewSessionWizard() { )} + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={ + + + + } + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> {nonFavoriteBuiltInProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; @@ -1761,7 +1758,6 @@ function NewSessionWizard() { {/* Section 2: Machine Selection */} - 2. Select Machine @@ -1796,7 +1792,6 @@ function NewSessionWizard() { {/* Section 3: Working Directory */} - 3. Select Working Directory @@ -1818,11 +1813,10 @@ function NewSessionWizard() { {/* Section 4: Permission Mode */} - 4. - - Select Permission Mode - - + + Select Permission Mode + + {(agentType === 'codex' ? [ @@ -1870,7 +1864,6 @@ function NewSessionWizard() { {/* Section 5: Session Type */} - 5. Select Session Type diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index d3617b37b..1db728d78 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -116,7 +116,7 @@ export default function ProfilePickerScreen() { > @@ -190,21 +190,6 @@ export default function ProfilePickerScreen() { ) : ( <> - - } - onPress={() => setProfileParamAndClose('')} - showChevron={false} - selected={selectedId === ''} - pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={selectedId === '' - ? - : null} - /> - - {favoriteProfileItems.length > 0 && ( {favoriteProfileItems.map((profile, index) => { @@ -253,6 +238,19 @@ export default function ProfilePickerScreen() { )} + } + onPress={() => setProfileParamAndClose('')} + showChevron={false} + selected={selectedId === ''} + pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={selectedId === '' + ? + : null} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> {nonFavoriteBuiltInProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; const isLast = index === nonFavoriteBuiltInProfiles.length - 1; diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 64460faba..f3a5c136e 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -227,19 +227,19 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel style={{ opacity: isSelected ? 1 : 0 }} /> - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + { @@ -302,19 +302,19 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel style={{ opacity: isSelected ? 1 : 0 }} /> - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + { @@ -374,19 +374,19 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel style={{ opacity: isSelected ? 1 : 0 }} /> - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + { diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 2c4308c17..968e02b8d 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -197,7 +197,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) > diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 689bd146c..402637f3e 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -51,14 +51,14 @@ export function MachineSelector({ getItemIcon: () => ( ), getRecentItemIcon: () => ( ), diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index c22c8b98a..1cc8bcf48 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -217,7 +217,7 @@ export function PathSelector({ > From ba0c6c3d943bff0847fbc5a4f9d122b87d21f29c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 15:39:01 +0100 Subject: [PATCH 037/106] fix(ui): lock backend chip when profile selected --- sources/app/(app)/new/index.tsx | 22 +++++++++++++++++++--- sources/app/(app)/new/pick/profile.tsx | 2 +- sources/app/(app)/settings/profiles.tsx | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index b85d582e8..189f64c21 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -358,7 +358,7 @@ 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?) -> claude if (prev === 'claude') return 'codex'; @@ -1026,6 +1026,22 @@ function NewSessionWizard() { }); }, [router, selectedMachineId, selectedProfileId]); + const handleAgentClick = React.useCallback(() => { + if (useProfiles && selectedProfileId !== null) { + 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; + } + + handleAgentCycle(); + }, [handleAgentCycle, handleProfileClick, selectedProfileId, useProfiles]); + const handlePathClick = React.useCallback(() => { if (selectedMachineId) { router.push({ @@ -1671,7 +1687,7 @@ function NewSessionWizard() { } + leftElement={} showChevron={false} selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} @@ -1682,7 +1698,7 @@ function NewSessionWizard() { name="checkmark-circle" size={24} color={theme.colors.button.primary.background} - style={{ opacity: selectedProfileId ? 1 : 0 }} + style={{ opacity: selectedProfileId ? 0 : 1 }} /> } diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 1db728d78..69666fc53 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -241,7 +241,7 @@ export default function ProfilePickerScreen() { } + icon={} onPress={() => setProfileParamAndClose('')} showChevron={false} selected={selectedId === ''} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index f3a5c136e..a6c4cb546 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -197,7 +197,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel selected={selectedProfileId === null} pressableStyle={selectedProfileId === null ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={selectedProfileId === null - ? + ? : null} /> From e8e6c4bbdc03ff9e2e563395889cd2ea829afe8b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 15:44:44 +0100 Subject: [PATCH 038/106] fix(profiles): gate backend changes by profile compatibility --- sources/app/(app)/new/index.tsx | 54 ++++++++++++++++--------- sources/app/(app)/settings/profiles.tsx | 17 +------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 189f64c21..167911fd3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1028,19 +1028,34 @@ function NewSessionWizard() { const handleAgentClick = React.useCallback(() => { if (useProfiles && selectedProfileId !== null) { - 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 }, - ], - ); + 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 currentIndex = supportedAgents.indexOf(agentType); + const nextIndex = (currentIndex + 1) % supportedAgents.length; + setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? 'claude'); return; } handleAgentCycle(); - }, [handleAgentCycle, handleProfileClick, selectedProfileId, useProfiles]); + }, [agentType, allowGemini, handleAgentCycle, handleProfileClick, profileMap, selectedProfileId, setAgentType, useProfiles]); const handlePathClick = React.useCallback(() => { if (selectedMachineId) { @@ -1692,16 +1707,17 @@ function NewSessionWizard() { selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={ - - - - } + rightElement={!selectedProfileId + ? ( + + + + ) + : null} showDivider={nonFavoriteBuiltInProfiles.length > 0} /> {nonFavoriteBuiltInProfiles.map((profile, index) => { diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index a6c4cb546..1a847a9f5 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -187,21 +187,6 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel return ( - - } - onPress={() => handleSelectProfile(null)} - showChevron={false} - selected={selectedProfileId === null} - pressableStyle={selectedProfileId === null ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={selectedProfileId === null - ? - : null} - /> - - {favoriteProfileItems.length > 0 && ( {favoriteProfileItems.map((profile) => { @@ -350,7 +335,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel )} - + {nonFavoriteBuiltInProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; const isFavorite = favoriteProfileIdSet.has(profile.id); From f4b1ffea5984baf50b9a1c2c348c826203f24cd5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 16:31:53 +0100 Subject: [PATCH 039/106] feat(profiles): add backend compatibility and wizard AI step --- sources/app/(app)/new/index.tsx | 384 ++++++++++-------- sources/components/ProfileEditForm.tsx | 50 +++ sources/components/SearchHeader.tsx | 39 +- .../newSession/ProfileCompatibilityIcon.tsx | 22 +- 4 files changed, 310 insertions(+), 185 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 167911fd3..4197fc325 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -763,24 +763,14 @@ function NewSessionWizard() { // 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) { @@ -791,7 +781,7 @@ function NewSessionWizard() { setPermissionMode(profile.defaultPermissionMode as PermissionMode); } } - }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); + }, [agentType, allowGemini, profileMap]); // Handle profile route param from picker screens React.useEffect(() => { @@ -812,6 +802,27 @@ function NewSessionWizard() { } }, [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]); + // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent React.useEffect(() => { const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; @@ -827,7 +838,7 @@ function NewSessionWizard() { }, [agentType, permissionMode]); // Scroll to section helpers - for AgentInput button clicks - const wizardSectionOffsets = React.useRef<{ profile?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); + 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; @@ -856,7 +867,7 @@ function NewSessionWizard() { }, [scrollToWizardSection]); const handleAgentInputAgentClick = React.useCallback(() => { - scrollToWizardSection('profile'); + scrollToWizardSection('agent'); }, [scrollToWizardSection]); const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { @@ -1415,16 +1426,140 @@ function NewSessionWizard() { )} - {/* Section 1: Profile Management */} - - - - {useProfiles ? 'Select AI Profile & Backend' : 'Select AI'} - + {useProfiles && ( + <> + + + + Select AI Profile + + + + Select a 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 ( + availability.available && 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 ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + 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 ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + } + onPress={handleAddProfile} + showChevron={false} + showDivider={false} + /> + + + + + )} + + {/* Section: AI Backend */} + + + + + Select AI Backend + + - {useProfiles - ? 'Select 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.'} @@ -1573,7 +1708,7 @@ function NewSessionWizard() { )} - {selectedMachineId && cliAvailability.gemini === false && experimentsEnabled && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( + {selectedMachineId && cliAvailability.gemini === false && allowGemini && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( )} - {useProfiles ? ( - <> - {favoriteProfileItems.length > 0 && ( - - {favoriteProfileItems.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === favoriteProfileItems.length - 1; - return ( - availability.available && 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 ( - availability.available && selectProfile(profile.id)} - rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - )} - - - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={!selectedProfileId - ? ( + } 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} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + 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); + }} + rightElement={( - ) - : 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 ( - availability.available && selectProfile(profile.id)} - rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - - } - onPress={handleAddProfile} - showChevron={false} - showDivider={false} - /> - - - ) : ( - - } - selected={agentType === 'claude'} - onPress={() => setAgentType('claude')} - showChevron={false} - /> - } - selected={agentType === 'codex'} - onPress={() => setAgentType('codex')} - showChevron={false} - /> - {allowGemini && ( - } - selected={agentType === 'gemini'} - onPress={() => setAgentType('gemini')} - showChevron={false} - showDivider={false} - /> - )} - - )} + )} + showChevron={false} + showDivider={index < options.length - 1} + /> + ); + }); + })()} + diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index b673c0b80..a0a4bf674 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -14,6 +14,8 @@ import { Item } from '@/components/Item'; import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; +import { useSetting } from '@/sync/storage'; +import { Modal } from '@/modal'; export interface ProfileEditFormProps { profile: AIBackendProfile; @@ -33,6 +35,7 @@ export function ProfileEditForm({ const { theme } = useUnistyles(); const styles = stylesheet; const groupStyle = React.useMemo(() => ({ marginBottom: 8 }), []); + const experimentsEnabled = useSetting('experiments'); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -53,6 +56,21 @@ export function ProfileEditForm({ const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( (profile.defaultPermissionMode as PermissionMode) || 'default', ); + const [compatibility, setCompatibility] = React.useState>( + profile.compatibility || { claude: true, codex: true, gemini: true }, + ); + + 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; @@ -93,9 +111,11 @@ export function ProfileEditForm({ }, defaultSessionType, defaultPermissionMode, + compatibility, updatedAt: Date.now(), }); }, [ + compatibility, defaultPermissionMode, defaultSessionType, environmentVariables, @@ -169,6 +189,36 @@ export function ProfileEditForm({ ))} + + } + 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} + /> + )} + + ({ 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: { - outlineStyle: 'none', - outlineWidth: 0, - outlineColor: 'transparent', - boxShadow: 'none', - }, - default: {}, - }) as object), - }, + 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, }, diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index ce2fb9b86..2555df500 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -10,19 +10,25 @@ type Props = { style?: ViewStyle; }; -export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { +export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { const { theme } = useUnistyles(); + const hasClaude = !!profile.compatibility?.claude; + const hasCodex = !!profile.compatibility?.codex; + const hasGemini = !!profile.compatibility?.gemini; + const glyph = - profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : - profile.compatibility?.claude ? '✳' : - profile.compatibility?.codex ? '꩜' : - '•'; + hasClaude && hasCodex ? '✳꩜' : + hasClaude ? '✳' : + hasCodex ? '꩜' : + hasGemini ? '✦' : + '•'; const glyphSize = - glyph === '✳' ? Math.round(size * 0.9) : - glyph === '✳꩜' ? Math.round(size * 0.7) : - Math.round(size * 0.75); + glyph === '✳' ? Math.round(size * 1.0) : + glyph === '꩜' ? Math.round(size * 0.9) : + glyph === '✳꩜' ? Math.round(size * 0.8) : + Math.round(size * 0.85); return ( Date: Wed, 14 Jan 2026 17:19:01 +0100 Subject: [PATCH 040/106] fix(new-session): persist profile edits and preserve permissions --- sources/app/(app)/new/index.tsx | 143 ++++++++++++++------ sources/app/(app)/new/pick/profile-edit.tsx | 47 ++++++- 2 files changed, 149 insertions(+), 41 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 4197fc325..c19337132 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -425,12 +425,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 // @@ -759,6 +771,7 @@ function NewSessionWizard() { }, [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); @@ -776,12 +789,19 @@ function NewSessionWizard() { 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, profileMap]); + }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]); // Handle profile route param from picker screens React.useEffect(() => { @@ -823,19 +843,66 @@ function NewSessionWizard() { } }, [agentType, allowGemini, profileMap, selectedProfileId, useProfiles]); - // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent + 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'; + } + } + + // 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 isValidForCurrentAgent = agentType === 'codex' - ? validCodexModes.includes(permissionMode) - : validClaudeModes.includes(permissionMode); + const isValidForNewAgent = agentType === 'codex' + ? validCodexModes.includes(current) + : validClaudeModes.includes(current); - if (!isValidForCurrentAgent) { - setPermissionMode('default'); + if (isValidForNewAgent) { + return; } - }, [agentType, permissionMode]); + + const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); + applyPermissionMode(mapped, 'auto'); + }, [agentType, applyPermissionMode, mapPermissionModeAcrossAgents]); // Scroll to section helpers - for AgentInput button clicks const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); @@ -1915,22 +1982,22 @@ function NewSessionWizard() { Select Permission Mode - - {(agentType === 'codex' - ? [ - { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-half-outline' }, - { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, - ] - : [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-half-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) => ( - + {(agentType === 'codex' + ? [ + { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-half-outline' }, + { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.default' : 'agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-half-outline' }, + { value: 'acceptEdits' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.acceptEdits' : 'agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.plan' : 'agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.bypassPermissions' : 'agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( + - ) : null} - onPress={() => setPermissionMode(option.value)} - showChevron={false} - selected={permissionMode === option.value} - pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < array.length - 1} - /> - ))} - + ) : null} + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + showDivider={index < array.length - 1} + /> + ))} + diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 7710ab643..d85d9f4bd 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -10,6 +10,10 @@ 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 } from '@/sync/profileMutations'; +import { Modal } from '@/modal'; export default function ProfileEditScreen() { const { theme } = useUnistyles(); @@ -17,6 +21,8 @@ export default function ProfileEditScreen() { const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { @@ -27,7 +33,7 @@ export default function ProfileEditScreen() { try { return JSON.parse(params.profileData); } catch { - return JSON.parse(decodeURIComponent(params.profileData)); + return JSON.parse(decodeURIComponent(params.profileData)); } } catch (error) { console.error('Failed to parse profile data:', error); @@ -48,8 +54,43 @@ export default function ProfileEditScreen() { }, [params.profileData]); const handleSave = (savedProfile: AIBackendProfile) => { - // Call the callback to notify wizard of saved profile - callbacks.onProfileSaved(savedProfile); + 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 updatedProfiles = existingIndex >= 0 + ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) + : [...profiles, profileToSave]; + + setProfiles(updatedProfiles); + setLastUsedProfile(profileToSave.id); + + // Still notify the /new screen in case it is mounted and wants to update selection immediately. + callbacks.onProfileSaved(profileToSave); router.back(); }; From a57cbb59a88c7b3f759d1935246a061d3d535f37 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 17:19:20 +0100 Subject: [PATCH 041/106] fix(profiles): polish editor and picker UI --- sources/app/(app)/new/pick/profile.tsx | 71 ++++++++++++------- sources/app/(app)/settings/profiles.tsx | 33 +++++++-- .../components/EnvironmentVariableCard.tsx | 25 ++++++- .../components/EnvironmentVariablesList.tsx | 42 ++++++++--- sources/components/ProfileEditForm.tsx | 56 ++++++++++++--- .../newSession/DirectorySelector.tsx | 4 +- .../components/newSession/PathSelector.tsx | 22 +++--- .../newSession/ProfileCompatibilityIcon.tsx | 7 +- 8 files changed, 191 insertions(+), 69 deletions(-) diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 69666fc53..44bca012c 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -21,6 +21,7 @@ export default function ProfilePickerScreen() { const navigation = useNavigation(); const params = useLocalSearchParams<{ selectedId?: string; machineId?: string }>(); const useProfiles = useSetting('useProfiles'); + const experimentsEnabled = useSetting('experiments'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); @@ -31,6 +32,22 @@ export default function ProfilePickerScreen() { 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]; @@ -196,15 +213,15 @@ export default function ProfilePickerScreen() { const isSelected = selectedId === profile.id; const isLast = index === favoriteProfileItems.length - 1; return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={renderProfileRowRightElement(profile, isSelected, true)} showDivider={!isLast} /> @@ -220,15 +237,15 @@ export default function ProfilePickerScreen() { const isLast = index === nonFavoriteCustomProfiles.length - 1; const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} showDivider={!isLast} /> @@ -256,15 +273,15 @@ export default function ProfilePickerScreen() { const isLast = index === nonFavoriteBuiltInProfiles.length - 1; const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} showDivider={!isLast} /> diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 1a847a9f5..00ff41fb1 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -16,6 +16,7 @@ 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; @@ -31,6 +32,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); + const experimentsEnabled = useSetting('experiments'); if (!useProfiles) { return ( @@ -130,9 +132,18 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } }; + 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; } @@ -148,6 +159,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel p.name.trim() === newProfile.name.trim() ); if (isDuplicate) { + Modal.alert(t('common.error'), 'A profile with that name already exists.'); return; } @@ -159,6 +171,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel 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; } @@ -196,7 +209,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } onPress={() => handleSelectProfile(profile.id)} showChevron={false} @@ -271,7 +284,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } onPress={() => handleSelectProfile(profile.id)} showChevron={false} @@ -343,7 +356,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } onPress={() => handleSelectProfile(profile.id)} showChevron={false} @@ -409,8 +422,14 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( - - + { + setShowAddForm(false); + setEditingProfile(null); + }} + > + { }}> - - + + )} ); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index f06be84a5..fac07ace9 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -69,6 +69,19 @@ 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, @@ -76,6 +89,13 @@ export function EnvironmentVariableCard({ ...Typography.default(), }), []); + const remoteToggleLabelStyle = React.useMemo(() => ({ + fontSize: Platform.select({ ios: 16, default: 15 }), + 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); @@ -108,6 +128,7 @@ export function EnvironmentVariableCard({ return ( Copy from remote machine @@ -187,6 +208,7 @@ export function EnvironmentVariableCard({ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, marginBottom: 4, + ...webNoOutline, }} placeholder="Variable name (e.g., Z_AI_MODEL)" placeholderTextColor={theme.colors.input.placeholder} @@ -282,6 +304,7 @@ export function EnvironmentVariableCard({ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, marginBottom: 4, + ...webNoOutline, }} placeholder={expectedValue || (useRemoteVariable ? 'Default value' : 'Value')} placeholderTextColor={theme.colors.input.placeholder} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index f18e301a7..f0d1908cf 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -8,6 +8,8 @@ import type { ProfileDocumentation } from '@/sync/profileUtils'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { layout } from '@/components/layout'; +import { Modal } from '@/modal'; +import { t } from '@/text'; export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string }>; @@ -33,6 +35,19 @@ export function EnvironmentVariablesList({ 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 }; @@ -79,20 +94,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() || '' }]); @@ -100,7 +124,7 @@ export function EnvironmentVariablesList({ setNewVarName(''); setNewVarValue(''); setShowAddForm(false); - }, [newVarName, newVarValue, environmentVariables, onChange]); + }, [environmentVariables, newVarName, newVarValue, onChange]); return ( @@ -138,11 +162,11 @@ export function EnvironmentVariablesList({ marginBottom: 8, }}> setNewVarName(text.toUpperCase())} autoCapitalize="characters" autoCorrect={false} /> @@ -158,7 +182,7 @@ export function EnvironmentVariablesList({ marginBottom: 12, }}> - + {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index a0a4bf674..8a1f30dd2 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -16,6 +16,7 @@ import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; import { useSetting } from '@/sync/storage'; import { Modal } from '@/modal'; +import { RoundButton } from '@/components/RoundButton'; export interface ProfileEditFormProps { profile: AIBackendProfile; @@ -34,7 +35,7 @@ export function ProfileEditForm({ }: ProfileEditFormProps) { const { theme } = useUnistyles(); const styles = stylesheet; - const groupStyle = React.useMemo(() => ({ marginBottom: 8 }), []); + const groupStyle = React.useMemo(() => ({ marginBottom: 12 }), []); const experimentsEnabled = useSetting('experiments'); const profileDocs = React.useMemo(() => { @@ -88,6 +89,7 @@ export function ProfileEditForm({ const handleSave = React.useCallback(() => { if (!name.trim()) { + Modal.alert(t('common.error'), 'Enter a profile name.'); return; } @@ -264,14 +266,26 @@ export function ProfileEditForm({ /> - - - - + + + + + + + + + + ); } @@ -301,6 +315,18 @@ const stylesheet = StyleSheet.create((theme) => ({ 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'), @@ -313,5 +339,17 @@ const stylesheet = StyleSheet.create((theme) => ({ 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/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx index 75b43d220..6c66fabe4 100644 --- a/sources/components/newSession/DirectorySelector.tsx +++ b/sources/components/newSession/DirectorySelector.tsx @@ -66,14 +66,14 @@ export function DirectorySelector({ getItemIcon: () => ( ), getRecentItemIcon: () => ( ), diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 1cc8bcf48..ef9e6b321 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -284,7 +284,7 @@ export function PathSelector({ } + leftElement={} onPress={() => setPathAndFocus(path)} selected={isSelected} showChevron={false} @@ -398,7 +398,7 @@ export function PathSelector({ } + leftElement={} onPress={() => setPathAndFocus(path)} selected={isSelected} showChevron={false} @@ -418,15 +418,15 @@ export function PathSelector({ const isLast = index === filteredSuggestedPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} showDivider={!isLast} /> ); diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index 2555df500..3ad18e3dc 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -24,10 +24,11 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { hasGemini ? '✦' : '•'; + // Match visual size across glyphs (codex glyph runs larger; claude glyph runs smaller). const glyphSize = - glyph === '✳' ? Math.round(size * 1.0) : - glyph === '꩜' ? Math.round(size * 0.9) : - glyph === '✳꩜' ? Math.round(size * 0.8) : + glyph === '✳' ? Math.round(size * 1.08) : + glyph === '꩜' ? Math.round(size * 0.82) : + glyph === '✳꩜' ? Math.round(size * 0.78) : Math.round(size * 0.85); return ( From 64e84222649a56024f0abedba003a0eb8fd9ea2c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 18:33:53 +0100 Subject: [PATCH 042/106] fix(profiles): improve env var UX and editor safety --- sources/app/(app)/new/index.tsx | 66 +++--- sources/app/(app)/new/pick/profile-edit.tsx | 61 ++++- sources/app/(app)/settings/profiles.tsx | 44 +++- sources/components/AgentInput.tsx | 80 +++++-- .../components/EnvironmentVariableCard.tsx | 210 ++++++++++-------- sources/components/ProfileEditForm.tsx | 43 ++++ .../EnvironmentVariablesPreviewModal.tsx | 158 +++++++++++++ .../newSession/ProfileCompatibilityIcon.tsx | 44 ++-- 8 files changed, 531 insertions(+), 175 deletions(-) create mode 100644 sources/components/newSession/EnvironmentVariablesPreviewModal.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c19337132..edd7064af 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -34,6 +34,7 @@ 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 { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; @@ -1055,37 +1056,18 @@ function NewSessionWizard() { 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 = convertBuiltInProfileToCustom(savedProfile); - } - - const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); - let updatedProfiles: AIBackendProfile[]; - - if (existingIndex >= 0) { - // Update existing profile - updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profileToSave; - } else { - // Add new profile - updatedProfiles = [...profiles, profileToSave]; + // Only auto-select newly created profiles (Add / Duplicate / Save As). + // Edits to other profiles should not change the current selection. + const wasExisting = profiles.some(p => p.id === savedProfile.id); + if (!wasExisting) { + setSelectedProfileId(savedProfile.id); } - - setProfiles(updatedProfiles); // Use mutable setter for persistence - setSelectedProfileId(profileToSave.id); }; onProfileSaved = handler; return () => { onProfileSaved = () => { }; }; - }, [profiles, setProfiles]); + }, [profiles]); const handleMachineClick = React.useCallback(() => { router.push({ @@ -1147,6 +1129,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) { @@ -1383,7 +1392,12 @@ function NewSessionWizard() { onMachineClick={handleMachineClick} currentPath={selectedPath} onPathClick={handlePathClick} - {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleProfileClick } : {})} + {...(useProfiles ? { + profileId: selectedProfileId, + onProfileClick: handleProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} /> diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index d85d9f4bd..be7dc1c34 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 { useNavigation } from '@react-navigation/native'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -18,11 +19,18 @@ import { Modal } from '@/modal'; export default function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); + const navigation = useNavigation(); const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); 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(() => { @@ -53,6 +61,32 @@ export default function ProfileEditScreen() { }; }, [params.profileData]); + 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) => { if (!savedProfile.name || savedProfile.name.trim() === '') { Modal.alert(t('common.error'), 'Enter a profile name.'); @@ -82,21 +116,33 @@ export default function ProfileEditScreen() { } const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id); + const isNewProfile = existingIndex < 0; const updatedProfiles = existingIndex >= 0 ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) : [...profiles, profileToSave]; setProfiles(updatedProfiles); - setLastUsedProfile(profileToSave.id); - - // Still notify the /new screen in case it is mounted and wants to update selection immediately. - callbacks.onProfileSaved(profileToSave); + if (isNewProfile) { + setLastUsedProfile(profileToSave.id); + // Notify the /new screen only for newly created profiles (Add / Duplicate / Save As). + callbacks.onProfileSaved(profileToSave); + } router.back(); }; - const handleCancel = () => { - 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 ( diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 00ff41fb1..0e78d2488 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -32,8 +32,14 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); + const [isEditingDirty, setIsEditingDirty] = React.useState(false); + const isEditingDirtyRef = React.useRef(false); const experimentsEnabled = useSetting('experiments'); + React.useEffect(() => { + isEditingDirtyRef.current = isEditingDirty; + }, [isEditingDirty]); + if (!useProfiles) { return ( @@ -73,6 +79,30 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel 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'), @@ -193,8 +223,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setProfiles(updatedProfiles); } - setShowAddForm(false); - setEditingProfile(null); + closeEditor(); }; return ( @@ -424,20 +453,15 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {showAddForm && editingProfile && ( { - setShowAddForm(false); - setEditingProfile(null); - }} + onPress={requestCloseEditor} > { }}> { - setShowAddForm(false); - setEditingProfile(null); - }} + onCancel={requestCloseEditor} + onDirtyChange={setIsEditingDirty} /> diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 713286947..89fe69077 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -74,6 +74,8 @@ interface AgentInputProps { minHeight?: number; profileId?: string | null; onProfileClick?: () => void; + envVarsCount?: number; + onEnvVarsClick?: () => void; } const MAX_CONTEXT_SIZE = 190000; @@ -807,11 +809,11 @@ export const AgentInput = React.memo(React.forwardRef - + {profileLabel ?? t('profiles.noProfile')} - + )} + {/* 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: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + opacity: p.pressed ? 0.7 : 1, + gap: 6, + })} + > + + + {props.envVarsCount ? `Env Vars (${props.envVarsCount})` : 'Env Vars'} + + + )} + {/* Agent selector button */} {props.agentType && props.onAgentClick && ( - + - + - + ({ - fontSize: Platform.select({ ios: 16, default: 15 }), + fontSize: Platform.select({ ios: 17, default: 16 }), lineHeight: 20, letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), ...Typography.default(), @@ -126,6 +126,11 @@ export function EnvironmentVariableCard({ const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + const computedTemplateValue = + useRemoteVariable && remoteVariableName.trim() !== '' + ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + : defaultValue; + return ( )} - {/* Toggle: Copy from remote machine */} - + {/* Value label */} + + {useRemoteVariable ? 'Fallback value:' : 'Value:'} + + + {/* Value input */} + + + {/* 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 */} + - Copy from remote machine + Use value from machine environment - {/* Remote variable name input (only when enabled) */} + + Resolved when the session starts on the selected machine. + + + {/* Source variable name input (only when enabled) */} {useRemoteVariable && ( - + + ...secondaryTextStyle, + }}> + Source variable + + + + )} - {/* Remote variable status */} + {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( {remoteValue === undefined ? ( @@ -228,7 +313,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...secondaryTextStyle, }}> - Checking remote machine... + Checking machine environment... ) : remoteValue === null ? ( )} - {useRemoteVariable && !isSecret && !machineId && ( - - Select a machine to check if variable exists - - )} - - {/* Security message for secrets */} - {isSecret && ( - - Secret value - not retrieved for security - - )} - - {/* Value label */} - - {useRemoteVariable ? 'Default value:' : 'Value:'} - - - {/* Default value input */} - - - {/* Default override warning */} - {showDefaultOverrideWarning && !isSecret && ( - - Overriding documented default: {expectedValue} - - )} - {/* Session preview */} - + style={({ pressed }) => ({ + 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')} + + diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index d835d0adb..3ee2c30c9 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, ScrollView, Pressable, Platform } from 'react-native'; +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'; @@ -33,6 +33,7 @@ function isSecretLike(name: string) { export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { const { theme } = useUnistyles(); + const { height: windowHeight } = useWindowDimensions(); const envVarEntries = React.useMemo(() => { return Object.entries(props.environmentVariables) @@ -54,17 +55,20 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev 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 ( - + - These environment variables are sent when starting the session. - {props.machineName ? ` Values are resolved using the daemon on ${props.machineName}.` : ' Values are resolved using the daemon on the selected machine.'} + These environment variables are sent when starting the session. Values are resolved using the daemon on{' '} + {props.machineName ? ( + + {props.machineName} + + ) : ( + 'the selected machine' + )} + . diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index 8f53f0a04..dfaac7f8a 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -48,18 +48,22 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { ) : ( - {glyphs.map((item) => ( - - {item.glyph} - - ))} + {glyphs.map((item) => { + const fontSize = Math.round(size * multiScale * item.factor); + return ( + + {item.glyph} + + ); + })} )} diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 36db3482b..90780e4b1 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -15,6 +15,7 @@ export interface NewSessionDraft { input: string; selectedMachineId: string | null; selectedPath: string | null; + selectedProfileId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; sessionType: NewSessionSessionType; @@ -140,6 +141,7 @@ 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'; @@ -153,6 +155,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { input, selectedMachineId, selectedPath, + selectedProfileId, agentType, permissionMode, sessionType, From b79dfe5a9346f7e65ef94eceb840e17c964a1c14 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:08:24 +0100 Subject: [PATCH 047/106] fix(new-session): persist model mode in draft --- sources/app/(app)/new/index.tsx | 17 +++++++++++++++-- sources/sync/persistence.ts | 6 ++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 869bcf157..4e7d07afe 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -403,6 +403,17 @@ function NewSessionWizard() { 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 (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; + } + } + if (lastUsedModelMode) { if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { return lastUsedModelMode as ModelMode; @@ -671,6 +682,7 @@ function NewSessionWizard() { selectedProfileId: useProfiles ? selectedProfileId : null, agentType, permissionMode, + modelMode, sessionType, updatedAt: Date.now(), }); @@ -678,7 +690,7 @@ function NewSessionWizard() { const profileData = JSON.stringify(profile); const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; router.push(selectedMachineId ? `${base}&machineId=${encodeURIComponent(selectedMachineId)}` as any : base as any); - }, [agentType, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); const handleAddProfile = React.useCallback(() => { openProfileEdit(createEmptyCustomProfile()); @@ -1370,10 +1382,11 @@ function NewSessionWizard() { selectedProfileId: useProfiles ? selectedProfileId : null, agentType, permissionMode, + modelMode, sessionType, updatedAt: Date.now(), }); - }, [agentType, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + }, [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 diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 90780e4b1..34ae1ad0c 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -4,6 +4,7 @@ import { LocalSettings, localSettingsDefaults, localSettingsParse } from './loca import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { ModelMode } from '@/components/PermissionModeSelector'; const mmkv = new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; @@ -18,6 +19,7 @@ export interface NewSessionDraft { selectedProfileId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; + modelMode: ModelMode; sessionType: NewSessionSessionType; updatedAt: number; } @@ -148,6 +150,9 @@ export function loadNewSessionDraft(): NewSessionDraft | null { 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(); @@ -158,6 +163,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { selectedProfileId, agentType, permissionMode, + modelMode, sessionType, updatedAt, }; From 78d696bf971e27c7bfe12e5ce5747d98a33a9b8b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:17:30 +0100 Subject: [PATCH 048/106] refactor(new-session): stop using lastUsedModelMode --- sources/app/(app)/new/index.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 4e7d07afe..7a5f71432 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -283,7 +283,6 @@ function NewSessionWizard() { 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'); @@ -413,16 +412,6 @@ function NewSessionWizard() { return draftMode; } } - - 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; - } - } return agentType === 'codex' ? 'gpt-5-codex-high' : 'default'; }); @@ -1275,9 +1264,6 @@ function NewSessionWizard() { if (profilesActive) { settingsUpdate.lastUsedProfile = selectedProfileId; } - if (useEnhancedSessionWizard) { - settingsUpdate.lastUsedModelMode = modelMode; - } sync.applySettings(settingsUpdate); // Get environment variables from selected profile From 8307d1d0786a83a6b7861de0f94a3eb8952af6c7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:42:33 +0100 Subject: [PATCH 049/106] fix(new-session): compact mobile profile actions --- sources/app/(app)/new/index.tsx | 119 +++++++++++++----- sources/components/AgentInput.tsx | 29 ++--- .../newSession/ProfileActionsMenuModal.tsx | 115 +++++++++++++++++ 3 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 sources/components/newSession/ProfileActionsMenuModal.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 7a5f71432..d1b24f81a 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -35,6 +35,7 @@ import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; +import { ProfileActionsMenuModal } from '@/components/newSession/ProfileActionsMenuModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; @@ -251,13 +252,14 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ - prompt?: string; - dataId?: string; - machineId?: string; + const router = useRouter(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const screenWidth = useWindowDimensions().width; + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ + prompt?: string; + dataId?: string; + machineId?: string; path?: string; profileId?: string; }>(); @@ -971,16 +973,76 @@ function NewSessionWizard() { } 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; - return ( - - - { + return ; + }, []); + + const openProfileActionsMenu = React.useCallback((profile: AIBackendProfile, isFavorite: boolean) => { + Modal.show({ + component: ProfileActionsMenuModal, + props: { + profileName: profile.name, + isFavorite, + hasEnvVars: Object.keys(getProfileEnvironmentVariables(profile)).length > 0, + canDelete: !profile.isBuiltIn, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onViewEnvVars: () => openProfileEnvVarsPreview(profile), + onEdit: () => openProfileEdit(profile), + onCopy: () => handleDuplicateProfile(profile), + onDelete: !profile.isBuiltIn ? () => handleDeleteProfile(profile) : undefined, + }, + } as any); + }, [handleDeleteProfile, handleDuplicateProfile, openProfileEdit, openProfileEnvVarsPreview, toggleFavoriteProfile]); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; + const compact = screenWidth < 420; + + if (compact) { + return ( + + + + + {envVarCount > 0 && ( + { + ignoreProfileRowPressRef.current = true; + }} + onPress={(e) => { + e.stopPropagation(); + openProfileEnvVarsPreview(profile); + }} + > + + + )} + { + ignoreProfileRowPressRef.current = true; + }} + onPress={(e) => { + e.stopPropagation(); + openProfileActionsMenu(profile, isFavorite); + }} + > + + + + ); + } + + return ( + + + { @@ -1321,8 +1385,7 @@ function NewSessionWizard() { } }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); - const screenWidth = useWindowDimensions().width; - const showInlineClose = screenWidth < 520; + 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. diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 9ed377621..98093555d 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -230,13 +230,14 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ justifyContent: 'space-between', paddingHorizontal: 0, }, - actionButtonsLeft: { - flexDirection: 'row', - gap: 8, - flex: 1, - flexWrap: 'wrap', - overflow: 'visible', - }, + actionButtonsLeft: { + flexDirection: 'row', + columnGap: 8, + rowGap: 6, + flex: 1, + flexWrap: 'wrap', + overflow: 'visible', + }, actionButton: { flexDirection: 'row', alignItems: 'center', @@ -741,13 +742,13 @@ export const AgentInput = React.memo(React.forwardRef - - {/* Action buttons below input */} - - - {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - - + + {/* Action buttons below input */} + + + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} + + {/* Permission chip (popover in standard flow, scroll in wizard) */} {showPermissionChip && ( diff --git a/sources/components/newSession/ProfileActionsMenuModal.tsx b/sources/components/newSession/ProfileActionsMenuModal.tsx new file mode 100644 index 000000000..b658c33b1 --- /dev/null +++ b/sources/components/newSession/ProfileActionsMenuModal.tsx @@ -0,0 +1,115 @@ +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 interface ProfileActionsMenuModalProps { + profileName: string; + isFavorite: boolean; + hasEnvVars: boolean; + canDelete: boolean; + onToggleFavorite: () => void; + onViewEnvVars?: () => void; + onEdit: () => void; + onCopy: () => void; + onDelete?: () => void; + onClose: () => void; +} + +export function ProfileActionsMenuModal(props: ProfileActionsMenuModalProps) { + const { theme } = useUnistyles(); + + const closeThen = React.useCallback((fn?: () => void) => { + props.onClose(); + if (!fn) return; + setTimeout(() => fn(), 0); + }, [props]); + + return ( + + + + {props.profileName} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + {props.hasEnvVars && props.onViewEnvVars && ( + } + onPress={() => closeThen(props.onViewEnvVars)} + showChevron={false} + /> + )} + + } + onPress={() => closeThen(props.onToggleFavorite)} + showChevron={false} + /> + } + onPress={() => closeThen(props.onEdit)} + showChevron={false} + /> + } + onPress={() => closeThen(props.onCopy)} + showChevron={false} + /> + {props.canDelete && props.onDelete && ( + } + onPress={() => closeThen(props.onDelete)} + showChevron={false} + /> + )} + + + + ); +} + From 4692b3b199b9723ae0aa75fbb7417139d7b12a42 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:57:52 +0100 Subject: [PATCH 050/106] fix(ui): suppress selected background for single-item groups --- sources/app/(app)/machine/[id].tsx | 1 - sources/app/(app)/new/index.tsx | 58 +++++----- sources/app/(app)/new/pick/profile.tsx | 40 +++---- sources/app/(app)/settings/profiles.tsx | 33 +++--- sources/components/Item.tsx | 35 +++--- sources/components/ItemGroup.tsx | 53 ++++++--- sources/components/ProfileEditForm.tsx | 1 - sources/components/SearchableListSelector.tsx | 1 - sources/components/SessionTypeSelector.tsx | 3 - .../components/newSession/PathSelector.tsx | 105 +++++++++--------- 10 files changed, 167 insertions(+), 163 deletions(-) diff --git a/sources/app/(app)/machine/[id].tsx b/sources/app/(app)/machine/[id].tsx index bb014796a..68438d54d 100644 --- a/sources/app/(app)/machine/[id].tsx +++ b/sources/app/(app)/machine/[id].tsx @@ -399,7 +399,6 @@ export default function MachineDetailScreen() { disabled={!isMachineOnline(machine)} selected={isSelected} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={!hideDivider} /> ); diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d1b24f81a..884027594 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1658,11 +1658,10 @@ function NewSessionWizard() { title={profile.name} subtitle={getProfileSubtitle(profile)} leftElement={renderProfileLeftElement(profile)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - disabled={!availability.available} - onPress={() => { + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => { if (!availability.available) return; if (ignoreProfileRowPressRef.current) { ignoreProfileRowPressRef.current = false; @@ -1691,11 +1690,10 @@ function NewSessionWizard() { title={profile.name} subtitle={getProfileSubtitle(profile)} leftElement={renderProfileLeftElement(profile)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - disabled={!availability.available} - onPress={() => { + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => { if (!availability.available) return; if (ignoreProfileRowPressRef.current) { ignoreProfileRowPressRef.current = false; @@ -1716,12 +1714,11 @@ function NewSessionWizard() { title={t('profiles.noProfile')} subtitle={t('profiles.noProfileDescription')} leftElement={} - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={!selectedProfileId - ? ( + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + rightElement={!selectedProfileId + ? ( { + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => { if (!availability.available) return; if (ignoreProfileRowPressRef.current) { ignoreProfileRowPressRef.current = false; @@ -2042,11 +2038,10 @@ function NewSessionWizard() { key={option.key} title={option.title} subtitle={disabledReason ?? option.subtitle} - leftElement={} - selected={isSelected} - disabled={!!disabledReason} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - onPress={() => { + leftElement={} + selected={isSelected} + disabled={!!disabledReason} + onPress={() => { if (disabledReason) { Modal.alert( 'AI Backend', @@ -2177,12 +2172,11 @@ function NewSessionWizard() { color={theme.colors.button.primary.background} /> ) : null} - onPress={() => handlePermissionModeChange(option.value)} - showChevron={false} - selected={permissionMode === option.value} - pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < array.length - 1} - /> + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> ))} diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 44bca012c..233425f98 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -220,11 +220,10 @@ export default function ProfilePickerScreen() { icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderProfileRowRightElement(profile, isSelected, true)} - showDivider={!isLast} - /> + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> ); })} @@ -244,11 +243,10 @@ export default function ProfilePickerScreen() { icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -260,12 +258,11 @@ export default function ProfilePickerScreen() { subtitle={t('profiles.noProfileDescription')} icon={} onPress={() => setProfileParamAndClose('')} - showChevron={false} - selected={selectedId === ''} - pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={selectedId === '' - ? - : null} + showChevron={false} + selected={selectedId === ''} + rightElement={selectedId === '' + ? + : null} showDivider={nonFavoriteBuiltInProfiles.length > 0} /> {nonFavoriteBuiltInProfiles.map((profile, index) => { @@ -279,12 +276,11 @@ export default function ProfilePickerScreen() { subtitle={getProfileSubtitle(profile)} icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 0e78d2488..fd3aec8f8 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -240,12 +240,11 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel title={profile.name} subtitle={getProfileBackendSubtitle(profile)} leftElement={} - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + ({ 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/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/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 7940b1c86..a79bfd564 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -227,7 +227,6 @@ export function ProfileEditForm({ onPress={() => setDefaultPermissionMode(option.value)} showChevron={false} selected={defaultPermissionMode === option.value} - pressableStyle={defaultPermissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={index < array.length - 1} /> ))} diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 968e02b8d..5d93bb0eb 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -242,7 +242,6 @@ export function SearchableListSelector(props: SearchableListSelectorProps) onPress={() => onSelect(item)} showChevron={false} selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} /> ); diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index b4420235a..d7489d4ec 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -48,7 +48,6 @@ export function SessionTypeSelectorRows({ value, onChange }: Pick )} selected={value === 'simple'} - pressableStyle={value === 'simple' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} onPress={() => onChange('simple')} showChevron={false} showDivider={true} @@ -62,7 +61,6 @@ export function SessionTypeSelectorRows({ value, onChange }: Pick )} selected={value === 'worktree'} - pressableStyle={value === 'worktree' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} onPress={() => onChange('worktree')} showChevron={false} showDivider={false} @@ -82,4 +80,3 @@ export function SessionTypeSelector({ value, onChange, title = t('newSession.ses ); } - diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index ef9e6b321..6ab44d019 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -281,17 +281,16 @@ export function PathSelector({ const isLast = index === filteredRecentPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -326,17 +325,16 @@ export function PathSelector({ const isSelected = selectedPath.trim() === path; const isLast = index === filteredFavoritePaths.length - 1; return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, true)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> ); })} @@ -349,17 +347,16 @@ export function PathSelector({ const isLast = index === filteredRecentPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -395,17 +392,16 @@ export function PathSelector({ const isLast = index === filteredSuggestedPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -418,17 +414,16 @@ export function PathSelector({ const isLast = index === filteredSuggestedPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} From f3815a1766265dedfe73181243e559883d29ddc7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:07:46 +0100 Subject: [PATCH 051/106] fix(ui): collapse row actions on mobile --- sources/app/(app)/settings/profiles.tsx | 286 ++++++++++---------- sources/components/ItemActionsMenuModal.tsx | 91 +++++++ sources/components/ItemRowActions.tsx | 85 ++++++ 3 files changed, 312 insertions(+), 150 deletions(-) create mode 100644 sources/components/ItemActionsMenuModal.tsx create mode 100644 sources/components/ItemRowActions.tsx diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fd3aec8f8..8b80f011b 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -12,6 +12,8 @@ import { ProfileEditForm } from '@/components/ProfileEditForm'; 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'; @@ -231,20 +233,50 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {favoriteProfileItems.length > 0 && ( - {favoriteProfileItems.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { + 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={( - + selected={isSelected} + rightElement={( + - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - {!profile.isBuiltIn && ( - { - e.stopPropagation(); - void handleDeleteProfile(profile); - }} - > - - - )} - - )} - /> - ); - })} + + + )} + /> + ); + })} )} {nonFavoriteCustomProfiles.length > 0 && ( - {nonFavoriteCustomProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { + 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={( - + selected={isSelected} + rightElement={( + - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - { - e.stopPropagation(); - void handleDeleteProfile(profile); - }} - > - - - - )} - /> + + + )} + /> ); })} )} - {nonFavoriteBuiltInProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { + 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 ( + } onPress={() => handleSelectProfile(profile.id)} showChevron={false} - selected={isSelected} - rightElement={( - + selected={isSelected} + rightElement={( + - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - - )} - /> - ); - })} + + + )} + /> + ); + })} 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/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(); + }} + > + + + )} + + ); +} From 97d644e0a284e173cd3a85c04bd633ab0c143bf7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:21:41 +0100 Subject: [PATCH 052/106] refactor(new-session): reuse ItemRowActions for profile rows --- sources/app/(app)/new/index.tsx | 192 +++++------------- sources/app/(app)/new/pick/profile.tsx | 107 +++++----- .../newSession/ProfileActionsMenuModal.tsx | 115 ----------- 3 files changed, 108 insertions(+), 306 deletions(-) delete mode 100644 sources/components/newSession/ProfileActionsMenuModal.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 884027594..daded0ffe 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -35,9 +35,10 @@ import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; -import { ProfileActionsMenuModal } from '@/components/newSession/ProfileActionsMenuModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -977,66 +978,45 @@ function NewSessionWizard() { return ; }, []); - const openProfileActionsMenu = React.useCallback((profile: AIBackendProfile, isFavorite: boolean) => { - Modal.show({ - component: ProfileActionsMenuModal, - props: { - profileName: profile.name, - isFavorite, - hasEnvVars: Object.keys(getProfileEnvironmentVariables(profile)).length > 0, - canDelete: !profile.isBuiltIn, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onViewEnvVars: () => openProfileEnvVarsPreview(profile), - onEdit: () => openProfileEdit(profile), - onCopy: () => handleDuplicateProfile(profile), - onDelete: !profile.isBuiltIn ? () => handleDeleteProfile(profile) : undefined, - }, - } as any); - }, [handleDeleteProfile, handleDuplicateProfile, openProfileEdit, openProfileEnvVarsPreview, toggleFavoriteProfile]); - const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; - const compact = screenWidth < 420; - - if (compact) { - return ( - - - - - {envVarCount > 0 && ( - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileEnvVarsPreview(profile); - }} - > - - - )} - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileActionsMenu(profile, isFavorite); - }} - > - - - - ); + + 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(profile), + }); + 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 ( @@ -1045,85 +1025,25 @@ function NewSessionWizard() { - - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - {envVarCount > 0 && ( - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileEnvVarsPreview(profile); - }} - > - - - )} - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - {!profile.isBuiltIn && ( - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - )} - - ); - }, [ - handleDeleteProfile, + color={theme.colors.button.primary.background} + style={{ opacity: isSelected ? 1 : 0 }} + /> + + 0 ? ['envVars'] : []} + iconSize={20} + onActionPressIn={() => { + ignoreProfileRowPressRef.current = true; + }} + /> + + ); + }, [ + handleDeleteProfile, handleDuplicateProfile, openProfileEnvVarsPreview, - openProfileActionsMenu, openProfileEdit, screenWidth, theme.colors.button.primary.background, diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 233425f98..31b87bf5e 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -14,6 +14,8 @@ import { Modal } from '@/modal'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; export default function ProfilePickerScreen() { const { theme } = useUnistyles(); @@ -113,65 +115,60 @@ export default function ProfilePickerScreen() { ); }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); - const renderProfileRowRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { - return ( - - + 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: '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 ( + + - - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - {!profile.isBuiltIn && ( - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - )} - - ); - }, [ - handleDeleteProfile, - handleDuplicateProfile, - openProfileEdit, + style={{ opacity: isSelected ? 1 : 0 }} + /> + + + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEdit, theme.colors.button.primary.background, theme.colors.button.secondary.tint, theme.colors.deleteAction, diff --git a/sources/components/newSession/ProfileActionsMenuModal.tsx b/sources/components/newSession/ProfileActionsMenuModal.tsx deleted file mode 100644 index b658c33b1..000000000 --- a/sources/components/newSession/ProfileActionsMenuModal.tsx +++ /dev/null @@ -1,115 +0,0 @@ -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 interface ProfileActionsMenuModalProps { - profileName: string; - isFavorite: boolean; - hasEnvVars: boolean; - canDelete: boolean; - onToggleFavorite: () => void; - onViewEnvVars?: () => void; - onEdit: () => void; - onCopy: () => void; - onDelete?: () => void; - onClose: () => void; -} - -export function ProfileActionsMenuModal(props: ProfileActionsMenuModalProps) { - const { theme } = useUnistyles(); - - const closeThen = React.useCallback((fn?: () => void) => { - props.onClose(); - if (!fn) return; - setTimeout(() => fn(), 0); - }, [props]); - - return ( - - - - {props.profileName} - - - ({ opacity: pressed ? 0.7 : 1 })} - > - - - - - - - {props.hasEnvVars && props.onViewEnvVars && ( - } - onPress={() => closeThen(props.onViewEnvVars)} - showChevron={false} - /> - )} - - } - onPress={() => closeThen(props.onToggleFavorite)} - showChevron={false} - /> - } - onPress={() => closeThen(props.onEdit)} - showChevron={false} - /> - } - onPress={() => closeThen(props.onCopy)} - showChevron={false} - /> - {props.canDelete && props.onDelete && ( - } - onPress={() => closeThen(props.onDelete)} - showChevron={false} - /> - )} - - - - ); -} - From 17c531ed6f4792b3ca6d0195a9ce62ff6fe7229d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:21:50 +0100 Subject: [PATCH 053/106] fix(ui): center modals and tighten chips --- sources/app/(app)/_layout.tsx | 6 ++++++ sources/components/AgentInput.tsx | 6 +++--- sources/modal/components/BaseModal.tsx | 8 ++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 97735e214..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'), }} /> + ({ actionButtonsLeft: { flexDirection: 'row', columnGap: 8, - rowGap: 6, + rowGap: 3, flex: 1, flexWrap: 'wrap', overflow: 'visible', @@ -745,10 +745,10 @@ export const AgentInput = React.memo(React.forwardRef - + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - + {/* Permission chip (popover in standard flow, scroll in wizard) */} {showPermissionChip && ( diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx index 34010fd0c..8ff4ab56f 100644 --- a/sources/modal/components/BaseModal.tsx +++ b/sources/modal/components/BaseModal.tsx @@ -84,6 +84,7 @@ export function BaseModal({ Date: Wed, 14 Jan 2026 22:43:53 +0100 Subject: [PATCH 054/106] fix(ui): tighten new session chip spacing --- sources/components/AgentInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 95541784c..720b20ac3 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -232,7 +232,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ }, actionButtonsLeft: { flexDirection: 'row', - columnGap: 8, + columnGap: 6, rowGap: 3, flex: 1, flexWrap: 'wrap', @@ -748,7 +748,7 @@ export const AgentInput = React.memo(React.forwardRef {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - + {/* Permission chip (popover in standard flow, scroll in wizard) */} {showPermissionChip && ( From 71a0edfe2b540f1db1b3504dbf72fc84ad5581f4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:56:23 +0100 Subject: [PATCH 055/106] fix(ui): align new session input padding --- sources/app/(app)/new/index.tsx | 55 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index daded0ffe..2d58ae7a1 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -253,14 +253,17 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); - const screenWidth = useWindowDimensions().width; - const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ - prompt?: string; - dataId?: string; - machineId?: string; + const router = useRouter(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const screenWidth = useWindowDimensions().width; + + 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; }>(); @@ -1406,23 +1409,23 @@ function NewSessionWizard() { )} - {/* 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) }}> - - + + - - {/* AgentInput - Sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - + + Date: Wed, 14 Jan 2026 23:18:31 +0100 Subject: [PATCH 056/106] fix(ui): simplify chips and move session profile info --- sources/-session/SessionView.tsx | 11 ----- sources/app/(app)/session/[id]/info.tsx | 57 +++++++++++++++++-------- sources/components/AgentInput.tsx | 53 +++++++++++++---------- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index d7f032ccd..e93fdd4eb 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -273,17 +273,6 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} metadata={session.metadata} - profileId={session.metadata?.profileId ?? undefined} - onProfileClick={session.metadata?.profileId !== undefined ? () => { - const profileId = session.metadata?.profileId; - const profileInfo = (profileId === null || (typeof profileId === 'string' && profileId.trim() === '')) - ? t('profiles.noProfile') - : (typeof profileId === 'string' ? profileId : t('status.unknown')); - Modal.alert( - t('profiles.title'), - `This session uses: ${profileInfo}\n\nProfiles are fixed per session. To use a different profile, start a new session.`, - ); - } : undefined} connectionStatus={{ text: sessionStatus.statusText, color: sessionStatus.statusColor, diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..245deb278 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,25 @@ function SessionInfoContent({ session }: { session: Session }) { }} /> )} - } - showChevron={false} - /> - } - showChevron={false} + } + showChevron={false} + /> + {useProfiles && session.metadata?.profileId !== undefined && ( + } + showChevron={false} + /> + )} + } + showChevron={false} /> ({ container: { alignItems: 'center', @@ -417,27 +422,27 @@ export const AgentInput = React.memo(React.forwardRef { + const permissionChipLabel = React.useMemo(() => { const mode = props.permissionMode ?? 'default'; if (isCodex) { - return mode === 'default' ? t('agentInput.codexPermissionMode.default') : - mode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - mode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - mode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : ''; + 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' ? t('agentInput.geminiPermissionMode.badgeAcceptAllEdits') : - mode === 'bypassPermissions' ? t('agentInput.geminiPermissionMode.badgeBypassAllPermissions') : - mode === 'plan' ? t('agentInput.geminiPermissionMode.badgePlanMode') : ''; + mode === 'acceptEdits' ? 'Accept' : + mode === 'bypassPermissions' ? 'YOLO' : + mode === 'plan' ? 'Plan' : ''; } return mode === 'default' ? t('agentInput.permissionMode.default') : - mode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - mode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - mode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : ''; + mode === 'acceptEdits' ? 'Accept' : + mode === 'bypassPermissions' ? 'YOLO' : + mode === 'plan' ? 'Plan' : ''; }, [isCodex, isGemini, props.permissionMode]); // Handle settings button press @@ -775,7 +780,7 @@ export const AgentInput = React.memo(React.forwardRef @@ -785,7 +790,7 @@ export const AgentInput = React.memo(React.forwardRef - {permissionBadgeLabel} + {permissionChipLabel} )} @@ -923,16 +928,18 @@ export const AgentInput = React.memo(React.forwardRef - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + + + )} {/* Abort button */} {props.onAbort && ( From 05233ff72c0085737e76268bf56f3f13421f70bf Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 05:20:13 +0100 Subject: [PATCH 057/106] fix(session-info): show profile under metadata --- sources/app/(app)/session/[id]/info.tsx | 48 ++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 245deb278..e1cf603d2 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -242,14 +242,6 @@ function SessionInfoContent({ session }: { session: Session }) { icon={} showChevron={false} /> - {useProfiles && session.metadata?.profileId !== undefined && ( - } - 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} /> From 94f8b28a88b908771b3566b1559faaf67068f1f7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 05:20:22 +0100 Subject: [PATCH 058/106] fix(profiles): align edit form buttons --- sources/components/ProfileEditForm.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index a79bfd564..8c8f6d68a 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -307,15 +307,13 @@ export function ProfileEditForm({ /> - + ({ backgroundColor: theme.colors.surface, - borderColor: theme.colors.divider, - borderWidth: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center', From 7482c7ca804674b885971dcaa188f2a2264ec971 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 06:36:46 +0100 Subject: [PATCH 059/106] fix(env-vars): improve preview scroll and machine resolution --- .../components/EnvironmentVariableCard.tsx | 32 ++-- sources/components/ProfileEditForm.tsx | 147 +++++++++++++++++- .../EnvironmentVariablesPreviewModal.tsx | 104 +++++++++++-- 3 files changed, 251 insertions(+), 32 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 6118d29c5..3c1e98c0a 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -110,6 +110,7 @@ export function EnvironmentVariableCard({ ); const remoteValue = remoteValues[remoteVariableName]; + const hasFallback = defaultValue.trim() !== ''; // Update parent when local state changes React.useEffect(() => { @@ -131,6 +132,15 @@ export function EnvironmentVariableCard({ ? `\${${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 ( @@ -315,12 +327,16 @@ export function EnvironmentVariableCard({ }}> Checking machine environment... - ) : remoteValue === null ? ( + ) : (remoteValue === null || remoteValue === '') ? ( - Value not found + {remoteValue === '' ? ( + hasFallback ? 'Empty on machine (using fallback)' : 'Empty on machine' + ) : ( + hasFallback ? 'Not found on machine (using fallback)' : 'Not found on machine' + )} ) : ( <> @@ -350,15 +366,7 @@ export function EnvironmentVariableCard({ marginTop: 4, ...secondaryTextStyle, }}> - Session will receive: {variable.name} = { - isSecret - ? (useRemoteVariable && remoteVariableName - ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) - : (useRemoteVariable && machineId && remoteValue !== undefined && remoteValue !== null - ? remoteValue - : (computedTemplateValue || '(empty)')) - } + Session will receive: {variable.name} = {resolvedSessionValue} ); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 8c8f6d68a..adb20ce70 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable } 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'; @@ -14,8 +14,10 @@ import { Item } from '@/components/Item'; import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; -import { useSetting } from '@/sync/storage'; +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; @@ -26,6 +28,90 @@ export interface ProfileEditFormProps { 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, @@ -38,6 +124,38 @@ export function ProfileEditForm({ 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]); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -298,10 +416,33 @@ export function ProfileEditForm({ )} + {!routeMachine && ( + + } + onPress={showMachinePreviewPicker} + /> + + )} + + {routeMachine && resolvedMachine && ( + + + Resolving against{' '} + + {resolvedMachine.metadata?.displayName || resolvedMachine.metadata?.host || resolvedMachine.id} + + . + + + )} + diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index 3ee2c30c9..853281d37 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -34,6 +34,26 @@ function isSecretLike(name: string) { 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) @@ -58,18 +78,21 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); return ( - + - + { + 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 ( ); })} From b00bbc08af187bb746850b3f9f7851b5d324a9dc Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 06:37:17 +0100 Subject: [PATCH 060/106] fix(new-session): refine wizard padding and bottom bar --- sources/app/(app)/new/index.tsx | 104 ++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 2d58ae7a1..6129391eb 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -110,12 +110,12 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 scrollContainer: { flex: 1, }, - contentContainer: { - width: '100%', - alignSelf: 'center', - paddingTop: rt.insets.top, - paddingBottom: 16, - }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: rt.insets.top + 16, + paddingBottom: 16, + }, wizardContainer: { marginBottom: 16, }, @@ -1421,13 +1421,27 @@ function NewSessionWizard() { )} - {/* AgentInput with inline chips - sticky at bottom */} - - - + 0 ? handleEnvVarsClick : undefined, - } : {})} - /> - - + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + ); @@ -1498,13 +1511,13 @@ function NewSessionWizard() { { maxWidth: layout.maxWidth, flex: 1, width: '100%', alignSelf: 'center' } ]}> - {/* CLI Detection Status Banner - shows after detection completes */} - {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - - 0 && selectedMachine && connectionStatus && ( + + - {/* AgentInput - Sticky at bottom */} - - - + 0 ? handleEnvVarsClick : undefined, - } : {})} - /> - - + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + ); From 3d1d6fb338928239536dee4f777c8f463defca6b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 07:13:45 +0100 Subject: [PATCH 061/106] fix(env-vars): clarify machine resolution and source var input --- .../components/EnvironmentVariableCard.tsx | 4 ++-- .../components/EnvironmentVariablesList.tsx | 20 +++++++++++++++++++ sources/components/ProfileEditForm.tsx | 13 +----------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 3c1e98c0a..267a5bc36 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -309,8 +309,8 @@ export function EnvironmentVariableCard({ placeholder="Source variable name (e.g., Z_AI_MODEL)" placeholderTextColor={theme.colors.input.placeholder} value={remoteVariableName} - onChangeText={setRemoteVariableName} - autoCapitalize="none" + onChangeText={(text) => setRemoteVariableName(text.toUpperCase())} + autoCapitalize="characters" autoCorrect={false} /> diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index fe845ed37..d7507ae3d 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -12,6 +12,7 @@ import { t } from '@/text'; 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; } @@ -23,6 +24,7 @@ export interface EnvironmentVariablesListProps { export function EnvironmentVariablesList({ environmentVariables, machineId, + machineName, profileDocs, onChange, }: EnvironmentVariablesListProps) { @@ -130,6 +132,9 @@ export function EnvironmentVariablesList({ paddingTop: Platform.select({ ios: 35, default: 16 }), paddingBottom: Platform.select({ ios: 6, default: 8 }), paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', }}> Environment Variables + + {machineId && machineName && ( + + + + {machineName} + + + )} {environmentVariables.length > 0 && ( diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index adb20ce70..69a33fdc0 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -427,22 +427,11 @@ export function ProfileEditForm({ )} - {routeMachine && resolvedMachine && ( - - - Resolving against{' '} - - {resolvedMachine.metadata?.displayName || resolvedMachine.metadata?.host || resolvedMachine.id} - - . - - - )} - From f0148f1c815a2986a55efdc1f33ca17cdd68b10e Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 07:14:03 +0100 Subject: [PATCH 062/106] fix(new-session): improve wizard sizing and AI profile labels --- sources/app/(app)/new/index.tsx | 87 +++++++++++++++----------- sources/app/(app)/new/pick/profile.tsx | 4 +- sources/components/AgentInput.tsx | 3 +- 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 6129391eb..55d5dbbf6 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -110,12 +110,12 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 scrollContainer: { flex: 1, }, - contentContainer: { - width: '100%', - alignSelf: 'center', - paddingTop: rt.insets.top + 16, - paddingBottom: 16, - }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: rt.insets.top + 24, + paddingBottom: 16, + }, wizardContainer: { marginBottom: 16, }, @@ -253,13 +253,14 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); - const screenWidth = useWindowDimensions().width; - - const newSessionSidePadding = 16; - const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const router = useRouter(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + + const newSessionSidePadding = 16; + const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const webModalHeight = Platform.OS === 'web' ? Math.min(Math.max(520, screenHeight - 48), 860) : undefined; const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; @@ -1386,7 +1387,7 @@ function NewSessionWizard() { {showInlineClose && ( - + + 0 ? handleEnvVarsClick : undefined, } : {})} /> + + @@ -1477,7 +1483,7 @@ function NewSessionWizard() { {showInlineClose && ( Select AI Profile - - - Select a profile to apply environment variables and defaults to your session. - + + + Select an AI profile to apply environment variables and defaults to your session. + {favoriteProfileItems.length > 0 && ( @@ -1613,8 +1619,8 @@ function NewSessionWizard() { )} - {nonFavoriteCustomProfiles.length > 0 && ( - + {nonFavoriteCustomProfiles.length > 0 && ( + {nonFavoriteCustomProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; @@ -1645,7 +1651,7 @@ function NewSessionWizard() { )} - + + + 0 ? handleEnvVarsClick : undefined, } : {})} /> + + diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 31b87bf5e..d003dfcdd 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -227,7 +227,7 @@ export default function ProfilePickerScreen() { )} {nonFavoriteCustomProfiles.length > 0 && ( - + {nonFavoriteCustomProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; const isLast = index === nonFavoriteCustomProfiles.length - 1; @@ -249,7 +249,7 @@ export default function ProfilePickerScreen() { )} - + void; envVarsCount?: number; onEnvVarsClick?: () => void; + contentPaddingHorizontal?: number; } const MAX_CONTEXT_SIZE = 190000; @@ -550,7 +551,7 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 16 : 8 } + { paddingHorizontal: props.contentPaddingHorizontal ?? (screenWidth > 700 ? 16 : 8) } ]}> Date: Thu, 15 Jan 2026 08:05:45 +0100 Subject: [PATCH 063/106] fix(new-session): avoid nested scroll and align composer padding --- sources/app/(app)/new/index.tsx | 15 +++++++++++---- sources/components/AgentInput.tsx | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 55d5dbbf6..739dde2de 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -106,9 +106,17 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 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%', @@ -256,11 +264,10 @@ function NewSessionWizard() { const router = useRouter(); const safeArea = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); - const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const { width: screenWidth } = useWindowDimensions(); const newSessionSidePadding = 16; const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); - const webModalHeight = Platform.OS === 'web' ? Math.min(Math.max(520, screenHeight - 48), 860) : undefined; const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; @@ -1387,7 +1394,7 @@ function NewSessionWizard() { {showInlineClose && ( {showInlineClose && ( ({ alignItems: 'center', flexShrink: 0, marginLeft: 8, + marginRight: 8, }, sendButtonActive: { backgroundColor: theme.colors.button.primary.background, From a1bcc98b6947a5d8d9e873d05c2b08b12b959d69 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 08:06:07 +0100 Subject: [PATCH 064/106] fix(env-vars): resolve via login shell and clarify machine status --- .../components/EnvironmentVariableCard.tsx | 11 +++++--- .../components/EnvironmentVariablesList.tsx | 19 +------------- sources/hooks/useEnvironmentVariables.ts | 25 +++++++++++++------ 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 267a5bc36..9923b4561 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -9,6 +9,7 @@ import { Switch } from '@/components/Switch'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; machineId: string | null; + machineName?: string | null; expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) @@ -60,6 +61,7 @@ function parseVariableValue(value: string): { export function EnvironmentVariableCard({ variable, machineId, + machineName, expectedValue, description, isSecret = false, @@ -111,6 +113,7 @@ export function EnvironmentVariableCard({ const remoteValue = remoteValues[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; + const machineLabel = machineName?.trim() ? machineName.trim() : 'machine'; // Update parent when local state changes React.useEffect(() => { @@ -325,7 +328,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...secondaryTextStyle, }}> - Checking machine environment... + Checking {machineLabel}... ) : (remoteValue === null || remoteValue === '') ? ( {remoteValue === '' ? ( - hasFallback ? 'Empty on machine (using fallback)' : 'Empty on machine' + hasFallback ? `Empty on ${machineLabel} (using fallback)` : `Empty on ${machineLabel}` ) : ( - hasFallback ? 'Not found on machine (using fallback)' : 'Not found on machine' + hasFallback ? `Not found on ${machineLabel} (using fallback)` : `Not found on ${machineLabel}` )} ) : ( @@ -344,7 +347,7 @@ export function EnvironmentVariableCard({ color: theme.colors.success, ...secondaryTextStyle, }}> - Value found + Value found on {machineLabel} {showRemoteDiffersWarning && ( Environment Variables - - {machineId && machineName && ( - - - - {machineName} - - - )} {environmentVariables.length > 0 && ( @@ -181,6 +163,7 @@ export function EnvironmentVariablesList({ key={index} variable={envVar} machineId={machineId} + machineName={machineName ?? null} expectedValue={docs.expectedValue} description={docs.description} isSecret={isSecret} diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 5dcd0b3af..3e08f5ced 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -70,6 +70,9 @@ export function useEnvironmentVariables( } // Query variables in a single machineBash() call. + // + // IMPORTANT: Run the query inside a login shell so we match the environment a session + // would typically start with (e.g. macOS users often configure PATH in zsh startup files). // 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 = [ @@ -82,20 +85,26 @@ export function useEnvironmentVariables( "process.stdout.write(JSON.stringify(out));", ].join(""); const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${validVarNames.join(' ')}`; - // Bash fallback uses indirect expansion to avoid eval and to distinguish unset vs empty. - // IMPORTANT: avoid embedding literal `${...}` inside this TypeScript template string (it would be parsed as JS interpolation). - const bashIsSetExpr = '\\$' + '{!name+x}'; - const bashValueExpr = '\\$' + '{!name}'; - const bashFallback = [ + // 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 [ -n "${bashIsSetExpr}" ]; then`, - `printf "%s=%s\\n" "$name" "${bashValueExpr}";`, + `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 ${bashFallback}; fi`; + + const inShell = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; + const escapedInShell = inShell.replace(/'/g, "'\\''"); + + const command = [ + `if command -v zsh >/dev/null 2>&1; then zsh -lc '${escapedInShell}';`, + `elif command -v bash >/dev/null 2>&1; then bash -lc '${escapedInShell}';`, + `else sh -lc '${escapedInShell}'; fi`, + ].join(' '); try { const result = await machineBash(machineId, command, '/'); From 2b8b6e712ca0307b47bc7c265d518ca8878fa1f7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 09:04:55 +0100 Subject: [PATCH 065/106] fix(web): increase modal height and align env preview with daemon --- sources/hooks/useEnvironmentVariables.ts | 30 +++++++++++++----------- sources/theme.css | 14 ++++++++++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 3e08f5ced..a012c46af 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -71,8 +71,10 @@ export function useEnvironmentVariables( // Query variables in a single machineBash() call. // - // IMPORTANT: Run the query inside a login shell so we match the environment a session - // would typically start with (e.g. macOS users often configure PATH in zsh startup files). + // 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 = [ @@ -97,14 +99,7 @@ export function useEnvironmentVariables( `done`, ].join(' '); - const inShell = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; - const escapedInShell = inShell.replace(/'/g, "'\\''"); - - const command = [ - `if command -v zsh >/dev/null 2>&1; then zsh -lc '${escapedInShell}';`, - `elif command -v bash >/dev/null 2>&1; then bash -lc '${escapedInShell}';`, - `else sh -lc '${escapedInShell}'; fi`, - ].join(' '); + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; try { const result = await machineBash(machineId, command, '/'); @@ -115,9 +110,14 @@ export function useEnvironmentVariables( const stdout = result.stdout; // JSON protocol: {"VAR":"value","MISSING":null} - if (stdout.trim().startsWith('{')) { + // 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(stdout) as Record; + const parsed = JSON.parse(jsonSlice) as Record; validVarNames.forEach((name) => { results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; }); @@ -128,9 +128,11 @@ export function useEnvironmentVariables( // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__" if (Object.keys(results).length === 0) { - // Do not trim: it can corrupt values with meaningful whitespace. + // 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 => { + 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); diff --git a/sources/theme.css b/sources/theme.css index 7e241b5ae..e10795b89 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(860px, calc(100vh - 48px)) !important; + max-height: min(860px, calc(100vh - 48px)) !important; + min-height: min(860px, 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 +} From 4b70ee6c98daf8e60cf0f3a03ac4b21c37baa885 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 09:55:29 +0100 Subject: [PATCH 066/106] fix(env-vars): batch resolve in editor and support := --- .../components/EnvironmentVariableCard.tsx | 22 +++++------- .../components/EnvironmentVariablesList.tsx | 36 ++++++++++++++----- .../EnvironmentVariablesPreviewModal.tsx | 2 +- sources/hooks/envVarUtils.ts | 6 ++-- sources/theme.css | 6 ++-- 5 files changed, 44 insertions(+), 28 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 9923b4561..26647598e 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -3,13 +3,14 @@ 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) @@ -26,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, @@ -62,6 +63,8 @@ export function EnvironmentVariableCard({ variable, machineId, machineName, + machineEnv, + isMachineEnvLoading = false, expectedValue, description, isSecret = false, @@ -104,14 +107,7 @@ export function EnvironmentVariableCard({ const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); - // Query remote machine for variable value (only if toggle 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'; @@ -139,7 +135,7 @@ export function EnvironmentVariableCard({ isSecret ? (useRemoteVariable && remoteVariableName ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) + : (defaultValue ? '***hidden***' : '(empty)')) : (useRemoteVariable && machineId && remoteValue !== undefined ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : '(empty)') : remoteValue) : (computedTemplateValue || '(empty)')); @@ -322,7 +318,7 @@ export function EnvironmentVariableCard({ {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - {remoteValue === undefined ? ( + {isMachineEnvLoading || remoteValue === undefined ? ( ; @@ -30,6 +31,31 @@ export function EnvironmentVariablesList({ }: 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(''); @@ -60,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 }; @@ -151,8 +171,6 @@ export function EnvironmentVariablesList({ {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); - - const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; const isSecret = docs.isSecret || SECRET_NAME_REGEX.test(envVar.name) || @@ -164,6 +182,8 @@ export function EnvironmentVariablesList({ variable={envVar} machineId={machineId} machineName={machineName ?? null} + machineEnv={machineEnv} + isMachineEnvLoading={isMachineEnvLoading} expectedValue={docs.expectedValue} description={docs.description} isSecret={isSecret} diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index 853281d37..3ad466286 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -16,7 +16,7 @@ export interface EnvironmentVariablesPreviewModalProps { } function parseTemplateValue(value: string): { sourceVar: string; fallback: string } | null { - const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/); if (withFallback) { return { sourceVar: withFallback[1], fallback: withFallback[2] }; } diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index d3bc26823..83f5fa825 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -35,7 +35,7 @@ export function resolveEnvVarSubstitution( // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset). // Group 1: Variable name (required) // Group 2: Default value (optional) - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::-(.*))?\}$/); + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=](.*))?\}$/); if (match) { const varName = match[1]; const defaultValue = match[2]; // :- default @@ -75,9 +75,9 @@ export function extractEnvVarReferences( const refs = new Set(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset) + // 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/theme.css b/sources/theme.css index e10795b89..2c80b7222 100644 --- a/sources/theme.css +++ b/sources/theme.css @@ -39,9 +39,9 @@ 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(860px, calc(100vh - 48px)) !important; - max-height: min(860px, calc(100vh - 48px)) !important; - min-height: min(860px, calc(100vh - 48px)) !important; + height: min(820px, calc(100vh - 48px)) !important; + max-height: min(820px, calc(100vh - 48px)) !important; + min-height: min(820px, calc(100vh - 48px)) !important; } } From 21c79688f528de2ee1ffd1c44c91e75517dece4a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:07:36 +0100 Subject: [PATCH 067/106] refactor(i18n): move translation types to _types Use sources/text/translations/en.ts as the canonical English strings and remove sources/text/_default.ts as a second source of truth. --- sources/text/README.md | 11 +- sources/text/_default.ts | 946 --------------------------- sources/text/_types.ts | 24 + sources/text/index.ts | 14 +- sources/text/translations/ca.ts | 2 +- sources/text/translations/en.ts | 10 +- sources/text/translations/es.ts | 2 +- sources/text/translations/it.ts | 2 +- sources/text/translations/ja.ts | 2 +- sources/text/translations/pl.ts | 2 +- sources/text/translations/pt.ts | 2 +- sources/text/translations/ru.ts | 2 +- sources/text/translations/zh-Hans.ts | 2 +- 13 files changed, 45 insertions(+), 976 deletions(-) delete mode 100644 sources/text/_default.ts create mode 100644 sources/text/_types.ts 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 024c7f6eb..000000000 --- a/sources/text/_default.ts +++ /dev/null @@ -1,946 +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', - 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: { - 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: 'Default', - 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: ({ name }: { name: string }) => `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 4a55163eb..636312712 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 diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 985b75fcb..45ae3de1d 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', @@ -939,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 75d5eaa44..09433d74d 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 diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 1387529c0..a9d572351 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 diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 72f3aa402..c0f4a21eb 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 diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index d44114e02..d620dea97 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 diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 5e6e625be..6b59d4c99 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 diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 49d57231e..0d8ac93b8 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 diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index bdbb5f19e..31e4aba22 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 From 04628ed9d6884fac33ab71dbaef3a9747bba6546 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:05 +0100 Subject: [PATCH 068/106] refactor(profiles): remove provider config objects Profiles are env-var-based only: drop anthropic/openai/azure/together config objects from the schema and conversion, and migrate any legacy values into environmentVariables. Also extract PermissionMode/ModelMode into sources/sync/permissionTypes and remove the unused PermissionModeSelector component. --- sources/app/(app)/new/index.tsx | 91 ++-------- sources/components/AgentInput.tsx | 17 +- sources/components/PermissionModeSelector.tsx | 110 ------------ sources/components/ProfileEditForm.tsx | 15 +- sources/sync/permissionTypes.ts | 21 +++ sources/sync/persistence.ts | 3 +- sources/sync/profileMutations.ts | 2 - sources/sync/profileUtils.ts | 9 +- sources/sync/settings.spec.ts | 39 ++++- sources/sync/settings.ts | 162 ++++++++---------- sources/sync/storage.ts | 16 +- 11 files changed, 153 insertions(+), 332 deletions(-) delete mode 100644 sources/components/PermissionModeSelector.tsx create mode 100644 sources/sync/permissionTypes.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 739dde2de..37dcfafc3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -19,13 +19,12 @@ import { SessionTypeSelector, SessionTypeSelectorRows } from '@/components/Sessi 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, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; import { useCLIDetection } from '@/hooks/useCLIDetection'; -import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; @@ -36,23 +35,9 @@ import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; -import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { ItemRowActions } from '@/components/ItemRowActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; -// 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); - } -} - // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { return React.useMemo(() => @@ -65,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); }; @@ -560,19 +545,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 }); @@ -676,7 +648,7 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); - const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { + const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => { // Persist wizard state before navigating so selection doesn't reset on return. saveNewSessionDraft({ input: sessionPrompt, @@ -690,17 +662,21 @@ function NewSessionWizard() { updatedAt: Date.now(), }); - const profileData = JSON.stringify(profile); - const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; - router.push(selectedMachineId ? `${base}&machineId=${encodeURIComponent(selectedMachineId)}` as any : base as any); + 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(createEmptyCustomProfile()); + openProfileEdit({}); }, [openProfileEdit]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - openProfileEdit(duplicateProfileForEdit(profile)); + openProfileEdit({ cloneFromProfileId: profile.id }); }, [openProfileEdit]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { @@ -1008,12 +984,12 @@ function NewSessionWizard() { 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(profile), - }); + actions.push({ + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit({ profileId: profile.id }), + }); actions.push({ id: 'copy', title: 'Duplicate profile', @@ -1094,37 +1070,6 @@ function NewSessionWizard() { return parts.join(' · '); }, [isProfileAvailable]); - // Handle machine and path selection callbacks - React.useEffect(() => { - let handler = (machineId: string) => { - let machine = storage.getState().machines[machineId]; - if (machine) { - setSelectedMachineId(machineId); - const bestPath = getRecentPathForMachine(machineId, recentMachinePaths); - setSelectedPath(bestPath); - } - }; - onMachineSelected = handler; - return () => { - onMachineSelected = () => { }; - }; - }, [recentMachinePaths]); - - React.useEffect(() => { - let handler = (savedProfile: AIBackendProfile) => { - // Only auto-select newly created profiles (Add / Duplicate / Save As). - // Edits to other profiles should not change the current selection. - const wasExisting = profiles.some(p => p.id === savedProfile.id); - if (!wasExisting) { - setSelectedProfileId(savedProfile.id); - } - }; - onProfileSaved = handler; - return () => { - onProfileSaved = () => { }; - }; - }, [profiles]); - const handleMachineClick = React.useCallback(() => { router.push({ pathname: '/new/pick/machine', diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 0d3719398..2f7ea6f87 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -5,7 +5,7 @@ import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; -import { PermissionMode, ModelMode } from './PermissionModeSelector'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; import { hapticsLight, hapticsError } from './haptics'; import { Shaker, ShakeInstance } from './Shaker'; import { StatusDot } from './StatusDot'; @@ -372,7 +372,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('📝 Input state changed:', JSON.stringify(newState)); setInputState(newState); }, []); @@ -382,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; @@ -415,8 +402,6 @@ export const AgentInput = React.memo(React.forwardRef 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 69a33fdc0..5f543fdf1 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -6,7 +6,7 @@ import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { AIBackendProfile } from '@/sync/settings'; -import { PermissionMode } from '@/components/PermissionModeSelector'; +import type { PermissionMode } from '@/sync/permissionTypes'; import { SessionTypeSelector } from '@/components/SessionTypeSelector'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; @@ -256,21 +256,14 @@ export function ProfileEditForm({ onSave({ ...profile, name: name.trim(), - anthropicConfig: {}, - openaiConfig: {}, - azureOpenAIConfig: {}, environmentVariables, tmuxConfig: useTmux ? { + ...(profile.tmuxConfig ?? {}), sessionName: tmuxSession.trim() || '', tmpDir: tmuxTmpDir.trim() || undefined, - updateEnvironment: undefined, } - : { - sessionName: undefined, - tmpDir: undefined, - updateEnvironment: undefined, - }, + : undefined, defaultSessionType, defaultPermissionMode, compatibility, @@ -394,7 +387,7 @@ export function ProfileEditForm({ Tmux Session Name ({t('common.optional')}) { return { id: 'anthropic', name: 'Anthropic (Default)', - anthropicConfig: {}, environmentVariables: [], defaultPermissionMode: 'default', compatibility: { claude: true, codex: false, gemini: false }, @@ -269,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 @@ -295,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 @@ -320,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' }, @@ -339,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/settings.spec.ts b/sources/sync/settings.spec.ts index 43c615afc..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', () => { @@ -411,7 +442,7 @@ describe('settings', () => { lastUsedModelMode: null, profiles: [], lastUsedProfile: null, - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, @@ -595,7 +626,6 @@ describe('settings', () => { { id: 'server-profile', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -613,7 +643,6 @@ describe('settings', () => { { id: 'local-profile', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -715,7 +744,6 @@ describe('settings', () => { profiles: [{ id: 'test-profile', name: 'Test', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -748,7 +776,6 @@ describe('settings', () => { profiles: [{ id: 'device-b-profile', name: 'Device B Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -860,7 +887,6 @@ describe('settings', () => { profiles: [{ id: 'server-profile-1', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -879,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 66a9625c0..ce7e3669e 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -4,50 +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 URL_OR_TEMPLATE_REGEX = /^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/; -const URL_OR_TEMPLATE_ERROR = 'Must be a valid URL or ${VAR} or ${VAR:-default} template string'; - -function isUrlOrTemplateString(val: string): boolean { - if (!val) return true; // Optional or empty string - if (URL_OR_TEMPLATE_REGEX.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } -} - -function urlOrTemplateStringOptional() { - return z.string().refine(isUrlOrTemplateString, { message: URL_OR_TEMPLATE_ERROR }).optional(); -} - -const AnthropicConfigSchema = z.object({ - baseUrl: urlOrTemplateStringOptional(), - authToken: z.string().optional(), - model: z.string().optional(), -}); - -const OpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - baseUrl: urlOrTemplateStringOptional(), - model: z.string().optional(), -}); - -const AzureOpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - endpoint: urlOrTemplateStringOptional(), - 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(), @@ -75,12 +31,6 @@ 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(), @@ -115,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. * @@ -132,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: daemon must interpolate ${VAR} / ${VAR:-default} in env values before calling spawn() (Node does not expand placeholders) + * - 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}) @@ -159,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 @@ -224,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({ @@ -357,6 +336,8 @@ export function settingsParse(settings: unknown): Settings { return { ...settingsDefaults }; } + 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; @@ -364,7 +345,7 @@ export function settingsParse(settings: unknown): Settings { // Parse known fields individually to avoid whole-object failure. (Object.keys(SettingsSchema.shape) as Array).forEach((key) => { - if (!(key in input)) return; + if (!Object.prototype.hasOwnProperty.call(input, key)) return; // Special-case profiles: validate per profile entry, keep valid ones. if (key === 'profiles') { @@ -372,10 +353,10 @@ export function settingsParse(settings: unknown): Settings { if (Array.isArray(profilesValue)) { const parsedProfiles: AIBackendProfile[] = []; for (const rawProfile of profilesValue) { - const parsedProfile = AIBackendProfileSchema.safeParse(rawProfile); + const parsedProfile = AIBackendProfileSchema.safeParse(normalizeLegacyProfileConfig(rawProfile)); if (parsedProfile.success) { parsedProfiles.push(parsedProfile.data); - } else if (__DEV__) { + } else if (isDev) { console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues); } } @@ -388,16 +369,13 @@ export function settingsParse(settings: unknown): Settings { const parsedField = schema.safeParse(input[key]); if (parsedField.success) { result[key] = parsedField.data; - } else if (__DEV__) { + } 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 (result.preferredLanguage === 'zh') { - if (__DEV__) { - console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); - } result.preferredLanguage = 'zh-Hans'; } @@ -415,8 +393,14 @@ export function settingsParse(settings: unknown): Settings { // Preserve unknown fields (forward compatibility). for (const [key, value] of Object.entries(input)) { - if (!(key in SettingsSchema.shape)) { - result[key] = value; + if (key === '__proto__') continue; + if (!Object.prototype.hasOwnProperty.call(SettingsSchema.shape, key)) { + Object.defineProperty(result, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); } } diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index d95e186d2..6a212b35d 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"; @@ -367,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 }; @@ -385,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 || {}; @@ -403,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` ); @@ -880,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, From 5571035ab69f0c24aaa8418cb12d53de7d6e9791 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:21 +0100 Subject: [PATCH 069/106] fix(new-session): switch profile picker to id-based navigation Avoid URL-encoding full profile JSON when editing/duplicating profiles and return selection via navigation params (unmount-safe). --- sources/app/(app)/new/pick/profile-edit.tsx | 68 ++++++++++------ sources/app/(app)/new/pick/profile.tsx | 77 +++++++++++-------- .../new/pick/profilePickerRouting.test.ts | 26 +++++++ 3 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 sources/app/(app)/new/pick/profilePickerRouting.test.ts diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 7dd6865e4..8fe47f83e 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { useNavigation } from '@react-navigation/native'; +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'; @@ -10,17 +10,25 @@ 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 } from '@/sync/profileMutations'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { Modal } from '@/modal'; export default function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); const navigation = useNavigation(); - const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); + 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'); @@ -34,32 +42,38 @@ export default function ProfileEditScreen() { // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { - if (params.profileData) { + if (profileDataParam) { try { // 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(params.profileData); + return JSON.parse(profileDataParam); } catch { - return JSON.parse(decodeURIComponent(params.profileData)); + 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( @@ -122,10 +136,20 @@ export default function ProfileEditScreen() { : [...profiles, profileToSave]; setProfiles(updatedProfiles); + + // Update last used profile for convenience in other screens. if (isNewProfile) { setLastUsedProfile(profileToSave.id); - // Notify the /new screen only for newly created profiles (Add / Duplicate / Save As). - callbacks.onProfileSaved(profileToSave); + } + + // 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; @@ -167,7 +191,7 @@ export default function ProfileEditScreen() { ]}> (); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); const useProfiles = useSetting('useProfiles'); const experimentsEnabled = useSetting('experiments'); const [profiles, setProfiles] = useSettingMutable('profiles'); @@ -29,6 +28,7 @@ export default function ProfilePickerScreen() { 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 ; @@ -62,9 +62,24 @@ export default function ProfilePickerScreen() { router.back(); }, [navigation, router]); - const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { - const profileData = JSON.stringify(profile); - const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; + 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]); @@ -86,12 +101,8 @@ export default function ProfilePickerScreen() { }, [favoriteProfileIdSet, favoriteProfileIds, setFavoriteProfileIds]); const handleAddProfile = React.useCallback(() => { - openProfileEdit(createEmptyCustomProfile()); - }, [openProfileEdit]); - - const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - openProfileEdit(duplicateProfileForEdit(profile)); - }, [openProfileEdit]); + openProfileCreate(); + }, [openProfileCreate]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { Modal.alert( @@ -124,19 +135,19 @@ export default function ProfilePickerScreen() { 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: 'copy', - title: 'Duplicate profile', - icon: 'copy-outline', - onPress: () => handleDuplicateProfile(profile), - }, - ]; + { + 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', @@ -165,15 +176,15 @@ export default function ProfilePickerScreen() { /> ); - }, [ - handleDeleteProfile, - handleDuplicateProfile, - openProfileEdit, - theme.colors.button.primary.background, - theme.colors.button.secondary.tint, - theme.colors.deleteAction, - theme.colors.textSecondary, - toggleFavoriteProfile, + }, [ + handleDeleteProfile, + openProfileEdit, + openProfileDuplicate, + theme.colors.button.primary.background, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, ]); return ( diff --git a/sources/app/(app)/new/pick/profilePickerRouting.test.ts b/sources/app/(app)/new/pick/profilePickerRouting.test.ts new file mode 100644 index 000000000..f0a78b93e --- /dev/null +++ b/sources/app/(app)/new/pick/profilePickerRouting.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('ProfilePickerScreen routing', () => { + 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'); + }); +}); + From 1a36c61acda56ba9595143834a768dfb03ef2939 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:36 +0100 Subject: [PATCH 070/106] fix(env): align template semantics and preview safety Support , default, and default (:= treated like :-) and apply bash-like empty-string fallback semantics. Prevent secret-like env vars from being queried into UI memory during env previews. --- .../EnvironmentVariablesList.keys.test.ts | 14 ++++++++++++++ sources/components/EnvironmentVariablesList.tsx | 2 +- .../EnvironmentVariablesPreviewModal.tsx | 2 ++ sources/hooks/envVarUtils.ts | 7 ++++++- sources/hooks/useEnvironmentVariables.test.ts | 4 ++++ 5 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 sources/components/EnvironmentVariablesList.keys.test.ts 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 b210c1666..9b92e2198 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -178,7 +178,7 @@ export function EnvironmentVariablesList({ return ( { 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); } }); diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index 83f5fa825..e839a6b10 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -41,13 +41,18 @@ export function resolveEnvVarSubstitution( 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 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'); }); From 8adde3f337beab5a1b4ddb1be8a49037ce1e71a4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:55 +0100 Subject: [PATCH 071/106] fix(sync): stop resetting model meta and gate logs Stop sending model:null/fallbackModel:null in outgoing message meta (keeps modelMode dormant without forcing per-message resets). Gate noisy realtime/sync debug logs behind __DEV__/remove console noise in tests. --- sources/realtime/RealtimeVoiceSession.tsx | 19 +++--- sources/realtime/RealtimeVoiceSession.web.tsx | 29 +++++---- sources/sync/messageMeta.test.ts | 18 ++++++ sources/sync/messageMeta.ts | 15 +++++ sources/sync/reducer/phase0-skipping.spec.ts | 8 +-- sources/sync/sync.ts | 60 +++---------------- 6 files changed, 70 insertions(+), 79 deletions(-) create mode 100644 sources/sync/messageMeta.test.ts create mode 100644 sources/sync/messageMeta.ts 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..e87cf1431 --- /dev/null +++ b/sources/sync/messageMeta.test.ts @@ -0,0 +1,18 @@ +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); + }); +}); diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts new file mode 100644 index 000000000..39b7bd67d --- /dev/null +++ b/sources/sync/messageMeta.ts @@ -0,0 +1,15 @@ +import type { MessageMeta } from './typesMessageMeta'; + +export function buildOutgoingMessageMeta(params: { + sentFrom: string; + permissionMode: NonNullable; + appendSystemPrompt: string; + displayText?: string; +}): MessageMeta { + return { + sentFrom: params.sentFrom, + permissionMode: params.permissionMode, + appendSystemPrompt: params.appendSystemPrompt, + ...(params.displayText ? { displayText: params.displayText } : {}), + }; +} 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/sync.ts b/sources/sync/sync.ts index 2b136f1a6..be559bea6 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. @@ -225,7 +226,6 @@ class Sync { // Read permission mode and model mode from session state const permissionMode = session.permissionMode || 'default'; - const modelMode = session.modelMode || 'default'; // Generate local ID const localId = randomUUID(); @@ -247,10 +247,6 @@ class Sync { sentFrom = 'web'; // fallback } - // Model settings - models are configured in CLI settings - const model: string | null = null; - const fallbackModel: string | null = null; - // Create user message content with metadata const content: RawRecord = { role: 'user', @@ -258,14 +254,12 @@ 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 +829,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 +843,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; @@ -1181,11 +1173,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 +1209,6 @@ class Sync { parsedSettings = { ...settingsDefaults }; } - // Avoid logging full settings in production (may contain secrets like API keys / profile env vars). - if (__DEV__) { - console.log('settings', { - version: data.settingsVersion, - schemaVersion: parsedSettings.schemaVersion, - }); - } - // Apply settings to storage storage.getState().replaceSettings(parsedSettings, data.settingsVersion); @@ -1261,16 +1240,6 @@ class Sync { const data = await response.json(); const parsedProfile = profileParse(data); - // Keep debug logs dev-only (avoid leaking PII/noise in prod logs). - if (__DEV__) { - console.log('profile', { - id: parsedProfile.id, - timestamp: parsedProfile.timestamp, - hasAvatar: !!parsedProfile.avatar, - hasGitHub: !!parsedProfile.github, - }); - } - // Apply profile to storage storage.getState().applyProfile(parsedProfile); } @@ -1308,12 +1277,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) { @@ -1327,7 +1295,7 @@ class Sync { }); } } catch (error) { - console.log('[fetchNativeUpdate] Error:', error); + console.error('[fetchNativeUpdate] Error:', error); storage.getState().applyNativeUpdateStatus(null); } } @@ -1348,7 +1316,6 @@ class Sync { } if (!apiKey) { - console.log(`RevenueCat: No API key found for platform ${Platform.OS}`); return; } @@ -1365,7 +1332,6 @@ class Sync { }); this.revenueCatInitialized = true; - console.log('RevenueCat initialized successfully'); } // Sync purchases @@ -1432,9 +1398,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); @@ -1461,7 +1424,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; } @@ -1509,15 +1472,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') { @@ -1551,7 +1511,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') { @@ -1937,7 +1896,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`); } @@ -1946,17 +1904,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); } From b870e1de97b50ce8ecdb565036113652012a505f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 00:06:51 +0100 Subject: [PATCH 072/106] fix(new): align Gemini permission and model modes --- sources/app/(app)/new/index.tsx | 26 +++++++++++++------------- sources/sync/permissionTypes.ts | 5 ++++- sources/text/translations/ca.ts | 14 +++++++------- sources/text/translations/es.ts | 14 +++++++------- sources/text/translations/it.ts | 12 ++++++------ sources/text/translations/ja.ts | 12 ++++++------ sources/text/translations/pl.ts | 14 +++++++------- sources/text/translations/pt.ts | 14 +++++++------- sources/text/translations/zh-Hans.ts | 14 +++++++------- 9 files changed, 64 insertions(+), 61 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d4c6364c1..031c7af34 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -2066,24 +2066,24 @@ function NewSessionWizard() { - {(agentType === 'codex' + {(agentType === 'codex' || agentType === 'gemini' ? [ - { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + { 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(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.default' : 'agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.acceptEdits' : 'agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.plan' : 'agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.bypassPermissions' : 'agentInput.permissionMode.bypassPermissions'), description: 'Skip all 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) => ( - `${percent}% restant`, diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 099f954ad..3272ca282 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -439,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`, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 241ca8d9e..c1c31981e 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -470,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`, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index ea316bdaf..34d6d8047 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -473,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}%`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 08c4485ca..436e9a4ee 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -449,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}%`, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 9da339b47..080db2565 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -439,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`, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 00552934b..fc33c96f9 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -441,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}%`, From 47ae80af7802b92156b852557ebb89f39e978a95 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 00:29:10 +0100 Subject: [PATCH 073/106] fix(app): avoid bundling vitest test file --- sources/{app/(app)/new/pick => }/profilePickerRouting.test.ts | 1 - 1 file changed, 1 deletion(-) rename sources/{app/(app)/new/pick => }/profilePickerRouting.test.ts (99%) diff --git a/sources/app/(app)/new/pick/profilePickerRouting.test.ts b/sources/profilePickerRouting.test.ts similarity index 99% rename from sources/app/(app)/new/pick/profilePickerRouting.test.ts rename to sources/profilePickerRouting.test.ts index f0a78b93e..0bb994c9e 100644 --- a/sources/app/(app)/new/pick/profilePickerRouting.test.ts +++ b/sources/profilePickerRouting.test.ts @@ -23,4 +23,3 @@ describe('ProfilePickerScreen routing', () => { expect(content).toContain('params.profileId'); }); }); - From 667388160a03a75fec0e26eb01a55e0464986f28 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 00:36:25 +0100 Subject: [PATCH 074/106] fix(agent-input): remove duplicate machine/path panel --- sources/components/AgentInput.tsx | 81 ------------------------------- 1 file changed, 81 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 78b98737d..0341c483b 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -820,87 +820,6 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Box 1: Context Information (Machine + Path) - Only show if either exists */} - {(props.machineName !== undefined || props.currentPath) && ( - - {/* Machine chip */} - {props.machineName !== undefined && props.onMachineClick && ( - { - hapticsLight(); - props.onMachineClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} - - {/* Path chip */} - {props.currentPath && props.onPathClick && ( - { - hapticsLight(); - props.onPathClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.currentPath} - - - )} - - )} - {/* Box 2: Action Area (Input + Send) */} {/* Input field */} From 7bb538f4a3946f3d055368405907a7ac5e821619 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 02:42:22 +0100 Subject: [PATCH 075/106] fix(new): consume profileId param once --- sources/app/(app)/new/index.tsx | 39 ++++++++++++++++----- sources/app/(app)/new/pick/profile-edit.tsx | 10 ++++++ sources/profileRouteParams.test.ts | 39 +++++++++++++++++++++ sources/profileRouteParams.ts | 32 +++++++++++++++++ 4 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 sources/profileRouteParams.test.ts create mode 100644 sources/profileRouteParams.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 031c7af34..58a4f3a50 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -37,6 +37,8 @@ import { EnvironmentVariablesPreviewModal } from '@/components/newSession/Enviro import { buildProfileGroups } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { CommonActions, useNavigation } from '@react-navigation/native'; +import { consumeProfileIdParam } from '@/profileRouteParams'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -247,6 +249,7 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); const router = useRouter(); + const navigation = useNavigation(); const safeArea = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const { width: screenWidth } = useWindowDimensions(); @@ -340,6 +343,21 @@ function NewSessionWizard() { setSelectedProfileId(null); } }, [useProfiles, selectedProfileId]); + + // If a new profile is created while this screen is open (e.g. "Save As" from a built-in profile), + // `lastUsedProfile` is updated. Keep the selection in sync so the new profile is immediately active. + React.useEffect(() => { + if (!useProfiles) { + return; + } + if (!lastUsedProfile || !profileMap.has(lastUsedProfile)) { + return; + } + if (selectedProfileId === lastUsedProfile) { + return; + } + setSelectedProfileId(lastUsedProfile); + }, [lastUsedProfile, profileMap, selectedProfileId, useProfiles]); const allowGemini = experimentsEnabled; const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { @@ -818,20 +836,23 @@ function NewSessionWizard() { return; } - const nextProfileIdFromParams = Array.isArray(profileIdParam) ? profileIdParam[0] : profileIdParam; - if (typeof nextProfileIdFromParams !== 'string') { - return; - } - if (nextProfileIdFromParams === '') { + const { nextSelectedProfileId, shouldClearParam } = consumeProfileIdParam({ + profileIdParam, + selectedProfileId, + }); + + if (nextSelectedProfileId === null) { if (selectedProfileId !== null) { setSelectedProfileId(null); } - return; + } else if (typeof nextSelectedProfileId === 'string') { + selectProfile(nextSelectedProfileId); } - if (nextProfileIdFromParams !== selectedProfileId) { - selectProfile(nextProfileIdFromParams); + + if (shouldClearParam) { + navigation.dispatch(CommonActions.setParams({ profileId: undefined }) as never); } - }, [profileIdParam, selectedProfileId, selectProfile, useProfiles]); + }, [navigation, profileIdParam, selectedProfileId, selectProfile, useProfiles]); // Keep agentType compatible with the currently selected profile. React.useEffect(() => { diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 8fe47f83e..964467f8b 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -140,6 +140,16 @@ export default function ProfileEditScreen() { // Update last used profile for convenience in other screens. if (isNewProfile) { setLastUsedProfile(profileToSave.id); + // For newly created profiles (including "Save As" from a built-in profile), jump back to /new + // and pass the id through route params so it can be selected immediately. + // This avoids relying on intermediate picker screens to forward the selection. + isDirtyRef.current = false; + setIsDirty(false); + router.replace({ + pathname: '/new', + params: { profileId: profileToSave.id }, + } as any); + return; } // Pass selection back to the /new screen via navigation params (unmount-safe). diff --git a/sources/profileRouteParams.test.ts b/sources/profileRouteParams.test.ts new file mode 100644 index 000000000..7991655c7 --- /dev/null +++ b/sources/profileRouteParams.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { consumeProfileIdParam } from './profileRouteParams'; + +describe('consumeProfileIdParam', () => { + it('does nothing when param is missing', () => { + expect(consumeProfileIdParam({ profileIdParam: undefined, selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); + + it('clears param and deselects when param is empty string', () => { + expect(consumeProfileIdParam({ profileIdParam: '', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: null, + shouldClearParam: true, + }); + }); + + it('clears param without changing selection when it matches current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'abc', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: true, + }); + }); + + it('clears param and selects when it differs from current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'next', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('accepts array params and uses the first value', () => { + expect(consumeProfileIdParam({ profileIdParam: ['next', 'ignored'], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); +}); diff --git a/sources/profileRouteParams.ts b/sources/profileRouteParams.ts new file mode 100644 index 000000000..99eae054a --- /dev/null +++ b/sources/profileRouteParams.ts @@ -0,0 +1,32 @@ +export function normalizeOptionalParam(value?: string | string[]) { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +export function consumeProfileIdParam(params: { + profileIdParam?: string | string[]; + selectedProfileId: string | null; +}): { + nextSelectedProfileId: string | null | undefined; + shouldClearParam: boolean; +} { + const nextProfileIdFromParams = normalizeOptionalParam(params.profileIdParam); + + if (typeof nextProfileIdFromParams !== 'string') { + return { nextSelectedProfileId: undefined, shouldClearParam: false }; + } + + if (nextProfileIdFromParams === '') { + return { nextSelectedProfileId: null, shouldClearParam: true }; + } + + if (nextProfileIdFromParams === params.selectedProfileId) { + // Nothing to do, but still clear it so it doesn't lock the selection. + return { nextSelectedProfileId: undefined, shouldClearParam: true }; + } + + return { nextSelectedProfileId: nextProfileIdFromParams, shouldClearParam: true }; +} + From a4bf4eb99f10bcf198686fdb750e68e3fd5ed23a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 02:47:21 +0100 Subject: [PATCH 076/106] fix(new): polish standard modal controls --- sources/app/(app)/new/index.tsx | 115 ++++++++++++++---------------- sources/components/AgentInput.tsx | 9 +-- sources/theme.css | 6 +- 3 files changed, 63 insertions(+), 67 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 58a4f3a50..e4b4211a3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -15,7 +15,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { machineSpawnNewSession } from '@/sync/ops'; import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; -import { SessionTypeSelector, SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; +import { SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; import { createWorktree } from '@/utils/createWorktree'; import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; @@ -1392,7 +1392,7 @@ function NewSessionWizard() { {showInlineClose && ( )} - + {/* Session type selector only if experiments enabled */} {experimentsEnabled && ( - - - - )} - - {/* AgentInput with inline chips - sticky at bottom */} - - - - []} - agentType={agentType} - onAgentClick={handleAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handlePermissionModeChange} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleMachineClick} - currentPath={selectedPath} - onPathClick={handlePathClick} - contentPaddingHorizontal={0} - {...(useProfiles ? { - profileId: selectedProfileId, - onProfileClick: handleProfileClick, - envVarsCount: selectedProfileEnvVarsCount || undefined, - onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, - } : {})} - /> - - - - + + + + + + )} + + {/* AgentInput with inline chips - sticky at bottom */} + + + + []} + agentType={agentType} + onAgentClick={handleAgentClick} + permissionMode={permissionMode} + onPermissionModeChange={handlePermissionModeChange} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleMachineClick} + currentPath={selectedPath} + onPathClick={handlePathClick} + contentPaddingHorizontal={0} + panelStyle={{ backgroundColor: theme.colors.surface }} + {...(useProfiles + ? { + profileId: selectedProfileId, + onProfileClick: handleProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } + : {})} + /> + + + + ); } diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 0341c483b..229216e50 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -77,6 +77,7 @@ interface AgentInputProps { envVarsCount?: number; onEnvVarsClick?: () => void; contentPaddingHorizontal?: number; + panelStyle?: ViewStyle; } const MAX_CONTEXT_SIZE = 190000; @@ -421,9 +422,9 @@ export const AgentInput = React.memo(React.forwardRef + {/* Input field */} Date: Fri, 16 Jan 2026 02:47:34 +0100 Subject: [PATCH 077/106] fix(profiles): improve dark-mode action icon contrast --- sources/app/(app)/new/pick/profile.tsx | 120 +++++++++++++------------ 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 5f82486df..2b6e2e2a0 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -126,66 +126,68 @@ export default function ProfilePickerScreen() { ); }, [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), - }); - } + 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.text : 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 ( + + + + + + + ); + }, + [ + handleDeleteProfile, + openProfileEdit, + openProfileDuplicate, + theme.colors.text, + theme.colors.textSecondary, + toggleFavoriteProfile, + ], + ); return ( <> From 6b979e8624c95cbb145225d8d704bec754ee6670 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 08:11:19 +0100 Subject: [PATCH 078/106] fix(new): restore standard modal input panel background --- sources/app/(app)/new/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index e4b4211a3..550cf8101 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1457,7 +1457,6 @@ function NewSessionWizard() { currentPath={selectedPath} onPathClick={handlePathClick} contentPaddingHorizontal={0} - panelStyle={{ backgroundColor: theme.colors.surface }} {...(useProfiles ? { profileId: selectedProfileId, From dfa6a2232be2c66f9cb22df69c60aba00179d3a3 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 08:31:50 +0100 Subject: [PATCH 079/106] chore(copy): update input placeholder --- sources/text/translations/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 2ef8d1025..eb50aa48a 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -322,7 +322,7 @@ export const en = { }, session: { - inputPlaceholder: 'Type a message ...', + inputPlaceholder: 'What would you like to work on?', }, commandPalette: { From 72055b60c1e3fea407f220fd3b3cdd5b43faae34 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 08:31:58 +0100 Subject: [PATCH 080/106] feat(new): add Gemini model selection to wizard --- sources/app/(app)/new/index.tsx | 79 ++++++++++++++----- sources/app/(app)/new/pick/profile.tsx | 27 ++++--- sources/components/SearchableListSelector.tsx | 8 +- 3 files changed, 80 insertions(+), 34 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 550cf8101..74619380b 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -253,6 +253,7 @@ function NewSessionWizard() { const safeArea = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const { width: screenWidth } = useWindowDimensions(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const newSessionSidePadding = 16; const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); @@ -533,11 +534,12 @@ function NewSessionWizard() { // Path selection state - initialize with formatted selected path // Refs for scrolling to sections - const scrollViewRef = React.useRef(null); - const profileSectionRef = React.useRef(null); - const machineSectionRef = React.useRef(null); - const pathSectionRef = React.useRef(null); - const permissionSectionRef = React.useRef(null); + const scrollViewRef = React.useRef(null); + const profileSectionRef = React.useRef(null); + const modelSectionRef = React.useRef(null); + const machineSectionRef = React.useRef(null); + const pathSectionRef = React.useRef(null); + const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine const cliAvailability = useCLIDetection(selectedMachineId); @@ -965,7 +967,7 @@ function NewSessionWizard() { }, [agentType, modelMode]); // Scroll to section helpers - for AgentInput button clicks - const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); + const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; model?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { return (e: any) => { wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; @@ -1027,13 +1029,13 @@ function NewSessionWizard() { 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: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }); actions.push({ id: 'edit', title: 'Edit profile', @@ -1062,7 +1064,7 @@ function NewSessionWizard() { @@ -1083,7 +1085,7 @@ function NewSessionWizard() { openProfileEnvVarsPreview, openProfileEdit, screenWidth, - theme.colors.button.primary.background, + selectedIndicatorColor, theme.colors.button.secondary.tint, theme.colors.deleteAction, theme.colors.textSecondary, @@ -1664,7 +1666,7 @@ function NewSessionWizard() { ) @@ -2003,7 +2005,7 @@ function NewSessionWizard() { @@ -2016,6 +2018,47 @@ function NewSessionWizard() { })()} + {agentType === 'gemini' && ( + + + + + Select AI Model + + + + {([ + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Most capable' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, + ] as const).map((option, index, options) => { + const isSelected = modelMode === option.value; + return ( + setModelMode(option.value)} + rightElement={( + + + + )} + showDivider={index < options.length - 1} + /> + ); + })} + + + )} + {/* Section 2: Machine Selection */} @@ -2110,7 +2153,7 @@ function NewSessionWizard() { ) : null} onPress={() => handlePermissionModeChange(option.value)} diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 2b6e2e2a0..c1bf786cb 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -17,7 +17,8 @@ import { ItemRowActions } from '@/components/ItemRowActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; export default function ProfilePickerScreen() { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const router = useRouter(); const navigation = useNavigation(); const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); @@ -263,18 +264,18 @@ export default function ProfilePickerScreen() { )} - } - onPress={() => setProfileParamAndClose('')} - showChevron={false} - selected={selectedId === ''} - rightElement={selectedId === '' - ? - : null} - showDivider={nonFavoriteBuiltInProfiles.length > 0} - /> + } + 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; diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 5d93bb0eb..d6851aabd 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -94,7 +94,7 @@ const stylesheet = StyleSheet.create((theme) => ({ })); export function SearchableListSelector(props: SearchableListSelectorProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const styles = stylesheet; const { config, @@ -183,7 +183,8 @@ export function SearchableListSelector(props: SearchableListSelectorProps) const canRemove = config.canRemoveFavorite?.(item) ?? true; const disabled = isFavorite && !canRemove; - const color = isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const color = isFavorite ? selectedColor : theme.colors.textSecondary; return ( (props: SearchableListSelectorProps) : config.getItemIcon(item); const status = config.getItemStatus?.(item, theme); const isFavorite = favoriteIds.has(itemId) || forFavorite; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; return ( (props: SearchableListSelectorProps) From 40fdf02188831a10df0cc3ca8d7c74832b8b2c03 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 08:59:08 +0100 Subject: [PATCH 081/106] feat(new): polish model selection step --- sources/app/(app)/new/index.tsx | 126 +++++++++++++++++------------- sources/components/AgentInput.tsx | 29 ++++--- sources/sync/modelOptions.ts | 20 +++++ 3 files changed, 104 insertions(+), 71 deletions(-) create mode 100644 sources/sync/modelOptions.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 74619380b..040f6a6e1 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -39,6 +39,7 @@ import { ItemRowActions } from '@/components/ItemRowActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { consumeProfileIdParam } from '@/profileRouteParams'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -415,7 +416,7 @@ function NewSessionWizard() { // which intelligently resets only when the current mode is invalid for the new agent type. // A duplicate unconditional reset here was removed to prevent race conditions. - const [modelMode, setModelMode] = React.useState(() => { + const [modelMode, setModelMode] = React.useState(() => { const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; // Note: 'default' is NOT valid for Gemini - we want explicit model selection @@ -431,8 +432,9 @@ function NewSessionWizard() { return draftMode; } } - return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; - }); + return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; + }); + const modelOptions = React.useMemo(() => getModelOptionsForAgentType(agentType), [agentType]); // Session details state const [selectedMachineId, setSelectedMachineId] = React.useState(() => { @@ -2018,31 +2020,31 @@ function NewSessionWizard() { })()} - {agentType === 'gemini' && ( - - - - - Select AI Model - - - - {([ - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Most capable' }, - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, - { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, - ] as const).map((option, index, options) => { - const isSelected = modelMode === option.value; - return ( - setModelMode(option.value)} - rightElement={( - + {modelOptions.length > 0 && ( + + + + + Select AI Model + + + + Choose the model used by this session. + + + {modelOptions.map((option, index, options) => { + const isSelected = modelMode === option.value; + return ( + } + showChevron={false} + selected={isSelected} + onPress={() => setModelMode(option.value)} + rightElement={( + - {/* Section 2: Machine Selection */} - - - - Select Machine - - + {/* Section 2: Machine Selection */} + + + + Select Machine + + + + Choose where this session runs. + - {/* Section 3: Working Directory */} - - - - Select Working Directory - - + {/* Section 3: Working Directory */} + + + + Select Working Directory + + + + Pick the folder used for commands and context. + - {/* Section 4: Permission Mode */} - - - - Select Permission Mode - - + {/* Section 4: Permission Mode */} + + + + Select Permission Mode + + + + Control how strictly actions require approval. + {(agentType === 'codex' || agentType === 'gemini' ? [ @@ -2166,13 +2177,16 @@ function NewSessionWizard() { - {/* Section 5: Session Type */} - - - - Select Session Type - - + {/* Section 5: Session Type */} + + + + Select Session Type + + + + Choose a simple session or one tied to a Git worktree. + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 229216e50..7899c7c30 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -6,6 +6,7 @@ import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; import { hapticsLight, hapticsError } from './haptics'; import { Shaker, ShakeInstance } from './Shaker'; import { StatusDot } from './StatusDot'; @@ -316,6 +317,12 @@ export const AgentInput = React.memo(React.forwardRef { + if (effectiveFlavor === 'claude' || effectiveFlavor === 'codex' || effectiveFlavor === 'gemini') { + return getModelOptionsForAgentType(effectiveFlavor); + } + return []; + }, [effectiveFlavor]); // Profile data const profiles = useSetting('profiles'); @@ -662,23 +669,15 @@ export const AgentInput = React.memo(React.forwardRef {t('agentInput.model.title')} - {isGemini ? ( - // Gemini model selector - (['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'] as const).map((model) => { - const modelConfig = { - 'gemini-2.5-pro': { label: 'Gemini 2.5 Pro', description: 'Most capable' }, - 'gemini-2.5-flash': { label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, - 'gemini-2.5-flash-lite': { label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, - }; - const config = modelConfig[model]; - const isSelected = props.modelMode === model; - + {modelOptions.length > 0 ? ( + modelOptions.map((option) => { + const isSelected = props.modelMode === option.value; return ( { hapticsLight(); - props.onModelModeChange?.(model); + props.onModelModeChange?.(option.value); }} style={({ pressed }) => ({ flexDirection: 'row', @@ -713,14 +712,14 @@ export const AgentInput = React.memo(React.forwardRef - {config.label} + {option.label} - {config.description} + {option.description} diff --git a/sources/sync/modelOptions.ts b/sources/sync/modelOptions.ts new file mode 100644 index 000000000..3732560b9 --- /dev/null +++ b/sources/sync/modelOptions.ts @@ -0,0 +1,20 @@ +import type { ModelMode } from './permissionTypes'; + +export type AgentType = 'claude' | 'codex' | 'gemini'; + +export type ModelOption = Readonly<{ + value: ModelMode; + label: string; + description: string; +}>; + +const GEMINI_MODEL_OPTIONS: readonly ModelOption[] = [ + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Most capable' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, +]; + +export function getModelOptionsForAgentType(agentType: AgentType): readonly ModelOption[] { + if (agentType === 'gemini') return GEMINI_MODEL_OPTIONS; + return []; +} From cd790b27fb5b615c20799eafcd79771fde7636a4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 08:59:22 +0100 Subject: [PATCH 082/106] fix(ui): improve selected icon contrast in dark mode --- sources/app/(app)/settings/profiles.tsx | 79 ++++++++++--------- sources/components/ProfileEditForm.tsx | 5 +- .../components/newSession/PathSelector.tsx | 9 ++- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 8b80f011b..c853961d9 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -27,7 +27,8 @@ interface ProfileManagerProps { // Profile utilities now imported from @/sync/profileUtils const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); @@ -236,14 +237,14 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {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), - }, + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, { id: 'edit', title: 'Edit profile', @@ -278,12 +279,12 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel rightElement={( - + toggleFavoriteProfile(profile.id), - }, + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, { id: 'edit', title: 'Edit profile', @@ -344,12 +345,12 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel rightElement={( - + toggleFavoriteProfile(profile.id), - }, + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, { id: 'edit', title: 'Edit profile', @@ -402,12 +403,12 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel rightElement={( - + ({ marginBottom: 12 }), []); const experimentsEnabled = useSetting('experiments'); @@ -332,7 +333,7 @@ export function ProfileEditForm({ } rightElement={ defaultPermissionMode === option.value ? ( - + ) : null } onPress={() => setDefaultPermissionMode(option.value)} diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 6ab44d019..cd603c06f 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -56,7 +56,8 @@ export function PathSelector({ favoriteDirectories, onChangeFavoriteDirectories, }: PathSelectorProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const styles = stylesheet; const inputRef = useRef(null); const searchInputRef = useRef(null); @@ -204,7 +205,7 @@ export function PathSelector({ @@ -218,12 +219,12 @@ export function PathSelector({ ); - }, [theme.colors.button.primary.background, theme.colors.textSecondary, toggleFavorite]); + }, [selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); return ( <> From b0dd47e4603fbadbe97b634b029bdeeea80c0a58 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 09:13:39 +0100 Subject: [PATCH 083/106] fix(ui): align gemini permission mapping and cleanup profiles --- sources/app/(app)/new/index.tsx | 96 +++------------ sources/app/(app)/new/pick/profile.tsx | 43 ++----- sources/app/(app)/settings/profiles.tsx | 110 +++++------------ .../EnvironmentVariablesList.keys.test.ts | 14 --- .../newSession/DirectorySelector.tsx | 111 ------------------ sources/components/profileActions.ts | 64 ++++++++++ sources/profilePickerRouting.test.ts | 25 ---- sources/sync/permissionMapping.test.ts | 30 +++++ sources/sync/permissionMapping.ts | 52 ++++++++ sources/sync/sync.ts | 5 +- 10 files changed, 208 insertions(+), 342 deletions(-) delete mode 100644 sources/components/EnvironmentVariablesList.keys.test.ts delete mode 100644 sources/components/newSession/DirectorySelector.tsx create mode 100644 sources/components/profileActions.ts delete mode 100644 sources/profilePickerRouting.test.ts create mode 100644 sources/sync/permissionMapping.test.ts create mode 100644 sources/sync/permissionMapping.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 040f6a6e1..432c0dfd4 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -20,6 +20,7 @@ import { createWorktree } from '@/utils/createWorktree'; import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { mapPermissionModeAcrossAgents } from '@/sync/permissionMapping'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; @@ -36,7 +37,7 @@ import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompati import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; -import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { buildProfileActions } from '@/components/profileActions'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { consumeProfileIdParam } from '@/profileRouteParams'; import { getModelOptionsForAgentType } from '@/sync/modelOptions'; @@ -59,7 +60,7 @@ const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: // Helper function to get the most recent path for a machine // Returns the path from the most recently CREATED session for this machine -const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ machineId: string; path: string }>): string => { +const getRecentPathForMachine = (machineId: string | null): string => { if (!machineId) return ''; const machine = storage.getState().machines[machineId]; @@ -474,7 +475,7 @@ function NewSessionWizard() { // const [selectedPath, setSelectedPath] = React.useState(() => { - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); + return getRecentPathForMachine(selectedMachineId); }); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; @@ -491,7 +492,7 @@ function NewSessionWizard() { } if (machineIdParam !== selectedMachineId) { setSelectedMachineId(machineIdParam); - const bestPath = getRecentPathForMachine(machineIdParam, recentMachinePaths); + const bestPath = getRecentPathForMachine(machineIdParam); setSelectedPath(bestPath); } }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); @@ -519,7 +520,7 @@ function NewSessionWizard() { } setSelectedMachineId(machineIdToUse); - setSelectedPath(getRecentPathForMachine(machineIdToUse, recentMachinePaths)); + setSelectedPath(getRecentPathForMachine(machineIdToUse)); }, [machines, recentMachinePaths, selectedMachineId]); // Handle path route param from picker screens (main's navigation pattern) @@ -881,41 +882,6 @@ function NewSessionWizard() { 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'; - } - } - - // 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; @@ -938,7 +904,7 @@ function NewSessionWizard() { const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); applyPermissionMode(mapped, 'auto'); - }, [agentType, applyPermissionMode, mapPermissionModeAcrossAgents]); + }, [agentType, applyPermissionMode]); // Reset model mode when agent type changes to appropriate default React.useEffect(() => { @@ -1022,43 +988,17 @@ function NewSessionWizard() { 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 ? selectedIndicatorColor : 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), + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => openProfileEdit({ profileId: profile.id }), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => handleDeleteProfile(profile), + onViewEnvironmentVariables: envVarCount > 0 ? () => openProfileEnvVarsPreview(profile) : undefined, }); - if (!profile.isBuiltIn) { - actions.push({ - id: 'delete', - title: 'Delete profile', - icon: 'trash-outline', - destructive: true, - onPress: () => handleDeleteProfile(profile), - }); - } return ( @@ -2086,7 +2026,7 @@ function NewSessionWizard() { searchPlaceholder="Search machines..." onSelect={(machine) => { setSelectedMachineId(machine.id); - const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); + const bestPath = getRecentPathForMachine(machine.id); setSelectedPath(bestPath); }} onToggleFavorite={(machine) => { diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index c1bf786cb..5c988e97c 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -14,7 +14,7 @@ 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'; +import { buildProfileActions } from '@/components/profileActions'; export default function ProfilePickerScreen() { const { theme, rt } = useUnistyles(); @@ -129,37 +129,16 @@ export default function ProfilePickerScreen() { 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.text : 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), - }); - } + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: theme.colors.text, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => openProfileEdit(profile.id), + onDuplicate: () => openProfileDuplicate(profile.id), + onDelete: () => handleDeleteProfile(profile), + }); return ( diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index c853961d9..b33f6993b 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -13,7 +13,7 @@ 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 { buildProfileActions } from '@/components/profileActions'; import { Switch } from '@/components/Switch'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { buildProfileGroups } from '@/sync/profileGrouping'; @@ -237,36 +237,16 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {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 ? selectedIndicatorColor : 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); }, - }); - } + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); return ( { 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 ? selectedIndicatorColor : 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); }, - }, - ]; + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); return ( { 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 ? selectedIndicatorColor : 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), - }, - ]; + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + }); return ( { - 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/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx deleted file mode 100644 index 6c66fabe4..000000000 --- a/sources/components/newSession/DirectorySelector.tsx +++ /dev/null @@ -1,111 +0,0 @@ -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/profileActions.ts b/sources/components/profileActions.ts new file mode 100644 index 000000000..3ada6c5d7 --- /dev/null +++ b/sources/components/profileActions.ts @@ -0,0 +1,64 @@ +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import type { AIBackendProfile } from '@/sync/settings'; + +export function buildProfileActions(params: { + profile: AIBackendProfile; + isFavorite: boolean; + favoriteActionColor?: string; + nonFavoriteActionColor?: string; + onToggleFavorite: () => void; + onEdit: () => void; + onDuplicate: () => void; + onDelete?: () => void; + onViewEnvironmentVariables?: () => void; +}): ItemAction[] { + const actions: ItemAction[] = []; + + if (params.onViewEnvironmentVariables) { + actions.push({ + id: 'envVars', + title: 'View environment variables', + icon: 'list-outline', + onPress: params.onViewEnvironmentVariables, + }); + } + + const favoriteColor = params.isFavorite ? params.favoriteActionColor : params.nonFavoriteActionColor; + const favoriteAction: ItemAction = { + id: 'favorite', + title: params.isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: params.isFavorite ? 'star' : 'star-outline', + onPress: params.onToggleFavorite, + }; + if (favoriteColor) { + favoriteAction.color = favoriteColor; + } + actions.push(favoriteAction); + + actions.push({ + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: params.onEdit, + }); + + actions.push({ + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: params.onDuplicate, + }); + + if (!params.profile.isBuiltIn && params.onDelete) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: params.onDelete, + }); + } + + return actions; +} + diff --git a/sources/profilePickerRouting.test.ts b/sources/profilePickerRouting.test.ts deleted file mode 100644 index 0bb994c9e..000000000 --- a/sources/profilePickerRouting.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -describe('ProfilePickerScreen routing', () => { - 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/sync/permissionMapping.test.ts b/sources/sync/permissionMapping.test.ts new file mode 100644 index 000000000..6bebe35a7 --- /dev/null +++ b/sources/sync/permissionMapping.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { mapPermissionModeAcrossAgents } from './permissionMapping'; + +describe('mapPermissionModeAcrossAgents', () => { + it('maps Claude plan to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Claude bypassPermissions to Gemini yolo', () => { + expect(mapPermissionModeAcrossAgents('bypassPermissions', 'claude', 'gemini')).toBe('yolo'); + }); + + it('maps Claude acceptEdits to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('acceptEdits', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Codex yolo to Claude bypassPermissions', () => { + expect(mapPermissionModeAcrossAgents('yolo', 'codex', 'claude')).toBe('bypassPermissions'); + }); + + it('maps Gemini safe-yolo to Claude plan', () => { + expect(mapPermissionModeAcrossAgents('safe-yolo', 'gemini', 'claude')).toBe('plan'); + }); + + it('keeps Codex/Gemini modes unchanged when switching between them', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('safe-yolo', 'codex', 'gemini')).toBe('safe-yolo'); + }); +}); + diff --git a/sources/sync/permissionMapping.ts b/sources/sync/permissionMapping.ts new file mode 100644 index 000000000..fbbb3a882 --- /dev/null +++ b/sources/sync/permissionMapping.ts @@ -0,0 +1,52 @@ +import type { PermissionMode } from './permissionTypes'; + +type AgentType = 'claude' | 'codex' | 'gemini'; + +function isCodexLike(agent: AgentType) { + return agent === 'codex' || agent === 'gemini'; +} + +export function mapPermissionModeAcrossAgents( + mode: PermissionMode, + from: AgentType, + to: AgentType, +): PermissionMode { + if (from === to) return mode; + + const fromCodexLike = isCodexLike(from); + const toCodexLike = isCodexLike(to); + + // Codex <-> Gemini uses the same permission mode set. + if (fromCodexLike && toCodexLike) return mode; + + if (!fromCodexLike && toCodexLike) { + // Claude -> Codex/Gemini + switch (mode) { + case 'bypassPermissions': + return 'yolo'; + case 'plan': + return 'safe-yolo'; + case 'acceptEdits': + return 'safe-yolo'; + case 'default': + return 'default'; + default: + return 'default'; + } + } + + // Codex/Gemini -> Claude + switch (mode) { + case 'yolo': + return 'bypassPermissions'; + case 'safe-yolo': + return 'plan'; + case 'read-only': + return 'default'; + case 'default': + return 'default'; + default: + return 'default'; + } +} + diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index e3b5c0df5..4fcafb4fb 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1510,7 +1510,8 @@ class Sync { const dataType = rawContent?.content?.data?.type; // Debug logging to trace lifecycle events - if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + if (isDev && (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started')) { console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`); } @@ -1521,7 +1522,7 @@ class Sync { const isTaskStarted = ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'); - if (isTaskComplete || isTaskStarted) { + if (isDev && (isTaskComplete || isTaskStarted)) { console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`); } From bbd4ccc10563debccc5ce2658b7aa010a5513f7f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 12:18:24 +0100 Subject: [PATCH 084/106] fix(happy): address upstream-sync regressions - Fix profile selection param clearing + actions\n- Normalize tool-result content across providers\n- Preserve env var operator round-trips (:- vs :=)\n- Improve ItemGroup dividers with fragment support\n- Tighten modal typing; remove unsafe casts\n- Localize new UI strings + update locale files\n- Add targeted regression tests --- sources/-session/SessionView.tsx | 12 +- sources/app/(app)/new/index.tsx | 53 +++--- sources/app/(app)/new/pick/machine.tsx | 16 +- sources/app/(app)/new/pick/path.tsx | 20 +-- sources/app/(app)/new/pick/profile-edit.tsx | 20 +-- sources/app/(app)/new/pick/profile.tsx | 153 ++++++++++-------- sources/app/(app)/session/[id]/info.tsx | 8 +- sources/app/(app)/settings/profiles.tsx | 74 ++++----- sources/components/AgentInput.tsx | 43 +++-- .../CommandPalette/CommandPaletteProvider.tsx | 4 +- .../components/EnvironmentVariableCard.tsx | 87 +++++----- .../components/EnvironmentVariablesList.tsx | 16 +- sources/components/ItemGroup.dividers.test.ts | 67 ++++++++ sources/components/ItemGroup.dividers.ts | 49 ++++++ sources/components/ItemGroup.tsx | 19 +-- sources/components/ItemRowActions.tsx | 2 +- sources/components/ProfileEditForm.tsx | 78 +++++---- sources/components/SearchHeader.tsx | 2 +- sources/components/SearchableListSelector.tsx | 8 +- sources/components/SettingsView.tsx | 7 +- .../EnvironmentVariablesPreviewModal.tsx | 80 ++++----- .../components/newSession/MachineSelector.tsx | 13 +- .../components/newSession/PathSelector.tsx | 4 +- .../newSession/ProfileCompatibilityIcon.tsx | 51 +++--- sources/components/profileActions.ts | 12 +- sources/components/tools/knownTools.tsx | 2 +- .../tools/views/GeminiExecuteView.tsx | 4 +- sources/modal/ModalManager.ts | 18 ++- sources/modal/components/CustomModal.tsx | 7 +- sources/modal/types.ts | 19 ++- sources/sync/messageMeta.test.ts | 12 ++ sources/sync/messageMeta.ts | 2 +- sources/sync/ops.ts | 4 +- sources/sync/profileGrouping.test.ts | 16 ++ sources/sync/profileGrouping.ts | 21 +++ sources/sync/settings.ts | 4 - sources/sync/storageTypes.ts | 6 +- sources/sync/typesRaw.spec.ts | 54 +++++++ sources/sync/typesRaw.ts | 6 +- sources/text/_types.ts | 22 +-- sources/text/translations/ca.ts | 151 +++++++++++++++-- sources/text/translations/en.ts | 131 ++++++++++++++- sources/text/translations/es.ts | 151 +++++++++++++++-- sources/text/translations/it.ts | 143 ++++++++++++++-- sources/text/translations/ja.ts | 143 ++++++++++++++-- sources/text/translations/pl.ts | 151 +++++++++++++++-- sources/text/translations/pt.ts | 151 +++++++++++++++-- sources/text/translations/ru.ts | 145 +++++++++++++++-- sources/text/translations/zh-Hans.ts | 151 +++++++++++++++-- sources/utils/envVarTemplate.test.ts | 31 ++++ sources/utils/envVarTemplate.ts | 40 +++++ 51 files changed, 1982 insertions(+), 501 deletions(-) create mode 100644 sources/components/ItemGroup.dividers.test.ts create mode 100644 sources/components/ItemGroup.dividers.ts create mode 100644 sources/sync/profileGrouping.test.ts create mode 100644 sources/utils/envVarTemplate.test.ts create mode 100644 sources/utils/envVarTemplate.ts diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index 457419294..589b8d2f6 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -22,6 +22,7 @@ import { isRunningOnMac } from '@/utils/platform'; import { useDeviceType, useHeaderHeight, useIsLandscape, useIsTablet } from '@/utils/responsive'; import { formatPathRelativeToHome, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; +import type { ModelMode } from '@/sync/permissionTypes'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import * as React from 'react'; @@ -197,8 +198,11 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: }, [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); + const updateModelMode = React.useCallback((mode: ModelMode) => { + // Only Gemini model modes are configurable from the UI today. + if (mode === 'default' || mode === 'gemini-2.5-pro' || mode === 'gemini-2.5-flash' || mode === 'gemini-2.5-flash-lite') { + storage.getState().updateSessionModelMode(sessionId, mode); + } }, [sessionId]); // Memoize header-dependent styles to prevent re-renders @@ -280,8 +284,8 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: sessionId={sessionId} permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} - modelMode={modelMode as any} - onModelModeChange={updateModelMode as any} + modelMode={modelMode} + onModelModeChange={updateModelMode} metadata={session.metadata} connectionStatus={{ text: sessionStatus.statusText, diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 432c0dfd4..e689f17e3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -5,7 +5,7 @@ import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } f import { Ionicons, Octicons } from '@expo/vector-icons'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { t } from '@/text'; @@ -35,10 +35,9 @@ 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 { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; import { buildProfileActions } from '@/components/profileActions'; -import { CommonActions, useNavigation } from '@react-navigation/native'; import { consumeProfileIdParam } from '@/profileRouteParams'; import { getModelOptionsForAgentType } from '@/sync/modelOptions'; @@ -52,7 +51,7 @@ const useProfileMap = (profiles: AIBackendProfile[]) => { // Environment variable transformation helper // Returns ALL profile environment variables - daemon will use them as-is -const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => { +const transformProfileToEnvironmentVars = (profile: AIBackendProfile) => { // getProfileEnvironmentVariables already returns ALL env vars from profile // including custom environmentVariables array return getProfileEnvironmentVariables(profile); @@ -316,12 +315,8 @@ function NewSessionWizard() { }, [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]); + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }, [favoriteProfileIds, setFavoriteProfileIds]); const machines = useAllMachines(); const sessions = useSessions(); @@ -855,7 +850,15 @@ function NewSessionWizard() { } if (shouldClearParam) { - navigation.dispatch(CommonActions.setParams({ profileId: undefined }) as never); + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ profileId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: undefined } }, + } as never); + } } }, [navigation, profileIdParam, selectedProfileId, selectProfile, useProfiles]); @@ -978,7 +981,7 @@ function NewSessionWizard() { machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, profileName: profile.name, }, - } as any); + }); }, [selectedMachine, selectedMachineId]); const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { @@ -1010,15 +1013,15 @@ function NewSessionWizard() { style={{ opacity: isSelected ? 1 : 0 }} /> - 0 ? ['envVars'] : []} - iconSize={20} - onActionPressIn={() => { - ignoreProfileRowPressRef.current = true; - }} - /> + 0 ? ['envVars'] : [])]} + iconSize={20} + onActionPressIn={() => { + ignoreProfileRowPressRef.current = true; + }} + /> ); }, [ @@ -1131,8 +1134,8 @@ function NewSessionWizard() { const selectedProfileEnvVars = React.useMemo(() => { if (!selectedProfileForEnvVars) return {}; - return transformProfileToEnvironmentVars(selectedProfileForEnvVars, agentType) ?? {}; - }, [agentType, selectedProfileForEnvVars]); + return transformProfileToEnvironmentVars(selectedProfileForEnvVars) ?? {}; + }, [selectedProfileForEnvVars]); const selectedProfileEnvVarsCount = React.useMemo(() => { return Object.keys(selectedProfileEnvVars).length; @@ -1148,7 +1151,7 @@ function NewSessionWizard() { machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, profileName: selectedProfileForEnvVars.name, }, - } as any); + }); }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]); // Session creation @@ -1205,7 +1208,7 @@ function NewSessionWizard() { if (profilesActive && selectedProfileId) { const selectedProfile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); if (selectedProfile) { - environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); + environmentVariables = transformProfileToEnvironmentVars(selectedProfile); } } diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index 3568fc3d2..420b4a2e9 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { View, Text } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Typography } from '@/constants/Typography'; import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -28,7 +27,7 @@ const stylesheet = StyleSheet.create((theme) => ({ }, })); -export default function MachinePickerScreen() { +export default React.memo(function MachinePickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -50,7 +49,8 @@ export default function MachinePickerScreen() { const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ - ...CommonActions.setParams({ machineId }), + type: 'SET_PARAMS', + payload: { params: { machineId } }, source: previousRoute.key, } as never); } @@ -90,14 +90,14 @@ export default function MachinePickerScreen() { - No machines available + {t('newSession.noMachinesFound')} @@ -110,7 +110,7 @@ export default function MachinePickerScreen() { @@ -134,4 +134,4 @@ export default function MachinePickerScreen() { ); -} +}); diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index 4762b9f28..57db918f0 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -1,7 +1,6 @@ -import React, { useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo } from 'react'; import { View, Text, Pressable } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Typography } from '@/constants/Typography'; import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; @@ -49,7 +48,7 @@ const stylesheet = StyleSheet.create((theme) => ({ }, })); -export default function PathPickerScreen() { +export default React.memo(function PathPickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -121,7 +120,8 @@ export default function PathPickerScreen() { const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ - ...CommonActions.setParams({ path: pathToUse }), + type: 'SET_PARAMS', + payload: { params: { path: pathToUse } }, source: previousRoute.key, } as never); } @@ -134,7 +134,7 @@ export default function PathPickerScreen() { ( - No machine selected + {t('newSession.noMachineSelected')} @@ -169,7 +169,7 @@ export default function PathPickerScreen() { ( )} @@ -214,4 +214,4 @@ export default function PathPickerScreen() { ); -} +}); diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 964467f8b..779039cd1 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,7 +1,6 @@ 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 { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -15,7 +14,7 @@ import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { Modal } from '@/modal'; -export default function ProfileEditScreen() { +export default React.memo(function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); const navigation = useNavigation(); @@ -77,9 +76,9 @@ export default function ProfileEditScreen() { const confirmDiscard = React.useCallback(async () => { return Modal.confirm( - 'Discard changes?', - 'You have unsaved changes. Discard them?', - { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' }, + t('common.discardChanges'), + t('common.unsavedChangesWarning'), + { destructive: true, confirmText: t('common.discard'), cancelText: t('common.keepEditing') }, ); }, []); @@ -103,7 +102,7 @@ export default function ProfileEditScreen() { const handleSave = (savedProfile: AIBackendProfile) => { if (!savedProfile.name || savedProfile.name.trim() === '') { - Modal.alert(t('common.error'), 'Enter a profile name.'); + Modal.alert(t('common.error'), t('profiles.nameRequired')); return; } @@ -125,7 +124,7 @@ export default function ProfileEditScreen() { 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.'); + Modal.alert(t('common.error'), t('profiles.duplicateName')); return; } @@ -157,7 +156,8 @@ export default function ProfileEditScreen() { const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { (navigation as any).dispatch({ - ...CommonActions.setParams({ profileId: profileToSave.id }), + type: 'SET_PARAMS', + payload: { params: { profileId: profileToSave.id } }, source: previousRoute.key, } as never); } @@ -210,7 +210,7 @@ export default function ProfileEditScreen() { ); -} +}); const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({ container: { diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 5c988e97c..017240cac 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; @@ -12,11 +11,11 @@ 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 { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; import { buildProfileActions } from '@/components/profileActions'; -export default function ProfilePickerScreen() { +export default React.memo(function ProfilePickerScreen() { const { theme, rt } = useUnistyles(); const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const router = useRouter(); @@ -30,6 +29,7 @@ export default function ProfilePickerScreen() { const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const ignoreProfileRowPressRef = React.useRef(false); const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { return ; @@ -46,7 +46,8 @@ export default function ProfilePickerScreen() { const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { const backend = getProfileBackendSubtitle(profile); if (profile.isBuiltIn) { - return backend ? `Built-in · ${backend}` : 'Built-in'; + const builtInLabel = t('profiles.builtIn'); + return backend ? `${builtInLabel} · ${backend}` : builtInLabel; } return backend; }, [getProfileBackendSubtitle]); @@ -56,13 +57,22 @@ export default function ProfilePickerScreen() { const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ - ...CommonActions.setParams({ profileId }), + type: 'SET_PARAMS', + payload: { params: { profileId } }, source: previousRoute.key, } as never); } router.back(); }, [navigation, router]); + const handleProfileRowPress = React.useCallback((profileId: string) => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setProfileParamAndClose(profileId); + }, [setProfileParamAndClose]); + React.useEffect(() => { if (typeof profileId === 'string' && profileId.length > 0) { setProfileParamAndClose(profileId); @@ -70,18 +80,24 @@ export default function ProfilePickerScreen() { }, [profileId, setProfileParamAndClose]); const openProfileCreate = React.useCallback(() => { - const base = '/new/pick/profile-edit'; - router.push(machineId ? `${base}?machineId=${encodeURIComponent(machineId)}` as any : base as any); + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { machineId } : {}, + }); }, [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); + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { profileId, machineId } : { profileId }, + }); }, [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); + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { cloneFromProfileId, machineId } : { cloneFromProfileId }, + }); }, [machineId, router]); const { @@ -94,12 +110,8 @@ export default function ProfilePickerScreen() { }, [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]); + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }, [favoriteProfileIds, setFavoriteProfileIds]); const handleAddProfile = React.useCallback(() => { openProfileCreate(); @@ -127,10 +139,10 @@ export default function ProfilePickerScreen() { ); }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); - const renderProfileRowRightElement = React.useCallback( - (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { - const actions = buildProfileActions({ - profile, + const renderProfileRowRightElement = React.useCallback( + (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const actions = buildProfileActions({ + profile, isFavorite, favoriteActionColor: theme.colors.text, nonFavoriteActionColor: theme.colors.textSecondary, @@ -140,25 +152,28 @@ export default function ProfilePickerScreen() { onDelete: () => handleDeleteProfile(profile), }); - return ( - - - + + - - - - ); - }, + + { + ignoreProfileRowPressRef.current = true; + }} + /> + + ); + }, [ handleDeleteProfile, openProfileEdit, @@ -198,21 +213,21 @@ export default function ProfilePickerScreen() { ) : ( <> {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} + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} /> ); })} @@ -220,38 +235,38 @@ export default function ProfilePickerScreen() { )} {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} + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} /> ); })} )} - - } - onPress={() => setProfileParamAndClose('')} - showChevron={false} - selected={selectedId === ''} - rightElement={selectedId === '' - ? + + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={selectedId === '' + ? : null} showDivider={nonFavoriteBuiltInProfiles.length > 0} /> @@ -288,4 +303,4 @@ export default function ProfilePickerScreen() { ); -} +}); diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index e1cf603d2..e0a9b9fec 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -326,9 +326,9 @@ function SessionInfoContent({ session }: { session: Session }) { title={t('sessionInfo.aiProvider')} subtitle={(() => { const flavor = session.metadata.flavor || 'claude'; - if (flavor === 'claude') return 'Claude'; - if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; - if (flavor === 'gemini') return 'Gemini'; + if (flavor === 'claude') return t('agentInput.agent.claude'); + if (flavor === 'gpt' || flavor === 'openai') return t('agentInput.agent.codex'); + if (flavor === 'gemini') return t('agentInput.agent.gemini'); return flavor; })()} icon={} @@ -336,7 +336,7 @@ function SessionInfoContent({ session }: { session: Session }) { /> {useProfiles && session.metadata?.profileId !== undefined && ( } showChevron={false} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index b33f6993b..c8dba321d 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -16,7 +16,7 @@ import { ItemRowActions } from '@/components/ItemRowActions'; import { buildProfileActions } from '@/components/profileActions'; import { Switch } from '@/components/Switch'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; -import { buildProfileGroups } from '@/sync/profileGrouping'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { useSetting } from '@/sync/storage'; @@ -43,30 +43,6 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel isEditingDirtyRef.current = isEditingDirty; }, [isEditingDirty]); - if (!useProfiles) { - return ( - - - } - rightElement={ - - } - showChevron={false} - /> - - - ); - } - const handleAddProfile = () => { setEditingProfile(createEmptyCustomProfile()); setShowAddForm(true); @@ -95,9 +71,9 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel return; } const discard = await Modal.confirm( - 'Discard changes?', - 'You have unsaved changes. Discard them?', - { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' }, + t('common.discardChanges'), + t('common.unsavedChangesWarning'), + { destructive: true, confirmText: t('common.discard'), cancelText: t('common.keepEditing') }, ); if (discard) { isEditingDirtyRef.current = false; @@ -158,11 +134,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel }, [favoriteProfileIds, profiles]); const toggleFavoriteProfile = (profileId: string) => { - if (favoriteProfileIdSet.has(profileId)) { - setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); - } else { - setFavoriteProfileIds([profileId, ...favoriteProfileIds]); - } + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); }; const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { @@ -176,7 +148,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel 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.'); + Modal.alert(t('common.error'), t('profiles.nameRequired')); return; } @@ -192,7 +164,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel p.name.trim() === newProfile.name.trim() ); if (isDuplicate) { - Modal.alert(t('common.error'), 'A profile with that name already exists.'); + Modal.alert(t('common.error'), t('profiles.duplicateName')); return; } @@ -204,7 +176,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel p.id !== profile.id && p.name.trim() === profile.name.trim() ); if (isDuplicate) { - Modal.alert(t('common.error'), 'A profile with that name already exists.'); + Modal.alert(t('common.error'), t('profiles.duplicateName')); return; } @@ -229,11 +201,35 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel closeEditor(); }; + if (!useProfiles) { + return ( + + + } + rightElement={ + + } + showChevron={false} + /> + + + ); + } + return ( {favoriteProfileItems.length > 0 && ( - + {favoriteProfileItems.map((profile) => { const isSelected = selectedProfileId === profile.id; const isFavorite = favoriteProfileIdSet.has(profile.id); @@ -281,7 +277,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel )} {nonFavoriteCustomProfiles.length > 0 && ( - + {nonFavoriteCustomProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; const isFavorite = favoriteProfileIdSet.has(profile.id); @@ -328,7 +324,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel )} - + {nonFavoriteBuiltInProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; const isFavorite = favoriteProfileIdSet.has(profile.id); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 7899c7c30..4059e1141 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -421,23 +421,38 @@ export const AgentInput = React.memo(React.forwardRef {}); return <>{children}; -} \ No newline at end of file +} diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 26647598e..2e37ab4cc 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -4,6 +4,8 @@ import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { Switch } from '@/components/Switch'; +import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/envVarTemplate'; +import { t } from '@/text'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; @@ -26,24 +28,15 @@ function parseVariableValue(value: string): { useRemoteVariable: boolean; remoteVariableName: string; defaultValue: string; + fallbackOperator: EnvVarTemplateOperator | null; } { - // Match: ${VARIABLE_NAME:-default_value} or ${VARIABLE_NAME:=default_value} - const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/); - if (matchWithFallback) { + const parsedTemplate = parseEnvVarTemplate(value); + if (parsedTemplate) { return { useRemoteVariable: true, - remoteVariableName: matchWithFallback[1], - defaultValue: matchWithFallback[2] - }; - } - - // Match: ${VARIABLE_NAME} (no fallback) - const matchNoFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); - if (matchNoFallback) { - return { - useRemoteVariable: true, - remoteVariableName: matchNoFallback[1], - defaultValue: '' + remoteVariableName: parsedTemplate.sourceVar, + defaultValue: parsedTemplate.fallback, + fallbackOperator: parsedTemplate.operator, }; } @@ -51,7 +44,8 @@ function parseVariableValue(value: string): { return { useRemoteVariable: false, remoteVariableName: '', - defaultValue: value + defaultValue: value, + fallbackOperator: null, }; } @@ -106,21 +100,24 @@ export function EnvironmentVariableCard({ const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); + const [fallbackOperator] = React.useState(parsed.fallbackOperator); const remoteValue = machineEnv?.[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; - const machineLabel = machineName?.trim() ? machineName.trim() : 'machine'; + const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); + + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); // Update parent when local state changes React.useEffect(() => { const newValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: fallbackOperator }) : defaultValue; if (newValue !== variable.value) { onUpdate(newValue); } - }, [useRemoteVariable, remoteVariableName, defaultValue, variable.value, onUpdate]); + }, [useRemoteVariable, remoteVariableName, defaultValue, fallbackOperator, variable.value, onUpdate]); // Determine status const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; @@ -128,17 +125,19 @@ export function EnvironmentVariableCard({ const computedTemplateValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: fallbackOperator }) : defaultValue; const resolvedSessionValue = isSecret ? (useRemoteVariable && remoteVariableName - ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) + ? t('profiles.environmentVariables.preview.secretValueHidden', { + value: formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue !== '' ? '***' : '', operator: fallbackOperator }), + }) + : (defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue)) : (useRemoteVariable && machineId && remoteValue !== undefined - ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : '(empty)') : remoteValue) - : (computedTemplateValue || '(empty)')); + ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : emptyValue) : remoteValue) + : (computedTemplateValue || emptyValue)); return ( - {useRemoteVariable ? 'Fallback value:' : 'Value:'} + {useRemoteVariable ? t('profiles.environmentVariables.card.fallbackValueLabel') : t('profiles.environmentVariables.card.valueLabel')} {/* Value input */} @@ -219,7 +218,12 @@ export function EnvironmentVariableCard({ marginBottom: 4, ...webNoOutline, }} - placeholder={expectedValue || (useRemoteVariable ? 'Default value' : 'Value')} + placeholder={ + expectedValue || + (useRemoteVariable + ? t('profiles.environmentVariables.card.defaultValueInputPlaceholder') + : t('profiles.environmentVariables.card.valueInputPlaceholder')) + } placeholderTextColor={theme.colors.input.placeholder} value={defaultValue} onChangeText={setDefaultValue} @@ -236,7 +240,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...secondaryTextStyle, }}> - Secret value - not retrieved for security + {t('profiles.environmentVariables.card.secretNotRetrieved')} )} @@ -247,7 +251,7 @@ export function EnvironmentVariableCard({ marginBottom: 8, ...secondaryTextStyle, }}> - Overriding documented default: {expectedValue} + {t('profiles.environmentVariables.card.overridingDefault', { expectedValue })} )} @@ -264,7 +268,7 @@ export function EnvironmentVariableCard({ color: theme.colors.textSecondary, ...remoteToggleLabelStyle, }}> - Use value from machine environment + {t('profiles.environmentVariables.card.useMachineEnvToggle')} - Resolved when the session starts on the selected machine. + {t('profiles.environmentVariables.card.resolvedOnSessionStart')} {/* Source variable name input (only when enabled) */} @@ -288,7 +292,7 @@ export function EnvironmentVariableCard({ marginBottom: 4, ...secondaryTextStyle, }}> - Source variable + {t('profiles.environmentVariables.card.sourceVariableLabel')} setRemoteVariableName(text.toUpperCase())} @@ -324,7 +328,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...secondaryTextStyle, }}> - Checking {machineLabel}... + {t('profiles.environmentVariables.card.checkingMachine', { machine: machineLabel })} ) : (remoteValue === null || remoteValue === '') ? ( {remoteValue === '' ? ( - hasFallback ? `Empty on ${machineLabel} (using fallback)` : `Empty on ${machineLabel}` + hasFallback + ? t('profiles.environmentVariables.card.emptyOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.emptyOnMachine', { machine: machineLabel }) ) : ( - hasFallback ? `Not found on ${machineLabel} (using fallback)` : `Not found on ${machineLabel}` + hasFallback + ? t('profiles.environmentVariables.card.notFoundOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.notFoundOnMachine', { machine: machineLabel }) )} ) : ( @@ -343,7 +351,7 @@ export function EnvironmentVariableCard({ color: theme.colors.success, ...secondaryTextStyle, }}> - Value found on {machineLabel} + {t('profiles.environmentVariables.card.valueFoundOnMachine', { machine: machineLabel })} {showRemoteDiffersWarning && ( - Differs from documented value: {expectedValue} + {t('profiles.environmentVariables.card.differsFromDocumented', { expectedValue })} )} @@ -365,7 +373,10 @@ export function EnvironmentVariableCard({ marginTop: 4, ...secondaryTextStyle, }}> - Session will receive: {variable.name} = {resolvedSessionValue} + {t('profiles.environmentVariables.preview.sessionWillReceive', { + name: variable.name, + value: resolvedSessionValue, + })} ); diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index 9b92e2198..913fb9640 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -116,7 +116,7 @@ export function EnvironmentVariablesList({ const handleAddVariable = React.useCallback(() => { const normalizedName = newVarName.trim().toUpperCase(); if (!normalizedName) { - Modal.alert(t('common.error'), 'Enter a variable name.'); + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.nameRequired')); return; } @@ -124,14 +124,14 @@ export function EnvironmentVariablesList({ 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.', + t('profiles.environmentVariables.validation.invalidNameFormat'), ); return; } // Check for duplicates if (environmentVariables.some(v => v.name === normalizedName)) { - Modal.alert(t('common.error'), 'That variable already exists.'); + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.duplicateName')); return; } @@ -162,7 +162,7 @@ export function EnvironmentVariablesList({ textTransform: 'uppercase', fontWeight: Platform.select({ ios: 'normal', default: '500' } as any), }}> - Environment Variables + {t('profiles.environmentVariables.title')} @@ -208,7 +208,7 @@ export function EnvironmentVariablesList({ elevation: 1, }}> setNewVarName(text.toUpperCase())} @@ -261,7 +261,7 @@ export function EnvironmentVariablesList({ }}> - Add + {t('common.add')} diff --git a/sources/components/ItemGroup.dividers.test.ts b/sources/components/ItemGroup.dividers.test.ts new file mode 100644 index 000000000..ad8161b9f --- /dev/null +++ b/sources/components/ItemGroup.dividers.test.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { withItemGroupDividers } from './ItemGroup.dividers'; + +type FragmentProps = { + children?: React.ReactNode; +}; + +function TestItem(_props: { id: string; showDivider?: boolean }) { + return null; +} + +function collectShowDividers(node: React.ReactNode): Array { + const values: Array = []; + + const walk = (n: React.ReactNode) => { + React.Children.forEach(n, (child) => { + if (!React.isValidElement(child)) return; + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + walk(fragment.props.children); + return; + } + if (child.type === TestItem) { + const element = child as React.ReactElement<{ showDivider?: boolean }>; + values.push(element.props.showDivider); + return; + } + // Ignore other element types. + }); + }; + + walk(node); + return values; +} + +describe('withItemGroupDividers', () => { + it('treats fragment children as part of the divider sequence', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a' }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([true, true, false]); + }); + + it('preserves explicit showDivider={false} overrides', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a', showDivider: false }), + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([false, true, false]); + }); +}); diff --git a/sources/components/ItemGroup.dividers.ts b/sources/components/ItemGroup.dividers.ts new file mode 100644 index 000000000..c14b531e0 --- /dev/null +++ b/sources/components/ItemGroup.dividers.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; + +type DividerChildProps = { + showDivider?: boolean; +}; + +type FragmentProps = { + children?: React.ReactNode; +}; + +export function withItemGroupDividers(children: React.ReactNode): React.ReactNode { + const countNonFragmentElements = (node: React.ReactNode): number => { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + return count + countNonFragmentElements(fragment.props.children); + } + return count + 1; + }, 0); + }; + + const total = countNonFragmentElements(children); + if (total === 0) return children; + + let index = 0; + const apply = (node: React.ReactNode): React.ReactNode => { + return React.Children.map(node, (child) => { + if (!React.isValidElement(child)) { + return child; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + return React.cloneElement(fragment, {}, apply(fragment.props.children)); + } + + const isLast = index === total - 1; + index += 1; + + const element = child as React.ReactElement; + const showDivider = !isLast && element.props.showDivider !== false; + return React.cloneElement(element, { showDivider }); + }); + }; + + return apply(children); +} diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index 006f534d3..222b67e56 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -10,6 +10,9 @@ import { import { Typography } from '@/constants/Typography'; import { layout } from './layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { withItemGroupDividers } from './ItemGroup.dividers'; + +export { withItemGroupDividers } from './ItemGroup.dividers'; export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); @@ -138,21 +141,7 @@ export const ItemGroup = React.memo((props) => { {/* Content Container */} - {React.Children.map(children, (child, index) => { - if (React.isValidElement(child)) { - // Don't add props to React.Fragment - if (child.type === React.Fragment) { - return child; - } - const isLast = index === React.Children.count(children) - 1; - const childProps = child.props as ItemChildProps; - return React.cloneElement(child, { - ...childProps, - showDivider: !isLast && childProps.showDivider !== false - }); - } - return child; - })} + {withItemGroupDividers(children)} diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx index 9d2789411..01e3cdb34 100644 --- a/sources/components/ItemRowActions.tsx +++ b/sources/components/ItemRowActions.tsx @@ -38,7 +38,7 @@ export function ItemRowActions(props: ItemRowActionsProps) { title: props.title, actions: overflowActions, }, - } as any); + }); }, [overflowActions, props.title]); const iconSize = props.iconSize ?? 20; diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index a6191c926..baa26f9b6 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -80,7 +80,7 @@ function MachinePreviewModal(props: MachinePreviewModalProps) { color: theme.colors.text, ...Typography.default('semiBold'), }}> - Preview Machine + {t('profiles.previewMachine.title')} { @@ -227,7 +227,7 @@ export function ProfileEditForm({ 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.'); + Modal.alert(t('common.error'), t('profiles.aiBackend.selectAtLeastOneError')); return prev; } return next; @@ -250,7 +250,7 @@ export function ProfileEditForm({ const handleSave = React.useCallback(() => { if (!name.trim()) { - Modal.alert(t('common.error'), 'Enter a profile name.'); + Modal.alert(t('common.error'), t('profiles.nameRequired')); return; } @@ -300,25 +300,45 @@ export function ProfileEditForm({ {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' }, + { + value: 'default' as PermissionMode, + label: t('agentInput.permissionMode.default'), + description: t('profiles.defaultPermissionMode.descriptions.default'), + icon: 'shield-outline' + }, + { + value: 'acceptEdits' as PermissionMode, + label: t('agentInput.permissionMode.acceptEdits'), + description: t('profiles.defaultPermissionMode.descriptions.acceptEdits'), + icon: 'checkmark-outline' + }, + { + value: 'plan' as PermissionMode, + label: t('agentInput.permissionMode.plan'), + description: t('profiles.defaultPermissionMode.descriptions.plan'), + icon: 'list-outline' + }, + { + value: 'bypassPermissions' as PermissionMode, + label: t('agentInput.permissionMode.bypassPermissions'), + description: t('profiles.defaultPermissionMode.descriptions.bypassPermissions'), + icon: 'flash-outline' + }, ].map((option, index, array) => ( - + } rightElement={ toggleCompatibility('claude')} />} showChevron={false} onPress={() => toggleCompatibility('claude')} /> } rightElement={ toggleCompatibility('codex')} />} showChevron={false} @@ -363,8 +383,8 @@ export function ProfileEditForm({ /> {experimentsEnabled && ( } rightElement={ toggleCompatibility('gemini')} />} showChevron={false} @@ -374,10 +394,10 @@ export function ProfileEditForm({ )} - + } showChevron={false} onPress={() => setUseTmux((v) => !v)} @@ -385,20 +405,20 @@ export function ProfileEditForm({ {useTmux && ( - Tmux Session Name ({t('common.optional')}) + {t('profiles.tmuxSession')} ({t('common.optional')}) - Tmux Temp Directory ({t('common.optional')}) + {t('profiles.tmuxTempDir')} ({t('common.optional')}) {!routeMachine && ( - + } onPress={showMachinePreviewPicker} /> diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 511de8705..bf3fc00dd 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -102,7 +102,7 @@ export function SearchHeader({ onBlur={onBlur} style={styles.textInput} /> - {value.trim().length > 0 && ( + {value.length > 0 && ( (props: SearchableListSelectorProps) {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} {recentItemsToShow.length === 0 - ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage) + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) : recentItemsToShow.map((item, index, arr) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -349,7 +349,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} {filteredFavoriteItems.length === 0 - ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage) + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) : filteredFavoriteItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -364,7 +364,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {effectiveSearchPlacement === 'all' && searchNodeEmbedded} {filteredItems.length === 0 - ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage) + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) : filteredItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -378,7 +378,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && ( {effectiveSearchPlacement !== 'header' && searchNodeEmbedded} - {renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)} + {renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage)} )} diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 955b66f49..540603230 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -37,7 +37,6 @@ 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(); @@ -322,14 +321,14 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.featuresTitle')} subtitle={t('settings.featuresSubtitle')} icon={} - onPress={() => router.push('/settings/features')} + onPress={() => router.push('/(app)/settings/features')} /> {useProfiles && ( } - onPress={() => router.push('/settings/profiles')} + onPress={() => router.push('/(app)/settings/profiles')} /> )} {experiments && ( @@ -337,7 +336,7 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.usage')} subtitle={t('settings.usageSubtitle')} icon={} - onPress={() => router.push('/settings/usage')} + onPress={() => router.push('/(app)/settings/usage')} /> )} diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index dac0c164a..49eeba853 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -6,6 +6,8 @@ import { Typography } from '@/constants/Typography'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { t } from '@/text'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from '@/utils/envVarTemplate'; export interface EnvironmentVariablesPreviewModalProps { environmentVariables: Record; @@ -15,18 +17,6 @@ export interface EnvironmentVariablesPreviewModalProps { 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); } @@ -64,7 +54,7 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev const refsToQuery = React.useMemo(() => { const refs = new Set(); envVarEntries.forEach((envVar) => { - const parsed = parseTemplateValue(envVar.value); + const parsed = parseEnvVarTemplate(envVar.value); if (parsed?.sourceVar) { // Never fetch secret-like values into UI memory. if (isSecretLike(envVar.name) || isSecretLike(parsed.sourceVar)) return; @@ -76,8 +66,11 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev const { variables: machineEnv } = useEnvironmentVariables(props.machineId, refsToQuery); - const title = props.profileName ? `Env Vars · ${props.profileName}` : 'Environment Variables'; + const title = props.profileName + ? t('profiles.environmentVariables.previewModal.titleWithProfile', { profileName: props.profileName }) + : t('profiles.environmentVariables.title'); const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); return ( - These environment variables are sent when starting the session. Values are resolved using the daemon on{' '} + {t('profiles.environmentVariables.previewModal.descriptionPrefix')}{' '} {props.machineName ? ( {props.machineName} ) : ( - 'the selected machine' + t('profiles.environmentVariables.previewModal.descriptionFallbackMachine') )} - . + {t('profiles.environmentVariables.previewModal.descriptionSuffix')} @@ -160,13 +153,13 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), ...Typography.default(), }}> - No environment variables are set for this profile. + {t('profiles.environmentVariables.previewModal.emptyMessage')} ) : ( - + {envVarEntries.map((envVar, idx) => { - const parsed = parseTemplateValue(envVar.value); + const parsed = parseEnvVarTemplate(envVar.value); const secret = isSecretLike(envVar.name) || (parsed?.sourceVar ? isSecretLike(parsed.sourceVar) : false); const hasMachineContext = Boolean(props.machineId); @@ -178,38 +171,53 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev displayValue = '•••'; } else if (parsed) { if (!hasMachineContext) { - displayValue = `\${${parsed.sourceVar}${parsed.fallback ? `:-${parsed.fallback}` : ''}}`; + displayValue = formatEnvVarTemplate(parsed); } else if (resolvedValue === undefined) { - displayValue = `\${${parsed.sourceVar}${parsed.fallback ? `:-${parsed.fallback}` : ''}} (checking…)`; + displayValue = `${formatEnvVarTemplate(parsed)} ${t('profiles.environmentVariables.previewModal.checkingSuffix')}`; } else if (resolvedValue === null || resolvedValue === '') { - displayValue = parsed.fallback ? parsed.fallback : '(empty)'; + displayValue = parsed.fallback ? parsed.fallback : emptyValue; } else { displayValue = resolvedValue; } } else { - displayValue = envVar.value || '(empty)'; + displayValue = envVar.value || emptyValue; } - const detailLabel = (() => { + type DetailKind = 'fixed' | 'machine' | 'checking' | 'fallback' | 'missing'; + + const detailKind: DetailKind | undefined = (() => { 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'; + 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 detailLabel = (() => { + if (!detailKind) return undefined; + return detailKind === 'fixed' + ? t('profiles.environmentVariables.previewModal.detail.fixed') + : detailKind === 'machine' + ? t('profiles.environmentVariables.previewModal.detail.machine') + : detailKind === 'checking' + ? t('profiles.environmentVariables.previewModal.detail.checking') + : detailKind === 'fallback' + ? t('profiles.environmentVariables.previewModal.detail.fallback') + : t('profiles.environmentVariables.previewModal.detail.missing'); })(); + const detailColor = + detailKind === 'machine' + ? theme.colors.status.connected + : detailKind === 'fallback' || detailKind === 'missing' + ? theme.colors.warning + : theme.colors.textSecondary; + const rightElement = (() => { if (secret) return undefined; if (!isMachineBased) return undefined; - if (!hasMachineContext || detailLabel === 'Checking') { + if (!hasMachineContext || detailKind === 'checking') { return ; } return ; diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 402637f3e..593f55ebc 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -4,6 +4,7 @@ import { useUnistyles } from 'react-native-unistyles'; import { SearchableListSelector } from '@/components/SearchableListSelector'; import type { Machine } from '@/sync/storageTypes'; import { isMachineOnline } from '@/utils/machineUtils'; +import { t } from '@/text'; export interface MachineSelectorProps { machines: Machine[]; @@ -34,11 +35,11 @@ export function MachineSelector({ showRecent = true, showSearch = true, searchPlacement = 'header', - searchPlaceholder = 'Type to filter machines...', - recentSectionTitle = 'Recent Machines', - favoritesSectionTitle = 'Favorite Machines', - allSectionTitle = 'All Machines', - noItemsMessage = 'No machines available', + searchPlaceholder = t('newSession.machinePicker.searchPlaceholder'), + recentSectionTitle = t('newSession.machinePicker.recentTitle'), + favoritesSectionTitle = t('newSession.machinePicker.favoritesTitle'), + allSectionTitle = t('newSession.machinePicker.allTitle'), + noItemsMessage = t('newSession.machinePicker.emptyMessage'), }: MachineSelectorProps) { const { theme } = useUnistyles(); @@ -65,7 +66,7 @@ export function MachineSelector({ getItemStatus: (machine) => { const offline = !isMachineOnline(machine); return { - text: offline ? 'offline' : 'online', + text: offline ? t('status.offline') : t('status.online'), color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, isPulsing: !offline, diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index cd603c06f..7ff1f9017 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { View, Pressable } from 'react-native'; +import { View, Pressable, TextInput } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ItemGroup } from '@/components/ItemGroup'; @@ -60,7 +60,7 @@ export function PathSelector({ const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const styles = stylesheet; const inputRef = useRef(null); - const searchInputRef = useRef(null); + const searchInputRef = useRef(null); const searchWasFocusedRef = useRef(false); const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index dfaac7f8a..81498b878 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Text, View, type ViewStyle } from 'react-native'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import type { AIBackendProfile } from '@/sync/settings'; import { useSetting } from '@/sync/storage'; @@ -11,8 +11,26 @@ type Props = { style?: ViewStyle; }; +const stylesheet = StyleSheet.create((theme) => ({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + stack: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0, + }, + glyph: { + color: theme.colors.textSecondary, + ...Typography.default(), + }, +})); + export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { - const { theme } = useUnistyles(); + useUnistyles(); + const styles = stylesheet; const experimentsEnabled = useSetting('experiments'); const hasClaude = !!profile.compatibility?.claude; @@ -31,34 +49,25 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { const multiScale = glyphs.length === 1 ? 1 : glyphs.length === 2 ? 0.6 : 0.5; return ( - + {glyphs.length === 1 ? ( - + {glyphs[0].glyph} ) : ( - + {glyphs.map((item) => { const fontSize = Math.round(size * multiScale * item.factor); return ( {item.glyph} diff --git a/sources/components/profileActions.ts b/sources/components/profileActions.ts index 3ada6c5d7..7fcdf6275 100644 --- a/sources/components/profileActions.ts +++ b/sources/components/profileActions.ts @@ -1,5 +1,6 @@ import type { ItemAction } from '@/components/ItemActionsMenuModal'; import type { AIBackendProfile } from '@/sync/settings'; +import { t } from '@/text'; export function buildProfileActions(params: { profile: AIBackendProfile; @@ -17,7 +18,7 @@ export function buildProfileActions(params: { if (params.onViewEnvironmentVariables) { actions.push({ id: 'envVars', - title: 'View environment variables', + title: t('profiles.actions.viewEnvironmentVariables'), icon: 'list-outline', onPress: params.onViewEnvironmentVariables, }); @@ -26,7 +27,7 @@ export function buildProfileActions(params: { const favoriteColor = params.isFavorite ? params.favoriteActionColor : params.nonFavoriteActionColor; const favoriteAction: ItemAction = { id: 'favorite', - title: params.isFavorite ? 'Remove from favorites' : 'Add to favorites', + title: params.isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), icon: params.isFavorite ? 'star' : 'star-outline', onPress: params.onToggleFavorite, }; @@ -37,14 +38,14 @@ export function buildProfileActions(params: { actions.push({ id: 'edit', - title: 'Edit profile', + title: t('profiles.actions.editProfile'), icon: 'create-outline', onPress: params.onEdit, }); actions.push({ id: 'copy', - title: 'Duplicate profile', + title: t('profiles.actions.duplicateProfile'), icon: 'copy-outline', onPress: params.onDuplicate, }); @@ -52,7 +53,7 @@ export function buildProfileActions(params: { if (!params.profile.isBuiltIn && params.onDelete) { actions.push({ id: 'delete', - title: 'Delete profile', + title: t('profiles.actions.deleteProfile'), icon: 'trash-outline', destructive: true, onPress: params.onDelete, @@ -61,4 +62,3 @@ export function buildProfileActions(params: { return actions; } - diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 696f8315e..742f4ee38 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -592,7 +592,7 @@ export const knownTools = { } }, 'change_title': { - title: 'Change Title', + title: t('tools.names.changeTitle'), icon: ICON_EDIT, minimal: true, noStatus: true, diff --git a/sources/components/tools/views/GeminiExecuteView.tsx b/sources/components/tools/views/GeminiExecuteView.tsx index 86fe20e84..3101a78f9 100644 --- a/sources/components/tools/views/GeminiExecuteView.tsx +++ b/sources/components/tools/views/GeminiExecuteView.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../tools/ToolSectionView'; import { ToolViewProps } from './_all'; import { CodeView } from '@/components/CodeView'; +import { t } from '@/text'; /** * Extract execute command info from Gemini's nested input format. @@ -62,7 +63,7 @@ export const GeminiExecuteView = React.memo(({ tool }) => { {(description || cwd) && ( {cwd && ( - 📁 {cwd} + {t('tools.geminiExecute.cwd', { cwd })} )} {description && ( {description} @@ -89,4 +90,3 @@ const styles = StyleSheet.create((theme) => ({ fontStyle: 'italic', }, })); - diff --git a/sources/modal/ModalManager.ts b/sources/modal/ModalManager.ts index 1e0cf0aaf..d94423d7c 100644 --- a/sources/modal/ModalManager.ts +++ b/sources/modal/ModalManager.ts @@ -1,6 +1,6 @@ import { Platform, Alert } from 'react-native'; import { t } from '@/text'; -import { AlertButton, ModalConfig, CustomModalConfig, IModal } from './types'; +import { AlertButton, ModalConfig, CustomModalConfig, IModal, type CustomModalInjectedProps } from './types'; class ModalManagerClass implements IModal { private showModalFn: ((config: Omit) => string) | null = null; @@ -95,16 +95,22 @@ class ModalManagerClass implements IModal { } } - show(config: Omit): string { + show

(config: { + component: CustomModalConfig

['component']; + props?: CustomModalConfig

['props']; + }): string { if (!this.showModalFn) { console.error('ModalManager not initialized. Make sure ModalProvider is mounted.'); return ''; } - return this.showModalFn({ - ...config, - type: 'custom' - }); + const modalConfig: Omit = { + type: 'custom', + component: config.component as unknown as CustomModalConfig['component'], + props: config.props as unknown as CustomModalConfig['props'], + }; + + return this.showModalFn(modalConfig); } hide(id: string): void { diff --git a/sources/modal/components/CustomModal.tsx b/sources/modal/components/CustomModal.tsx index 0c2a82504..0a92db046 100644 --- a/sources/modal/components/CustomModal.tsx +++ b/sources/modal/components/CustomModal.tsx @@ -9,6 +9,8 @@ interface CustomModalProps { onClose: () => void; } +type CommandPaletteExternalProps = Omit, 'onClose'>; + export function CustomModal({ config, onClose }: CustomModalProps) { const Component = config.component; @@ -27,6 +29,7 @@ export function CustomModal({ config, onClose }: CustomModalProps) { // Helper component to manage CommandPalette animation state function CommandPaletteWithAnimation({ config, onClose }: CustomModalProps) { const [isClosing, setIsClosing] = React.useState(false); + const commandPaletteProps = (config.props as CommandPaletteExternalProps | undefined) ?? { commands: [] }; const handleClose = React.useCallback(() => { setIsClosing(true); @@ -36,7 +39,7 @@ function CommandPaletteWithAnimation({ config, onClose }: CustomModalProps) { return ( - + ); -} \ No newline at end of file +} diff --git a/sources/modal/types.ts b/sources/modal/types.ts index c9cfdc640..e169c3658 100644 --- a/sources/modal/types.ts +++ b/sources/modal/types.ts @@ -40,13 +40,17 @@ export interface PromptModalConfig extends BaseModalConfig { inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; } -export interface CustomModalConfig extends BaseModalConfig { +export type CustomModalInjectedProps = Readonly<{ + onClose: () => void; +}>; + +export interface CustomModalConfig

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

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

(config: { + component: ComponentType

; + props?: Omit; + }): string; hide(id: string): void; hideAll(): void; -} \ No newline at end of file +} diff --git a/sources/sync/messageMeta.test.ts b/sources/sync/messageMeta.test.ts index a7f11a90e..9ebc98369 100644 --- a/sources/sync/messageMeta.test.ts +++ b/sources/sync/messageMeta.test.ts @@ -27,4 +27,16 @@ describe('buildOutgoingMessageMeta', () => { expect(meta.model).toBe('gemini-2.5-pro'); expect('model' in meta).toBe(true); }); + + it('includes displayText when explicitly provided (including empty string)', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + displayText: '', + }); + + expect('displayText' in meta).toBe(true); + expect(meta.displayText).toBe(''); + }); }); diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts index ab3c0b61a..d97b22055 100644 --- a/sources/sync/messageMeta.ts +++ b/sources/sync/messageMeta.ts @@ -12,7 +12,7 @@ export function buildOutgoingMessageMeta(params: { sentFrom: params.sentFrom, permissionMode: params.permissionMode, appendSystemPrompt: params.appendSystemPrompt, - ...(params.displayText ? { displayText: params.displayText } : {}), + ...(params.displayText !== undefined ? { 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 510cfe104..6a29d6dd4 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -148,7 +148,7 @@ export interface SpawnSessionOptions { // - OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_TIMEOUT_MS // - AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME // - TOGETHER_API_KEY, TOGETHER_MODEL - // - TMUX_SESSION_NAME, TMUX_TMPDIR, TMUX_UPDATE_ENVIRONMENT + // - TMUX_SESSION_NAME, TMUX_TMPDIR // - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC // - Custom variables (DEEPSEEK_*, Z_AI_*, etc.) environmentVariables?: Record; @@ -535,4 +535,4 @@ export type { TreeNode, SessionRipgrepResponse, SessionKillResponse -}; \ No newline at end of file +}; diff --git a/sources/sync/profileGrouping.test.ts b/sources/sync/profileGrouping.test.ts new file mode 100644 index 000000000..9687a30d5 --- /dev/null +++ b/sources/sync/profileGrouping.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { toggleFavoriteProfileId } from './profileGrouping'; + +describe('toggleFavoriteProfileId', () => { + it('adds the profile id to the front when missing', () => { + expect(toggleFavoriteProfileId([], 'anthropic')).toEqual(['anthropic']); + }); + + it('removes the profile id when already present', () => { + expect(toggleFavoriteProfileId(['anthropic', 'openai'], 'anthropic')).toEqual(['openai']); + }); + + it('ignores empty ids and does not allow favoriting the empty profile id', () => { + expect(toggleFavoriteProfileId(['', 'anthropic', 'anthropic', 'openai'], '')).toEqual(['anthropic', 'openai']); + }); +}); diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts index 59b0eb551..feb3880ef 100644 --- a/sources/sync/profileGrouping.ts +++ b/sources/sync/profileGrouping.ts @@ -13,6 +13,27 @@ function isProfile(profile: AIBackendProfile | null | undefined): profile is AIB return Boolean(profile); } +export function toggleFavoriteProfileId(favoriteProfileIds: string[], profileId: string): string[] { + const normalized: string[] = []; + const seen = new Set(); + for (const id of favoriteProfileIds) { + if (!id) continue; + if (seen.has(id)) continue; + seen.add(id); + normalized.push(id); + } + + if (!profileId) { + return normalized; + } + + if (seen.has(profileId)) { + return normalized.filter((id) => id !== profileId); + } + + return [profileId, ...normalized]; +} + export function buildProfileGroups({ customProfiles, favoriteProfileIds, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index ce7e3669e..c42eb8391 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -8,7 +8,6 @@ import * as z from 'zod'; const TmuxConfigSchema = z.object({ sessionName: z.string().optional(), tmpDir: z.string().optional(), - updateEnvironment: z.boolean().optional(), }); // Environment variables schema with validation @@ -170,9 +169,6 @@ export function getProfileEnvironmentVariables(profile: AIBackendProfile): Recor if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; // Empty string may be valid for tmpDir to use tmux defaults if (profile.tmuxConfig.tmpDir !== undefined) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; - if (profile.tmuxConfig.updateEnvironment !== undefined) { - envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); - } } return envVars; diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index f57b41c41..a42b46cd1 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -70,8 +70,8 @@ export interface Session { id: string; }>; draft?: string | null; // Local draft message, not synced to server - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server - modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; // Local permission mode, not synced to server + modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; // Local model mode, not synced to server // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. // We store it directly on Session to ensure it's available immediately on load. // Do NOT store reducerState itself on Session - it's mutable and should only exist in SessionMessages. @@ -154,4 +154,4 @@ export interface GitStatus { aheadCount?: number; // Commits ahead of upstream behindCount?: number; // Commits behind upstream stashCount?: number; // Number of stash entries -} \ No newline at end of file +} diff --git a/sources/sync/typesRaw.spec.ts b/sources/sync/typesRaw.spec.ts index 29178a25d..bf00b3b3c 100644 --- a/sources/sync/typesRaw.spec.ts +++ b/sources/sync/typesRaw.spec.ts @@ -1489,4 +1489,58 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } }); }); + + describe('ACP tool result normalization', () => { + it('normalizes ACP tool-result output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-1', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-1', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('hello'); + } + } + }); + + it('normalizes ACP tool-call-result output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-call-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-2', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-2', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('hello'); + } + } + }); + }); }); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 7921435b2..19d1c5b72 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -738,7 +738,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: raw.content.data.isError ?? false, uuid: raw.content.data.id, parentUUID: null @@ -757,7 +757,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null @@ -851,4 +851,4 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } } return null; -} \ No newline at end of file +} diff --git a/sources/text/_types.ts b/sources/text/_types.ts index 4234daee3..9998ac68d 100644 --- a/sources/text/_types.ts +++ b/sources/text/_types.ts @@ -1,24 +1,4 @@ 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] - } -}; - +export type TranslationStructure = Translations; diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 4067d6861..d7cc189c3 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -31,6 +31,7 @@ export const ca: TranslationStructure = { common: { // Simple string constants + add: 'Afegeix', cancel: 'Cancel·la', authenticate: 'Autentica', save: 'Desa', @@ -47,6 +48,9 @@ export const ca: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descarta', + discardChanges: 'Descarta els canvis', + unsavedChangesWarning: 'Tens canvis sense desar.', + keepEditing: 'Continua editant', version: 'Versió', copied: 'Copiat', copy: 'Copiar', @@ -60,6 +64,8 @@ export const ca: TranslationStructure = { retry: 'Torna-ho a provar', delete: 'Elimina', optional: 'Opcional', + noMatches: 'Sense coincidències', + machine: 'màquina', }, profile: { @@ -208,15 +214,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', + profiles: 'Perfils d\'IA', + profilesEnabled: 'Selecció de perfils activada', + profilesDisabled: 'Selecció de perfils desactivada', + pickerSearch: 'Cerca als selectors', + pickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquina i camí', + machinePickerSearch: 'Cerca de màquines', + machinePickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquines', + pathPickerSearch: 'Cerca de camins', + pathPickerSearchSubtitle: 'Mostra un camp de cerca als selectors de camins', }, errors: { @@ -269,6 +275,9 @@ export const ca: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Inicia una nova sessió', + selectMachineTitle: 'Selecciona màquina', + selectPathTitle: 'Selecciona camí', + searchPathsPlaceholder: 'Cerca camins...', noMachinesFound: 'No s\'han trobat màquines. Inicia una sessió de Happy al teu ordinador primer.', allMachinesOffline: 'Totes les màquines estan fora de línia', machineDetails: 'Veure detalls de la màquina →', @@ -284,6 +293,13 @@ export const ca: TranslationStructure = { startNewSessionInFolder: 'Nova sessió aquí', noMachineSelected: 'Si us plau, selecciona una màquina per iniciar la sessió', noPathSelected: 'Si us plau, selecciona un directori per iniciar la sessió', + machinePicker: { + searchPlaceholder: 'Cerca màquines...', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + allTitle: 'Totes', + emptyMessage: 'No hi ha màquines disponibles', + }, sessionType: { title: 'Tipus de sessió', simple: 'Simple', @@ -345,6 +361,7 @@ export const ca: TranslationStructure = { happySessionId: 'ID de la sessió de Happy', claudeCodeSessionId: 'ID de la sessió de Claude Code', claudeCodeSessionIdCopied: 'ID de la sessió de Claude Code copiat al porta-retalls', + aiProfile: 'Perfil d\'IA', aiProvider: 'Proveïdor d\'IA', failedToCopyClaudeCodeSessionId: 'Ha fallat copiar l\'ID de la sessió de Claude Code', metadataCopied: 'Metadades copiades al porta-retalls', @@ -441,11 +458,11 @@ export const ca: TranslationStructure = { geminiPermissionMode: { title: 'MODE DE PERMISOS GEMINI', default: 'Per defecte', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Només lectura', + safeYolo: 'YOLO segur', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Només lectura', + badgeSafeYolo: 'YOLO segur', badgeYolo: 'YOLO', }, context: { @@ -513,6 +530,10 @@ export const ca: TranslationStructure = { applyChanges: 'Actualitza fitxer', viewDiff: 'Canvis del fitxer actual', question: 'Pregunta', + changeTitle: 'Canvia el títol', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -905,6 +926,110 @@ export const ca: TranslationStructure = { tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux', deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`, nameRequired: 'El nom del perfil és obligatori', + builtIn: 'Integrat', + groups: { + favorites: 'Preferits', + custom: 'Els teus perfils', + builtIn: 'Perfils integrats', + }, + actions: { + viewEnvironmentVariables: 'Variables d\'entorn', + addToFavorites: 'Afegeix als preferits', + removeFromFavorites: 'Treu dels preferits', + editProfile: 'Edita el perfil', + duplicateProfile: 'Duplica el perfil', + deleteProfile: 'Elimina el perfil', + }, + duplicateName: 'Ja existeix un perfil amb aquest nom', + setupInstructions: { + title: 'Instruccions de configuració', + viewOfficialGuide: 'Veure la guia oficial de configuració', + }, + defaultSessionType: 'Tipus de sessió predeterminat', + defaultPermissionMode: { + title: 'Mode de permisos predeterminat', + descriptions: { + default: 'Demana permisos', + acceptEdits: 'Aprova edicions automàticament', + plan: 'Planifica abans d\'executar', + bypassPermissions: 'Salta tots els permisos', + }, + }, + aiBackend: { + title: 'Backend d\'IA', + selectAtLeastOneError: 'Selecciona com a mínim un backend d\'IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Inicia sessions a Tmux', + spawnSessionsEnabledSubtitle: 'Les sessions s\'inicien en noves finestres de tmux.', + spawnSessionsDisabledSubtitle: 'Les sessions s\'inicien en un shell normal (sense integració amb tmux)', + sessionNamePlaceholder: 'Buit = sessió actual/més recent', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Previsualitza màquina', + selectMachine: 'Selecciona màquina', + resolveSubtitle: 'Resol variables d\'entorn de la màquina per a aquest perfil.', + selectSubtitle: 'Selecciona una màquina per previsualitzar els valors resolts.', + }, + environmentVariables: { + title: 'Variables d\'entorn', + addVariable: 'Afegeix variable', + namePlaceholder: 'Nom de variable (p. ex., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ex., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Introdueix un nom de variable.', + invalidNameFormat: 'Els noms de variable han de ser lletres majúscules, números i guions baixos, i no poden començar amb un número.', + duplicateName: 'Aquesta variable ja existeix.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de reserva:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor per defecte', + secretNotRetrieved: 'Valor secret - no es recupera per seguretat', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `S'està substituint el valor predeterminat documentat: ${expectedValue}`, + useMachineEnvToggle: 'Utilitza el valor de l\'entorn de la màquina', + resolvedOnSessionStart: 'Es resol quan la sessió s\'inicia a la màquina seleccionada.', + sourceVariableLabel: 'Variable d\'origen', + sourceVariablePlaceholder: 'Nom de variable d\'origen (p. ex., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Comprovant ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Buit a ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Buit a ${machine} (utilitzant reserva)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No trobat a ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No trobat a ${machine} (utilitzant reserva)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor trobat a ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiereix del valor documentat: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ocult per seguretat`, + hiddenValue: '***ocult***', + emptyValue: '(buit)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessió rebrà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variables d'entorn · ${profileName}`, + descriptionPrefix: 'Aquestes variables d\'entorn s\'envien en iniciar la sessió. Els valors es resolen usant el dimoni a', + descriptionFallbackMachine: 'la màquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hi ha variables d\'entorn configurades per a aquest perfil.', + checkingSuffix: '(comprovant…)', + detail: { + fixed: 'Fix', + machine: 'Màquina', + checking: 'Comprovant', + fallback: 'Reserva', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `Estàs segur que vols eliminar "${name}"? Aquesta acció no es pot desfer.`, diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index eb50aa48a..be1979ba1 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -44,6 +44,7 @@ export const en = { common: { // Simple string constants + add: 'Add', cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', @@ -60,6 +61,9 @@ export const en = { yes: 'Yes', no: 'No', discard: 'Discard', + discardChanges: 'Discard changes', + unsavedChangesWarning: 'You have unsaved changes.', + keepEditing: 'Keep editing', version: 'Version', copy: 'Copy', copied: 'Copied', @@ -73,6 +77,8 @@ export const en = { retry: 'Retry', delete: 'Delete', optional: 'optional', + noMatches: 'No matches', + machine: 'machine', }, profile: { @@ -282,6 +288,9 @@ export const en = { newSession: { // Used by new-session screen and launch flows title: 'Start New Session', + selectMachineTitle: 'Select Machine', + selectPathTitle: 'Select Path', + searchPathsPlaceholder: 'Search paths...', noMachinesFound: 'No machines found. Start a Happy session on your computer first.', allMachinesOffline: 'All machines appear offline', machineDetails: 'View machine details →', @@ -297,6 +306,13 @@ export const en = { notConnectedToServer: 'Not connected to server. Check your internet connection.', noMachineSelected: 'Please select a machine to start the session', noPathSelected: 'Please select a directory to start the session in', + machinePicker: { + searchPlaceholder: 'Search machines...', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + allTitle: 'All', + emptyMessage: 'No machines available', + }, sessionType: { title: 'Session Type', simple: 'Simple', @@ -358,6 +374,7 @@ export const en = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', + aiProfile: 'AI Profile', aiProvider: 'AI Provider', failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', metadataCopied: 'Metadata copied to clipboard', @@ -526,6 +543,10 @@ export const en = { applyChanges: 'Update file', viewDiff: 'Current file changes', question: 'Question', + changeTitle: 'Change Title', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, askUserQuestion: { submit: 'Submit Answer', @@ -909,8 +930,8 @@ export const en = { // Profile management feature title: 'Profiles', subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', + noProfile: 'Default Environment', + noProfileDescription: 'Use the machine environment without profile variables', defaultModel: 'Default Model', addProfile: 'Add Profile', profileName: 'Profile Name', @@ -928,6 +949,110 @@ export const en = { deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`, editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', + builtIn: 'Built-in', + groups: { + favorites: 'Favorites', + custom: 'Your Profiles', + builtIn: 'Built-in Profiles', + }, + actions: { + viewEnvironmentVariables: 'Environment Variables', + addToFavorites: 'Add to favorites', + removeFromFavorites: 'Remove from favorites', + editProfile: 'Edit profile', + duplicateProfile: 'Duplicate profile', + deleteProfile: 'Delete profile', + }, + duplicateName: 'A profile with this name already exists', + setupInstructions: { + title: 'Setup Instructions', + viewOfficialGuide: 'View Official Setup Guide', + }, + defaultSessionType: 'Default Session Type', + defaultPermissionMode: { + title: 'Default Permission Mode', + descriptions: { + default: 'Ask for permissions', + acceptEdits: 'Auto-approve edits', + plan: 'Plan before executing', + bypassPermissions: 'Skip all permissions', + }, + }, + aiBackend: { + title: 'AI Backend', + selectAtLeastOneError: 'Select at least one AI backend.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Spawn Sessions in Tmux', + spawnSessionsEnabledSubtitle: 'Sessions spawn in new tmux windows.', + spawnSessionsDisabledSubtitle: 'Sessions spawn in regular shell (no tmux integration)', + sessionNamePlaceholder: 'Empty = current/most recent session', + tempDirPlaceholder: '/tmp (optional)', + }, + previewMachine: { + title: 'Preview Machine', + selectMachine: 'Select machine', + resolveSubtitle: 'Resolve machine environment variables for this profile.', + selectSubtitle: 'Select a machine to preview resolved values.', + }, + environmentVariables: { + title: 'Environment Variables', + addVariable: 'Add Variable', + namePlaceholder: 'Variable name (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Value (e.g., my-value or ${MY_VAR})', + validation: { + nameRequired: 'Enter a variable name.', + invalidNameFormat: 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.', + duplicateName: 'That variable already exists.', + }, + card: { + valueLabel: 'Value:', + fallbackValueLabel: 'Fallback value:', + valueInputPlaceholder: 'Value', + defaultValueInputPlaceholder: 'Default value', + secretNotRetrieved: 'Secret value - not retrieved for security', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Overriding documented default: ${expectedValue}`, + useMachineEnvToggle: 'Use value from machine environment', + resolvedOnSessionStart: 'Resolved when the session starts on the selected machine.', + sourceVariableLabel: 'Source variable', + sourceVariablePlaceholder: 'Source variable name (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Checking ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Empty on ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Empty on ${machine} (using fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Not found on ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Not found on ${machine} (using fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Value found on ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Differs from documented value: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - hidden for security`, + hiddenValue: '***hidden***', + emptyValue: '(empty)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Session will receive: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Env Vars · ${profileName}`, + descriptionPrefix: 'These environment variables are sent when starting the session. Values are resolved using the daemon on', + descriptionFallbackMachine: 'the selected machine', + descriptionSuffix: '.', + emptyMessage: 'No environment variables are set for this profile.', + checkingSuffix: '(checking…)', + detail: { + fixed: 'Fixed', + machine: 'Machine', + checking: 'Checking', + fallback: 'Fallback', + missing: 'Missing', + }, + }, + }, delete: { title: 'Delete Profile', message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, @@ -935,6 +1060,6 @@ export const en = { cancel: 'Cancel', }, } -} as const; +}; export type TranslationsEn = typeof en; diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 3272ca282..bd434fe42 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -31,6 +31,7 @@ export const es: TranslationStructure = { common: { // Simple string constants + add: 'Añadir', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Guardar', @@ -47,6 +48,9 @@ export const es: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descartar', + discardChanges: 'Descartar cambios', + unsavedChangesWarning: 'Tienes cambios sin guardar.', + keepEditing: 'Seguir editando', version: 'Versión', copied: 'Copiado', copy: 'Copiar', @@ -60,6 +64,8 @@ export const es: TranslationStructure = { retry: 'Reintentar', delete: 'Eliminar', optional: 'opcional', + noMatches: 'Sin coincidencias', + machine: 'máquina', }, profile: { @@ -208,15 +214,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', + profiles: 'Perfiles de IA', + profilesEnabled: 'Selección de perfiles habilitada', + profilesDisabled: 'Selección de perfiles deshabilitada', + pickerSearch: 'Búsqueda en selectores', + pickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquina y ruta', + machinePickerSearch: 'Búsqueda de máquinas', + machinePickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquinas', + pathPickerSearch: 'Búsqueda de rutas', + pathPickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de rutas', }, errors: { @@ -269,6 +275,9 @@ export const es: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nueva sesión', + selectMachineTitle: 'Seleccionar máquina', + selectPathTitle: 'Seleccionar ruta', + searchPathsPlaceholder: 'Buscar rutas...', noMachinesFound: 'No se encontraron máquinas. Inicia una sesión de Happy en tu computadora primero.', allMachinesOffline: 'Todas las máquinas están desconectadas', machineDetails: 'Ver detalles de la máquina →', @@ -284,6 +293,13 @@ export const es: TranslationStructure = { startNewSessionInFolder: 'Nueva sesión aquí', noMachineSelected: 'Por favor, selecciona una máquina para iniciar la sesión', noPathSelected: 'Por favor, selecciona un directorio para iniciar la sesión', + machinePicker: { + searchPlaceholder: 'Buscar máquinas...', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'No hay máquinas disponibles', + }, sessionType: { title: 'Tipo de sesión', simple: 'Simple', @@ -345,6 +361,7 @@ export const es: TranslationStructure = { happySessionId: 'ID de sesión de Happy', claudeCodeSessionId: 'ID de sesión de Claude Code', claudeCodeSessionIdCopied: 'ID de sesión de Claude Code copiado al portapapeles', + aiProfile: 'Perfil de IA', aiProvider: 'Proveedor de IA', failedToCopyClaudeCodeSessionId: 'Falló al copiar ID de sesión de Claude Code', metadataCopied: 'Metadatos copiados al portapapeles', @@ -441,11 +458,11 @@ export const es: TranslationStructure = { geminiPermissionMode: { title: 'MODO DE PERMISOS GEMINI', default: 'Por defecto', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Solo lectura', + safeYolo: 'YOLO seguro', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Solo lectura', + badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, context: { @@ -513,6 +530,10 @@ export const es: TranslationStructure = { applyChanges: 'Actualizar archivo', viewDiff: 'Cambios del archivo actual', question: 'Pregunta', + changeTitle: 'Cambiar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -915,6 +936,110 @@ export const es: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar el perfil "${name}"?`, editProfile: 'Editar Perfil', addProfileTitle: 'Agregar Nuevo Perfil', + builtIn: 'Integrado', + groups: { + favorites: 'Favoritos', + custom: 'Tus perfiles', + builtIn: 'Perfiles integrados', + }, + actions: { + viewEnvironmentVariables: 'Variables de entorno', + addToFavorites: 'Agregar a favoritos', + removeFromFavorites: 'Quitar de favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Eliminar perfil', + }, + duplicateName: 'Ya existe un perfil con este nombre', + setupInstructions: { + title: 'Instrucciones de configuración', + viewOfficialGuide: 'Ver la guía oficial de configuración', + }, + defaultSessionType: 'Tipo de sesión predeterminado', + defaultPermissionMode: { + title: 'Modo de permisos predeterminado', + descriptions: { + default: 'Pedir permisos', + acceptEdits: 'Aprobar ediciones automáticamente', + plan: 'Planificar antes de ejecutar', + bypassPermissions: 'Omitir todos los permisos', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecciona al menos un backend de IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sesiones en Tmux', + spawnSessionsEnabledSubtitle: 'Las sesiones se abren en nuevas ventanas de tmux.', + spawnSessionsDisabledSubtitle: 'Las sesiones se abren en una shell normal (sin integración con tmux)', + sessionNamePlaceholder: 'Vacío = sesión actual/más reciente', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Vista previa de la máquina', + selectMachine: 'Seleccionar máquina', + resolveSubtitle: 'Resolver variables de entorno de la máquina para este perfil.', + selectSubtitle: 'Selecciona una máquina para previsualizar los valores resueltos.', + }, + environmentVariables: { + title: 'Variables de entorno', + addVariable: 'Añadir variable', + namePlaceholder: 'Nombre de variable (p. ej., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ej., mi-valor o ${MY_VAR})', + validation: { + nameRequired: 'Introduce un nombre de variable.', + invalidNameFormat: 'Los nombres de variables deben ser letras mayúsculas, números y guiones bajos, y no pueden empezar por un número.', + duplicateName: 'Esa variable ya existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de respaldo:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor predeterminado', + secretNotRetrieved: 'Valor secreto: no se recupera por seguridad', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sobrescribiendo el valor documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor del entorno de la máquina', + resolvedOnSessionStart: 'Se resuelve al iniciar la sesión en la máquina seleccionada.', + sourceVariableLabel: 'Variable de origen', + sourceVariablePlaceholder: 'Nombre de variable de origen (p. ej., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vacío en ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vacío en ${machine} (usando respaldo)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No encontrado en ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No encontrado en ${machine} (usando respaldo)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado en ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiere del valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por seguridad`, + hiddenValue: '***oculto***', + emptyValue: '(vacío)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sesión recibirá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de entorno · ${profileName}`, + descriptionPrefix: 'Estas variables de entorno se envían al iniciar la sesión. Los valores se resuelven usando el daemon en', + descriptionFallbackMachine: 'la máquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hay variables de entorno configuradas para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fijo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Respaldo', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar "${name}"? Esta acción no se puede deshacer.`, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index c1c31981e..d0fa31694 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -31,6 +31,7 @@ export const it: TranslationStructure = { common: { // Simple string constants + add: 'Aggiungi', cancel: 'Annulla', authenticate: 'Autentica', save: 'Salva', @@ -46,6 +47,9 @@ export const it: TranslationStructure = { yes: 'Sì', no: 'No', discard: 'Scarta', + discardChanges: 'Scarta modifiche', + unsavedChangesWarning: 'Hai modifiche non salvate.', + keepEditing: 'Continua a modificare', version: 'Versione', copied: 'Copiato', copy: 'Copia', @@ -59,6 +63,8 @@ export const it: TranslationStructure = { retry: 'Riprova', delete: 'Elimina', optional: 'opzionale', + noMatches: 'Nessuna corrispondenza', + machine: 'macchina', saveAs: 'Salva con nome', }, @@ -93,6 +99,110 @@ export const it: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `Sei sicuro di voler eliminare il profilo "${name}"?`, editProfile: 'Modifica profilo', addProfileTitle: 'Aggiungi nuovo profilo', + builtIn: 'Integrato', + groups: { + favorites: 'Preferiti', + custom: 'I tuoi profili', + builtIn: 'Profili integrati', + }, + actions: { + viewEnvironmentVariables: 'Variabili ambiente', + addToFavorites: 'Aggiungi ai preferiti', + removeFromFavorites: 'Rimuovi dai preferiti', + editProfile: 'Modifica profilo', + duplicateProfile: 'Duplica profilo', + deleteProfile: 'Elimina profilo', + }, + duplicateName: 'Esiste già un profilo con questo nome', + setupInstructions: { + title: 'Istruzioni di configurazione', + viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione', + }, + defaultSessionType: 'Tipo di sessione predefinito', + defaultPermissionMode: { + title: 'Modalità di permesso predefinita', + descriptions: { + default: 'Chiedi permessi', + acceptEdits: 'Approva automaticamente le modifiche', + plan: 'Pianifica prima di eseguire', + bypassPermissions: 'Salta tutti i permessi', + }, + }, + aiBackend: { + title: 'Backend IA', + selectAtLeastOneError: 'Seleziona almeno un backend IA.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (sperimentale)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Avvia sessioni in Tmux', + spawnSessionsEnabledSubtitle: 'Le sessioni vengono avviate in nuove finestre di tmux.', + spawnSessionsDisabledSubtitle: 'Le sessioni vengono avviate in una shell normale (senza integrazione tmux)', + sessionNamePlaceholder: 'Vuoto = sessione corrente/più recente', + tempDirPlaceholder: '/tmp (opzionale)', + }, + previewMachine: { + title: 'Anteprima macchina', + selectMachine: 'Seleziona macchina', + resolveSubtitle: 'Risolvi le variabili ambiente della macchina per questo profilo.', + selectSubtitle: 'Seleziona una macchina per visualizzare l\'anteprima dei valori risolti.', + }, + environmentVariables: { + title: 'Variabili ambiente', + addVariable: 'Aggiungi variabile', + namePlaceholder: 'Nome variabile (es., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valore (es., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Inserisci un nome variabile.', + invalidNameFormat: 'I nomi delle variabili devono usare lettere maiuscole, numeri e underscore e non possono iniziare con un numero.', + duplicateName: 'Questa variabile esiste già.', + }, + card: { + valueLabel: 'Valore:', + fallbackValueLabel: 'Valore di fallback:', + valueInputPlaceholder: 'Valore', + defaultValueInputPlaceholder: 'Valore predefinito', + secretNotRetrieved: 'Valore segreto - non recuperato per sicurezza', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sostituzione del valore predefinito documentato: ${expectedValue}`, + useMachineEnvToggle: 'Usa valore dall\'ambiente della macchina', + resolvedOnSessionStart: 'Risolto quando la sessione viene avviata sulla macchina selezionata.', + sourceVariableLabel: 'Variabile sorgente', + sourceVariablePlaceholder: 'Nome variabile sorgente (es., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verifica ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vuoto su ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vuoto su ${machine} (uso fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Non trovato su ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Non trovato su ${machine} (uso fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valore trovato su ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diverso dal valore documentato: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - nascosto per sicurezza`, + hiddenValue: '***nascosto***', + emptyValue: '(vuoto)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessione riceverà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variabili ambiente · ${profileName}`, + descriptionPrefix: 'Queste variabili ambiente vengono inviate all\'avvio della sessione. I valori vengono risolti dal daemon su', + descriptionFallbackMachine: 'la macchina selezionata', + descriptionSuffix: '.', + emptyMessage: 'Nessuna variabile ambiente è impostata per questo profilo.', + checkingSuffix: '(verifica…)', + detail: { + fixed: 'Fisso', + machine: 'Macchina', + checking: 'Verifica', + fallback: 'Fallback', + missing: 'Mancante', + }, + }, + }, delete: { title: 'Elimina profilo', message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`, @@ -237,15 +347,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', + profiles: 'Profili IA', + profilesEnabled: 'Selezione profili abilitata', + profilesDisabled: 'Selezione profili disabilitata', + pickerSearch: 'Ricerca nei selettori', + pickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchina e percorso', + machinePickerSearch: 'Ricerca macchine', + machinePickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchine', + pathPickerSearch: 'Ricerca percorsi', + pathPickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di percorsi', }, errors: { @@ -298,6 +408,9 @@ export const it: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Avvia nuova sessione', + selectMachineTitle: 'Seleziona macchina', + selectPathTitle: 'Seleziona percorso', + searchPathsPlaceholder: 'Cerca percorsi...', noMachinesFound: 'Nessuna macchina trovata. Avvia prima una sessione Happy sul tuo computer.', allMachinesOffline: 'Tutte le macchine sembrano offline', machineDetails: 'Visualizza dettagli macchina →', @@ -313,6 +426,13 @@ export const it: TranslationStructure = { notConnectedToServer: 'Non connesso al server. Controlla la tua connessione Internet.', noMachineSelected: 'Seleziona una macchina per avviare la sessione', noPathSelected: 'Seleziona una directory in cui avviare la sessione', + machinePicker: { + searchPlaceholder: 'Cerca macchine...', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + allTitle: 'Tutte', + emptyMessage: 'Nessuna macchina disponibile', + }, sessionType: { title: 'Tipo di sessione', simple: 'Semplice', @@ -374,6 +494,7 @@ export const it: TranslationStructure = { happySessionId: 'ID sessione Happy', claudeCodeSessionId: 'ID sessione Claude Code', claudeCodeSessionIdCopied: 'ID sessione Claude Code copiato negli appunti', + aiProfile: 'Profilo IA', aiProvider: 'Provider IA', failedToCopyClaudeCodeSessionId: 'Impossibile copiare l\'ID sessione Claude Code', metadataCopied: 'Metadati copiati negli appunti', @@ -546,6 +667,10 @@ export const it: TranslationStructure = { applyChanges: 'Aggiorna file', viewDiff: 'Modifiche file attuali', question: 'Domanda', + changeTitle: 'Cambia titolo', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminale(cmd: ${cmd})`, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 34d6d8047..68e4d5da2 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -34,6 +34,7 @@ export const ja: TranslationStructure = { common: { // Simple string constants + add: '追加', cancel: 'キャンセル', authenticate: '認証', save: '保存', @@ -49,6 +50,9 @@ export const ja: TranslationStructure = { yes: 'はい', no: 'いいえ', discard: '破棄', + discardChanges: '変更を破棄', + unsavedChangesWarning: '未保存の変更があります。', + keepEditing: '編集を続ける', version: 'バージョン', copied: 'コピーしました', copy: 'コピー', @@ -62,6 +66,8 @@ export const ja: TranslationStructure = { retry: '再試行', delete: '削除', optional: '任意', + noMatches: '一致するものがありません', + machine: 'マシン', saveAs: '名前を付けて保存', }, @@ -96,6 +102,110 @@ export const ja: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', + builtIn: '組み込み', + groups: { + favorites: 'お気に入り', + custom: 'あなたのプロファイル', + builtIn: '組み込みプロファイル', + }, + actions: { + viewEnvironmentVariables: '環境変数', + addToFavorites: 'お気に入りに追加', + removeFromFavorites: 'お気に入りから削除', + editProfile: 'プロファイルを編集', + duplicateProfile: 'プロファイルを複製', + deleteProfile: 'プロファイルを削除', + }, + duplicateName: '同じ名前のプロファイルが既に存在します', + setupInstructions: { + title: 'セットアップ手順', + viewOfficialGuide: '公式セットアップガイドを表示', + }, + defaultSessionType: 'デフォルトのセッションタイプ', + defaultPermissionMode: { + title: 'デフォルトの権限モード', + descriptions: { + default: '権限を要求する', + acceptEdits: '編集を自動承認', + plan: '実行前に計画', + bypassPermissions: 'すべての権限をスキップ', + }, + }, + aiBackend: { + title: 'AIバックエンド', + selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI(実験)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Tmuxでセッションを起動', + spawnSessionsEnabledSubtitle: 'セッションは新しいtmuxウィンドウで起動します。', + spawnSessionsDisabledSubtitle: 'セッションは通常のシェルで起動します(tmux連携なし)', + sessionNamePlaceholder: '空 = 現在/最近のセッション', + tempDirPlaceholder: '/tmp(任意)', + }, + previewMachine: { + title: 'マシンをプレビュー', + selectMachine: 'マシンを選択', + resolveSubtitle: 'このプロファイルのマシン環境変数を解決します。', + selectSubtitle: '解決後の値をプレビューするマシンを選択してください。', + }, + environmentVariables: { + title: '環境変数', + addVariable: '変数を追加', + namePlaceholder: '変数名(例: MY_CUSTOM_VAR)', + valuePlaceholder: '値(例: my-value または ${MY_VAR})', + validation: { + nameRequired: '変数名を入力してください。', + invalidNameFormat: '変数名は大文字、数字、アンダースコアのみで、数字から始めることはできません。', + duplicateName: 'その変数は既に存在します。', + }, + card: { + valueLabel: '値:', + fallbackValueLabel: 'フォールバック値:', + valueInputPlaceholder: '値', + defaultValueInputPlaceholder: 'デフォルト値', + secretNotRetrieved: 'シークレット値 — セキュリティのため取得しません', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `ドキュメントのデフォルト値を上書き: ${expectedValue}`, + useMachineEnvToggle: 'マシン環境から値を使用', + resolvedOnSessionStart: '選択したマシンでセッション開始時に解決されます。', + sourceVariableLabel: '参照元変数', + sourceVariablePlaceholder: '参照元変数名(例: Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `${machine} を確認中...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} では空です`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} では空です(フォールバック使用)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で見つかりません`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} で見つかりません(フォールバック使用)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で値を確認`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `ドキュメント値と異なります: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - セキュリティのため非表示`, + hiddenValue: '***非表示***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `セッションに渡される値: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `環境変数 · ${profileName}`, + descriptionPrefix: 'これらの環境変数はセッション開始時に送信されます。値はデーモンが', + descriptionFallbackMachine: '選択したマシン', + descriptionSuffix: 'で解決します。', + emptyMessage: 'このプロファイルには環境変数が設定されていません。', + checkingSuffix: '(確認中…)', + detail: { + fixed: '固定', + machine: 'マシン', + checking: '確認中', + fallback: 'フォールバック', + missing: '未設定', + }, + }, + }, delete: { title: 'プロファイルを削除', message: ({ name }: { name: string }) => `「${name}」を削除してもよろしいですか?この操作は元に戻せません。`, @@ -240,15 +350,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', + profiles: 'AIプロファイル', + profilesEnabled: 'プロファイル選択を有効化', + profilesDisabled: 'プロファイル選択を無効化', + pickerSearch: 'ピッカー検索', + pickerSearchSubtitle: 'マシンとパスのピッカーに検索欄を表示', + machinePickerSearch: 'マシン検索', + machinePickerSearchSubtitle: 'マシンピッカーに検索欄を表示', + pathPickerSearch: 'パス検索', + pathPickerSearchSubtitle: 'パスピッカーに検索欄を表示', }, errors: { @@ -301,6 +411,9 @@ export const ja: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '新しいセッションを開始', + selectMachineTitle: 'マシンを選択', + selectPathTitle: 'パスを選択', + searchPathsPlaceholder: 'パスを検索...', noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。', allMachinesOffline: 'すべてのマシンがオフラインです', machineDetails: 'マシンの詳細を表示 →', @@ -316,6 +429,13 @@ export const ja: TranslationStructure = { notConnectedToServer: 'サーバーに接続されていません。インターネット接続を確認してください。', noMachineSelected: 'セッションを開始するマシンを選択してください', noPathSelected: 'セッションを開始するディレクトリを選択してください', + machinePicker: { + searchPlaceholder: 'マシンを検索...', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + allTitle: 'すべて', + emptyMessage: '利用可能なマシンがありません', + }, sessionType: { title: 'セッションタイプ', simple: 'シンプル', @@ -377,6 +497,7 @@ export const ja: TranslationStructure = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました', + aiProfile: 'AIプロファイル', aiProvider: 'AIプロバイダー', failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました', metadataCopied: 'メタデータがクリップボードにコピーされました', @@ -549,6 +670,10 @@ export const ja: TranslationStructure = { applyChanges: 'ファイルを更新', viewDiff: '現在のファイル変更', question: '質問', + changeTitle: 'タイトルを変更', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 436e9a4ee..a396947d4 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -42,6 +42,7 @@ export const pl: TranslationStructure = { common: { // Simple string constants + add: 'Dodaj', cancel: 'Anuluj', authenticate: 'Uwierzytelnij', save: 'Zapisz', @@ -58,6 +59,9 @@ export const pl: TranslationStructure = { yes: 'Tak', no: 'Nie', discard: 'Odrzuć', + discardChanges: 'Odrzuć zmiany', + unsavedChangesWarning: 'Masz niezapisane zmiany.', + keepEditing: 'Kontynuuj edycję', version: 'Wersja', copied: 'Skopiowano', copy: 'Kopiuj', @@ -71,6 +75,8 @@ export const pl: TranslationStructure = { retry: 'Ponów', delete: 'Usuń', optional: 'opcjonalnie', + noMatches: 'Brak dopasowań', + machine: 'maszyna', }, profile: { @@ -219,15 +225,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', + profiles: 'Profile AI', + profilesEnabled: 'Wybór profili włączony', + profilesDisabled: 'Wybór profili wyłączony', + pickerSearch: 'Wyszukiwanie w selektorach', + pickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn i ścieżek', + machinePickerSearch: 'Wyszukiwanie maszyn', + machinePickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn', + pathPickerSearch: 'Wyszukiwanie ścieżek', + pathPickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach ścieżek', }, errors: { @@ -280,6 +286,9 @@ export const pl: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Rozpocznij nową sesję', + selectMachineTitle: 'Wybierz maszynę', + selectPathTitle: 'Wybierz ścieżkę', + searchPathsPlaceholder: 'Szukaj ścieżek...', noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.', allMachinesOffline: 'Wszystkie maszyny są offline', machineDetails: 'Zobacz szczegóły maszyny →', @@ -295,6 +304,13 @@ export const pl: TranslationStructure = { startNewSessionInFolder: 'Nowa sesja tutaj', noMachineSelected: 'Proszę wybrać maszynę do rozpoczęcia sesji', noPathSelected: 'Proszę wybrać katalog do rozpoczęcia sesji', + machinePicker: { + searchPlaceholder: 'Szukaj maszyn...', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + allTitle: 'Wszystkie', + emptyMessage: 'Brak dostępnych maszyn', + }, sessionType: { title: 'Typ sesji', simple: 'Prosta', @@ -356,6 +372,7 @@ export const pl: TranslationStructure = { happySessionId: 'ID sesji Happy', claudeCodeSessionId: 'ID sesji Claude Code', claudeCodeSessionIdCopied: 'ID sesji Claude Code skopiowane do schowka', + aiProfile: 'Profil AI', aiProvider: 'Dostawca AI', failedToCopyClaudeCodeSessionId: 'Nie udało się skopiować ID sesji Claude Code', metadataCopied: 'Metadane skopiowane do schowka', @@ -451,11 +468,11 @@ export const pl: TranslationStructure = { geminiPermissionMode: { title: 'TRYB UPRAWNIEŃ GEMINI', default: 'Domyślny', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Tylko do odczytu', + safeYolo: 'Bezpieczne YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Tylko do odczytu', + badgeSafeYolo: 'Bezpieczne YOLO', badgeYolo: 'YOLO', }, context: { @@ -523,6 +540,10 @@ export const pl: TranslationStructure = { applyChanges: 'Zaktualizuj plik', viewDiff: 'Bieżące zmiany pliku', question: 'Pytanie', + changeTitle: 'Zmień tytuł', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -938,6 +959,110 @@ export const pl: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć profil "${name}"?`, editProfile: 'Edytuj Profil', addProfileTitle: 'Dodaj Nowy Profil', + builtIn: 'Wbudowane', + groups: { + favorites: 'Ulubione', + custom: 'Twoje profile', + builtIn: 'Profile wbudowane', + }, + actions: { + viewEnvironmentVariables: 'Zmienne środowiskowe', + addToFavorites: 'Dodaj do ulubionych', + removeFromFavorites: 'Usuń z ulubionych', + editProfile: 'Edytuj profil', + duplicateProfile: 'Duplikuj profil', + deleteProfile: 'Usuń profil', + }, + duplicateName: 'Profil o tej nazwie już istnieje', + setupInstructions: { + title: 'Instrukcje konfiguracji', + viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji', + }, + defaultSessionType: 'Domyślny typ sesji', + defaultPermissionMode: { + title: 'Domyślny tryb uprawnień', + descriptions: { + default: 'Pytaj o uprawnienia', + acceptEdits: 'Automatycznie zatwierdzaj edycje', + plan: 'Zaplanuj przed wykonaniem', + bypassPermissions: 'Pomiń wszystkie uprawnienia', + }, + }, + aiBackend: { + title: 'Backend AI', + selectAtLeastOneError: 'Wybierz co najmniej jeden backend AI.', + claudeSubtitle: 'CLI Claude', + codexSubtitle: 'CLI Codex', + geminiSubtitleExperimental: 'CLI Gemini (eksperymentalne)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Uruchamiaj sesje w Tmux', + spawnSessionsEnabledSubtitle: 'Sesje uruchamiają się w nowych oknach tmux.', + spawnSessionsDisabledSubtitle: 'Sesje uruchamiają się w zwykłej powłoce (bez integracji z tmux)', + sessionNamePlaceholder: 'Puste = bieżąca/najnowsza sesja', + tempDirPlaceholder: '/tmp (opcjonalne)', + }, + previewMachine: { + title: 'Podgląd maszyny', + selectMachine: 'Wybierz maszynę', + resolveSubtitle: 'Rozwiąż zmienne środowiskowe maszyny dla tego profilu.', + selectSubtitle: 'Wybierz maszynę, aby podejrzeć rozwiązane wartości.', + }, + environmentVariables: { + title: 'Zmienne środowiskowe', + addVariable: 'Dodaj zmienną', + namePlaceholder: 'Nazwa zmiennej (np. MY_CUSTOM_VAR)', + valuePlaceholder: 'Wartość (np. my-value lub ${MY_VAR})', + validation: { + nameRequired: 'Wprowadź nazwę zmiennej.', + invalidNameFormat: 'Nazwy zmiennych muszą zawierać wielkie litery, cyfry i podkreślenia oraz nie mogą zaczynać się od cyfry.', + duplicateName: 'Taka zmienna już istnieje.', + }, + card: { + valueLabel: 'Wartość:', + fallbackValueLabel: 'Wartość fallback:', + valueInputPlaceholder: 'Wartość', + defaultValueInputPlaceholder: 'Wartość domyślna', + secretNotRetrieved: 'Wartość sekretna - nie jest pobierana ze względów bezpieczeństwa', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Nadpisywanie udokumentowanej wartości domyślnej: ${expectedValue}`, + useMachineEnvToggle: 'Użyj wartości ze środowiska maszyny', + resolvedOnSessionStart: 'Rozwiązywane podczas uruchamiania sesji na wybranej maszynie.', + sourceVariableLabel: 'Zmienna źródłowa', + sourceVariablePlaceholder: 'Nazwa zmiennej źródłowej (np. Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Sprawdzanie ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Pusto na ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Pusto na ${machine} (używam fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine} (używam fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Znaleziono wartość na ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Różni się od udokumentowanej wartości: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ukryte ze względów bezpieczeństwa`, + hiddenValue: '***ukryte***', + emptyValue: '(puste)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Sesja otrzyma: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Zmienne środowiskowe · ${profileName}`, + descriptionPrefix: 'Te zmienne środowiskowe są wysyłane podczas uruchamiania sesji. Wartości są rozwiązywane przez daemon na', + descriptionFallbackMachine: 'wybranej maszynie', + descriptionSuffix: '.', + emptyMessage: 'Dla tego profilu nie ustawiono zmiennych środowiskowych.', + checkingSuffix: '(sprawdzanie…)', + detail: { + fixed: 'Stała', + machine: 'Maszyna', + checking: 'Sprawdzanie', + fallback: 'Fallback', + missing: 'Brak', + }, + }, + }, delete: { title: 'Usuń Profil', message: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć "${name}"? Tej czynności nie można cofnąć.`, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 080db2565..89446905e 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -31,6 +31,7 @@ export const pt: TranslationStructure = { common: { // Simple string constants + add: 'Adicionar', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Salvar', @@ -47,6 +48,9 @@ export const pt: TranslationStructure = { yes: 'Sim', no: 'Não', discard: 'Descartar', + discardChanges: 'Descartar alterações', + unsavedChangesWarning: 'Você tem alterações não salvas.', + keepEditing: 'Continuar editando', version: 'Versão', copied: 'Copiado', copy: 'Copiar', @@ -60,6 +64,8 @@ export const pt: TranslationStructure = { retry: 'Tentar novamente', delete: 'Excluir', optional: 'Opcional', + noMatches: 'Nenhuma correspondência', + machine: 'máquina', }, profile: { @@ -208,15 +214,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', + profiles: 'Perfis de IA', + profilesEnabled: 'Seleção de perfis ativada', + profilesDisabled: 'Seleção de perfis desativada', + pickerSearch: 'Busca nos seletores', + pickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquina e caminho', + machinePickerSearch: 'Busca de máquinas', + machinePickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquinas', + pathPickerSearch: 'Busca de caminhos', + pathPickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de caminhos', }, errors: { @@ -269,6 +275,9 @@ export const pt: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nova sessão', + selectMachineTitle: 'Selecionar máquina', + selectPathTitle: 'Selecionar caminho', + searchPathsPlaceholder: 'Pesquisar caminhos...', noMachinesFound: 'Nenhuma máquina encontrada. Inicie uma sessão Happy no seu computador primeiro.', allMachinesOffline: 'Todas as máquinas estão offline', machineDetails: 'Ver detalhes da máquina →', @@ -284,6 +293,13 @@ export const pt: TranslationStructure = { startNewSessionInFolder: 'Nova sessão aqui', noMachineSelected: 'Por favor, selecione uma máquina para iniciar a sessão', noPathSelected: 'Por favor, selecione um diretório para iniciar a sessão', + machinePicker: { + searchPlaceholder: 'Pesquisar máquinas...', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'Nenhuma máquina disponível', + }, sessionType: { title: 'Tipo de sessão', simple: 'Simples', @@ -345,6 +361,7 @@ export const pt: TranslationStructure = { happySessionId: 'ID da sessão Happy', claudeCodeSessionId: 'ID da sessão Claude Code', claudeCodeSessionIdCopied: 'ID da sessão Claude Code copiado para a área de transferência', + aiProfile: 'Perfil de IA', aiProvider: 'Provedor de IA', failedToCopyClaudeCodeSessionId: 'Falha ao copiar ID da sessão Claude Code', metadataCopied: 'Metadados copiados para a área de transferência', @@ -441,11 +458,11 @@ export const pt: TranslationStructure = { geminiPermissionMode: { title: 'MODO DE PERMISSÃO GEMINI', default: 'Padrão', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Somente leitura', + safeYolo: 'YOLO seguro', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Somente leitura', + badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, context: { @@ -513,6 +530,10 @@ export const pt: TranslationStructure = { applyChanges: 'Atualizar arquivo', viewDiff: 'Alterações do arquivo atual', question: 'Pergunta', + changeTitle: 'Alterar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -905,6 +926,110 @@ export const pt: TranslationStructure = { tmuxUpdateEnvironment: 'Atualizar ambiente tmux', deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`, nameRequired: 'O nome do perfil é obrigatório', + builtIn: 'Integrado', + groups: { + favorites: 'Favoritos', + custom: 'Seus perfis', + builtIn: 'Perfis integrados', + }, + actions: { + viewEnvironmentVariables: 'Variáveis de ambiente', + addToFavorites: 'Adicionar aos favoritos', + removeFromFavorites: 'Remover dos favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Excluir perfil', + }, + duplicateName: 'Já existe um perfil com este nome', + setupInstructions: { + title: 'Instruções de configuração', + viewOfficialGuide: 'Ver guia oficial de configuração', + }, + defaultSessionType: 'Tipo de sessão padrão', + defaultPermissionMode: { + title: 'Modo de permissão padrão', + descriptions: { + default: 'Solicitar permissões', + acceptEdits: 'Aprovar edições automaticamente', + plan: 'Planejar antes de executar', + bypassPermissions: 'Ignorar todas as permissões', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecione pelo menos um backend de IA.', + claudeSubtitle: 'CLI do Claude', + codexSubtitle: 'CLI do Codex', + geminiSubtitleExperimental: 'CLI do Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sessões no Tmux', + spawnSessionsEnabledSubtitle: 'As sessões são iniciadas em novas janelas do tmux.', + spawnSessionsDisabledSubtitle: 'As sessões são iniciadas no shell comum (sem integração com tmux)', + sessionNamePlaceholder: 'Vazio = sessão atual/mais recente', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Pré-visualizar máquina', + selectMachine: 'Selecionar máquina', + resolveSubtitle: 'Resolver variáveis de ambiente da máquina para este perfil.', + selectSubtitle: 'Selecione uma máquina para pré-visualizar os valores resolvidos.', + }, + environmentVariables: { + title: 'Variáveis de ambiente', + addVariable: 'Adicionar variável', + namePlaceholder: 'Nome da variável (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (e.g., my-value ou ${MY_VAR})', + validation: { + nameRequired: 'Digite um nome de variável.', + invalidNameFormat: 'Os nomes das variáveis devem conter letras maiúsculas, números e sublinhados, e não podem começar com um número.', + duplicateName: 'Essa variável já existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de fallback:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor padrão', + secretNotRetrieved: 'Valor secreto - não é recuperado por segurança', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Substituindo o valor padrão documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor do ambiente da máquina', + resolvedOnSessionStart: 'Resolvido quando a sessão começa na máquina selecionada.', + sourceVariableLabel: 'Variável de origem', + sourceVariablePlaceholder: 'Nome da variável de origem (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vazio em ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vazio em ${machine} (usando fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Não encontrado em ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Não encontrado em ${machine} (usando fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado em ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diferente do valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por segurança`, + hiddenValue: '***oculto***', + emptyValue: '(vazio)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `A sessão receberá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de ambiente · ${profileName}`, + descriptionPrefix: 'Estas variáveis de ambiente são enviadas ao iniciar a sessão. Os valores são resolvidos usando o daemon em', + descriptionFallbackMachine: 'a máquina selecionada', + descriptionSuffix: '.', + emptyMessage: 'Nenhuma variável de ambiente está definida para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fixo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Fallback', + missing: 'Ausente', + }, + }, + }, delete: { title: 'Excluir Perfil', message: ({ name }: { name: string }) => `Tem certeza de que deseja excluir "${name}"? Esta ação não pode ser desfeita.`, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index d42e4bb20..c9b7dc89f 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -42,6 +42,7 @@ export const ru: TranslationStructure = { common: { // Simple string constants + add: 'Добавить', cancel: 'Отмена', authenticate: 'Авторизация', save: 'Сохранить', @@ -58,6 +59,9 @@ export const ru: TranslationStructure = { yes: 'Да', no: 'Нет', discard: 'Отменить', + discardChanges: 'Отменить изменения', + unsavedChangesWarning: 'У вас есть несохранённые изменения.', + keepEditing: 'Продолжить редактирование', version: 'Версия', copied: 'Скопировано', copy: 'Копировать', @@ -71,6 +75,8 @@ export const ru: TranslationStructure = { retry: 'Повторить', delete: 'Удалить', optional: 'необязательно', + noMatches: 'Нет совпадений', + machine: 'машина', }, connect: { @@ -190,15 +196,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', + profiles: 'Профили ИИ', + profilesEnabled: 'Выбор профилей включён', + profilesDisabled: 'Выбор профилей отключён', + pickerSearch: 'Поиск в выборе', + pickerSearchSubtitle: 'Показывать поле поиска в выборе машины и пути', + machinePickerSearch: 'Поиск машин', + machinePickerSearchSubtitle: 'Показывать поле поиска при выборе машины', + pathPickerSearch: 'Поиск путей', + pathPickerSearchSubtitle: 'Показывать поле поиска при выборе пути', }, errors: { @@ -251,6 +257,9 @@ export const ru: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Начать новую сессию', + selectMachineTitle: 'Выбрать машину', + selectPathTitle: 'Выбрать путь', + searchPathsPlaceholder: 'Поиск путей...', noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.', allMachinesOffline: 'Все машины находятся offline', machineDetails: 'Посмотреть детали машины →', @@ -266,6 +275,13 @@ export const ru: TranslationStructure = { startNewSessionInFolder: 'Новая сессия здесь', noMachineSelected: 'Пожалуйста, выберите машину для запуска сессии', noPathSelected: 'Пожалуйста, выберите директорию для запуска сессии', + machinePicker: { + searchPlaceholder: 'Поиск машин...', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + allTitle: 'Все', + emptyMessage: 'Нет доступных машин', + }, sessionType: { title: 'Тип сессии', simple: 'Простая', @@ -319,6 +335,7 @@ export const ru: TranslationStructure = { happySessionId: 'ID сессии Happy', claudeCodeSessionId: 'ID сессии Claude Code', claudeCodeSessionIdCopied: 'ID сессии Claude Code скопирован в буфер обмена', + aiProfile: 'Профиль ИИ', aiProvider: 'Поставщик ИИ', failedToCopyClaudeCodeSessionId: 'Не удалось скопировать ID сессии Claude Code', metadataCopied: 'Метаданные скопированы в буфер обмена', @@ -523,6 +540,10 @@ export const ru: TranslationStructure = { applyChanges: 'Обновить файл', viewDiff: 'Текущие изменения файла', question: 'Вопрос', + changeTitle: 'Изменить заголовок', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Терминал(команда: ${cmd})`, @@ -937,6 +958,112 @@ export const ru: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`, editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', + builtIn: 'Встроенный', + groups: { + favorites: 'Избранное', + custom: 'Ваши профили', + builtIn: 'Встроенные профили', + }, + actions: { + viewEnvironmentVariables: 'Переменные окружения', + addToFavorites: 'Добавить в избранное', + removeFromFavorites: 'Убрать из избранного', + editProfile: 'Редактировать профиль', + duplicateProfile: 'Дублировать профиль', + deleteProfile: 'Удалить профиль', + }, + duplicateName: 'Профиль с таким названием уже существует', + setupInstructions: { + title: 'Инструкции по настройке', + viewOfficialGuide: 'Открыть официальное руководство', + }, + defaultSessionType: 'Тип сессии по умолчанию', + defaultPermissionMode: { + title: 'Режим разрешений по умолчанию', + descriptions: { + default: 'Запрашивать разрешения', + acceptEdits: 'Авто-одобрять правки', + plan: 'Планировать перед выполнением', + bypassPermissions: 'Пропускать все разрешения', + }, + }, + aiBackend: { + title: 'Бекенд ИИ', + selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (экспериментально)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Запускать сессии в Tmux', + spawnSessionsEnabledSubtitle: 'Сессии запускаются в новых окнах tmux.', + spawnSessionsDisabledSubtitle: 'Сессии запускаются в обычной оболочке (без интеграции с tmux)', + sessionNamePlaceholder: 'Пусто = текущая/последняя сессия', + tempDirPlaceholder: '/tmp (необязательно)', + }, + previewMachine: { + title: 'Предпросмотр машины', + selectMachine: 'Выбрать машину', + resolveSubtitle: 'Разрешить переменные окружения машины для этого профиля.', + selectSubtitle: 'Выберите машину, чтобы просмотреть вычисленные значения.', + }, + environmentVariables: { + title: 'Переменные окружения', + addVariable: 'Добавить переменную', + namePlaceholder: 'Имя переменной (например, MY_CUSTOM_VAR)', + valuePlaceholder: 'Значение (например, my-value или ${MY_VAR})', + validation: { + nameRequired: 'Введите имя переменной.', + invalidNameFormat: 'Имена переменных должны содержать заглавные буквы, цифры и подчёркивания и не могут начинаться с цифры.', + duplicateName: 'Такая переменная уже существует.', + }, + card: { + valueLabel: 'Значение:', + fallbackValueLabel: 'Значение по умолчанию:', + valueInputPlaceholder: 'Значение', + defaultValueInputPlaceholder: 'Значение по умолчанию', + secretNotRetrieved: 'Секретное значение — не извлекается из соображений безопасности', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Переопределение документированного значения: ${expectedValue}`, + useMachineEnvToggle: 'Использовать значение из окружения машины', + resolvedOnSessionStart: 'Разрешается при запуске сессии на выбранной машине.', + sourceVariableLabel: 'Переменная-источник', + sourceVariablePlaceholder: 'Имя переменной-источника (например, Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Проверка ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Пусто на ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Пусто на ${machine} (используется значение по умолчанию)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Не найдено на ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Не найдено на ${machine} (используется значение по умолчанию)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Значение найдено на ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Отличается от документированного значения: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} — скрыто из соображений безопасности`, + hiddenValue: '***скрыто***', + emptyValue: '(пусто)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Сессия получит: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Переменные окружения · ${profileName}`, + descriptionPrefix: 'Эти переменные окружения отправляются при запуске сессии. Значения разрешаются демоном на', + descriptionFallbackMachine: 'выбранной машине', + descriptionSuffix: '.', + emptyMessage: 'Для этого профиля не заданы переменные окружения.', + checkingSuffix: '(проверка…)', + detail: { + fixed: 'Фиксированное', + machine: 'Машина', + checking: 'Проверка', + fallback: 'По умолчанию', + missing: 'Отсутствует', + }, + }, + }, delete: { title: 'Удалить Профиль', message: ({ name }: { name: string }) => `Вы уверены, что хотите удалить "${name}"? Это действие нельзя отменить.`, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index fc33c96f9..25e29ee88 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -33,6 +33,7 @@ export const zhHans: TranslationStructure = { common: { // Simple string constants + add: '添加', cancel: '取消', authenticate: '认证', save: '保存', @@ -49,6 +50,9 @@ export const zhHans: TranslationStructure = { yes: '是', no: '否', discard: '放弃', + discardChanges: '放弃更改', + unsavedChangesWarning: '你有未保存的更改。', + keepEditing: '继续编辑', version: '版本', copied: '已复制', copy: '复制', @@ -62,6 +66,8 @@ export const zhHans: TranslationStructure = { retry: '重试', delete: '删除', optional: '可选的', + noMatches: '无匹配结果', + machine: '机器', }, profile: { @@ -210,15 +216,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', + profiles: 'AI 配置文件', + profilesEnabled: '已启用配置文件选择', + profilesDisabled: '已禁用配置文件选择', + pickerSearch: '选择器搜索', + pickerSearchSubtitle: '在设备和路径选择器中显示搜索框', + machinePickerSearch: '设备搜索', + machinePickerSearchSubtitle: '在设备选择器中显示搜索框', + pathPickerSearch: '路径搜索', + pathPickerSearchSubtitle: '在路径选择器中显示搜索框', }, errors: { @@ -271,6 +277,9 @@ export const zhHans: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '启动新会话', + selectMachineTitle: '选择设备', + selectPathTitle: '选择路径', + searchPathsPlaceholder: '搜索路径...', noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。', allMachinesOffline: '所有设备似乎都已离线', machineDetails: '查看设备详情 →', @@ -286,6 +295,13 @@ export const zhHans: TranslationStructure = { notConnectedToServer: '未连接到服务器。请检查您的网络连接。', noMachineSelected: '请选择一台设备以启动会话', noPathSelected: '请选择一个目录以启动会话', + machinePicker: { + searchPlaceholder: '搜索设备...', + recentTitle: '最近', + favoritesTitle: '收藏', + allTitle: '全部', + emptyMessage: '没有可用设备', + }, sessionType: { title: '会话类型', simple: '简单', @@ -347,6 +363,7 @@ export const zhHans: TranslationStructure = { happySessionId: 'Happy 会话 ID', claudeCodeSessionId: 'Claude Code 会话 ID', claudeCodeSessionIdCopied: 'Claude Code 会话 ID 已复制到剪贴板', + aiProfile: 'AI 配置文件', aiProvider: 'AI 提供商', failedToCopyClaudeCodeSessionId: '复制 Claude Code 会话 ID 失败', metadataCopied: '元数据已复制到剪贴板', @@ -443,11 +460,11 @@ export const zhHans: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI 权限模式', default: '默认', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: '只读', + safeYolo: '安全 YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: '只读', + badgeSafeYolo: '安全 YOLO', badgeYolo: 'YOLO', }, context: { @@ -515,6 +532,10 @@ export const zhHans: TranslationStructure = { applyChanges: '更新文件', viewDiff: '当前文件更改', question: '问题', + changeTitle: '更改标题', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `终端(命令: ${cmd})`, @@ -907,6 +928,110 @@ export const zhHans: TranslationStructure = { tmuxUpdateEnvironment: '更新 tmux 环境', deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', + builtIn: '内置', + groups: { + favorites: '收藏', + custom: '你的配置文件', + builtIn: '内置配置文件', + }, + actions: { + viewEnvironmentVariables: '环境变量', + addToFavorites: '添加到收藏', + removeFromFavorites: '从收藏中移除', + editProfile: '编辑配置文件', + duplicateProfile: '复制配置文件', + deleteProfile: '删除配置文件', + }, + duplicateName: '已存在同名配置文件', + setupInstructions: { + title: '设置说明', + viewOfficialGuide: '查看官方设置指南', + }, + defaultSessionType: '默认会话类型', + defaultPermissionMode: { + title: '默认权限模式', + descriptions: { + default: '询问权限', + acceptEdits: '自动批准编辑', + plan: '执行前先规划', + bypassPermissions: '跳过所有权限', + }, + }, + aiBackend: { + title: 'AI 后端', + selectAtLeastOneError: '至少选择一个 AI 后端。', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI(实验)', + }, + tmux: { + title: 'tmux', + spawnSessionsTitle: '在 tmux 中启动会话', + spawnSessionsEnabledSubtitle: '会话将在新的 tmux 窗口中启动。', + spawnSessionsDisabledSubtitle: '会话将在普通 shell 中启动(无 tmux 集成)', + sessionNamePlaceholder: '留空 = 当前/最近会话', + tempDirPlaceholder: '/tmp(可选)', + }, + previewMachine: { + title: '预览设备', + selectMachine: '选择设备', + resolveSubtitle: '为此配置文件解析设备环境变量。', + selectSubtitle: '选择设备以预览解析后的值。', + }, + environmentVariables: { + title: '环境变量', + addVariable: '添加变量', + namePlaceholder: '变量名(例如 MY_CUSTOM_VAR)', + valuePlaceholder: '值(例如 my-value 或 ${MY_VAR})', + validation: { + nameRequired: '请输入变量名。', + invalidNameFormat: '变量名必须由大写字母、数字和下划线组成,且不能以数字开头。', + duplicateName: '该变量已存在。', + }, + card: { + valueLabel: '值:', + fallbackValueLabel: '备用值:', + valueInputPlaceholder: '值', + defaultValueInputPlaceholder: '默认值', + secretNotRetrieved: '秘密值——出于安全原因不会读取', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `正在覆盖文档默认值:${expectedValue}`, + useMachineEnvToggle: '使用设备环境中的值', + resolvedOnSessionStart: '会话在所选设备上启动时解析。', + sourceVariableLabel: '来源变量', + sourceVariablePlaceholder: '来源变量名(例如 Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `正在检查 ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} 上为空`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} 上为空(使用备用值)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上未找到`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `在 ${machine} 上未找到(使用备用值)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上找到值`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `与文档值不同:${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - 出于安全已隐藏`, + hiddenValue: '***已隐藏***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `会话将收到:${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `环境变量 · ${profileName}`, + descriptionPrefix: '这些环境变量会在启动会话时发送。值会通过守护进程解析于', + descriptionFallbackMachine: '所选设备', + descriptionSuffix: '。', + emptyMessage: '该配置文件未设置环境变量。', + checkingSuffix: '(检查中…)', + detail: { + fixed: '固定', + machine: '设备', + checking: '检查中', + fallback: '备用', + missing: '缺失', + }, + }, + }, delete: { title: '删除配置', message: ({ name }: { name: string }) => `确定要删除"${name}"吗?此操作无法撤销。`, diff --git a/sources/utils/envVarTemplate.test.ts b/sources/utils/envVarTemplate.test.ts new file mode 100644 index 000000000..52ca30646 --- /dev/null +++ b/sources/utils/envVarTemplate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from './envVarTemplate'; + +describe('envVarTemplate', () => { + it('preserves := operator during parse/format round-trip', () => { + const input = '${FOO:=bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':=', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('preserves :- operator during parse/format round-trip', () => { + const input = '${FOO:-bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':-', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('round-trips templates without a fallback', () => { + const input = '${FOO}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: null, fallback: '' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('formats an empty fallback when operator is explicitly provided', () => { + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':=', fallback: '' })).toBe('${FOO:=}'); + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':-', fallback: '' })).toBe('${FOO:-}'); + }); +}); + diff --git a/sources/utils/envVarTemplate.ts b/sources/utils/envVarTemplate.ts new file mode 100644 index 000000000..493ca41eb --- /dev/null +++ b/sources/utils/envVarTemplate.ts @@ -0,0 +1,40 @@ +export type EnvVarTemplateOperator = ':-' | ':='; + +export type EnvVarTemplate = Readonly<{ + sourceVar: string; + fallback: string; + operator: EnvVarTemplateOperator | null; +}>; + +export function parseEnvVarTemplate(value: string): EnvVarTemplate | null { + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-|:=)(.*)\}$/); + if (withFallback) { + return { + sourceVar: withFallback[1], + operator: withFallback[2] as EnvVarTemplateOperator, + fallback: withFallback[3], + }; + } + + const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + if (noFallback) { + return { + sourceVar: noFallback[1], + operator: null, + fallback: '', + }; + } + + return null; +} + +export function formatEnvVarTemplate(params: { + sourceVar: string; + fallback: string; + operator?: EnvVarTemplateOperator | null; +}): string { + const operator: EnvVarTemplateOperator | null = params.operator ?? (params.fallback !== '' ? ':-' : null); + const suffix = operator ? `${operator}${params.fallback}` : ''; + return `\${${params.sourceVar}${suffix}}`; +} + From 14e962cd88b4329cbbc9104cae74435b8387fa6f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 18:08:25 +0100 Subject: [PATCH 085/106] fix(modal): improve web alert button layout --- sources/modal/components/WebAlertModal.tsx | 106 ++++++++++++++++++--- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/sources/modal/components/WebAlertModal.tsx b/sources/modal/components/WebAlertModal.tsx index 67e61ae43..24cad9309 100644 --- a/sources/modal/components/WebAlertModal.tsx +++ b/sources/modal/components/WebAlertModal.tsx @@ -32,6 +32,8 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps ] : config.buttons || [{ text: 'OK', style: 'default' as const }]; + const buttonLayout = buttons.length === 3 ? 'twoPlusOne' : buttons.length > 3 ? 'column' : 'row'; + const styles = StyleSheet.create({ container: { backgroundColor: theme.colors.surface, @@ -69,6 +71,9 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps buttonContainer: { borderTopWidth: 1, borderTopColor: theme.colors.divider, + flexDirection: buttonLayout === 'row' ? 'row' : 'column' + }, + twoPlusOneRow: { flexDirection: 'row' }, button: { @@ -81,13 +86,25 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps backgroundColor: theme.colors.divider }, buttonSeparator: { + width: buttonLayout === 'row' ? 1 : undefined, + height: buttonLayout === 'row' ? undefined : 1, + backgroundColor: theme.colors.divider + }, + buttonSeparatorVertical: { width: 1, backgroundColor: theme.colors.divider }, + buttonSeparatorHorizontal: { + height: 1, + backgroundColor: theme.colors.divider + }, buttonText: { fontSize: 17, color: theme.colors.textLink }, + primaryText: { + color: theme.colors.text + }, cancelText: { fontWeight: '400' }, @@ -110,30 +127,93 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps )} - - {buttons.map((button, index) => ( - - {index > 0 && } + {buttonLayout === 'twoPlusOne' ? ( + + [ styles.button, pressed && styles.buttonPressed ]} - onPress={() => handleButtonPress(index)} + onPress={() => handleButtonPress(0)} > - {button.text} + {buttons[0]?.text} - - ))} - + + + + [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(2)} + > + + {buttons[2]?.text} + + + + + + + [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(1)} + > + + {buttons[1]?.text} + + + + ) : ( + + {buttons.map((button, index) => ( + + {index > 0 && } + [ + styles.button, + pressed && styles.buttonPressed + ]} + onPress={() => handleButtonPress(index)} + > + + {button.text} + + + + ))} + + )} ); -} \ No newline at end of file +} From 14c35f0746e8e99414b1c8e45260b05fec4653cf Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 18:12:33 +0100 Subject: [PATCH 086/106] feat(path): handle Enter in wizard and picker --- sources/app/(app)/new/pick/path.tsx | 23 +-- .../components/newSession/PathSelector.tsx | 147 ++++++++++++++++-- 2 files changed, 147 insertions(+), 23 deletions(-) diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index 57db918f0..74b47dd37 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -113,8 +113,9 @@ export default React.memo(function PathPickerScreen() { }, [sessions, params.machineId, recentMachinePaths]); - const handleSelectPath = React.useCallback(() => { - const pathToUse = customPath.trim() || machine?.metadata?.homeDir || '/home'; + const handleSelectPath = React.useCallback((pathOverride?: string) => { + const rawPath = typeof pathOverride === 'string' ? pathOverride : customPath; + const pathToUse = rawPath.trim() || machine?.metadata?.homeDir || '/home'; // Pass path back via navigation params (main's pattern, received by new/index.tsx) const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; @@ -138,7 +139,7 @@ export default React.memo(function PathPickerScreen() { headerBackTitle: t('common.back'), headerRight: () => ( handleSelectPath()} disabled={!customPath.trim()} style={({ pressed }) => ({ marginRight: 16, @@ -171,13 +172,13 @@ export default React.memo(function PathPickerScreen() { headerShown: true, headerTitle: t('newSession.selectPathTitle'), headerBackTitle: t('common.back'), - headerRight: () => ( - ({ - opacity: pressed ? 0.7 : 1, - padding: 4, + headerRight: () => ( + handleSelectPath()} + disabled={!customPath.trim()} + style={({ pressed }) => ({ + opacity: pressed ? 0.7 : 1, + padding: 4, })} > void; + onSubmitSelectedPath?: (path: string) => void; + submitBehavior?: 'showRow' | 'confirm'; recentPaths: string[]; usePickerSearch: boolean; searchVariant?: 'header' | 'group' | 'none'; @@ -55,17 +57,20 @@ export function PathSelector({ onChangeSearchQuery: onChangeSearchQueryProp, favoriteDirectories, onChangeFavoriteDirectories, + onSubmitSelectedPath, + submitBehavior = 'showRow', }: PathSelectorProps) { const { theme, rt } = useUnistyles(); const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const styles = stylesheet; - const inputRef = useRef(null); + const inputRef = useRef(null); const searchInputRef = useRef(null); const searchWasFocusedRef = useRef(false); const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; const setSearchQuery = onChangeSearchQueryProp ?? setUncontrolledSearchQuery; + const [submittedCustomPath, setSubmittedCustomPath] = useState(null); const suggestedPaths = useMemo(() => { const homeDir = machineHomeDir || '/home'; @@ -193,11 +198,33 @@ export function PathSelector({ ); }, [favoriteDirectories, machineHomeDir, onChangeFavoriteDirectories]); + const handleChangeSelectedPath = React.useCallback((text: string) => { + onChangeSelectedPath(text); + if (submittedCustomPath && text.trim() !== submittedCustomPath) { + setSubmittedCustomPath(null); + } + }, [onChangeSelectedPath, submittedCustomPath]); + const setPathAndFocus = React.useCallback((path: string) => { onChangeSelectedPath(path); + setSubmittedCustomPath(null); setTimeout(() => inputRef.current?.focus(), 50); }, [onChangeSelectedPath]); + const handleSubmitPath = React.useCallback(() => { + const trimmed = selectedPath.trim(); + if (!trimmed) return; + + if (trimmed !== selectedPath) { + onChangeSelectedPath(trimmed); + } + + onSubmitSelectedPath?.(trimmed); + if (submitBehavior !== 'confirm') { + setSubmittedCustomPath(trimmed); + } + }, [onChangeSelectedPath, onSubmitSelectedPath, selectedPath, submitBehavior]); + const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { return ( @@ -226,6 +253,65 @@ export function PathSelector({ ); }, [selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + const renderCustomRightElement = React.useCallback((absolutePath: string) => { + const isFavorite = favoritePaths.includes(absolutePath); + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + { + e.stopPropagation(); + setSubmittedCustomPath(null); + onChangeSelectedPath(''); + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + + + + ); + }, [favoritePaths, onChangeSelectedPath, selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const showSubmittedCustomPathRow = useMemo(() => { + if (!submittedCustomPath) return null; + const trimmed = selectedPath.trim(); + if (!trimmed) return null; + if (trimmed !== submittedCustomPath) return null; + + const visiblePaths = new Set([ + ...filteredFavoritePaths, + ...filteredRecentPaths, + ...filteredSuggestedPaths, + ]); + if (visiblePaths.has(trimmed)) return null; + + return trimmed; + }, [filteredFavoritePaths, filteredRecentPaths, filteredSuggestedPaths, selectedPath, submittedCustomPath]); + return ( <> {usePickerSearch && searchVariant === 'header' && ( @@ -239,19 +325,54 @@ export function PathSelector({ - + {showSubmittedCustomPathRow && ( + + } + onPress={() => setTimeout(() => inputRef.current?.focus(), 50)} + selected={true} + showChevron={false} + rightElement={renderCustomRightElement(showSubmittedCustomPathRow)} + showDivider={false} + /> + + )} + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( {effectiveGroupSearchPlacement === 'recent' && ( @@ -287,11 +408,11 @@ export function PathSelector({ title={path} leftElement={} onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} From 5439a838a02c588dc1bfd97c73e2c1f66995ced0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 18:12:52 +0100 Subject: [PATCH 087/106] feat(profile-edit): allow save from discard prompt --- sources/app/(app)/new/pick/profile-edit.tsx | 31 +++++++++++----- sources/components/ProfileEditForm.tsx | 12 +++++++ .../utils/promptUnsavedChangesAlert.test.ts | 26 ++++++++++++++ sources/utils/promptUnsavedChangesAlert.ts | 35 +++++++++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 sources/utils/promptUnsavedChangesAlert.test.ts create mode 100644 sources/utils/promptUnsavedChangesAlert.ts diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 779039cd1..d57fd69a2 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -13,6 +13,7 @@ import { useSettingMutable } from '@/sync/storage'; import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { Modal } from '@/modal'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; export default React.memo(function ProfileEditScreen() { const { theme } = useUnistyles(); @@ -34,6 +35,7 @@ export default React.memo(function ProfileEditScreen() { const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); const [isDirty, setIsDirty] = React.useState(false); const isDirtyRef = React.useRef(false); + const saveRef = React.useRef<(() => void) | null>(null); React.useEffect(() => { isDirtyRef.current = isDirty; @@ -75,12 +77,18 @@ export default React.memo(function ProfileEditScreen() { }, [cloneFromProfileIdParam, profileDataParam, profileIdParam, profiles]); const confirmDiscard = React.useCallback(async () => { - return Modal.confirm( - t('common.discardChanges'), - t('common.unsavedChangesWarning'), - { destructive: true, confirmText: t('common.discard'), cancelText: t('common.keepEditing') }, + const saveText = profile.isBuiltIn ? t('common.saveAs') : t('common.save'); + return promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), + { + title: t('common.discardChanges'), + message: t('common.unsavedChangesWarning'), + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), + }, ); - }, []); + }, [profile.isBuiltIn]); React.useEffect(() => { const subscription = (navigation as any)?.addListener?.('beforeRemove', (e: any) => { @@ -89,10 +97,12 @@ export default React.memo(function ProfileEditScreen() { e.preventDefault(); void (async () => { - const discard = await confirmDiscard(); - if (discard) { + const decision = await confirmDiscard(); + if (decision === 'discard') { isDirtyRef.current = false; (navigation as any).dispatch(e.data.action); + } else if (decision === 'save') { + saveRef.current?.(); } })(); }); @@ -173,10 +183,12 @@ export default React.memo(function ProfileEditScreen() { router.back(); return; } - const discard = await confirmDiscard(); - if (discard) { + const decision = await confirmDiscard(); + if (decision === 'discard') { isDirtyRef.current = false; router.back(); + } else if (decision === 'save') { + saveRef.current?.(); } })(); }, [confirmDiscard, router]); @@ -205,6 +217,7 @@ export default React.memo(function ProfileEditScreen() { onSave={handleSave} onCancel={handleCancel} onDirtyChange={setIsDirty} + saveRef={saveRef} /> diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index baa26f9b6..635542097 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -26,6 +26,7 @@ export interface ProfileEditFormProps { onCancel: () => void; onDirtyChange?: (isDirty: boolean) => void; containerStyle?: ViewStyle; + saveRef?: React.MutableRefObject<(() => void) | null>; } interface MachinePreviewModalProps { @@ -119,6 +120,7 @@ export function ProfileEditForm({ onCancel, onDirtyChange, containerStyle, + saveRef, }: ProfileEditFormProps) { const { theme, rt } = useUnistyles(); const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; @@ -283,6 +285,16 @@ export function ProfileEditForm({ useTmux, ]); + React.useEffect(() => { + if (!saveRef) { + return; + } + saveRef.current = handleSave; + return () => { + saveRef.current = null; + }; + }, [handleSave, saveRef]); + return ( diff --git a/sources/utils/promptUnsavedChangesAlert.test.ts b/sources/utils/promptUnsavedChangesAlert.test.ts new file mode 100644 index 000000000..45e1b6095 --- /dev/null +++ b/sources/utils/promptUnsavedChangesAlert.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import type { AlertButton } from '@/modal/types'; +import { promptUnsavedChangesAlert } from './promptUnsavedChangesAlert'; + +describe('promptUnsavedChangesAlert', () => { + it('resolves to save when the Save button is pressed', async () => { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', + }); + + lastButtons?.find((b) => b.text === 'Save')?.onPress?.(); + + await expect(promise).resolves.toBe('save'); + }); +}); + diff --git a/sources/utils/promptUnsavedChangesAlert.ts b/sources/utils/promptUnsavedChangesAlert.ts new file mode 100644 index 000000000..867580f3a --- /dev/null +++ b/sources/utils/promptUnsavedChangesAlert.ts @@ -0,0 +1,35 @@ +import type { AlertButton } from '@/modal/types'; + +export type UnsavedChangesDecision = 'discard' | 'save' | 'keepEditing'; + +export function promptUnsavedChangesAlert( + alert: (title: string, message?: string, buttons?: AlertButton[]) => void, + params: { + title: string; + message: string; + discardText: string; + saveText: string; + keepEditingText: string; + }, +): Promise { + return new Promise((resolve) => { + alert(params.title, params.message, [ + { + text: params.discardText, + style: 'destructive', + onPress: () => resolve('discard'), + }, + { + text: params.saveText, + style: 'default', + onPress: () => resolve('save'), + }, + { + text: params.keepEditingText, + style: 'cancel', + onPress: () => resolve('keepEditing'), + }, + ]); + }); +} + From dba0855930b8137fac6cbc19b1363e64eb892916 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 18:13:19 +0100 Subject: [PATCH 088/106] fix(profiles): allow favoriting default environment - Support favoriting the default environment (empty profile id).\n- Avoid selection snaps after row actions by ignoring the next row press.\n- Stop auto-overriding selection from lastUsedProfile. --- sources/app/(app)/new/index.tsx | 113 +++++++++++++++-------- sources/app/(app)/new/pick/profile.tsx | 79 +++++++++++++--- sources/sync/profileGrouping.test.ts | 5 +- sources/sync/profileGrouping.ts | 5 - sources/utils/ignoreNextRowPress.test.ts | 18 ++++ sources/utils/ignoreNextRowPress.ts | 7 ++ 6 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 sources/utils/ignoreNextRowPress.test.ts create mode 100644 sources/utils/ignoreNextRowPress.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index e689f17e3..7ebea7b9f 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -38,8 +38,10 @@ import { EnvironmentVariablesPreviewModal } from '@/components/newSession/Enviro import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; import { buildProfileActions } from '@/components/profileActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; import { consumeProfileIdParam } from '@/profileRouteParams'; import { getModelOptionsForAgentType } from '@/sync/modelOptions'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -314,6 +316,8 @@ function NewSessionWizard() { return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); }, [favoriteProfileIds, profiles]); + const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); + const toggleFavoriteProfile = React.useCallback((profileId: string) => { setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); }, [favoriteProfileIds, setFavoriteProfileIds]); @@ -342,20 +346,6 @@ function NewSessionWizard() { } }, [useProfiles, selectedProfileId]); - // If a new profile is created while this screen is open (e.g. "Save As" from a built-in profile), - // `lastUsedProfile` is updated. Keep the selection in sync so the new profile is immediately active. - React.useEffect(() => { - if (!useProfiles) { - return; - } - if (!lastUsedProfile || !profileMap.has(lastUsedProfile)) { - return; - } - if (selectedProfileId === lastUsedProfile) { - return; - } - setSelectedProfileId(lastUsedProfile); - }, [lastUsedProfile, profileMap, selectedProfileId, useProfiles]); const allowGemini = experimentsEnabled; const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { @@ -988,6 +978,41 @@ function NewSessionWizard() { return ; }, []); + const renderDefaultEnvironmentRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavoriteProfile(''), + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [isDefaultEnvironmentFavorite, selectedIndicatorColor, theme.colors.textSecondary, toggleFavoriteProfile]); + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; @@ -1019,7 +1044,7 @@ function NewSessionWizard() { compactActionIds={['favorite', ...(envVarCount > 0 ? ['envVars'] : [])]} iconSize={20} onActionPressIn={() => { - ignoreProfileRowPressRef.current = true; + ignoreNextRowPress(ignoreProfileRowPressRef); }} /> @@ -1534,8 +1559,26 @@ function NewSessionWizard() { Select an AI profile to apply environment variables and defaults to your session. - {favoriteProfileItems.length > 0 && ( + {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( + {isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setSelectedProfileId(null); + }} + rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} + showDivider={favoriteProfileItems.length > 0} + /> + )} {favoriteProfileItems.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; @@ -1598,26 +1641,24 @@ function NewSessionWizard() { )} - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - rightElement={!selectedProfileId - ? ( - - - - ) - : null} - showDivider={nonFavoriteBuiltInProfiles.length > 0} - /> + {!isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setSelectedProfileId(null); + }} + rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + )} {nonFavoriteBuiltInProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 017240cac..49d25b17f 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -14,6 +14,8 @@ import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompati import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; import { buildProfileActions } from '@/components/profileActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; export default React.memo(function ProfilePickerScreen() { const { theme, rt } = useUnistyles(); @@ -109,6 +111,8 @@ export default React.memo(function ProfilePickerScreen() { return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); }, [favoriteProfileIds, profiles]); + const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); + const toggleFavoriteProfile = React.useCallback((profileId: string) => { setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); }, [favoriteProfileIds, setFavoriteProfileIds]); @@ -168,7 +172,7 @@ export default React.memo(function ProfilePickerScreen() { compactActionIds={['favorite', 'edit']} iconSize={20} onActionPressIn={() => { - ignoreProfileRowPressRef.current = true; + ignoreNextRowPress(ignoreProfileRowPressRef); }} /> @@ -184,6 +188,41 @@ export default React.memo(function ProfilePickerScreen() { ], ); + const renderDefaultEnvironmentRowRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavoriteProfile(''), + color: isFavorite ? theme.colors.text : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [isDefaultEnvironmentFavorite, theme.colors.text, theme.colors.textSecondary, toggleFavoriteProfile]); + return ( <> ) : ( <> - {favoriteProfileItems.length > 0 && ( + {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( + {isDefaultEnvironmentFavorite && ( + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} + showDivider={favoriteProfileItems.length > 0} + /> + )} {favoriteProfileItems.map((profile, index) => { const isSelected = selectedId === profile.id; const isLast = index === favoriteProfileItems.length - 1; @@ -258,18 +309,18 @@ export default React.memo(function ProfilePickerScreen() { )} - } - onPress={() => handleProfileRowPress('')} - showChevron={false} - selected={selectedId === ''} - rightElement={selectedId === '' - ? - : null} - showDivider={nonFavoriteBuiltInProfiles.length > 0} - /> + {!isDefaultEnvironmentFavorite && ( + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + )} {nonFavoriteBuiltInProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; const isLast = index === nonFavoriteBuiltInProfiles.length - 1; diff --git a/sources/sync/profileGrouping.test.ts b/sources/sync/profileGrouping.test.ts index 9687a30d5..ef8a86a44 100644 --- a/sources/sync/profileGrouping.test.ts +++ b/sources/sync/profileGrouping.test.ts @@ -10,7 +10,8 @@ describe('toggleFavoriteProfileId', () => { expect(toggleFavoriteProfileId(['anthropic', 'openai'], 'anthropic')).toEqual(['openai']); }); - it('ignores empty ids and does not allow favoriting the empty profile id', () => { - expect(toggleFavoriteProfileId(['', 'anthropic', 'anthropic', 'openai'], '')).toEqual(['anthropic', 'openai']); + it('supports favoriting the default environment (empty profile id)', () => { + expect(toggleFavoriteProfileId(['anthropic'], '')).toEqual(['', 'anthropic']); + expect(toggleFavoriteProfileId(['', 'anthropic'], '')).toEqual(['anthropic']); }); }); diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts index feb3880ef..8e22ebe97 100644 --- a/sources/sync/profileGrouping.ts +++ b/sources/sync/profileGrouping.ts @@ -17,16 +17,11 @@ export function toggleFavoriteProfileId(favoriteProfileIds: string[], profileId: const normalized: string[] = []; const seen = new Set(); for (const id of favoriteProfileIds) { - if (!id) continue; if (seen.has(id)) continue; seen.add(id); normalized.push(id); } - if (!profileId) { - return normalized; - } - if (seen.has(profileId)) { return normalized.filter((id) => id !== profileId); } diff --git a/sources/utils/ignoreNextRowPress.test.ts b/sources/utils/ignoreNextRowPress.test.ts new file mode 100644 index 000000000..487702dab --- /dev/null +++ b/sources/utils/ignoreNextRowPress.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ignoreNextRowPress } from './ignoreNextRowPress'; + +describe('ignoreNextRowPress', () => { + it('resets the ignore flag on the next tick', () => { + vi.useFakeTimers(); + const ref = { current: false }; + + ignoreNextRowPress(ref); + expect(ref.current).toBe(true); + + vi.runAllTimers(); + expect(ref.current).toBe(false); + + vi.useRealTimers(); + }); +}); + diff --git a/sources/utils/ignoreNextRowPress.ts b/sources/utils/ignoreNextRowPress.ts new file mode 100644 index 000000000..55c95e473 --- /dev/null +++ b/sources/utils/ignoreNextRowPress.ts @@ -0,0 +1,7 @@ +export function ignoreNextRowPress(ref: { current: boolean }): void { + ref.current = true; + setTimeout(() => { + ref.current = false; + }, 0); +} + From 919c46ed50a86c28b3be0ea16f40a854e209c4e8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 22:01:37 +0100 Subject: [PATCH 089/106] fix(sync): avoid settings downgrades and normalize favorites --- sources/sync/permissionMapping.ts | 4 +--- sources/sync/profileGrouping.test.ts | 29 +++++++++++++++++++++++++++- sources/sync/profileGrouping.ts | 7 ++++++- sources/sync/sync.ts | 4 ++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/sources/sync/permissionMapping.ts b/sources/sync/permissionMapping.ts index fbbb3a882..eab3cdc11 100644 --- a/sources/sync/permissionMapping.ts +++ b/sources/sync/permissionMapping.ts @@ -1,6 +1,5 @@ import type { PermissionMode } from './permissionTypes'; - -type AgentType = 'claude' | 'codex' | 'gemini'; +import type { AgentType } from './modelOptions'; function isCodexLike(agent: AgentType) { return agent === 'codex' || agent === 'gemini'; @@ -49,4 +48,3 @@ export function mapPermissionModeAcrossAgents( return 'default'; } } - diff --git a/sources/sync/profileGrouping.test.ts b/sources/sync/profileGrouping.test.ts index ef8a86a44..5a08b3ac5 100644 --- a/sources/sync/profileGrouping.test.ts +++ b/sources/sync/profileGrouping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { toggleFavoriteProfileId } from './profileGrouping'; +import { buildProfileGroups, toggleFavoriteProfileId } from './profileGrouping'; describe('toggleFavoriteProfileId', () => { it('adds the profile id to the front when missing', () => { @@ -15,3 +15,30 @@ describe('toggleFavoriteProfileId', () => { expect(toggleFavoriteProfileId(['', 'anthropic'], '')).toEqual(['anthropic']); }); }); + +describe('buildProfileGroups', () => { + it('filters favoriteIds to resolvable profiles (preserves default environment favorite)', () => { + const customProfiles = [ + { + id: 'custom-profile', + name: 'Custom Profile', + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + ]; + + const groups = buildProfileGroups({ + customProfiles, + favoriteProfileIds: ['', 'anthropic', 'missing-profile', 'custom-profile'], + }); + + expect(groups.favoriteIds.has('')).toBe(true); + expect(groups.favoriteIds.has('anthropic')).toBe(true); + expect(groups.favoriteIds.has('custom-profile')).toBe(true); + expect(groups.favoriteIds.has('missing-profile')).toBe(false); + }); +}); diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts index 8e22ebe97..d493bc7d9 100644 --- a/sources/sync/profileGrouping.ts +++ b/sources/sync/profileGrouping.ts @@ -37,7 +37,6 @@ export function buildProfileGroups({ 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)); @@ -45,6 +44,12 @@ export function buildProfileGroups({ .map((id) => customById.get(id) ?? getBuiltInProfile(id)) .filter(isProfile); + const favoriteIds = new Set(favoriteProfiles.map((profile) => profile.id)); + // Preserve "default environment" favorite marker (not a real profile object). + if (favoriteProfileIds.includes('')) { + favoriteIds.add(''); + } + const nonFavoriteCustomProfiles = customProfiles.filter((profile) => !favoriteIds.has(profile.id)); const nonFavoriteBuiltInProfiles = DEFAULT_PROFILES diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 4fcafb4fb..e2c43a708 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1172,7 +1172,7 @@ class Sync { const mergedSettings = applySettings(serverSettings, this.pendingSettings); // Update local storage with merged result at server's version - storage.getState().replaceSettings(mergedSettings, data.currentVersion); + storage.getState().applySettings(mergedSettings, data.currentVersion); // Sync tracking state with merged settings if (tracking) { @@ -1217,7 +1217,7 @@ class Sync { } // Apply settings to storage - storage.getState().replaceSettings(parsedSettings, data.settingsVersion); + storage.getState().applySettings(parsedSettings, data.settingsVersion); // Sync PostHog opt-out state with settings if (tracking) { From 12d43864e406f7afa0793493a6509821ef2935a3 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 22:01:48 +0100 Subject: [PATCH 090/106] fix(sync): persist session model mode across restarts --- sources/sync/persistence.test.ts | 52 ++++++++++++++++++++++++++++++++ sources/sync/persistence.ts | 42 ++++++++++++++++++++++++++ sources/sync/storage.ts | 25 +++++++++++++-- 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 sources/sync/persistence.test.ts diff --git a/sources/sync/persistence.test.ts b/sources/sync/persistence.test.ts new file mode 100644 index 000000000..08f21e9ba --- /dev/null +++ b/sources/sync/persistence.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = new Map(); + +vi.mock('react-native-mmkv', () => { + class MMKV { + getString(key: string) { + return store.get(key); + } + + set(key: string, value: string) { + store.set(key, value); + } + + delete(key: string) { + store.delete(key); + } + + clearAll() { + store.clear(); + } + } + + return { MMKV }; +}); + +import { clearPersistence, loadSessionModelModes, saveSessionModelModes } from './persistence'; + +describe('persistence', () => { + beforeEach(() => { + clearPersistence(); + }); + + describe('session model modes', () => { + it('returns an empty object when nothing is persisted', () => { + expect(loadSessionModelModes()).toEqual({}); + }); + + it('roundtrips session model modes', () => { + saveSessionModelModes({ abc: 'gemini-2.5-pro' }); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + + it('filters out invalid persisted model modes', () => { + store.set( + 'session-model-modes', + JSON.stringify({ abc: 'gemini-2.5-pro', bad: 'adaptiveUsage' }), + ); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + }); +}); diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 4baef71a7..8a245eb01 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -3,6 +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 { Session } from './storageTypes'; import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; const mmkv = new MMKV(); @@ -11,6 +12,19 @@ const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; export type NewSessionAgentType = 'claude' | 'codex' | 'gemini'; export type NewSessionSessionType = 'simple' | 'worktree'; +type SessionModelMode = NonNullable; + +const SESSION_MODEL_MODES = new Set([ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +]); + +function isSessionModelMode(value: unknown): value is SessionModelMode { + return typeof value === 'string' && SESSION_MODEL_MODES.has(value as SessionModelMode); +} + export interface NewSessionDraft { input: string; selectedMachineId: string | null; @@ -197,6 +211,34 @@ export function saveSessionPermissionModes(modes: Record mmkv.set('session-permission-modes', JSON.stringify(modes)); } +export function loadSessionModelModes(): Record { + const modes = mmkv.getString('session-model-modes'); + if (modes) { + try { + const parsed: unknown = JSON.parse(modes); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + + const result: Record = {}; + Object.entries(parsed as Record).forEach(([sessionId, mode]) => { + if (isSessionModelMode(mode)) { + result[sessionId] = mode; + } + }); + return result; + } catch (e) { + console.error('Failed to parse session model modes', e); + return {}; + } + } + return {}; +} + +export function saveSessionModelModes(modes: Record) { + mmkv.set('session-model-modes', JSON.stringify(modes)); +} + export function loadProfile(): Profile { const profile = mmkv.getString('profile'); if (profile) { diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index 8c76be584..83d5c716d 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -11,7 +11,7 @@ import { Purchases, customerInfoToPurchases } from "./purchases"; import { TodoState } from "../-zen/model/ops"; import { Profile } from "./profile"; import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; -import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence"; +import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionModelModes, saveSessionModelModes } from "./persistence"; import type { PermissionMode } from '@/sync/permissionTypes'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; @@ -46,6 +46,8 @@ function isSessionActive(session: { active: boolean; activeAt: number }): boolea // Known entitlement IDs export type KnownEntitlements = 'pro'; +type SessionModelMode = NonNullable; + interface SessionMessages { messages: Message[]; messagesMap: Record; @@ -251,6 +253,7 @@ export const storage = create()((set, get) => { let profile = loadProfile(); let sessionDrafts = loadSessionDrafts(); let sessionPermissionModes = loadSessionPermissionModes(); + let sessionModelModes = loadSessionModelModes(); return { settings, settingsVersion: version, @@ -304,6 +307,7 @@ export const storage = create()((set, get) => { // Load drafts and permission modes if sessions are empty (initial load) const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {}; const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {}; + const savedModelModes = Object.keys(state.sessions).length === 0 ? sessionModelModes : {}; // Merge new sessions with existing ones const mergedSessions: Record = { ...state.sessions }; @@ -318,11 +322,14 @@ export const storage = create()((set, get) => { const savedDraft = savedDrafts[session.id]; const existingPermissionMode = state.sessions[session.id]?.permissionMode; const savedPermissionMode = savedPermissionModes[session.id]; + const existingModelMode = state.sessions[session.id]?.modelMode; + const savedModelMode = savedModelModes[session.id]; mergedSessions[session.id] = { ...session, presence, draft: existingDraft || savedDraft || session.draft || null, - permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default' + permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default', + modelMode: existingModelMode || savedModelMode || session.modelMode || 'default', }; }); @@ -818,6 +825,16 @@ export const storage = create()((set, get) => { } }; + // Collect all model modes for persistence (only non-default values to save space) + const allModes: Record = {}; + Object.entries(updatedSessions).forEach(([id, sess]) => { + if (sess.modelMode && sess.modelMode !== 'default') { + allModes[id] = sess.modelMode; + } + }); + + saveSessionModelModes(allModes); + // No need to rebuild sessionListViewData since model mode doesn't affect the list display return { ...state, @@ -926,6 +943,10 @@ export const storage = create()((set, get) => { const modes = loadSessionPermissionModes(); delete modes[sessionId]; saveSessionPermissionModes(modes); + + const modelModes = loadSessionModelModes(); + delete modelModes[sessionId]; + saveSessionModelModes(modelModes); // Rebuild sessionListViewData without the deleted session const sessionListViewData = buildSessionListViewData(remainingSessions); From 8630a030e9f88c22a1a3d58bc07a71c7544881ac Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 22:02:09 +0100 Subject: [PATCH 091/106] refactor(ui): localize selectors and move styles to unistyles --- sources/app/(app)/new/pick/machine.tsx | 38 ++-- sources/app/(app)/new/pick/profile.tsx | 36 ++-- sources/components/AgentInput.tsx | 4 +- .../components/EnvironmentVariableCard.tsx | 18 +- .../components/EnvironmentVariablesList.tsx | 7 +- sources/components/Item.tsx | 38 ++-- sources/components/ItemActionsMenuModal.tsx | 68 ++++--- .../EnvironmentVariablesPreviewModal.tsx | 114 ++++++----- .../components/newSession/MachineSelector.tsx | 3 +- .../components/newSession/PathSelector.tsx | 128 ++++++------ .../newSession/ProfileCompatibilityIcon.tsx | 2 +- sources/modal/components/BaseModal.tsx | 2 +- sources/modal/components/CustomModal.tsx | 2 +- sources/modal/components/WebAlertModal.tsx | 185 +++++++++--------- sources/profileRouteParams.test.ts | 7 + sources/sync/modelOptions.ts | 27 ++- sources/text/translations/ca.ts | 33 ++++ sources/text/translations/en.ts | 33 ++++ sources/text/translations/es.ts | 33 ++++ sources/text/translations/it.ts | 33 ++++ sources/text/translations/ja.ts | 33 ++++ sources/text/translations/pl.ts | 33 ++++ sources/text/translations/pt.ts | 33 ++++ sources/text/translations/ru.ts | 33 ++++ sources/text/translations/zh-Hans.ts | 33 ++++ sources/utils/ignoreNextRowPress.test.ts | 17 +- .../utils/promptUnsavedChangesAlert.test.ts | 41 +++- 27 files changed, 733 insertions(+), 301 deletions(-) diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index 420b4a2e9..8c5acc3a0 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -8,25 +8,6 @@ import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; import { MachineSelector } from '@/components/newSession/MachineSelector'; -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, -})); - export default React.memo(function MachinePickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; @@ -135,3 +116,22 @@ export default React.memo(function MachinePickerScreen() { ); }); + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.groupped.background, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, +})); diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 49d25b17f..4f5d043ab 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -7,7 +7,7 @@ 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 { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { AIBackendProfile } from '@/sync/settings'; import { Modal } from '@/modal'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; @@ -19,6 +19,7 @@ import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; export default React.memo(function ProfilePickerScreen() { const { theme, rt } = useUnistyles(); + const styles = stylesheet; const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const router = useRouter(); const navigation = useNavigation(); @@ -156,14 +157,14 @@ export default React.memo(function ProfilePickerScreen() { onDelete: () => handleDeleteProfile(profile), }); - return ( - - - + + - + + ); }); + +const stylesheet = StyleSheet.create(() => ({ + rowRightElement: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + indicatorSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, +})); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 4059e1141..c1fae191f 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -968,7 +968,9 @@ export const AgentInput = React.memo(React.forwardRef - {props.envVarsCount ? `Env Vars (${props.envVarsCount})` : 'Env Vars'} + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} )} diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 2e37ab4cc..65c8f8968 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -9,6 +9,7 @@ import { t } from '@/text'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; + index: number; machineId: string | null; machineName?: string | null; machineEnv?: Record; @@ -16,9 +17,9 @@ export interface EnvironmentVariableCardProps { expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) - onUpdate: (newValue: string) => void; - onDelete: () => void; - onDuplicate: () => void; + onUpdate: (index: number, newValue: string) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; } /** @@ -55,6 +56,7 @@ function parseVariableValue(value: string): { */ export function EnvironmentVariableCard({ variable, + index, machineId, machineName, machineEnv, @@ -100,7 +102,7 @@ export function EnvironmentVariableCard({ const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); - const [fallbackOperator] = React.useState(parsed.fallbackOperator); + const fallbackOperator = parsed.fallbackOperator; const remoteValue = machineEnv?.[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; @@ -115,9 +117,9 @@ export function EnvironmentVariableCard({ : defaultValue; if (newValue !== variable.value) { - onUpdate(newValue); + onUpdate(index, newValue); } - }, [useRemoteVariable, remoteVariableName, defaultValue, fallbackOperator, variable.value, onUpdate]); + }, [defaultValue, fallbackOperator, index, onUpdate, remoteVariableName, useRemoteVariable, variable.value]); // Determine status const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; @@ -170,13 +172,13 @@ export function EnvironmentVariableCard({ onDelete(index)} > onDuplicate(index)} > diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index 913fb9640..839c106b0 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -180,6 +180,7 @@ export function EnvironmentVariablesList({ handleUpdateVariable(index, newValue)} - onDelete={() => handleDeleteVariable(index)} - onDuplicate={() => handleDuplicateVariable(index)} + onUpdate={handleUpdateVariable} + onDelete={handleDeleteVariable} + onDuplicate={handleDuplicateVariable} /> ); })} diff --git a/sources/components/Item.tsx b/sources/components/Item.tsx index e761b3f98..9869a768b 100644 --- a/sources/components/Item.tsx +++ b/sources/components/Item.tsx @@ -113,7 +113,7 @@ 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'; @@ -198,11 +198,11 @@ export const Item = React.memo((props) => { // If copy is enabled and no onPress is provided, don't set a regular press handler // The copy will be handled by long press instead const handlePress = onPress; - - const isInteractive = handlePress || onLongPress || (copy && !isWeb); - const showAccessory = isInteractive && showChevron && !rightElement; - const chevronSize = (isIOS && !isWeb) ? 17 : 24; - const showSelectedBackground = !!selected && ((selectionContext?.selectableItemCount ?? 2) > 1); + + const 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; @@ -288,23 +288,23 @@ export const Item = React.memo((props) => { ); - if (isInteractive) { - return ( - [ - { - backgroundColor: pressed && isIOS && !isWeb - ? theme.colors.surfacePressedOverlay - : (showSelectedBackground ? theme.colors.surfaceSelected : '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 index 387a94b60..dc4cb1b42 100644 --- a/sources/components/ItemActionsMenuModal.tsx +++ b/sources/components/ItemActionsMenuModal.tsx @@ -1,10 +1,11 @@ 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 { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; +import { t } from '@/text'; export type ItemAction = { id: string; @@ -21,38 +22,51 @@ export interface ItemActionsMenuModalProps { onClose: () => void; } +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 420, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flexGrow: 0, + }, + scrollContent: { + paddingBottom: 12, + }, +})); + export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) { const { theme } = useUnistyles(); + const styles = stylesheet; const closeThen = React.useCallback((fn: () => void) => { props.onClose(); setTimeout(() => fn(), 0); - }, [props]); + }, [props.onClose]); return ( - - - + + + {props.title} @@ -65,8 +79,8 @@ export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) { - - + + {props.actions.map((action, idx) => ( ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingBottom: 16, + flexGrow: 1, + }, + section: { + paddingHorizontal: 16, + paddingTop: 12, + }, + descriptionText: { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + machineNameText: { + color: theme.colors.status.connected, + ...Typography.default('semiBold'), + }, + detailText: { + fontSize: 13, + ...Typography.default('semiBold'), + }, +})); + export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { const { theme } = useUnistyles(); + const styles = stylesheet; const { height: windowHeight } = useWindowDimensions(); const scrollRef = React.useRef(null); const scrollYRef = React.useRef(0); @@ -74,34 +128,11 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev return ( - - + + {title} @@ -116,25 +147,19 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev - - + + {t('profiles.environmentVariables.previewModal.descriptionPrefix')}{' '} {props.machineName ? ( - + {props.machineName} ) : ( @@ -145,14 +170,8 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev {envVarEntries.length === 0 ? ( - - + + {t('profiles.environmentVariables.previewModal.emptyMessage')} @@ -232,9 +251,8 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev copy={secret ? false : displayValue} detail={detailLabel} detailStyle={{ - fontSize: 13, color: detailColor, - ...Typography.default('semiBold'), + ...styles.detailText, }} rightElement={rightElement} showChevron={false} diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 593f55ebc..af79bc8c9 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -81,8 +81,9 @@ export function MachineSelector({ filterItem: (machine, searchText) => { const displayName = (machine.metadata?.displayName || '').toLowerCase(); const host = (machine.metadata?.host || '').toLowerCase(); + const id = machine.id.toLowerCase(); const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); + return displayName.includes(search) || host.includes(search) || id.includes(search); }, searchPlaceholder, recentSectionTitle, diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 8ad031882..72f071f52 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -8,8 +8,9 @@ import { SearchHeader } from '@/components/SearchHeader'; import { Typography } from '@/constants/Typography'; import { formatPathRelativeToHome } from '@/utils/sessionUtils'; import { resolveAbsolutePath } from '@/utils/pathUtils'; +import { t } from '@/text'; -export interface PathSelectorProps { +type PathSelectorBaseProps = { machineHomeDir: string; selectedPath: string; onChangeSelectedPath: (path: string) => void; @@ -18,11 +19,25 @@ export interface PathSelectorProps { recentPaths: string[]; usePickerSearch: boolean; searchVariant?: 'header' | 'group' | 'none'; - searchQuery?: string; - onChangeSearchQuery?: (text: string) => void; favoriteDirectories: string[]; onChangeFavoriteDirectories: (dirs: string[]) => void; -} +}; + +type PathSelectorControlledSearchProps = { + searchQuery: string; + onChangeSearchQuery: (text: string) => void; +}; + +type PathSelectorUncontrolledSearchProps = { + searchQuery?: undefined; + onChangeSearchQuery?: undefined; +}; + +export type PathSelectorProps = + & PathSelectorBaseProps + & (PathSelectorControlledSearchProps | PathSelectorUncontrolledSearchProps); + +const ITEM_RIGHT_GAP = 16; const stylesheet = StyleSheet.create((theme) => ({ pathInputContainer: { @@ -42,10 +57,22 @@ const stylesheet = StyleSheet.create((theme) => ({ borderWidth: 0.5, borderColor: theme.colors.divider, }, + searchHeaderContainer: { + backgroundColor: 'transparent', + borderBottomWidth: 0, + }, + rightElementRow: { + flexDirection: 'row', + alignItems: 'center', + gap: ITEM_RIGHT_GAP, + }, + iconSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, })); -const ITEM_RIGHT_GAP = 16; - export function PathSelector({ machineHomeDir, selectedPath, @@ -68,8 +95,9 @@ export function PathSelector({ const searchWasFocusedRef = useRef(false); const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); - const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; - const setSearchQuery = onChangeSearchQueryProp ?? setUncontrolledSearchQuery; + const isSearchQueryControlled = controlledSearchQuery !== undefined && onChangeSearchQueryProp !== undefined; + const searchQuery = isSearchQueryControlled ? controlledSearchQuery : uncontrolledSearchQuery; + const setSearchQuery = isSearchQueryControlled ? onChangeSearchQueryProp : setUncontrolledSearchQuery; const [submittedCustomPath, setSubmittedCustomPath] = useState(null); const suggestedPaths = useMemo(() => { @@ -227,8 +255,8 @@ export function PathSelector({ const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { return ( - - + + { const isFavorite = favoritePaths.includes(absolutePath); return ( - - + + )} - + {showSubmittedCustomPathRow && ( - + + {effectiveGroupSearchPlacement === 'recent' && ( { searchWasFocusedRef.current = true; }} onBlur={() => { searchWasFocusedRef.current = false; }} - containerStyle={{ - backgroundColor: 'transparent', - borderBottomWidth: 0, - }} + containerStyle={styles.searchHeaderContainer} /> )} {filteredRecentPaths.length === 0 ? ( + {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && ( { searchWasFocusedRef.current = true; }} onBlur={() => { searchWasFocusedRef.current = false; }} - containerStyle={{ - backgroundColor: 'transparent', - borderBottomWidth: 0, - }} + containerStyle={styles.searchHeaderContainer} /> )} {filteredFavoritePaths.length === 0 ? ( 0 && searchVariant !== 'group' && ( - + {filteredRecentPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; const isLast = index === filteredRecentPaths.length - 1; @@ -485,25 +509,22 @@ export function PathSelector({ )} {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && ( - + {effectiveGroupSearchPlacement === 'suggested' && ( { searchWasFocusedRef.current = true; }} onBlur={() => { searchWasFocusedRef.current = false; }} - containerStyle={{ - backgroundColor: 'transparent', - borderBottomWidth: 0, - }} + containerStyle={styles.searchHeaderContainer} /> )} {filteredSuggestedPaths.length === 0 ? ( 0 && searchVariant !== 'group' && ( - + {filteredSuggestedPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; const isLast = index === filteredSuggestedPaths.length - 1; @@ -552,21 +573,18 @@ export function PathSelector({ )} {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && ( - + { searchWasFocusedRef.current = true; }} onBlur={() => { searchWasFocusedRef.current = false; }} - containerStyle={{ - backgroundColor: 'transparent', - borderBottomWidth: 0, - }} + containerStyle={styles.searchHeaderContainer} /> ({ })); export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { - useUnistyles(); + useUnistyles(); // Subscribe to theme changes for re-render const styles = stylesheet; const experimentsEnabled = useSetting('experiments'); diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx index 8ff4ab56f..3d5702f5a 100644 --- a/sources/modal/components/BaseModal.tsx +++ b/sources/modal/components/BaseModal.tsx @@ -4,10 +4,10 @@ import { Modal, TouchableWithoutFeedback, Animated, - StyleSheet, KeyboardAvoidingView, Platform } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; // On web, stop events from propagating to expo-router's modal overlay // which intercepts clicks when it applies pointer-events: none to body diff --git a/sources/modal/components/CustomModal.tsx b/sources/modal/components/CustomModal.tsx index 0a92db046..d577a7fb5 100644 --- a/sources/modal/components/CustomModal.tsx +++ b/sources/modal/components/CustomModal.tsx @@ -38,7 +38,7 @@ function CommandPaletteWithAnimation({ config, onClose }: CustomModalProps) { }, [onClose]); return ( - + ); diff --git a/sources/modal/components/WebAlertModal.tsx b/sources/modal/components/WebAlertModal.tsx index 24cad9309..7bf0c3b4e 100644 --- a/sources/modal/components/WebAlertModal.tsx +++ b/sources/modal/components/WebAlertModal.tsx @@ -3,8 +3,8 @@ import { View, Text, Pressable } from 'react-native'; import { BaseModal } from './BaseModal'; import { AlertModalConfig, ConfirmModalConfig } from '../types'; import { Typography } from '@/constants/Typography'; -import { StyleSheet } from 'react-native'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; interface WebAlertModalProps { config: AlertModalConfig | ConfirmModalConfig; @@ -12,8 +12,85 @@ interface WebAlertModalProps { onConfirm?: (value: boolean) => void; } +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + borderRadius: 14, + width: 270, + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + content: { + paddingHorizontal: 16, + paddingTop: 20, + paddingBottom: 16, + alignItems: 'center', + }, + title: { + fontSize: 17, + textAlign: 'center', + color: theme.colors.text, + marginBottom: 4, + }, + message: { + fontSize: 13, + textAlign: 'center', + color: theme.colors.text, + marginTop: 4, + lineHeight: 18, + }, + buttonContainer: { + borderTopWidth: 1, + borderTopColor: theme.colors.divider, + }, + buttonRow: { + flexDirection: 'row', + }, + buttonColumn: { + flexDirection: 'column', + }, + button: { + flex: 1, + paddingVertical: 11, + alignItems: 'center', + justifyContent: 'center', + }, + buttonPressed: { + backgroundColor: theme.colors.divider, + }, + separatorVertical: { + width: 1, + backgroundColor: theme.colors.divider, + }, + separatorHorizontal: { + height: 1, + backgroundColor: theme.colors.divider, + }, + buttonText: { + fontSize: 17, + color: theme.colors.textLink, + }, + primaryText: { + color: theme.colors.text, + }, + cancelText: { + fontWeight: '400', + }, + destructiveText: { + color: theme.colors.textDestructive, + }, +})); + export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps) { - const { theme } = useUnistyles(); + useUnistyles(); + const styles = stylesheet; const isConfirm = config.type === 'confirm'; const handleButtonPress = (buttonIndex: number) => { @@ -27,92 +104,13 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps const buttons = isConfirm ? [ - { text: config.cancelText || 'Cancel', style: 'cancel' as const }, - { text: config.confirmText || 'OK', style: config.destructive ? 'destructive' as const : 'default' as const } + { text: config.cancelText || t('common.cancel'), style: 'cancel' as const }, + { text: config.confirmText || t('common.ok'), style: config.destructive ? 'destructive' as const : 'default' as const } ] - : config.buttons || [{ text: 'OK', style: 'default' as const }]; + : config.buttons || [{ text: t('common.ok'), style: 'default' as const }]; const buttonLayout = buttons.length === 3 ? 'twoPlusOne' : buttons.length > 3 ? 'column' : 'row'; - const styles = StyleSheet.create({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: 14, - width: 270, - overflow: 'hidden', - shadowColor: theme.colors.shadow.color, - shadowOffset: { - width: 0, - height: 2 - }, - shadowOpacity: 0.25, - shadowRadius: 4, - elevation: 5 - }, - content: { - paddingHorizontal: 16, - paddingTop: 20, - paddingBottom: 16, - alignItems: 'center' - }, - title: { - fontSize: 17, - textAlign: 'center', - color: theme.colors.text, - marginBottom: 4 - }, - message: { - fontSize: 13, - textAlign: 'center', - color: theme.colors.text, - marginTop: 4, - lineHeight: 18 - }, - buttonContainer: { - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - flexDirection: buttonLayout === 'row' ? 'row' : 'column' - }, - twoPlusOneRow: { - flexDirection: 'row' - }, - button: { - flex: 1, - paddingVertical: 11, - alignItems: 'center', - justifyContent: 'center' - }, - buttonPressed: { - backgroundColor: theme.colors.divider - }, - buttonSeparator: { - width: buttonLayout === 'row' ? 1 : undefined, - height: buttonLayout === 'row' ? undefined : 1, - backgroundColor: theme.colors.divider - }, - buttonSeparatorVertical: { - width: 1, - backgroundColor: theme.colors.divider - }, - buttonSeparatorHorizontal: { - height: 1, - backgroundColor: theme.colors.divider - }, - buttonText: { - fontSize: 17, - color: theme.colors.textLink - }, - primaryText: { - color: theme.colors.text - }, - cancelText: { - fontWeight: '400' - }, - destructiveText: { - color: theme.colors.textDestructive - } - }); - return ( @@ -129,7 +127,7 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps {buttonLayout === 'twoPlusOne' ? ( - + [ styles.button, @@ -147,7 +145,7 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps - + [ @@ -167,7 +165,7 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps - + [ @@ -188,10 +186,17 @@ export function WebAlertModal({ config, onClose, onConfirm }: WebAlertModalProps ) : ( - + {buttons.map((button, index) => ( - {index > 0 && } + {index > 0 && ( + + )} [ styles.button, diff --git a/sources/profileRouteParams.test.ts b/sources/profileRouteParams.test.ts index 7991655c7..166f0d0b3 100644 --- a/sources/profileRouteParams.test.ts +++ b/sources/profileRouteParams.test.ts @@ -36,4 +36,11 @@ describe('consumeProfileIdParam', () => { shouldClearParam: true, }); }); + + it('treats empty array params as missing', () => { + expect(consumeProfileIdParam({ profileIdParam: [], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); }); diff --git a/sources/sync/modelOptions.ts b/sources/sync/modelOptions.ts index 3732560b9..0278fd621 100644 --- a/sources/sync/modelOptions.ts +++ b/sources/sync/modelOptions.ts @@ -1,4 +1,5 @@ import type { ModelMode } from './permissionTypes'; +import { t } from '@/text'; export type AgentType = 'claude' | 'codex' | 'gemini'; @@ -8,13 +9,25 @@ export type ModelOption = Readonly<{ description: string; }>; -const GEMINI_MODEL_OPTIONS: readonly ModelOption[] = [ - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Most capable' }, - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, - { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, -]; - export function getModelOptionsForAgentType(agentType: AgentType): readonly ModelOption[] { - if (agentType === 'gemini') return GEMINI_MODEL_OPTIONS; + if (agentType === 'gemini') { + return [ + { + value: 'gemini-2.5-pro', + label: t('agentInput.geminiModel.gemini25Pro.label'), + description: t('agentInput.geminiModel.gemini25Pro.description'), + }, + { + value: 'gemini-2.5-flash', + label: t('agentInput.geminiModel.gemini25Flash.label'), + description: t('agentInput.geminiModel.gemini25Flash.description'), + }, + { + value: 'gemini-2.5-flash-lite', + label: t('agentInput.geminiModel.gemini25FlashLite.label'), + description: t('agentInput.geminiModel.gemini25FlashLite.description'), + }, + ]; + } return []; } diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index d7cc189c3..bdc3679e2 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -32,6 +32,7 @@ export const ca: TranslationStructure = { common: { // Simple string constants add: 'Afegeix', + actions: 'Accions', cancel: 'Cancel·la', authenticate: 'Autentica', save: 'Desa', @@ -300,6 +301,19 @@ export const ca: TranslationStructure = { allTitle: 'Totes', emptyMessage: 'No hi ha màquines disponibles', }, + pathPicker: { + enterPathTitle: 'Introdueix el camí', + enterPathPlaceholder: 'Introdueix un camí...', + customPathTitle: 'Camí personalitzat', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + suggestedTitle: 'Suggerits', + allTitle: 'Totes', + emptyRecent: 'No hi ha camins recents', + emptyFavorites: 'No hi ha camins preferits', + emptySuggested: 'No hi ha camins suggerits', + emptyAll: 'No hi ha camins', + }, sessionType: { title: 'Tipus de sessió', simple: 'Simple', @@ -416,6 +430,10 @@ export const ca: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Variables d\'entorn', + titleWithCount: ({ count }: { count: number }) => `Variables d'entorn (${count})`, + }, permissionMode: { title: 'MODE DE PERMISOS', default: 'Per defecte', @@ -465,6 +483,21 @@ export const ca: TranslationStructure = { badgeSafeYolo: 'YOLO segur', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Més capaç', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Ràpid i eficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Més ràpid', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restant`, }, diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index be1979ba1..a4979c8ec 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -45,6 +45,7 @@ export const en = { common: { // Simple string constants add: 'Add', + actions: 'Actions', cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', @@ -313,6 +314,19 @@ export const en = { allTitle: 'All', emptyMessage: 'No machines available', }, + pathPicker: { + enterPathTitle: 'Enter Path', + enterPathPlaceholder: 'Enter a path...', + customPathTitle: 'Custom Path', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + suggestedTitle: 'Suggested', + allTitle: 'All', + emptyRecent: 'No recent paths', + emptyFavorites: 'No favorite paths', + emptySuggested: 'No suggested paths', + emptyAll: 'No paths', + }, sessionType: { title: 'Session Type', simple: 'Simple', @@ -429,6 +443,10 @@ export const en = { }, agentInput: { + envVars: { + title: 'Env Vars', + titleWithCount: ({ count }: { count: number }) => `Env Vars (${count})`, + }, permissionMode: { title: 'PERMISSION MODE', default: 'Default', @@ -478,6 +496,21 @@ export const en = { badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Most capable', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Fast & efficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Fastest', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, }, diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index bd434fe42..5071035f9 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -32,6 +32,7 @@ export const es: TranslationStructure = { common: { // Simple string constants add: 'Añadir', + actions: 'Acciones', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Guardar', @@ -300,6 +301,19 @@ export const es: TranslationStructure = { allTitle: 'Todas', emptyMessage: 'No hay máquinas disponibles', }, + pathPicker: { + enterPathTitle: 'Ingresar ruta', + enterPathPlaceholder: 'Ingresa una ruta...', + customPathTitle: 'Ruta personalizada', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridas', + allTitle: 'Todas', + emptyRecent: 'No hay rutas recientes', + emptyFavorites: 'No hay rutas favoritas', + emptySuggested: 'No hay rutas sugeridas', + emptyAll: 'No hay rutas', + }, sessionType: { title: 'Tipo de sesión', simple: 'Simple', @@ -416,6 +430,10 @@ export const es: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Variables de entorno', + titleWithCount: ({ count }: { count: number }) => `Variables de entorno (${count})`, + }, permissionMode: { title: 'MODO DE PERMISOS', default: 'Por defecto', @@ -465,6 +483,21 @@ export const es: TranslationStructure = { badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Más capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido y eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Más rápido', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, }, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index d0fa31694..081b5b43a 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -32,6 +32,7 @@ export const it: TranslationStructure = { common: { // Simple string constants add: 'Aggiungi', + actions: 'Azioni', cancel: 'Annulla', authenticate: 'Autentica', save: 'Salva', @@ -433,6 +434,19 @@ export const it: TranslationStructure = { allTitle: 'Tutte', emptyMessage: 'Nessuna macchina disponibile', }, + pathPicker: { + enterPathTitle: 'Inserisci percorso', + enterPathPlaceholder: 'Inserisci un percorso...', + customPathTitle: 'Percorso personalizzato', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + suggestedTitle: 'Suggeriti', + allTitle: 'Tutte', + emptyRecent: 'Nessun percorso recente', + emptyFavorites: 'Nessun percorso preferito', + emptySuggested: 'Nessun percorso suggerito', + emptyAll: 'Nessun percorso', + }, sessionType: { title: 'Tipo di sessione', simple: 'Semplice', @@ -549,6 +563,10 @@ export const it: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Var env', + titleWithCount: ({ count }: { count: number }) => `Var env (${count})`, + }, permissionMode: { title: 'MODALITÀ PERMESSI', default: 'Predefinito', @@ -598,6 +616,21 @@ export const it: TranslationStructure = { badgeSafeYolo: 'YOLO sicuro', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODELLO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Il più potente', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Veloce ed efficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Il più veloce', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, }, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 68e4d5da2..6fd41a4a7 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -35,6 +35,7 @@ export const ja: TranslationStructure = { common: { // Simple string constants add: '追加', + actions: '操作', cancel: 'キャンセル', authenticate: '認証', save: '保存', @@ -436,6 +437,19 @@ export const ja: TranslationStructure = { allTitle: 'すべて', emptyMessage: '利用可能なマシンがありません', }, + pathPicker: { + enterPathTitle: 'パスを入力', + enterPathPlaceholder: 'パスを入力...', + customPathTitle: 'カスタムパス', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + suggestedTitle: 'おすすめ', + allTitle: 'すべて', + emptyRecent: '最近のパスはありません', + emptyFavorites: 'お気に入りのパスはありません', + emptySuggested: 'おすすめのパスはありません', + emptyAll: 'パスがありません', + }, sessionType: { title: 'セッションタイプ', simple: 'シンプル', @@ -552,6 +566,10 @@ export const ja: TranslationStructure = { }, agentInput: { + envVars: { + title: '環境変数', + titleWithCount: ({ count }: { count: number }) => `環境変数 (${count})`, + }, permissionMode: { title: '権限モード', default: 'デフォルト', @@ -601,6 +619,21 @@ export const ja: TranslationStructure = { badgeSafeYolo: 'セーフYOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINIモデル', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最高性能', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '高速・効率的', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最速', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `残り ${percent}%`, }, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index a396947d4..8861e3050 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -43,6 +43,7 @@ export const pl: TranslationStructure = { common: { // Simple string constants add: 'Dodaj', + actions: 'Akcje', cancel: 'Anuluj', authenticate: 'Uwierzytelnij', save: 'Zapisz', @@ -311,6 +312,19 @@ export const pl: TranslationStructure = { allTitle: 'Wszystkie', emptyMessage: 'Brak dostępnych maszyn', }, + pathPicker: { + enterPathTitle: 'Wpisz ścieżkę', + enterPathPlaceholder: 'Wpisz ścieżkę...', + customPathTitle: 'Niestandardowa ścieżka', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + suggestedTitle: 'Sugerowane', + allTitle: 'Wszystkie', + emptyRecent: 'Brak ostatnich ścieżek', + emptyFavorites: 'Brak ulubionych ścieżek', + emptySuggested: 'Brak sugerowanych ścieżek', + emptyAll: 'Brak ścieżek', + }, sessionType: { title: 'Typ sesji', simple: 'Prosta', @@ -426,6 +440,10 @@ export const pl: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Zmienne środowiskowe', + titleWithCount: ({ count }: { count: number }) => `Zmienne środowiskowe (${count})`, + }, permissionMode: { title: 'TRYB UPRAWNIEŃ', default: 'Domyślny', @@ -475,6 +493,21 @@ export const pl: TranslationStructure = { badgeSafeYolo: 'Bezpieczne YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Najbardziej zaawansowany', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Szybki i wydajny', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Najszybszy', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`, }, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 89446905e..3af690072 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -32,6 +32,7 @@ export const pt: TranslationStructure = { common: { // Simple string constants add: 'Adicionar', + actions: 'Ações', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Salvar', @@ -300,6 +301,19 @@ export const pt: TranslationStructure = { allTitle: 'Todas', emptyMessage: 'Nenhuma máquina disponível', }, + pathPicker: { + enterPathTitle: 'Inserir caminho', + enterPathPlaceholder: 'Insira um caminho...', + customPathTitle: 'Caminho personalizado', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridos', + allTitle: 'Todas', + emptyRecent: 'Nenhum caminho recente', + emptyFavorites: 'Nenhum caminho favorito', + emptySuggested: 'Nenhum caminho sugerido', + emptyAll: 'Nenhum caminho', + }, sessionType: { title: 'Tipo de sessão', simple: 'Simples', @@ -416,6 +430,10 @@ export const pt: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Vars env', + titleWithCount: ({ count }: { count: number }) => `Vars env (${count})`, + }, permissionMode: { title: 'MODO DE PERMISSÃO', default: 'Padrão', @@ -465,6 +483,21 @@ export const pt: TranslationStructure = { badgeSafeYolo: 'YOLO seguro', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Mais capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido e eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Mais rápido', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, }, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index c9b7dc89f..febab47a0 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -43,6 +43,7 @@ export const ru: TranslationStructure = { common: { // Simple string constants add: 'Добавить', + actions: 'Действия', cancel: 'Отмена', authenticate: 'Авторизация', save: 'Сохранить', @@ -282,6 +283,19 @@ export const ru: TranslationStructure = { allTitle: 'Все', emptyMessage: 'Нет доступных машин', }, + pathPicker: { + enterPathTitle: 'Введите путь', + enterPathPlaceholder: 'Введите путь...', + customPathTitle: 'Пользовательский путь', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + suggestedTitle: 'Рекомендуемые', + allTitle: 'Все', + emptyRecent: 'Нет недавних путей', + emptyFavorites: 'Нет избранных путей', + emptySuggested: 'Нет рекомендуемых путей', + emptyAll: 'Нет путей', + }, sessionType: { title: 'Тип сессии', simple: 'Простая', @@ -426,6 +440,10 @@ export const ru: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Переменные окружения', + titleWithCount: ({ count }: { count: number }) => `Переменные окружения (${count})`, + }, permissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', @@ -475,6 +493,21 @@ export const ru: TranslationStructure = { badgeSafeYolo: 'Безопасный YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Самая мощная', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Быстро и эффективно', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Самая быстрая', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`, }, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 25e29ee88..d4886d858 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -34,6 +34,7 @@ export const zhHans: TranslationStructure = { common: { // Simple string constants add: '添加', + actions: '操作', cancel: '取消', authenticate: '认证', save: '保存', @@ -302,6 +303,19 @@ export const zhHans: TranslationStructure = { allTitle: '全部', emptyMessage: '没有可用设备', }, + pathPicker: { + enterPathTitle: '输入路径', + enterPathPlaceholder: '输入路径...', + customPathTitle: '自定义路径', + recentTitle: '最近', + favoritesTitle: '收藏', + suggestedTitle: '推荐', + allTitle: '全部', + emptyRecent: '没有最近的路径', + emptyFavorites: '没有收藏的路径', + emptySuggested: '没有推荐的路径', + emptyAll: '没有路径', + }, sessionType: { title: '会话类型', simple: '简单', @@ -418,6 +432,10 @@ export const zhHans: TranslationStructure = { }, agentInput: { + envVars: { + title: '环境变量', + titleWithCount: ({ count }: { count: number }) => `环境变量 (${count})`, + }, permissionMode: { title: '权限模式', default: '默认', @@ -467,6 +485,21 @@ export const zhHans: TranslationStructure = { badgeSafeYolo: '安全 YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI 模型', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最强能力', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '快速且高效', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最快', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`, }, diff --git a/sources/utils/ignoreNextRowPress.test.ts b/sources/utils/ignoreNextRowPress.test.ts index 487702dab..807780c5b 100644 --- a/sources/utils/ignoreNextRowPress.test.ts +++ b/sources/utils/ignoreNextRowPress.test.ts @@ -4,15 +4,16 @@ import { ignoreNextRowPress } from './ignoreNextRowPress'; describe('ignoreNextRowPress', () => { it('resets the ignore flag on the next tick', () => { vi.useFakeTimers(); - const ref = { current: false }; + try { + const ref = { current: false }; - ignoreNextRowPress(ref); - expect(ref.current).toBe(true); + ignoreNextRowPress(ref); + expect(ref.current).toBe(true); - vi.runAllTimers(); - expect(ref.current).toBe(false); - - vi.useRealTimers(); + vi.runAllTimers(); + expect(ref.current).toBe(false); + } finally { + vi.useRealTimers(); + } }); }); - diff --git a/sources/utils/promptUnsavedChangesAlert.test.ts b/sources/utils/promptUnsavedChangesAlert.test.ts index 45e1b6095..74b92e50a 100644 --- a/sources/utils/promptUnsavedChangesAlert.test.ts +++ b/sources/utils/promptUnsavedChangesAlert.test.ts @@ -22,5 +22,44 @@ describe('promptUnsavedChangesAlert', () => { await expect(promise).resolves.toBe('save'); }); -}); + it('resolves to discard when the Discard button is pressed', async () => { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', + }); + + lastButtons?.find((b) => b.text === 'Discard')?.onPress?.(); + + await expect(promise).resolves.toBe('discard'); + }); + + it('resolves to keepEditing when the Keep editing button is pressed', async () => { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', + }); + + lastButtons?.find((b) => b.text === 'Keep editing')?.onPress?.(); + + await expect(promise).resolves.toBe('keepEditing'); + }); +}); From d41b07cef288d73739dc14e8a75ef064be9047cf Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 23:10:34 +0100 Subject: [PATCH 092/106] fix(ios): patch react-native-libsodium podspec --- ...re-tech+react-native-libsodium+1.5.5.patch | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 patches/@more-tech+react-native-libsodium+1.5.5.patch diff --git a/patches/@more-tech+react-native-libsodium+1.5.5.patch b/patches/@more-tech+react-native-libsodium+1.5.5.patch new file mode 100644 index 000000000..dbd45c3a1 --- /dev/null +++ b/patches/@more-tech+react-native-libsodium+1.5.5.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec b/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec +index 5dbd9f1..bc3da26 100644 +--- a/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec ++++ b/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec +@@ -30,7 +30,14 @@ Pod::Spec.new do |s| + } + s.dependency "React-Codegen" + if ENV['RCT_USE_RN_DEP'] != '1' +- s.dependency 'RCT-Folly', folly_version ++ # `folly_version` is not always defined during podspec evaluation ++ # (e.g. Expo/RN >= 0.81), so fall back to an unpinned dependency. ++ folly_ver = defined?(folly_version) ? folly_version : nil ++ if folly_ver ++ s.dependency 'RCT-Folly', folly_ver ++ else ++ s.dependency 'RCT-Folly' ++ end + end + s.dependency "RCTRequired" + s.dependency "RCTTypeSafety" From d740d8929a5c5a222a23c7c176967c5ababb7aaa Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 23:10:44 +0100 Subject: [PATCH 093/106] fix(env-vars): avoid fetching secrets and sync card state --- .../EnvironmentVariableCard.test.ts | 121 ++++++ .../components/EnvironmentVariableCard.tsx | 345 ++++++++++-------- .../EnvironmentVariablesList.test.ts | 128 +++++++ .../components/EnvironmentVariablesList.tsx | 178 +++++---- sources/types/react-test-renderer.d.ts | 1 + 5 files changed, 559 insertions(+), 214 deletions(-) create mode 100644 sources/components/EnvironmentVariableCard.test.ts create mode 100644 sources/components/EnvironmentVariablesList.test.ts create mode 100644 sources/types/react-test-renderer.d.ts diff --git a/sources/components/EnvironmentVariableCard.test.ts b/sources/components/EnvironmentVariableCard.test.ts new file mode 100644 index 000000000..793f78ad2 --- /dev/null +++ b/sources/components/EnvironmentVariableCard.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, + }, +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }), + }, +})); + +vi.mock('@/components/Switch', () => { + const React = require('react'); + return { + Switch: (props: unknown) => React.createElement('Switch', props), + }; +}); + +import { EnvironmentVariableCard } from './EnvironmentVariableCard'; + +describe('EnvironmentVariableCard', () => { + it('syncs remote-variable state when variable.value changes externally', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:-baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + expect(tree?.root.findByType('Switch').props.value).toBe(true); + + act(() => { + tree?.update( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: 'literal' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + expect(tree?.root.findByType('Switch').props.value).toBe(false); + }); +}); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 65c8f8968..dcbb82a4e 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, TextInput, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { Switch } from '@/components/Switch'; import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/envVarTemplate'; @@ -69,41 +69,21 @@ export function EnvironmentVariableCard({ onDuplicate, }: 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(), - }), []); + const styles = stylesheet; // Parse current value - const parsed = parseVariableValue(variable.value); + const parsed = React.useMemo(() => parseVariableValue(variable.value), [variable.value]); const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); const fallbackOperator = parsed.fallbackOperator; + React.useEffect(() => { + setUseRemoteVariable(parsed.useRemoteVariable); + setRemoteVariableName(parsed.remoteVariableName); + setDefaultValue(parsed.defaultValue); + }, [parsed.defaultValue, parsed.remoteVariableName, parsed.useRemoteVariable]); + const remoteValue = machineEnv?.[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); @@ -142,34 +122,22 @@ export function EnvironmentVariableCard({ : (computedTemplateValue || emptyValue)); return ( - + {/* Header row with variable name and action buttons */} - - + + {variable.name} {isSecret && ( - + )} - + onDelete(index)} @@ -187,39 +155,19 @@ export function EnvironmentVariableCard({ {/* Description */} {description && ( - + {description} )} {/* Value label */} - + {useRemoteVariable ? t('profiles.environmentVariables.card.fallbackValueLabel') : t('profiles.environmentVariables.card.valueLabel')} {/* Value input */} + {t('profiles.environmentVariables.card.secretNotRetrieved')} )} {/* Default override warning */} {showDefaultOverrideWarning && !isSecret && ( - + {t('profiles.environmentVariables.card.overridingDefault', { expectedValue })} )} - + {/* Toggle: Use value from machine environment */} - - + + {t('profiles.environmentVariables.card.useMachineEnvToggle')} - + {t('profiles.environmentVariables.card.resolvedOnSessionStart')} {/* Source variable name input (only when enabled) */} {useRemoteVariable && ( <> - + {t('profiles.environmentVariables.card.sourceVariableLabel')} + {isMachineEnvLoading || remoteValue === undefined ? ( - + {t('profiles.environmentVariables.card.checkingMachine', { machine: machineLabel })} ) : (remoteValue === null || remoteValue === '') ? ( - + {remoteValue === '' ? ( hasFallback ? t('profiles.environmentVariables.card.emptyOnMachineUsingFallback', { machine: machineLabel }) @@ -349,18 +259,11 @@ export function EnvironmentVariableCard({ ) : ( <> - + {t('profiles.environmentVariables.card.valueFoundOnMachine', { machine: machineLabel })} {showRemoteDiffersWarning && ( - + {t('profiles.environmentVariables.card.differsFromDocumented', { expectedValue })} )} @@ -370,11 +273,7 @@ export function EnvironmentVariableCard({ )} {/* Session preview */} - + {t('profiles.environmentVariables.preview.sessionWillReceive', { name: variable.name, value: resolvedSessionValue, @@ -383,3 +282,163 @@ export function EnvironmentVariableCard({ ); } + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '100%', + backgroundColor: theme.colors.surface, + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + nameText: { + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + lockIcon: { + marginLeft: 4, + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.margins.md, + }, + secondaryText: { + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + descriptionText: { + color: theme.colors.textSecondary, + marginBottom: 8, + }, + labelText: { + color: theme.colors.textSecondary, + marginBottom: 4, + }, + valueInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 4, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + secretMessage: { + color: theme.colors.textSecondary, + marginBottom: 8, + fontStyle: 'italic', + }, + defaultOverrideWarning: { + color: theme.colors.textSecondary, + marginBottom: 8, + }, + divider: { + height: 1, + backgroundColor: theme.colors.divider, + marginVertical: 12, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 6, + }, + toggleLabelText: { + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + toggleLabel: { + flex: 1, + color: theme.colors.textSecondary, + }, + resolvedOnStartText: { + color: theme.colors.textSecondary, + marginBottom: 0, + }, + resolvedOnStartWithRemote: { + marginBottom: 10, + }, + sourceLabel: { + color: theme.colors.textSecondary, + marginBottom: 4, + }, + sourceInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 6, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + machineStatusContainer: { + marginBottom: 8, + }, + machineStatusLoading: { + color: theme.colors.textSecondary, + fontStyle: 'italic', + }, + machineStatusWarning: { + color: theme.colors.warning, + }, + machineStatusSuccess: { + color: theme.colors.success, + }, + machineStatusDiffers: { + color: theme.colors.textSecondary, + marginTop: 2, + }, + sessionPreview: { + color: theme.colors.textSecondary, + marginTop: 4, + }, +})); diff --git a/sources/components/EnvironmentVariablesList.test.ts b/sources/components/EnvironmentVariablesList.test.ts new file mode 100644 index 000000000..3185bcacb --- /dev/null +++ b/sources/components/EnvironmentVariablesList.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; +import type { ProfileDocumentation } from '@/sync/profileUtils'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, + }, +})); + +const useEnvironmentVariablesMock = vi.fn((_machineId: any, _refs: any) => ({ + variables: {}, + isLoading: false, +})); + +vi.mock('@/hooks/useEnvironmentVariables', () => ({ + useEnvironmentVariables: (machineId: any, refs: any) => useEnvironmentVariablesMock(machineId, refs), +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }), + }, +})); + +vi.mock('@/components/Item', () => { + const React = require('react'); + return { + Item: (props: unknown) => React.createElement('Item', props), + }; +}); + +vi.mock('./EnvironmentVariableCard', () => { + const React = require('react'); + return { + EnvironmentVariableCard: (props: unknown) => React.createElement('EnvironmentVariableCard', props), + }; +}); + +import { EnvironmentVariablesList } from './EnvironmentVariablesList'; + +describe('EnvironmentVariablesList', () => { + beforeEach(() => { + useEnvironmentVariablesMock.mockClear(); + }); + + it('does not query machine env for documented secret refs', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret but name is not secret-like', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + act(() => { + renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [ + { name: 'FOO', value: '${MAGIC}' }, + { name: 'BAR', value: '${HOME}' }, + ], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); + const [_machineId, refs] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[]]; + expect(refs).toContain('HOME'); + expect(refs).not.toContain('MAGIC'); + }); +}); diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index 839c106b0..3e1e7436b 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, Pressable, TextInput, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { EnvironmentVariableCard } from './EnvironmentVariableCard'; import type { ProfileDocumentation } from '@/sync/profileUtils'; @@ -30,6 +30,7 @@ export function EnvironmentVariablesList({ onChange, }: EnvironmentVariablesListProps) { const { theme } = useUnistyles(); + const styles = stylesheet; // Extract variable name from a template value (for matching documentation / machine env lookup) const extractVarNameFromValue = React.useCallback((value: string): string | null => { @@ -39,6 +40,16 @@ export function EnvironmentVariablesList({ const SECRET_NAME_REGEX = React.useMemo(() => /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i, []); + const documentedSecretNames = React.useMemo(() => { + if (!profileDocs) return new Set(); + + return new Set( + profileDocs.environmentVariables + .filter((envVar) => envVar.isSecret) + .map((envVar) => envVar.name), + ); + }, [profileDocs]); + const resolvedEnvVarRefs = React.useMemo(() => { const refs = new Set(); environmentVariables.forEach((envVar) => { @@ -46,10 +57,11 @@ export function EnvironmentVariablesList({ 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; + if (documentedSecretNames.has(ref) || documentedSecretNames.has(envVar.name)) return; refs.add(ref); }); return Array.from(refs); - }, [SECRET_NAME_REGEX, environmentVariables, extractVarNameFromValue]); + }, [SECRET_NAME_REGEX, documentedSecretNames, environmentVariables, extractVarNameFromValue]); const { variables: machineEnv, isLoading: isMachineEnvLoading } = useEnvironmentVariables( machineId, @@ -61,19 +73,6 @@ export function EnvironmentVariablesList({ 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 }; @@ -147,27 +146,15 @@ export function EnvironmentVariablesList({ }, [environmentVariables, newVarName, newVarValue, onChange]); return ( - - - + + + {t('profiles.environmentVariables.title')} {environmentVariables.length > 0 && ( - + {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); @@ -197,17 +184,7 @@ export function EnvironmentVariablesList({ )} - + {showAddForm && ( - - + + - + ({ - backgroundColor: theme.colors.button.primary.background, - borderRadius: 10, - paddingVertical: 10, - alignItems: 'center', - opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1, - })} + style={({ pressed }) => [ + styles.addButton, + { opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1 }, + ]} > - + {t('common.add')} @@ -292,3 +250,81 @@ export function EnvironmentVariablesList({ ); } + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + marginBottom: 16, + }, + titleContainer: { + paddingTop: Platform.select({ ios: 35, default: 16 }), + paddingBottom: Platform.select({ ios: 6, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + titleText: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: '500', + }, + envVarListContainer: { + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + }, + addContainer: { + backgroundColor: theme.colors.surface, + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + borderRadius: Platform.select({ ios: 10, default: 16 }), + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + addFormContainer: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + addInputRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + marginBottom: 8, + }, + addInputRowLast: { + marginBottom: 12, + }, + addTextInput: { + flex: 1, + fontSize: 16, + color: theme.colors.input.text, + ...Typography.default('regular'), + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + addButton: { + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + }, + addButtonText: { + color: theme.colors.button.primary.tint, + ...Typography.default('semiBold'), + }, +})); diff --git a/sources/types/react-test-renderer.d.ts b/sources/types/react-test-renderer.d.ts new file mode 100644 index 000000000..e02674e5a --- /dev/null +++ b/sources/types/react-test-renderer.d.ts @@ -0,0 +1 @@ +declare module 'react-test-renderer'; From e955a0a95d3b215e7bb41cebb53801deae840106 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 23:11:11 +0100 Subject: [PATCH 094/106] fix(ui): address reviewer feedback --- sources/app/(app)/new/pick/profile.tsx | 51 ++++++++----- sources/components/AgentInput.tsx | 2 +- .../components/newSession/MachineSelector.tsx | 16 ++-- sources/text/translations/ja.ts | 10 --- .../utils/promptUnsavedChangesAlert.test.ts | 76 ++++++++----------- 5 files changed, 75 insertions(+), 80 deletions(-) diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 4f5d043ab..0063973c2 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -164,7 +164,7 @@ export default React.memo(function ProfilePickerScreen() { name="checkmark-circle" size={24} color={theme.colors.text} - style={{ opacity: isSelected ? 1 : 0 }} + style={isSelected ? styles.selectedIndicatorVisible : styles.selectedIndicatorHidden} /> - + {!useProfiles ? ( 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} - /> - ); - })} + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} @@ -358,6 +358,9 @@ export default React.memo(function ProfilePickerScreen() { }); const stylesheet = StyleSheet.create(() => ({ + itemList: { + paddingTop: 0, + }, rowRightElement: { flexDirection: 'row', alignItems: 'center', @@ -368,4 +371,10 @@ const stylesheet = StyleSheet.create(() => ({ alignItems: 'center', justifyContent: 'center', }, + selectedIndicatorVisible: { + opacity: 1, + }, + selectedIndicatorHidden: { + opacity: 0, + }, })); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index c1fae191f..30dce2819 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -554,7 +554,7 @@ export const AgentInput = React.memo(React.forwardRef config={{ diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 6fd41a4a7..2ae3ac5c8 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -7,16 +7,6 @@ import type { TranslationStructure } from '../_types'; -/** - * Japanese plural helper function - * Japanese doesn't have grammatical plurals, so this just returns the appropriate form - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - export const ja: TranslationStructure = { tabs: { // Tab navigation labels diff --git a/sources/utils/promptUnsavedChangesAlert.test.ts b/sources/utils/promptUnsavedChangesAlert.test.ts index 74b92e50a..85daab85f 100644 --- a/sources/utils/promptUnsavedChangesAlert.test.ts +++ b/sources/utils/promptUnsavedChangesAlert.test.ts @@ -1,64 +1,54 @@ import { describe, expect, it } from 'vitest'; import type { AlertButton } from '@/modal/types'; -import { promptUnsavedChangesAlert } from './promptUnsavedChangesAlert'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; -describe('promptUnsavedChangesAlert', () => { - it('resolves to save when the Save button is pressed', async () => { - let lastButtons: AlertButton[] | undefined; +const basePromptOptions = { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', +} as const; + +function createPromptHarness() { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, basePromptOptions); + + function press(text: string) { + const button = lastButtons?.find((b) => b.text === text); + expect(button).toBeDefined(); + button?.onPress?.(); + } - const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { - lastButtons = buttons; - }; + return { promise, press }; +} - const promise = promptUnsavedChangesAlert(alert, { - title: 'Discard changes', - message: 'You have unsaved changes.', - discardText: 'Discard', - saveText: 'Save', - keepEditingText: 'Keep editing', - }); +describe('promptUnsavedChangesAlert', () => { + it('resolves to save when the Save button is pressed', async () => { + const { promise, press } = createPromptHarness(); - lastButtons?.find((b) => b.text === 'Save')?.onPress?.(); + press('Save'); await expect(promise).resolves.toBe('save'); }); it('resolves to discard when the Discard button is pressed', async () => { - let lastButtons: AlertButton[] | undefined; - - const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { - lastButtons = buttons; - }; + const { promise, press } = createPromptHarness(); - const promise = promptUnsavedChangesAlert(alert, { - title: 'Discard changes', - message: 'You have unsaved changes.', - discardText: 'Discard', - saveText: 'Save', - keepEditingText: 'Keep editing', - }); - - lastButtons?.find((b) => b.text === 'Discard')?.onPress?.(); + press('Discard'); await expect(promise).resolves.toBe('discard'); }); it('resolves to keepEditing when the Keep editing button is pressed', async () => { - let lastButtons: AlertButton[] | undefined; - - const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { - lastButtons = buttons; - }; - - const promise = promptUnsavedChangesAlert(alert, { - title: 'Discard changes', - message: 'You have unsaved changes.', - discardText: 'Discard', - saveText: 'Save', - keepEditingText: 'Keep editing', - }); + const { promise, press } = createPromptHarness(); - lastButtons?.find((b) => b.text === 'Keep editing')?.onPress?.(); + press('Keep editing'); await expect(promise).resolves.toBe('keepEditing'); }); From c0e71624f498749a083d371c0e07013b41fd3028 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 08:13:28 +0100 Subject: [PATCH 095/106] fix(ui): address reviewer feedback - Fix env-var docs/secret handling when value references another var - Ensure env-var template fallback operator persists - Refactor AgentInput new UI styles into Unistyles stylesheet - Clean up profile picker imports/indentation - Replace untyped react-test-renderer shim with @types --- package.json | 1 + sources/app/(app)/new/pick/profile.tsx | 135 ++-- sources/components/AgentInput.tsx | 661 +++++++++--------- .../EnvironmentVariableCard.test.ts | 35 +- .../EnvironmentVariablesList.test.ts | 36 + .../components/EnvironmentVariablesList.tsx | 12 +- sources/types/react-test-renderer.d.ts | 1 - yarn.lock | 7 + 8 files changed, 470 insertions(+), 418 deletions(-) delete mode 100644 sources/types/react-test-renderer.d.ts diff --git a/package.json b/package.json index 591e10d3a..f7e4e46e1 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "@material/material-color-utilities": "^0.3.0", "@stablelib/hex": "^2.0.1", "@types/react": "~19.1.10", + "@types/react-test-renderer": "^19.1.0", "babel-plugin-transform-remove-console": "^6.9.4", "cross-env": "^10.1.0", "patch-package": "^8.0.0", diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 0063973c2..7e7a6bd3a 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; @@ -18,9 +18,8 @@ import type { ItemAction } from '@/components/ItemActionsMenuModal'; import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; export default React.memo(function ProfilePickerScreen() { - const { theme, rt } = useUnistyles(); + const { theme } = useUnistyles(); const styles = stylesheet; - const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const router = useRouter(); const navigation = useNavigation(); const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); @@ -144,10 +143,10 @@ export default React.memo(function ProfilePickerScreen() { ); }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); - const renderProfileRowRightElement = React.useCallback( - (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { - const actions = buildProfileActions({ - profile, + const renderProfileRowRightElement = React.useCallback( + (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const actions = buildProfileActions({ + profile, isFavorite, favoriteActionColor: theme.colors.text, nonFavoriteActionColor: theme.colors.textSecondary, @@ -157,28 +156,28 @@ export default React.memo(function ProfilePickerScreen() { onDelete: () => handleDeleteProfile(profile), }); - return ( - - - + + - - { - ignoreNextRowPress(ignoreProfileRowPressRef); - }} - /> - - ); - }, + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [ handleDeleteProfile, openProfileEdit, @@ -270,17 +269,17 @@ export default React.memo(function ProfilePickerScreen() { const isSelected = selectedId === profile.id; const isLast = index === favoriteProfileItems.length - 1; return ( - handleProfileRowPress(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={renderProfileRowRightElement(profile, isSelected, true)} - showDivider={!isLast} - /> + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> ); })} @@ -293,17 +292,17 @@ export default React.memo(function ProfilePickerScreen() { const isLast = index === nonFavoriteCustomProfiles.length - 1; const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - handleProfileRowPress(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -322,24 +321,24 @@ export default React.memo(function ProfilePickerScreen() { showDivider={nonFavoriteBuiltInProfiles.length > 0} /> )} - {nonFavoriteBuiltInProfiles.map((profile, index) => { - const isSelected = selectedId === profile.id; - const isLast = index === nonFavoriteBuiltInProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - handleProfileRowPress(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 30dce2819..ff8a5c2fa 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -238,14 +238,104 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ justifyContent: 'space-between', paddingHorizontal: 0, }, - actionButtonsLeft: { - flexDirection: 'row', - columnGap: 6, - rowGap: 3, - flex: 1, - flexWrap: 'wrap', - overflow: 'visible', - }, + actionButtonsColumn: { + flexDirection: 'column', + flex: 1, + gap: 3, + }, + actionButtonsColumnNarrow: { + flexDirection: 'column', + flex: 1, + gap: 2, + }, + actionButtonsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + actionButtonsLeft: { + flexDirection: 'row', + columnGap: 6, + rowGap: 3, + flex: 1, + flexWrap: 'wrap', + overflow: 'visible', + }, + actionButtonsLeftNarrow: { + columnGap: 4, + }, + actionChip: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + gap: 6, + }, + actionChipPressed: { + opacity: 0.7, + }, + actionChipText: { + fontSize: 13, + color: theme.colors.button.secondary.tint, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + overlayOptionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + }, + overlayOptionRowPressed: { + backgroundColor: theme.colors.surfacePressed, + }, + overlayRadioOuter: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + overlayRadioOuterSelected: { + borderColor: theme.colors.radio.active, + }, + overlayRadioOuterUnselected: { + borderColor: theme.colors.radio.inactive, + }, + overlayRadioInner: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.radio.dot, + }, + overlayOptionLabel: { + fontSize: 14, + color: theme.colors.text, + ...Typography.default(), + }, + overlayOptionLabelSelected: { + color: theme.colors.radio.active, + }, + overlayOptionLabelUnselected: { + color: theme.colors.text, + }, + overlayOptionDescription: { + fontSize: 11, + color: theme.colors.textSecondary, + ...Typography.default(), + }, + overlayEmptyText: { + fontSize: 13, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + ...Typography.default(), + }, actionButton: { flexDirection: 'row', alignItems: 'center', @@ -628,38 +718,29 @@ export const AgentInput = React.memo(React.forwardRef handleSettingsSelect(mode)} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} > - + {isSelected && ( - + )} - + {config.label} @@ -668,22 +749,11 @@ export const AgentInput = React.memo(React.forwardRef {/* Divider */} - + {/* Model Section */} - - + + {t('agentInput.model.title')} {modelOptions.length > 0 ? ( @@ -696,46 +766,35 @@ export const AgentInput = React.memo(React.forwardRef ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} > - + {isSelected && ( - + )} - + {option.label} - + {option.description} @@ -743,13 +802,7 @@ export const AgentInput = React.memo(React.forwardRef + {t('agentInput.model.configureInCli')} )} @@ -853,238 +906,175 @@ export const AgentInput = React.memo(React.forwardRef - - {/* 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, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {permissionChipLabel} - - - )} - - {/* Profile selector button - FIRST */} - {props.onProfileClick && ( - { - hapticsLight(); - props.onProfileClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {profileLabel ?? t('profiles.noProfile')} - - - )} - {/* 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: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.envVarsCount === undefined - ? t('agentInput.envVars.title') - : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} - - - )} + {/* 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) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {permissionChipLabel} + + + )} - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( - { - hapticsLight(); - props.onAgentClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} - - - )} + {/* Profile selector button - FIRST */} + {props.onProfileClick && ( + { + hapticsLight(); + props.onProfileClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {profileLabel ?? t('profiles.noProfile')} + + + )} - {/* Machine selector button */} - {(props.machineName !== undefined) && props.onMachineClick && ( - { - hapticsLight(); - props.onMachineClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.machineName === null - ? t('agentInput.noMachinesAvailable') - : truncateWithEllipsis(props.machineName, 12)} - - - )} - - {/* Abort button */} - {props.onAbort && ( - + {/* Env vars preview (standard flow) */} + {props.onEnvVarsClick && ( + { + hapticsLight(); + props.onEnvVarsClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} + + + )} + + {/* Agent selector button */} + {props.agentType && props.onAgentClick && ( + { + hapticsLight(); + props.onAgentClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.agentType === 'claude' + ? t('agentInput.agent.claude') + : props.agentType === 'codex' + ? t('agentInput.agent.codex') + : t('agentInput.agent.gemini')} + + + )} + + {/* Machine selector button */} + {(props.machineName !== undefined) && props.onMachineClick && ( ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} + onPress={() => { + hapticsLight(); + props.onMachineClick?.(); + }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={handleAbortPress} - disabled={isAborting} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} > - {isAborting ? ( - - ) : ( - - )} + + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + - - )} + )} + + {/* Abort button */} + {props.onAbort && ( + + [ + styles.actionButton, + p.pressed ? styles.actionButtonPressed : null, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleAbortPress} + disabled={isAborting} + > + {isAborting ? ( + + ) : ( + + )} + + + )} - {/* Git Status Badge */} - + {/* Git Status Badge */} + {/* Send/Voice button - aligned with first row */} @@ -1097,13 +1087,10 @@ export const AgentInput = React.memo(React.forwardRef ({ - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - opacity: p.pressed ? 0.7 : 1, - })} + style={(p) => [ + styles.sendButtonInner, + p.pressed ? styles.sendButtonInnerPressed : null, + ]} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} onPress={() => { hapticsLight(); @@ -1164,29 +1151,17 @@ export const AgentInput = React.memo(React.forwardRef ({ - 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, - })} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} > - - + + {props.currentPath} diff --git a/sources/components/EnvironmentVariableCard.test.ts b/sources/components/EnvironmentVariableCard.test.ts index 793f78ad2..d6cec5651 100644 --- a/sources/components/EnvironmentVariableCard.test.ts +++ b/sources/components/EnvironmentVariableCard.test.ts @@ -101,7 +101,7 @@ describe('EnvironmentVariableCard', () => { ); }); - expect(tree?.root.findByType('Switch').props.value).toBe(true); + expect(tree?.root.findByType('Switch' as any).props.value).toBe(true); act(() => { tree?.update( @@ -116,6 +116,37 @@ describe('EnvironmentVariableCard', () => { ); }); - expect(tree?.root.findByType('Switch').props.value).toBe(false); + expect(tree?.root.findByType('Switch' as any).props.value).toBe(false); + }); + + it('adds a fallback operator when user enters a fallback for a template without one', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.('baz'); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR:-baz}'); }); }); diff --git a/sources/components/EnvironmentVariablesList.test.ts b/sources/components/EnvironmentVariablesList.test.ts index 3185bcacb..d8c704d56 100644 --- a/sources/components/EnvironmentVariablesList.test.ts +++ b/sources/components/EnvironmentVariablesList.test.ts @@ -125,4 +125,40 @@ describe('EnvironmentVariablesList', () => { expect(refs).toContain('HOME'); expect(refs).not.toContain('MAGIC'); }); + + it('treats a documented-secret variable name as secret even when its value references another var', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + let tree: ReturnType | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [{ name: 'MAGIC', value: '${HOME}' }], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); + const [_machineId, refs] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[]]; + expect(refs).toEqual([]); + + const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.props.isSecret).toBe(true); + expect(cards?.[0]?.props.expectedValue).toBe('***'); + }); }); diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index 3e1e7436b..6b3f6123e 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -157,11 +157,15 @@ export function EnvironmentVariablesList({ {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); + const primaryDocs = getDocumentation(envVar.name); + const refDocs = varNameFromValue ? getDocumentation(varNameFromValue) : undefined; const isSecret = - docs.isSecret || + primaryDocs.isSecret || + refDocs?.isSecret || SECRET_NAME_REGEX.test(envVar.name) || SECRET_NAME_REGEX.test(varNameFromValue || ''); + const expectedValue = primaryDocs.expectedValue ?? refDocs?.expectedValue; + const description = primaryDocs.description ?? refDocs?.description; return ( Date: Sat, 17 Jan 2026 09:43:56 +0100 Subject: [PATCH 096/106] fix: address review feedback --- sources/-session/SessionView.tsx | 15 +- sources/app/(app)/new/pick/path.tsx | 74 ++++---- sources/app/(app)/new/pick/profile-edit.tsx | 11 +- sources/app/(app)/session/[id]/info.tsx | 12 +- sources/app/(app)/settings/features.tsx | 5 +- sources/app/(app)/settings/profiles.tsx | 2 +- sources/app/(app)/settings/voice/language.tsx | 4 +- sources/components/AgentInput.tsx | 165 +++++++++--------- .../CommandPalette/CommandPaletteProvider.tsx | 2 +- .../EnvironmentVariableCard.test.ts | 31 ++++ .../components/EnvironmentVariableCard.tsx | 9 +- .../components/EnvironmentVariablesList.tsx | 6 +- .../ItemGroup.selectableCount.test.ts | 47 +++++ .../components/ItemGroup.selectableCount.ts | 24 +++ sources/components/ItemGroup.tsx | 23 +-- sources/components/ItemRowActions.tsx | 23 ++- sources/components/ProfileEditForm.tsx | 54 +++--- sources/components/SearchHeader.tsx | 20 ++- sources/components/SearchableListSelector.tsx | 2 +- sources/components/SessionTypeSelector.tsx | 1 - sources/components/Switch.web.tsx | 11 +- sources/components/tools/knownTools.tsx | 26 +-- sources/hooks/useCLIDetection.ts | 27 ++- sources/sync/messageMeta.test.ts | 12 ++ sources/sync/permissionMapping.test.ts | 11 +- sources/sync/permissionMapping.ts | 4 +- sources/sync/permissionTypes.test.ts | 26 +++ sources/sync/permissionTypes.ts | 12 ++ sources/sync/persistence.test.ts | 24 ++- sources/sync/persistence.ts | 4 +- sources/sync/profileMutations.ts | 6 +- sources/sync/profileUtils.test.ts | 13 ++ sources/sync/profileUtils.ts | 5 +- sources/sync/typesRaw.spec.ts | 78 +++++++++ sources/sync/typesRaw.ts | 6 +- sources/text/README.md | 2 +- sources/text/_types.ts | 5 +- sources/text/translations/ca.ts | 1 + sources/text/translations/en.ts | 3 + sources/text/translations/es.ts | 1 + sources/text/translations/it.ts | 1 + sources/text/translations/ja.ts | 1 + sources/text/translations/pl.ts | 1 + sources/text/translations/pt.ts | 1 + sources/text/translations/ru.ts | 1 + sources/text/translations/zh-Hans.ts | 1 + 46 files changed, 567 insertions(+), 246 deletions(-) create mode 100644 sources/components/ItemGroup.selectableCount.test.ts create mode 100644 sources/components/ItemGroup.selectableCount.ts create mode 100644 sources/sync/permissionTypes.test.ts create mode 100644 sources/sync/profileUtils.test.ts diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index 589b8d2f6..530c928dd 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -197,13 +197,24 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); + const CONFIGURABLE_MODEL_MODES = [ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + ] as const; + type ConfigurableModelMode = (typeof CONFIGURABLE_MODEL_MODES)[number]; + const isConfigurableModelMode = React.useCallback((mode: ModelMode): mode is ConfigurableModelMode => { + return (CONFIGURABLE_MODEL_MODES as readonly string[]).includes(mode); + }, []); + // Function to update model mode (for Gemini sessions) const updateModelMode = React.useCallback((mode: ModelMode) => { // Only Gemini model modes are configurable from the UI today. - if (mode === 'default' || mode === 'gemini-2.5-pro' || mode === 'gemini-2.5-flash' || mode === 'gemini-2.5-flash-lite') { + if (isConfigurableModelMode(mode)) { storage.getState().updateSessionModelMode(sessionId, mode); } - }, [sessionId]); + }, [isConfigurableModelMode, sessionId]); // Memoize header-dependent styles to prevent re-renders const headerDependentStyles = React.useMemo(() => ({ diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index 74b47dd37..f26f121e5 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -11,43 +11,6 @@ import { layout } from '@/components/layout'; import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; -const stylesheet = StyleSheet.create((theme) => ({ - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - alignSelf: 'center', - }, - pathInputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 16, - }, - pathInput: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: 10, - paddingHorizontal: 12, - minHeight: 36, - position: 'relative', - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, -})); - export default React.memo(function PathPickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; @@ -218,3 +181,40 @@ export default React.memo(function PathPickerScreen() { ); }); + +const stylesheet = StyleSheet.create((theme) => ({ + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, +})); diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index d57fd69a2..616857acb 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -61,7 +61,7 @@ export default React.memo(function ProfileEditScreen() { if (cloneFromProfileIdParam) { const base = resolveById(cloneFromProfileIdParam); if (base) { - return duplicateProfileForEdit(base); + return duplicateProfileForEdit(base, { copySuffix: t('profiles.copySuffix') }); } } @@ -91,7 +91,12 @@ export default React.memo(function ProfileEditScreen() { }, [profile.isBuiltIn]); React.useEffect(() => { - const subscription = (navigation as any)?.addListener?.('beforeRemove', (e: any) => { + const addListener = (navigation as any)?.addListener; + if (typeof addListener !== 'function') { + return; + } + + const subscription = addListener.call(navigation, 'beforeRemove', (e: any) => { if (!isDirtyRef.current) return; e.preventDefault(); @@ -107,7 +112,7 @@ export default React.memo(function ProfileEditScreen() { })(); }); - return subscription; + return () => subscription?.remove?.(); }, [confirmDiscard, navigation]); const handleSave = (savedProfile: AIBackendProfile) => { diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index e0a9b9fec..d73116760 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -325,12 +325,12 @@ function SessionInfoContent({ session }: { session: Session }) { { - const flavor = session.metadata.flavor || 'claude'; - if (flavor === 'claude') return t('agentInput.agent.claude'); - if (flavor === 'gpt' || flavor === 'openai') return t('agentInput.agent.codex'); - if (flavor === 'gemini') return t('agentInput.agent.gemini'); - return flavor; - })()} + const flavor = session.metadata.flavor || 'claude'; + if (flavor === 'claude') return t('agentInput.agent.claude'); + if (flavor === 'gpt' || flavor === 'openai' || flavor === 'codex') return t('agentInput.agent.codex'); + if (flavor === 'gemini') return t('agentInput.agent.gemini'); + return flavor; + })()} icon={} showChevron={false} /> diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index c701ef908..589e3e99b 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; @@ -7,7 +8,7 @@ import { useSettingMutable, useLocalSettingMutable } from '@/sync/storage'; import { Switch } from '@/components/Switch'; import { t } from '@/text'; -export default function FeaturesSettingsScreen() { +export default React.memo(function FeaturesSettingsScreen() { const [experiments, setExperiments] = useSettingMutable('experiments'); const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend'); @@ -139,4 +140,4 @@ export default function FeaturesSettingsScreen() { )} ); -} +}); diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index c8dba321d..a9ea53d82 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -54,7 +54,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel }; const handleDuplicateProfile = (profile: AIBackendProfile) => { - setEditingProfile(duplicateProfileForEdit(profile)); + setEditingProfile(duplicateProfileForEdit(profile, { copySuffix: t('profiles.copySuffix') })); setShowAddForm(true); }; diff --git a/sources/app/(app)/settings/voice/language.tsx b/sources/app/(app)/settings/voice/language.tsx index 7f95d9eba..38ad5e0e8 100644 --- a/sources/app/(app)/settings/voice/language.tsx +++ b/sources/app/(app)/settings/voice/language.tsx @@ -10,7 +10,7 @@ import { useSettingMutable } from '@/sync/storage'; import { LANGUAGES, getLanguageDisplayName, type Language } from '@/constants/Languages'; import { t } from '@/text'; -export default function LanguageSelectionScreen() { +export default React.memo(function LanguageSelectionScreen() { const router = useRouter(); const [voiceAssistantLanguage, setVoiceAssistantLanguage] = useSettingMutable('voiceAssistantLanguage'); const [searchQuery, setSearchQuery] = useState(''); @@ -69,4 +69,4 @@ export default function LanguageSelectionScreen() { ); -} +}); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index ff8a5c2fa..db1187856 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -5,7 +5,7 @@ import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; -import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { normalizePermissionModeForAgentFlavor, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; import { getModelOptionsForAgentType } from '@/sync/modelOptions'; import { hapticsLight, hapticsError } from './haptics'; import { Shaker, ShakeInstance } from './Shaker'; @@ -217,6 +217,9 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ fontSize: 11, ...Typography.default(), }, + statusDot: { + marginRight: 6, + }, permissionModeContainer: { flexDirection: 'column', alignItems: 'flex-end', @@ -253,6 +256,10 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', justifyContent: 'space-between', }, + pathRow: { + flexDirection: 'row', + alignItems: 'center', + }, actionButtonsLeft: { flexDirection: 'row', columnGap: 6, @@ -264,6 +271,9 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ actionButtonsLeftNarrow: { columnGap: 4, }, + actionButtonsLeftNoFlex: { + flex: 0, + }, actionChip: { flexDirection: 'row', alignItems: 'center', @@ -379,6 +389,10 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ sendButtonIcon: { color: theme.colors.button.primary.tint, }, + micIcon: { + width: 24, + height: 24, + }, })); const getContextWarning = (contextSize: number, alwaysShow: boolean = false, theme: Theme) => { @@ -507,43 +521,48 @@ export const AgentInput = React.memo(React.forwardRef { - const mode = props.permissionMode ?? 'default'; + const normalizedPermissionMode = React.useMemo(() => { + return normalizePermissionModeForAgentFlavor( + props.permissionMode ?? 'default', + isCodex ? 'codex' : isGemini ? 'gemini' : 'claude', + ); + }, [isCodex, isGemini, props.permissionMode]); + const permissionChipLabel = React.useMemo(() => { if (isCodex) { - return mode === 'default' + return normalizedPermissionMode === 'default' ? t('agentInput.codexPermissionMode.default') - : mode === 'read-only' + : normalizedPermissionMode === 'read-only' ? t('agentInput.codexPermissionMode.readOnly') - : mode === 'safe-yolo' + : normalizedPermissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.safeYolo') - : mode === 'yolo' + : normalizedPermissionMode === 'yolo' ? t('agentInput.codexPermissionMode.yolo') : ''; } if (isGemini) { - return mode === 'default' + return normalizedPermissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') - : mode === 'read-only' + : normalizedPermissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.readOnly') - : mode === 'safe-yolo' + : normalizedPermissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.safeYolo') - : mode === 'yolo' + : normalizedPermissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.yolo') : ''; } - return mode === 'default' + return normalizedPermissionMode === 'default' ? t('agentInput.permissionMode.default') - : mode === 'acceptEdits' + : normalizedPermissionMode === 'acceptEdits' ? t('agentInput.permissionMode.acceptEdits') - : mode === 'plan' + : normalizedPermissionMode === 'plan' ? t('agentInput.permissionMode.plan') - : mode === 'bypassPermissions' + : normalizedPermissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.bypassPermissions') : ''; - }, [isCodex, isGemini, props.permissionMode]); + }, [isCodex, isGemini, normalizedPermissionMode]); // Handle settings button press const handleSettingsPress = React.useCallback(() => { @@ -712,7 +731,7 @@ export const AgentInput = React.memo(React.forwardRef - + + {props.connectionStatus && ( <> - - - {props.connectionStatus.text} - + + + {props.connectionStatus.text} + )} {contextWarning && ( - + {props.connectionStatus ? '• ' : ''}{contextWarning.text} )} - + {props.permissionMode && ( - + {isCodex ? ( - props.permissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' + normalizedPermissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : + normalizedPermissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : + normalizedPermissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : + normalizedPermissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' ) : isGemini ? ( - props.permissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' + normalizedPermissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : + normalizedPermissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : + normalizedPermissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : + normalizedPermissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' ) : ( - props.permissionMode === 'default' ? t('agentInput.permissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' + normalizedPermissionMode === 'default' ? t('agentInput.permissionMode.default') : + normalizedPermissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : + normalizedPermissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : + normalizedPermissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' )} )} @@ -1120,10 +1130,7 @@ export const AgentInput = React.memo(React.forwardRef ) : ( @@ -1143,8 +1150,8 @@ export const AgentInput = React.memo(React.forwardRef - + + { hapticsLight(); diff --git a/sources/components/CommandPalette/CommandPaletteProvider.tsx b/sources/components/CommandPalette/CommandPaletteProvider.tsx index bf3ab544b..748250c28 100644 --- a/sources/components/CommandPalette/CommandPaletteProvider.tsx +++ b/sources/components/CommandPalette/CommandPaletteProvider.tsx @@ -121,7 +121,7 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode } return cmds; - }, [router, logout, sessions]); + }, [router, logout, sessions, navigateToSession]); const showCommandPalette = useCallback(() => { if (Platform.OS !== 'web' || !commandPaletteEnabled) return; diff --git a/sources/components/EnvironmentVariableCard.test.ts b/sources/components/EnvironmentVariableCard.test.ts index d6cec5651..fa83f53d9 100644 --- a/sources/components/EnvironmentVariableCard.test.ts +++ b/sources/components/EnvironmentVariableCard.test.ts @@ -149,4 +149,35 @@ describe('EnvironmentVariableCard', () => { expect(lastCall[0]).toBe(0); expect(lastCall[1]).toBe('${BAR:-baz}'); }); + + it('removes the operator when user clears the fallback value', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:=baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.(''); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR}'); + }); }); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index dcbb82a4e..599375a9d 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -86,6 +86,7 @@ export function EnvironmentVariableCard({ const remoteValue = machineEnv?.[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; + const computedOperator: EnvVarTemplateOperator | null = hasFallback ? (fallbackOperator ?? ':-') : null; const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); @@ -93,13 +94,13 @@ export function EnvironmentVariableCard({ // Update parent when local state changes React.useEffect(() => { const newValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: fallbackOperator }) + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) : defaultValue; if (newValue !== variable.value) { onUpdate(index, newValue); } - }, [defaultValue, fallbackOperator, index, onUpdate, remoteVariableName, useRemoteVariable, variable.value]); + }, [computedOperator, defaultValue, index, onUpdate, remoteVariableName, useRemoteVariable, variable.value]); // Determine status const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; @@ -107,14 +108,14 @@ export function EnvironmentVariableCard({ const computedTemplateValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: fallbackOperator }) + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) : defaultValue; const resolvedSessionValue = isSecret ? (useRemoteVariable && remoteVariableName ? t('profiles.environmentVariables.preview.secretValueHidden', { - value: formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue !== '' ? '***' : '', operator: fallbackOperator }), + value: formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue !== '' ? '***' : '', operator: computedOperator }), }) : (defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue)) : (useRemoteVariable && machineId && remoteValue !== undefined diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index 6b3f6123e..3f1fd954f 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -18,6 +18,8 @@ export interface EnvironmentVariablesListProps { onChange: (newVariables: Array<{ name: string; value: string }>) => void; } +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + /** * Complete environment variables section with title, add button, and editable cards * Matches profile list pattern from index.tsx:1159-1308 @@ -38,8 +40,6 @@ export function EnvironmentVariablesList({ return match ? match[1] : null; }, []); - const SECRET_NAME_REGEX = React.useMemo(() => /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i, []); - const documentedSecretNames = React.useMemo(() => { if (!profileDocs) return new Set(); @@ -61,7 +61,7 @@ export function EnvironmentVariablesList({ refs.add(ref); }); return Array.from(refs); - }, [SECRET_NAME_REGEX, documentedSecretNames, environmentVariables, extractVarNameFromValue]); + }, [documentedSecretNames, environmentVariables, extractVarNameFromValue]); const { variables: machineEnv, isLoading: isMachineEnvLoading } = useEnvironmentVariables( machineId, diff --git a/sources/components/ItemGroup.selectableCount.test.ts b/sources/components/ItemGroup.selectableCount.test.ts new file mode 100644 index 000000000..ee7b0de51 --- /dev/null +++ b/sources/components/ItemGroup.selectableCount.test.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { countSelectableItems } from './ItemGroup.selectableCount'; + +function TestItem(_props: { title?: React.ReactNode; onPress?: () => void; onLongPress?: () => void }) { + return null; +} + +describe('countSelectableItems', () => { + it('counts items with ReactNode titles as selectable', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'X'), onPress: () => {} }), + React.createElement(TestItem, { title: 'Y', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(2); + }); + + it('does not count items with empty-string titles', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: '', onPress: () => {} }), + React.createElement(TestItem, { title: 'ok', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(1); + }); + + it('recurse-counts Fragment children', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: 'a', onPress: () => {} }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'b'), onPress: () => {} }), + React.createElement(TestItem, { title: undefined, onPress: () => {} }), + ), + ); + + expect(countSelectableItems(node)).toBe(2); + }); +}); diff --git a/sources/components/ItemGroup.selectableCount.ts b/sources/components/ItemGroup.selectableCount.ts new file mode 100644 index 000000000..1265140dc --- /dev/null +++ b/sources/components/ItemGroup.selectableCount.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; + +type ItemChildProps = { + title?: unknown; + onPress?: unknown; + onLongPress?: unknown; +}; + +export function countSelectableItems(node: React.ReactNode): number { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<{ children?: React.ReactNode }>; + return count + countSelectableItems(fragment.props.children); + } + const propsAny = (child as React.ReactElement).props as any; + const title = propsAny?.title; + const hasTitle = title !== null && title !== undefined && title !== ''; + const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function'; + return count + (hasTitle && isSelectable ? 1 : 0); + }, 0); +} diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index 222b67e56..225680abb 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -11,16 +11,12 @@ import { Typography } from '@/constants/Typography'; import { layout } from './layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { withItemGroupDividers } from './ItemGroup.dividers'; +import { countSelectableItems } from './ItemGroup.selectableCount'; export { withItemGroupDividers } from './ItemGroup.dividers'; export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); -interface ItemChildProps { - showDivider?: boolean; - [key: string]: any; -} - export interface ItemGroupProps { title?: string | React.ReactNode; footer?: string; @@ -101,22 +97,7 @@ export const ItemGroup = React.memo((props) => { } = 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); + return countSelectableItems(children); }, [children]); return ( diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx index 01e3cdb34..c039618bc 100644 --- a/sources/components/ItemRowActions.tsx +++ b/sources/components/ItemRowActions.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { View, Pressable, useWindowDimensions } from 'react-native'; +import { View, Pressable, useWindowDimensions, type GestureResponderEvent } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Modal } from '@/modal'; import { ItemActionsMenuModal, type ItemAction } from '@/components/ItemActionsMenuModal'; @@ -17,6 +17,7 @@ export interface ItemRowActionsProps { export function ItemRowActions(props: ItemRowActionsProps) { const { theme } = useUnistyles(); + const styles = stylesheet; const { width } = useWindowDimensions(); const compact = width < (props.compactThreshold ?? 420); @@ -45,16 +46,18 @@ export function ItemRowActions(props: ItemRowActionsProps) { const gap = props.gap ?? 16; return ( - + {inlineActions.map((action) => ( props.onActionPressIn?.()} - onPress={(e: any) => { + onPress={(e: GestureResponderEvent) => { e?.stopPropagation?.(); action.onPress(); }} + accessibilityRole="button" + accessibilityLabel={action.title} > props.onActionPressIn?.()} - onPress={(e: any) => { + onPress={(e: GestureResponderEvent) => { e?.stopPropagation?.(); openMenu(); }} + accessibilityRole="button" + accessibilityLabel="More actions" + accessibilityHint="Opens a menu with more actions" > ); } + +const stylesheet = StyleSheet.create(() => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, +})); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 635542097..ecb2cb4b5 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -40,6 +40,7 @@ interface MachinePreviewModalProps { function MachinePreviewModal(props: MachinePreviewModalProps) { const { theme } = useUnistyles(); + const styles = stylesheet; const { height: windowHeight } = useWindowDimensions(); const selectedMachine = React.useMemo(() => { @@ -55,32 +56,9 @@ function MachinePreviewModal(props: MachinePreviewModalProps) { const maxHeight = Math.min(720, Math.max(420, Math.floor(windowHeight * 0.85))); return ( - - - + + + {t('profiles.previewMachine.title')} @@ -504,6 +482,30 @@ export function ProfileEditForm({ } const stylesheet = StyleSheet.create((theme) => ({ + machinePreviewModalContainer: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + machinePreviewModalHeader: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + machinePreviewModalTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, inputContainer: { paddingHorizontal: 16, paddingVertical: 12, diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index bf3fc00dd..1f3c540c9 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, TextInput, Platform, StyleProp, ViewStyle } from 'react-native'; +import { View, TextInput, Platform, Pressable, StyleProp, ViewStyle } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -103,13 +103,19 @@ export function SearchHeader({ style={styles.textInput} /> {value.length > 0 && ( - onChangeText('')} - style={styles.clearIcon} - /> + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Clear search" + > + + )} diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index ee2c89062..ac5293a5d 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -361,7 +361,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) )} {shouldRenderAllGroup && ( - + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} {filteredItems.length === 0 ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index d7489d4ec..bc1f2d3c2 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -35,7 +35,6 @@ const stylesheet = StyleSheet.create((theme) => ({ })); export function SessionTypeSelectorRows({ value, onChange }: Pick) { - const { theme } = useUnistyles(); const styles = stylesheet; return ( diff --git a/sources/components/Switch.web.tsx b/sources/components/Switch.web.tsx index e8baf0c76..150d37d5d 100644 --- a/sources/components/Switch.web.tsx +++ b/sources/components/Switch.web.tsx @@ -23,7 +23,7 @@ const stylesheet = StyleSheet.create(() => ({ }, })); -export const Switch = ({ value, disabled, onValueChange }: SwitchProps) => { +export const Switch = ({ value, disabled, onValueChange, style, ...rest }: SwitchProps) => { const { theme } = useUnistyles(); const styles = stylesheet; @@ -31,13 +31,15 @@ export const Switch = ({ value, disabled, onValueChange }: SwitchProps) => { return ( onValueChange?.(!value)} - style={({ pressed }) => ({ - opacity: disabled ? 0.6 : pressed ? 0.85 : 1, - })} + style={({ pressed }) => [ + style as any, + { opacity: disabled ? 0.6 : pressed ? 0.85 : 1 }, + ]} > { ); }; - diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 742f4ee38..bf5353955 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -181,9 +181,12 @@ export const knownTools = { return path; } // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } } return t('tools.names.readFile'); }, @@ -211,9 +214,12 @@ export const knownTools = { 'read': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } } if (typeof opts.tool.input.file_path === 'string') { const path = resolvePath(opts.tool.input.file_path, opts.metadata); @@ -617,15 +623,15 @@ export const knownTools = { let filePath: string | undefined; // 1. Check toolCall.content[0].path - if (opts.tool.input?.toolCall?.content?.[0]?.path) { + if (typeof opts.tool.input?.toolCall?.content?.[0]?.path === 'string') { filePath = opts.tool.input.toolCall.content[0].path; } // 2. Check toolCall.title (has nice "Writing to ..." format) - else if (opts.tool.input?.toolCall?.title) { + else if (typeof opts.tool.input?.toolCall?.title === 'string') { return opts.tool.input.toolCall.title; } // 3. Check input[0].path (array format) - else if (Array.isArray(opts.tool.input?.input) && opts.tool.input.input[0]?.path) { + else if (Array.isArray(opts.tool.input?.input) && typeof opts.tool.input.input[0]?.path === 'string') { filePath = opts.tool.input.input[0].path; } // 4. Check direct path field @@ -633,7 +639,7 @@ export const knownTools = { filePath = opts.tool.input.path; } - if (filePath) { + if (typeof filePath === 'string' && filePath.length > 0) { return resolvePath(filePath, opts.metadata); } return t('tools.names.editFile'); diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts index 97f8f1cb0..7a839a9ae 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -1,6 +1,13 @@ import { useState, useEffect } from 'react'; import { machineBash } from '@/sync/ops'; +function debugLog(...args: unknown[]) { + if (__DEV__) { + // eslint-disable-next-line no-console + console.log(...args); + } +} + interface CLIAvailability { claude: boolean | null; // null = unknown/loading, true = installed, false = not installed codex: boolean | null; @@ -52,9 +59,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { const detectCLIs = async () => { // Set detecting flag (non-blocking - UI stays responsive) setAvailability(prev => ({ ...prev, isDetecting: true })); - if (__DEV__) { - console.log('[useCLIDetection] Starting detection for machineId:', machineId); - } + debugLog('[useCLIDetection] Starting detection for machineId:', machineId); try { // Use single bash command to check both CLIs efficiently @@ -68,9 +73,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { ); if (cancelled) return; - if (__DEV__) { - console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); - } + debugLog('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); if (result.success && result.exitCode === 0) { // Parse output: "claude:true\ncodex:false\ngemini:false" @@ -84,9 +87,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { } }); - if (__DEV__) { - console.log('[useCLIDetection] Parsed CLI status:', cliStatus); - } + debugLog('[useCLIDetection] Parsed CLI status:', cliStatus); setAvailability({ claude: cliStatus.claude ?? null, codex: cliStatus.codex ?? null, @@ -96,9 +97,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { }); } else { // Detection command failed - CONSERVATIVE fallback (don't assume availability) - if (__DEV__) { - console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); - } + debugLog('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); setAvailability({ claude: null, codex: null, @@ -112,9 +111,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { if (cancelled) return; // Network/RPC error - CONSERVATIVE fallback (don't assume availability) - if (__DEV__) { - console.log('[useCLIDetection] Network/RPC error:', error); - } + debugLog('[useCLIDetection] Network/RPC error:', error); setAvailability({ claude: null, codex: null, diff --git a/sources/sync/messageMeta.test.ts b/sources/sync/messageMeta.test.ts index 9ebc98369..558485cc4 100644 --- a/sources/sync/messageMeta.test.ts +++ b/sources/sync/messageMeta.test.ts @@ -39,4 +39,16 @@ describe('buildOutgoingMessageMeta', () => { expect('displayText' in meta).toBe(true); expect(meta.displayText).toBe(''); }); + + it('includes fallbackModel when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + fallbackModel: 'gemini-2.5-flash', + }); + + expect('fallbackModel' in meta).toBe(true); + expect(meta.fallbackModel).toBe('gemini-2.5-flash'); + }); }); diff --git a/sources/sync/permissionMapping.test.ts b/sources/sync/permissionMapping.test.ts index 6bebe35a7..52bc50c20 100644 --- a/sources/sync/permissionMapping.test.ts +++ b/sources/sync/permissionMapping.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest'; import { mapPermissionModeAcrossAgents } from './permissionMapping'; describe('mapPermissionModeAcrossAgents', () => { + it('returns the same mode when from and to are the same', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'claude')).toBe('plan'); + }); + it('maps Claude plan to Gemini safe-yolo', () => { expect(mapPermissionModeAcrossAgents('plan', 'claude', 'gemini')).toBe('safe-yolo'); }); @@ -22,9 +26,14 @@ describe('mapPermissionModeAcrossAgents', () => { expect(mapPermissionModeAcrossAgents('safe-yolo', 'gemini', 'claude')).toBe('plan'); }); + it('preserves read-only across agents', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'claude', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('read-only', 'codex', 'claude')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'claude')).toBe('read-only'); + }); + it('keeps Codex/Gemini modes unchanged when switching between them', () => { expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'codex')).toBe('read-only'); expect(mapPermissionModeAcrossAgents('safe-yolo', 'codex', 'gemini')).toBe('safe-yolo'); }); }); - diff --git a/sources/sync/permissionMapping.ts b/sources/sync/permissionMapping.ts index eab3cdc11..5330454c6 100644 --- a/sources/sync/permissionMapping.ts +++ b/sources/sync/permissionMapping.ts @@ -27,6 +27,8 @@ export function mapPermissionModeAcrossAgents( return 'safe-yolo'; case 'acceptEdits': return 'safe-yolo'; + case 'read-only': + return 'read-only'; case 'default': return 'default'; default: @@ -41,7 +43,7 @@ export function mapPermissionModeAcrossAgents( case 'safe-yolo': return 'plan'; case 'read-only': - return 'default'; + return 'read-only'; case 'default': return 'default'; default: diff --git a/sources/sync/permissionTypes.test.ts b/sources/sync/permissionTypes.test.ts new file mode 100644 index 000000000..0008428e1 --- /dev/null +++ b/sources/sync/permissionTypes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import type { PermissionMode } from './permissionTypes'; +import { normalizePermissionModeForAgentFlavor } from './permissionTypes'; + +describe('normalizePermissionModeForAgentFlavor', () => { + it('clamps non-codex permission modes to default for codex', () => { + expect(normalizePermissionModeForAgentFlavor('plan', 'codex')).toBe('default'); + }); + + it('clamps codex-like permission modes to default for claude', () => { + expect(normalizePermissionModeForAgentFlavor('read-only', 'claude')).toBe('default'); + }); + + it('preserves codex-like modes for gemini', () => { + expect(normalizePermissionModeForAgentFlavor('safe-yolo', 'gemini')).toBe('safe-yolo'); + expect(normalizePermissionModeForAgentFlavor('yolo', 'gemini')).toBe('yolo'); + }); + + it('preserves claude modes for claude', () => { + const modes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + for (const mode of modes) { + expect(normalizePermissionModeForAgentFlavor(mode, 'claude')).toBe(mode); + } + }); +}); + diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts index c42e082d9..a286fa2fa 100644 --- a/sources/sync/permissionTypes.ts +++ b/sources/sync/permissionTypes.ts @@ -7,6 +7,18 @@ export type PermissionMode = | 'safe-yolo' | 'yolo'; +export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const; +export const CODEX_LIKE_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const; + +export type AgentFlavor = 'claude' | 'codex' | 'gemini'; + +export function normalizePermissionModeForAgentFlavor(mode: PermissionMode, flavor: AgentFlavor): PermissionMode { + if (flavor === 'codex' || flavor === 'gemini') { + return (CODEX_LIKE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; + } + return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; +} + export type ModelMode = | 'default' | 'adaptiveUsage' diff --git a/sources/sync/persistence.test.ts b/sources/sync/persistence.test.ts index 08f21e9ba..06a318902 100644 --- a/sources/sync/persistence.test.ts +++ b/sources/sync/persistence.test.ts @@ -24,7 +24,7 @@ vi.mock('react-native-mmkv', () => { return { MMKV }; }); -import { clearPersistence, loadSessionModelModes, saveSessionModelModes } from './persistence'; +import { clearPersistence, loadNewSessionDraft, loadSessionModelModes, saveSessionModelModes } from './persistence'; describe('persistence', () => { beforeEach(() => { @@ -49,4 +49,26 @@ describe('persistence', () => { expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); }); }); + + describe('new session draft', () => { + it('clamps invalid modelMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'default', + modelMode: 'not-a-real-model', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('default'); + }); + }); }); diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 8a245eb01..49efa5671 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -163,8 +163,8 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string' ? (parsed.permissionMode as PermissionMode) : 'default'; - const modelMode: ModelMode = typeof parsed.modelMode === 'string' - ? (parsed.modelMode as ModelMode) + const modelMode: ModelMode = isSessionModelMode(parsed.modelMode) + ? parsed.modelMode : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(); diff --git a/sources/sync/profileMutations.ts b/sources/sync/profileMutations.ts index 2f7ab69be..340093911 100644 --- a/sources/sync/profileMutations.ts +++ b/sources/sync/profileMutations.ts @@ -14,11 +14,13 @@ export function createEmptyCustomProfile(): AIBackendProfile { }; } -export function duplicateProfileForEdit(profile: AIBackendProfile): AIBackendProfile { +export function duplicateProfileForEdit(profile: AIBackendProfile, opts?: { copySuffix?: string }): AIBackendProfile { + const suffix = opts?.copySuffix ?? '(Copy)'; + const separator = profile.name.trim().length > 0 ? ' ' : ''; return { ...profile, id: randomUUID(), - name: `${profile.name} (Copy)`, + name: `${profile.name}${separator}${suffix}`, isBuiltIn: false, createdAt: Date.now(), updatedAt: Date.now(), diff --git a/sources/sync/profileUtils.test.ts b/sources/sync/profileUtils.test.ts new file mode 100644 index 000000000..7ddef693e --- /dev/null +++ b/sources/sync/profileUtils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { getProfilePrimaryCli } from './profileUtils'; + +describe('getProfilePrimaryCli', () => { + it('ignores unknown compatibility keys', () => { + const profile = { + compatibility: { unknownCli: true }, + } as any; + + expect(getProfilePrimaryCli(profile)).toBe('none'); + }); +}); + diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index aed7c68ae..e7ea1f087 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -2,11 +2,14 @@ import { AIBackendProfile } from './settings'; export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none'; +const ALLOWED_PROFILE_CLIS = new Set(['claude', 'codex', 'gemini']); + export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { if (!profile) return 'none'; const supported = Object.entries(profile.compatibility ?? {}) .filter(([, isSupported]) => isSupported) - .map(([cli]) => cli as 'claude' | 'codex' | 'gemini'); + .map(([cli]) => cli) + .filter((cli): cli is 'claude' | 'codex' | 'gemini' => ALLOWED_PROFILE_CLIS.has(cli)); if (supported.length === 0) return 'none'; if (supported.length === 1) return supported[0]; diff --git a/sources/sync/typesRaw.spec.ts b/sources/sync/typesRaw.spec.ts index bf00b3b3c..55851f426 100644 --- a/sources/sync/typesRaw.spec.ts +++ b/sources/sync/typesRaw.spec.ts @@ -1542,5 +1542,83 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } } }); + + it('normalizes ACP tool-result string output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: 'direct string', + id: 'acp-msg-3', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-3', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('direct string'); + } + } + }); + + it('normalizes ACP tool-result object output to JSON text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: { key: 'value' }, + id: 'acp-msg-4', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-4', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe(JSON.stringify({ key: 'value' })); + } + } + }); + + it('normalizes ACP tool-result null output to empty text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: null, + id: 'acp-msg-5', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-5', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe(''); + } + } + }); }); }); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 19d1c5b72..b408a9053 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -248,13 +248,13 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ oldContent: z.string().optional(), newContent: z.string().optional(), id: z.string() - }), + }).passthrough(), // Terminal/command output z.object({ type: z.literal('terminal-output'), data: z.string(), callId: z.string() - }), + }).passthrough(), // Task lifecycle events z.object({ type: z.literal('task_started'), id: z.string() }), z.object({ type: z.literal('task_complete'), id: z.string() }), @@ -266,7 +266,7 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ toolName: z.string(), description: z.string(), options: z.any().optional() - }), + }).passthrough(), // Usage/metrics z.object({ type: z.literal('token_count') }).passthrough() ]) diff --git a/sources/text/README.md b/sources/text/README.md index 9c40002fc..29f98e0c8 100644 --- a/sources/text/README.md +++ b/sources/text/README.md @@ -218,7 +218,7 @@ statusMessage: ({ files, online, syncing }: { ## Future Expansion To add more languages: -1. Create new translation files (e.g., `_spanish.ts`) +1. Create new translation files (e.g., `translations/es.ts`) 2. Update types to include new locales 3. Add locale switching logic 4. All existing type safety is preserved diff --git a/sources/text/_types.ts b/sources/text/_types.ts index 9998ac68d..435f5471e 100644 --- a/sources/text/_types.ts +++ b/sources/text/_types.ts @@ -1,4 +1,3 @@ -import { en } from './translations/en'; +export type { TranslationStructure } from './translations/en'; -export type Translations = typeof en; -export type TranslationStructure = Translations; +export type Translations = import('./translations/en').TranslationStructure; diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index bdc3679e2..22eb65b2b 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -973,6 +973,7 @@ export const ca: TranslationStructure = { duplicateProfile: 'Duplica el perfil', deleteProfile: 'Elimina el perfil', }, + copySuffix: '(Copy)', duplicateName: 'Ja existeix un perfil amb aquest nom', setupInstructions: { title: 'Instruccions de configuració', diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index a4979c8ec..c1df216fa 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -996,6 +996,7 @@ export const en = { duplicateProfile: 'Duplicate profile', deleteProfile: 'Delete profile', }, + copySuffix: '(Copy)', duplicateName: 'A profile with this name already exists', setupInstructions: { title: 'Setup Instructions', @@ -1095,4 +1096,6 @@ export const en = { } }; +export type TranslationStructure = typeof en; + export type TranslationsEn = typeof en; diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 5071035f9..7b7345d29 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -983,6 +983,7 @@ export const es: TranslationStructure = { duplicateProfile: 'Duplicar perfil', deleteProfile: 'Eliminar perfil', }, + copySuffix: '(Copy)', duplicateName: 'Ya existe un perfil con este nombre', setupInstructions: { title: 'Instrucciones de configuración', diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 081b5b43a..a4e95036f 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -114,6 +114,7 @@ export const it: TranslationStructure = { duplicateProfile: 'Duplica profilo', deleteProfile: 'Elimina profilo', }, + copySuffix: '(Copy)', duplicateName: 'Esiste già un profilo con questo nome', setupInstructions: { title: 'Istruzioni di configurazione', diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 2ae3ac5c8..852f30795 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -107,6 +107,7 @@ export const ja: TranslationStructure = { duplicateProfile: 'プロファイルを複製', deleteProfile: 'プロファイルを削除', }, + copySuffix: '(Copy)', duplicateName: '同じ名前のプロファイルが既に存在します', setupInstructions: { title: 'セットアップ手順', diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 8861e3050..4e97e9848 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -1006,6 +1006,7 @@ export const pl: TranslationStructure = { duplicateProfile: 'Duplikuj profil', deleteProfile: 'Usuń profil', }, + copySuffix: '(Copy)', duplicateName: 'Profil o tej nazwie już istnieje', setupInstructions: { title: 'Instrukcje konfiguracji', diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 3af690072..f386edffe 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -973,6 +973,7 @@ export const pt: TranslationStructure = { duplicateProfile: 'Duplicar perfil', deleteProfile: 'Excluir perfil', }, + copySuffix: '(Copy)', duplicateName: 'Já existe um perfil com este nome', setupInstructions: { title: 'Instruções de configuração', diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index febab47a0..df521f9ea 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -1005,6 +1005,7 @@ export const ru: TranslationStructure = { duplicateProfile: 'Дублировать профиль', deleteProfile: 'Удалить профиль', }, + copySuffix: '(Copy)', duplicateName: 'Профиль с таким названием уже существует', setupInstructions: { title: 'Инструкции по настройке', diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index d4886d858..ccff0cbd7 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -975,6 +975,7 @@ export const zhHans: TranslationStructure = { duplicateProfile: '复制配置文件', deleteProfile: '删除配置文件', }, + copySuffix: '(Copy)', duplicateName: '已存在同名配置文件', setupInstructions: { title: '设置说明', From fcefb257a09a4d048f587229df2334859f61935d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 11:38:09 +0100 Subject: [PATCH 097/106] feat(storage): add optional storage scoping --- sources/auth/tokenStorage.ts | 32 ++++++++++++++++---- sources/sync/persistence.ts | 5 +++- sources/sync/serverConfig.ts | 5 +++- sources/utils/storageScope.test.ts | 47 ++++++++++++++++++++++++++++++ sources/utils/storageScope.ts | 32 ++++++++++++++++++++ 5 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 sources/utils/storageScope.test.ts create mode 100644 sources/utils/storageScope.ts diff --git a/sources/auth/tokenStorage.ts b/sources/auth/tokenStorage.ts index b69060ef9..a85ae83d8 100644 --- a/sources/auth/tokenStorage.ts +++ b/sources/auth/tokenStorage.ts @@ -1,10 +1,17 @@ import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; const AUTH_KEY = 'auth_credentials'; +function getAuthKey(): string { + const scope = Platform.OS === 'web' ? null : readStorageScopeFromEnv(); + return scopedStorageId(AUTH_KEY, scope); +} + // Cache for synchronous access let credentialsCache: string | null = null; +let credentialsCacheKey: string | null = null; export interface AuthCredentials { token: string; @@ -13,13 +20,22 @@ export interface AuthCredentials { export const TokenStorage = { async getCredentials(): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - return localStorage.getItem(AUTH_KEY) ? JSON.parse(localStorage.getItem(AUTH_KEY)!) as AuthCredentials : null; + return localStorage.getItem(key) ? JSON.parse(localStorage.getItem(key)!) as AuthCredentials : null; + } + if (credentialsCache && credentialsCacheKey === key) { + try { + return JSON.parse(credentialsCache) as AuthCredentials; + } catch { + // Ignore cache parse errors, fall through to secure store read. + } } try { - const stored = await SecureStore.getItemAsync(AUTH_KEY); + const stored = await SecureStore.getItemAsync(key); if (!stored) return null; credentialsCache = stored; // Update cache + credentialsCacheKey = key; return JSON.parse(stored) as AuthCredentials; } catch (error) { console.error('Error getting credentials:', error); @@ -28,14 +44,16 @@ export const TokenStorage = { }, async setCredentials(credentials: AuthCredentials): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.setItem(AUTH_KEY, JSON.stringify(credentials)); + localStorage.setItem(key, JSON.stringify(credentials)); return true; } try { const json = JSON.stringify(credentials); - await SecureStore.setItemAsync(AUTH_KEY, json); + await SecureStore.setItemAsync(key, json); credentialsCache = json; // Update cache + credentialsCacheKey = key; return true; } catch (error) { console.error('Error setting credentials:', error); @@ -44,13 +62,15 @@ export const TokenStorage = { }, async removeCredentials(): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.removeItem(AUTH_KEY); + localStorage.removeItem(key); return true; } try { - await SecureStore.deleteItemAsync(AUTH_KEY); + await SecureStore.deleteItemAsync(key); credentialsCache = null; // Clear cache + credentialsCacheKey = null; return true; } catch (error) { console.error('Error removing credentials:', error); diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 49efa5671..8b363ef4d 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -5,8 +5,11 @@ import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; import type { Session } from './storageTypes'; import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; -const mmkv = new MMKV(); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const storageScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const mmkv = storageScope ? new MMKV({ id: scopedStorageId('default', storageScope) }) : new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; export type NewSessionAgentType = 'claude' | 'codex' | 'gemini'; diff --git a/sources/sync/serverConfig.ts b/sources/sync/serverConfig.ts index fedea04df..b52f452d0 100644 --- a/sources/sync/serverConfig.ts +++ b/sources/sync/serverConfig.ts @@ -1,7 +1,10 @@ import { MMKV } from 'react-native-mmkv'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; // Separate MMKV instance for server config that persists across logouts -const serverConfigStorage = new MMKV({ id: 'server-config' }); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const serverConfigScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const serverConfigStorage = new MMKV({ id: scopedStorageId('server-config', serverConfigScope) }); const SERVER_KEY = 'custom-server-url'; const DEFAULT_SERVER_URL = 'https://api.cluster-fluster.com'; diff --git a/sources/utils/storageScope.test.ts b/sources/utils/storageScope.test.ts new file mode 100644 index 000000000..bb2d15354 --- /dev/null +++ b/sources/utils/storageScope.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR, + normalizeStorageScope, + readStorageScopeFromEnv, + scopedStorageId, +} from './storageScope'; + +describe('storageScope', () => { + describe('normalizeStorageScope', () => { + it('returns null for non-strings and empty strings', () => { + expect(normalizeStorageScope(undefined)).toBeNull(); + expect(normalizeStorageScope(null)).toBeNull(); + expect(normalizeStorageScope(123)).toBeNull(); + expect(normalizeStorageScope('')).toBeNull(); + expect(normalizeStorageScope(' ')).toBeNull(); + }); + + it('sanitizes unsafe characters and clamps length', () => { + expect(normalizeStorageScope(' pr272-107 ')).toBe('pr272-107'); + expect(normalizeStorageScope('a/b:c')).toBe('a_b_c'); + expect(normalizeStorageScope('a__b')).toBe('a_b'); + + const long = 'x'.repeat(100); + expect(normalizeStorageScope(long)?.length).toBe(64); + }); + }); + + describe('readStorageScopeFromEnv', () => { + it('reads from EXPO_PUBLIC_HAPPY_STORAGE_SCOPE', () => { + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: 'stack-1' })).toBe('stack-1'); + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: ' ' })).toBeNull(); + }); + }); + + describe('scopedStorageId', () => { + it('returns baseId when scope is null', () => { + expect(scopedStorageId('auth_credentials', null)).toBe('auth_credentials'); + }); + + it('namespaces when scope is present', () => { + expect(scopedStorageId('auth_credentials', 'stack-1')).toBe('auth_credentials::stack-1'); + }); + }); +}); + diff --git a/sources/utils/storageScope.ts b/sources/utils/storageScope.ts new file mode 100644 index 000000000..bce4620d3 --- /dev/null +++ b/sources/utils/storageScope.ts @@ -0,0 +1,32 @@ +export const EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR = 'EXPO_PUBLIC_HAPPY_STORAGE_SCOPE'; + +/** + * Returns a sanitized storage scope suitable for identifiers/keys, or null. + * + * Notes: + * - This is intentionally conservative (stable, URL/key friendly). + * - If unset/empty, callers should behave exactly as they did before (no scoping). + */ +export function normalizeStorageScope(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + // Keep only safe characters to avoid backend/storage quirks (keychain, MMKV id, etc.) + // Replace everything else with '_' for stability. + const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_'); + const collapsed = sanitized.replace(/_+/g, '_'); + const clamped = collapsed.slice(0, 64); + return clamped || null; +} + +export function readStorageScopeFromEnv( + env: Record = process.env, +): string | null { + return normalizeStorageScope(env[EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]); +} + +export function scopedStorageId(baseId: string, scope: string | null): string { + return scope ? `${baseId}::${scope}` : baseId; +} + From 5a6c855155357df9cdaaaa86ac62a5726856aa4e Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 11:38:15 +0100 Subject: [PATCH 098/106] feat(env): add daemon env preview support --- sources/hooks/useEnvironmentVariables.ts | 125 +++++++++++++++++++++-- sources/sync/ops.ts | 77 ++++++++++++++ 2 files changed, 192 insertions(+), 10 deletions(-) diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index a012c46af..c6d0a4325 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { machineBash } from '@/sync/ops'; +import { machineBash, machinePreviewEnv, type EnvPreviewSecretsPolicy, type PreviewEnvValue } from '@/sync/ops'; // Re-export pure utility functions from envVarUtils for backwards compatibility export { resolveEnvVarSubstitution, extractEnvVarReferences } from './envVarUtils'; @@ -10,9 +10,26 @@ interface EnvironmentVariables { interface UseEnvironmentVariablesResult { variables: EnvironmentVariables; + meta: Record; + policy: EnvPreviewSecretsPolicy | null; + isPreviewEnvSupported: boolean; isLoading: boolean; } +interface UseEnvironmentVariablesOptions { + /** + * When provided, the daemon will compute an effective spawn environment: + * effective = { ...daemon.process.env, ...expand(extraEnv) } + * This makes previews exactly match what sessions will receive. + */ + extraEnv?: Record; + /** + * Marks variables as sensitive (at minimum). The daemon may also treat vars as sensitive + * based on name heuristics (TOKEN/KEY/etc). + */ + sensitiveHints?: Record; +} + /** * Queries environment variable values from the daemon's process environment. * @@ -36,18 +53,33 @@ interface UseEnvironmentVariablesResult { */ export function useEnvironmentVariables( machineId: string | null, - varNames: string[] + varNames: string[], + options?: UseEnvironmentVariablesOptions ): UseEnvironmentVariablesResult { const [variables, setVariables] = useState({}); + const [meta, setMeta] = useState>({}); + const [policy, setPolicy] = useState(null); + const [isPreviewEnvSupported, setIsPreviewEnvSupported] = useState(false); const [isLoading, setIsLoading] = useState(false); // Memoize sorted var names for stable dependency (avoid unnecessary re-queries) const sortedVarNames = useMemo(() => [...varNames].sort().join(','), [varNames]); + const extraEnvKey = useMemo(() => { + const entries = Object.entries(options?.extraEnv ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.extraEnv]); + const sensitiveHintsKey = useMemo(() => { + const entries = Object.entries(options?.sensitiveHints ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.sensitiveHints]); useEffect(() => { // Early exit conditions if (!machineId || varNames.length === 0) { setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } @@ -57,6 +89,7 @@ export function useEnvironmentVariables( const fetchVars = async () => { const results: EnvironmentVariables = {}; + const metaResults: Record = {}; // SECURITY: Validate all variable names to prevent bash injection // Only accept valid environment variable names: [A-Z_][A-Z0-9_]* @@ -65,10 +98,59 @@ export function useEnvironmentVariables( if (validVarNames.length === 0) { // No valid variables to query setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } + // Prefer daemon-native env preview if supported (more accurate + supports secret policy). + const preview = await machinePreviewEnv(machineId, { + keys: validVarNames, + extraEnv: options?.extraEnv, + sensitiveHints: options?.sensitiveHints, + }); + + if (cancelled) return; + + if (preview.supported) { + const response = preview.response; + validVarNames.forEach((name) => { + const entry = response.values[name]; + if (entry) { + metaResults[name] = entry; + results[name] = entry.value; + } else { + // Defensive fallback: treat as unset. + metaResults[name] = { value: null, isSet: false, isSensitive: false, display: 'unset' }; + results[name] = null; + } + }); + + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(response.policy); + setIsPreviewEnvSupported(true); + setIsLoading(false); + } + return; + } + + // Fallback (older daemon): use bash probing for non-sensitive variables only. + // Never fetch secret-like values into UI memory via bash. + const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + const sensitiveHints = options?.sensitiveHints ?? {}; + const safeVarNames = validVarNames.filter((name) => !SECRET_NAME_REGEX.test(name) && sensitiveHints[name] !== true); + + // Mark excluded keys as hidden (conservative). + validVarNames.forEach((name) => { + if (safeVarNames.includes(name)) return; + metaResults[name] = { value: null, isSet: true, isSensitive: true, display: 'hidden' }; + results[name] = null; + }); + // Query variables in a single machineBash() call. // // IMPORTANT: This runs inside the daemon process environment on the machine, because the @@ -86,11 +168,11 @@ export function useEnvironmentVariables( "}", "process.stdout.write(JSON.stringify(out));", ].join(""); - const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${validVarNames.join(' ')}`; + const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${safeVarNames.join(' ')}`; // Shell fallback uses `printenv` to distinguish unset vs empty via exit code. // Note: values containing newlines may not round-trip here; the node/JSON path preserves them. const shellFallback = [ - `for name in ${validVarNames.join(' ')}; do`, + `for name in ${safeVarNames.join(' ')}; do`, `if printenv "$name" >/dev/null 2>&1; then`, `printf "%s=%s\\n" "$name" "$(printenv "$name")";`, `else`, @@ -102,6 +184,17 @@ export function useEnvironmentVariables( const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; try { + if (safeVarNames.length === 0) { + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); + setIsLoading(false); + } + return; + } + const result = await machineBash(machineId, command, '/'); if (cancelled) return; @@ -118,7 +211,7 @@ export function useEnvironmentVariables( const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1); try { const parsed = JSON.parse(jsonSlice) as Record; - validVarNames.forEach((name) => { + safeVarNames.forEach((name) => { results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; }); } catch { @@ -143,14 +236,14 @@ export function useEnvironmentVariables( } // Ensure all requested variables have entries (even if missing from output) - validVarNames.forEach(name => { + safeVarNames.forEach(name => { if (!(name in results)) { results[name] = null; } }); } else { // Bash command failed - mark all variables as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } @@ -158,13 +251,25 @@ export function useEnvironmentVariables( if (cancelled) return; // RPC error (network, encryption, etc.) - mark all as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } if (!cancelled) { + safeVarNames.forEach((name) => { + const value = results[name]; + metaResults[name] = { + value, + isSet: value !== null, + isSensitive: false, + display: value === null ? 'unset' : 'full', + }; + }); setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); } }; @@ -175,7 +280,7 @@ export function useEnvironmentVariables( return () => { cancelled = true; }; - }, [machineId, sortedVarNames]); + }, [extraEnvKey, machineId, sensitiveHintsKey, sortedVarNames]); - return { variables, isLoading }; + return { variables, meta, policy, isPreviewEnvSupported, isLoading }; } diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index 6a29d6dd4..eeb6bbccc 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -237,6 +237,83 @@ export async function machineBash( } } +export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full'; + +export interface PreviewEnvValue { + value: string | null; + isSet: boolean; + isSensitive: boolean; + display: 'full' | 'redacted' | 'hidden' | 'unset'; +} + +export interface PreviewEnvResponse { + policy: EnvPreviewSecretsPolicy; + values: Record; +} + +interface PreviewEnvRequest { + keys: string[]; + extraEnv?: Record; + sensitiveHints?: Record; +} + +export type MachinePreviewEnvResult = + | { supported: true; response: PreviewEnvResponse } + | { supported: false }; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Preview environment variables exactly as the daemon will spawn them. + * + * This calls the daemon's `preview-env` RPC (if supported). The daemon computes: + * - effective env = { ...daemon.process.env, ...expand(extraEnv) } + * - applies `HAPPY_ENV_PREVIEW_SECRETS` policy for sensitive variables + * + * If the daemon is old and doesn't support `preview-env`, returns `{ supported: false }`. + */ +export async function machinePreviewEnv( + machineId: string, + params: PreviewEnvRequest +): Promise { + try { + const result = await apiSocket.machineRPC( + machineId, + 'preview-env', + params + ); + + if (isPlainObject(result) && typeof result.error === 'string') { + // Older daemons (or errors) return an encrypted `{ error: ... }` payload. + // Treat method-not-found as “unsupported” and fallback to bash-based probing. + if (result.error === 'Method not found') { + return { supported: false }; + } + // For any other error, degrade gracefully in UI by using fallback behavior. + return { supported: false }; + } + + // Basic shape validation (be defensive for mixed daemon versions). + if ( + !isPlainObject(result) || + (result.policy !== 'none' && result.policy !== 'redacted' && result.policy !== 'full') || + !isPlainObject(result.values) + ) { + return { supported: false }; + } + + const response: PreviewEnvResponse = { + policy: result.policy as EnvPreviewSecretsPolicy, + values: result.values as unknown as Record, + }; + return { supported: true, response }; + } catch { + return { supported: false }; + } +} + /** * Update machine metadata with optimistic concurrency control and automatic retry */ From 03df1f96fca214d82093a9b1d317d26d6fd62549 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 11:38:23 +0100 Subject: [PATCH 099/106] fix(env): make env previews policy-aware --- .../components/EnvironmentVariableCard.tsx | 66 +++++++++++---- .../EnvironmentVariablesList.test.ts | 25 ++++-- .../components/EnvironmentVariablesList.tsx | 72 +++++++++++----- .../EnvironmentVariablesPreviewModal.tsx | 83 +++++++++++++++---- 4 files changed, 186 insertions(+), 60 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 599375a9d..c5c5f45e6 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -6,13 +6,15 @@ import { Typography } from '@/constants/Typography'; import { Switch } from '@/components/Switch'; import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/envVarTemplate'; import { t } from '@/text'; +import type { EnvPreviewSecretsPolicy, PreviewEnvValue } from '@/sync/ops'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; index: number; machineId: string | null; machineName?: string | null; - machineEnv?: Record; + machineEnv?: Record; + machineEnvPolicy?: EnvPreviewSecretsPolicy | null; isMachineEnvLoading?: boolean; expectedValue?: string; // From profile documentation description?: string; // Variable description @@ -60,6 +62,7 @@ export function EnvironmentVariableCard({ machineId, machineName, machineEnv, + machineEnvPolicy = null, isMachineEnvLoading = false, expectedValue, description, @@ -84,7 +87,8 @@ export function EnvironmentVariableCard({ setDefaultValue(parsed.defaultValue); }, [parsed.defaultValue, parsed.remoteVariableName, parsed.useRemoteVariable]); - const remoteValue = machineEnv?.[remoteVariableName]; + const remoteEntry = remoteVariableName ? machineEnv?.[remoteVariableName] : undefined; + const remoteValue = remoteEntry?.value; const hasFallback = defaultValue.trim() !== ''; const computedOperator: EnvVarTemplateOperator | null = hasFallback ? (fallbackOperator ?? ':-') : null; const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); @@ -111,16 +115,46 @@ export function EnvironmentVariableCard({ ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) : defaultValue; - const resolvedSessionValue = - isSecret - ? (useRemoteVariable && remoteVariableName - ? t('profiles.environmentVariables.preview.secretValueHidden', { - value: formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue !== '' ? '***' : '', operator: computedOperator }), - }) - : (defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue)) - : (useRemoteVariable && machineId && remoteValue !== undefined - ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : emptyValue) : remoteValue) - : (computedTemplateValue || emptyValue)); + const targetEntry = machineEnv?.[variable.name]; + const resolvedSessionValue = (() => { + // Prefer daemon-computed effective value for the target env var (matches spawn exactly). + if (machineId && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + return targetEntry.value ?? emptyValue; + } + if (targetEntry.display === 'hidden') { + return t('profiles.environmentVariables.preview.hiddenValue'); + } + return emptyValue; // unset + } + + // Fallback (no machine context / older daemon): best-effort preview. + if (isSecret) { + // If daemon policy is known and allows showing secrets, targetEntry would have handled it above. + // Otherwise, keep secrets hidden in UI. + if (useRemoteVariable && remoteVariableName) { + return t('profiles.environmentVariables.preview.secretValueHidden', { + value: formatEnvVarTemplate({ + sourceVar: remoteVariableName, + fallback: defaultValue !== '' ? '***' : '', + operator: computedOperator, + }), + }); + } + return defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue; + } + + if (useRemoteVariable && machineId && remoteEntry !== undefined) { + // Note: remoteEntry may be hidden/redacted by daemon policy. We do NOT treat hidden as missing. + if (remoteEntry.display === 'hidden') return t('profiles.environmentVariables.preview.hiddenValue'); + if (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') { + return hasFallback ? defaultValue : emptyValue; + } + return remoteValue; + } + + return computedTemplateValue || emptyValue; + })(); return ( @@ -184,7 +218,7 @@ export function EnvironmentVariableCard({ /> {/* Security message for secrets */} - {isSecret && ( + {isSecret && (machineEnvPolicy === null || machineEnvPolicy === 'none') && ( {t('profiles.environmentVariables.card.secretNotRetrieved')} @@ -242,11 +276,11 @@ export function EnvironmentVariableCard({ {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - {isMachineEnvLoading || remoteValue === undefined ? ( + {isMachineEnvLoading || remoteEntry === undefined ? ( {t('profiles.environmentVariables.card.checkingMachine', { machine: machineLabel })} - ) : (remoteValue === null || remoteValue === '') ? ( + ) : (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') ? ( {remoteValue === '' ? ( hasFallback @@ -277,7 +311,7 @@ export function EnvironmentVariableCard({ {t('profiles.environmentVariables.preview.sessionWillReceive', { name: variable.name, - value: resolvedSessionValue, + value: resolvedSessionValue ?? emptyValue, })} diff --git a/sources/components/EnvironmentVariablesList.test.ts b/sources/components/EnvironmentVariablesList.test.ts index d8c704d56..0385756c6 100644 --- a/sources/components/EnvironmentVariablesList.test.ts +++ b/sources/components/EnvironmentVariablesList.test.ts @@ -24,13 +24,16 @@ vi.mock('react-native', () => ({ }, })); -const useEnvironmentVariablesMock = vi.fn((_machineId: any, _refs: any) => ({ +const useEnvironmentVariablesMock = vi.fn((_machineId: any, _refs: any, _options?: any) => ({ variables: {}, + meta: {}, + policy: null, + isPreviewEnvSupported: false, isLoading: false, })); vi.mock('@/hooks/useEnvironmentVariables', () => ({ - useEnvironmentVariables: (machineId: any, refs: any) => useEnvironmentVariablesMock(machineId, refs), + useEnvironmentVariables: (machineId: any, refs: any, options?: any) => useEnvironmentVariablesMock(machineId, refs, options), })); vi.mock('@expo/vector-icons', () => { @@ -92,7 +95,7 @@ describe('EnvironmentVariablesList', () => { useEnvironmentVariablesMock.mockClear(); }); - it('does not query machine env for documented secret refs', () => { + it('marks documented secret refs as sensitive hints (daemon-controlled disclosure)', () => { const profileDocs: ProfileDocumentation = { description: 'test', environmentVariables: [ @@ -121,9 +124,12 @@ describe('EnvironmentVariablesList', () => { }); expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); - const [_machineId, refs] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[]]; - expect(refs).toContain('HOME'); - expect(refs).not.toContain('MAGIC'); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('FOO'); + expect(keys).toContain('BAR'); + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(options?.sensitiveHints?.MAGIC).toBe(true); }); it('treats a documented-secret variable name as secret even when its value references another var', () => { @@ -153,8 +159,11 @@ describe('EnvironmentVariablesList', () => { }); expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); - const [_machineId, refs] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[]]; - expect(refs).toEqual([]); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(options?.sensitiveHints?.MAGIC).toBe(true); + expect(options?.sensitiveHints?.HOME).toBe(true); const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); expect(cards?.length).toBe(1); diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index 3f1fd954f..d52c132d9 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -19,6 +19,7 @@ export interface EnvironmentVariablesListProps { } const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; /** * Complete environment variables section with title, add button, and editable cards @@ -34,10 +35,17 @@ export function EnvironmentVariablesList({ const { theme } = useUnistyles(); const styles = stylesheet; - // 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 extractVarRefsFromValue = React.useCallback((value: string): string[] => { + const refs: string[] = []; + if (!value) return refs; + let match: RegExpExecArray | null; + // Reset regex state defensively (global regex). + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; }, []); const documentedSecretNames = React.useMemo(() => { @@ -50,22 +58,46 @@ export function EnvironmentVariablesList({ ); }, [profileDocs]); - const resolvedEnvVarRefs = React.useMemo(() => { - const refs = new Set(); + const { keysToQuery, extraEnv, sensitiveHints } = React.useMemo(() => { + const keys = new Set(); + const env: Record = {}; + const hints: Record = {}; + + const isSecretName = (name: string) => + documentedSecretNames.has(name) || SECRET_NAME_REGEX.test(name); + environmentVariables.forEach((envVar) => { - 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; - if (documentedSecretNames.has(ref) || documentedSecretNames.has(envVar.name)) return; - refs.add(ref); + keys.add(envVar.name); + env[envVar.name] = envVar.value; + + const valueRefs = extractVarRefsFromValue(envVar.value); + valueRefs.forEach((ref) => keys.add(ref)); + + // Mark sensitivity for both the target var and any referenced vars. + const isSensitive = isSecretName(envVar.name) || valueRefs.some(isSecretName); + if (isSensitive) { + hints[envVar.name] = true; + valueRefs.forEach((ref) => { hints[ref] = true; }); + } else { + // Still mark direct secret-like names as sensitive, even without docs. + if (SECRET_NAME_REGEX.test(envVar.name)) hints[envVar.name] = true; + valueRefs.forEach((ref) => { + if (SECRET_NAME_REGEX.test(ref)) hints[ref] = true; + }); + } }); - return Array.from(refs); - }, [documentedSecretNames, environmentVariables, extractVarNameFromValue]); - const { variables: machineEnv, isLoading: isMachineEnvLoading } = useEnvironmentVariables( + return { + keysToQuery: Array.from(keys), + extraEnv: env, + sensitiveHints: hints, + }; + }, [documentedSecretNames, environmentVariables, extractVarRefsFromValue]); + + const { meta: machineEnv, isLoading: isMachineEnvLoading, policy: machineEnvPolicy } = useEnvironmentVariables( machineId, - resolvedEnvVarRefs, + keysToQuery, + { extraEnv, sensitiveHints }, ); // Add variable inline form state @@ -156,14 +188,15 @@ export function EnvironmentVariablesList({ {environmentVariables.length > 0 && ( {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0] ?? null; const primaryDocs = getDocumentation(envVar.name); - const refDocs = varNameFromValue ? getDocumentation(varNameFromValue) : undefined; + const refDocs = primaryRef ? getDocumentation(primaryRef) : undefined; const isSecret = primaryDocs.isSecret || refDocs?.isSecret || SECRET_NAME_REGEX.test(envVar.name) || - SECRET_NAME_REGEX.test(varNameFromValue || ''); + refs.some((ref) => SECRET_NAME_REGEX.test(ref)); const expectedValue = primaryDocs.expectedValue ?? refDocs?.expectedValue; const description = primaryDocs.description ?? refDocs?.description; @@ -175,6 +208,7 @@ export function EnvironmentVariablesList({ machineId={machineId} machineName={machineName ?? null} machineEnv={machineEnv} + machineEnvPolicy={machineEnvPolicy} isMachineEnvLoading={isMachineEnvLoading} expectedValue={expectedValue} description={description} diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index cc0aa9625..b726056e0 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -21,6 +21,20 @@ function isSecretLike(name: string) { return /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i.test(name); } +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + +function extractVarRefsFromValue(value: string): string[] { + const refs: string[] = []; + if (!value) return refs; + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; +} + const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { width: '92%', @@ -108,17 +122,31 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev const refsToQuery = React.useMemo(() => { const refs = new Set(); envVarEntries.forEach((envVar) => { - const parsed = parseEnvVarTemplate(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); - } + // Query both target keys and any referenced keys so preview can show the effective spawned value. + refs.add(envVar.name); + extractVarRefsFromValue(envVar.value).forEach((ref) => refs.add(ref)); }); return Array.from(refs); }, [envVarEntries]); - const { variables: machineEnv } = useEnvironmentVariables(props.machineId, refsToQuery); + const sensitiveHints = React.useMemo(() => { + const hints: Record = {}; + envVarEntries.forEach((envVar) => { + const refs = extractVarRefsFromValue(envVar.value); + const isSensitive = isSecretLike(envVar.name) || refs.some(isSecretLike); + if (isSensitive) { + hints[envVar.name] = true; + refs.forEach((ref) => { hints[ref] = true; }); + } + }); + return hints; + }, [envVarEntries]); + + const { meta: machineEnv, policy: machineEnvPolicy } = useEnvironmentVariables( + props.machineId, + refsToQuery, + { extraEnv: props.environmentVariables, sensitiveHints }, + ); const title = props.profileName ? t('profiles.environmentVariables.previewModal.titleWithProfile', { profileName: props.profileName }) @@ -179,24 +207,38 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev {envVarEntries.map((envVar, idx) => { const parsed = parseEnvVarTemplate(envVar.value); - const secret = isSecretLike(envVar.name) || (parsed?.sourceVar ? isSecretLike(parsed.sourceVar) : false); + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0]; + const secret = isSecretLike(envVar.name) || (primaryRef ? isSecretLike(primaryRef) : false); const hasMachineContext = Boolean(props.machineId); - const resolvedValue = parsed?.sourceVar ? machineEnv[parsed.sourceVar] : undefined; - const isMachineBased = Boolean(parsed?.sourceVar); + const targetEntry = machineEnv?.[envVar.name]; + const resolvedValue = parsed?.sourceVar ? machineEnv?.[parsed.sourceVar] : undefined; + const isMachineBased = Boolean(refs.length > 0); let displayValue: string; - if (secret) { - displayValue = '•••'; + if (hasMachineContext && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + displayValue = targetEntry.value ?? emptyValue; + } else if (targetEntry.display === 'hidden') { + displayValue = '•••'; + } else { + displayValue = emptyValue; + } + } else if (secret) { + // If daemon policy is known and allows showing secrets, we would have used targetEntry above. + displayValue = machineEnvPolicy === 'full' || machineEnvPolicy === 'redacted' ? (envVar.value || emptyValue) : '•••'; } else if (parsed) { if (!hasMachineContext) { displayValue = formatEnvVarTemplate(parsed); } else if (resolvedValue === undefined) { displayValue = `${formatEnvVarTemplate(parsed)} ${t('profiles.environmentVariables.previewModal.checkingSuffix')}`; - } else if (resolvedValue === null || resolvedValue === '') { + } else if (resolvedValue.display === 'hidden') { + displayValue = '•••'; + } else if (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '') { displayValue = parsed.fallback ? parsed.fallback : emptyValue; } else { - displayValue = resolvedValue; + displayValue = resolvedValue.value ?? emptyValue; } } else { displayValue = envVar.value || emptyValue; @@ -208,8 +250,10 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev 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'; + if (parsed?.sourceVar && resolvedValue === undefined) return 'checking'; + if (parsed?.sourceVar && resolvedValue && (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '')) { + return parsed?.fallback ? 'fallback' : 'missing'; + } return 'machine'; })(); @@ -242,13 +286,18 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev return ; })(); + const canCopy = (() => { + if (secret) return false; + return Boolean(displayValue); + })(); + return ( Date: Sat, 17 Jan 2026 11:38:30 +0100 Subject: [PATCH 100/106] chore(expo): allow optional app config overrides --- app.config.js | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/app.config.js b/app.config.js index 655f8542a..8a4505280 100644 --- a/app.config.js +++ b/app.config.js @@ -1,14 +1,28 @@ const variant = process.env.APP_ENV || 'development'; -const name = { - development: "Happy (dev)", - preview: "Happy (preview)", - production: "Happy" -}[variant]; -const bundleId = { - development: "com.slopus.happy.dev", - preview: "com.slopus.happy.preview", - production: "com.ex3ndr.happy" -}[variant]; + +// Allow opt-in overrides for local dev tooling without changing upstream defaults. +const nameOverride = (process.env.EXPO_APP_NAME || '').trim(); +const bundleIdOverride = (process.env.EXPO_APP_BUNDLE_ID || '').trim(); + +const name = + nameOverride || + { + development: "Happy (dev)", + preview: "Happy (preview)", + production: "Happy" + }[variant]; +const bundleId = + bundleIdOverride || + { + development: "com.slopus.happy.dev", + preview: "com.slopus.happy.preview", + production: "com.ex3ndr.happy" + }[variant]; +// NOTE: +// The URL scheme is used for deep linking *and* by the Expo development client launcher flow. +// Keep the default stable for upstream users, but allow opt-in overrides for local dev variants +// (e.g. to avoid iOS scheme collisions between multiple installs). +const scheme = (process.env.EXPO_APP_SCHEME || '').trim() || "happy"; export default { expo: { @@ -18,7 +32,7 @@ export default { runtimeVersion: "18", orientation: "default", icon: "./sources/assets/images/icon.png", - scheme: "happy", + scheme, userInterfaceStyle: "automatic", newArchEnabled: true, notification: { From 3e0e176ef9e7b84c84d12f67432696d6c4f22500 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:08:55 +0100 Subject: [PATCH 101/106] fix(auth): harden token storage on web --- sources/auth/tokenStorage.test.ts | 73 +++++++++++++++++++++++++++++++ sources/auth/tokenStorage.ts | 29 +++++++++--- 2 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 sources/auth/tokenStorage.test.ts diff --git a/sources/auth/tokenStorage.test.ts b/sources/auth/tokenStorage.test.ts new file mode 100644 index 000000000..f0593e8d9 --- /dev/null +++ b/sources/auth/tokenStorage.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('react-native', () => ({ + Platform: { OS: 'web' }, +})); + +vi.mock('expo-secure-store', () => ({})); + +function installLocalStorage() { + const store = new Map(); + const getItem = vi.fn((key: string) => store.get(key) ?? null); + const setItem = vi.fn((key: string, value: string) => { + store.set(key, value); + }); + const removeItem = vi.fn((key: string) => { + store.delete(key); + }); + + Object.defineProperty(globalThis, 'localStorage', { + value: { getItem, setItem, removeItem }, + configurable: true, + }); + + return { store, getItem, setItem, removeItem }; +} + +describe('TokenStorage (web)', () => { + beforeEach(() => { + vi.resetModules(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when localStorage JSON is invalid', async () => { + const { setItem } = installLocalStorage(); + setItem('auth_credentials', '{not valid json'); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.getCredentials()).resolves.toBeNull(); + }); + + it('returns false when localStorage.setItem throws', async () => { + installLocalStorage(); + (globalThis.localStorage.setItem as any).mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.setCredentials({ token: 't', secret: 's' })).resolves.toBe(false); + }); + + it('returns false when localStorage.removeItem throws', async () => { + installLocalStorage(); + (globalThis.localStorage.removeItem as any).mockImplementation(() => { + throw new Error('SecurityError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.removeCredentials()).resolves.toBe(false); + }); + + it('calls localStorage.getItem at most once per getCredentials call', async () => { + const { getItem, setItem } = installLocalStorage(); + setItem('auth_credentials', JSON.stringify({ token: 't', secret: 's' })); + + const { TokenStorage } = await import('./tokenStorage'); + await TokenStorage.getCredentials(); + expect(getItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/sources/auth/tokenStorage.ts b/sources/auth/tokenStorage.ts index a85ae83d8..a557a43aa 100644 --- a/sources/auth/tokenStorage.ts +++ b/sources/auth/tokenStorage.ts @@ -22,7 +22,14 @@ export const TokenStorage = { async getCredentials(): Promise { const key = getAuthKey(); if (Platform.OS === 'web') { - return localStorage.getItem(key) ? JSON.parse(localStorage.getItem(key)!) as AuthCredentials : null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as AuthCredentials; + } catch (error) { + console.error('Error getting credentials:', error); + return null; + } } if (credentialsCache && credentialsCacheKey === key) { try { @@ -46,8 +53,13 @@ export const TokenStorage = { async setCredentials(credentials: AuthCredentials): Promise { const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.setItem(key, JSON.stringify(credentials)); - return true; + try { + localStorage.setItem(key, JSON.stringify(credentials)); + return true; + } catch (error) { + console.error('Error setting credentials:', error); + return false; + } } try { const json = JSON.stringify(credentials); @@ -64,8 +76,13 @@ export const TokenStorage = { async removeCredentials(): Promise { const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.removeItem(key); - return true; + try { + localStorage.removeItem(key); + return true; + } catch (error) { + console.error('Error removing credentials:', error); + return false; + } } try { await SecureStore.deleteItemAsync(key); @@ -77,4 +94,4 @@ export const TokenStorage = { return false; } }, -}; \ No newline at end of file +}; From 5061512dac02b9b3353ec60028496364a21182a2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:09:06 +0100 Subject: [PATCH 102/106] fix(persistence): validate draft modes from storage --- sources/sync/permissionTypes.test.ts | 41 +++++++++++++++++++- sources/sync/permissionTypes.ts | 56 ++++++++++++++++++++-------- sources/sync/persistence.test.ts | 40 ++++++++++++++++++++ sources/sync/persistence.ts | 11 ++++-- 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/sources/sync/permissionTypes.test.ts b/sources/sync/permissionTypes.test.ts index 0008428e1..c585b4c41 100644 --- a/sources/sync/permissionTypes.test.ts +++ b/sources/sync/permissionTypes.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { PermissionMode } from './permissionTypes'; -import { normalizePermissionModeForAgentFlavor } from './permissionTypes'; +import { + isModelMode, + isPermissionMode, + normalizePermissionModeForAgentFlavor, + normalizeProfileDefaultPermissionMode, +} from './permissionTypes'; describe('normalizePermissionModeForAgentFlavor', () => { it('clamps non-codex permission modes to default for codex', () => { @@ -24,3 +29,37 @@ describe('normalizePermissionModeForAgentFlavor', () => { }); }); +describe('isPermissionMode', () => { + it('returns true for valid permission modes', () => { + expect(isPermissionMode('default')).toBe(true); + expect(isPermissionMode('read-only')).toBe(true); + expect(isPermissionMode('plan')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isPermissionMode('bogus')).toBe(false); + expect(isPermissionMode(null)).toBe(false); + expect(isPermissionMode(123)).toBe(false); + }); +}); + +describe('normalizeProfileDefaultPermissionMode', () => { + it('clamps codex-like modes to default for profile defaultPermissionMode', () => { + expect(normalizeProfileDefaultPermissionMode('read-only')).toBe('default'); + expect(normalizeProfileDefaultPermissionMode('safe-yolo')).toBe('default'); + expect(normalizeProfileDefaultPermissionMode('yolo')).toBe('default'); + }); +}); + +describe('isModelMode', () => { + it('returns true for valid model modes', () => { + expect(isModelMode('default')).toBe(true); + expect(isModelMode('adaptiveUsage')).toBe(true); + expect(isModelMode('gemini-2.5-pro')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isModelMode('bogus')).toBe(false); + expect(isModelMode(null)).toBe(false); + }); +}); diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts index a286fa2fa..b85972a1d 100644 --- a/sources/sync/permissionTypes.ts +++ b/sources/sync/permissionTypes.ts @@ -7,11 +7,25 @@ export type PermissionMode = | 'safe-yolo' | 'yolo'; +const ALL_PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const; export const CODEX_LIKE_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const; export type AgentFlavor = 'claude' | 'codex' | 'gemini'; +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === 'string' && (ALL_PERMISSION_MODES as readonly string[]).includes(value); +} + export function normalizePermissionModeForAgentFlavor(mode: PermissionMode, flavor: AgentFlavor): PermissionMode { if (flavor === 'codex' || flavor === 'gemini') { return (CODEX_LIKE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; @@ -19,18 +33,30 @@ export function normalizePermissionModeForAgentFlavor(mode: PermissionMode, flav return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; } -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'; +export function normalizeProfileDefaultPermissionMode(mode: PermissionMode | null | undefined): PermissionMode { + if (!mode) return 'default'; + return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; +} + +export const MODEL_MODES = [ + 'default', + 'adaptiveUsage', + 'sonnet', + 'opus', + 'gpt-5-codex-high', + 'gpt-5-codex-medium', + 'gpt-5-codex-low', + 'gpt-5-minimal', + 'gpt-5-low', + 'gpt-5-medium', + 'gpt-5-high', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +] as const; + +export type ModelMode = (typeof MODEL_MODES)[number]; + +export function isModelMode(value: unknown): value is ModelMode { + return typeof value === 'string' && (MODEL_MODES as readonly string[]).includes(value); +} diff --git a/sources/sync/persistence.test.ts b/sources/sync/persistence.test.ts index 06a318902..0e15b8c3c 100644 --- a/sources/sync/persistence.test.ts +++ b/sources/sync/persistence.test.ts @@ -51,6 +51,46 @@ describe('persistence', () => { }); describe('new session draft', () => { + it('preserves valid non-session modelMode values', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'claude', + permissionMode: 'default', + modelMode: 'adaptiveUsage', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('adaptiveUsage'); + }); + + it('clamps invalid permissionMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'bogus', + modelMode: 'default', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.permissionMode).toBe('default'); + }); + it('clamps invalid modelMode to default', () => { store.set( 'new-session-draft-v1', diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 8b363ef4d..afe07faca 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -4,7 +4,7 @@ import { LocalSettings, localSettingsDefaults, localSettingsParse } from './loca import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; import type { Session } from './storageTypes'; -import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { isModelMode, isPermissionMode, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; @@ -17,6 +17,9 @@ export type NewSessionSessionType = 'simple' | 'worktree'; type SessionModelMode = NonNullable; +// NOTE: +// This set must stay in sync with the configurable Session model modes. +// TypeScript will catch invalid entries here, but it won't force adding new Session modes. const SESSION_MODEL_MODES = new Set([ 'default', 'gemini-2.5-pro', @@ -163,10 +166,10 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' ? parsed.agentType : 'claude'; - const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string' - ? (parsed.permissionMode as PermissionMode) + const permissionMode: PermissionMode = isPermissionMode(parsed.permissionMode) + ? parsed.permissionMode : 'default'; - const modelMode: ModelMode = isSessionModelMode(parsed.modelMode) + const modelMode: ModelMode = isModelMode(parsed.modelMode) ? parsed.modelMode : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; From b7e768d7324451ee2445425536d5e49e69de3737 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:09:19 +0100 Subject: [PATCH 103/106] fix(i18n): localize search accessibility labels --- sources/components/SearchHeader.tsx | 3 ++- sources/components/SearchableListSelector.tsx | 2 +- sources/text/README.md | 4 ++++ sources/text/translations/ca.ts | 2 ++ sources/text/translations/en.ts | 2 ++ sources/text/translations/es.ts | 2 ++ sources/text/translations/it.ts | 2 ++ sources/text/translations/ja.ts | 2 ++ sources/text/translations/pl.ts | 2 ++ sources/text/translations/pt.ts | 2 ++ sources/text/translations/ru.ts | 2 ++ sources/text/translations/zh-Hans.ts | 2 ++ 12 files changed, 25 insertions(+), 2 deletions(-) diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 1f3c540c9..458c26bad 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { layout } from '@/components/layout'; +import { t } from '@/text'; export interface SearchHeaderProps { value: string; @@ -107,7 +108,7 @@ export function SearchHeader({ onPress={() => onChangeText('')} hitSlop={8} accessibilityRole="button" - accessibilityLabel="Clear search" + accessibilityLabel={t('common.clearSearch')} > (props: SearchableListSelectorProps) )} {shouldRenderAllGroup && ( - + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} {filteredItems.length === 0 ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) diff --git a/sources/text/README.md b/sources/text/README.md index 29f98e0c8..38551135d 100644 --- a/sources/text/README.md +++ b/sources/text/README.md @@ -100,6 +100,10 @@ export const en = { ### `_types.ts` Contains the TypeScript types derived from the English translation structure. +This keeps the canonical translation object (`translations/en.ts`) separate from the type-level API: +- `Translations` / `TranslationStructure` are derived from `en` and used to type-check other locales. +- `TranslationKey` / `TranslationParams` are derived from `Translations` (in `index.ts`) to type `t(...)`. + ### `index.ts` Main module with the `t` function and utilities: - `t()` - Main translation function with strict typing diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 22eb65b2b..21e3aeac4 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -66,7 +66,9 @@ export const ca: TranslationStructure = { delete: 'Elimina', optional: 'Opcional', noMatches: 'Sense coincidències', + all: 'All', machine: 'màquina', + clearSearch: 'Clear search', }, profile: { diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index c1df216fa..08a29e0a0 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -79,7 +79,9 @@ export const en = { delete: 'Delete', optional: 'optional', noMatches: 'No matches', + all: 'All', machine: 'machine', + clearSearch: 'Clear search', }, profile: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 7b7345d29..406db5501 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -66,7 +66,9 @@ export const es: TranslationStructure = { delete: 'Eliminar', optional: 'opcional', noMatches: 'Sin coincidencias', + all: 'All', machine: 'máquina', + clearSearch: 'Clear search', }, profile: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index a4e95036f..d1bf3aeca 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -65,7 +65,9 @@ export const it: TranslationStructure = { delete: 'Elimina', optional: 'opzionale', noMatches: 'Nessuna corrispondenza', + all: 'All', machine: 'macchina', + clearSearch: 'Clear search', saveAs: 'Salva con nome', }, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 852f30795..eb77cd0ef 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -58,7 +58,9 @@ export const ja: TranslationStructure = { delete: '削除', optional: '任意', noMatches: '一致するものがありません', + all: 'All', machine: 'マシン', + clearSearch: 'Clear search', saveAs: '名前を付けて保存', }, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4e97e9848..51fb6106d 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -77,7 +77,9 @@ export const pl: TranslationStructure = { delete: 'Usuń', optional: 'opcjonalnie', noMatches: 'Brak dopasowań', + all: 'All', machine: 'maszyna', + clearSearch: 'Clear search', }, profile: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index f386edffe..fb00f8da2 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -66,7 +66,9 @@ export const pt: TranslationStructure = { delete: 'Excluir', optional: 'Opcional', noMatches: 'Nenhuma correspondência', + all: 'All', machine: 'máquina', + clearSearch: 'Clear search', }, profile: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index df521f9ea..5234d4a60 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -77,7 +77,9 @@ export const ru: TranslationStructure = { delete: 'Удалить', optional: 'необязательно', noMatches: 'Нет совпадений', + all: 'All', machine: 'машина', + clearSearch: 'Clear search', }, connect: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index ccff0cbd7..00affc97b 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -68,7 +68,9 @@ export const zhHans: TranslationStructure = { delete: '删除', optional: '可选的', noMatches: '无匹配结果', + all: 'All', machine: '机器', + clearSearch: 'Clear search', }, profile: { From 401e64bea8fdd06525db3e66689954653c8f7116 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:09:32 +0100 Subject: [PATCH 104/106] chore(expo): default unknown APP_ENV to dev --- app.config.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/app.config.js b/app.config.js index 8a4505280..112c9e725 100644 --- a/app.config.js +++ b/app.config.js @@ -4,20 +4,21 @@ const variant = process.env.APP_ENV || 'development'; const nameOverride = (process.env.EXPO_APP_NAME || '').trim(); const bundleIdOverride = (process.env.EXPO_APP_BUNDLE_ID || '').trim(); -const name = - nameOverride || - { - development: "Happy (dev)", - preview: "Happy (preview)", - production: "Happy" - }[variant]; -const bundleId = - bundleIdOverride || - { - development: "com.slopus.happy.dev", - preview: "com.slopus.happy.preview", - production: "com.ex3ndr.happy" - }[variant]; +const namesByVariant = { + development: "Happy (dev)", + preview: "Happy (preview)", + production: "Happy" +}; +const bundleIdsByVariant = { + development: "com.slopus.happy.dev", + preview: "com.slopus.happy.preview", + production: "com.ex3ndr.happy" +}; + +// If APP_ENV is unknown, fall back to development-safe defaults to avoid generating +// an invalid Expo config with undefined name/bundle id. +const name = nameOverride || namesByVariant[variant] || namesByVariant.development; +const bundleId = bundleIdOverride || bundleIdsByVariant[variant] || bundleIdsByVariant.development; // NOTE: // The URL scheme is used for deep linking *and* by the Expo development client launcher flow. // Keep the default stable for upstream users, but allow opt-in overrides for local dev variants @@ -188,4 +189,4 @@ export default { }, owner: "bulkacorp" } -}; \ No newline at end of file +}; From f9676f749c751026e025396458ce89a7fe8c4086 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:09:50 +0100 Subject: [PATCH 105/106] fix(ui): address reviewer nits --- sources/app/(app)/new/pick/path.test.tsx | 76 ++++++++ sources/app/(app)/new/pick/path.tsx | 3 +- sources/app/(app)/settings/profiles.tsx | 216 +++++++++++------------ sources/components/AgentInput.tsx | 6 +- sources/components/ItemGroup.tsx | 6 +- sources/components/ProfileEditForm.tsx | 29 +-- sources/components/tools/knownTools.tsx | 4 +- sources/hooks/useEnvironmentVariables.ts | 3 +- 8 files changed, 214 insertions(+), 129 deletions(-) create mode 100644 sources/app/(app)/new/pick/path.test.tsx diff --git a/sources/app/(app)/new/pick/path.test.tsx b/sources/app/(app)/new/pick/path.test.tsx new file mode 100644 index 000000000..4f181a4a5 --- /dev/null +++ b/sources/app/(app)/new/pick/path.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import renderer from 'react-test-renderer'; + +let lastPathSelectorProps: any = null; + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', +})); + +vi.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useRouter: () => ({ back: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }) }), + useLocalSearchParams: () => ({ machineId: 'm1', selectedPath: '/tmp' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } } }), + StyleSheet: { create: (fn: any) => fn({ colors: { textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } }) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/ItemList', () => ({ + ItemList: ({ children }: any) => <>{children}, +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 900 }, +})); + +vi.mock('@/components/SearchHeader', () => ({ + SearchHeader: () => null, +})); + +vi.mock('@/components/newSession/PathSelector', () => ({ + PathSelector: (props: any) => { + lastPathSelectorProps = props; + return null; + }, +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => [{ id: 'm1', metadata: { homeDir: '/home' } }], + useSessions: () => [], + useSetting: (key: string) => { + if (key === 'recentMachinePaths') return []; + if (key === 'usePathPickerSearch') return false; + return null; + }, + useSettingMutable: (key: string) => { + if (key === 'favoriteDirectories') return [undefined, vi.fn()]; + return [null, vi.fn()]; + }, +})); + +describe('PathPickerScreen', () => { + beforeEach(() => { + lastPathSelectorProps = null; + }); + + it('defaults favoriteDirectories to an empty array when setting is undefined', async () => { + const PathPickerScreen = (await import('./path')).default; + renderer.create(); + + expect(lastPathSelectorProps).toBeTruthy(); + expect(lastPathSelectorProps.favoriteDirectories).toEqual([]); + expect(typeof lastPathSelectorProps.onChangeFavoriteDirectories).toBe('function'); + }); +}); + diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index f26f121e5..3636143d7 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -21,7 +21,8 @@ export default React.memo(function PathPickerScreen() { const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); const usePathPickerSearch = useSetting('usePathPickerSearch'); - const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const [favoriteDirectoriesRaw, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const favoriteDirectories = favoriteDirectoriesRaw ?? []; const [customPath, setCustomPath] = useState(params.selectedPath || ''); const [pathSearchQuery, setPathSearchQuery] = useState(''); diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index a9ea53d82..67b678450 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -230,143 +230,143 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {favoriteProfileItems.length > 0 && ( - {favoriteProfileItems.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: selectedIndicatorColor, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => handleEditProfile(profile), - onDuplicate: () => handleDuplicateProfile(profile), - onDelete: () => { void handleDeleteProfile(profile); }, - }); - return ( - { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); + return ( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + - + - - - )} - /> - ); - })} + + + )} + /> + ); + })} )} {nonFavoriteCustomProfiles.length > 0 && ( - {nonFavoriteCustomProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: selectedIndicatorColor, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => handleEditProfile(profile), - onDuplicate: () => handleDuplicateProfile(profile), - onDelete: () => { void handleDeleteProfile(profile); }, - }); - return ( - { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); + return ( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + - + - - - )} - /> + + + )} + /> ); })} )} - {nonFavoriteBuiltInProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: selectedIndicatorColor, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => handleEditProfile(profile), - onDuplicate: () => handleDuplicateProfile(profile), - }); - return ( - { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + }); + return ( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + - + - - - )} - /> - ); - })} + + + )} + /> + ); + })} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index db1187856..6ae21e859 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -389,10 +389,6 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ sendButtonIcon: { color: theme.colors.button.primary.tint, }, - micIcon: { - width: 24, - height: 24, - }, })); const getContextWarning = (contextSize: number, alwaysShow: boolean = false, theme: Theme) => { @@ -1130,7 +1126,7 @@ export const AgentInput = React.memo(React.forwardRef ) : ( diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index 225680abb..2cac16d5b 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -100,6 +100,10 @@ export const ItemGroup = React.memo((props) => { return countSelectableItems(children); }, [children]); + const selectionContextValue = React.useMemo(() => { + return { selectableItemCount }; + }, [selectableItemCount]); + return ( @@ -121,7 +125,7 @@ export const ItemGroup = React.memo((props) => { {/* Content Container */} - + {withItemGroupDividers(children)} diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index ecb2cb4b5..bd09a0073 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -6,7 +6,7 @@ import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { AIBackendProfile } from '@/sync/settings'; -import type { PermissionMode } from '@/sync/permissionTypes'; +import { normalizeProfileDefaultPermissionMode, type PermissionMode } from '@/sync/permissionTypes'; import { SessionTypeSelector } from '@/components/SessionTypeSelector'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; @@ -125,18 +125,25 @@ export function ProfileEditForm({ } }, [favoriteMachines, setFavoriteMachines]); + const MachinePreviewModalWrapper = React.useCallback(({ onClose }: { onClose: () => void }) => { + return ( + + ); + }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); + const showMachinePreviewPicker = React.useCallback(() => { Modal.show({ - component: MachinePreviewModal, - props: { - machines, - favoriteMachineIds: favoriteMachines, - selectedMachineId: previewMachineId, - onSelect: setPreviewMachineId, - onToggleFavorite: toggleFavoriteMachineId, - }, + component: MachinePreviewModalWrapper, + props: {}, }); - }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); + }, [MachinePreviewModalWrapper]); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -155,7 +162,7 @@ export function ProfileEditForm({ profile.defaultSessionType || 'simple', ); const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( - (profile.defaultPermissionMode as PermissionMode) || 'default', + normalizeProfileDefaultPermissionMode(profile.defaultPermissionMode as PermissionMode), ); const [compatibility, setCompatibility] = React.useState>( profile.compatibility || { claude: true, codex: true, gemini: true }, diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index bf5353955..55e991b08 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -663,7 +663,7 @@ export const knownTools = { 'execute': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Gemini sends nice title in toolCall.title - if (opts.tool.input?.toolCall?.title) { + if (typeof opts.tool.input?.toolCall?.title === 'string') { // Title is like "rm file.txt [cwd /path] (description)" // Extract just the command part before [ const fullTitle = opts.tool.input.toolCall.title; @@ -680,7 +680,7 @@ export const knownTools = { input: z.object({}).partial().loose(), extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Extract description from parentheses at the end - if (opts.tool.input?.toolCall?.title) { + if (typeof opts.tool.input?.toolCall?.title === 'string') { const title = opts.tool.input.toolCall.title; const parenMatch = title.match(/\(([^)]+)\)$/); if (parenMatch) { diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index c6d0a4325..4caa6e6d1 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -4,6 +4,8 @@ import { machineBash, machinePreviewEnv, type EnvPreviewSecretsPolicy, type Prev // Re-export pure utility functions from envVarUtils for backwards compatibility export { resolveEnvVarSubstitution, extractEnvVarReferences } from './envVarUtils'; +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + interface EnvironmentVariables { [varName: string]: string | null; // null = variable not set in daemon environment } @@ -140,7 +142,6 @@ export function useEnvironmentVariables( // Fallback (older daemon): use bash probing for non-sensitive variables only. // Never fetch secret-like values into UI memory via bash. - const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; const sensitiveHints = options?.sensitiveHints ?? {}; const safeVarNames = validVarNames.filter((name) => !SECRET_NAME_REGEX.test(name) && sensitiveHints[name] !== true); From c8ef4b52d85ab1020b08bf15c72be09162bcaa1a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:24:01 +0100 Subject: [PATCH 106/106] fix(i18n): localize built-in profile names --- sources/app/(app)/session/[id]/info.tsx | 7 +++++-- sources/sync/profileUtils.test.ts | 15 +++++++++++++- sources/sync/profileUtils.ts | 26 +++++++++++++++++++++++++ sources/text/translations/ca.ts | 7 +++++++ sources/text/translations/en.ts | 7 +++++++ sources/text/translations/es.ts | 7 +++++++ sources/text/translations/it.ts | 7 +++++++ sources/text/translations/ja.ts | 7 +++++++ sources/text/translations/pl.ts | 7 +++++++ sources/text/translations/pt.ts | 7 +++++++ sources/text/translations/ru.ts | 7 +++++++ sources/text/translations/zh-Hans.ts | 7 +++++++ 12 files changed, 108 insertions(+), 3 deletions(-) diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index d73116760..bac2f1f5e 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -20,7 +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'; +import { getBuiltInProfile, getBuiltInProfileNameKey } from '@/sync/profileUtils'; // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -79,7 +79,10 @@ function SessionInfoContent({ session }: { session: Session }) { if (typeof profileId !== 'string') return t('status.unknown'); const builtIn = getBuiltInProfile(profileId); - if (builtIn) return builtIn.name; + if (builtIn) { + const key = getBuiltInProfileNameKey(profileId); + return key ? t(key) : builtIn.name; + } const custom = profiles.find(p => p.id === profileId); return custom?.name ?? t('status.unknown'); diff --git a/sources/sync/profileUtils.test.ts b/sources/sync/profileUtils.test.ts index 7ddef693e..f6f1553c8 100644 --- a/sources/sync/profileUtils.test.ts +++ b/sources/sync/profileUtils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getProfilePrimaryCli } from './profileUtils'; +import { getBuiltInProfileNameKey, getProfilePrimaryCli } from './profileUtils'; describe('getProfilePrimaryCli', () => { it('ignores unknown compatibility keys', () => { @@ -11,3 +11,16 @@ describe('getProfilePrimaryCli', () => { }); }); +describe('getBuiltInProfileNameKey', () => { + it('returns the translation key for known built-in profile ids', () => { + expect(getBuiltInProfileNameKey('anthropic')).toBe('profiles.builtInNames.anthropic'); + expect(getBuiltInProfileNameKey('deepseek')).toBe('profiles.builtInNames.deepseek'); + expect(getBuiltInProfileNameKey('zai')).toBe('profiles.builtInNames.zai'); + expect(getBuiltInProfileNameKey('openai')).toBe('profiles.builtInNames.openai'); + expect(getBuiltInProfileNameKey('azure-openai')).toBe('profiles.builtInNames.azureOpenai'); + }); + + it('returns null for unknown ids', () => { + expect(getBuiltInProfileNameKey('unknown')).toBeNull(); + }); +}); diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index e7ea1f087..ca04c41bb 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -2,6 +2,15 @@ import { AIBackendProfile } from './settings'; export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none'; +export type BuiltInProfileId = 'anthropic' | 'deepseek' | 'zai' | 'openai' | 'azure-openai'; + +export type BuiltInProfileNameKey = + | 'profiles.builtInNames.anthropic' + | 'profiles.builtInNames.deepseek' + | 'profiles.builtInNames.zai' + | 'profiles.builtInNames.openai' + | 'profiles.builtInNames.azureOpenai'; + const ALLOWED_PROFILE_CLIS = new Set(['claude', 'codex', 'gemini']); export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { @@ -16,6 +25,23 @@ export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefine return 'multi'; } +export function getBuiltInProfileNameKey(id: string): BuiltInProfileNameKey | null { + switch (id as BuiltInProfileId) { + case 'anthropic': + return 'profiles.builtInNames.anthropic'; + case 'deepseek': + return 'profiles.builtInNames.deepseek'; + case 'zai': + return 'profiles.builtInNames.zai'; + case 'openai': + return 'profiles.builtInNames.openai'; + case 'azure-openai': + return 'profiles.builtInNames.azureOpenai'; + default: + return null; + } +} + /** * Documentation and expected values for built-in profiles. * These help users understand what environment variables to set and their expected values. diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 21e3aeac4..ee8d1b1af 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -962,6 +962,13 @@ export const ca: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`, nameRequired: 'El nom del perfil és obligatori', builtIn: 'Integrat', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Preferits', custom: 'Els teus perfils', diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 08a29e0a0..6ed9244c7 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -985,6 +985,13 @@ export const en = { editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', builtIn: 'Built-in', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Favorites', custom: 'Your Profiles', diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 406db5501..9a2af7b1a 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -972,6 +972,13 @@ export const es: TranslationStructure = { editProfile: 'Editar Perfil', addProfileTitle: 'Agregar Nuevo Perfil', builtIn: 'Integrado', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Favoritos', custom: 'Tus perfiles', diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index d1bf3aeca..6ec7548dc 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -103,6 +103,13 @@ export const it: TranslationStructure = { editProfile: 'Modifica profilo', addProfileTitle: 'Aggiungi nuovo profilo', builtIn: 'Integrato', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Preferiti', custom: 'I tuoi profili', diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index eb77cd0ef..6dad8ea86 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -96,6 +96,13 @@ export const ja: TranslationStructure = { editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', builtIn: '組み込み', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'お気に入り', custom: 'あなたのプロファイル', diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 51fb6106d..a40e122cc 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -995,6 +995,13 @@ export const pl: TranslationStructure = { editProfile: 'Edytuj Profil', addProfileTitle: 'Dodaj Nowy Profil', builtIn: 'Wbudowane', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Ulubione', custom: 'Twoje profile', diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index fb00f8da2..9e33b159c 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -962,6 +962,13 @@ export const pt: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`, nameRequired: 'O nome do perfil é obrigatório', builtIn: 'Integrado', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Favoritos', custom: 'Seus perfis', diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 5234d4a60..c05ed4fac 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -994,6 +994,13 @@ export const ru: TranslationStructure = { editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', builtIn: 'Встроенный', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: 'Избранное', custom: 'Ваши профили', diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 00affc97b..d78e41b61 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -964,6 +964,13 @@ export const zhHans: TranslationStructure = { deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', builtIn: '内置', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, groups: { favorites: '收藏', custom: '你的配置文件',