diff --git a/.gitignore b/.gitignore index 664273ce..a4abbf5d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ yarn-error.* CLAUDE.local.md -.dev/worktree/* \ No newline at end of file +.dev/worktree/* + +# Development planning notes (keep local, don't commit) +notes/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 1ae068bb..542b6362 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,11 +7,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Development - `yarn start` - Start the Expo development server - `yarn ios` - Run the app on iOS simulator -- `yarn android` - Run the app on Android emulator +- `yarn android` - Run the app on Android emulator - `yarn web` - Run the app in web browser - `yarn prebuild` - Generate native iOS and Android directories - `yarn typecheck` - Run TypeScript type checking after all changes +### macOS Desktop (Tauri) +- `yarn tauri:dev` - Run macOS desktop app with hot reload +- `yarn tauri:build:dev` - Build development variant +- `yarn tauri:build:preview` - Build preview variant +- `yarn tauri:build:production` - Build production variant + ### Testing - `yarn test` - Run tests in watch mode (Jest with jest-expo preset) - No existing tests in the codebase yet diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5aa5635c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,308 @@ +# Contributing to Happy + +## Development Workflow: Build Variants + +The Happy app supports three build variants across **iOS, Android, and macOS desktop**, each with separate bundle IDs so all three can be installed simultaneously: + +| Variant | Bundle ID | App Name | Use Case | +|---------|-----------|----------|----------| +| **Development** | `com.slopus.happy.dev` | Happy (dev) | Local development with hot reload | +| **Preview** | `com.slopus.happy.preview` | Happy (preview) | Beta testing & OTA updates before production | +| **Production** | `com.ex3ndr.happy` | Happy | Public App Store release | + +**Why Preview?** +- **Development**: Fast iteration, dev server, instant reload +- **Preview**: Beta testers get OTA updates (`eas update --branch preview`) without app store submission +- **Production**: Stable App Store builds + +This allows you to test production-like builds with real users before releasing to the App Store. + +## Quick Start + +### iOS Development + +```bash +# Development variant (default) +npm run ios:dev + +# Preview variant +npm run ios:preview + +# Production variant +npm run ios:production +``` + +### Android Development + +```bash +# Development variant +npm run android:dev + +# Preview variant +npm run android:preview + +# Production variant +npm run android:production +``` + +### macOS Desktop (Tauri) + +```bash +# Development variant - run with hot reload +npm run tauri:dev + +# Build development variant +npm run tauri:build:dev + +# Build preview variant +npm run tauri:build:preview + +# Build production variant +npm run tauri:build:production +``` + +**How Tauri Variants Work:** +- Base config: `src-tauri/tauri.conf.json` (production defaults) +- Partial configs: `tauri.dev.conf.json`, `tauri.preview.conf.json` +- Tauri merges partial configs using [JSON Merge Patch (RFC 7396)](https://datatracker.ietf.org/doc/html/rfc7396) +- Only differences need to be specified in partial configs (DRY principle) + +### Development Server + +```bash +# Start dev server for development variant +npm run start:dev + +# Start dev server for preview variant +npm run start:preview + +# Start dev server for production variant +npm run start:production +``` + +## Visual Differences + +Each variant displays a different app name on your device: +- **Development**: "Happy (dev)" - Yellow/orange theme +- **Preview**: "Happy (preview)" - Preview theme +- **Production**: "Happy" - Standard theme + +This makes it easy to distinguish which version you're testing! + +## Common Workflows + +### Testing Development Changes + +1. **Build development variant:** + ```bash + npm run ios:dev + ``` + +2. **Make your changes** to the code + +3. **Hot reload** automatically updates the app + +4. **Rebuild if needed** for native changes: + ```bash + npm run ios:dev + ``` + +### Testing Preview (Pre-Release) + +1. **Build preview variant:** + ```bash + npm run ios:preview + ``` + +2. **Test OTA updates:** + ```bash + npm run ota # Publishes to preview branch + ``` + +3. **Verify** the preview build works as expected + +### Production Release + +1. **Build production variant:** + ```bash + npm run ios:production + ``` + +2. **Submit to App Store:** + ```bash + npm run submit + ``` + +3. **Deploy OTA updates:** + ```bash + npm run ota:production + ``` + +## All Variants Simultaneously + +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 +``` + +All three apps appear on your device with different icons and names! + +## EAS Build Profiles + +The project includes EAS build profiles for automated builds: + +```bash +# Development build +eas build --profile development + +# Production build +eas build --profile production +``` + +## Environment Variables + +Each variant can use different environment variables via `APP_ENV`: + +```javascript +// In app.config.js +const variant = process.env.APP_ENV || 'development'; +``` + +This controls: +- Bundle identifier +- App name +- Associated domains (deep linking) +- Intent filters (Android) +- Other variant-specific configuration + +## Deep Linking + +Only **production** variant has deep linking configured: + +- **Production**: `https://app.happy.engineering/*` +- **Development**: No deep linking +- **Preview**: No deep linking + +This prevents dev/preview builds from interfering with production deep links. + +## Testing Connected to Different Servers + +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 + +# Production app → Stable CLI daemon +npm run android:production +# Connect to CLI running: npm run stable:daemon:start +``` + +Each app maintains separate authentication and sessions! + +## Local Server Development + +To test with a local Happy server: + +```bash +npm run start:local-server +``` + +This sets: +- `EXPO_PUBLIC_HAPPY_SERVER_URL=http://localhost:3005` +- `EXPO_PUBLIC_DEBUG=1` +- Debug logging enabled + +## Troubleshooting + +### Build fails with "Bundle identifier already in use" + +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 + ``` + +### App not updating after changes + +1. **For JS changes**: Hot reload should work automatically +2. **For native changes**: Rebuild the variant: + ```bash + npm run ios:dev # Force rebuild + ``` +3. **For config changes**: Clean and prebuild: + ```bash + npm run prebuild + npm run ios:dev + ``` + +### All three apps look the same + +Check the app name on the home screen: +- "Happy (dev)" +- "Happy (preview)" +- "Happy" + +If they're all the same name, the variant might not be set correctly. Verify: + +```bash +# Check what APP_ENV is set to +echo $APP_ENV + +# Or look at the build output +npm run ios:dev # Should show "Happy (dev)" as the name +``` + +### Connected device not found + +For iOS connected device testing: + +```bash +# List available devices +xcrun devicectl list devices + +# Run on specific connected device +npm run ios:connected-device +``` + +## Tips + +1. **Use development variant for active work** - Fast iteration, debug features enabled +2. **Use preview for pre-release testing** - Test OTA updates before production +3. **Use production for final validation** - Exact configuration that ships to users +4. **Install all three simultaneously** - Compare behaviors side-by-side +5. **Different CLI instances** - Connect dev app to dev CLI, prod app to stable CLI +6. **Check app name** - Always visible which variant you're testing + +## How It Works + +The `app.config.js` file reads the `APP_ENV` environment variable: + +```javascript +const variant = process.env.APP_ENV || 'development'; +const bundleId = { + development: "com.slopus.happy.dev", + preview: "com.slopus.happy.preview", + production: "com.ex3ndr.happy" +}[variant]; +``` + +The `cross-env` package ensures this works cross-platform: + +```json +{ + "scripts": { + "ios:dev": "cross-env APP_ENV=development expo run:ios" + } +} +``` + +Cross-platform via `cross-env` - works identically on Windows, macOS, and Linux! diff --git a/README.md b/README.md index 1ab63a4c..16c3bbb7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ We're engineers scattered across Bay Area coffee shops and hacker houses, consta ## 📚 Documentation & Contributing - **[Documentation Website](https://happy.engineering/docs/)** - Learn how to use Happy Coder effectively +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup including iOS, Android, and macOS desktop variant builds - **[Edit docs at github.com/slopus/slopus.github.io](https://github.com/slopus/slopus.github.io)** - Help improve our documentation and guides ## License diff --git a/babel.config.js b/babel.config.js index 52c0d696..104fb64f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,23 @@ module.exports = function (api) { api.cache(true); + + // Determine which worklets plugin to use based on installed versions + // Reanimated v4+ uses react-native-worklets/plugin + // Reanimated v3.x uses react-native-reanimated/plugin + let workletsPlugin = 'react-native-worklets/plugin'; + try { + const reanimatedVersion = require('react-native-reanimated/package.json').version; + const majorVersion = parseInt(reanimatedVersion.split('.')[0], 10); + + // For Reanimated v3.x, use the old plugin + if (majorVersion < 4) { + workletsPlugin = 'react-native-reanimated/plugin'; + } + } catch (e) { + // If reanimated isn't installed, default to newer plugin + // This won't cause issues since the plugin won't be needed anyway + } + return { presets: ['babel-preset-expo'], env: { @@ -8,8 +26,8 @@ module.exports = function (api) { }, }, plugins: [ - 'react-native-worklets/plugin', - ['react-native-unistyles/plugin', { root: 'sources' }] + ['react-native-unistyles/plugin', { root: 'sources' }], + workletsPlugin // Must be last - automatically selects correct plugin for version ], }; }; \ No newline at end of file diff --git a/metro.config.js b/metro.config.js index 83bc23cd..ef943635 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,5 +1,23 @@ const { getDefaultConfig } = require("expo/metro-config"); -const config = getDefaultConfig(__dirname); +const config = getDefaultConfig(__dirname, { + // Enable CSS support for web + isCSSEnabled: true, +}); + +// Add support for .wasm files (required by Skia for all platforms) +// Source: https://shopify.github.io/react-native-skia/docs/getting-started/installation/ +config.resolver.assetExts.push('wasm'); + +// Enable inlineRequires for proper Skia and Reanimated loading +// Source: https://shopify.github.io/react-native-skia/docs/getting-started/web/ +// Without this, Skia throws "react-native-reanimated is not installed" error +// This is cross-platform compatible (iOS, Android, web) +config.transformer.getTransformOptions = async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: true, // Critical for @shopify/react-native-skia + }, +}); module.exports = config; \ No newline at end of file diff --git a/package.json b/package.json index b8f2e118..591e10d3 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,23 @@ "ota": "APP_ENV=preview NODE_ENV=preview tsx sources/scripts/parseChangelog.ts && yarn typecheck && eas update --branch preview", "ota:production": "npx eas-cli@latest workflow:run ota.yaml", "typecheck": "tsc --noEmit", - "postinstall": "patch-package", - "generate-theme": "tsx sources/theme.gen.ts" + "postinstall": "patch-package && npx setup-skia-web public", + "generate-theme": "tsx sources/theme.gen.ts", + "// ==== Development/Preview/Production Variants ====": "", + "ios:dev": "cross-env APP_ENV=development expo run:ios", + "ios:preview": "cross-env APP_ENV=preview expo run:ios", + "ios:production": "cross-env APP_ENV=production expo run:ios", + "android:dev": "cross-env APP_ENV=development expo run:android", + "android:preview": "cross-env APP_ENV=preview expo run:android", + "android:production": "cross-env APP_ENV=production expo run:android", + "start:dev": "cross-env APP_ENV=development expo start", + "start:preview": "cross-env APP_ENV=preview expo start", + "start:production": "cross-env APP_ENV=production expo start", + "// ==== macOS Desktop (Tauri) Variants ====": "", + "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", + "tauri:build:dev": "tauri build --config src-tauri/tauri.dev.conf.json", + "tauri:build:preview": "tauri build --config src-tauri/tauri.preview.conf.json", + "tauri:build:production": "tauri build" }, "jest": { "preset": "jest-expo" @@ -128,7 +143,7 @@ "react-native-purchases": "^9.4.2", "react-native-purchases-ui": "^9.4.2", "react-native-quick-base64": "^2.2.1", - "react-native-reanimated": "4.1.0", + "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "~5.6.0", "react-native-screen-transitions": "^1.2.0", "react-native-screens": "~4.16.0", @@ -139,7 +154,7 @@ "react-native-vision-camera": "^4.7.3", "react-native-web": "^0.21.0", "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1", + "react-native-worklets": "^0.7.1", "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.9", "resolve-path": "^1.4.0", @@ -159,6 +174,7 @@ "@stablelib/hex": "^2.0.1", "@types/react": "~19.1.10", "babel-plugin-transform-remove-console": "^6.9.4", + "cross-env": "^10.1.0", "patch-package": "^8.0.0", "react-test-renderer": "19.0.0", "tsx": "^4.20.4", diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index fa395500..e93fdd4e 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -105,8 +105,8 @@ export const SessionView = React.memo((props: { id: string }) => { }} /> )} - {/* Header - always shown, hidden in landscape mode on phone */} - {!(isLandscape && deviceType === 'phone') && ( + {/* Header - always shown on desktop/Mac, hidden in landscape mode only on actual phones */} + {!(isLandscape && deviceType === 'phone' && Platform.OS !== 'web') && ( { )} {/* Content based on state */} - + {!isDataReady ? ( // Loading state diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 879493ed..408d7ad2 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -311,12 +311,18 @@ export default function RootLayout() { headerBackTitle: t('common.back'), }} /> + ): string => { - if (!machineId) return '/home/'; +// Simple temporary state for passing selections back from picker screens +let onMachineSelected: (machineId: string) => void = () => { }; +let onProfileSaved: (profile: AIBackendProfile) => void = () => { }; - // First check recent paths from settings - const recentPath = recentPaths.find(rp => rp.machineId === machineId); - if (recentPath) { - return recentPath.path; +export const callbacks = { + onMachineSelected: (machineId: string) => { + onMachineSelected(machineId); + }, + onProfileSaved: (profile: AIBackendProfile) => { + onProfileSaved(profile); } +} + +// Optimized profile lookup utility +const useProfileMap = (profiles: AIBackendProfile[]) => { + return React.useMemo(() => + new Map(profiles.map(p => [p.id, p])), + [profiles] + ); +}; + +// Environment variable transformation helper +// 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 + return getProfileEnvironmentVariables(profile); +}; + +// Helper function to get the most recent path for a machine +// Returns the path from the most recently CREATED session for this machine +const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ machineId: string; path: string }>): string => { + if (!machineId) return ''; - // Fallback to session history const machine = storage.getState().machines[machineId]; - const defaultPath = machine?.metadata?.homeDir || '/home/'; + const defaultPath = machine?.metadata?.homeDir || ''; + // Get all sessions for this machine, sorted by creation time (most recent first) const sessions = Object.values(storage.getState().sessions); const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - const pathSet = new Set(); sessions.forEach(session => { if (session.metadata?.machineId === machineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } + pathsWithTimestamps.push({ + path: session.metadata.path, + timestamp: session.createdAt // Use createdAt, not updatedAt + }); } }); - // Sort by most recent first + // Sort by creation time (most recently created first) pathsWithTimestamps.sort((a, b) => b.timestamp - a.timestamp); + // Return the most recently created session's path, or default return pathsWithTimestamps[0]?.path || defaultPath; }; -// Helper function to update recent machine paths -const updateRecentMachinePaths = ( - currentPaths: Array<{ machineId: string; path: string }>, - machineId: string, - path: string -): Array<{ machineId: string; path: string }> => { - // Remove any existing entry for this machine - const filtered = currentPaths.filter(rp => rp.machineId !== machineId); - // Add new entry at the beginning - const updated = [{ machineId, path }, ...filtered]; - // Keep only the last 10 entries - return updated.slice(0, 10); -}; +// Configuration constants +const RECENT_PATHS_DEFAULT_VISIBLE = 5; +const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 character spaces at 11px font -function NewSessionScreen() { - const { theme } = useUnistyles(); +const styles = StyleSheet.create((theme, rt) => ({ + container: { + flex: 1, + justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', + paddingTop: Platform.OS === 'web' ? 0 : 40, + }, + scrollContainer: { + flex: 1, + }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: rt.insets.top, + paddingBottom: 16, + }, + wizardContainer: { + backgroundColor: theme.colors.surface, + borderRadius: 16, + marginHorizontal: 16, + padding: 16, + marginBottom: 16, + }, + sectionHeader: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + marginTop: 12, + ...Typography.default('semiBold') + }, + sectionDescription: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: 12, + lineHeight: 18, + ...Typography.default() + }, + profileListItem: { + backgroundColor: theme.colors.input.background, + borderRadius: 12, + padding: 8, + marginBottom: 8, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + profileListItemSelected: { + borderWidth: 2, + borderColor: theme.colors.text, + }, + profileIcon: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: theme.colors.button.primary.background, + justifyContent: 'center', + alignItems: 'center', + marginRight: 10, + }, + profileListName: { + fontSize: 13, + fontWeight: '600', + color: theme.colors.text, + ...Typography.default('semiBold') + }, + profileListDetails: { + fontSize: 12, + color: theme.colors.textSecondary, + marginTop: 2, + ...Typography.default() + }, + addProfileButton: { + backgroundColor: theme.colors.surface, + borderRadius: 12, + padding: 12, + marginBottom: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + addProfileButtonText: { + fontSize: 13, + fontWeight: '600', + color: theme.colors.button.secondary.tint, + marginLeft: 8, + ...Typography.default('semiBold') + }, + selectorButton: { + backgroundColor: theme.colors.input.background, + borderRadius: 8, + padding: 10, + marginBottom: 12, + borderWidth: 1, + borderColor: theme.colors.divider, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + selectorButtonText: { + color: theme.colors.text, + fontSize: 13, + 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', + justifyContent: 'space-between', + marginBottom: 16, + }, + permissionButton: { + width: '48%', + backgroundColor: theme.colors.input.background, + borderRadius: 12, + padding: 16, + marginBottom: 12, + alignItems: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + permissionButtonSelected: { + borderColor: theme.colors.button.primary.background, + backgroundColor: theme.colors.button.primary.background + '10', + }, + permissionButtonText: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.text, + marginTop: 8, + textAlign: 'center', + ...Typography.default('semiBold') + }, + permissionButtonTextSelected: { + color: theme.colors.button.primary.background, + }, + permissionButtonDesc: { + fontSize: 11, + color: theme.colors.textSecondary, + marginTop: 4, + textAlign: 'center', + ...Typography.default() + }, +})); + +function NewSessionWizard() { + const { theme, rt } = useUnistyles(); const router = useRouter(); + const safeArea = useSafeAreaInsets(); const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; @@ -83,7 +265,7 @@ function NewSessionScreen() { path?: string; }>(); - // Try to get data from temporary store first, fallback to direct prompt parameter + // Try to get data from temporary store first const tempSessionData = React.useMemo(() => { if (dataId) { return getTempData(dataId); @@ -91,108 +273,155 @@ function NewSessionScreen() { return null; }, [dataId]); + // Load persisted draft state (survives remounts/screen navigation) const persistedDraft = React.useRef(loadNewSessionDraft()).current; - const [input, setInput] = React.useState(() => { - if (tempSessionData?.prompt) { - return tempSessionData.prompt; - } - return prompt || persistedDraft?.input || ''; - }); - const [isSending, setIsSending] = React.useState(false); - const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>(() => { - if (tempSessionData?.sessionType) { - return tempSessionData.sessionType; - } - return persistedDraft?.sessionType || 'simple'; - }); - const ref = React.useRef(null); - const headerHeight = useHeaderHeight(); - const safeArea = useSafeAreaInsets(); - const screenWidth = useWindowDimensions().width; - - // Load recent machine paths and last used agent from settings + // Settings and state const recentMachinePaths = useSetting('recentMachinePaths'); const lastUsedAgent = useSetting('lastUsedAgent'); + + // A/B Test Flag - determines which wizard UI to show + // Control A (false): Simpler AgentInput-driven layout + // Variant B (true): Enhanced profile-first wizard with sections + const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); + const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const lastUsedProfile = useSetting('lastUsedProfile'); + const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); - // - // Machines state - // + // Combined profiles (built-in + custom) + const allProfiles = React.useMemo(() => { + const builtInProfiles = DEFAULT_PROFILES.map(bp => getBuiltInProfile(bp.id)!); + return [...builtInProfiles, ...profiles]; + }, [profiles]); + const profileMap = useProfileMap(allProfiles); const machines = useAllMachines(); - const [selectedMachineId, setSelectedMachineId] = React.useState(() => { - const machineIdFromDraft = tempSessionData?.machineId ?? persistedDraft?.selectedMachineId; - if (machineIdFromDraft) { - return machineIdFromDraft; + const sessions = useSessions(); + + // Wizard state + const [selectedProfileId, setSelectedProfileId] = React.useState(() => { + if (lastUsedProfile && profileMap.has(lastUsedProfile)) { + return lastUsedProfile; + } + return 'anthropic'; // Default to Anthropic + }); + 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) { + return 'claude'; + } + return tempSessionData.agentType; + } + if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { + return lastUsedAgent; + } + // Only allow gemini if experiments are enabled + if (lastUsedAgent === 'gemini' && experimentsEnabled) { + return lastUsedAgent; + } + return 'claude'; + }); + + // Agent cycling handler (for cycling through claude -> codex -> gemini) + // Note: Does NOT persist immediately - persistence is handled by useEffect below + const handleAgentClick = React.useCallback(() => { + setAgentType(prev => { + // Cycle: claude -> codex -> gemini (if experiments) -> claude + if (prev === 'claude') return 'codex'; + if (prev === 'codex') return experimentsEnabled ? 'gemini' : 'claude'; + return 'claude'; + }); + }, [experimentsEnabled]); + + // Persist agent selection changes (separate from setState to avoid race condition) + // This runs after agentType state is updated, ensuring the value is stable + React.useEffect(() => { + sync.applySettings({ lastUsedAgent: agentType }); + }, [agentType]); + + const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>('simple'); + const [permissionMode, setPermissionMode] = React.useState(() => { + // Initialize with last used permission mode if valid, otherwise default to 'default' + const validClaudeGeminiModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + + if (lastUsedPermissionMode) { + if (agentType === 'codex' && validCodexModes.includes(lastUsedPermissionMode as PermissionMode)) { + return lastUsedPermissionMode as PermissionMode; + } else if ((agentType === 'claude' || agentType === 'gemini') && validClaudeGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) { + return lastUsedPermissionMode as PermissionMode; + } } + return 'default'; + }); + + // NOTE: Permission mode reset on agentType change is handled by the validation useEffect below (lines ~670-681) + // 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 validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; + const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'default', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; + const validGeminiModes: ModelMode[] = ['default']; + + if (lastUsedModelMode) { + if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { + return lastUsedModelMode as ModelMode; + } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedModelMode as ModelMode)) { + return lastUsedModelMode as ModelMode; + } else if (agentType === 'gemini' && validGeminiModes.includes(lastUsedModelMode as ModelMode)) { + return lastUsedModelMode as ModelMode; + } + } + return agentType === 'codex' ? 'gpt-5-codex-high' : 'default'; + }); + + // Session details state + const [selectedMachineId, setSelectedMachineId] = React.useState(() => { if (machines.length > 0) { - // Check if we have a recently used machine that's currently available if (recentMachinePaths.length > 0) { - // Find the first machine from recent paths that's currently available for (const recent of recentMachinePaths) { if (machines.find(m => m.id === recent.machineId)) { return recent.machineId; } } } - // Fallback to first machine if no recent machine is available return machines[0].id; } return null; }); - React.useEffect(() => { - if (machines.length > 0) { - if (!selectedMachineId) { - // No machine selected yet, prefer the most recently used machine - let machineToSelect = machines[0].id; // Default to first machine - - // 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)) { - machineToSelect = recent.machineId; - break; // Use the first (most recent) match - } - } - } - setSelectedMachineId(machineToSelect); - // Also set the best path for the selected machine - const bestPath = getRecentPathForMachine(machineToSelect, recentMachinePaths); - setSelectedPath(bestPath); - } else { - // Machine is already selected, but check if we need to update path - // This handles the case where machines load after initial render - const currentMachine = machines.find(m => m.id === selectedMachineId); - if (!currentMachine) { - // Selected machine doesn't exist anymore - fall back safely - const fallbackMachineId = machines[0].id; - setSelectedMachineId(fallbackMachineId); - const bestPath = getRecentPathForMachine(fallbackMachineId, recentMachinePaths); - setSelectedPath(bestPath); - return; - } + const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + setPermissionMode(mode); + // Save the new selection immediately + sync.applySettings({ lastUsedPermissionMode: mode }); + }, []); - // Update path based on recent paths (only if path hasn't been manually changed) - const bestPath = getRecentPathForMachine(selectedMachineId, recentMachinePaths); - setSelectedPath(prevPath => { - // Only update if current path is the default /home/ - if (prevPath === '/home/' && bestPath !== '/home/') { - return bestPath; - } - return prevPath; - }); - } - } - }, [machines, selectedMachineId, recentMachinePaths]); + // + // Path selection + // + const [selectedPath, setSelectedPath] = React.useState(() => { + return getRecentPathForMachine(selectedMachineId, recentMachinePaths); + }); + const [sessionPrompt, setSessionPrompt] = React.useState(() => { + return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; + }); + const [isCreating, setIsCreating] = React.useState(false); + const [showAdvanced, setShowAdvanced] = React.useState(false); + + // Handle machineId route param from picker screens (main's navigation pattern) React.useEffect(() => { if (typeof machineIdParam !== 'string' || machines.length === 0) { return; } - // Only accept machineId if it exists in the current machines list if (!machines.some(m => m.id === machineIdParam)) { return; } @@ -203,120 +432,528 @@ function NewSessionScreen() { } }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); + // Handle path route param from picker screens (main's navigation pattern) React.useEffect(() => { if (typeof pathParam !== 'string') { return; } const trimmedPath = pathParam.trim(); - if (!trimmedPath) { - return; + if (trimmedPath && trimmedPath !== selectedPath) { + setSelectedPath(trimmedPath); } - setSelectedPath(trimmedPath); - }, [pathParam]); + }, [pathParam, selectedPath]); - const handleMachineClick = React.useCallback(() => { - router.push({ - pathname: '/new/pick/machine', - params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + // 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); + + // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine + const cliAvailability = useCLIDetection(selectedMachineId); + + // Auto-correct invalid agent selection after CLI detection completes + // This handles the case where lastUsedAgent was 'codex' but codex is not installed + React.useEffect(() => { + // Only act when detection has completed (timestamp > 0) + if (cliAvailability.timestamp === 0) return; + + // Check if currently selected agent is available + const agentAvailable = cliAvailability[agentType]; + + if (agentAvailable === false) { + // Current agent not available - find first available + const availableAgent: 'claude' | 'codex' | 'gemini' = + cliAvailability.claude === true ? 'claude' : + cliAvailability.codex === true ? 'codex' : + (cliAvailability.gemini === true && experimentsEnabled) ? 'gemini' : + 'claude'; // Fallback to claude (will fail at spawn with clear error) + + console.warn(`[AgentSelection] ${agentType} not available, switching to ${availableAgent}`); + setAgentType(availableAgent); + } + }, [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)); }); - }, [router, selectedMachineId]); + return Array.from(refs); + }, [allProfiles]); - // - // Agent selection - // + // Query daemon environment for ${VAR} resolution + const { variables: daemonEnv } = useEnvironmentVariables(selectedMachineId, envVarRefs); - 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) { - return 'claude'; - } - return tempSessionData.agentType; + // 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 }); + + // Helper to check if CLI warning has been dismissed (checks both global and per-machine) + const isWarningDismissed = React.useCallback((cli: 'claude' | 'codex' | 'gemini'): boolean => { + // Check global dismissal first + if (dismissedCLIWarnings.global?.[cli] === true) return true; + // Check per-machine dismissal + if (!selectedMachineId) return false; + return dismissedCLIWarnings.perMachine?.[selectedMachineId]?.[cli] === true; + }, [selectedMachineId, dismissedCLIWarnings]); + + // Unified dismiss handler for all three button types (easy to use correctly, hard to use incorrectly) + const handleCLIBannerDismiss = React.useCallback((cli: 'claude' | 'codex' | 'gemini', type: 'temporary' | 'machine' | 'global') => { + if (type === 'temporary') { + // X button: Hide for current session only (not persisted) + setHiddenBanners(prev => ({ ...prev, [cli]: true })); + } else if (type === 'global') { + // [any machine] button: Permanent dismissal across all machines + setDismissedCLIWarnings({ + ...dismissedCLIWarnings, + global: { + ...dismissedCLIWarnings.global, + [cli]: true, + }, + }); + } else { + // [this machine] button: Permanent dismissal for current machine only + if (!selectedMachineId) return; + const machineWarnings = dismissedCLIWarnings.perMachine?.[selectedMachineId] || {}; + setDismissedCLIWarnings({ + ...dismissedCLIWarnings, + perMachine: { + ...dismissedCLIWarnings.perMachine, + [selectedMachineId]: { + ...machineWarnings, + [cli]: true, + }, + }, + }); } - // Use persisted draft if available - if (persistedDraft?.agentType) { - if (persistedDraft.agentType === 'gemini' && !experimentsEnabled) { - return 'claude'; - } - return persistedDraft.agentType; + }, [selectedMachineId, dismissedCLIWarnings, setDismissedCLIWarnings]); + + // Helper to check if profile is available (compatible + CLI detected) + 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'; + return { + available: false, + reason: `requires-agent:${required}`, + }; } - // Initialize with last used agent if valid, otherwise default to 'claude' - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; + + // 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 (requiredCLI && cliAvailability[requiredCLI] === false) { + return { + available: false, + reason: `cli-not-detected:${requiredCLI}`, + }; } - // Only allow gemini if experiments are enabled - if (lastUsedAgent === 'gemini' && experimentsEnabled) { - return lastUsedAgent; + + // Optimistic: If detection hasn't completed (null) or profile supports both, assume available + return { available: true }; + }, [agentType, cliAvailability]); + + // Computed values + const compatibleProfiles = React.useMemo(() => { + return allProfiles.filter(profile => validateProfileForAgent(profile, agentType)); + }, [allProfiles, agentType]); + + const selectedProfile = React.useMemo(() => { + if (!selectedProfileId) { + return null; } - return 'claude'; - }); + // Check custom profiles first + if (profileMap.has(selectedProfileId)) { + return profileMap.get(selectedProfileId)!; + } + // Check built-in profiles + return getBuiltInProfile(selectedProfileId); + }, [selectedProfileId, profileMap]); - const handleAgentClick = React.useCallback(() => { - setAgentType(prev => { - // Cycle: claude -> codex -> gemini (if experiments) -> claude - let newAgent: 'claude' | 'codex' | 'gemini'; - if (prev === 'claude') { - newAgent = 'codex'; - } else if (prev === 'codex') { - newAgent = experimentsEnabled ? 'gemini' : 'claude'; - } else { - newAgent = 'claude'; + const selectedMachine = React.useMemo(() => { + if (!selectedMachineId) return null; + return machines.find(m => m.id === selectedMachineId); + }, [selectedMachineId, machines]); + + // Get recent paths for the selected machine + // Recent machines computed from sessions (for inline machine selection) + const recentMachines = React.useMemo(() => { + const machineIds = new Set(); + const machinesWithTimestamp: Array<{ machine: typeof machines[0]; timestamp: number }> = []; + + 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); + if (machine) { + machineIds.add(machine.id); + machinesWithTimestamp.push({ + machine, + timestamp: session.updatedAt || session.createdAt + }); + } } - // Save the new selection immediately - sync.applySettings({ lastUsedAgent: newAgent }); - return newAgent; }); - }, [experimentsEnabled]); - // - // Permission Mode selection - // + return machinesWithTimestamp + .sort((a, b) => b.timestamp - a.timestamp) + .map(item => item.machine); + }, [sessions, machines]); - const [permissionMode, setPermissionMode] = React.useState(() => { - // Initialize with last used permission mode if valid, otherwise default to 'default' - const validClaudeGeminiModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + const recentPaths = React.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); + } + }); - const permissionModeFromDraft = persistedDraft?.permissionMode ?? lastUsedPermissionMode; - if (permissionModeFromDraft) { - if (agentType === 'codex' && validCodexModes.includes(permissionModeFromDraft as PermissionMode)) { - return permissionModeFromDraft as PermissionMode; - } else if ((agentType === 'claude' || agentType === 'gemini') && validClaudeGeminiModes.includes(permissionModeFromDraft as PermissionMode)) { - return permissionModeFromDraft as PermissionMode; + // 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]); + + // Validation + const canCreate = React.useMemo(() => { + return ( + selectedProfileId !== null && + selectedMachineId !== null && + selectedPath.trim() !== '' + ); + }, [selectedProfileId, selectedMachineId, selectedPath]); + + const selectProfile = React.useCallback((profileId: string) => { + setSelectedProfileId(profileId); + // Check both custom profiles and built-in profiles + const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); + if (profile) { + // Auto-select agent based on profile's EXCLUSIVE compatibility + // Only switch if profile supports exactly one CLI - scales automatically with new agents + const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + .filter(([, supported]) => supported) + .map(([agent]) => agent); + + if (supportedCLIs.length === 1) { + const requiredAgent = supportedCLIs[0] as 'claude' | 'codex' | 'gemini'; + // Check if this agent is available and allowed + const isAvailable = cliAvailability[requiredAgent] !== false; + const isAllowed = requiredAgent !== 'gemini' || experimentsEnabled; + + if (isAvailable && isAllowed) { + setAgentType(requiredAgent); + } + // If the required CLI is unavailable or not allowed, keep current agent (profile will show as unavailable) + } + // If supportedCLIs.length > 1, profile supports multiple CLIs - don't force agent switch + + // Set session type from profile's default + if (profile.defaultSessionType) { + setSessionType(profile.defaultSessionType); + } + // Set permission mode from profile's default + if (profile.defaultPermissionMode) { + setPermissionMode(profile.defaultPermissionMode as PermissionMode); } } - return 'default'; - }); + }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); - // Reset permission mode when agent type changes - const hasMountedRef = React.useRef(false); + // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent React.useEffect(() => { - if (!hasMountedRef.current) { - hasMountedRef.current = true; - return; + 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); + + if (!isValidForCurrentAgent) { + setPermissionMode('default'); } - setPermissionMode('default'); - }, [agentType]); + }, [agentType, permissionMode]); - const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - // Save the new selection immediately - sync.applySettings({ lastUsedPermissionMode: mode }); + // 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'); + } + ); + } + }); }, []); - // - // Path selection - // + const handleAgentInputProfileClick = React.useCallback(() => { + scrollToSection(profileSectionRef); + }, [scrollToSection]); - const [selectedPath, setSelectedPath] = React.useState(() => { - // Initialize with the path from the selected machine (which should be the most recent if available) - const pathFromDraft = tempSessionData?.path ?? persistedDraft?.selectedPath; - if (pathFromDraft) { - return pathFromDraft; + const handleAgentInputMachineClick = React.useCallback(() => { + scrollToSection(machineSectionRef); + }, [scrollToSection]); + + const handleAgentInputPathClick = React.useCallback(() => { + 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 = encodeURIComponent(JSON.stringify(newProfile)); + router.push(`/new/pick/profile-edit?profileData=${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}`); + }, [router, selectedMachineId]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + const duplicatedProfile: AIBackendProfile = { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); + router.push(`/new/pick/profile-edit?profileData=${profileData}`); + }, [router]); + + // Helper to get meaningful subtitle text for profiles + const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { + const parts: string[] = []; + const availability = isProfileAvailable(profile); + + // Add "Built-in" indicator first for built-in profiles + if (profile.isBuiltIn) { + parts.push('Built-in'); } - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); - }); + + // Add CLI type second (before warnings/availability) + if (profile.compatibility.claude && profile.compatibility.codex) { + parts.push('Claude & Codex CLI'); + } else if (profile.compatibility.claude) { + parts.push('Claude CLI'); + } else if (profile.compatibility.codex) { + parts.push('Codex CLI'); + } + + // 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`); + } else if (availability.reason.startsWith('cli-not-detected:')) { + const cli = availability.reason.split(':')[1]; + const cliName = cli === 'claude' ? 'Claude' : 'Codex'; + parts.push(`⚠️ ${cliName} CLI not detected (this profile needs it)`); + } + } + + // Get model name - check both anthropicConfig and environmentVariables + let modelName: string | undefined; + if (profile.anthropicConfig?.model) { + // User set in GUI - literal value, no evaluation needed + modelName = profile.anthropicConfig.model; + } else if (profile.openaiConfig?.model) { + modelName = profile.openaiConfig.model; + } else { + // Check environmentVariables - may need ${VAR} evaluation + const modelEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_MODEL'); + if (modelEnvVar) { + const resolved = resolveEnvVarSubstitution(modelEnvVar.value, daemonEnv); + if (resolved) { + // Show as "VARIABLE: value" when evaluated from ${VAR} + const varName = modelEnvVar.value.match(/^\$\{(.+)\}$/)?.[1]; + modelName = varName ? `${varName}: ${resolved}` : resolved; + } else { + // Show raw ${VAR} if not resolved (machine not selected or var not set) + modelName = modelEnvVar.value; + } + } + } + + if (modelName) { + parts.push(modelName); + } + + // Add base URL if exists in environmentVariables + const baseUrlEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_BASE_URL'); + if (baseUrlEnvVar) { + const resolved = resolveEnvVarSubstitution(baseUrlEnvVar.value, daemonEnv); + if (resolved) { + // Extract hostname and show with variable name + const varName = baseUrlEnvVar.value.match(/^\$\{([A-Z_][A-Z0-9_]*)/)?.[1]; + try { + const url = new URL(resolved); + const display = varName ? `${varName}: ${url.hostname}` : url.hostname; + parts.push(display); + } catch { + // Not a valid URL, show as-is with variable name + parts.push(varName ? `${varName}: ${resolved}` : resolved); + } + } else { + // Show raw ${VAR} if not resolved (machine not selected or var not set) + parts.push(baseUrlEnvVar.value); + } + } + + 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) => { + let machine = storage.getState().machines[machineId]; + if (machine) { + setSelectedMachineId(machineId); + const bestPath = getRecentPathForMachine(machineId, recentMachinePaths); + setSelectedPath(bestPath); + } + }; + onMachineSelected = handler; + return () => { + onMachineSelected = () => { }; + }; + }, [recentMachinePaths]); + + React.useEffect(() => { + let handler = (savedProfile: AIBackendProfile) => { + // Handle saved profile from profile-edit screen + + // Check if this is a built-in profile being edited + const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === savedProfile.id); + let profileToSave = savedProfile; + + // For built-in profiles, create a new custom profile instead of modifying the built-in + if (isBuiltIn) { + profileToSave = { + ...savedProfile, + id: randomUUID(), // Generate new UUID for custom profile + isBuiltIn: false, + }; + } + + const 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]; + } + + setProfiles(updatedProfiles); // Use mutable setter for persistence + setSelectedProfileId(profileToSave.id); + }; + onProfileSaved = handler; + return () => { + onProfileSaved = () => { }; + }; + }, [profiles, setProfiles]); + + const handleMachineClick = React.useCallback(() => { + router.push('/new/pick/machine'); + }, [router]); + const handlePathClick = React.useCallback(() => { if (selectedMachineId) { router.push({ @@ -329,25 +966,8 @@ function NewSessionScreen() { } }, [selectedMachineId, selectedPath, router]); - // Get selected machine name - const selectedMachine = React.useMemo(() => { - if (!selectedMachineId) return null; - return machines.find(m => m.id === selectedMachineId); - }, [selectedMachineId, machines]); - - // Autofocus - React.useLayoutEffect(() => { - if (Platform.OS === 'ios') { - setTimeout(() => { - ref.current?.focus(); - }, 800); - } else { - ref.current?.focus(); - } - }, []); - - // Create - const doCreate = React.useCallback(async () => { + // Session creation + const handleCreateSession = React.useCallback(async () => { if (!selectedMachineId) { Modal.alert(t('common.error'), t('newSession.noMachineSelected')); return; @@ -357,76 +977,69 @@ function NewSessionScreen() { return; } - setIsSending(true); + setIsCreating(true); + try { let actualPath = selectedPath; - - // Handle worktree creation if selected and experiments are enabled + + // Handle worktree creation if (sessionType === 'worktree' && experimentsEnabled) { const worktreeResult = await createWorktree(selectedMachineId, selectedPath); - + if (!worktreeResult.success) { if (worktreeResult.error === 'Not a Git repository') { - Modal.alert( - t('common.error'), - t('newSession.worktree.notGitRepo') - ); + Modal.alert(t('common.error'), t('newSession.worktree.notGitRepo')); } else { - Modal.alert( - t('common.error'), - t('newSession.worktree.failed', { error: worktreeResult.error || 'Unknown error' }) - ); + Modal.alert(t('common.error'), t('newSession.worktree.failed', { error: worktreeResult.error || 'Unknown error' })); } - setIsSending(false); + setIsCreating(false); return; } - - // Update the path to the new worktree location + actualPath = worktreeResult.worktreePath; } - // Save the machine-path combination to settings before sending - const updatedPaths = updateRecentMachinePaths(recentMachinePaths, selectedMachineId, selectedPath); - sync.applySettings({ recentMachinePaths: updatedPaths }); + // Save settings + const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10); + sync.applySettings({ + recentMachinePaths: updatedPaths, + lastUsedAgent: agentType, + lastUsedProfile: selectedProfileId, + lastUsedPermissionMode: permissionMode, + lastUsedModelMode: modelMode, + }); + + // Get environment variables from selected profile + let environmentVariables = undefined; + if (selectedProfileId) { + const selectedProfile = profileMap.get(selectedProfileId); + if (selectedProfile) { + environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); + } + } const result = await machineSpawnNewSession({ machineId: selectedMachineId, directory: actualPath, - // For now we assume you already have a path to start in approvedNewDirectoryCreation: true, - agent: agentType + agent: agentType, + environmentVariables }); - // Use sessionId to check for success for backwards compatibility if ('sessionId' in result && result.sessionId) { + // Clear draft state on successful session creation clearNewSessionDraft(); - // Store worktree metadata if applicable - if (sessionType === 'worktree') { - // The metadata will be stored by the session itself once created - } - // Link task to session if task ID is provided - if (tempSessionData?.taskId && tempSessionData?.taskTitle) { - const promptDisplayTitle = tempSessionData.prompt?.startsWith('Work on this task:') - ? `Work on: ${tempSessionData.taskTitle}` - : `Clarify: ${tempSessionData.taskTitle}`; - await linkTaskToSession( - tempSessionData.taskId, - result.sessionId, - tempSessionData.taskTitle, - promptDisplayTitle - ); - } - - // Load sessions await sync.refreshSessions(); // Set permission mode on the session storage.getState().updateSessionPermissionMode(result.sessionId, permissionMode); - // Send message - await sync.sendMessage(result.sessionId, input); - // Navigate to session + // Send initial message if provided + if (sessionPrompt.trim()) { + await sync.sendMessage(result.sessionId, sessionPrompt); + } + router.replace(`/session/${result.sessionId}`, { dangerouslySingular() { return 'session' @@ -437,7 +1050,6 @@ function NewSessionScreen() { } } catch (error) { console.error('Failed to start session', error); - let errorMessage = 'Failed to start session. Make sure the daemon is running on the target machine.'; if (error instanceof Error) { if (error.message.includes('timeout')) { @@ -446,14 +1058,36 @@ function NewSessionScreen() { errorMessage = 'Not connected to server. Check your internet connection.'; } } - Modal.alert(t('common.error'), errorMessage); - } finally { - setIsSending(false); + setIsCreating(false); } - }, [agentType, selectedMachineId, selectedPath, input, recentMachinePaths, sessionType, experimentsEnabled, permissionMode]); + }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]); + + const screenWidth = useWindowDimensions().width; - // Persist the current modal state so it survives remounts and reopen/close + // Machine online status for AgentInput (DRY - reused in info box too) + const connectionStatus = React.useMemo(() => { + if (!selectedMachine) return undefined; + const isOnline = isMachineOnline(selectedMachine); + + // Include CLI status only when in wizard AND detection completed + const includeCLI = selectedMachineId && cliAvailability.timestamp > 0; + + return { + text: isOnline ? 'online' : 'offline', + color: isOnline ? theme.colors.success : theme.colors.textDestructive, + dotColor: isOnline ? theme.colors.success : theme.colors.textDestructive, + isPulsing: isOnline, + cliStatus: includeCLI ? { + claude: cliAvailability.claude, + codex: cliAvailability.codex, + ...(experimentsEnabled && { gemini: cliAvailability.gemini }), + } : undefined, + }; + }, [selectedMachine, selectedMachineId, cliAvailability, experimentsEnabled, theme]); + + // Persist the current wizard state so it survives remounts and screen navigation + // Uses debouncing to avoid excessive writes const draftSaveTimerRef = React.useRef | null>(null); React.useEffect(() => { if (draftSaveTimerRef.current) { @@ -461,7 +1095,7 @@ function NewSessionScreen() { } draftSaveTimerRef.current = setTimeout(() => { saveNewSessionDraft({ - input, + input: sessionPrompt, selectedMachineId, selectedPath, agentType, @@ -475,97 +1109,813 @@ function NewSessionScreen() { clearTimeout(draftSaveTimerRef.current); } }; - }, [input, selectedMachineId, selectedPath, agentType, permissionMode, sessionType]); + }, [sessionPrompt, selectedMachineId, selectedPath, agentType, permissionMode, sessionType]); - return ( - - - {/* Session type selector - only show when experiments are enabled */} - {experimentsEnabled && ( - 700 ? 16 : 8, flexDirection: 'row', justifyContent: 'center' } - ]}> - - + + {/* Session type selector only if experiments enabled */} + {experimentsEnabled && ( + 700 ? 16 : 8, marginBottom: 16 }}> + + + + + )} + + {/* AgentInput with inline chips - sticky at bottom */} + 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> + + []} + agentType={agentType} + onAgentClick={handleAgentClick} + permissionMode={permissionMode} + onPermissionModeChange={handlePermissionModeChange} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleMachineClick} + currentPath={selectedPath} + onPathClick={handlePathClick} /> - )} - - {/* Agent input */} - []} - /> + + + ); + } + // ======================================================================== + // VARIANT B: Enhanced profile-first wizard (flag ON) + // Full wizard with numbered sections, profile management, CLI detection + // ======================================================================== + return ( + + + 700 ? 16 : 8, flexDirection: 'row', justifyContent: 'center' } + { paddingHorizontal: screenWidth > 700 ? 16 : 8 } ]}> - ({ - backgroundColor: theme.colors.input.background, - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 12, - paddingVertical: 10, - marginBottom: 8, - flexDirection: 'row', - alignItems: 'center', - opacity: p.pressed ? 0.7 : 1, - })} - > - - - {selectedPath} + + {/* CLI Detection Status Banner - shows after detection completes */} + {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( + + + + + {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: + + + + + {connectionStatus.text} + + + + + {cliAvailability.claude ? '✓' : '✗'} + + + claude + + + + + {cliAvailability.codex ? '✓' : '✗'} + + + codex + + + {experimentsEnabled && ( + + + {cliAvailability.gemini ? '✓' : '✗'} + + + gemini + + + )} + + + )} + + {/* Section 1: Profile Management */} + + 1. + + Choose AI Profile + + + Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs. - + + {/* Missing CLI Installation Banners */} + {selectedMachineId && cliAvailability.claude === false && !isWarningDismissed('claude') && !hiddenBanners.claude && ( + + + + + + Claude CLI Not Detected + + + + Don't show this popup for + + handleCLIBannerDismiss('claude', 'machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + this machine + + + handleCLIBannerDismiss('claude', 'global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + any machine + + + + handleCLIBannerDismiss('claude', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + Install: npm install -g @anthropic-ai/claude-code • + + { + if (Platform.OS === 'web') { + window.open('https://docs.anthropic.com/en/docs/claude-code/installation', '_blank'); + } + }}> + + View Installation Guide → + + + + + )} + + {selectedMachineId && cliAvailability.codex === false && !isWarningDismissed('codex') && !hiddenBanners.codex && ( + + + + + + Codex CLI Not Detected + + + + Don't show this popup for + + handleCLIBannerDismiss('codex', 'machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + this machine + + + handleCLIBannerDismiss('codex', 'global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + any machine + + + + handleCLIBannerDismiss('codex', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + Install: npm install -g codex-cli • + + { + if (Platform.OS === 'web') { + window.open('https://github.com/openai/openai-codex', '_blank'); + } + }}> + + View Installation Guide → + + + + + )} + + {selectedMachineId && cliAvailability.gemini === false && experimentsEnabled && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( + + + + + + Gemini CLI Not Detected + + + + Don't show this popup for + + handleCLIBannerDismiss('gemini', 'machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + this machine + + + handleCLIBannerDismiss('gemini', 'global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + any machine + + + + handleCLIBannerDismiss('gemini', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + Install gemini CLI if available • + + { + if (Platform.OS === 'web') { + window.open('https://ai.google.dev/gemini-api/docs/get-started', '_blank'); + } + }}> + + View Gemini Docs → + + + + + )} + + {/* 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 + + + + + {/* Section 2: Machine Selection */} + + + 2. + + Select Machine + + + + + + config={{ + getItemId: (machine) => machine.id, + getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + getItemSubtitle: undefined, + getItemIcon: (machine) => ( + + ), + getRecentItemIcon: (machine) => ( + + ), + getItemStatus: (machine) => { + const offline = !isMachineOnline(machine); + return { + text: offline ? 'offline' : 'online', + color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + isPulsing: !offline, + }; + }, + formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + parseFromDisplay: (text) => { + return machines.find(m => + m.metadata?.displayName === text || m.metadata?.host === text || m.id === text + ) || null; + }, + filterItem: (machine, searchText) => { + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const search = searchText.toLowerCase(); + return displayName.includes(search) || host.includes(search); + }, + searchPlaceholder: "Type to filter machines...", + recentSectionTitle: "Recent Machines", + favoritesSectionTitle: "Favorite Machines", + noItemsMessage: "No machines available", + showFavorites: true, + showRecent: true, + showSearch: true, + allowCustomInput: false, + compactItems: true, + }} + items={machines} + recentItems={recentMachines} + favoriteItems={machines.filter(m => favoriteMachines.includes(m.id))} + selectedItem={selectedMachine || null} + onSelect={(machine) => { + setSelectedMachineId(machine.id); + const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); + } + }} + /> + + + {/* Section 3: Working Directory */} + + + 3. + + Select Working Directory + + + + + + config={{ + getItemId: (path) => path, + getItemTitle: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), + getItemSubtitle: undefined, + getItemIcon: (path) => ( + + ), + getRecentItemIcon: (path) => ( + + ), + getFavoriteItemIcon: (path) => ( + + ), + canRemoveFavorite: (path) => path !== selectedMachine?.metadata?.homeDir, + formatForDisplay: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), + parseFromDisplay: (text) => { + if (selectedMachine?.metadata?.homeDir) { + return resolveAbsolutePath(text, selectedMachine.metadata.homeDir); + } + return null; + }, + filterItem: (path, searchText) => { + const displayPath = formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir); + return displayPath.toLowerCase().includes(searchText.toLowerCase()); + }, + searchPlaceholder: "Type to filter or enter custom directory...", + recentSectionTitle: "Recent Directories", + favoritesSectionTitle: "Favorite Directories", + noItemsMessage: "No recent directories", + showFavorites: true, + showRecent: true, + showSearch: true, + allowCustomInput: true, + compactItems: true, + }} + items={recentPaths} + recentItems={recentPaths} + favoriteItems={(() => { + if (!selectedMachine?.metadata?.homeDir) return []; + const homeDir = selectedMachine.metadata.homeDir; + // Include home directory plus user favorites + return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; + })()} + selectedItem={selectedPath} + onSelect={(path) => { + setSelectedPath(path); + }} + onToggleFavorite={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return; + + // Don't allow removing home directory (handled by canRemoveFavorite) + if (path === homeDir) return; + + // Convert to relative format for storage + const relativePath = formatPathRelativeToHome(path, homeDir); + + // Check if already in favorites + const isInFavorites = favoriteDirectories.some(fav => + resolveAbsolutePath(fav, homeDir) === path + ); + + if (isInFavorites) { + // Remove from favorites + setFavoriteDirectories(favoriteDirectories.filter(fav => + resolveAbsolutePath(fav, homeDir) !== path + )); + } else { + // Add to favorites + setFavoriteDirectories([...favoriteDirectories, relativePath]); + } + }} + context={{ homeDir: selectedMachine?.metadata?.homeDir }} + /> + + + {/* Section 4: Permission Mode */} + + 4. Permission Mode + + + {(agentType === 'codex' + ? [ + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: 'Read Only', description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: 'Safe YOLO', description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: 'YOLO', description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( + + } + rightElement={permissionMode === option.value ? ( + + ) : null} + onPress={() => setPermissionMode(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + style={permissionMode === option.value ? { + borderWidth: 2, + borderColor: theme.colors.button.primary.tint, + borderRadius: Platform.select({ ios: 10, default: 16 }), + } : undefined} + /> + ))} + + + {/* Section 5: Advanced Options (Collapsible) */} + {experimentsEnabled && ( + <> + setShowAdvanced(!showAdvanced)} + > + Advanced Options + + + + {showAdvanced && ( + + + + )} + + )} + + + + + + {/* Section 5: AgentInput - Sticky at bottom */} + 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> + + []} + agentType={agentType} + onAgentClick={handleAgentInputAgentClick} + permissionMode={permissionMode} + onPermissionModeChange={handleAgentInputPermissionChange} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleAgentInputMachineClick} + currentPath={selectedPath} + onPathClick={handleAgentInputPathClick} + profileId={selectedProfileId} + onProfileClick={handleAgentInputProfileClick} + /> - ) + ); } -export default React.memo(NewSessionScreen); +export default React.memo(NewSessionWizard); diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index 75708013..c02580e8 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -1,34 +1,21 @@ import React from 'react'; -import { View, Text, ScrollView, ActivityIndicator } from 'react-native'; +import { View, Text } 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 } from '@/sync/storage'; +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 { layout } from '@/components/layout'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; const stylesheet = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.groupped.background, }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - paddingVertical: 16, - alignItems: 'center', - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - }, emptyContainer: { flex: 1, justifyContent: 'center', @@ -41,29 +28,6 @@ const stylesheet = StyleSheet.create((theme) => ({ textAlign: 'center', ...Typography.default(), }, - offlineWarning: { - marginHorizontal: 16, - marginTop: 16, - marginBottom: 8, - padding: 16, - backgroundColor: theme.colors.box.warning.background, - borderRadius: 12, - borderWidth: 1, - borderColor: theme.colors.box.warning.border, - }, - offlineWarningTitle: { - fontSize: 14, - color: theme.colors.box.warning.text, - marginBottom: 8, - ...Typography.default('semiBold'), - }, - offlineWarningText: { - fontSize: 13, - color: theme.colors.box.warning.text, - lineHeight: 20, - marginBottom: 4, - ...Typography.default(), - }, })); export default function MachinePickerScreen() { @@ -73,8 +37,15 @@ export default function MachinePickerScreen() { const navigation = useNavigation(); const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); + const sessions = useSessions(); + + const selectedMachine = machines.find(m => m.id === params.selectedId) || null; + + const handleSelectMachine = (machine: typeof machines[0]) => { + // Support both callback pattern (feature branch wizard) and navigation params (main) + const machineId = machine.id; - const handleSelectMachine = (machineId: string) => { + // Navigation params approach from main for backward compatibility const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { @@ -83,9 +54,35 @@ export default function MachinePickerScreen() { source: previousRoute.key, } as never); } + router.back(); }; + // Compute recent machines from sessions + const recentMachines = React.useMemo(() => { + const machineIds = new Set(); + const machinesWithTimestamp: Array<{ machine: typeof machines[0]; timestamp: number }> = []; + + 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); + if (machine) { + machineIds.add(machine.id); + machinesWithTimestamp.push({ + machine, + timestamp: session.updatedAt || session.createdAt + }); + } + } + }); + + return machinesWithTimestamp + .sort((a, b) => b.timestamp - a.timestamp) + .map(item => item.machine); + }, [sessions, machines]); + if (machines.length === 0) { return ( <> @@ -109,56 +106,70 @@ export default function MachinePickerScreen() { return ( <> + - {machines.length === 0 && ( - - - All machines offline - - - - {t('machine.offlineHelp')} - - - - )} - - - {machines.map((machine) => { - const displayName = machine.metadata?.displayName || machine.metadata?.host || machine.id; - const hostName = machine.metadata?.host || machine.id; - const offline = !isMachineOnline(machine); - const isSelected = params.selectedId === machine.id; - - return ( - - } - detail={offline ? 'offline' : 'online'} - detailStyle={{ - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected - }} - titleStyle={{ - color: offline ? theme.colors.textSecondary : theme.colors.text - }} - subtitleStyle={{ - color: theme.colors.textSecondary - }} - selected={isSelected} - onPress={() => handleSelectMachine(machine.id)} - showChevron={false} + + 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} + onSelect={handleSelectMachine} + /> ); diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index ac48d71f..b0214d6c 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -123,6 +123,7 @@ export default function PathPickerScreen() { const handleSelectPath = React.useCallback(() => { const pathToUse = customPath.trim() || machine?.metadata?.homeDir || '/home'; + // Pass path back via navigation params (main's pattern, received by new/index.tsx) const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx new file mode 100644 index 00000000..9bf311c8 --- /dev/null +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; +import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { StyleSheet } from 'react-native-unistyles'; +import { useUnistyles } from 'react-native-unistyles'; +import { useHeaderHeight } from '@react-navigation/elements'; +import Constants from 'expo-constants'; +import { t } from '@/text'; +import { ProfileEditForm } from '@/components/ProfileEditForm'; +import { AIBackendProfile } from '@/sync/settings'; +import { layout } from '@/components/layout'; +import { callbacks } from '../index'; + +export default function ProfileEditScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); + const screenWidth = useWindowDimensions().width; + const headerHeight = useHeaderHeight(); + + // Deserialize profile from URL params + const profile: AIBackendProfile = React.useMemo(() => { + if (params.profileData) { + try { + return JSON.parse(decodeURIComponent(params.profileData)); + } catch (error) { + console.error('Failed to parse profile data:', error); + } + } + // 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]); + + const handleSave = (savedProfile: AIBackendProfile) => { + // Call the callback to notify wizard of saved profile + callbacks.onProfileSaved(savedProfile); + router.back(); + }; + + const handleCancel = () => { + router.back(); + }; + + return ( + + + 700 ? 16 : 8 } + ]}> + + + + + + ); +} + +const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface, + paddingTop: rt.insets.top, + paddingBottom: rt.insets.bottom, + }, +})); diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index bb523eee..ac726145 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -13,7 +13,8 @@ export default function FeaturesSettingsScreen() { const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled'); const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); - + const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); + return ( {/* Experimental Features */} @@ -57,6 +58,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 new file mode 100644 index 00000000..fa452202 --- /dev/null +++ b/sources/app/(app)/settings/profiles.tsx @@ -0,0 +1,436 @@ +import React from 'react'; +import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useSettingMutable } from '@/sync/storage'; +import { StyleSheet } from 'react-native-unistyles'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { Modal as HappyModal } from '@/modal/ModalManager'; +import { layout } from '@/components/layout'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useWindowDimensions } from 'react-native'; +import { 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; +} + +interface ProfileManagerProps { + onProfileSelect?: (profile: AIBackendProfile | null) => void; + selectedProfileId?: string | null; +} + +// Profile utilities now imported from @/sync/profileUtils + +function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { + const { theme } = useUnistyles(); + 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; + + 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', + }); + setShowAddForm(true); + }; + + const handleEditProfile = (profile: AIBackendProfile) => { + setEditingProfile({ ...profile }); + setShowAddForm(true); + }; + + const handleDeleteProfile = (profile: AIBackendProfile) => { + // Show confirmation dialog before deleting + Alert.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); + + // 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 } + ); + }; + + const handleSelectProfile = (profileId: string | null) => { + let profile: AIBackendProfile | null = null; + + if (profileId) { + // Check if it's a built-in profile + const builtInProfile = getBuiltInProfile(profileId); + if (builtInProfile) { + profile = builtInProfile; + } else { + // Check if it's a custom profile + profile = profiles.find(p => p.id === profileId) || null; + } + } + + if (onProfileSelect) { + onProfileSelect(profile); + } + setLastUsedProfile(profileId); + }; + + const handleSaveProfile = (profile: AIBackendProfile) => { + // Profile validation - ensure name is not empty + if (!profile.name || profile.name.trim() === '') { + return; + } + + // Check if this is a built-in profile being edited + const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === profile.id); + + // 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 + }; + + // Check for duplicate names (excluding the new profile) + const isDuplicate = profiles.some(p => + p.name.trim() === newProfile.name.trim() + ); + if (isDuplicate) { + return; + } + + setProfiles([...profiles, newProfile]); + } else { + // Handle custom profile updates + // Check for duplicate names (excluding current profile if editing) + const isDuplicate = profiles.some(p => + p.id !== profile.id && p.name.trim() === profile.name.trim() + ); + if (isDuplicate) { + return; + } + + const existingIndex = profiles.findIndex(p => p.id === profile.id); + let updatedProfiles: AIBackendProfile[]; + + if (existingIndex >= 0) { + // Update existing profile + updatedProfiles = [...profiles]; + updatedProfiles[existingIndex] = profile; + } else { + // Add new profile + updatedProfiles = [...profiles, profile]; + } + + setProfiles(updatedProfiles); + } + + setShowAddForm(false); + setEditingProfile(null); + }; + + return ( + + 700 ? 16 : 8, + paddingBottom: safeArea.bottom + 100, + }} + > + + + {t('profiles.title')} + + + {/* None option - no profile */} + handleSelectProfile(null)} + > + + + + + + {t('profiles.noProfile')} + + + {t('profiles.noProfileDescription')} + + + {selectedProfileId === null && ( + + )} + + + {/* Built-in profiles */} + {DEFAULT_PROFILES.map((profileDisplay) => { + const profile = getBuiltInProfile(profileDisplay.id); + if (!profile) return null; + + return ( + handleSelectProfile(profile.id)} + > + + + + + + {profile.name} + + + {profile.anthropicConfig?.model || 'Default model'} + {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} + + + + {selectedProfileId === profile.id && ( + + )} + handleEditProfile(profile)} + > + + + + + ); + })} + + {/* Custom profiles */} + {profiles.map((profile) => ( + handleSelectProfile(profile.id)} + > + + + + + + {profile.name} + + + {profile.anthropicConfig?.model || t('profiles.defaultModel')} + {profile.tmuxConfig?.sessionName && ` • tmux: ${profile.tmuxConfig.sessionName}`} + {profile.tmuxConfig?.tmpDir && ` • dir: ${profile.tmuxConfig.tmpDir}`} + + + + {selectedProfileId === profile.id && ( + + )} + handleEditProfile(profile)} + > + + + handleDeleteProfile(profile)} + style={{ marginLeft: 16 }} + > + + + + + ))} + + {/* Add profile button */} + + + + {t('profiles.addProfile')} + + + + + + {/* Profile Add/Edit Modal */} + {showAddForm && editingProfile && ( + + + { + setShowAddForm(false); + setEditingProfile(null); + }} + /> + + + )} + + ); +} + +// ProfileEditForm now imported from @/components/ProfileEditForm + +const profileManagerStyles = StyleSheet.create((theme) => ({ + modalOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + modalContent: { + width: '100%', + maxWidth: Math.min(layout.maxWidth, 600), + maxHeight: '90%', + }, +})); + +export default ProfileManager; \ No newline at end of file diff --git a/sources/auth/authQRStart.ts b/sources/auth/authQRStart.ts index bdcd9f54..ab9a7b6e 100644 --- a/sources/auth/authQRStart.ts +++ b/sources/auth/authQRStart.ts @@ -21,17 +21,23 @@ export function generateAuthKeyPair(): QRAuthKeyPair { export async function authQRStart(keypair: QRAuthKeyPair): Promise { try { const serverUrl = getServerUrl(); - console.log(`[AUTH DEBUG] Sending auth request to: ${serverUrl}/v1/auth/account/request`); - console.log(`[AUTH DEBUG] Public key: ${encodeBase64(keypair.publicKey).substring(0, 20)}...`); + if (process.env.EXPO_PUBLIC_DEBUG) { + console.log(`[AUTH DEBUG] Sending auth request to: ${serverUrl}/v1/auth/account/request`); + console.log(`[AUTH DEBUG] Public key: ${encodeBase64(keypair.publicKey).substring(0, 20)}...`); + } await axios.post(`${serverUrl}/v1/auth/account/request`, { publicKey: encodeBase64(keypair.publicKey), }); - console.log('[AUTH DEBUG] Auth request sent successfully'); + if (process.env.EXPO_PUBLIC_DEBUG) { + console.log('[AUTH DEBUG] Auth request sent successfully'); + } return true; } catch (error) { - console.log('[AUTH DEBUG] Failed to send auth request:', error); + if (process.env.EXPO_PUBLIC_DEBUG) { + console.log('[AUTH DEBUG] Failed to send auth request:', error); + } console.log('Failed to create authentication request, please try again later.'); return false; } diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index f3c7e1ff..e406b872 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -21,6 +21,8 @@ import { useSetting } from '@/sync/storage'; 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'; interface AgentInputProps { value: string; @@ -43,6 +45,11 @@ interface AgentInputProps { color: string; dotColor: string; isPulsing?: boolean; + cliStatus?: { + claude: boolean | null; + codex: boolean | null; + gemini?: boolean | null; + }; }; autocompletePrefixes: string[]; autocompleteSuggestions: (query: string) => Promise<{ key: string, text: string, component: React.ElementType }[]>; @@ -64,6 +71,8 @@ interface AgentInputProps { isSendDisabled?: boolean; isSending?: boolean; minHeight?: number; + profileId?: string | null; + onProfileClick?: () => void; } const MAX_CONTEXT_SIZE = 190000; @@ -289,11 +298,22 @@ 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'; + // Profile data + const profiles = useSetting('profiles'); + const currentProfile = React.useMemo(() => { + if (!props.profileId) return null; + // Check custom profiles first + const customProfile = profiles.find(p => p.id === props.profileId); + if (customProfile) return customProfile; + // Check built-in profiles + return getBuiltInProfile(props.profileId); + }, [profiles, props.profileId]); + // Calculate context warning const contextWarning = props.usageData?.contextSize ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) @@ -425,8 +445,14 @@ export const AgentInput = React.memo(React.forwardRef - + {props.connectionStatus && ( <> - - - {props.connectionStatus.text} - + + + + {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 && ( @@ -685,7 +779,89 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Unified panel containing input and action buttons */} + + {/* 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 */} @@ -704,242 +880,211 @@ export const AgentInput = React.memo(React.forwardRef - - - {/* Settings button */} - {props.onPermissionModeChange && ( - ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} - > - - - )} - - {/* 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')} - - - )} + + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} + + - {/* 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') : props.machineName} - - - )} + {/* Settings button */} + {props.onPermissionModeChange && ( + ({ + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 8, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + opacity: p.pressed ? 0.7 : 1, + })} + > + + + )} - {/* 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} - - - )} + {/* Profile selector button - FIRST */} + {props.profileId && props.onProfileClick && ( + { + hapticsLight(); + props.onProfileClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => ({ + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + opacity: p.pressed ? 0.7 : 1, + gap: 6, + })} + > + + + {currentProfile?.name || 'Select Profile'} + + + )} - {/* Abort button */} - {props.onAbort && ( - + {/* 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')} + + + )} + + {/* Abort button */} + {props.onAbort && ( + + ({ + 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, + })} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleAbortPress} + disabled={isAborting} + > + {isAborting ? ( + + ) : ( + + )} + + + )} + + {/* Git Status Badge */} + + + + {/* Send/Voice button - aligned with first row */} + + ({ + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + opacity: p.pressed ? 0.7 : 1, })} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={handleAbortPress} - disabled={isAborting} + onPress={() => { + hapticsLight(); + if (hasText) { + props.onSend(); + } else { + props.onMicPress?.(); + } + }} + disabled={props.isSendDisabled || props.isSending || (!hasText && !props.onMicPress)} > - {isAborting ? ( + {props.isSending ? ( + ) : hasText ? ( + + ) : props.onMicPress && !props.isMicActive ? ( + ) : ( )} - - )} - - {/* Git Status Badge */} - - - - {/* Send/Voice button */} - - ({ - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - opacity: p.pressed ? 0.7 : 1, - })} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={() => { - hapticsLight(); - if (hasText) { - props.onSend(); - } else { - props.onMicPress?.(); - } - }} - disabled={props.isSendDisabled || props.isSending || (!hasText && !props.onMicPress)} - > - {props.isSending ? ( - - ) : hasText ? ( - - ) : props.onMicPress && !props.isMicActive ? ( - - ) : ( - - )} - + + diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx new file mode 100644 index 00000000..2185e0b2 --- /dev/null +++ b/sources/components/EnvironmentVariableCard.tsx @@ -0,0 +1,336 @@ +import React from 'react'; +import { View, Text, TextInput, Pressable } 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'; + +export interface EnvironmentVariableCardProps { + variable: { name: string; value: string }; + machineId: string | null; + 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; +} + +/** + * Parse environment variable value to determine configuration + */ +function parseVariableValue(value: string): { + useRemoteVariable: boolean; + remoteVariableName: string; + defaultValue: string; +} { + // Match: ${VARIABLE_NAME:-default_value} + const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); + if (matchWithFallback) { + 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: '' + }; + } + + // Literal value (no template) + return { + useRemoteVariable: false, + remoteVariableName: '', + defaultValue: value + }; +} + +/** + * Single environment variable card component + * Matches profile list pattern from index.tsx:1163-1217 + */ +export function EnvironmentVariableCard({ + variable, + machineId, + expectedValue, + description, + isSecret = false, + onUpdate, + onDelete, + onDuplicate, +}: EnvironmentVariableCardProps) { + const { theme } = useUnistyles(); + + // Parse current value + const parsed = parseVariableValue(variable.value); + const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); + const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); + const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); + + // Query remote machine for variable value (only if checkbox enabled and not secret) + const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; + const { variables: remoteValues } = useEnvironmentVariables( + machineId, + shouldQueryRemote ? [remoteVariableName] : [] + ); + + const remoteValue = remoteValues[remoteVariableName]; + + // Update parent when local state changes + React.useEffect(() => { + const newValue = useRemoteVariable && remoteVariableName.trim() !== '' + ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + : defaultValue; + + if (newValue !== variable.value) { + onUpdate(newValue); + } + }, [useRemoteVariable, remoteVariableName, defaultValue, variable.value, onUpdate]); + + // Determine status + const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; + const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + + return ( + + {/* Header row with variable name and action buttons */} + + + {variable.name} + {isSecret && ( + + )} + + + + + + + + + + + + + {/* Description */} + {description && ( + + {description} + + )} + + {/* Checkbox: First try copying variable from remote machine */} + setUseRemoteVariable(!useRemoteVariable)} + > + + {useRemoteVariable && ( + + )} + + + First try copying variable from remote machine: + + + + {/* Remote variable name input */} + + + {/* Remote variable status */} + {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( + + {remoteValue === undefined ? ( + + ⏳ Checking remote machine... + + ) : remoteValue === null ? ( + + ✗ Value not found + + ) : ( + <> + + ✓ Value found: {remoteValue} + + {showRemoteDiffersWarning && ( + + ⚠️ Differs from documented value: {expectedValue} + + )} + + )} + + )} + + {useRemoteVariable && !isSecret && !machineId && ( + + ℹ️ Select a machine to check if variable exists + + )} + + {/* Security message for secrets */} + {isSecret && ( + + 🔒 Secret value - not retrieved for security + + )} + + {/* Default value label */} + + Default value: + + + {/* Default value input */} + + + {/* Default override warning */} + {showDefaultOverrideWarning && !isSecret && ( + + ⚠️ Overriding documented default: {expectedValue} + + )} + + {/* Session preview */} + + Session will receive: {variable.name} = { + isSecret + ? (useRemoteVariable && remoteVariableName + ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` + : (defaultValue ? '***hidden***' : '(empty)')) + : (useRemoteVariable && remoteValue !== undefined && remoteValue !== null + ? remoteValue + : defaultValue || '(empty)') + } + + + ); +} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx new file mode 100644 index 00000000..e42e6141 --- /dev/null +++ b/sources/components/EnvironmentVariablesList.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import { View, Text, Pressable, TextInput } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { EnvironmentVariableCard } from './EnvironmentVariableCard'; +import type { ProfileDocumentation } from '@/sync/profileUtils'; + +export interface EnvironmentVariablesListProps { + environmentVariables: Array<{ name: string; value: string }>; + machineId: string | null; + profileDocs?: ProfileDocumentation | null; + onChange: (newVariables: Array<{ name: string; value: string }>) => void; +} + +/** + * Complete environment variables section with title, add button, and editable cards + * Matches profile list pattern from index.tsx:1159-1308 + */ +export function EnvironmentVariablesList({ + environmentVariables, + machineId, + profileDocs, + onChange, +}: EnvironmentVariablesListProps) { + const { theme } = useUnistyles(); + + // Add variable inline form state + const [showAddForm, setShowAddForm] = React.useState(false); + const [newVarName, setNewVarName] = React.useState(''); + const [newVarValue, setNewVarValue] = React.useState(''); + + // Helper to get expected value and description from documentation + const getDocumentation = React.useCallback((varName: string) => { + if (!profileDocs) return { expectedValue: undefined, description: undefined, isSecret: false }; + + const doc = profileDocs.environmentVariables.find(ev => ev.name === varName); + return { + expectedValue: doc?.expectedValue, + description: doc?.description, + isSecret: doc?.isSecret || false + }; + }, [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 }; + onChange(updated); + }, [environmentVariables, onChange]); + + const handleDeleteVariable = React.useCallback((index: number) => { + onChange(environmentVariables.filter((_, i) => i !== index)); + }, [environmentVariables, onChange]); + + const handleDuplicateVariable = React.useCallback((index: number) => { + const envVar = environmentVariables[index]; + const baseName = envVar.name.replace(/_COPY\d*$/, ''); + + // Find next available copy number + let copyNum = 1; + while (environmentVariables.some(v => v.name === `${baseName}_COPY${copyNum}`)) { + copyNum++; + } + + const duplicated = { + name: `${baseName}_COPY${copyNum}`, + value: envVar.value + }; + onChange([...environmentVariables, duplicated]); + }, [environmentVariables, onChange]); + + const handleAddVariable = React.useCallback(() => { + if (!newVarName.trim()) return; + + // Validate variable name format + if (!/^[A-Z_][A-Z0-9_]*$/.test(newVarName.trim())) { + return; + } + + // Check for duplicates + if (environmentVariables.some(v => v.name === newVarName.trim())) { + return; + } + + onChange([...environmentVariables, { + name: newVarName.trim(), + value: newVarValue.trim() || '' + }]); + + // Reset form + setNewVarName(''); + setNewVarValue(''); + setShowAddForm(false); + }, [newVarName, newVarValue, environmentVariables, onChange]); + + return ( + + {/* Section header */} + + Environment Variables + + + {/* Add Variable Button */} + setShowAddForm(true)} + > + + + Add Variable + + + + {/* Add variable inline form */} + {showAddForm && ( + + + + + { + setShowAddForm(false); + setNewVarName(''); + setNewVarValue(''); + }} + > + + Cancel + + + + + Add + + + + + )} + + {/* Variable cards */} + {environmentVariables.map((envVar, index) => { + const varNameFromValue = extractVarNameFromValue(envVar.value); + const docs = getDocumentation(varNameFromValue || envVar.name); + + // Auto-detect secrets if not explicitly documented + const isSecret = docs.isSecret || /TOKEN|KEY|SECRET|AUTH/i.test(envVar.name) || /TOKEN|KEY|SECRET|AUTH/i.test(varNameFromValue || ''); + + return ( + handleUpdateVariable(index, newValue)} + onDelete={() => handleDeleteVariable(index)} + onDuplicate={() => handleDuplicateVariable(index)} + /> + ); + })} + + ); +} diff --git a/sources/components/MessageView.tsx b/sources/components/MessageView.tsx index 006a030c..9ddabe01 100644 --- a/sources/components/MessageView.tsx +++ b/sources/components/MessageView.tsx @@ -10,6 +10,7 @@ import { ToolView } from "./tools/ToolView"; import { AgentEvent } from "@/sync/typesRaw"; import { sync } from '@/sync/sync'; import { Option } from './markdown/MarkdownView'; +import { useSetting } from "@/sync/storage"; export const MessageView = (props: { message: Message; @@ -88,10 +89,16 @@ function AgentTextBlock(props: { message: AgentTextMessage; sessionId: string; }) { + const experiments = useSetting('experiments'); const handleOptionPress = React.useCallback((option: Option) => { sync.sendMessage(props.sessionId, option.title); }, [props.sessionId]); + // Hide thinking messages unless experiments is enabled + if (props.message.isThinking && !experiments) { + return null; + } + return ( diff --git a/sources/components/MultiTextInput.web.tsx b/sources/components/MultiTextInput.web.tsx index 56588ae5..0cec9ac1 100644 --- a/sources/components/MultiTextInput.web.tsx +++ b/sources/components/MultiTextInput.web.tsx @@ -61,7 +61,7 @@ export const MultiTextInput = React.forwardRef) => { if (!onKeyPress) return; - const isComposing = e.nativeEvent.isComposing || e.keyCode === 229; + const isComposing = e.nativeEvent.isComposing || (e.nativeEvent as any).isComposing || e.keyCode === 229; if (isComposing) { return; } diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx new file mode 100644 index 00000000..ea556c99 --- /dev/null +++ b/sources/components/NewSessionWizard.tsx @@ -0,0 +1,1917 @@ +import React, { useState, useMemo } from 'react'; +import { View, Text, Pressable, ScrollView, TextInput } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { Ionicons } from '@expo/vector-icons'; +import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { PermissionModeSelector, PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { useAllMachines, useSessions, useSetting, storage } from '@/sync/storage'; +import { useRouter } from 'expo-router'; +import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from '@/sync/settings'; +import { Modal } from '@/modal'; +import { sync } from '@/sync/sync'; +import { profileSyncService } from '@/sync/profileSync'; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 24, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + stepIndicator: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + stepDot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 4, + }, + stepDotActive: { + backgroundColor: theme.colors.button.primary.background, + }, + stepDotInactive: { + backgroundColor: theme.colors.divider, + }, + stepContent: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 0, // No bottom padding since footer is separate + }, + stepTitle: { + fontSize: 20, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + ...Typography.default('semiBold'), + }, + stepDescription: { + fontSize: 16, + color: theme.colors.textSecondary, + marginBottom: 24, + ...Typography.default(), + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 24, + paddingVertical: 16, + borderTopWidth: 1, + borderTopColor: theme.colors.divider, + backgroundColor: theme.colors.surface, // Ensure footer has solid background + }, + button: { + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + minWidth: 100, + alignItems: 'center', + justifyContent: 'center', + }, + buttonPrimary: { + backgroundColor: theme.colors.button.primary.background, + }, + buttonSecondary: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: theme.colors.divider, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + buttonTextPrimary: { + color: '#FFFFFF', + }, + buttonTextSecondary: { + color: theme.colors.text, + }, + textInput: { + backgroundColor: theme.colors.input.background, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + color: theme.colors.text, + borderWidth: 1, + borderColor: theme.colors.divider, + ...Typography.default(), + }, + agentOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + borderWidth: 2, + marginBottom: 12, + }, + agentOptionSelected: { + borderColor: theme.colors.button.primary.background, + backgroundColor: theme.colors.input.background, + }, + agentOptionUnselected: { + borderColor: theme.colors.divider, + backgroundColor: theme.colors.input.background, + }, + agentIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: theme.colors.button.primary.background, + alignItems: 'center', + justifyContent: 'center', + marginRight: 16, + }, + agentInfo: { + flex: 1, + }, + agentName: { + fontSize: 16, + fontWeight: '600', + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + agentDescription: { + fontSize: 14, + color: theme.colors.textSecondary, + marginTop: 4, + ...Typography.default(), + }, +})); + +type WizardStep = 'profile' | 'profileConfig' | 'sessionType' | 'agent' | 'options' | 'machine' | 'path' | 'prompt'; + +// Profile selection item component with management actions +interface ProfileSelectionItemProps { + profile: AIBackendProfile; + isSelected: boolean; + onSelect: () => void; + onUseAsIs: () => void; + onEdit: () => void; + onDuplicate?: () => void; + onDelete?: () => void; + showManagementActions?: boolean; +} + +function ProfileSelectionItem({ profile, isSelected, onSelect, onUseAsIs, onEdit, onDuplicate, onDelete, showManagementActions = false }: ProfileSelectionItemProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + return ( + + {/* Profile Header */} + + + + + + + + {profile.name} + + + {profile.description} + + {profile.isBuiltIn && ( + + Built-in profile + + )} + + {isSelected && ( + + )} + + + + {/* Action Buttons - Only show when selected */} + {isSelected && ( + + {/* Primary Actions */} + + + + + Use As-Is + + + + + + + Edit + + + + + {/* Management Actions - Only show for custom profiles */} + {showManagementActions && !profile.isBuiltIn && ( + + + + + Duplicate + + + + + + + Delete + + + + )} + + )} + + ); +} + +// Manual configuration item component +interface ManualConfigurationItemProps { + isSelected: boolean; + onSelect: () => void; + onUseCliVars: () => void; + onConfigureManually: () => void; +} + +function ManualConfigurationItem({ isSelected, onSelect, onUseCliVars, onConfigureManually }: ManualConfigurationItemProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + return ( + + {/* Profile Header */} + + + + + + + + Manual Configuration + + + Use CLI environment variables or configure manually + + + {isSelected && ( + + )} + + + + {/* Action Buttons - Only show when selected */} + {isSelected && ( + + + + + Use CLI Vars + + + + + + + Configure + + + + )} + + ); +} + +interface NewSessionWizardProps { + onComplete: (config: { + sessionType: 'simple' | 'worktree'; + profileId: string | null; + agentType: 'claude' | 'codex'; + permissionMode: PermissionMode; + modelMode: ModelMode; + machineId: string; + path: string; + prompt: string; + environmentVariables?: Record; + }) => void; + onCancel: () => void; + initialPrompt?: string; +} + +export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: NewSessionWizardProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const router = useRouter(); + const machines = useAllMachines(); + const sessions = useSessions(); + const experimentsEnabled = useSetting('experiments'); + const recentMachinePaths = useSetting('recentMachinePaths'); + const lastUsedAgent = useSetting('lastUsedAgent'); + const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); + const lastUsedModelMode = useSetting('lastUsedModelMode'); + const profiles = useSetting('profiles'); + const lastUsedProfile = useSetting('lastUsedProfile'); + + // Wizard state + const [currentStep, setCurrentStep] = useState('profile'); + const [sessionType, setSessionType] = useState<'simple' | 'worktree'>('simple'); + const [agentType, setAgentType] = useState<'claude' | 'codex'>(() => { + if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { + return lastUsedAgent; + } + return 'claude'; + }); + const [permissionMode, setPermissionMode] = useState('default'); + const [modelMode, setModelMode] = useState('default'); + const [selectedProfileId, setSelectedProfileId] = useState(() => { + return lastUsedProfile; + }); + + // Built-in profiles + const builtInProfiles: AIBackendProfile[] = useMemo(() => [ + { + id: 'anthropic', + name: 'Anthropic (Default)', + description: 'Default Claude configuration', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: false, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + { + id: 'deepseek', + name: 'DeepSeek (Reasoner)', + description: 'DeepSeek reasoning model with proxy to Anthropic API', + anthropicConfig: { + baseUrl: 'https://api.deepseek.com/anthropic', + model: 'deepseek-reasoner', + }, + environmentVariables: [ + { name: 'API_TIMEOUT_MS', value: '600000' }, + { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'deepseek-chat' }, + { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' }, + ], + compatibility: { claude: true, codex: false, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + { + id: 'openai', + name: 'OpenAI (GPT-4/Codex)', + description: 'OpenAI GPT-4 and Codex models', + openaiConfig: { + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4-turbo', + }, + environmentVariables: [], + compatibility: { claude: false, codex: true, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + { + id: 'azure-openai-codex', + name: 'Azure OpenAI (Codex)', + description: 'Microsoft Azure OpenAI for Codex agents', + azureOpenAIConfig: { + endpoint: 'https://your-resource.openai.azure.com/', + apiVersion: '2024-02-15-preview', + deploymentName: 'gpt-4-turbo', + }, + environmentVariables: [], + compatibility: { claude: false, codex: true, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + { + id: 'azure-openai', + name: 'Azure OpenAI', + description: 'Microsoft Azure OpenAI configuration', + azureOpenAIConfig: { + apiVersion: '2024-02-15-preview', + }, + environmentVariables: [ + { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, + ], + compatibility: { claude: false, codex: true, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + { + id: 'zai', + name: 'Z.ai (GLM-4.6)', + description: 'Z.ai GLM-4.6 model with proxy to Anthropic API', + anthropicConfig: { + baseUrl: 'https://api.z.ai/api/anthropic', + model: 'glm-4.6', + }, + environmentVariables: [], + compatibility: { claude: true, codex: false, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + { + id: 'microsoft', + name: 'Microsoft Azure', + description: 'Microsoft Azure AI services', + openaiConfig: { + baseUrl: 'https://api.openai.azure.com', + model: 'gpt-4-turbo', + }, + environmentVariables: [], + compatibility: { claude: false, codex: true, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }, + ], []); + + // Combined profiles + const allProfiles = useMemo(() => { + return [...builtInProfiles, ...profiles]; + }, [profiles, builtInProfiles]); + + const [selectedMachineId, setSelectedMachineId] = useState(() => { + if (machines.length > 0) { + // Check if we have a recently used machine that's currently available + if (recentMachinePaths.length > 0) { + for (const recent of recentMachinePaths) { + if (machines.find(m => m.id === recent.machineId)) { + return recent.machineId; + } + } + } + return machines[0].id; + } + return ''; + }); + const [selectedPath, setSelectedPath] = useState(() => { + if (machines.length > 0 && selectedMachineId) { + const machine = machines.find(m => m.id === selectedMachineId); + return machine?.metadata?.homeDir || '/home'; + } + return '/home'; + }); + const [prompt, setPrompt] = useState(initialPrompt); + const [customPath, setCustomPath] = useState(''); + const [showCustomPathInput, setShowCustomPathInput] = useState(false); + + // Profile configuration state + const [profileApiKeys, setProfileApiKeys] = useState>>({}); + const [profileConfigs, setProfileConfigs] = useState>>({}); + + // Dynamic steps based on whether profile needs configuration + const steps: WizardStep[] = React.useMemo(() => { + const baseSteps: WizardStep[] = experimentsEnabled + ? ['profile', 'sessionType', 'agent', 'options', 'machine', 'path', 'prompt'] + : ['profile', 'agent', 'options', 'machine', 'path', 'prompt']; + + // Insert profileConfig step after profile if needed + if (profileNeedsConfiguration(selectedProfileId)) { + const profileIndex = baseSteps.indexOf('profile'); + const beforeProfile = baseSteps.slice(0, profileIndex + 1) as WizardStep[]; + const afterProfile = baseSteps.slice(profileIndex + 1) as WizardStep[]; + return [ + ...beforeProfile, + 'profileConfig', + ...afterProfile + ] as WizardStep[]; + } + + return baseSteps; + }, [experimentsEnabled, selectedProfileId]); + + // Helper function to check if profile needs API keys + const profileNeedsConfiguration = (profileId: string | null): boolean => { + if (!profileId) return false; // Manual configuration doesn't need API keys + const profile = allProfiles.find(p => p.id === profileId); + if (!profile) return false; + + // Check if profile is one that requires API keys + const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; + return profilesNeedingKeys.includes(profile.id); + }; + + // Get required fields for profile configuration + const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { + if (!profileId) return []; + const profile = allProfiles.find(p => p.id === profileId); + if (!profile) return []; + + switch (profile.id) { + case 'deepseek': + return [ + { key: 'ANTHROPIC_AUTH_TOKEN', label: 'DeepSeek API Key', placeholder: 'DEEPSEEK_API_KEY', isPassword: true } + ]; + case 'openai': + return [ + { key: 'OPENAI_API_KEY', label: 'OpenAI API Key', placeholder: 'sk-...', isPassword: true } + ]; + case 'azure-openai': + return [ + { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, + { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, + { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } + ]; + case 'zai': + return [ + { key: 'ANTHROPIC_AUTH_TOKEN', label: 'Z.ai API Key', placeholder: 'Z_AI_API_KEY', isPassword: true } + ]; + case 'microsoft': + return [ + { key: 'AZURE_OPENAI_API_KEY', label: 'Azure API Key', placeholder: 'Enter your Azure API key', isPassword: true }, + { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, + { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } + ]; + case 'azure-openai-codex': + return [ + { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, + { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, + { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } + ]; + default: + return []; + } + }; + + // Auto-load profile settings and sync with CLI + React.useEffect(() => { + if (selectedProfileId) { + const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); + if (selectedProfile) { + // Auto-select agent type based on profile compatibility + if (selectedProfile.compatibility.claude && !selectedProfile.compatibility.codex) { + setAgentType('claude'); + } else if (selectedProfile.compatibility.codex && !selectedProfile.compatibility.claude) { + setAgentType('codex'); + } + + // Sync active profile to CLI + profileSyncService.setActiveProfile(selectedProfileId).catch(error => { + console.error('[Wizard] Failed to sync active profile to CLI:', error); + }); + } + } + }, [selectedProfileId, allProfiles]); + + // Sync profiles with CLI on component mount and when profiles change + React.useEffect(() => { + const syncProfiles = async () => { + try { + await profileSyncService.bidirectionalSync(allProfiles); + } catch (error) { + console.error('[Wizard] Failed to sync profiles with CLI:', error); + // Continue without sync - profiles work locally + } + }; + + // Sync on mount + syncProfiles(); + + // Set up sync listener for profile changes + const handleSyncEvent = (event: any) => { + if (event.status === 'error') { + console.warn('[Wizard] Profile sync error:', event.error); + } + }; + + profileSyncService.addEventListener(handleSyncEvent); + + return () => { + profileSyncService.removeEventListener(handleSyncEvent); + }; + }, [allProfiles]); + + // Get recent paths for the selected machine + const recentPaths = useMemo(() => { + if (!selectedMachineId) return []; + + const paths: string[] = []; + const pathSet = new Set(); + + // First, add paths from recentMachinePaths (these are the most recent) + recentMachinePaths.forEach(entry => { + if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) { + paths.push(entry.path); + pathSet.add(entry.path); + } + }); + + // Then add paths from sessions if we need more + if (sessions) { + const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; + + sessions.forEach(item => { + if (typeof item === 'string') return; // Skip section headers + + const session = item as any; + if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) { + const path = session.metadata.path; + if (!pathSet.has(path)) { + pathSet.add(path); + pathsWithTimestamps.push({ + path, + timestamp: session.updatedAt || session.createdAt + }); + } + } + }); + + // Sort session paths by most recent first and add them + pathsWithTimestamps + .sort((a, b) => b.timestamp - a.timestamp) + .forEach(item => paths.push(item.path)); + } + + return paths; + }, [sessions, selectedMachineId, recentMachinePaths]); + + const currentStepIndex = steps.indexOf(currentStep); + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === steps.length - 1; + + // Handler for "Use Profile As-Is" - quick session creation + const handleUseProfileAsIs = (profile: AIBackendProfile) => { + setSelectedProfileId(profile.id); + + // Auto-select agent type based on profile compatibility + if (profile.compatibility.claude && !profile.compatibility.codex) { + setAgentType('claude'); + } else if (profile.compatibility.codex && !profile.compatibility.claude) { + setAgentType('codex'); + } + + // Get environment variables from profile (no user configuration) + const environmentVariables = getProfileEnvironmentVariables(profile); + + // Complete wizard immediately with profile settings + onComplete({ + sessionType, + profileId: profile.id, + agentType: agentType || (profile.compatibility.claude ? 'claude' : 'codex'), + permissionMode, + modelMode, + machineId: selectedMachineId, + path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, + prompt, + environmentVariables, + }); + }; + + // Handler for "Edit Profile" - load profile and go to configuration step + const handleEditProfile = (profile: AIBackendProfile) => { + setSelectedProfileId(profile.id); + + // Auto-select agent type based on profile compatibility + if (profile.compatibility.claude && !profile.compatibility.codex) { + setAgentType('claude'); + } else if (profile.compatibility.codex && !profile.compatibility.claude) { + setAgentType('codex'); + } + + // If profile needs configuration, go to profileConfig step + if (profileNeedsConfiguration(profile.id)) { + setCurrentStep('profileConfig'); + } else { + // If no configuration needed, proceed to next step in the normal flow + const profileIndex = steps.indexOf('profile'); + setCurrentStep(steps[profileIndex + 1]); + } + }; + + // Handler for "Create New Profile" + const handleCreateProfile = () => { + Modal.prompt( + 'Create New Profile', + 'Enter a name for your new profile:', + { + defaultValue: 'My Custom Profile', + confirmText: 'Create', + cancelText: 'Cancel' + } + ).then((profileName) => { + if (profileName && profileName.trim()) { + const newProfile: AIBackendProfile = { + id: crypto.randomUUID(), + name: profileName.trim(), + description: 'Custom AI profile', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + + // Get current profiles from settings + const currentProfiles = storage.getState().settings.profiles || []; + const updatedProfiles = [...currentProfiles, newProfile]; + + // Persist through settings system + sync.applySettings({ profiles: updatedProfiles }); + + // Sync with CLI + profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { + console.error('[Wizard] Failed to sync new profile with CLI:', error); + }); + + // Auto-select the newly created profile + setSelectedProfileId(newProfile.id); + } + }); + }; + + // Handler for "Duplicate Profile" + const handleDuplicateProfile = (profile: AIBackendProfile) => { + Modal.prompt( + 'Duplicate Profile', + `Enter a name for the duplicate of "${profile.name}":`, + { + defaultValue: `${profile.name} (Copy)`, + confirmText: 'Duplicate', + cancelText: 'Cancel' + } + ).then((newName) => { + if (newName && newName.trim()) { + const duplicatedProfile: AIBackendProfile = { + ...profile, + id: crypto.randomUUID(), + name: newName.trim(), + description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Get current profiles from settings + const currentProfiles = storage.getState().settings.profiles || []; + const updatedProfiles = [...currentProfiles, duplicatedProfile]; + + // Persist through settings system + sync.applySettings({ profiles: updatedProfiles }); + + // Sync with CLI + profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { + console.error('[Wizard] Failed to sync duplicated profile with CLI:', error); + }); + } + }); + }; + + // Handler for "Delete Profile" + const handleDeleteProfile = (profile: AIBackendProfile) => { + Modal.confirm( + 'Delete Profile', + `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`, + { + confirmText: 'Delete', + destructive: true + } + ).then((confirmed) => { + if (confirmed) { + // Get current profiles from settings + const currentProfiles = storage.getState().settings.profiles || []; + const updatedProfiles = currentProfiles.filter(p => p.id !== profile.id); + + // Persist through settings system + sync.applySettings({ profiles: updatedProfiles }); + + // Sync with CLI + profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { + console.error('[Wizard] Failed to sync profile deletion with CLI:', error); + }); + + // Clear selection if deleted profile was selected + if (selectedProfileId === profile.id) { + setSelectedProfileId(null); + } + } + }); + }; + + // Handler for "Use CLI Environment Variables" - quick session creation with CLI vars + const handleUseCliEnvironmentVariables = () => { + setSelectedProfileId(null); + + // Complete wizard immediately with no profile (rely on CLI environment variables) + onComplete({ + sessionType, + profileId: null, + agentType, + permissionMode, + modelMode, + machineId: selectedMachineId, + path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, + prompt, + environmentVariables: undefined, // Let CLI handle environment variables + }); + }; + + // Handler for "Manual Configuration" - go through normal wizard flow + const handleManualConfiguration = () => { + setSelectedProfileId(null); + + // Proceed to next step in normal wizard flow + const profileIndex = steps.indexOf('profile'); + setCurrentStep(steps[profileIndex + 1]); + }; + + const handleNext = () => { + // Special handling for profileConfig step - skip if profile doesn't need configuration + if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { + setCurrentStep(steps[currentStepIndex + 1]); + return; + } + + if (isLastStep) { + // Get environment variables from selected profile with proper precedence handling + let environmentVariables: Record | undefined; + if (selectedProfileId) { + const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); + if (selectedProfile) { + // Start with profile environment variables (base configuration) + environmentVariables = getProfileEnvironmentVariables(selectedProfile); + + // Only add user-provided API keys if they're non-empty + // This preserves CLI environment variable precedence when wizard fields are empty + const userApiKeys = profileApiKeys[selectedProfileId]; + if (userApiKeys) { + Object.entries(userApiKeys).forEach(([key, value]) => { + // Only override if user provided a non-empty value + if (value && value.trim().length > 0) { + environmentVariables![key] = value; + } + }); + } + + // Only add user configurations if they're non-empty + const userConfigs = profileConfigs[selectedProfileId]; + if (userConfigs) { + Object.entries(userConfigs).forEach(([key, value]) => { + // Only override if user provided a non-empty value + if (value && value.trim().length > 0) { + environmentVariables![key] = value; + } + }); + } + } + } + + onComplete({ + sessionType, + profileId: selectedProfileId, + agentType, + permissionMode, + modelMode, + machineId: selectedMachineId, + path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, + prompt, + environmentVariables, + }); + } else { + setCurrentStep(steps[currentStepIndex + 1]); + } + }; + + const handleBack = () => { + if (isFirstStep) { + onCancel(); + } else { + setCurrentStep(steps[currentStepIndex - 1]); + } + }; + + const canProceed = useMemo(() => { + switch (currentStep) { + case 'profile': + return true; // Always valid (profile can be null for manual config) + case 'profileConfig': + if (!selectedProfileId) return false; + const requiredFields = getProfileRequiredFields(selectedProfileId); + // Profile configuration step is always shown when needed + // Users can leave fields empty to preserve CLI environment variables + return true; + case 'sessionType': + return true; // Always valid + case 'agent': + return true; // Always valid + case 'options': + return true; // Always valid + case 'machine': + return selectedMachineId.length > 0; + case 'path': + return (selectedPath.trim().length > 0) || (showCustomPathInput && customPath.trim().length > 0); + case 'prompt': + return prompt.trim().length > 0; + default: + return false; + } + }, [currentStep, selectedMachineId, selectedPath, prompt, showCustomPathInput, customPath, selectedProfileId, profileApiKeys, profileConfigs, getProfileRequiredFields]); + + const renderStepContent = () => { + switch (currentStep) { + case 'profile': + return ( + + Choose AI Profile + + Select a pre-configured AI profile or set up manually + + + + {builtInProfiles.map((profile) => ( + setSelectedProfileId(profile.id)} + onUseAsIs={() => handleUseProfileAsIs(profile)} + onEdit={() => handleEditProfile(profile)} + /> + ))} + + + {profiles.length > 0 && ( + + {profiles.map((profile) => ( + setSelectedProfileId(profile.id)} + onUseAsIs={() => handleUseProfileAsIs(profile)} + onEdit={() => handleEditProfile(profile)} + onDuplicate={() => handleDuplicateProfile(profile)} + onDelete={() => handleDeleteProfile(profile)} + showManagementActions={true} + /> + ))} + + )} + + {/* Create New Profile Button */} + + + + + + + + Create New Profile + + + Set up a custom AI backend configuration + + + + + + + setSelectedProfileId(null)} + onUseCliVars={() => handleUseCliEnvironmentVariables()} + onConfigureManually={() => handleManualConfiguration()} + /> + + + + + 💡 **Profile Selection Options:** + + + • **Use As-Is**: Quick session creation with current profile settings + + + • **Edit**: Configure API keys and settings before session creation + + + • **Manual**: Use CLI environment variables without profile configuration + + + + ); + + case 'profileConfig': + if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { + // Skip configuration if no profile selected or profile doesn't need configuration + setCurrentStep(steps[currentStepIndex + 1]); + return null; + } + + return ( + + Configure {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Profile'} + + Enter your API keys and configuration details + + + + {getProfileRequiredFields(selectedProfileId).map((field) => ( + + + {field.label} + + { + if (field.isPassword) { + // API key + setProfileApiKeys(prev => ({ + ...prev, + [selectedProfileId!]: { + ...(prev[selectedProfileId!] as Record || {}), + [field.key]: text + } + })); + } else { + // Configuration field + setProfileConfigs(prev => ({ + ...prev, + [selectedProfileId!]: { + ...(prev[selectedProfileId!] as Record || {}), + [field.key]: text + } + })); + } + }} + secureTextEntry={field.isPassword} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="next" + /> + + ))} + + + + + 💡 Tip: Your API keys are only used for this session and are not stored permanently + + + 📝 Note: Leave fields empty to use CLI environment variables if they're already set + + + + ); + + case 'sessionType': + return ( + + Choose AI Backend & Session Type + + Select your AI provider and how you want to work with your code + + + + {[ + { + id: 'anthropic', + name: 'Anthropic Claude', + description: 'Advanced reasoning and coding assistant', + icon: 'cube-outline', + agentType: 'claude' as const + }, + { + id: 'openai', + name: 'OpenAI GPT-5', + description: 'Specialized coding assistant', + icon: 'code-outline', + agentType: 'codex' as const + }, + { + id: 'deepseek', + name: 'DeepSeek Reasoner', + description: 'Advanced reasoning model', + icon: 'analytics-outline', + agentType: 'claude' as const + }, + { + id: 'zai', + name: 'Z.ai', + description: 'AI assistant for development', + icon: 'flash-outline', + agentType: 'claude' as const + }, + { + id: 'microsoft', + name: 'Microsoft Azure', + description: 'Enterprise AI services', + icon: 'cloud-outline', + agentType: 'codex' as const + }, + ].map((backend) => ( + + } + rightElement={agentType === backend.agentType ? ( + + ) : null} + onPress={() => setAgentType(backend.agentType)} + showChevron={false} + selected={agentType === backend.agentType} + showDivider={true} + /> + ))} + + + + + ); + + case 'agent': + return ( + + Choose AI Agent + + Select which AI assistant you want to use + + + {selectedProfileId && ( + + + Profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} + + + {allProfiles.find(p => p.id === selectedProfileId)?.description} + + + )} + + p.id === selectedProfileId)?.compatibility.claude && { + opacity: 0.5, + backgroundColor: theme.colors.surface + } + ]} + onPress={() => { + if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude) { + setAgentType('claude'); + } + }} + disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude)} + > + + C + + + Claude + + Anthropic's AI assistant, great for coding and analysis + + {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && ( + + Not compatible with selected profile + + )} + + {agentType === 'claude' && ( + + )} + + + p.id === selectedProfileId)?.compatibility.codex && { + opacity: 0.5, + backgroundColor: theme.colors.surface + } + ]} + onPress={() => { + if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex) { + setAgentType('codex'); + } + }} + disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex)} + > + + X + + + Codex + + OpenAI's specialized coding assistant + + {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && ( + + Not compatible with selected profile + + )} + + {agentType === 'codex' && ( + + )} + + + ); + + case 'options': + return ( + + Agent Options + + Configure how the AI agent should behave + + + {selectedProfileId && ( + + + Using profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} + + + Environment variables will be applied automatically + + + )} + + {([ + { value: 'default', label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits', label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan', label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions', label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, + ] as const).map((option, index, array) => ( + + } + rightElement={permissionMode === option.value ? ( + + ) : null} + onPress={() => setPermissionMode(option.value as PermissionMode)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> + ))} + + + + {(agentType === 'claude' ? [ + { value: 'default', label: 'Default', description: 'Balanced performance', icon: 'cube-outline' }, + { value: 'adaptiveUsage', label: 'Adaptive Usage', description: 'Automatically choose model', icon: 'analytics-outline' }, + { value: 'sonnet', label: 'Sonnet', description: 'Fast and efficient', icon: 'speedometer-outline' }, + { value: 'opus', label: 'Opus', description: 'Most capable model', icon: 'diamond-outline' }, + ] as const : [ + { value: 'gpt-5-codex-high', label: 'GPT-5 Codex High', description: 'Best for complex coding', icon: 'diamond-outline' }, + { value: 'gpt-5-codex-medium', label: 'GPT-5 Codex Medium', description: 'Balanced coding assistance', icon: 'cube-outline' }, + { value: 'gpt-5-codex-low', label: 'GPT-5 Codex Low', description: 'Fast coding help', icon: 'speedometer-outline' }, + ] as const).map((option, index, array) => ( + + } + rightElement={modelMode === option.value ? ( + + ) : null} + onPress={() => setModelMode(option.value as ModelMode)} + showChevron={false} + selected={modelMode === option.value} + showDivider={index < array.length - 1} + /> + ))} + + + ); + + case 'machine': + return ( + + Select Machine + + Choose which machine to run your session on + + + + {machines.map((machine, index) => ( + + } + rightElement={selectedMachineId === machine.id ? ( + + ) : null} + onPress={() => { + setSelectedMachineId(machine.id); + // Update path when machine changes + const homeDir = machine.metadata?.homeDir || '/home'; + setSelectedPath(homeDir); + }} + showChevron={false} + selected={selectedMachineId === machine.id} + showDivider={index < machines.length - 1} + /> + ))} + + + ); + + case 'path': + return ( + + Working Directory + + Choose the directory to work in + + + {/* Recent Paths */} + {recentPaths.length > 0 && ( + + {recentPaths.map((path, index) => ( + + } + rightElement={selectedPath === path && !showCustomPathInput ? ( + + ) : null} + onPress={() => { + setSelectedPath(path); + setShowCustomPathInput(false); + }} + showChevron={false} + selected={selectedPath === path && !showCustomPathInput} + showDivider={index < recentPaths.length - 1} + /> + ))} + + )} + + {/* Common Directories */} + + {(() => { + const machine = machines.find(m => m.id === selectedMachineId); + const homeDir = machine?.metadata?.homeDir || '/home'; + const pathOptions = [ + { value: homeDir, label: homeDir, description: 'Home directory' }, + { value: `${homeDir}/projects`, label: `${homeDir}/projects`, description: 'Projects folder' }, + { value: `${homeDir}/Documents`, label: `${homeDir}/Documents`, description: 'Documents folder' }, + { value: `${homeDir}/Desktop`, label: `${homeDir}/Desktop`, description: 'Desktop folder' }, + ]; + return pathOptions.map((option, index) => ( + + } + rightElement={selectedPath === option.value && !showCustomPathInput ? ( + + ) : null} + onPress={() => { + setSelectedPath(option.value); + setShowCustomPathInput(false); + }} + showChevron={false} + selected={selectedPath === option.value && !showCustomPathInput} + showDivider={index < pathOptions.length - 1} + /> + )); + })()} + + + {/* Custom Path Option */} + + + } + rightElement={showCustomPathInput ? ( + + ) : null} + onPress={() => setShowCustomPathInput(true)} + showChevron={false} + selected={showCustomPathInput} + showDivider={false} + /> + {showCustomPathInput && ( + + + + )} + + + ); + + case 'prompt': + return ( + + Initial Message + + Write your first message to the AI agent + + + + + ); + + default: + return null; + } + }; + + return ( + + + New Session + + + + + + + {steps.map((step, index) => ( + + ))} + + + + {renderStepContent()} + + + + + + {isFirstStep ? 'Cancel' : 'Back'} + + + + + + {isLastStep ? 'Create Session' : 'Next'} + + + + + ); +} \ No newline at end of file diff --git a/sources/components/PermissionModeSelector.tsx b/sources/components/PermissionModeSelector.tsx index 5e33a37a..da96dd23 100644 --- a/sources/components/PermissionModeSelector.tsx +++ b/sources/components/PermissionModeSelector.tsx @@ -6,7 +6,7 @@ import { hapticsLight } from './haptics'; export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; -export type ModelMode = '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'; interface PermissionModeSelectorProps { mode: PermissionMode; diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx new file mode 100644 index 00000000..8a3864d4 --- /dev/null +++ b/sources/components/ProfileEditForm.tsx @@ -0,0 +1,580 @@ +import React from 'react'; +import { View, Text, Pressable, ScrollView, 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 { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; +import { useEnvironmentVariables, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; +import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; + +export interface ProfileEditFormProps { + profile: AIBackendProfile; + machineId: string | null; + onSave: (profile: AIBackendProfile) => void; + onCancel: () => void; + containerStyle?: ViewStyle; +} + +export function ProfileEditForm({ + profile, + machineId, + onSave, + onCancel, + containerStyle +}: ProfileEditFormProps) { + const { theme } = useUnistyles(); + + // Get documentation for built-in profiles + const profileDocs = React.useMemo(() => { + if (!profile.isBuiltIn) return null; + return getBuiltInProfileDocumentation(profile.id); + }, [profile.isBuiltIn, profile.id]); + + // Local state for environment variables (unified for all config) + const [environmentVariables, setEnvironmentVariables] = React.useState>( + profile.environmentVariables || [] + ); + + // Extract ${VAR} references from environmentVariables for querying daemon + const envVarNames = React.useMemo(() => { + return extractEnvVarReferences(environmentVariables); + }, [environmentVariables]); + + // Query daemon environment using hook + const { variables: actualEnvVars } = useEnvironmentVariables(machineId, envVarNames); + + const [name, setName] = React.useState(profile.name || ''); + const [useTmux, setUseTmux] = React.useState(profile.tmuxConfig?.sessionName !== undefined); + const [tmuxSession, setTmuxSession] = React.useState(profile.tmuxConfig?.sessionName || ''); + const [tmuxTmpDir, setTmuxTmpDir] = React.useState(profile.tmuxConfig?.tmpDir || ''); + const [useStartupScript, setUseStartupScript] = React.useState(!!profile.startupBashScript); + const [startupScript, setStartupScript] = React.useState(profile.startupBashScript || ''); + const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>(profile.defaultSessionType || 'simple'); + const [defaultPermissionMode, setDefaultPermissionMode] = React.useState((profile.defaultPermissionMode as PermissionMode) || 'default'); + const [agentType, setAgentType] = React.useState<'claude' | 'codex'>(() => { + if (profile.compatibility.claude && !profile.compatibility.codex) return 'claude'; + if (profile.compatibility.codex && !profile.compatibility.claude) return 'codex'; + return 'claude'; // Default to Claude if both or neither + }); + + const handleSave = () => { + 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, + updatedAt: Date.now(), + }); + }; + + 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} + /> + ))} + + + + {/* 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. + + + + {/* Startup Bash Script */} + + + setUseStartupScript(!useStartupScript)} + > + + {useStartupScript && ( + + )} + + + + Startup Bash Script + + + + {useStartupScript + ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.' + : 'No startup script - sessions spawn directly'} + + + + {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, + }, + scrollContent: { + padding: 20, + }, + formContainer: { + backgroundColor: theme.colors.surface, + borderRadius: 16, // Matches new session panel main container + padding: 20, + width: '100%', + }, +})); diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx new file mode 100644 index 00000000..c81ba79e --- /dev/null +++ b/sources/components/SearchableListSelector.tsx @@ -0,0 +1,675 @@ +import * as React from 'react'; +import { View, Text, Pressable, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { MultiTextInput } from '@/components/MultiTextInput'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { StatusDot } from '@/components/StatusDot'; + +/** + * Configuration object for customizing the SearchableListSelector component. + * Uses TypeScript generics to support any data type (T). + */ +export interface SelectorConfig { + // Core data accessors + getItemId: (item: T) => string; + getItemTitle: (item: T) => string; + getItemSubtitle?: (item: T) => string | undefined; + getItemIcon: (item: T) => React.ReactNode; + + // Status display (for machines: online/offline, paths: none) + getItemStatus?: (item: T, theme: any) => { + text: string; + color: string; + dotColor: string; + isPulsing?: boolean; + } | null; + + // Display formatting (e.g., formatPathRelativeToHome for paths, displayName for machines) + formatForDisplay: (item: T, context?: any) => string; + parseFromDisplay: (text: string, context?: any) => T | null; + + // Filtering logic + filterItem: (item: T, searchText: string, context?: any) => boolean; + + // UI customization + searchPlaceholder: string; + recentSectionTitle: string; + favoritesSectionTitle: string; + noItemsMessage: string; + + // Optional features + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + allowCustomInput?: boolean; + + // Item subtitle override (for recent items, e.g., "Recently used") + getRecentItemSubtitle?: (item: T) => string | undefined; + + // Custom icon for recent items (e.g., time-outline for recency indicator) + getRecentItemIcon?: (item: T) => React.ReactNode; + + // Custom icon for favorite items (e.g., home directory uses home-outline instead of star-outline) + getFavoriteItemIcon?: (item: T) => React.ReactNode; + + // 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) +} + +/** + * Props for the SearchableListSelector component. + */ +export interface SearchableListSelectorProps { + config: SelectorConfig; + items: T[]; + recentItems?: T[]; + favoriteItems?: T[]; + selectedItem: T | null; + onSelect: (item: T) => void; + onToggleFavorite?: (item: T) => void; + 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 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, + }, +})); + +/** + * 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; + const { + config, + items, + recentItems = [], + favoriteItems = [], + selectedItem, + onSelect, + onToggleFavorite, + context, + showFavorites = config.showFavorites !== false, + showRecent = config.showRecent !== false, + showSearch = config.showSearch !== false, + collapsedSections, + onCollapsedSectionsChange, + } = props; + + // 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 ''; + }); + 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 filteredFavoriteItems = React.useMemo(() => { + if (!inputText.trim()) return favoriteItems; + + 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; + + // Check if already in favorites + const parsedId = config.getItemId(parsedItem); + return !favoriteItems.some(fav => config.getItemId(fav) === parsedId); + }, [inputText, favoriteItems, config, context, onToggleFavorite]); + + // Handle input text change + const handleInputChange = (text: string) => { + isUserTyping.current = true; // User is actively typing + setInputText(text); + + // If allowCustomInput, try to parse and select + if (config.allowCustomInput && text.trim()) { + const parsedItem = config.parseFromDisplay(text.trim(), context); + if (parsedItem) { + onSelect(parsedItem); + } + } + }; + + // Handle item selection from list + const handleSelectItem = (item: T) => { + isUserTyping.current = false; // User clicked from list + setInputText(config.formatForDisplay(item, context)); + onSelect(item); + }; + + // Handle clear button + const handleClear = () => { + isUserTyping.current = false; + setInputText(''); + // Don't clear selection - just clear input + }; + + // Handle add to favorites + const handleAddToFavorites = () => { + if (!canAddToFavorites || !onToggleFavorite) return; + + const parsedItem = config.parseFromDisplay(inputText.trim(), context); + if (parsedItem) { + onToggleFavorite(parsedItem); + } + }; + + // 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 ( + + + + {status.text} + + + ); + }; + + // Render individual item (for recent items) + const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false) => { + const itemId = config.getItemId(item); + const title = config.getItemTitle(item); + const subtitle = forRecent && config.getRecentItemSubtitle + ? config.getRecentItemSubtitle(item) + : config.getItemSubtitle?.(item); + const icon = forRecent && config.getRecentItemIcon + ? config.getRecentItemIcon(item) + : config.getItemIcon(item); + const status = config.getItemStatus?.(item, theme); + + return ( + + {renderStatus(status)} + {isSelected && ( + + )} + + } + onPress={() => handleSelectItem(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 + ? 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} + + + + {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 + ]} + /> + ); + })} + + )} + + )} + + {/* 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); + })} + + )} + + )} + + ); +} diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 7c9ea593..249345e9 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -322,6 +322,12 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => router.push('/settings/features')} /> + } + onPress={() => router.push('/settings/profiles')} + /> {experiments && ( ({ container: { @@ -45,6 +46,13 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', pointerEvents: 'none', }, + titleContainerLeft: { + flex: 1, + flexDirection: 'column', + alignItems: 'flex-start', + marginLeft: 8, + justifyContent: 'center', + }, titleText: { fontSize: 17, fontWeight: '600', @@ -133,8 +141,8 @@ export const SidebarView = React.memo(() => { const inboxHasContent = useInboxHasContent(); const settings = useSettings(); - // Get connection status styling (matching sessionUtils.ts pattern) - const getConnectionStatus = () => { + // Compute connection status once per render (theme-reactive, no stale memoization) + const connectionStatus = (() => { const { status } = socketStatus; switch (status) { case 'connected': @@ -173,16 +181,45 @@ export const SidebarView = React.memo(() => { textColor: styles.statusDefault.color }; } - }; + })(); + + // Calculate sidebar width and determine title positioning + // Uses same formula as SidebarNavigator.tsx:18 for consistency + const { width: windowWidth } = useWindowDimensions(); + const sidebarWidth = Math.min(Math.max(Math.floor(windowWidth * 0.3), 250), 360); + // With experiments: 4 icons (148px total), threshold 408px > max 360px → always left-justify + // Without experiments: 3 icons (108px total), threshold 328px → left-justify below ~340px + const shouldLeftJustify = settings.experiments || sidebarWidth < 340; const handleNewSession = React.useCallback(() => { router.push('/new'); }, [router]); + // Title content used in both centered and left-justified modes (DRY) + const titleContent = ( + <> + {t('sidebar.sessionsTitle')} + {connectionStatus.text && ( + + + + {connectionStatus.text} + + + )} + + ); + return ( <> + {/* Logo - always first */} { style={[styles.logo, { height: 24, width: 24 }]} /> + + {/* Left-justified title - in document flow, prevents overlap */} + {shouldLeftJustify && ( + + {titleContent} + + )} + + {/* Navigation icons */} {settings.experiments && ( { tintColor={theme.colors.header.tint} /> + + + - - {t('sidebar.sessionsTitle')} - {getConnectionStatus().text && ( - - - - {getConnectionStatus().text} - - - )} - + + {/* Centered title - absolute positioned over full header */} + {!shouldLeftJustify && ( + + {titleContent} + + )} {realtimeStatus !== 'disconnected' && ( diff --git a/sources/components/autocomplete/findActiveWord.test.ts b/sources/components/autocomplete/findActiveWord.test.ts index 554eb22b..2c50366b 100644 --- a/sources/components/autocomplete/findActiveWord.test.ts +++ b/sources/components/autocomplete/findActiveWord.test.ts @@ -7,35 +7,35 @@ describe('findActiveWord', () => { const content = 'Hello @john'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@john', offset: 6, length: 5 }); + expect(result).toEqual({ word: '@john', activeWord: '@john', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); it('should detect : emoji at cursor', () => { const content = 'I feel :happy'; const selection = { start: 13, end: 13 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: ':happy', offset: 7, length: 6 }); + expect(result).toEqual({ word: ':happy', activeWord: ':happy', offset: 7, length: 6, activeLength: 6, endOffset: 13 }); }); it('should detect / command at cursor', () => { const content = 'Type /help for info'; const selection = { start: 10, end: 10 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '/help', offset: 5, length: 5 }); + expect(result).toEqual({ word: '/help', activeWord: '/help', offset: 5, length: 5, activeLength: 5, endOffset: 10 }); }); it('should detect # tag at cursor', () => { const content = 'This is #important'; const selection = { start: 18, end: 18 }; const result = findActiveWord(content, selection, ['@', ':', '/', '#']); - expect(result).toEqual({ word: '#important', offset: 8, length: 10 }); + expect(result).toEqual({ word: '#important', activeWord: '#important', offset: 8, length: 10, activeLength: 10, endOffset: 18 }); }); it('should return just the prefix when typed alone', () => { const content = 'Hello @'; const selection = { start: 7, end: 7 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@', offset: 6, length: 1 }); + expect(result).toEqual({ word: '@', activeWord: '@', offset: 6, length: 1, activeLength: 1, endOffset: 7 }); }); }); @@ -51,21 +51,21 @@ describe('findActiveWord', () => { const content = 'Hello @user'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 6, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); it('should detect prefix at start of line', () => { const content = '@user hello'; const selection = { start: 5, end: 5 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 0, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 0, length: 5, activeLength: 5, endOffset: 5 }); }); it('should detect prefix after newline', () => { const content = 'Hello\n@user'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 6, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); }); @@ -74,49 +74,49 @@ describe('findActiveWord', () => { const content = 'Hello\n@user'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 6, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); it('should stop at comma', () => { const content = 'Hi, @user'; const selection = { start: 9, end: 9 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 4, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 4, length: 5, activeLength: 5, endOffset: 9 }); }); it('should stop at parentheses', () => { const content = '(@user)'; const selection = { start: 6, end: 6 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 1, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 1, length: 5, activeLength: 5, endOffset: 6 }); }); it('should stop at brackets', () => { const content = '[@user]'; const selection = { start: 6, end: 6 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 1, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 1, length: 5, activeLength: 5, endOffset: 6 }); }); it('should stop at braces', () => { const content = '{@user}'; const selection = { start: 6, end: 6 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 1, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 1, length: 5, activeLength: 5, endOffset: 6 }); }); it('should stop at angle brackets', () => { const content = '<@user>'; const selection = { start: 6, end: 6 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 1, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 1, length: 5, activeLength: 5, endOffset: 6 }); }); it('should stop at semicolon', () => { const content = 'text;@user'; const selection = { start: 10, end: 10 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 5, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 5, length: 5, activeLength: 5, endOffset: 10 }); }); }); @@ -125,21 +125,21 @@ describe('findActiveWord', () => { const content = 'Hello @user'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 6, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); it('should stop at multiple spaces', () => { const content = 'Hello @user'; const selection = { start: 12, end: 12 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 7, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 7, length: 5, activeLength: 5, endOffset: 12 }); }); it('should handle spaces within active word search', () => { const content = 'text @user name'; const selection = { start: 10, end: 10 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 5, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 5, length: 5, activeLength: 5, endOffset: 10 }); }); }); @@ -185,26 +185,26 @@ describe('findActiveWord', () => { const content = 'Hello $user'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection, ['$']); - expect(result).toEqual({ word: '$user', offset: 6, length: 5 }); + expect(result).toEqual({ word: '$user', activeWord: '$user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); it('should work with multiple custom prefixes', () => { const content1 = 'Hello $user'; const selection1 = { start: 11, end: 11 }; const result1 = findActiveWord(content1, selection1, ['$', '%']); - expect(result1).toEqual({ word: '$user', offset: 6, length: 5 }); + expect(result1).toEqual({ word: '$user', activeWord: '$user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); const content2 = 'Hello %task'; const selection2 = { start: 11, end: 11 }; const result2 = findActiveWord(content2, selection2, ['$', '%']); - expect(result2).toEqual({ word: '%task', offset: 6, length: 5 }); + expect(result2).toEqual({ word: '%task', activeWord: '%task', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); it('should use default prefixes when none provided', () => { const content = 'Hello @user'; const selection = { start: 11, end: 11 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@user', offset: 6, length: 5 }); + expect(result).toEqual({ word: '@user', activeWord: '@user', offset: 6, length: 5, activeLength: 5, endOffset: 11 }); }); }); @@ -283,29 +283,29 @@ describe('findActiveWord', () => { const content = 'Hey @john, use :smile: and /help'; const selection1 = { start: 9, end: 9 }; const result1 = findActiveWord(content, selection1); - expect(result1).toEqual({ word: '@john', offset: 4, length: 5 }); + expect(result1).toEqual({ word: '@john', activeWord: '@john', offset: 4, length: 5, activeLength: 5, endOffset: 9 }); const selection2 = { start: 22, end: 22 }; const result2 = findActiveWord(content, selection2); - expect(result2).toEqual({ word: ':smile:', offset: 15, length: 7 }); + expect(result2).toEqual({ word: ':smile:', activeWord: ':smile:', offset: 15, length: 7, activeLength: 7, endOffset: 22 }); const selection3 = { start: 32, end: 32 }; const result3 = findActiveWord(content, selection3); - expect(result3).toEqual({ word: '/help', offset: 27, length: 5 }); + expect(result3).toEqual({ word: '/help', activeWord: '/help', offset: 27, length: 5, activeLength: 5, endOffset: 32 }); }); it('should handle prefix at end of text', () => { const content = 'Hello @'; const selection = { start: 7, end: 7 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@', offset: 6, length: 1 }); + expect(result).toEqual({ word: '@', activeWord: '@', offset: 6, length: 1, activeLength: 1, endOffset: 7 }); }); it('should handle long active words', () => { const content = 'Hello @very_long_username_here'; const selection = { start: 30, end: 30 }; const result = findActiveWord(content, selection); - expect(result).toEqual({ word: '@very_long_username_here', offset: 6, length: 24 }); + expect(result).toEqual({ word: '@very_long_username_here', activeWord: '@very_long_username_here', offset: 6, length: 24, activeLength: 24, endOffset: 30 }); }); it('should handle cursor positions within active word', () => { diff --git a/sources/components/tools/views/AskUserQuestionView.tsx b/sources/components/tools/views/AskUserQuestionView.tsx index a4531cd9..2c220ef9 100644 --- a/sources/components/tools/views/AskUserQuestionView.tsx +++ b/sources/components/tools/views/AskUserQuestionView.tsx @@ -24,6 +24,147 @@ interface AskUserQuestionInput { questions: Question[]; } +// Styles MUST be defined outside the component to prevent infinite re-renders +// with react-native-unistyles. The theme is passed as a function parameter. +const styles = StyleSheet.create((theme) => ({ + container: { + gap: 16, + }, + questionSection: { + gap: 8, + }, + headerChip: { + alignSelf: 'flex-start', + backgroundColor: theme.colors.surfaceHighest, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + marginBottom: 4, + }, + headerText: { + fontSize: 12, + fontWeight: '600', + color: theme.colors.textSecondary, + textTransform: 'uppercase', + }, + questionText: { + fontSize: 15, + fontWeight: '500', + color: theme.colors.text, + marginBottom: 8, + }, + optionsContainer: { + gap: 4, + }, + optionButton: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 12, + paddingHorizontal: 12, + borderRadius: 8, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: theme.colors.divider, + gap: 10, + minHeight: 44, // Minimum touch target for mobile + }, + optionButtonSelected: { + backgroundColor: theme.colors.surfaceHigh, + borderColor: theme.colors.radio.active, + }, + optionButtonDisabled: { + opacity: 0.6, + }, + radioOuter: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: theme.colors.textSecondary, + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + radioOuterSelected: { + borderColor: theme.colors.radio.active, + }, + radioInner: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: theme.colors.radio.dot, + }, + checkboxOuter: { + width: 20, + height: 20, + borderRadius: 4, + borderWidth: 2, + borderColor: theme.colors.textSecondary, + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + checkboxOuterSelected: { + borderColor: theme.colors.radio.active, + backgroundColor: theme.colors.radio.active, + }, + optionContent: { + flex: 1, + }, + optionLabel: { + fontSize: 14, + fontWeight: '500', + color: theme.colors.text, + }, + optionDescription: { + fontSize: 13, + color: theme.colors.textSecondary, + marginTop: 2, + }, + actionsContainer: { + flexDirection: 'row', + gap: 12, + marginTop: 8, + justifyContent: 'flex-end', + }, + submitButton: { + backgroundColor: theme.colors.button.primary.background, + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 44, // Minimum touch target for mobile + }, + submitButtonDisabled: { + opacity: 0.5, + }, + submitButtonText: { + color: theme.colors.button.primary.tint, + fontSize: 14, + fontWeight: '600', + }, + submittedContainer: { + gap: 8, + }, + submittedItem: { + flexDirection: 'row', + gap: 8, + }, + submittedHeader: { + fontSize: 13, + fontWeight: '600', + color: theme.colors.textSecondary, + }, + submittedValue: { + fontSize: 13, + color: theme.colors.text, + flex: 1, + }, +})); + export const AskUserQuestionView = React.memo(({ tool, sessionId }) => { const { theme } = useUnistyles(); const [selections, setSelections] = React.useState>>(new Map()); diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts new file mode 100644 index 00000000..32540465 --- /dev/null +++ b/sources/hooks/envVarUtils.ts @@ -0,0 +1,88 @@ +/** + * Pure utility functions for environment variable handling + * These functions are extracted to enable testing without React dependencies + */ + +interface EnvironmentVariables { + [varName: string]: string | null; +} + +/** + * Resolves ${VAR} substitution in a profile environment variable value. + * + * Profiles use ${VAR} syntax to reference daemon environment variables. + * This function resolves those references to actual values, including + * bash parameter expansion with default values. + * + * @param value - Raw value from profile (e.g., "${Z_AI_MODEL}" or "literal-value") + * @param daemonEnv - Actual environment variables fetched from daemon + * @returns Resolved value (string), null if substitution variable not set, or original value if not a substitution + * + * @example + * // Substitution found and resolved + * resolveEnvVarSubstitution('${Z_AI_MODEL}', { Z_AI_MODEL: 'GLM-4.6' }) // 'GLM-4.6' + * + * // Substitution with default, variable not set + * resolveEnvVarSubstitution('${MISSING:-fallback}', {}) // 'fallback' + * + * // Not a substitution (literal value) + * resolveEnvVarSubstitution('https://api.example.com', {}) // 'https://api.example.com' + */ +export function resolveEnvVarSubstitution( + value: string, + daemonEnv: EnvironmentVariables +): string | null { + // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // 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_]*)(:-(.*))?(:=(.*))?}$/); + if (match) { + const varName = match[1]; + const defaultValue = match[3] ?? match[5]; // :- default or := default + + const daemonValue = daemonEnv[varName]; + if (daemonValue !== undefined && daemonValue !== null) { + return daemonValue; + } + // Variable not set - use default if provided + if (defaultValue !== undefined) { + return defaultValue; + } + return null; + } + // Not a substitution - return literal value + return value; +} + +/** + * Extracts all ${VAR} references from a profile's environment variables array. + * Used to determine which daemon environment variables need to be queried. + * + * @param environmentVariables - Profile's environmentVariables array from AIBackendProfile + * @returns Array of unique variable names that are referenced (e.g., ['Z_AI_MODEL', 'Z_AI_BASE_URL']) + * + * @example + * extractEnvVarReferences([ + * { name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL}' }, + * { name: 'ANTHROPIC_MODEL', value: '${Z_AI_MODEL}' }, + * { name: 'API_TIMEOUT_MS', value: '600000' } // Literal, not extracted + * ]) // Returns: ['Z_AI_BASE_URL', 'Z_AI_MODEL'] + */ +export function extractEnvVarReferences( + environmentVariables: { name: string; value: string }[] | undefined +): string[] { + if (!environmentVariables) return []; + + const refs = new Set(); + environmentVariables.forEach(ev => { + // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Only capture the variable name, not the default value + 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]); + } + }); + return Array.from(refs); +} diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts new file mode 100644 index 00000000..bda5c547 --- /dev/null +++ b/sources/hooks/useCLIDetection.ts @@ -0,0 +1,128 @@ +import { useState, useEffect } from 'react'; +import { machineBash } from '@/sync/ops'; + +interface CLIAvailability { + claude: boolean | null; // null = unknown/loading, true = installed, false = not installed + codex: boolean | null; + gemini: boolean | null; + isDetecting: boolean; // Explicit loading state + timestamp: number; // When detection completed + error?: string; // Detection error message (for debugging) +} + +/** + * Detects which CLI tools (claude, codex, gemini) are installed on a remote machine. + * + * NON-BLOCKING: Detection runs asynchronously in useEffect. UI shows all profiles + * while detection is in progress, then updates when results arrive. + * + * Detection is automatic when machineId changes. Uses existing machineBash() RPC + * to run `command -v` checks on the remote machine. + * + * CONSERVATIVE FALLBACK: If detection fails (network error, timeout, bash error), + * sets all CLIs to null and timestamp to 0, hiding status from UI. + * User discovers CLI availability when attempting to spawn. + * + * @param machineId - The machine to detect CLIs on (null = no detection) + * @returns CLI availability status for claude, codex, and gemini + * + * @example + * const cliAvailability = useCLIDetection(selectedMachineId); + * if (cliAvailability.claude === false) { + * // Show "Claude CLI not detected" warning + * } + */ +export function useCLIDetection(machineId: string | null): CLIAvailability { + const [availability, setAvailability] = useState({ + claude: null, + codex: null, + gemini: null, + isDetecting: false, + timestamp: 0, + }); + + useEffect(() => { + if (!machineId) { + setAvailability({ claude: null, codex: null, gemini: null, isDetecting: false, timestamp: 0 }); + return; + } + + let cancelled = false; + + const detectCLIs = async () => { + // Set detecting flag (non-blocking - UI stays responsive) + setAvailability(prev => ({ ...prev, isDetecting: true })); + console.log('[useCLIDetection] Starting detection for machineId:', machineId); + + try { + // Use single bash command to check both CLIs efficiently + // command -v is POSIX compliant and more reliable than which + const result = await machineBash( + machineId, + '(command -v claude >/dev/null 2>&1 && echo "claude:true" || echo "claude:false") && ' + + '(command -v codex >/dev/null 2>&1 && echo "codex:true" || echo "codex:false") && ' + + '(command -v gemini >/dev/null 2>&1 && echo "gemini:true" || echo "gemini:false")', + '/' + ); + + if (cancelled) return; + 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" + const lines = result.stdout.trim().split('\n'); + const cliStatus: { claude?: boolean; codex?: boolean; gemini?: boolean } = {}; + + lines.forEach(line => { + const [cli, status] = line.split(':'); + if (cli && status) { + cliStatus[cli.trim() as 'claude' | 'codex' | 'gemini'] = status.trim() === 'true'; + } + }); + + console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + setAvailability({ + claude: cliStatus.claude ?? null, + codex: cliStatus.codex ?? null, + gemini: cliStatus.gemini ?? null, + isDetecting: false, + timestamp: Date.now(), + }); + } else { + // Detection command failed - CONSERVATIVE fallback (don't assume availability) + console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); + setAvailability({ + claude: null, + codex: null, + gemini: null, + isDetecting: false, + timestamp: 0, + error: `Detection failed: ${result.stderr || 'Unknown error'}`, + }); + } + } catch (error) { + if (cancelled) return; + + // Network/RPC error - CONSERVATIVE fallback (don't assume availability) + console.log('[useCLIDetection] Network/RPC error:', error); + setAvailability({ + claude: null, + codex: null, + gemini: null, + isDetecting: false, + timestamp: 0, + error: error instanceof Error ? error.message : 'Detection error', + }); + } + }; + + detectCLIs(); + + // Cleanup: Cancel detection if component unmounts or machineId changes + return () => { + cancelled = true; + }; + }, [machineId]); + + return availability; +} diff --git a/sources/hooks/useEnvironmentVariables.test.ts b/sources/hooks/useEnvironmentVariables.test.ts new file mode 100644 index 00000000..e1bae6d2 --- /dev/null +++ b/sources/hooks/useEnvironmentVariables.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { extractEnvVarReferences, resolveEnvVarSubstitution } from './envVarUtils'; + +describe('extractEnvVarReferences', () => { + it('extracts simple ${VAR} references', () => { + const envVars = [{ name: 'TOKEN', value: '${API_KEY}' }]; + expect(extractEnvVarReferences(envVars)).toEqual(['API_KEY']); + }); + + it('extracts ${VAR:-default} references (bash parameter expansion)', () => { + const envVars = [{ name: 'URL', value: '${BASE_URL:-https://api.example.com}' }]; + expect(extractEnvVarReferences(envVars)).toEqual(['BASE_URL']); + }); + + it('extracts ${VAR:=default} references (bash assignment)', () => { + const envVars = [{ name: 'MODEL', value: '${MODEL:=gpt-4}' }]; + expect(extractEnvVarReferences(envVars)).toEqual(['MODEL']); + }); + + it('ignores literal values without substitution', () => { + const envVars = [{ name: 'TIMEOUT', value: '30000' }]; + expect(extractEnvVarReferences(envVars)).toEqual([]); + }); + + it('handles mixed literal and substitution values', () => { + const envVars = [ + { name: 'TIMEOUT', value: '30000' }, + { name: 'TOKEN', value: '${API_KEY}' }, + { name: 'URL', value: 'https://example.com' }, + ]; + expect(extractEnvVarReferences(envVars)).toEqual(['API_KEY']); + }); + + it('handles DeepSeek profile pattern', () => { + const envVars = [ + { name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}' }, + { name: 'ANTHROPIC_AUTH_TOKEN', value: '${DEEPSEEK_AUTH_TOKEN}' }, + ]; + expect(extractEnvVarReferences(envVars).sort()).toEqual(['DEEPSEEK_AUTH_TOKEN', 'DEEPSEEK_BASE_URL']); + }); + + it('handles Z.AI profile pattern', () => { + const envVars = [ + { name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL:-https://ai.zingdata.com/anthropic}' }, + { name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' }, + { name: 'ANTHROPIC_MODEL', value: '${Z_AI_MODEL:-Claude4}' }, + ]; + expect(extractEnvVarReferences(envVars).sort()).toEqual(['Z_AI_AUTH_TOKEN', 'Z_AI_BASE_URL', 'Z_AI_MODEL']); + }); + + it('returns empty array for undefined input', () => { + expect(extractEnvVarReferences(undefined)).toEqual([]); + }); + + it('returns empty array for empty input', () => { + expect(extractEnvVarReferences([])).toEqual([]); + }); + + it('deduplicates repeated variable references', () => { + const envVars = [ + { name: 'TOKEN1', value: '${API_KEY}' }, + { name: 'TOKEN2', value: '${API_KEY}' }, + ]; + expect(extractEnvVarReferences(envVars)).toEqual(['API_KEY']); + }); +}); + +describe('resolveEnvVarSubstitution', () => { + const daemonEnv = { API_KEY: 'sk-123', BASE_URL: 'https://custom.api.com', EMPTY: '' }; + + it('resolves simple ${VAR} when present', () => { + expect(resolveEnvVarSubstitution('${API_KEY}', daemonEnv)).toBe('sk-123'); + }); + + it('returns null for missing simple ${VAR}', () => { + expect(resolveEnvVarSubstitution('${MISSING}', daemonEnv)).toBeNull(); + }); + + it('resolves ${VAR:-default} when VAR present', () => { + expect(resolveEnvVarSubstitution('${BASE_URL:-https://default.com}', daemonEnv)).toBe('https://custom.api.com'); + }); + + it('returns default when VAR missing in ${VAR:-default}', () => { + expect(resolveEnvVarSubstitution('${MISSING:-fallback}', daemonEnv)).toBe('fallback'); + }); + + it('returns default when VAR is null in ${VAR:-default}', () => { + const envWithNull = { VAR: null as unknown as string }; + expect(resolveEnvVarSubstitution('${VAR:-fallback}', envWithNull)).toBe('fallback'); + }); + + it('returns literal for non-substitution values', () => { + expect(resolveEnvVarSubstitution('literal-value', daemonEnv)).toBe('literal-value'); + }); + + it('returns literal URL for non-substitution', () => { + expect(resolveEnvVarSubstitution('https://api.example.com', daemonEnv)).toBe('https://api.example.com'); + }); + + it('handles ${VAR:=default} syntax', () => { + expect(resolveEnvVarSubstitution('${MISSING:=assignment}', daemonEnv)).toBe('assignment'); + }); + + it('resolves DeepSeek default URL pattern', () => { + expect(resolveEnvVarSubstitution('${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}', {})) + .toBe('https://api.deepseek.com/anthropic'); + }); + + it('resolves actual value over default when present', () => { + const env = { DEEPSEEK_BASE_URL: 'https://custom.deepseek.com' }; + expect(resolveEnvVarSubstitution('${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}', env)) + .toBe('https://custom.deepseek.com'); + }); + + it('handles complex default values with special characters', () => { + expect(resolveEnvVarSubstitution('${URL:-https://api.example.com/v1?key=value&foo=bar}', {})) + .toBe('https://api.example.com/v1?key=value&foo=bar'); + }); +}); diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts new file mode 100644 index 00000000..568bb058 --- /dev/null +++ b/sources/hooks/useEnvironmentVariables.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useMemo } from 'react'; +import { machineBash } from '@/sync/ops'; + +// Re-export pure utility functions from envVarUtils for backwards compatibility +export { resolveEnvVarSubstitution, extractEnvVarReferences } from './envVarUtils'; + +interface EnvironmentVariables { + [varName: string]: string | null; // null = variable not set in daemon environment +} + +interface UseEnvironmentVariablesResult { + variables: EnvironmentVariables; + isLoading: boolean; +} + +/** + * Queries environment variable values from the daemon's process environment. + * + * IMPORTANT: This queries the daemon's ACTUAL environment (where CLI runs), + * NOT a new shell session. This ensures ${VAR} substitutions in profiles + * resolve to the values the daemon was launched with. + * + * Performance: Batches multiple variables into a single machineBash() call + * to minimize network round-trips. + * + * @param machineId - Machine to query (null = skip query, return empty result) + * @param varNames - Array of variable names to fetch (e.g., ['Z_AI_MODEL', 'DEEPSEEK_BASE_URL']) + * @returns Environment variable values and loading state + * + * @example + * const { variables, isLoading } = useEnvironmentVariables( + * machineId, + * ['Z_AI_MODEL', 'Z_AI_BASE_URL'] + * ); + * const model = variables['Z_AI_MODEL']; // 'GLM-4.6' or null if not set + */ +export function useEnvironmentVariables( + machineId: string | null, + varNames: string[] +): UseEnvironmentVariablesResult { + const [variables, setVariables] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + // Memoize sorted var names for stable dependency (avoid unnecessary re-queries) + const sortedVarNames = useMemo(() => [...varNames].sort().join(','), [varNames]); + + useEffect(() => { + // Early exit conditions + if (!machineId || varNames.length === 0) { + setVariables({}); + setIsLoading(false); + return; + } + + let cancelled = false; + setIsLoading(true); + + const fetchVars = async () => { + const results: EnvironmentVariables = {}; + + // SECURITY: Validate all variable names to prevent bash injection + // Only accept valid environment variable names: [A-Z_][A-Z0-9_]* + const validVarNames = varNames.filter(name => /^[A-Z_][A-Z0-9_]*$/.test(name)); + + if (validVarNames.length === 0) { + // No valid variables to query + setVariables({}); + setIsLoading(false); + return; + } + + // Build batched command: query all variables in single bash invocation + // Format: echo "VAR1=$VAR1" && echo "VAR2=$VAR2" && ... + // Using echo with variable expansion ensures we get daemon's environment + const command = validVarNames + .map(name => `echo "${name}=$${name}"`) + .join(' && '); + + try { + const result = await machineBash(machineId, command, '/'); + + if (cancelled) return; + + if (result.success && result.exitCode === 0) { + // Parse output: "VAR1=value1\nVAR2=value2\nVAR3=" + const lines = result.stdout.trim().split('\n'); + lines.forEach(line => { + const equalsIndex = line.indexOf('='); + if (equalsIndex !== -1) { + const name = line.substring(0, equalsIndex); + const value = line.substring(equalsIndex + 1); + results[name] = value || null; // Empty string → null (not set) + } + }); + + // Ensure all requested variables have entries (even if missing from output) + validVarNames.forEach(name => { + if (!(name in results)) { + results[name] = null; + } + }); + } else { + // Bash command failed - mark all variables as not set + validVarNames.forEach(name => { + results[name] = null; + }); + } + } catch (err) { + if (cancelled) return; + + // RPC error (network, encryption, etc.) - mark all as not set + validVarNames.forEach(name => { + results[name] = null; + }); + } + + if (!cancelled) { + setVariables(results); + setIsLoading(false); + } + }; + + fetchVars(); + + // Cleanup: prevent state updates after unmount + return () => { + cancelled = true; + }; + }, [machineId, sortedVarNames]); + + return { variables, isLoading }; +} diff --git a/sources/modal/ModalManager.ts b/sources/modal/ModalManager.ts index 8461c70f..1e0cf0aa 100644 --- a/sources/modal/ModalManager.ts +++ b/sources/modal/ModalManager.ts @@ -1,8 +1,8 @@ import { Platform, Alert } from 'react-native'; import { t } from '@/text'; -import { AlertButton, ModalConfig, CustomModalConfig } from './types'; +import { AlertButton, ModalConfig, CustomModalConfig, IModal } from './types'; -class ModalManagerClass { +class ModalManagerClass implements IModal { private showModalFn: ((config: Omit) => string) | null = null; private hideModalFn: ((id: string) => void) | null = null; private hideAllModalsFn: (() => void) | null = null; diff --git a/sources/modal/types.ts b/sources/modal/types.ts index b53878cb..c9cfdc64 100644 --- a/sources/modal/types.ts +++ b/sources/modal/types.ts @@ -57,4 +57,23 @@ export interface ModalContextValue { showModal: (config: Omit) => string; hideModal: (id: string) => void; hideAllModals: () => void; +} + +export interface IModal { + alert(title: string, message?: string, buttons?: AlertButton[]): void; + confirm(title: string, message?: string, options?: { + cancelText?: string; + confirmText?: string; + destructive?: boolean; + }): Promise; + prompt(title: string, message?: string, options?: { + placeholder?: string; + defaultValue?: string; + cancelText?: string; + confirmText?: string; + inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric'; + }): Promise; + show(config: Omit): string; + hide(id: string): void; + hideAll(): void; } \ No newline at end of file diff --git a/sources/realtime/RealtimeVoiceSession.tsx b/sources/realtime/RealtimeVoiceSession.tsx index edcc1b53..da558e1e 100644 --- a/sources/realtime/RealtimeVoiceSession.tsx +++ b/sources/realtime/RealtimeVoiceSession.tsx @@ -107,8 +107,12 @@ export const RealtimeVoiceSession: React.FC = () => { console.log('Realtime message:', data); }, onError: (error) => { - console.error('Realtime error:', error); - storage.getState().setRealtimeStatus('error'); + // Log but don't block app - voice features will be unavailable + // This prevents initialization errors from showing "Terminals error" on startup + console.warn('Realtime voice not available:', error); + // Don't set error status during initialization - just set disconnected + // This allows the app to continue working without voice features + storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { diff --git a/sources/realtime/RealtimeVoiceSession.web.tsx b/sources/realtime/RealtimeVoiceSession.web.tsx index 216d494a..54edb467 100644 --- a/sources/realtime/RealtimeVoiceSession.web.tsx +++ b/sources/realtime/RealtimeVoiceSession.web.tsx @@ -112,8 +112,12 @@ export const RealtimeVoiceSession: React.FC = () => { console.log('Realtime message:', data); }, onError: (error) => { - console.error('Realtime error:', error); - storage.getState().setRealtimeStatus('error'); + // Log but don't block app - voice features will be unavailable + // This prevents initialization errors from showing "Terminals error" on startup + console.warn('Realtime voice not available:', error); + // Don't set error status during initialization - just set disconnected + // This allows the app to continue working without voice features + storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { diff --git a/sources/scripts/compareTranslations.ts b/sources/scripts/compareTranslations.ts new file mode 100644 index 00000000..6e740716 --- /dev/null +++ b/sources/scripts/compareTranslations.ts @@ -0,0 +1,217 @@ +#!/usr/bin/env tsx + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Import all translation files +import { en } from '../text/translations/en'; +import { ru } from '../text/translations/ru'; +import { pl } from '../text/translations/pl'; +import { es } from '../text/translations/es'; +import { pt } from '../text/translations/pt'; +import { ca } from '../text/translations/ca'; +import { zhHans } from '../text/translations/zh-Hans'; + +const translations = { + en, + ru, + pl, + es, + pt, + ca, + 'zh-Hans': zhHans, +}; + +const languageNames: Record = { + en: 'English', + ru: 'Russian', + pl: 'Polish', + es: 'Spanish', + pt: 'Portuguese', + ca: 'Catalan', + 'zh-Hans': 'Chinese (Simplified)', +}; + +// Function to recursively extract all keys from an object +function extractKeys(obj: any, prefix = ''): Set { + const keys = new Set(); + + for (const key in obj) { + const fullKey = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + + if (typeof value === 'function') { + keys.add(fullKey); + } else if (typeof value === 'string') { + keys.add(fullKey); + } else if (typeof value === 'object' && value !== null) { + const subKeys = extractKeys(value, fullKey); + subKeys.forEach(k => keys.add(k)); + } + } + + return keys; +} + +// Function to check if a value is still in English (for non-English translations) +function checkIfEnglish(path: string, value: any, englishValue: any, lang: string): boolean { + if (lang === 'en') return false; + + // For functions, we can't easily compare + if (typeof value === 'function' && typeof englishValue === 'function') { + return false; // Skip function comparison + } + + // For strings, check if they're identical to English + if (typeof value === 'string' && typeof englishValue === 'string') { + // Some technical terms should remain in English + const technicalTerms = ['GitHub', 'URL', 'API', 'CLI', 'OAuth', 'QR', 'JSON', 'HTTP', 'HTTPS', 'ID', 'PID']; + for (const term of technicalTerms) { + if (value === term || englishValue === term) { + return false; // It's ok for technical terms to be the same + } + } + + // Check if the non-English translation is identical to English + return value === englishValue && value.length > 3; // Ignore short strings like "OK" + } + + return false; +} + +// Function to get nested value +function getNestedValue(obj: any, path: string): any { + const keys = path.split('.'); + let current = obj; + + for (const key of keys) { + if (current && typeof current === 'object' && key in current) { + current = current[key]; + } else { + return undefined; + } + } + + return current; +} + +console.log('# Translation Completeness Report\n'); +console.log('## Summary of Languages\n'); + +// Get all keys from English (reference) +const englishKeys = extractKeys(translations.en); +console.log(`**English (reference)**: ${englishKeys.size} keys\n`); + +// Track all issues +const missingKeys: Record = {}; +const untranslatedStrings: Record = {}; + +// Compare each language with English +for (const [langCode, translation] of Object.entries(translations)) { + if (langCode === 'en') continue; + + const langKeys = extractKeys(translation); + const missing: string[] = []; + const untranslated: string[] = []; + + // Find missing keys + for (const key of englishKeys) { + if (!langKeys.has(key)) { + missing.push(key); + } else { + // Check if the value is still in English + const value = getNestedValue(translation, key); + const englishValue = getNestedValue(translations.en, key); + if (checkIfEnglish(key, value, englishValue, langCode)) { + untranslated.push(`${key}: "${value}"`); + } + } + } + + // Find extra keys (that don't exist in English) + const extra: string[] = []; + for (const key of langKeys) { + if (!englishKeys.has(key)) { + extra.push(key); + } + } + + if (missing.length > 0) { + missingKeys[langCode] = missing; + } + if (untranslated.length > 0) { + untranslatedStrings[langCode] = untranslated; + } + + console.log(`**${languageNames[langCode]}** (${langCode}): ${langKeys.size} keys`); + if (missing.length > 0) { + console.log(` - ❌ Missing: ${missing.length} keys`); + } + if (untranslated.length > 0) { + console.log(` - ⚠️ Untranslated: ${untranslated.length} strings`); + } + if (extra.length > 0) { + console.log(` - ➕ Extra: ${extra.length} keys`); + } + if (missing.length === 0 && untranslated.length === 0 && extra.length === 0) { + console.log(` - ✅ Complete and consistent`); + } + console.log(''); +} + +// Detailed report of issues +if (Object.keys(missingKeys).length > 0 || Object.keys(untranslatedStrings).length > 0) { + console.log('\n## Detailed Issues\n'); + + // Report missing keys + if (Object.keys(missingKeys).length > 0) { + console.log('### Missing Translation Keys\n'); + for (const [langCode, missing] of Object.entries(missingKeys)) { + console.log(`#### ${languageNames[langCode]} (${langCode})\n`); + console.log('Missing the following keys:'); + for (const key of missing) { + const englishValue = getNestedValue(translations.en, key); + if (typeof englishValue === 'function') { + console.log(`- \`${key}\` (function)`); + } else { + console.log(`- \`${key}\`: "${englishValue}"`); + } + } + console.log(''); + } + } + + // Report untranslated strings + if (Object.keys(untranslatedStrings).length > 0) { + console.log('### Untranslated Strings (Still in English)\n'); + for (const [langCode, untranslated] of Object.entries(untranslatedStrings)) { + console.log(`#### ${languageNames[langCode]} (${langCode})\n`); + console.log('The following strings appear to be untranslated:'); + for (const item of untranslated) { + console.log(`- ${item}`); + } + console.log(''); + } + } +} else { + console.log('\n## ✅ All Translations Complete!\n'); + console.log('All language files have complete translations with no missing keys or untranslated strings.'); +} + +// Sample a few translations to verify content +console.log('\n## Sample Translation Verification\n'); +const sampleKeys = ['common.cancel', 'settings.title', 'errors.networkError', 'common.save']; + +for (const key of sampleKeys) { + console.log(`### Key: \`${key}\`\n`); + for (const [langCode, translation] of Object.entries(translations)) { + const value = getNestedValue(translation, key); + console.log(`- **${languageNames[langCode]}**: ${typeof value === 'string' ? `"${value}"` : '(function)'}`); + } + console.log(''); +} \ No newline at end of file diff --git a/sources/sync/apiGithub.spec.ts b/sources/sync/apiGithub.spec.ts index e500abff..4525e751 100644 --- a/sources/sync/apiGithub.spec.ts +++ b/sources/sync/apiGithub.spec.ts @@ -45,8 +45,7 @@ describe('apiGithub', () => { { method: 'DELETE', headers: { - 'Authorization': 'Bearer test-token', - 'Content-Type': 'application/json' + 'Authorization': 'Bearer test-token' } } ); diff --git a/sources/sync/apiSocket.ts b/sources/sync/apiSocket.ts index 544a1a5b..7e64ae58 100644 --- a/sources/sync/apiSocket.ts +++ b/sources/sync/apiSocket.ts @@ -131,17 +131,17 @@ class ApiSocket { /** * RPC call for machines - uses legacy/global encryption (for now) */ - async machineRPC(machineId: string, method: string, params: A): Promise { + async machineRPC(machineId: string, method: string, params: A): Promise { const machineEncryption = this.encryption!.getMachineEncryption(machineId); if (!machineEncryption) { throw new Error(`Machine encryption not found for ${machineId}`); } - + const result = await this.socket!.emitWithAck('rpc-call', { method: `${machineId}:${method}`, params: await machineEncryption.encryptRaw(params) }); - + if (result.ok) { return await machineEncryption.decryptRaw(result.result) as R; } diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index a4efca9e..07f70e69 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -139,6 +139,17 @@ export interface SpawnSessionOptions { approvedNewDirectoryCreation?: boolean; token?: string; agent?: 'codex' | 'claude' | 'gemini'; + // Environment variables from AI backend profile + // Accepts any environment variables - daemon will pass them to the agent process + // Common variables include: + // - ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL + // - 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 + // - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + // - Custom variables (DEEPSEEK_*, Z_AI_*, etc.) + environmentVariables?: Record; } // Exported session operation functions @@ -147,8 +158,8 @@ export interface SpawnSessionOptions { * Spawn a new remote session on a specific machine */ export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise { - - const { machineId, directory, approvedNewDirectoryCreation = false, token, agent } = options; + + const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables } = options; try { const result = await apiSocket.machineRPC; }>( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent } + { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, environmentVariables } ); return result; } catch (error) { diff --git a/sources/sync/profileSync.ts b/sources/sync/profileSync.ts new file mode 100644 index 00000000..694ea141 --- /dev/null +++ b/sources/sync/profileSync.ts @@ -0,0 +1,453 @@ +/** + * Profile Synchronization Service + * + * Handles bidirectional synchronization of profiles between GUI and CLI storage. + * Ensures consistent profile data across both systems with proper conflict resolution. + */ + +import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from './settings'; +import { sync } from './sync'; +import { storage } from './storage'; +import { apiSocket } from './apiSocket'; +import { Modal } from '@/modal'; + +// Profile sync status types +export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; +export type SyncDirection = 'gui-to-cli' | 'cli-to-gui' | 'bidirectional'; + +// Profile sync conflict resolution strategies +export type ConflictResolution = 'gui-wins' | 'cli-wins' | 'most-recent' | 'merge'; + +// Profile sync event data +export interface ProfileSyncEvent { + direction: SyncDirection; + status: SyncStatus; + profilesSynced?: number; + error?: string; + timestamp: number; + message?: string; + warning?: string; +} + +// Profile sync configuration +export interface ProfileSyncConfig { + autoSync: boolean; + conflictResolution: ConflictResolution; + syncOnProfileChange: boolean; + syncOnAppStart: boolean; +} + +// Default sync configuration +const DEFAULT_SYNC_CONFIG: ProfileSyncConfig = { + autoSync: true, + conflictResolution: 'most-recent', + syncOnProfileChange: true, + syncOnAppStart: true, +}; + +class ProfileSyncService { + private static instance: ProfileSyncService; + private syncStatus: SyncStatus = 'idle'; + private lastSyncTime: number = 0; + private config: ProfileSyncConfig = DEFAULT_SYNC_CONFIG; + private eventListeners: Array<(event: ProfileSyncEvent) => void> = []; + + private constructor() { + // Private constructor for singleton + } + + public static getInstance(): ProfileSyncService { + if (!ProfileSyncService.instance) { + ProfileSyncService.instance = new ProfileSyncService(); + } + return ProfileSyncService.instance; + } + + /** + * Add event listener for sync events + */ + public addEventListener(listener: (event: ProfileSyncEvent) => void): void { + this.eventListeners.push(listener); + } + + /** + * Remove event listener + */ + public removeEventListener(listener: (event: ProfileSyncEvent) => void): void { + const index = this.eventListeners.indexOf(listener); + if (index > -1) { + this.eventListeners.splice(index, 1); + } + } + + /** + * Emit sync event to all listeners + */ + private emitEvent(event: ProfileSyncEvent): void { + this.eventListeners.forEach(listener => { + try { + listener(event); + } catch (error) { + console.error('[ProfileSync] Event listener error:', error); + } + }); + } + + /** + * Update sync configuration + */ + public updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Get current sync configuration + */ + public getConfig(): ProfileSyncConfig { + return { ...this.config }; + } + + /** + * Get current sync status + */ + public getSyncStatus(): SyncStatus { + return this.syncStatus; + } + + /** + * Get last sync time + */ + public getLastSyncTime(): number { + return this.lastSyncTime; + } + + /** + * Sync profiles from GUI to CLI using proper Happy infrastructure + * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure + */ + public async syncGuiToCli(profiles: AIBackendProfile[]): Promise { + if (this.syncStatus === 'syncing') { + throw new Error('Sync already in progress'); + } + + this.syncStatus = 'syncing'; + this.emitEvent({ + direction: 'gui-to-cli', + status: 'syncing', + timestamp: Date.now(), + }); + + try { + // Profiles are stored in GUI settings and available through existing Happy sync system + // CLI daemon reads profiles from GUI settings via existing channels + // TODO: Implement machine RPC endpoints for profile management in CLI daemon + console.log(`[ProfileSync] GUI profiles stored in Happy settings. CLI access via existing infrastructure.`); + + this.lastSyncTime = Date.now(); + this.syncStatus = 'success'; + + this.emitEvent({ + direction: 'gui-to-cli', + status: 'success', + profilesSynced: profiles.length, + timestamp: Date.now(), + message: 'Profiles available through Happy settings system' + }); + } catch (error) { + this.syncStatus = 'error'; + const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; + + this.emitEvent({ + direction: 'gui-to-cli', + status: 'error', + error: errorMessage, + timestamp: Date.now(), + }); + + throw error; + } + } + + /** + * Sync profiles from CLI to GUI using proper Happy infrastructure + * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure + */ + public async syncCliToGui(): Promise { + if (this.syncStatus === 'syncing') { + throw new Error('Sync already in progress'); + } + + this.syncStatus = 'syncing'; + this.emitEvent({ + direction: 'cli-to-gui', + status: 'syncing', + timestamp: Date.now(), + }); + + try { + // CLI profiles are accessed through Happy settings system, not direct file access + // Return profiles from current GUI settings + const currentProfiles = storage.getState().settings.profiles || []; + + console.log(`[ProfileSync] Retrieved ${currentProfiles.length} profiles from Happy settings`); + + this.lastSyncTime = Date.now(); + this.syncStatus = 'success'; + + this.emitEvent({ + direction: 'cli-to-gui', + status: 'success', + profilesSynced: currentProfiles.length, + timestamp: Date.now(), + message: 'Profiles retrieved from Happy settings system' + }); + + return currentProfiles; + } catch (error) { + this.syncStatus = 'error'; + const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; + + this.emitEvent({ + direction: 'cli-to-gui', + status: 'error', + error: errorMessage, + timestamp: Date.now(), + }); + + throw error; + } + } + + /** + * Perform bidirectional sync with conflict resolution + */ + public async bidirectionalSync(guiProfiles: AIBackendProfile[]): Promise { + if (this.syncStatus === 'syncing') { + throw new Error('Sync already in progress'); + } + + this.syncStatus = 'syncing'; + this.emitEvent({ + direction: 'bidirectional', + status: 'syncing', + timestamp: Date.now(), + }); + + try { + // Get CLI profiles + const cliProfiles = await this.syncCliToGui(); + + // Resolve conflicts based on configuration + const resolvedProfiles = await this.resolveConflicts(guiProfiles, cliProfiles); + + // Update CLI with resolved profiles + await this.syncGuiToCli(resolvedProfiles); + + this.lastSyncTime = Date.now(); + this.syncStatus = 'success'; + + this.emitEvent({ + direction: 'bidirectional', + status: 'success', + profilesSynced: resolvedProfiles.length, + timestamp: Date.now(), + }); + + return resolvedProfiles; + } catch (error) { + this.syncStatus = 'error'; + const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; + + this.emitEvent({ + direction: 'bidirectional', + status: 'error', + error: errorMessage, + timestamp: Date.now(), + }); + + throw error; + } + } + + /** + * Resolve conflicts between GUI and CLI profiles + */ + private async resolveConflicts( + guiProfiles: AIBackendProfile[], + cliProfiles: AIBackendProfile[] + ): Promise { + const { conflictResolution } = this.config; + const resolvedProfiles: AIBackendProfile[] = []; + const processedIds = new Set(); + + // Process profiles that exist in both GUI and CLI + for (const guiProfile of guiProfiles) { + const cliProfile = cliProfiles.find(p => p.id === guiProfile.id); + + if (cliProfile) { + let resolvedProfile: AIBackendProfile; + + switch (conflictResolution) { + case 'gui-wins': + resolvedProfile = { ...guiProfile, updatedAt: Date.now() }; + break; + case 'cli-wins': + resolvedProfile = { ...cliProfile, updatedAt: Date.now() }; + break; + case 'most-recent': + resolvedProfile = guiProfile.updatedAt! >= cliProfile.updatedAt! + ? { ...guiProfile } + : { ...cliProfile }; + break; + case 'merge': + resolvedProfile = await this.mergeProfiles(guiProfile, cliProfile); + break; + default: + resolvedProfile = { ...guiProfile }; + } + + resolvedProfiles.push(resolvedProfile); + processedIds.add(guiProfile.id); + } else { + // Profile exists only in GUI + resolvedProfiles.push({ ...guiProfile, updatedAt: Date.now() }); + processedIds.add(guiProfile.id); + } + } + + // Add profiles that exist only in CLI + for (const cliProfile of cliProfiles) { + if (!processedIds.has(cliProfile.id)) { + resolvedProfiles.push({ ...cliProfile, updatedAt: Date.now() }); + } + } + + return resolvedProfiles; + } + + /** + * Merge two profiles, preferring non-null values from both + */ + private async mergeProfiles( + guiProfile: AIBackendProfile, + cliProfile: AIBackendProfile + ): Promise { + const merged: AIBackendProfile = { + id: guiProfile.id, + name: guiProfile.name || cliProfile.name, + description: guiProfile.description || cliProfile.description, + anthropicConfig: { ...cliProfile.anthropicConfig, ...guiProfile.anthropicConfig }, + openaiConfig: { ...cliProfile.openaiConfig, ...guiProfile.openaiConfig }, + azureOpenAIConfig: { ...cliProfile.azureOpenAIConfig, ...guiProfile.azureOpenAIConfig }, + togetherAIConfig: { ...cliProfile.togetherAIConfig, ...guiProfile.togetherAIConfig }, + tmuxConfig: { ...cliProfile.tmuxConfig, ...guiProfile.tmuxConfig }, + environmentVariables: this.mergeEnvironmentVariables( + cliProfile.environmentVariables || [], + guiProfile.environmentVariables || [] + ), + compatibility: { ...cliProfile.compatibility, ...guiProfile.compatibility }, + isBuiltIn: guiProfile.isBuiltIn || cliProfile.isBuiltIn, + createdAt: Math.min(guiProfile.createdAt || 0, cliProfile.createdAt || 0), + updatedAt: Math.max(guiProfile.updatedAt || 0, cliProfile.updatedAt || 0), + version: guiProfile.version || cliProfile.version || '1.0.0', + }; + + return merged; + } + + /** + * Merge environment variables from two profiles + */ + private mergeEnvironmentVariables( + cliVars: Array<{ name: string; value: string }>, + guiVars: Array<{ name: string; value: string }> + ): Array<{ name: string; value: string }> { + const mergedVars = new Map(); + + // Add CLI variables first + cliVars.forEach(v => mergedVars.set(v.name, v.value)); + + // Override with GUI variables + guiVars.forEach(v => mergedVars.set(v.name, v.value)); + + return Array.from(mergedVars.entries()).map(([name, value]) => ({ name, value })); + } + + /** + * Set active profile using Happy settings infrastructure + * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system + */ + public async setActiveProfile(profileId: string): Promise { + try { + // Store in GUI settings using Happy's settings system + sync.applySettings({ lastUsedProfile: profileId }); + + console.log(`[ProfileSync] Set active profile ${profileId} in Happy settings`); + + // Note: CLI daemon accesses active profile through Happy settings system + // TODO: Implement machine RPC endpoint for setting active profile in CLI daemon + } catch (error) { + console.error('[ProfileSync] Failed to set active profile:', error); + throw error; + } + } + + /** + * Get active profile using Happy settings infrastructure + * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system + */ + public async getActiveProfile(): Promise { + try { + // Get active profile from Happy settings system + const lastUsedProfileId = storage.getState().settings.lastUsedProfile; + + if (!lastUsedProfileId) { + return null; + } + + const profiles = storage.getState().settings.profiles || []; + const activeProfile = profiles.find((p: AIBackendProfile) => p.id === lastUsedProfileId); + + if (activeProfile) { + console.log(`[ProfileSync] Retrieved active profile ${activeProfile.name} from Happy settings`); + return activeProfile; + } + + return null; + } catch (error) { + console.error('[ProfileSync] Failed to get active profile:', error); + return null; + } + } + + /** + * Auto-sync if enabled and conditions are met + */ + public async autoSyncIfNeeded(guiProfiles: AIBackendProfile[]): Promise { + if (!this.config.autoSync) { + return; + } + + const timeSinceLastSync = Date.now() - this.lastSyncTime; + const AUTO_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes + + if (timeSinceLastSync > AUTO_SYNC_INTERVAL) { + try { + await this.bidirectionalSync(guiProfiles); + } catch (error) { + console.error('[ProfileSync] Auto-sync failed:', error); + // Don't throw for auto-sync failures + } + } + } +} + +// Export singleton instance +export const profileSyncService = ProfileSyncService.getInstance(); + +// Export convenience functions +export const syncGuiToCli = (profiles: AIBackendProfile[]) => profileSyncService.syncGuiToCli(profiles); +export const syncCliToGui = () => profileSyncService.syncCliToGui(); +export const bidirectionalSync = (guiProfiles: AIBackendProfile[]) => profileSyncService.bidirectionalSync(guiProfiles); +export const setActiveProfile = (profileId: string) => profileSyncService.setActiveProfile(profileId); +export const getActiveProfile = () => profileSyncService.getActiveProfile(); \ No newline at end of file diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts new file mode 100644 index 00000000..d90a98a9 --- /dev/null +++ b/sources/sync/profileUtils.ts @@ -0,0 +1,377 @@ +import { AIBackendProfile } from './settings'; + +/** + * Documentation and expected values for built-in profiles. + * These help users understand what environment variables to set and their expected values. + */ +export interface ProfileDocumentation { + setupGuideUrl?: string; // Link to official setup documentation + description: string; // Clear description of what this profile does + environmentVariables: { + name: string; // Environment variable name (e.g., "Z_AI_BASE_URL") + expectedValue: string; // What value it should have (e.g., "https://api.z.ai/api/anthropic") + description: string; // What this variable does + isSecret: boolean; // Whether this is a secret (never retrieve or display actual value) + }[]; + shellConfigExample: string; // Example .zshrc/.bashrc configuration +} + +/** + * Get documentation for a built-in profile. + * Returns setup instructions, expected values, and configuration examples. + */ +export const getBuiltInProfileDocumentation = (id: string): ProfileDocumentation | null => { + switch (id) { + case 'anthropic': + return { + description: 'Official Anthropic Claude API - uses your default Anthropic credentials', + environmentVariables: [], + shellConfigExample: `# No additional environment variables needed +# Uses ANTHROPIC_AUTH_TOKEN from your login session`, + }; + case 'deepseek': + return { + setupGuideUrl: 'https://api-docs.deepseek.com/', + description: 'DeepSeek Reasoner API proxied through Anthropic-compatible interface', + environmentVariables: [ + { + name: 'DEEPSEEK_BASE_URL', + expectedValue: 'https://api.deepseek.com/anthropic', + description: 'DeepSeek API endpoint (Anthropic-compatible)', + isSecret: false, + }, + { + name: 'DEEPSEEK_AUTH_TOKEN', + expectedValue: 'sk-...', + description: 'Your DeepSeek API key', + isSecret: true, + }, + { + name: 'DEEPSEEK_API_TIMEOUT_MS', + expectedValue: '600000', + description: 'API timeout (10 minutes for reasoning models)', + isSecret: false, + }, + { + name: 'DEEPSEEK_MODEL', + expectedValue: 'deepseek-reasoner', + description: 'Default model (reasoning model for complex debugging/algorithms, use deepseek-chat for faster general tasks)', + isSecret: false, + }, + { + name: 'DEEPSEEK_SMALL_FAST_MODEL', + expectedValue: 'deepseek-chat', + description: 'Fast model for quick responses', + isSecret: false, + }, + { + name: 'DEEPSEEK_CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + expectedValue: '1', + description: 'Disable non-essential network traffic', + isSecret: false, + }, + ], + shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: +export DEEPSEEK_BASE_URL="https://api.deepseek.com/anthropic" +export DEEPSEEK_AUTH_TOKEN="sk-YOUR_DEEPSEEK_API_KEY" +export DEEPSEEK_API_TIMEOUT_MS="600000" +export DEEPSEEK_MODEL="deepseek-reasoner" +export DEEPSEEK_SMALL_FAST_MODEL="deepseek-chat" +export DEEPSEEK_CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" + +# Model selection guide: +# - deepseek-reasoner: Best for complex debugging, algorithms, precision (slower but more accurate) +# - deepseek-chat: Best for everyday coding, boilerplate, speed (handles 80% of general tasks)`, + }; + case 'zai': + return { + setupGuideUrl: 'https://docs.z.ai/devpack/tool/claude', + description: 'Z.AI GLM-4.6 API proxied through Anthropic-compatible interface', + environmentVariables: [ + { + name: 'Z_AI_BASE_URL', + expectedValue: 'https://api.z.ai/api/anthropic', + description: 'Z.AI API endpoint (Anthropic-compatible)', + isSecret: false, + }, + { + name: 'Z_AI_AUTH_TOKEN', + expectedValue: 'sk-...', + description: 'Your Z.AI API key', + isSecret: true, + }, + { + name: 'Z_AI_API_TIMEOUT_MS', + expectedValue: '3000000', + description: 'API timeout (50 minutes)', + isSecret: false, + }, + { + name: 'Z_AI_MODEL', + expectedValue: 'GLM-4.6', + description: 'Default model', + isSecret: false, + }, + { + name: 'Z_AI_OPUS_MODEL', + expectedValue: 'GLM-4.6', + description: 'Model for "Opus" tasks (maps to GLM-4.6)', + isSecret: false, + }, + { + name: 'Z_AI_SONNET_MODEL', + expectedValue: 'GLM-4.6', + description: 'Model for "Sonnet" tasks (maps to GLM-4.6)', + isSecret: false, + }, + { + name: 'Z_AI_HAIKU_MODEL', + expectedValue: 'GLM-4.5-Air', + description: 'Model for "Haiku" tasks (maps to GLM-4.5-Air)', + isSecret: false, + }, + ], + shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: +export Z_AI_BASE_URL="https://api.z.ai/api/anthropic" +export Z_AI_AUTH_TOKEN="sk-YOUR_ZAI_API_KEY" +export Z_AI_API_TIMEOUT_MS="3000000" +export Z_AI_MODEL="GLM-4.6" +export Z_AI_OPUS_MODEL="GLM-4.6" +export Z_AI_SONNET_MODEL="GLM-4.6" +export Z_AI_HAIKU_MODEL="GLM-4.5-Air"`, + }; + case 'openai': + return { + setupGuideUrl: 'https://platform.openai.com/docs/api-reference', + description: 'OpenAI GPT-5 Codex API for code generation and completion', + environmentVariables: [ + { + name: 'OPENAI_BASE_URL', + expectedValue: 'https://api.openai.com/v1', + description: 'OpenAI API endpoint', + isSecret: false, + }, + { + name: 'OPENAI_API_KEY', + expectedValue: '', + description: 'Your OpenAI API key', + isSecret: true, + }, + { + name: 'OPENAI_MODEL', + expectedValue: 'gpt-5-codex-high', + description: 'Default model for code tasks', + isSecret: false, + }, + { + name: 'OPENAI_SMALL_FAST_MODEL', + expectedValue: 'gpt-5-codex-low', + description: 'Fast model for quick responses', + isSecret: false, + }, + ], + shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: +export OPENAI_BASE_URL="https://api.openai.com/v1" +export OPENAI_API_KEY="sk-YOUR_OPENAI_API_KEY" +export OPENAI_MODEL="gpt-5-codex-high" +export OPENAI_SMALL_FAST_MODEL="gpt-5-codex-low"`, + }; + case 'azure-openai': + return { + setupGuideUrl: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/', + description: 'Azure OpenAI Service for enterprise-grade AI with enhanced security and compliance', + environmentVariables: [ + { + name: 'AZURE_OPENAI_ENDPOINT', + expectedValue: 'https://YOUR_RESOURCE.openai.azure.com', + description: 'Your Azure OpenAI endpoint URL', + isSecret: false, + }, + { + name: 'AZURE_OPENAI_API_KEY', + expectedValue: '', + description: 'Your Azure OpenAI API key', + isSecret: true, + }, + { + name: 'AZURE_OPENAI_API_VERSION', + expectedValue: '2024-02-15-preview', + description: 'Azure OpenAI API version', + isSecret: false, + }, + { + name: 'AZURE_OPENAI_DEPLOYMENT_NAME', + expectedValue: 'gpt-5-codex', + description: 'Your deployment name for the model', + isSecret: false, + }, + ], + shellConfigExample: `# Add to ~/.zshrc or ~/.bashrc: +export AZURE_OPENAI_ENDPOINT="https://YOUR_RESOURCE.openai.azure.com" +export AZURE_OPENAI_API_KEY="YOUR_AZURE_API_KEY" +export AZURE_OPENAI_API_VERSION="2024-02-15-preview" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5-codex"`, + }; + default: + return null; + } +}; + +/** + * Get a built-in AI backend profile by ID. + * Built-in profiles provide sensible defaults for popular AI providers. + * + * ENVIRONMENT VARIABLE FLOW: + * 1. User launches daemon with env vars: Z_AI_AUTH_TOKEN=sk-... Z_AI_BASE_URL=https://api.z.ai + * 2. Profile defines mappings: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} + * 3. When spawning session, daemon expands ${VAR} from its process.env + * 4. Session receives: ANTHROPIC_AUTH_TOKEN=sk-... (actual value) + * 5. Claude CLI reads ANTHROPIC_* env vars, connects to Z.AI + * + * This pattern lets users: + * - Set credentials ONCE when launching daemon + * - Switch backends by selecting different profiles + * - Each profile maps daemon env vars to what CLI expects + * + * @param id - The profile ID (anthropic, deepseek, zai, openai, azure-openai, together) + * @returns The complete profile configuration, or null if not found + */ +export const getBuiltInProfile = (id: string): AIBackendProfile | null => { + switch (id) { + case 'anthropic': + return { + id: 'anthropic', + name: 'Anthropic (Default)', + anthropicConfig: {}, + environmentVariables: [], + defaultPermissionMode: 'default', + compatibility: { claude: true, codex: false, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + case 'deepseek': + // DeepSeek profile: Maps DEEPSEEK_* daemon environment to ANTHROPIC_* for Claude CLI + // 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) + 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 + { name: 'API_TIMEOUT_MS', value: '${DEEPSEEK_API_TIMEOUT_MS:-600000}' }, + { name: 'ANTHROPIC_MODEL', value: '${DEEPSEEK_MODEL:-deepseek-reasoner}' }, + { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: '${DEEPSEEK_SMALL_FAST_MODEL:-deepseek-chat}' }, + { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '${DEEPSEEK_CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1}' }, + ], + defaultPermissionMode: 'default', + compatibility: { claude: true, codex: false, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + case 'zai': + // Z.AI profile: Maps Z_AI_* daemon environment to ANTHROPIC_* for Claude CLI + // Launch daemon with: Z_AI_AUTH_TOKEN=sk-... Z_AI_BASE_URL=https://api.z.ai/api/anthropic + // 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 + 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 + { name: 'API_TIMEOUT_MS', value: '${Z_AI_API_TIMEOUT_MS:-3000000}' }, + { name: 'ANTHROPIC_MODEL', value: '${Z_AI_MODEL:-GLM-4.6}' }, + { name: 'ANTHROPIC_DEFAULT_OPUS_MODEL', value: '${Z_AI_OPUS_MODEL:-GLM-4.6}' }, + { name: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: '${Z_AI_SONNET_MODEL:-GLM-4.6}' }, + { name: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', value: '${Z_AI_HAIKU_MODEL:-GLM-4.5-Air}' }, + ], + defaultPermissionMode: 'default', + compatibility: { claude: true, codex: false, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + case 'openai': + 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' }, + { name: 'OPENAI_API_TIMEOUT_MS', value: '600000' }, + { name: 'OPENAI_SMALL_FAST_MODEL', value: 'gpt-5-codex-low' }, + { name: 'API_TIMEOUT_MS', value: '600000' }, + { name: 'CODEX_SMALL_FAST_MODEL', value: 'gpt-5-codex-low' }, + ], + compatibility: { claude: false, codex: true, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + case 'azure-openai': + 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' }, + { name: 'OPENAI_API_TIMEOUT_MS', value: '600000' }, + { name: 'API_TIMEOUT_MS', value: '600000' }, + ], + compatibility: { claude: false, codex: true, gemini: false }, + isBuiltIn: true, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + default: + return null; + } +}; + +/** + * Default built-in profiles available to all users. + * These provide quick-start configurations for popular AI providers. + */ +export const DEFAULT_PROFILES = [ + { + id: 'anthropic', + name: 'Anthropic (Default)', + isBuiltIn: true, + }, + { + id: 'deepseek', + name: 'DeepSeek (Reasoner)', + isBuiltIn: true, + }, + { + id: 'zai', + name: 'Z.AI (GLM-4.6)', + isBuiltIn: true, + }, + { + id: 'openai', + name: 'OpenAI (GPT-5)', + isBuiltIn: true, + }, + { + id: 'azure-openai', + name: 'Azure OpenAI', + isBuiltIn: true, + } +]; diff --git a/sources/sync/reducer/reducer.spec.ts b/sources/sync/reducer/reducer.spec.ts index 4895ef3e..96e39161 100644 --- a/sources/sync/reducer/reducer.spec.ts +++ b/sources/sync/reducer/reducer.spec.ts @@ -1636,7 +1636,7 @@ describe('reducer', () => { ]; const userResult = reducer(state, userMsg); - expect(userResult).toHaveLength(1); + expect(userResult.messages).toHaveLength(1); totalMessages++; // Add permission @@ -1651,7 +1651,7 @@ describe('reducer', () => { }; const permResult = reducer(state, [], agentState); - expect(permResult).toHaveLength(1); + expect(permResult.messages).toHaveLength(1); totalMessages++; // Approve permission @@ -1688,7 +1688,7 @@ describe('reducer', () => { ]; const dupResult = reducer(state, duplicateUser); - expect(dupResult).toHaveLength(0); + expect(dupResult.messages).toHaveLength(0); expect(state.messages.size).toBe(totalMessages); // No increase }); diff --git a/sources/sync/reducer/reducer.ts b/sources/sync/reducer/reducer.ts index 542cc4f7..bc99e5ff 100644 --- a/sources/sync/reducer/reducer.ts +++ b/sources/sync/reducer/reducer.ts @@ -123,6 +123,7 @@ type ReducerMessage = { createdAt: number; role: 'user' | 'agent'; text: string | null; + isThinking?: boolean; event: AgentEvent | null; tool: ToolCall | null; meta?: MessageMeta; @@ -627,16 +628,18 @@ export function reducer(state: ReducerState, messages: NormalizedMessage[], agen processUsageData(state, msg.usage, msg.createdAt); } - // Process text content only (tool calls handled in Phase 2) + // Process text and thinking content (tool calls handled in Phase 2) for (let c of msg.content) { - if (c.type === 'text') { + if (c.type === 'text' || c.type === 'thinking') { let mid = allocateId(); + const isThinking = c.type === 'thinking'; state.messages.set(mid, { id: mid, realID: msg.id, role: 'agent', createdAt: msg.createdAt, - text: c.text, + text: isThinking ? `*Thinking...*\n\n*${c.thinking}*` : c.text, + isThinking, tool: null, event: null, meta: msg.meta, @@ -860,14 +863,16 @@ export function reducer(state: ReducerState, messages: NormalizedMessage[], agen } else if (msg.role === 'agent') { // Process agent content in sidechain for (let c of msg.content) { - if (c.type === 'text') { + if (c.type === 'text' || c.type === 'thinking') { let mid = allocateId(); + const isThinking = c.type === 'thinking'; let textMsg: ReducerMessage = { id: mid, realID: msg.id, role: 'agent', createdAt: msg.createdAt, - text: c.text, + text: isThinking ? `*Thinking...*\n\n*${c.thinking}*` : c.text, + isThinking, tool: null, event: null, meta: msg.meta, @@ -1114,6 +1119,7 @@ function convertReducerMessageToMessage(reducerMsg: ReducerMessage, state: Reduc createdAt: reducerMsg.createdAt, kind: 'agent-text', text: reducerMsg.text, + ...(reducerMsg.isThinking && { isThinking: true }), meta: reducerMsg.meta }; } else if (reducerMsg.role === 'agent' && reducerMsg.tool !== null) { diff --git a/sources/sync/revenueCat/index.ts b/sources/sync/revenueCat/index.ts index f93e8fac..90c73c16 100644 --- a/sources/sync/revenueCat/index.ts +++ b/sources/sync/revenueCat/index.ts @@ -1,20 +1,21 @@ // Main export that selects the correct implementation based on platform // React Native's bundler will automatically choose .native.ts or .web.ts -export { +export type { RevenueCatInterface, CustomerInfo, Product, Offerings, PurchaseResult, RevenueCatConfig, - LogLevel, - PaywallResult, PaywallOptions, Offering, Package } from './types'; +// Export enums as values since they are used as runtime values +export { LogLevel, PaywallResult } from './types'; + // This will be resolved to either revenueCat.native.ts or revenueCat.web.ts // based on the platform export { default as RevenueCat } from './revenueCat'; \ No newline at end of file diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 24275896..5cc7d9ef 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { settingsParse, applySettings, settingsDefaults, type Settings } from './settings'; +import { settingsParse, applySettings, settingsDefaults, type Settings, AIBackendProfileSchema } from './settings'; +import { getBuiltInProfile } from './profileUtils'; describe('settings', () => { describe('settingsParse', () => { @@ -93,6 +94,7 @@ describe('settings', () => { describe('applySettings', () => { it('should apply delta to existing settings', () => { const currentSettings: Settings = { + schemaVersion: 1, viewInline: false, expandTodos: true, showLineNumbers: true, @@ -101,6 +103,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -115,11 +118,17 @@ describe('settings', () => { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: [], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { viewInline: true }; expect(applySettings(currentSettings, delta)).toEqual({ + schemaVersion: 1, // Preserved from currentSettings viewInline: true, expandTodos: true, showLineNumbers: true, @@ -128,8 +137,10 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, - avatarStyle: 'brutalist', + agentInputEnterToSend: true, + avatarStyle: 'gradient', // This should be preserved from currentSettings showFlavorIcons: false, compactSessionView: false, hideInactiveSessions: false, @@ -137,11 +148,21 @@ describe('settings', () => { reviewPromptLikedApp: null, voiceAssistantLanguage: null, preferredLanguage: null, + recentMachinePaths: [], + lastUsedAgent: null, + lastUsedPermissionMode: null, + lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: [], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); it('should merge with defaults', () => { const currentSettings: Settings = { + schemaVersion: 1, viewInline: true, expandTodos: true, showLineNumbers: true, @@ -150,6 +171,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -164,16 +186,19 @@ describe('settings', () => { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: [], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = {}; - expect(applySettings(currentSettings, delta)).toEqual({ - ...settingsDefaults, - viewInline: true - }); + expect(applySettings(currentSettings, delta)).toEqual(currentSettings); }); it('should override existing values with delta', () => { const currentSettings: Settings = { + schemaVersion: 1, viewInline: true, expandTodos: true, showLineNumbers: true, @@ -182,6 +207,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -196,33 +222,24 @@ describe('settings', () => { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: [], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { viewInline: false }; expect(applySettings(currentSettings, delta)).toEqual({ - viewInline: false, - expandTodos: true, - showLineNumbers: true, - showLineNumbersInToolViews: false, - wrapLinesInDiffs: false, - analyticsOptOut: false, - inferenceOpenAIKey: null, - experiments: false, - alwaysShowContextSize: false, - avatarStyle: 'brutalist', - showFlavorIcons: false, - compactSessionView: false, - hideInactiveSessions: false, - reviewPromptAnswered: false, - reviewPromptLikedApp: null, - voiceAssistantLanguage: null, - preferredLanguage: null, + ...currentSettings, + viewInline: false }); }); it('should handle empty delta', () => { const currentSettings: Settings = { + schemaVersion: 1, viewInline: true, expandTodos: true, showLineNumbers: true, @@ -231,6 +248,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -245,11 +263,13 @@ describe('settings', () => { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: [], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, }; - expect(applySettings(currentSettings, {})).toEqual({ - ...settingsDefaults, - viewInline: true - }); + expect(applySettings(currentSettings, {})).toEqual(currentSettings); }); it('should handle extra fields in current settings', () => { @@ -269,6 +289,7 @@ describe('settings', () => { it('should handle extra fields in delta', () => { const currentSettings: Settings = { + schemaVersion: 1, viewInline: true, expandTodos: true, showLineNumbers: true, @@ -277,6 +298,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -291,13 +313,18 @@ describe('settings', () => { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: [], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: any = { viewInline: false, newField: 'new value' }; expect(applySettings(currentSettings, delta)).toEqual({ - ...settingsDefaults, + ...currentSettings, viewInline: false, newField: 'new value' }); @@ -324,6 +351,7 @@ describe('settings', () => { describe('settingsDefaults', () => { it('should have correct default values', () => { expect(settingsDefaults).toEqual({ + schemaVersion: 2, viewInline: false, expandTodos: true, showLineNumbers: true, @@ -333,8 +361,25 @@ describe('settings', () => { inferenceOpenAIKey: null, experiments: false, alwaysShowContextSize: false, + avatarStyle: 'brutalist', + showFlavorIcons: false, + compactSessionView: false, agentInputEnterToSend: true, hideInactiveSessions: false, + reviewPromptAnswered: false, + reviewPromptLikedApp: null, + voiceAssistantLanguage: null, + preferredLanguage: null, + recentMachinePaths: [], + lastUsedAgent: null, + lastUsedPermissionMode: null, + lastUsedModelMode: null, + profiles: [], + lastUsedProfile: null, + favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + favoriteMachines: [], + dismissedCLIWarnings: { perMachine: {}, global: {} }, + useEnhancedSessionWizard: false, }); }); @@ -386,7 +431,7 @@ describe('settings', () => { it('should handle circular references gracefully', () => { const circular: any = { viewInline: true }; circular.self = circular; - + // Should not throw and should return defaults due to parse error expect(() => settingsParse(circular)).not.toThrow(); }); @@ -418,4 +463,407 @@ describe('settings', () => { expect(({} as any).evil).toBeUndefined(); }); }); + + describe('AIBackendProfile validation', () => { + it('validates built-in Anthropic profile', () => { + const profile = getBuiltInProfile('anthropic'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in DeepSeek profile', () => { + const profile = getBuiltInProfile('deepseek'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in Z.AI profile', () => { + const profile = getBuiltInProfile('zai'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in OpenAI profile', () => { + const profile = getBuiltInProfile('openai'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('validates built-in Azure OpenAI profile', () => { + const profile = getBuiltInProfile('azure-openai'); + expect(profile).not.toBeNull(); + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + + it('accepts all 7 permission modes', () => { + const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']; + modes.forEach(mode => { + const profile = { + id: crypto.randomUUID(), + name: 'Test Profile', + defaultPermissionMode: mode, + compatibility: { claude: true, codex: true }, + }; + expect(() => AIBackendProfileSchema.parse(profile)).not.toThrow(); + }); + }); + + it('rejects invalid permission mode', () => { + const profile = { + id: crypto.randomUUID(), + name: 'Test Profile', + defaultPermissionMode: 'invalid-mode', + compatibility: { claude: true, codex: true }, + }; + expect(() => AIBackendProfileSchema.parse(profile)).toThrow(); + }); + + it('validates environment variable names', () => { + const validProfile = { + id: crypto.randomUUID(), + name: 'Test Profile', + environmentVariables: [ + { name: 'VALID_VAR_123', value: 'test' }, + { name: 'API_KEY', value: '${SECRET}' }, + ], + compatibility: { claude: true, codex: true }, + }; + expect(() => AIBackendProfileSchema.parse(validProfile)).not.toThrow(); + }); + + it('rejects invalid environment variable names', () => { + const invalidProfile = { + id: crypto.randomUUID(), + name: 'Test Profile', + environmentVariables: [ + { name: 'invalid-name', value: 'test' }, + ], + compatibility: { claude: true, codex: true }, + }; + expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); + }); + }); + + describe('version-mismatch scenario (bug fix)', () => { + it('should preserve pending changes when merging server settings', () => { + // Simulates the bug scenario: + // 1. User enables useEnhancedSessionWizard (local change) + // 2. Version-mismatch occurs (server has newer version from another device) + // 3. Server settings don't have the flag (it was added by this device) + // 4. Merge should preserve the pending change + + const serverSettings: Partial = { + // Server settings from another device (version 11) + // Missing useEnhancedSessionWizard because other device doesn't have it + viewInline: true, + profiles: [ + { + id: 'server-profile', + name: 'Server Profile', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + } + ] + }; + + const pendingChanges: Partial = { + // User's local changes that haven't synced yet + useEnhancedSessionWizard: true, + profiles: [ + { + id: 'local-profile', + name: 'Local Profile', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + } + ] + }; + + // Parse server settings (fills in defaults for missing fields) + const parsedServerSettings = settingsParse(serverSettings); + + // Verify server settings default useEnhancedSessionWizard to false + expect(parsedServerSettings.useEnhancedSessionWizard).toBe(false); + + // Apply pending changes on top of server settings + const mergedSettings = applySettings(parsedServerSettings, pendingChanges); + + // CRITICAL: Pending changes should override defaults + expect(mergedSettings.useEnhancedSessionWizard).toBe(true); + expect(mergedSettings.profiles).toEqual(pendingChanges.profiles); + expect(mergedSettings.viewInline).toBe(true); // Preserved from server + }); + + it('should handle multiple pending changes during version-mismatch', () => { + const serverSettings = settingsParse({ + viewInline: false, + experiments: false + }); + + const pendingChanges: Partial = { + useEnhancedSessionWizard: true, + experiments: true, + profiles: [] + }; + + const merged = applySettings(serverSettings, pendingChanges); + + expect(merged.useEnhancedSessionWizard).toBe(true); + expect(merged.experiments).toBe(true); + expect(merged.viewInline).toBe(false); // From server + }); + + it('should handle empty server settings (server reset scenario)', () => { + const serverSettings = settingsParse({}); // Server has no settings + + const pendingChanges: Partial = { + useEnhancedSessionWizard: true + }; + + const merged = applySettings(serverSettings, pendingChanges); + + // Pending change should override default + expect(merged.useEnhancedSessionWizard).toBe(true); + // Other fields use defaults + expect(merged.viewInline).toBe(false); + }); + + it('should preserve user flag when server lacks field', () => { + // Exact bug scenario: + // Server has old settings without useEnhancedSessionWizard + const serverSettings = settingsParse({ + schemaVersion: 1, + viewInline: false, + // useEnhancedSessionWizard: NOT PRESENT + }); + + // User enabled flag locally (in pending) + const pendingChanges: Partial = { + useEnhancedSessionWizard: true + }; + + // Merge for version-mismatch retry + const merged = applySettings(serverSettings, pendingChanges); + + // BUG WOULD BE: merged.useEnhancedSessionWizard = false (from defaults) + // FIX IS: merged.useEnhancedSessionWizard = true (from pending) + expect(merged.useEnhancedSessionWizard).toBe(true); + }); + + it('should handle accumulating pending changes across syncs', () => { + // Scenario: User makes multiple changes before sync completes + + // Initial state from server + const serverSettings = settingsParse({ + viewInline: false, + experiments: false + }); + + // First pending change + const pending1: Partial = { + useEnhancedSessionWizard: true + }; + + // Accumulate second change (simulates line 298: this.pendingSettings = { ...this.pendingSettings, ...delta }) + const pending2: Partial = { + ...pending1, + profiles: [{ + id: 'test-profile', + name: 'Test', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }] + }; + + // Merge with server settings + const merged = applySettings(serverSettings, pending2); + + // Both pending changes preserved + expect(merged.useEnhancedSessionWizard).toBe(true); + expect(merged.profiles).toHaveLength(1); + expect(merged.profiles[0].id).toBe('test-profile'); + // Server settings preserved + expect(merged.viewInline).toBe(false); + expect(merged.experiments).toBe(false); + }); + + it('should handle multi-device conflict: Device A flag + Device B profile', () => { + // Device A and B both at version 10 + // Device A enables flag, Device B adds profile + // Both POST to server simultaneously + // One wins (becomes v11), other gets version-mismatch + + // Server accepted Device B's change first (v11) + const serverSettingsV11 = settingsParse({ + profiles: [{ + id: 'device-b-profile', + name: 'Device B Profile', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }] + }); + + // Device A's pending change + const deviceAPending: Partial = { + useEnhancedSessionWizard: true + }; + + // Device A merges and retries + const merged = applySettings(serverSettingsV11, deviceAPending); + + // Device A's flag preserved + expect(merged.useEnhancedSessionWizard).toBe(true); + // Device B's profile preserved + expect(merged.profiles).toHaveLength(1); + expect(merged.profiles[0].id).toBe('device-b-profile'); + }); + + it('should handle Device A and B both changing same field', () => { + // Device A sets flag to true + // Device B sets flag to false + // One POSTs first, other gets version-mismatch + + const serverSettings = settingsParse({ + useEnhancedSessionWizard: false // Device B won + }); + + const deviceAPending: Partial = { + useEnhancedSessionWizard: true // Device A's conflicting change + }; + + // Device A merges (its pending overrides server) + const merged = applySettings(serverSettings, deviceAPending); + + // Device A's value wins (last-write-wins for pending changes) + expect(merged.useEnhancedSessionWizard).toBe(true); + }); + + it('should handle server settings with extra fields + pending changes', () => { + // Server has newer schema version with new fields + const serverSettings = settingsParse({ + viewInline: true, + futureFeature: 'some value', // Field this device doesn't know about + anotherNewField: 123 + }); + + const pendingChanges: Partial = { + useEnhancedSessionWizard: true, + experiments: true + }; + + const merged = applySettings(serverSettings, pendingChanges); + + // Pending changes applied + expect(merged.useEnhancedSessionWizard).toBe(true); + expect(merged.experiments).toBe(true); + // Server fields preserved + expect(merged.viewInline).toBe(true); + expect((merged as any).futureFeature).toBe('some value'); + expect((merged as any).anotherNewField).toBe(123); + }); + + it('should handle empty pending (no local changes)', () => { + const serverSettings = settingsParse({ + useEnhancedSessionWizard: true, + viewInline: true + }); + + const pendingChanges: Partial = {}; + + const merged = applySettings(serverSettings, pendingChanges); + + // Server settings unchanged + expect(merged).toEqual(serverSettings); + }); + + it('should handle delta overriding multiple server fields', () => { + const serverSettings = settingsParse({ + viewInline: false, + experiments: false, + useEnhancedSessionWizard: false, + analyticsOptOut: false + }); + + const pendingChanges: Partial = { + viewInline: true, + useEnhancedSessionWizard: true, + analyticsOptOut: true + }; + + const merged = applySettings(serverSettings, pendingChanges); + + // All pending changes applied + expect(merged.viewInline).toBe(true); + expect(merged.useEnhancedSessionWizard).toBe(true); + expect(merged.analyticsOptOut).toBe(true); + // Un-changed field from server + expect(merged.experiments).toBe(false); + }); + + it('should preserve complex nested structures during merge', () => { + const serverSettings = settingsParse({ + profiles: [{ + id: 'server-profile-1', + name: 'Server Profile', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true }, + isBuiltIn: false, + createdAt: 1000, + updatedAt: 1000, + version: '1.0.0', + }], + dismissedCLIWarnings: { + perMachine: { 'machine-1': ['warning-1'] }, + global: ['global-warning'] + } + }); + + const pendingChanges: Partial = { + useEnhancedSessionWizard: true, + profiles: [{ + id: 'local-profile-1', + name: 'Local Profile', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true }, + isBuiltIn: false, + createdAt: 2000, + updatedAt: 2000, + version: '1.0.0', + }], + dismissedCLIWarnings: { + perMachine: { 'machine-2': ['warning-2'] }, + global: [] + } + }; + + const merged = applySettings(serverSettings, pendingChanges); + + // Pending changes completely override (not deep merge) + expect(merged.useEnhancedSessionWizard).toBe(true); + expect(merged.profiles).toEqual(pendingChanges.profiles); + expect(merged.dismissedCLIWarnings).toEqual(pendingChanges.dismissedCLIWarnings); + }); + }); }); diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index fdf2916c..5746c863 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -1,10 +1,260 @@ import * as z from 'zod'; // -// Schema +// Configuration Profile Schema (for environment variable profiles) // +// Environment variable schemas for different AI providers +// Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings +const AnthropicConfigSchema = z.object({ + baseUrl: z.string().refine( + (val) => { + if (!val) return true; // Optional + // Allow ${VAR} and ${VAR:-default} template strings + if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; + // Otherwise validate as URL + try { + new URL(val); + return true; + } catch { + return false; + } + }, + { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } + ).optional(), + authToken: z.string().optional(), + model: z.string().optional(), +}); + +const OpenAIConfigSchema = z.object({ + apiKey: z.string().optional(), + baseUrl: z.string().refine( + (val) => { + if (!val) return true; + // Allow ${VAR} and ${VAR:-default} template strings + if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; + try { + new URL(val); + return true; + } catch { + return false; + } + }, + { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } + ).optional(), + model: z.string().optional(), +}); + +const AzureOpenAIConfigSchema = z.object({ + apiKey: z.string().optional(), + endpoint: z.string().refine( + (val) => { + if (!val) return true; + // Allow ${VAR} and ${VAR:-default} template strings + if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; + try { + new URL(val); + return true; + } catch { + return false; + } + }, + { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } + ).optional(), + apiVersion: z.string().optional(), + deploymentName: z.string().optional(), +}); + +const TogetherAIConfigSchema = z.object({ + apiKey: z.string().optional(), + model: z.string().optional(), +}); + +// Tmux configuration schema +const TmuxConfigSchema = z.object({ + sessionName: z.string().optional(), + tmpDir: z.string().optional(), + updateEnvironment: z.boolean().optional(), +}); + +// Environment variables schema with validation +const EnvironmentVariableSchema = z.object({ + name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), + value: z.string(), +}); + +// Profile compatibility schema +const ProfileCompatibilitySchema = z.object({ + claude: z.boolean().default(true), + codex: z.boolean().default(true), + gemini: z.boolean().default(true), +}); + +export const AIBackendProfileSchema = z.object({ + // Accept both UUIDs (user profiles) and simple strings (built-in profiles like 'anthropic') + // The isBuiltIn field distinguishes profile types + id: z.string().min(1), + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + + // Agent-specific configurations + anthropicConfig: AnthropicConfigSchema.optional(), + openaiConfig: OpenAIConfigSchema.optional(), + azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), + togetherAIConfig: TogetherAIConfigSchema.optional(), + + // Tmux configuration + tmuxConfig: TmuxConfigSchema.optional(), + + // Startup bash script (executed before spawning session) + startupBashScript: z.string().optional(), + + // Environment variables (validated) + environmentVariables: z.array(EnvironmentVariableSchema).default([]), + + // Default session type for this profile + defaultSessionType: z.enum(['simple', 'worktree']).optional(), + + // Default permission mode for this profile + defaultPermissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), + + // Default model mode for this profile + defaultModelMode: z.string().optional(), + + // Compatibility metadata + compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }), + + // Built-in profile indicator + isBuiltIn: z.boolean().default(false), + + // Metadata + createdAt: z.number().default(() => Date.now()), + updatedAt: z.number().default(() => Date.now()), + version: z.string().default('1.0.0'), +}); + +export type AIBackendProfile = z.infer; + +// Helper functions for profile validation and compatibility +export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex' | 'gemini'): boolean { + return profile.compatibility[agent]; +} + +/** + * Converts a profile into environment variables for session spawning. + * + * HOW ENVIRONMENT VARIABLES WORK: + * + * 1. USER LAUNCHES DAEMON with credentials in environment: + * Example: Z_AI_AUTH_TOKEN=sk-real-key Z_AI_BASE_URL=https://api.z.ai happy daemon start + * + * 2. PROFILE DEFINES MAPPINGS using ${VAR} syntax to map daemon env vars to what CLI expects: + * Z.AI example: { name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' } + * DeepSeek example: { name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL}' } + * This maps provider-specific vars (Z_AI_AUTH_TOKEN, DEEPSEEK_BASE_URL) to CLI vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL) + * + * 3. GUI SENDS to daemon: Profile env vars with ${VAR} placeholders unchanged + * Sent: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} (literal string with placeholder) + * + * 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session: + * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching + * - Non-tmux mode: Node.js spawn with env: { ...process.env, ...profileEnvVars } (shell expansion in child) + * + * 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}) + * + * 6. CLAUDE CLI reads ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL and connects to Z.AI/DeepSeek/etc + * + * This design lets users: + * - Set credentials ONCE when launching daemon (Z_AI_AUTH_TOKEN, DEEPSEEK_AUTH_TOKEN, ANTHROPIC_AUTH_TOKEN) + * - Create multiple sessions, each with a different backend profile selected + * - Session 1 can use Z.AI backend, Session 2 can use DeepSeek backend (simultaneously) + * - 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): + * Final env = { ...daemon.process.env, ...expandedProfileVars, ...authVars } + * authVars override profile, profile overrides daemon.process.env + */ +export function getProfileEnvironmentVariables(profile: AIBackendProfile): Record { + const envVars: Record = {}; + + // Add validated environment variables + profile.environmentVariables.forEach(envVar => { + envVars[envVar.name] = envVar.value; + }); + + // Add Anthropic config + if (profile.anthropicConfig) { + if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; + if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; + if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; + } + + // Add OpenAI config + if (profile.openaiConfig) { + if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; + if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; + if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; + } + + // Add Azure OpenAI config + if (profile.azureOpenAIConfig) { + if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; + if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; + if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; + if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; + } + + // Add Together AI config + if (profile.togetherAIConfig) { + if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; + if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; + } + + // Add Tmux config + if (profile.tmuxConfig) { + // Empty string means "use current/most recent session", so include it + if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; + // Empty string may be valid for tmpDir to use tmux defaults + if (profile.tmuxConfig.tmpDir !== undefined) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; + if (profile.tmuxConfig.updateEnvironment !== undefined) { + envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); + } + } + + return envVars; +} + +// Profile versioning system +export const CURRENT_PROFILE_VERSION = '1.0.0'; + +// Profile version validation +export function validateProfileVersion(profile: AIBackendProfile): boolean { + // Simple semver validation for now + const semverRegex = /^\d+\.\d+\.\d+$/; + return semverRegex.test(profile.version); +} + +// Profile compatibility check for version upgrades +export function isProfileVersionCompatible(profileVersion: string, requiredVersion: string = CURRENT_PROFILE_VERSION): boolean { + // For now, all 1.x.x versions are compatible + const [major] = profileVersion.split('.'); + const [requiredMajor] = requiredVersion.split('.'); + return major === requiredMajor; +} + +// +// Settings Schema +// + +// Current schema version for backward compatibility +export const SUPPORTED_SCHEMA_VERSION = 2; + export const SettingsSchema = z.object({ + // Schema version for compatibility detection + schemaVersion: z.number().default(SUPPORTED_SCHEMA_VERSION).describe('Settings schema version for compatibility checks'), + viewInline: z.boolean().describe('Whether to view inline tool calls'), inferenceOpenAIKey: z.string().nullish().describe('OpenAI API key for inference'), expandTodos: z.boolean().describe('Whether to expand todo lists'), @@ -13,6 +263,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'), + 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)'), avatarStyle: z.string().describe('Avatar display style'), @@ -30,6 +281,26 @@ export const SettingsSchema = z.object({ lastUsedAgent: z.string().nullable().describe('Last selected agent type for new sessions'), lastUsedPermissionMode: z.string().nullable().describe('Last selected permission mode for new sessions'), lastUsedModelMode: z.string().nullable().describe('Last selected model mode for new sessions'), + // Profile management settings + profiles: z.array(AIBackendProfileSchema).describe('User-defined profiles for AI backend and environment variables'), + lastUsedProfile: z.string().nullable().describe('Last selected profile for new sessions'), + // Favorite directories for quick path selection + 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'), + // Dismissed CLI warning banners (supports both per-machine and global dismissal) + dismissedCLIWarnings: z.object({ + perMachine: z.record(z.string(), z.object({ + claude: z.boolean().optional(), + codex: z.boolean().optional(), + gemini: z.boolean().optional(), + })).default({}), + global: z.object({ + claude: z.boolean().optional(), + codex: z.boolean().optional(), + gemini: z.boolean().optional(), + }).default({}), + }).default({ perMachine: {}, global: {} }).describe('Tracks which CLI installation warnings user has dismissed (per-machine or globally)'), }); // @@ -43,7 +314,7 @@ export const SettingsSchema = z.object({ // only touch the fields it knows about. // -const SettingsSchemaPartial = SettingsSchema.loose().partial(); +const SettingsSchemaPartial = SettingsSchema.partial(); export type Settings = z.infer; @@ -52,6 +323,7 @@ export type Settings = z.infer; // export const settingsDefaults: Settings = { + schemaVersion: SUPPORTED_SCHEMA_VERSION, viewInline: false, inferenceOpenAIKey: null, expandTodos: true, @@ -60,6 +332,7 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -74,6 +347,15 @@ export const settingsDefaults: Settings = { lastUsedAgent: null, lastUsedPermissionMode: null, lastUsedModelMode: null, + // Profile management defaults + profiles: [], + lastUsedProfile: null, + // Default favorite directories (real common directories on Unix-like systems) + favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + // Favorite machines (empty by default) + favoriteMachines: [], + // Dismissed CLI warnings (empty by default) + dismissedCLIWarnings: { perMachine: {}, global: {} }, }; Object.freeze(settingsDefaults); @@ -82,18 +364,33 @@ Object.freeze(settingsDefaults); // export function settingsParse(settings: unknown): Settings { + // Handle null/undefined/invalid inputs + if (!settings || typeof settings !== 'object') { + return { ...settingsDefaults }; + } + const parsed = SettingsSchemaPartial.safeParse(settings); if (!parsed.success) { - return { ...settingsDefaults }; + // 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 }; } - + // 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'; } - - return { ...settingsDefaults, ...parsed.data }; + + // 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]); + + return { ...settingsDefaults, ...parsed.data, ...unknownFields }; } // @@ -102,5 +399,15 @@ export function settingsParse(settings: unknown): Settings { // export function applySettings(settings: Settings, delta: Partial): Settings { - return { ...settingsDefaults, ...settings, ...delta }; + // Original behavior: start with settings, apply delta, fill in missing with defaults + const result = { ...settings, ...delta }; + + // Fill in any missing fields with defaults + Object.keys(settingsDefaults).forEach(key => { + if (!(key in result)) { + (result as any)[key] = (settingsDefaults as any)[key]; + } + }); + + return result; } diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 49d9c07f..fde7d5b0 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -15,7 +15,7 @@ import { registerPushToken } from './apiPush'; import { Platform, AppState } from 'react-native'; import { isRunningOnMac } from '@/utils/platform'; import { NormalizedMessage, normalizeRawMessage, RawRecord } from './typesRaw'; -import { applySettings, Settings, settingsDefaults, settingsParse } from './settings'; +import { applySettings, Settings, settingsDefaults, settingsParse, SUPPORTED_SCHEMA_VERSION } from './settings'; import { Profile, profileParse } from './profile'; import { loadPendingSettings, savePendingSettings } from './persistence'; import { initializeTracking, tracking } from '@/track'; @@ -137,14 +137,12 @@ class Sync { async restore(credentials: AuthCredentials, encryption: Encryption) { // NOTE: No awaiting anything here, we're restoring from a disk (ie app restarted) + // Purchases sync is invalidated in #init() and will complete asynchronously this.credentials = credentials; this.encryption = encryption; this.anonID = encryption.anonID; this.serverID = parseToken(credentials.token); await this.#init(); - - // Await purchases sync so RevenueCat is initialized for paywall - await this.purchasesSync.awaitQueue(); } async #init() { @@ -1132,10 +1130,13 @@ class Sync { if (!this.credentials) return; const API_ENDPOINT = getServerUrl(); + const maxRetries = 3; + let retryCount = 0; + // Apply pending settings if (Object.keys(this.pendingSettings).length > 0) { - while (true) { + while (retryCount < maxRetries) { let version = storage.getState().settingsVersion; let settings = applySettings(storage.getState().settings, this.pendingSettings); const response = await fetch(`${API_ENDPOINT}/v1/account/settings`, { @@ -1158,47 +1159,46 @@ class Sync { success: true }; if (data.success) { + this.pendingSettings = {}; + savePendingSettings({}); break; } if (data.error === 'version-mismatch') { - let parsedSettings: Settings; - if (data.currentSettings) { - parsedSettings = settingsParse(await this.encryption.decryptRaw(data.currentSettings)); - } else { - parsedSettings = { ...settingsDefaults }; - } + // Parse server settings + const serverSettings = data.currentSettings + ? settingsParse(await this.encryption.decryptRaw(data.currentSettings)) + : { ...settingsDefaults }; - // Log - console.log('settings', JSON.stringify({ - settings: parsedSettings, - version: data.currentVersion - })); + // Merge: server base + our pending changes (our changes win) + const mergedSettings = applySettings(serverSettings, this.pendingSettings); - // Apply settings to storage - storage.getState().applySettings(parsedSettings, data.currentVersion); + // Update local storage with merged result at server's version + storage.getState().applySettings(mergedSettings, data.currentVersion); - // Clear pending - savePendingSettings({}); - - // Sync PostHog opt-out state with settings + // Sync tracking state with merged settings if (tracking) { - if (parsedSettings.analyticsOptOut) { - tracking.optOut(); - } else { - tracking.optIn(); - } + mergedSettings.analyticsOptOut ? tracking.optOut() : tracking.optIn(); } + // Log and retry + console.log('settings version-mismatch, retrying', { + serverVersion: data.currentVersion, + retry: retryCount + 1, + pendingKeys: Object.keys(this.pendingSettings) + }); + retryCount++; + continue; } else { throw new Error(`Failed to sync settings: ${data.error}`); } - - // Wait 1 second - await new Promise(resolve => setTimeout(resolve, 1000)); - break; } } + // If exhausted retries, throw to trigger outer backoff delay + if (retryCount >= maxRetries) { + throw new Error(`Settings sync failed after ${maxRetries} retries due to version conflicts`); + } + // Run request const response = await fetch(`${API_ENDPOINT}/v1/account/settings`, { headers: { @@ -1436,6 +1436,7 @@ class Sync { // Apply to storage this.applyMessages(sessionId, normalizedMessages); + storage.getState().applyMessagesLoaded(sessionId); log.log(`💬 fetchMessages completed for session ${sessionId} - processed ${normalizedMessages.length} messages`); } @@ -1653,6 +1654,29 @@ class Sync { // Apply the updated profile to storage storage.getState().applyProfile(updatedProfile); + + // Handle settings updates (new for profile sync) + if (accountUpdate.settings?.value) { + try { + const decryptedSettings = await this.encryption.decryptRaw(accountUpdate.settings.value); + const parsedSettings = settingsParse(decryptedSettings); + + // Version compatibility check + const settingsSchemaVersion = parsedSettings.schemaVersion ?? 1; + if (settingsSchemaVersion > SUPPORTED_SCHEMA_VERSION) { + console.warn( + `⚠️ Received settings schema v${settingsSchemaVersion}, ` + + `we support v${SUPPORTED_SCHEMA_VERSION}. Update app for full functionality.` + ); + } + + storage.getState().applySettings(parsedSettings, accountUpdate.settings.version); + log.log(`📋 Settings synced from server (schema v${settingsSchemaVersion}, version ${accountUpdate.settings.version})`); + } catch (error) { + console.error('❌ Failed to process settings update:', error); + // Don't crash on settings sync errors, just log + } + } } else if (updateData.body.t === 'update-machine') { const machineUpdate = updateData.body; const machineId = machineUpdate.machineId; // Changed from .id to .machineId diff --git a/sources/sync/typesMessage.ts b/sources/sync/typesMessage.ts index e3b15cfe..d7bd2d8a 100644 --- a/sources/sync/typesMessage.ts +++ b/sources/sync/typesMessage.ts @@ -46,6 +46,7 @@ export type AgentTextMessage = { localId: string | null; createdAt: number; text: string; + isThinking?: boolean; meta?: MessageMeta; } diff --git a/sources/sync/typesRaw.spec.ts b/sources/sync/typesRaw.spec.ts new file mode 100644 index 00000000..29178a25 --- /dev/null +++ b/sources/sync/typesRaw.spec.ts @@ -0,0 +1,1492 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeRawMessage } from './typesRaw'; + +/** + * WOLOG Content Normalization Tests + * + * These tests verify the Zod transform approach handles: + * 1. Hyphenated types (tool-call, tool-call-result) → Canonical (tool_use, tool_result) + * 2. Canonical types pass through unchanged (idempotency) + * 3. Unknown fields are preserved (future API compatibility) + * 4. Unexpected data formats are handled gracefully + * 5. Backwards compatibility with old CLI messages + * 6. Cross-agent compatibility (Claude SDK, Codex, Gemini) + */ + +// Import the actual schemas from typesRaw.ts +// Note: We're testing the schemas as black boxes through their public API +import { RawRecordSchema } from './typesRaw'; + +describe('Zod Transform - WOLOG Content Normalization', () => { + + describe('Accepts and transforms hyphenated types', () => { + it('transforms tool-call to tool_use with field remapping', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool-call', + callId: 'call_abc123', + name: 'Bash', + input: { command: 'ls -la' } + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const firstItem = content.data.message.content[0]; + expect(firstItem.type).toBe('tool_use'); + if (firstItem.type === 'tool_use') { + expect(firstItem.id).toBe('call_abc123'); // callId → id + expect(firstItem.name).toBe('Bash'); + expect(firstItem.input).toEqual({ command: 'ls -la' }); + } + } + } + }); + + it('transforms tool-call-result to tool_result with field remapping', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool-call-result', + callId: 'call_abc123', + output: 'file1.txt\nfile2.txt', + is_error: false + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'user') { + const msgContent = content.data.message.content; + if (Array.isArray(msgContent) && msgContent[0].type === 'tool_result') { + expect(msgContent[0].type).toBe('tool_result'); + expect(msgContent[0].tool_use_id).toBe('call_abc123'); // callId → tool_use_id + expect(msgContent[0].content).toBe('file1.txt\nfile2.txt'); // output → content + expect(msgContent[0].is_error).toBe(false); + } + } + } + }); + + it('preserves unknown fields for future compatibility', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool-call', + callId: 'call_xyz', + name: 'Read', + input: {}, + futureField: 'some_value', // Unknown field + metadata: { timestamp: 123 } // Unknown nested field + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const firstItem: any = content.data.message.content[0]; + expect(firstItem.type).toBe('tool_use'); + expect(firstItem.id).toBe('call_xyz'); + // Verify unknown fields are preserved + expect(firstItem.futureField).toBe('some_value'); + expect(firstItem.metadata).toEqual({ timestamp: 123 }); + } + } + }); + }); + + describe('Accepts canonical underscore types without transformation (idempotency)', () => { + it('passes through tool_use unchanged', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool_use', + id: 'call_123', + name: 'Write', + input: { file_path: '/test.txt' } + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const firstItem = content.data.message.content[0]; + expect(firstItem.type).toBe('tool_use'); + if (firstItem.type === 'tool_use') { + expect(firstItem.id).toBe('call_123'); + expect(firstItem.name).toBe('Write'); + } + } + } + }); + + it('passes through tool_result unchanged', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'call_123', + content: 'Success', + is_error: false + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'user') { + const msgContent = content.data.message.content; + if (Array.isArray(msgContent) && msgContent[0].type === 'tool_result') { + expect(msgContent[0].type).toBe('tool_result'); + expect(msgContent[0].tool_use_id).toBe('call_123'); + expect(msgContent[0].content).toBe('Success'); + } + } + } + }); + + it('passes through text content unchanged', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'text', + text: 'Hello world' + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const firstItem = content.data.message.content[0]; + expect(firstItem.type).toBe('text'); + if (firstItem.type === 'text') { + expect(firstItem.text).toBe('Hello world'); + } + } + } + }); + }); + + describe('Rejects unknown content types with clear errors', () => { + it('fails validation for unknown type with clear error message', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'unknown-type', + data: 'some data' + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(false); + if (!result.success) { + // Verify error includes information about expected types + expect(result.error.issues).toBeDefined(); + expect(result.error.issues.length).toBeGreaterThan(0); + // The error should be about invalid union (discriminated union mismatch) + const firstIssue = result.error.issues[0]; + expect(firstIssue.code).toBe('invalid_union'); + } + }); + }); + + describe('Handles mixed hyphenated and canonical in same message', () => { + it('transforms mixed content array correctly', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [ + { type: 'text', text: 'Running command...' }, + { type: 'tool-call', callId: 'call_1', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool_use', id: 'call_2', name: 'Read', input: { file_path: '/test.txt' } } + ] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const items = content.data.message.content; + + // Text passes through + expect(items[0].type).toBe('text'); + + // tool-call transformed to tool_use + expect(items[1].type).toBe('tool_use'); + if (items[1].type === 'tool_use') { + expect(items[1].id).toBe('call_1'); + } + + // tool_use passes through + expect(items[2].type).toBe('tool_use'); + if (items[2].type === 'tool_use') { + expect(items[2].id).toBe('call_2'); + } + } + } + }); + + it('handles tool results with both hyphenated and canonical', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [ + { type: 'tool-call-result', callId: 'call_1', output: 'result1' }, + { type: 'tool_result', tool_use_id: 'call_2', content: 'result2' } + ] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'user') { + const items = content.data.message.content; + if (Array.isArray(items)) { + // Both normalized to tool_result + expect(items[0].type).toBe('tool_result'); + if (items[0].type === 'tool_result') { + expect(items[0].tool_use_id).toBe('call_1'); + expect(items[0].content).toBe('result1'); + } + + expect(items[1].type).toBe('tool_result'); + if (items[1].type === 'tool_result') { + expect(items[1].tool_use_id).toBe('call_2'); + expect(items[1].content).toBe('result2'); + } + } + } + } + }); + }); + + describe('Backwards compatibility with old CLI messages', () => { + it('handles old CLI with canonical underscore types', () => { + const oldCliMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [ + { type: 'tool_use', id: 'call_old', name: 'Read', input: {} } + ] + }, + uuid: 'old-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(oldCliMessage); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const firstItem = content.data.message.content[0]; + expect(firstItem.type).toBe('tool_use'); + if (firstItem.type === 'tool_use') { + expect(firstItem.id).toBe('call_old'); + } + } + } + }); + }); + + describe('Codex/Gemini messages use native hyphenated schema (no transformation)', () => { + it('accepts Codex tool-call messages via codex schema path', () => { + const codexMessage = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'codex_1', + name: 'Bash', + input: { command: 'pwd' }, + id: 'codex-id-1' + } + } + }; + + const result = RawRecordSchema.safeParse(codexMessage); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'codex' && content.data.type === 'tool-call') { + // Codex path keeps hyphenated types as-is + expect(content.data.type).toBe('tool-call'); + expect(content.data.callId).toBe('codex_1'); + } + } + }); + + it('accepts Codex tool-call-result messages via codex schema path', () => { + const codexMessage = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call-result', + callId: 'codex_result_1', + output: 'command output', + id: 'codex-id-2' + } + } + }; + + const result = RawRecordSchema.safeParse(codexMessage); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'codex' && content.data.type === 'tool-call-result') { + // Codex path keeps hyphenated types as-is + expect(content.data.type).toBe('tool-call-result'); + expect(content.data.callId).toBe('codex_result_1'); + expect(content.data.output).toBe('command output'); + } + } + }); + }); + + describe('Handles unexpected data formats gracefully', () => { + it('handles tool-call with both callId and id fields (prefers callId)', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool-call', + callId: 'primary_id', + id: 'secondary_id', // Both present + name: 'Edit', + input: {} + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'assistant') { + const firstItem = content.data.message.content[0]; + expect(firstItem.type).toBe('tool_use'); + if (firstItem.type === 'tool_use') { + // Should use callId as the canonical id + expect(firstItem.id).toBe('primary_id'); + } + } + } + }); + + it('handles tool-call-result with both output and content fields (prefers output)', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool-call-result', + callId: 'call_dual', + output: 'primary_output', + content: 'secondary_content', // Both present + is_error: false + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'user') { + const msgContent = content.data.message.content; + if (Array.isArray(msgContent) && msgContent[0].type === 'tool_result') { + // Should use output as the canonical content + expect(msgContent[0].content).toBe('primary_output'); + } + } + } + }); + + it('handles missing optional is_error field (defaults to false)', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool-call-result', + callId: 'call_no_error', + output: 'success' + // is_error missing + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + expect(result.success).toBe(true); + if (result.success) { + const content = result.data.content; + if (content.type === 'output' && content.data.type === 'user') { + const msgContent = content.data.message.content; + if (Array.isArray(msgContent) && msgContent[0].type === 'tool_result') { + // Should default is_error to false + expect(msgContent[0].is_error).toBe(false); + } + } + } + }); + + it('rejects tool-call missing required callId field', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool-call', + // callId missing! + name: 'Bash', + input: {} + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + // Should fail validation + expect(result.success).toBe(false); + if (!result.success) { + // Verify error mentions missing callId + const errorString = JSON.stringify(result.error.issues); + expect(errorString).toContain('callId'); + } + }); + + it('rejects tool_use missing required id field', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool_use', + // id missing! + name: 'Read', + input: {} + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(message); + + // Should fail validation + expect(result.success).toBe(false); + if (!result.success) { + const errorString = JSON.stringify(result.error.issues); + expect(errorString).toContain('id'); + } + }); + }); + + describe('Integration: Complete message flow scenarios', () => { + it('handles real Claude SDK assistant message with tool_use', () => { + const realMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-4-5-20250929', + content: [ + { type: 'text', text: 'Let me read that file for you.' }, + { + type: 'tool_use', + id: 'toolu_01ABC123', + name: 'Read', + input: { file_path: '/Users/test/file.ts' } + } + ], + usage: { + input_tokens: 1000, + output_tokens: 50 + } + }, + uuid: 'real-assistant-uuid', + parentUuid: null + } + }, + meta: { + sentFrom: 'cli' + } + }; + + const result = RawRecordSchema.safeParse(realMessage); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('agent'); + expect(result.data.content.type).toBe('output'); + if (result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + const content = result.data.content.data.message.content; + expect(content.length).toBe(2); + expect(content[0].type).toBe('text'); + expect(content[1].type).toBe('tool_use'); + if (content[1].type === 'tool_use') { + expect(content[1].id).toBe('toolu_01ABC123'); + } + } + } + }); + + it('handles real user message with tool_result', () => { + const realMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_01ABC123', + content: 'File contents here...', + is_error: false, + permissions: { + date: 1736300000000, + result: 'approved', + mode: 'default' + } + }] + }, + uuid: 'real-user-uuid', + parentUuid: 'real-assistant-uuid', + isSidechain: false + } + }, + meta: { + sentFrom: 'cli' + } + }; + + const result = RawRecordSchema.safeParse(realMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'user') { + const content = result.data.content.data.message.content; + if (Array.isArray(content) && content[0].type === 'tool_result') { + expect(content[0].type).toBe('tool_result'); + expect(content[0].tool_use_id).toBe('toolu_01ABC123'); + expect(content[0].permissions).toBeDefined(); + } + } + }); + + it('handles sidechain messages (parent_tool_use_id present)', () => { + const sidechainMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [ + { type: 'text', text: 'Sidechain response' } + ] + }, + uuid: 'sidechain-uuid', + parentUuid: 'parent-uuid', + isSidechain: true, + parent_tool_use_id: 'toolu_parent' + } + }, + meta: { + sentFrom: 'cli' + } + }; + + const result = RawRecordSchema.safeParse(sidechainMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + expect(result.data.content.data.isSidechain).toBe(true); + expect(result.data.content.data.parent_tool_use_id).toBe('toolu_parent'); + } + }); + }); + + describe('Unexpected data format robustness', () => { + it('handles tool-call with extra unknown fields from future API', () => { + const futureMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-4', + content: [{ + type: 'tool-call', + callId: 'future_call', + name: 'FutureTool', + input: {}, + // Future API fields + priority: 'high', + timeout: 30000, + metadata: { version: '2.0' } + }] + }, + uuid: 'future-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(futureMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + const item: any = result.data.content.data.message.content[0]; + expect(item.type).toBe('tool_use'); + // Unknown fields should be preserved + expect(item.priority).toBe('high'); + expect(item.timeout).toBe(30000); + expect(item.metadata).toEqual({ version: '2.0' }); + } + }); + + it('handles empty content array', () => { + const emptyMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [] // Empty + }, + uuid: 'empty-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(emptyMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + expect(result.data.content.data.message.content).toEqual([]); + } + }); + + it('handles string content in user messages (not array)', () => { + const stringContentMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: 'Plain string message' // Not an array + }, + uuid: 'string-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(stringContentMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'user') { + expect(result.data.content.data.message.content).toBe('Plain string message'); + } + }); + + it('handles system messages (no transformation needed)', () => { + const systemMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'system' + } + } + }; + + const result = RawRecordSchema.safeParse(systemMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output') { + expect(result.data.content.data.type).toBe('system'); + } + }); + + it('handles summary messages', () => { + const summaryMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'summary', + summary: 'Session summary text' + } + } + }; + + const result = RawRecordSchema.safeParse(summaryMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'summary') { + expect(result.data.content.data.summary).toBe('Session summary text'); + } + }); + + it('handles event messages (no content transformation)', () => { + const eventMessage = { + role: 'agent', + content: { + type: 'event', + id: 'event-123', + data: { + type: 'switch', + mode: 'local' + } + } + }; + + const result = RawRecordSchema.safeParse(eventMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'event') { + expect(result.data.content.data.type).toBe('switch'); + if (result.data.content.data.type === 'switch') { + expect(result.data.content.data.mode).toBe('local'); + } + } + }); + + it('handles user role messages with text content', () => { + const userMessage = { + role: 'user', + content: { + type: 'text', + text: 'User input message' + } + }; + + const result = RawRecordSchema.safeParse(userMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.role === 'user') { + expect(result.data.content.type).toBe('text'); + expect(result.data.content.text).toBe('User input message'); + } + }); + }); + + describe('Field preservation and edge cases', () => { + it('preserves permissions object in tool_result', () => { + const messageWithPermissions = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'perm_call', + content: 'result', + is_error: false, + permissions: { + date: 1736300000000, + result: 'approved', + mode: 'acceptEdits', + allowedTools: ['Read', 'Write'], + decision: 'approved_for_session' + } + }] + }, + uuid: 'perm-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(messageWithPermissions); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'user') { + const content = result.data.content.data.message.content; + if (Array.isArray(content) && content[0].type === 'tool_result') { + expect(content[0].permissions).toBeDefined(); + expect(content[0].permissions?.result).toBe('approved'); + expect(content[0].permissions?.mode).toBe('acceptEdits'); + expect(content[0].permissions?.allowedTools).toEqual(['Read', 'Write']); + } + } + }); + + it('handles tool_result with array content (text blocks)', () => { + const messageWithArrayContent = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'array_call', + content: [ + { type: 'text', text: 'First block' }, + { type: 'text', text: 'Second block' } + ], + is_error: false + }] + }, + uuid: 'array-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(messageWithArrayContent); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'user') { + const content = result.data.content.data.message.content; + if (Array.isArray(content) && content[0].type === 'tool_result') { + expect(Array.isArray(content[0].content)).toBe(true); + if (Array.isArray(content[0].content)) { + expect(content[0].content.length).toBe(2); + expect(content[0].content[0].text).toBe('First block'); + } + } + } + }); + + it('handles metadata fields (uuid, parentUuid, isSidechain, etc.)', () => { + const messageWithMetadata = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ type: 'text', text: 'Test' }] + }, + uuid: 'meta-uuid-123', + parentUuid: 'parent-uuid-456', + isSidechain: true, + isCompactSummary: false, + isMeta: false + } + } + }; + + const result = RawRecordSchema.safeParse(messageWithMetadata); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output') { + expect(result.data.content.data.uuid).toBe('meta-uuid-123'); + expect(result.data.content.data.parentUuid).toBe('parent-uuid-456'); + expect(result.data.content.data.isSidechain).toBe(true); + } + }); + }); + + describe('WOLOG: Cross-agent format handling', () => { + it('Claude SDK (underscore) passes through unchanged', () => { + const claudeMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [ + { type: 'tool_use', id: 'claude_1', name: 'Bash', input: {} } + ] + }, + uuid: 'claude-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(claudeMessage); + + expect(result.success).toBe(true); + // Verify underscore types remain unchanged (idempotent) + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + expect(result.data.content.data.message.content[0].type).toBe('tool_use'); + } + }); + + it('Codex (hyphenated via codex path) uses native schema', () => { + const codexMessage = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'codex_tool', + name: 'Read', + input: {}, + id: 'codex-msg-id' + } + } + }; + + const result = RawRecordSchema.safeParse(codexMessage); + + expect(result.success).toBe(true); + // Codex path keeps hyphenated types (no transformation) + if (result.success && result.data.content.type === 'codex') { + expect(result.data.content.data.type).toBe('tool-call'); + if (result.data.content.data.type === 'tool-call') { + expect(result.data.content.data.callId).toBe('codex_tool'); + } + } + }); + + it('Gemini (uses codex path) works with hyphenated types', () => { + // Gemini uses sendCodexMessage() in CLI, so type: 'codex' + const geminiMessage = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Gemini reasoning output' + } + } + }; + + const result = RawRecordSchema.safeParse(geminiMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'codex' && result.data.content.data.type === 'message') { + expect(result.data.content.data.message).toBe('Gemini reasoning output'); + } + }); + + it('handles hypothetical hyphenated types in output path (defensive)', () => { + // This tests the defensive nature of the transform + // If CLI ever sends hyphenated in output path, it should work + const hypotheticalMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'future-model', + content: [{ + type: 'tool-call', // Hyphenated in output path + callId: 'defensive_test', + name: 'NewTool', + input: { param: 'value' } + }] + }, + uuid: 'defensive-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(hypotheticalMessage); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + // Should transform to tool_use + const item = result.data.content.data.message.content[0]; + expect(item.type).toBe('tool_use'); + if (item.type === 'tool_use') { + expect(item.id).toBe('defensive_test'); + } + } + }); + }); + + describe('Regression prevention: Ensure existing behavior unchanged', () => { + it('Zod transform produces same output as old preprocessing for canonical types', () => { + const canonicalMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'tool_use', id: 'c1', name: 'Read', input: {} } + ] + }, + uuid: 'regression-test' + } + } + }; + + const result = RawRecordSchema.safeParse(canonicalMessage); + + expect(result.success).toBe(true); + // Verify output format matches what old preprocessing would produce + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + const content = result.data.content.data.message.content; + expect(content[0].type).toBe('text'); + if (content[0].type === 'text') { + expect(content[0].text).toBe('Hello'); + } + expect(content[1].type).toBe('tool_use'); + if (content[1].type === 'tool_use') { + expect(content[1].id).toBe('c1'); + expect(content[1].name).toBe('Read'); + expect(content[1].input).toEqual({}); + } + } + }); + + it('Zod transform is idempotent (applying twice produces same result)', () => { + const message = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [ + { type: 'tool-call', callId: 'idem_1', name: 'Bash', input: {} } + ] + }, + uuid: 'idem-uuid' + } + } + }; + + // Parse once + const firstResult = RawRecordSchema.safeParse(message); + expect(firstResult.success).toBe(true); + + // Parse the result again (should be idempotent) + if (firstResult.success) { + const secondResult = RawRecordSchema.safeParse(firstResult.data); + expect(secondResult.success).toBe(true); + + // Results should be identical + expect(JSON.stringify(secondResult.data)).toBe(JSON.stringify(firstResult.data)); + } + }); + + it('Error messages are preserved (validation still returns clear errors)', () => { + const invalidMessage = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'invalid-type', + data: 'bad' + }] + }, + uuid: 'error-test' + } + } + }; + + const result = RawRecordSchema.safeParse(invalidMessage); + + expect(result.success).toBe(false); + if (!result.success) { + // Error should be clear and actionable + expect(result.error.issues.length).toBeGreaterThan(0); + // Should mention union validation issue + const errorJson = JSON.stringify(result.error.issues); + expect(errorJson).toContain('invalid_union'); + } + }); + }); + + describe('Unknown field preservation (WOLOG)', () => { + it('preserves unknown fields in thinking content via .passthrough()', () => { + const thinkingWithUnknownFields = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'thinking', + thinking: 'Reasoning here', + signature: 'EqkCCkYICxgCKkB...', // Unknown field + futureField: 'some_value' // Unknown field + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(thinkingWithUnknownFields); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + const thinkingContent = result.data.content.data.message.content[0]; + if (thinkingContent.type === 'thinking') { + // Verify unknown fields preserved + expect((thinkingContent as any).signature).toBe('EqkCCkYICxgCKkB...'); + expect((thinkingContent as any).futureField).toBe('some_value'); + } + } + }); + + it('preserves unknown fields in transformed tool-call → tool_use', () => { + const toolCallWithUnknownFields = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-3', + content: [{ + type: 'tool-call', + callId: 'test-call', + name: 'Bash', + input: { command: 'ls' }, + metadata: { timestamp: 123 }, // Unknown field + customField: 'custom_value' // Unknown field + }] + }, + uuid: 'test-uuid' + } + } + }; + + const result = RawRecordSchema.safeParse(toolCallWithUnknownFields); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output' && result.data.content.data.type === 'assistant') { + const toolUseContent = result.data.content.data.message.content[0]; + if (toolUseContent.type === 'tool_use') { + // Verify transform preserved unknown fields + expect(toolUseContent.id).toBe('test-call'); + expect((toolUseContent as any).metadata).toEqual({ timestamp: 123 }); + expect((toolUseContent as any).customField).toBe('custom_value'); + } + } + }); + + it('preserves CLI metadata fields via .passthrough()', () => { + const messageWithMetadata = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { role: 'assistant', model: 'claude-3', content: [] }, + uuid: 'test-uuid', + userType: 'external', // CLI metadata + cwd: '/path/to/project', // CLI metadata + sessionId: 'session-123', // CLI metadata + version: '2.1.1', // CLI metadata + gitBranch: 'main', // CLI metadata + slug: 'test-slug', // CLI metadata + requestId: 'req-123', // CLI metadata + timestamp: '2026-01-09T00:00:00.000Z' // CLI metadata + } + } + }; + + const result = RawRecordSchema.safeParse(messageWithMetadata); + + expect(result.success).toBe(true); + if (result.success && result.data.content.type === 'output') { + // Verify metadata preserved + expect((result.data.content.data as any).userType).toBe('external'); + expect((result.data.content.data as any).cwd).toBe('/path/to/project'); + expect((result.data.content.data as any).sessionId).toBe('session-123'); + } + }); + + it('END-TO-END: preserves unknown fields through normalizeRawMessage()', () => { + const messageWithUnknownFields = { + role: 'agent' as const, + content: { + type: 'output' as const, + data: { + type: 'assistant' as const, + message: { + role: 'assistant' as const, + model: 'claude-3', + content: [ + { + type: 'thinking' as const, + thinking: 'Extended thinking reasoning', + signature: 'EqkCCkYICxgCKkB...', // Unknown field from Claude API + customField: 'test_value' // Unknown field + }, + { + type: 'text' as const, + text: 'Final response', + metadata: { timestamp: 123 } // Unknown field + } + ] + }, + uuid: 'wolog-e2e-test', + userType: 'external' // CLI metadata (unknown to schema definition) + } + } + }; + + const normalized = normalizeRawMessage('msg-1', null, Date.now(), messageWithUnknownFields); + + expect(normalized).toBeTruthy(); + if (normalized && normalized.role === 'agent') { + expect(normalized.content.length).toBe(2); + + // Verify thinking content preserved unknown fields + const thinkingItem = normalized.content[0]; + expect(thinkingItem.type).toBe('thinking'); + if (thinkingItem.type === 'thinking') { + expect(thinkingItem.thinking).toBe('Extended thinking reasoning'); + expect((thinkingItem as any).signature).toBe('EqkCCkYICxgCKkB...'); + expect((thinkingItem as any).customField).toBe('test_value'); + } + + // Verify text content preserved unknown fields + const textItem = normalized.content[1]; + expect(textItem.type).toBe('text'); + if (textItem.type === 'text') { + expect(textItem.text).toBe('Final response'); + expect((textItem as any).metadata).toEqual({ timestamp: 123 }); + } + } + }); + + it('END-TO-END: preserves unknown fields in transformed tool-call through normalizeRawMessage()', () => { + const messageWithHyphenatedUnknownFields = { + role: 'agent' as const, + content: { + type: 'output' as const, + data: { + type: 'assistant' as const, + message: { + role: 'assistant' as const, + model: 'claude-3', + content: [{ + type: 'tool-call' as const, + callId: 'e2e-test-call', + name: 'Bash', + input: { command: 'ls' }, + executionMetadata: { server: 'remote' }, // Unknown field + timestamp: 1234567890 // Unknown field + }] + }, + uuid: 'wolog-transform-e2e' + } + } + }; + + const normalized = normalizeRawMessage('msg-2', null, Date.now(), messageWithHyphenatedUnknownFields); + + expect(normalized).toBeTruthy(); + if (normalized && normalized.role === 'agent') { + const toolCallItem = normalized.content[0]; + expect(toolCallItem.type).toBe('tool-call'); + if (toolCallItem.type === 'tool-call') { + expect(toolCallItem.id).toBe('e2e-test-call'); + expect(toolCallItem.name).toBe('Bash'); + // Verify unknown fields preserved through transformation + expect((toolCallItem as any).executionMetadata).toEqual({ server: 'remote' }); + expect((toolCallItem as any).timestamp).toBe(1234567890); + } + } + }); + }); +}); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 7956785a..4dde5e85 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -33,7 +33,7 @@ export type AgentEvent = z.infer; const rawTextContentSchema = z.object({ type: z.literal('text'), text: z.string(), -}); +}).passthrough(); // ROBUST: Accept unknown fields for future API compatibility export type RawTextContent = z.infer; const rawToolUseContentSchema = z.object({ @@ -41,7 +41,7 @@ const rawToolUseContentSchema = z.object({ id: z.string(), name: z.string(), input: z.any(), -}); +}).passthrough(); // ROBUST: Accept unknown fields preserved by transform export type RawToolUseContent = z.infer; const rawToolResultContentSchema = z.object({ @@ -52,17 +52,119 @@ const rawToolResultContentSchema = z.object({ permissions: z.object({ date: z.number(), result: z.enum(['approved', 'denied']), - mode: z.string().optional(), + mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), allowedTools: z.array(z.string()).optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), }).optional(), -}); +}).passthrough(); // ROBUST: Accept unknown fields for future API compatibility export type RawToolResultContent = z.infer; -const rawAgentContentSchema = z.discriminatedUnion('type', [ +/** + * Extended thinking content from Claude API + * Contains model's reasoning process before generating the final response + * Uses .passthrough() to preserve signature and other unknown fields + */ +const rawThinkingContentSchema = z.object({ + type: z.literal('thinking'), + thinking: z.string(), +}).passthrough(); // ROBUST: Accept signature and future fields +export type RawThinkingContent = z.infer; + +// ============================================================================ +// WOLOG: Type-Safe Content Normalization via Zod Transform +// ============================================================================ +// Accepts both hyphenated (Codex/Gemini) and underscore (Claude) formats +// Transforms all to canonical underscore format during validation +// Full type safety - no `unknown` types +// Source: Part D of the Expo Mobile Testing & Package Manager Agnostic System plan +// ============================================================================ + +/** + * Hyphenated tool-call format from Codex/Gemini agents + * Transforms to canonical tool_use format during validation + * Uses .passthrough() to preserve unknown fields for future API compatibility + */ +const rawHyphenatedToolCallSchema = z.object({ + type: z.literal('tool-call'), + callId: z.string(), + id: z.string().optional(), // Some messages have both + name: z.string(), + input: z.any(), +}).passthrough(); // ROBUST: Accept and preserve unknown fields +type RawHyphenatedToolCall = z.infer; + +/** + * Hyphenated tool-call-result format from Codex/Gemini agents + * Transforms to canonical tool_result format during validation + * Uses .passthrough() to preserve unknown fields for future API compatibility + */ +const rawHyphenatedToolResultSchema = z.object({ + type: z.literal('tool-call-result'), + callId: z.string(), + tool_use_id: z.string().optional(), // Some messages have both + output: z.any(), + content: z.any().optional(), // Some messages have both + is_error: z.boolean().optional(), +}).passthrough(); // ROBUST: Accept and preserve unknown fields +type RawHyphenatedToolResult = z.infer; + +/** + * Input schema accepting ALL formats (both hyphenated and canonical) + * Including Claude's extended thinking content type + */ +const rawAgentContentInputSchema = z.discriminatedUnion('type', [ + rawTextContentSchema, // type: 'text' (canonical) + rawToolUseContentSchema, // type: 'tool_use' (canonical) + rawToolResultContentSchema, // type: 'tool_result' (canonical) + rawThinkingContentSchema, // type: 'thinking' (canonical) + rawHyphenatedToolCallSchema, // type: 'tool-call' (hyphenated) + rawHyphenatedToolResultSchema, // type: 'tool-call-result' (hyphenated) +]); +type RawAgentContentInput = z.infer; + +/** + * Type-safe transform: Hyphenated tool-call → Canonical tool_use + * ROBUST: Unknown fields preserved via object spread and .passthrough() + */ +function normalizeToToolUse(input: RawHyphenatedToolCall) { + // Spread preserves all fields from input (passthrough fields included) + return { + ...input, + type: 'tool_use' as const, + id: input.callId, // Codex uses callId, canonical uses id + }; +} + +/** + * Type-safe transform: Hyphenated tool-call-result → Canonical tool_result + * ROBUST: Unknown fields preserved via object spread and .passthrough() + */ +function normalizeToToolResult(input: RawHyphenatedToolResult) { + // Spread preserves all fields from input (passthrough fields included) + return { + ...input, + type: 'tool_result' as const, + tool_use_id: input.callId, // Codex uses callId, canonical uses tool_use_id + content: input.output ?? input.content ?? '', // Codex uses output, canonical uses content + is_error: input.is_error ?? false, + }; +} + +/** + * Schema that accepts both hyphenated and canonical formats. + * Normalization happens via .preprocess() at root level to avoid Zod v4 "unmergable intersection" issue. + * See: https://github.com/colinhacks/zod/discussions/2100 + * + * Accepts: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'tool-call' | 'tool-call-result' + * All types validated by their respective schemas with .passthrough() for unknown fields + */ +const rawAgentContentSchema = z.union([ rawTextContentSchema, rawToolUseContentSchema, - rawToolResultContentSchema + rawToolResultContentSchema, + rawThinkingContentSchema, + rawHyphenatedToolCallSchema, + rawHyphenatedToolResultSchema, ]); export type RawAgentContent = z.infer; @@ -80,7 +182,7 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ isMeta: z.boolean().nullish(), uuid: z.string().nullish(), parentUuid: z.string().nullish(), - })), + }).passthrough()), // ROBUST: Accept CLI metadata fields (userType, cwd, sessionId, version, gitBranch, slug, requestId, timestamp) }), z.object({ type: z.literal('event'), id: z.string(), @@ -106,21 +208,60 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ ]) })]); -const rawRecordSchema = z.discriminatedUnion('role', [ - z.object({ - role: z.literal('agent'), - content: rawAgentRecordSchema, - meta: MessageMetaSchema.optional() - }), - z.object({ - role: z.literal('user'), - content: z.object({ - type: z.literal('text'), - text: z.string() +/** + * Preprocessor: Normalizes hyphenated content types to canonical before validation + * This avoids Zod v4's "unmergable intersection" issue with transforms inside complex schemas + * See: https://github.com/colinhacks/zod/discussions/2100 + */ +function preprocessMessageContent(data: any): any { + if (!data || typeof data !== 'object') return data; + + // Helper: normalize a single content item + const normalizeContent = (item: any): any => { + if (!item || typeof item !== 'object') return item; + + if (item.type === 'tool-call') { + return normalizeToToolUse(item); + } + if (item.type === 'tool-call-result') { + return normalizeToToolResult(item); + } + return item; + }; + + // Normalize assistant message content + if (data.role === 'agent' && data.content?.type === 'output' && data.content?.data?.message?.content) { + if (Array.isArray(data.content.data.message.content)) { + data.content.data.message.content = data.content.data.message.content.map(normalizeContent); + } + } + + // Normalize user message content + if (data.role === 'agent' && data.content?.type === 'output' && data.content?.data?.type === 'user' && Array.isArray(data.content.data.message?.content)) { + data.content.data.message.content = data.content.data.message.content.map(normalizeContent); + } + + return data; +} + +const rawRecordSchema = z.preprocess( + preprocessMessageContent, + z.discriminatedUnion('role', [ + z.object({ + role: z.literal('agent'), + content: rawAgentRecordSchema, + meta: MessageMetaSchema.optional() }), - meta: MessageMetaSchema.optional() - }) -]); + z.object({ + role: z.literal('user'), + content: z.object({ + type: z.literal('text'), + text: z.string() + }), + meta: MessageMetaSchema.optional() + }) + ]) +); export type RawRecord = z.infer; @@ -138,6 +279,11 @@ type NormalizedAgentContent = text: string; uuid: string; parentUUID: string | null; + } | { + type: 'thinking'; + thinking: string; + uuid: string; + parentUUID: string | null; } | { type: 'tool-call'; id: string; @@ -191,11 +337,13 @@ export type NormalizedMessage = ({ }; export function normalizeRawMessage(id: string, localId: string | null, createdAt: number, raw: RawRecord): NormalizedMessage | null { + // Zod transform handles normalization during validation let parsed = rawRecordSchema.safeParse(raw); if (!parsed.success) { - console.error('Invalid raw record:'); - console.error(parsed.error.issues); - console.error(raw); + 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 ==='); return null; } raw = parsed.data; @@ -231,20 +379,29 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA let content: NormalizedAgentContent[] = []; for (let c of raw.content.data.message.content) { if (c.type === 'text') { - content.push({ type: 'text', text: c.text, uuid: raw.content.data.uuid, parentUUID: raw.content.data.parentUuid ?? null }); + content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null + } as NormalizedAgentContent); + } else if (c.type === 'thinking') { + content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones (signature, etc.) + uuid: raw.content.data.uuid, + parentUUID: raw.content.data.parentUuid ?? null + } as NormalizedAgentContent); } else if (c.type === 'tool_use') { let description: string | null = null; if (typeof c.input === 'object' && c.input !== null && 'description' in c.input && typeof c.input.description === 'string') { description = c.input.description; } content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones type: 'tool-call', - id: c.id, - name: c.name, - input: c.input, - description, uuid: raw.content.data.uuid, + description, + uuid: raw.content.data.uuid, parentUUID: raw.content.data.parentUuid ?? null - }); + } as NormalizedAgentContent); } } return { @@ -307,8 +464,8 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA for (let c of raw.content.data.message.content) { if (c.type === 'tool_result') { content.push({ + ...c, // WOLOG: Preserve all fields including unknown ones type: 'tool-result', - tool_use_id: c.tool_use_id, content: raw.content.data.toolUseResult ? raw.content.data.toolUseResult : (typeof c.content === 'string' ? c.content : c.content[0].text), is_error: c.is_error || false, uuid: raw.content.data.uuid, @@ -320,7 +477,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA allowedTools: c.permissions.allowedTools, decision: c.permissions.decision } : undefined - }); + } as NormalizedAgentContent); } } } diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 0e5df636..66ed2cbe 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -34,6 +34,7 @@ export const en = { cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', + saveAs: 'Save As', error: 'Error', success: 'Success', ok: 'OK', @@ -57,6 +58,8 @@ export const en = { fileViewer: 'File Viewer', loading: 'Loading...', retry: 'Retry', + delete: 'Delete', + optional: 'optional', }, profile: { @@ -130,6 +133,8 @@ export const en = { 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`, @@ -199,6 +204,9 @@ export const en = { 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: { @@ -410,6 +418,16 @@ export const en = { 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', @@ -862,6 +880,37 @@ export const en = { 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; diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e7a4d8cc..e27bdba6 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -34,6 +34,7 @@ export const ca: TranslationStructure = { cancel: 'Cancel·la', authenticate: 'Autentica', save: 'Desa', + saveAs: 'Desa com a', error: 'Error', success: 'Èxit', ok: 'D\'acord', @@ -57,6 +58,8 @@ export const ca: TranslationStructure = { fileViewer: 'Visualitzador de fitxers', loading: 'Carregant...', retry: 'Torna-ho a provar', + delete: 'Elimina', + optional: 'Opcional', }, profile: { @@ -68,6 +71,7 @@ export const ca: TranslationStructure = { status: 'Estat', }, + status: { connected: 'connectat', connecting: 'connectant', @@ -130,6 +134,8 @@ export const ca: TranslationStructure = { exchangingTokens: 'Intercanviant tokens...', usage: 'Ús', usageSubtitle: "Veure l'ús de l'API i costos", + profiles: 'Perfils', + profilesSubtitle: 'Gestiona els perfils d\'entorn i variables', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Compte de ${service} connectat`, @@ -199,6 +205,9 @@ export const ca: TranslationStructure = { markdownCopyV2Subtitle: 'Pulsació llarga obre modal de còpia', hideInactiveSessions: 'Amaga les sessions inactives', hideInactiveSessionsSubtitle: 'Mostra només els xats actius a la llista', + enhancedSessionWizard: 'Assistent de sessió millorat', + enhancedSessionWizardEnabled: 'Llançador de sessió amb perfil actiu', + enhancedSessionWizardDisabled: 'Usant el llançador de sessió estàndard', }, errors: { @@ -410,6 +419,16 @@ export const ca: TranslationStructure = { 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: 'MODE DE PERMISOS', default: 'Per defecte', @@ -855,6 +874,36 @@ export const ca: TranslationStructure = { noData: "No hi ha dades d'ús disponibles", }, + profiles: { + title: 'Perfils', + subtitle: 'Gestiona els teus perfils de configuració', + noProfile: 'Cap perfil', + noProfileDescription: 'Crea un perfil per gestionar la teva configuració d\'entorn', + addProfile: 'Afegeix un perfil', + addProfileTitle: 'Títol del perfil d\'addició', + editProfile: 'Edita el perfil', + profileName: 'Nom del perfil', + enterName: 'Introdueix el nom del perfil', + baseURL: 'URL base', + authToken: 'Token d\'autenticació', + enterToken: 'Introdueix el token d\'autenticació', + model: 'Model', + defaultModel: 'Model per defecte', + tmuxSession: 'Sessió tmux', + enterTmuxSession: 'Introdueix el nom de la sessió tmux', + tmuxTempDir: 'Directori temporal tmux', + enterTmuxTempDir: 'Introdueix el directori temporal tmux', + tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux', + deleteConfirm: 'Segur que vols eliminar aquest perfil?', + nameRequired: 'El nom del perfil és obligatori', + delete: { + title: 'Eliminar Perfil', + message: ({ name }: { name: string }) => `Estàs segur que vols eliminar "${name}"? Aquesta acció no es pot desfer.`, + confirm: 'Eliminar', + cancel: 'Cancel·lar', + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} t'ha enviat una sol·licitud d'amistat`, diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts new file mode 100644 index 00000000..201e9c0e --- /dev/null +++ b/sources/text/translations/en.ts @@ -0,0 +1,933 @@ +import 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. + * + * 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 + * + * This file contains the complete English translation structure and serves as + * the reference implementation for all other language files. + * + * 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 + */ +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 type TranslationsEn = typeof en; \ No newline at end of file diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 03210817..387d726c 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -34,6 +34,7 @@ export const es: TranslationStructure = { cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Guardar', + saveAs: 'Guardar como', error: 'Error', success: 'Éxito', ok: 'OK', @@ -57,6 +58,8 @@ export const es: TranslationStructure = { fileViewer: 'Visor de archivos', loading: 'Cargando...', retry: 'Reintentar', + delete: 'Eliminar', + optional: 'opcional', }, profile: { @@ -68,6 +71,7 @@ export const es: TranslationStructure = { status: 'Estado', }, + status: { connected: 'conectado', connecting: 'conectando', @@ -130,6 +134,8 @@ export const es: TranslationStructure = { exchangingTokens: 'Intercambiando tokens...', usage: 'Uso', usageSubtitle: 'Ver tu uso de API y costos', + profiles: 'Perfiles', + profilesSubtitle: 'Gestionar perfiles de variables de entorno para sesiones', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Cuenta de ${service} conectada`, @@ -199,6 +205,9 @@ export const es: TranslationStructure = { markdownCopyV2Subtitle: 'Pulsación larga abre modal de copiado', hideInactiveSessions: 'Ocultar sesiones inactivas', hideInactiveSessionsSubtitle: 'Muestra solo los chats activos en tu lista', + enhancedSessionWizard: 'Asistente de sesión mejorado', + enhancedSessionWizardEnabled: 'Lanzador de sesión con perfil activo', + enhancedSessionWizardDisabled: 'Usando el lanzador de sesión estándar', }, errors: { @@ -410,6 +419,16 @@ export const es: TranslationStructure = { 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: 'MODO DE PERMISOS', default: 'Por defecto', @@ -862,6 +881,37 @@ export const es: TranslationStructure = { friendRequestGeneric: 'Nueva solicitud de amistad', friendAccepted: ({ name }: { name: string }) => `Ahora eres amigo de ${name}`, friendAcceptedGeneric: 'Solicitud de amistad aceptada', + }, + + profiles: { + // Profile management feature + title: 'Perfiles', + subtitle: 'Gestionar perfiles de variables de entorno para sesiones', + noProfile: 'Sin Perfil', + noProfileDescription: 'Usar configuración de entorno predeterminada', + defaultModel: 'Modelo Predeterminado', + addProfile: 'Agregar Perfil', + profileName: 'Nombre del Perfil', + enterName: 'Ingrese el nombre del perfil', + baseURL: 'URL Base', + authToken: 'Token de Autenticación', + enterToken: 'Ingrese el token de autenticación', + model: 'Modelo', + tmuxSession: 'Sesión Tmux', + enterTmuxSession: 'Ingrese el nombre de la sesión tmux', + tmuxTempDir: 'Directorio Temporal de Tmux', + 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}"?', + editProfile: 'Editar Perfil', + addProfileTitle: 'Agregar Nuevo Perfil', + delete: { + title: 'Eliminar Perfil', + message: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar "${name}"? Esta acción no se puede deshacer.`, + confirm: 'Eliminar', + cancel: 'Cancelar', + }, } } as const; diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index de503914..81145873 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -57,6 +57,9 @@ export const it: TranslationStructure = { fileViewer: 'Visualizzatore file', loading: 'Caricamento...', retry: 'Riprova', + delete: 'Elimina', + optional: 'opzionale', + saveAs: 'Salva con nome', }, profile: { @@ -68,6 +71,36 @@ export const it: TranslationStructure = { status: 'Stato', }, + profiles: { + title: 'Profili', + subtitle: 'Gestisci i profili delle variabili ambiente per le sessioni', + noProfile: 'Nessun profilo', + noProfileDescription: 'Usa le impostazioni ambiente predefinite', + defaultModel: 'Modello predefinito', + addProfile: 'Aggiungi profilo', + profileName: 'Nome profilo', + enterName: 'Inserisci nome profilo', + baseURL: 'URL base', + authToken: 'Token di autenticazione', + enterToken: 'Inserisci token di autenticazione', + model: 'Modello', + tmuxSession: 'Sessione Tmux', + enterTmuxSession: 'Inserisci nome sessione tmux', + tmuxTempDir: 'Directory temporanea Tmux', + enterTmuxTempDir: 'Inserisci percorso directory temporanea', + tmuxUpdateEnvironment: 'Aggiorna ambiente automaticamente', + nameRequired: 'Il nome del profilo è obbligatorio', + deleteConfirm: 'Sei sicuro di voler eliminare il profilo "{name}"?', + editProfile: 'Modifica profilo', + addProfileTitle: 'Aggiungi nuovo profilo', + delete: { + title: 'Elimina profilo', + message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`, + confirm: 'Elimina', + cancel: 'Annulla', + }, + }, + status: { connected: 'connesso', connecting: 'connessione in corso', @@ -130,6 +163,8 @@ export const it: TranslationStructure = { exchangingTokens: 'Scambio dei token...', usage: 'Utilizzo', usageSubtitle: 'Vedi il tuo utilizzo API e i costi', + profiles: 'Profili', + profilesSubtitle: 'Gestisci i profili delle variabili ambiente per le sessioni', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Account ${service} collegato`, @@ -199,6 +234,9 @@ export const it: TranslationStructure = { markdownCopyV2Subtitle: 'Pressione lunga apre la finestra di copia', hideInactiveSessions: 'Nascondi sessioni inattive', hideInactiveSessionsSubtitle: 'Mostra solo le chat attive nella tua lista', + enhancedSessionWizard: 'Wizard sessione avanzato', + enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo', + enhancedSessionWizardDisabled: 'Usando avvio sessioni standard', }, errors: { @@ -382,14 +420,14 @@ export const it: TranslationStructure = { agentInput: { permissionMode: { - title: 'MODALITA PERMESSI', + title: 'MODALITÀ PERMESSI', default: 'Predefinito', acceptEdits: 'Accetta modifiche', - plan: 'Modalita piano', - bypassPermissions: 'Modalita YOLO', + plan: 'Modalità piano', + bypassPermissions: 'Modalità YOLO', badgeAcceptAllEdits: 'Accetta tutte le modifiche', badgeBypassAllPermissions: 'Bypassa tutti i permessi', - badgePlanMode: 'Modalita piano', + badgePlanMode: 'Modalità piano', }, agent: { claude: 'Claude', @@ -401,24 +439,34 @@ export const it: TranslationStructure = { configureInCli: 'Configura i modelli nelle impostazioni CLI', }, codexPermissionMode: { - title: 'MODALITA PERMESSI CODEX', + title: 'MODALITÀ PERMESSI CODEX', default: 'Impostazioni CLI', - readOnly: 'Modalita sola lettura', + readOnly: 'Modalità sola lettura', safeYolo: 'YOLO sicuro', yolo: 'YOLO', - badgeReadOnly: 'Modalita sola lettura', + badgeReadOnly: 'Modalità sola lettura', badgeSafeYolo: 'YOLO sicuro', badgeYolo: 'YOLO', }, + codexModel: { + title: 'MODELLO CODEX', + gpt5CodexLow: 'gpt-5-codex basso', + gpt5CodexMedium: 'gpt-5-codex medio', + gpt5CodexHigh: 'gpt-5-codex alto', + gpt5Minimal: 'GPT-5 Minimo', + gpt5Low: 'GPT-5 Basso', + gpt5Medium: 'GPT-5 Medio', + gpt5High: 'GPT-5 Alto', + }, geminiPermissionMode: { - title: 'MODALITA PERMESSI GEMINI', + title: 'MODALITÀ PERMESSI GEMINI', default: 'Predefinito', acceptEdits: 'Accetta modifiche', - plan: 'Modalita piano', - bypassPermissions: 'Modalita YOLO', + plan: 'Modalità piano', + bypassPermissions: 'Modalità YOLO', badgeAcceptAllEdits: 'Accetta tutte le modifiche', badgeBypassAllPermissions: 'Bypassa tutti i permessi', - badgePlanMode: 'Modalita piano', + badgePlanMode: 'Modalità piano', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -455,7 +503,7 @@ export const it: TranslationStructure = { completed: 'Strumento completato con successo', noOutput: 'Nessun output prodotto', running: 'Strumento in esecuzione...', - rawJsonDevMode: 'JSON grezzo (Modalita sviluppatore)', + rawJsonDevMode: 'JSON grezzo (Modalità sviluppatore)', }, taskView: { initializing: 'Inizializzazione agente...', @@ -490,6 +538,10 @@ export const it: TranslationStructure = { viewDiff: 'Modifiche file attuali', question: 'Domanda', }, + askUserQuestion: { + submit: 'Invia risposta', + multipleQuestions: ({ count }: { count: number }) => `${count} domande`, + }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminale(cmd: ${cmd})`, searchPattern: ({ pattern }: { pattern: string }) => `Cerca(pattern: ${pattern})`, @@ -647,9 +699,9 @@ export const it: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo collegato con successo', terminalConnectedSuccessfully: 'Terminale collegato con successo', invalidAuthUrl: 'URL di autenticazione non valido', - developerMode: 'Modalita sviluppatore', - developerModeEnabled: 'Modalita sviluppatore attivata', - developerModeDisabled: 'Modalita sviluppatore disattivata', + developerMode: 'Modalità sviluppatore', + developerModeEnabled: 'Modalità sviluppatore attivata', + developerModeDisabled: 'Modalità sviluppatore disattivata', disconnectGithub: 'Disconnetti GitHub', disconnectGithubConfirm: 'Sei sicuro di voler disconnettere il tuo account GitHub?', disconnectService: ({ service }: { service: string }) => @@ -722,7 +774,7 @@ export const it: TranslationStructure = { }, message: { - switchedToMode: ({ mode }: { mode: string }) => `Passato alla modalita ${mode}`, + switchedToMode: ({ mode }: { mode: string }) => `Passato alla modalità ${mode}`, unknownEvent: 'Evento sconosciuto', usageLimitUntil: ({ time }: { time: string }) => `Limite di utilizzo raggiunto fino a ${time}`, unknownTime: 'ora sconosciuta', diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 8ba2b5b9..eb208200 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -60,6 +60,9 @@ export const ja: TranslationStructure = { fileViewer: 'ファイルビューアー', loading: '読み込み中...', retry: '再試行', + delete: '削除', + optional: '任意', + saveAs: '名前を付けて保存', }, profile: { @@ -71,6 +74,36 @@ export const ja: TranslationStructure = { status: 'ステータス', }, + profiles: { + title: 'プロファイル', + subtitle: 'セッション用の環境変数プロファイルを管理', + noProfile: 'プロファイルなし', + noProfileDescription: 'デフォルトの環境設定を使用', + defaultModel: 'デフォルトモデル', + addProfile: 'プロファイルを追加', + profileName: 'プロファイル名', + enterName: 'プロファイル名を入力', + baseURL: 'ベースURL', + authToken: '認証トークン', + enterToken: '認証トークンを入力', + model: 'モデル', + tmuxSession: 'Tmuxセッション', + enterTmuxSession: 'tmuxセッション名を入力', + tmuxTempDir: 'Tmux一時ディレクトリ', + enterTmuxTempDir: '一時ディレクトリのパスを入力', + tmuxUpdateEnvironment: '環境を自動更新', + nameRequired: 'プロファイル名は必須です', + deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + editProfile: 'プロファイルを編集', + addProfileTitle: '新しいプロファイルを追加', + delete: { + title: 'プロファイルを削除', + message: ({ name }: { name: string }) => `「${name}」を削除してもよろしいですか?この操作は元に戻せません。`, + confirm: '削除', + cancel: 'キャンセル', + }, + }, + status: { connected: '接続済み', connecting: '接続中', @@ -133,6 +166,8 @@ export const ja: TranslationStructure = { exchangingTokens: 'トークンを交換中...', usage: '使用状況', usageSubtitle: 'API使用量とコストを確認', + profiles: 'プロファイル', + profilesSubtitle: 'セッション用の環境変数プロファイルを管理', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `${service}アカウントが接続されました`, @@ -202,6 +237,9 @@ export const ja: TranslationStructure = { markdownCopyV2Subtitle: '長押しでコピーモーダルを開く', hideInactiveSessions: '非アクティブセッションを非表示', hideInactiveSessionsSubtitle: 'アクティブなチャットのみをリストに表示', + enhancedSessionWizard: '拡張セッションウィザード', + enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', + enhancedSessionWizardDisabled: '標準セッションランチャーを使用', }, errors: { @@ -413,6 +451,16 @@ export const ja: TranslationStructure = { badgeSafeYolo: 'セーフYOLO', badgeYolo: 'YOLO', }, + codexModel: { + title: 'CODEXモデル', + gpt5CodexLow: 'gpt-5-codex 低', + gpt5CodexMedium: 'gpt-5-codex 中', + gpt5CodexHigh: 'gpt-5-codex 高', + gpt5Minimal: 'GPT-5 最小', + gpt5Low: 'GPT-5 低', + gpt5Medium: 'GPT-5 中', + gpt5High: 'GPT-5 高', + }, geminiPermissionMode: { title: 'GEMINI権限モード', default: 'デフォルト', @@ -493,6 +541,10 @@ export const ja: TranslationStructure = { viewDiff: '現在のファイル変更', question: '質問', }, + askUserQuestion: { + submit: '回答を送信', + multipleQuestions: ({ count }: { count: number }) => `${count}件の質問`, + }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`, searchPattern: ({ pattern }: { pattern: string }) => `検索(pattern: ${pattern})`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index cc7b1fae..4d24cd14 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -45,6 +45,7 @@ export const pl: TranslationStructure = { cancel: 'Anuluj', authenticate: 'Uwierzytelnij', save: 'Zapisz', + saveAs: 'Zapisz jako', error: 'Błąd', success: 'Sukces', ok: 'OK', @@ -68,6 +69,8 @@ export const pl: TranslationStructure = { fileViewer: 'Przeglądarka plików', loading: 'Ładowanie...', retry: 'Ponów', + delete: 'Usuń', + optional: 'opcjonalnie', }, profile: { @@ -79,6 +82,7 @@ export const pl: TranslationStructure = { status: 'Status', }, + status: { connected: 'połączono', connecting: 'łączenie', @@ -141,6 +145,8 @@ export const pl: TranslationStructure = { exchangingTokens: 'Wymiana tokenów...', usage: 'Użycie', usageSubtitle: 'Zobacz użycie API i koszty', + profiles: 'Profile', + profilesSubtitle: 'Zarządzaj profilami zmiennych środowiskowych dla sesji', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Konto ${service} połączone`, @@ -210,6 +216,9 @@ export const pl: TranslationStructure = { markdownCopyV2Subtitle: 'Długie naciśnięcie otwiera modal kopiowania', hideInactiveSessions: 'Ukryj nieaktywne sesje', hideInactiveSessionsSubtitle: 'Wyświetlaj tylko aktywne czaty na liście', + enhancedSessionWizard: 'Ulepszony kreator sesji', + enhancedSessionWizardEnabled: 'Aktywny launcher z profilem', + enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji', }, errors: { @@ -420,6 +429,16 @@ export const pl: TranslationStructure = { 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: 'TRYB UPRAWNIEŃ', default: 'Domyślny', @@ -885,6 +904,37 @@ export const pl: TranslationStructure = { friendRequestGeneric: 'Nowe zaproszenie do znajomych', friendAccepted: ({ name }: { name: string }) => `Jesteś teraz znajomym z ${name}`, friendAcceptedGeneric: 'Zaproszenie do znajomych zaakceptowane', + }, + + profiles: { + // Profile management feature + title: 'Profile', + subtitle: 'Zarządzaj profilami zmiennych środowiskowych dla sesji', + noProfile: 'Brak Profilu', + noProfileDescription: 'Użyj domyślnych ustawień środowiska', + defaultModel: 'Domyślny Model', + addProfile: 'Dodaj Profil', + profileName: 'Nazwa Profilu', + enterName: 'Wprowadź nazwę profilu', + baseURL: 'Adres URL', + authToken: 'Token Autentykacji', + enterToken: 'Wprowadź token autentykacji', + model: 'Model', + tmuxSession: 'Sesja Tmux', + enterTmuxSession: 'Wprowadź nazwę sesji tmux', + tmuxTempDir: 'Katalog tymczasowy Tmux', + enterTmuxTempDir: 'Wprowadź ścieżkę do katalogu tymczasowego', + tmuxUpdateEnvironment: 'Aktualizuj środowisko automatycznie', + nameRequired: 'Nazwa profilu jest wymagana', + deleteConfirm: 'Czy na pewno chcesz usunąć profil "{name}"?', + editProfile: 'Edytuj Profil', + addProfileTitle: 'Dodaj Nowy Profil', + delete: { + title: 'Usuń Profil', + message: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć "${name}"? Tej czynności nie można cofnąć.`, + confirm: 'Usuń', + cancel: 'Anuluj', + }, } } as const; diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 63c41702..7a8508f9 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -34,6 +34,7 @@ export const pt: TranslationStructure = { cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Salvar', + saveAs: 'Salvar como', error: 'Erro', success: 'Sucesso', ok: 'OK', @@ -57,6 +58,8 @@ export const pt: TranslationStructure = { fileViewer: 'Visualizador de arquivos', loading: 'Carregando...', retry: 'Tentar novamente', + delete: 'Excluir', + optional: 'Opcional', }, profile: { @@ -68,6 +71,7 @@ export const pt: TranslationStructure = { status: 'Status', }, + status: { connected: 'conectado', connecting: 'conectando', @@ -130,6 +134,8 @@ export const pt: TranslationStructure = { exchangingTokens: 'Trocando tokens...', usage: 'Uso', usageSubtitle: 'Visualizar uso da API e custos', + profiles: 'Perfis', + profilesSubtitle: 'Gerenciar perfis de ambiente e variáveis', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Conta ${service} conectada`, @@ -199,6 +205,9 @@ export const pt: TranslationStructure = { markdownCopyV2Subtitle: 'Pressione e segure para abrir modal de cópia', hideInactiveSessions: 'Ocultar sessões inativas', hideInactiveSessionsSubtitle: 'Mostre apenas os chats ativos na sua lista', + 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', }, errors: { @@ -410,6 +419,16 @@ export const pt: TranslationStructure = { 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: 'MODO DE PERMISSÃO', default: 'Padrão', @@ -855,6 +874,36 @@ export const pt: TranslationStructure = { noData: 'Nenhum dado de uso disponível', }, + profiles: { + title: 'Perfis', + subtitle: 'Gerencie seus perfis de configuração', + noProfile: 'Nenhum perfil', + noProfileDescription: 'Crie um perfil para gerenciar sua configuração de ambiente', + addProfile: 'Adicionar perfil', + addProfileTitle: 'Título do perfil de adição', + editProfile: 'Editar perfil', + profileName: 'Nome do perfil', + enterName: 'Digite o nome do perfil', + baseURL: 'URL base', + authToken: 'Token de autenticação', + enterToken: 'Digite o token de autenticação', + model: 'Modelo', + defaultModel: 'Modelo padrão', + tmuxSession: 'Sessão tmux', + enterTmuxSession: 'Digite o nome da sessão tmux', + 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?', + nameRequired: 'O nome do perfil é obrigatório', + delete: { + title: 'Excluir Perfil', + message: ({ name }: { name: string }) => `Tem certeza de que deseja excluir "${name}"? Esta ação não pode ser desfeita.`, + confirm: 'Excluir', + cancel: 'Cancelar', + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} enviou-lhe um pedido de amizade`, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index a6ec750b..238ce60b 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -45,6 +45,7 @@ export const ru: TranslationStructure = { cancel: 'Отмена', authenticate: 'Авторизация', save: 'Сохранить', + saveAs: 'Сохранить как', error: 'Ошибка', success: 'Успешно', ok: 'ОК', @@ -68,6 +69,8 @@ export const ru: TranslationStructure = { fileViewer: 'Просмотр файла', loading: 'Загрузка...', retry: 'Повторить', + delete: 'Удалить', + optional: 'необязательно', }, connect: { @@ -113,6 +116,8 @@ export const ru: TranslationStructure = { exchangingTokens: 'Обмен токенов...', usage: 'Использование', usageSubtitle: 'Просмотр использования API и затрат', + profiles: 'Профили', + profilesSubtitle: 'Управление профилями переменных окружения для сессий', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Аккаунт ${service} подключен`, @@ -182,6 +187,9 @@ export const ru: TranslationStructure = { markdownCopyV2Subtitle: 'Долгое нажатие открывает модальное окно копирования', hideInactiveSessions: 'Скрывать неактивные сессии', hideInactiveSessionsSubtitle: 'Показывать в списке только активные чаты', + enhancedSessionWizard: 'Улучшенный мастер сессий', + enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', + enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', }, errors: { @@ -363,6 +371,7 @@ export const ru: TranslationStructure = { status: 'Статус', }, + status: { connected: 'подключено', connecting: 'подключение', @@ -420,6 +429,16 @@ export const ru: TranslationStructure = { 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: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', @@ -884,6 +903,37 @@ export const ru: TranslationStructure = { friendRequestGeneric: 'Новый запрос в друзья', friendAccepted: ({ name }: { name: string }) => `Вы теперь друзья с ${name}`, friendAcceptedGeneric: 'Запрос в друзья принят', + }, + + profiles: { + // Profile management feature + title: 'Профили', + subtitle: 'Управление профилями переменных окружения для сессий', + noProfile: 'Без Профиля', + noProfileDescription: 'Использовать настройки окружения по умолчанию', + defaultModel: 'Модель по Умолчанию', + addProfile: 'Добавить Профиль', + profileName: 'Имя Профиля', + enterName: 'Введите имя профиля', + baseURL: 'Базовый URL', + authToken: 'Токен Аутентификации', + enterToken: 'Введите токен аутентификации', + model: 'Модель', + tmuxSession: 'Сессия Tmux', + enterTmuxSession: 'Введите имя сессии tmux', + tmuxTempDir: 'Временный каталог Tmux', + enterTmuxTempDir: 'Введите путь к временному каталогу', + tmuxUpdateEnvironment: 'Обновлять окружение автоматически', + nameRequired: 'Имя профиля обязательно', + deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?', + editProfile: 'Редактировать Профиль', + addProfileTitle: 'Добавить Новый Профиль', + delete: { + title: 'Удалить Профиль', + message: ({ name }: { name: string }) => `Вы уверены, что хотите удалить "${name}"? Это действие нельзя отменить.`, + confirm: 'Удалить', + cancel: 'Отмена', + }, } } as const; diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 0fa65e9b..63041431 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -36,6 +36,7 @@ export const zhHans: TranslationStructure = { cancel: '取消', authenticate: '认证', save: '保存', + saveAs: '另存为', error: '错误', success: '成功', ok: '确定', @@ -59,6 +60,8 @@ export const zhHans: TranslationStructure = { fileViewer: '文件查看器', loading: '加载中...', retry: '重试', + delete: '删除', + optional: '可选的', }, profile: { @@ -70,6 +73,7 @@ export const zhHans: TranslationStructure = { status: '状态', }, + status: { connected: '已连接', connecting: '连接中', @@ -132,6 +136,8 @@ export const zhHans: TranslationStructure = { exchangingTokens: '正在交换令牌...', usage: '使用情况', usageSubtitle: '查看 API 使用情况和费用', + profiles: '配置文件', + profilesSubtitle: '管理环境配置文件和变量', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `已连接 ${service} 账户`, @@ -201,6 +207,9 @@ export const zhHans: TranslationStructure = { markdownCopyV2Subtitle: '长按打开复制模态框', hideInactiveSessions: '隐藏非活跃会话', hideInactiveSessionsSubtitle: '仅在列表中显示活跃的聊天', + enhancedSessionWizard: '增强会话向导', + enhancedSessionWizardEnabled: '配置文件优先启动器已激活', + enhancedSessionWizardDisabled: '使用标准会话启动器', }, errors: { @@ -412,6 +421,16 @@ export const zhHans: TranslationStructure = { 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: '权限模式', default: '默认', @@ -857,6 +876,36 @@ export const zhHans: TranslationStructure = { noData: '暂无使用数据', }, + profiles: { + title: '配置文件', + subtitle: '管理您的配置文件', + noProfile: '无配置文件', + noProfileDescription: '创建配置文件以管理您的环境设置', + addProfile: '添加配置文件', + addProfileTitle: '添加配置文件标题', + editProfile: '编辑配置文件', + profileName: '配置文件名称', + enterName: '输入配置文件名称', + baseURL: '基础 URL', + authToken: '认证令牌', + enterToken: '输入认证令牌', + model: '模型', + defaultModel: '默认模型', + tmuxSession: 'tmux 会话', + enterTmuxSession: '输入 tmux 会话名称', + tmuxTempDir: 'tmux 临时目录', + enterTmuxTempDir: '输入 tmux 临时目录', + tmuxUpdateEnvironment: '更新 tmux 环境', + deleteConfirm: '确定要删除此配置文件吗?', + nameRequired: '配置文件名称为必填项', + delete: { + title: '删除配置', + message: ({ name }: { name: string }) => `确定要删除"${name}"吗?此操作无法撤销。`, + confirm: '删除', + cancel: '取消', + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} 向您发送了好友请求`, diff --git a/sources/theme.ts b/sources/theme.ts index c6057824..c612581e 100644 --- a/sources/theme.ts +++ b/sources/theme.ts @@ -1,5 +1,35 @@ import { Platform } from 'react-native'; +// Shared spacing, sizing constants (DRY - used by both themes) +const sharedSpacing = { + // Spacing scale (based on actual usage patterns in codebase) + margins: { + xs: 4, // Tight spacing, status indicators + sm: 8, // Small gaps, most common gap value + md: 12, // Button gaps, card margins + lg: 16, // Most common padding value + xl: 20, // Large padding + xxl: 24, // Section spacing + }, + + // Border radii (based on actual usage patterns in codebase) + borderRadius: { + sm: 4, // Checkboxes (20x20 boxes use 4px corners) + md: 8, // Buttons, items (most common - 31 uses) + lg: 10, // Input fields (matches "new session panel input fields") + xl: 12, // Cards, containers (20 uses) + xxl: 16, // Main containers + }, + + // Icon sizes (based on actual usage patterns) + iconSize: { + small: 12, // Inline icons (checkmark, lock, status indicators) + medium: 16, // Section headers, add buttons + large: 20, // Action buttons (delete, duplicate, edit) - most common + xlarge: 24, // Main section icons (desktop, folder) + }, +} as const; + export const lightTheme = { dark: false, colors: { @@ -12,6 +42,7 @@ export const lightTheme = { textDestructive: Platform.select({ ios: '#FF3B30', default: '#F44336' }), textSecondary: Platform.select({ ios: '#8E8E93', default: '#49454F' }), textLink: '#2BACCC', + deleteAction: '#FF6B6B', // Delete/remove button color warningCritical: '#FF3B30', warning: '#8E8E93', success: '#34C759', @@ -204,6 +235,8 @@ export const lightTheme = { }, }, + + ...sharedSpacing, }; export const darkTheme = { @@ -218,6 +251,7 @@ export const darkTheme = { textDestructive: Platform.select({ ios: '#FF453A', default: '#F48FB1' }), textSecondary: Platform.select({ ios: '#8E8E93', default: '#CAC4D0' }), textLink: '#2BACCC', + deleteAction: '#FF6B6B', // Delete/remove button color (same in both themes) warningCritical: '#FF453A', warning: '#8E8E93', success: '#32D74B', @@ -411,6 +445,8 @@ export const darkTheme = { }, }, + + ...sharedSpacing, } satisfies typeof lightTheme; export type Theme = typeof lightTheme; diff --git a/sources/trash/projectManager.example.ts b/sources/trash/projectManager.example.ts deleted file mode 100644 index a47f5bda..00000000 --- a/sources/trash/projectManager.example.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Example usage of the Project Manager system - * This shows how to use the project management functionality - */ - -import { useProjects, useProjectForSession, useProjectSessions } from './storage'; -import { getProjectDisplayName, getProjectFullPath } from './projectManager'; - -// Example React component showing how to use projects -export function ProjectsListExample() { - // Get all projects - const projects = useProjects(); - - return ( -
-

Projects ({projects.length})

- {projects.map(project => ( -
-

{getProjectDisplayName(project)}

-

{getProjectFullPath(project)}

-

Sessions: {project.sessionIds.length}

-

Last updated: {new Date(project.updatedAt).toLocaleString()}

-
- ))} -
- ); -} - -// Example component showing project info for a specific session -export function SessionProjectInfoExample({ sessionId }: { sessionId: string }) { - const project = useProjectForSession(sessionId); - const projectSessions = useProjectSessions(project?.id || null); - - if (!project) { - return

Session not in any project

; - } - - return ( -
-

Project: {getProjectDisplayName(project)}

-

Path: {project.key.path}

-

Machine: {project.key.machineId}

-

Other sessions in this project: {projectSessions.filter(id => id !== sessionId).length}

-
- ); -} - -// Example of direct project manager usage (non-React) -export function getProjectStats() { - const { projectManager } = require('./projectManager'); - return projectManager.getStats(); -} - -export function findProjectsForMachine(machineId: string) { - const { projectManager } = require('./projectManager'); - return projectManager.getProjects().filter(p => p.key.machineId === machineId); -} \ No newline at end of file diff --git a/sources/trash/test-path-selection.ts b/sources/trash/test-path-selection.ts deleted file mode 100644 index ff552c3e..00000000 --- a/sources/trash/test-path-selection.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Test script to verify path selection logic -import { storage } from '../sync/storage'; - -// Mock function to test path selection -const getRecentPathForMachine = (machineId: string | null): string => { - if (!machineId) return '~'; - - const sessions = Object.values(storage.getState().sessions); - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - const pathSet = new Set(); - - sessions.forEach(session => { - if (session.metadata?.machineId === machineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - // Sort by most recent first - pathsWithTimestamps.sort((a, b) => b.timestamp - a.timestamp); - - return pathsWithTimestamps[0]?.path || '~'; -}; - -// Test scenarios -console.log('Testing path selection logic...\n'); - -// Test 1: No machine ID -console.log('Test 1 - No machine ID:'); -console.log('Result:', getRecentPathForMachine(null)); -console.log('Expected: ~\n'); - -// Test 2: Machine with no sessions -console.log('Test 2 - Machine with no sessions:'); -console.log('Result:', getRecentPathForMachine('non-existent-machine')); -console.log('Expected: ~\n'); - -// Test 3: Get actual machine from state if exists -const machines = Object.values(storage.getState().machines); -if (machines.length > 0) { - const testMachine = machines[0]; - console.log(`Test 3 - Machine "${testMachine.metadata?.displayName || testMachine.id}":`); - const result = getRecentPathForMachine(testMachine.id); - console.log('Result:', result); - console.log('(Should return most recent path or ~ if no sessions)\n'); - - // Show all paths for this machine - const sessions = Object.values(storage.getState().sessions); - const machinePaths = new Set(); - sessions.forEach(session => { - if (session.metadata?.machineId === testMachine.id && session.metadata?.path) { - machinePaths.add(session.metadata.path); - } - }); - - if (machinePaths.size > 0) { - console.log('All paths for this machine:', Array.from(machinePaths)); - } -} - -console.log('\nTest complete!'); \ No newline at end of file diff --git a/sources/utils/parseToken.ts b/sources/utils/parseToken.ts index 0c824dfc..3054a473 100644 --- a/sources/utils/parseToken.ts +++ b/sources/utils/parseToken.ts @@ -2,10 +2,22 @@ import { decodeBase64 } from "@/encryption/base64"; import { decodeUTF8, encodeUTF8 } from "@/encryption/text"; export function parseToken(token: string) { - const [header, payload, signature] = token.split('.'); - const sub = JSON.parse(decodeUTF8(decodeBase64(payload))).sub; - if (typeof sub !== 'string') { - throw new Error('Invalid token'); + const parts = token.split('.'); + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { + throw new Error('Invalid token format: expected "header.payload.signature" with non-empty parts'); + } + const [header, payload, signature] = parts; + + try { + const sub = JSON.parse(decodeUTF8(decodeBase64(payload))).sub; + if (typeof sub !== 'string') { + throw new Error('Invalid token: missing or invalid sub claim'); + } + return sub; + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid token')) { + throw error; // Re-throw our validation errors + } + throw new Error(`Invalid token: failed to decode payload - ${error instanceof Error ? error.message : 'unknown error'}`); } - return sub; } \ No newline at end of file diff --git a/sources/utils/sessionUtils.ts b/sources/utils/sessionUtils.ts index 17ccd36e..752d2010 100644 --- a/sources/utils/sessionUtils.ts +++ b/sources/utils/sessionUtils.ts @@ -81,7 +81,10 @@ export function getSessionName(session: Session): string { return session.metadata.summary.text; } else if (session.metadata) { const segments = session.metadata.path.split('/').filter(Boolean); - const lastSegment = segments.pop()!; + const lastSegment = segments.pop(); + if (!lastSegment) { + return t('status.unknown'); + } return lastSegment; } return t('status.unknown'); diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json new file mode 100644 index 00000000..bf2a1239 --- /dev/null +++ b/src-tauri/tauri.dev.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Happy (dev)", + "identifier": "com.slopus.happy.dev", + "app": { + "windows": [ + { + "title": "Happy (dev)" + } + ] + } +} diff --git a/src-tauri/tauri.preview.conf.json b/src-tauri/tauri.preview.conf.json new file mode 100644 index 00000000..e70f6272 --- /dev/null +++ b/src-tauri/tauri.preview.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Happy (preview)", + "identifier": "com.slopus.happy.preview", + "app": { + "windows": [ + { + "title": "Happy (preview)" + } + ] + } +} diff --git a/tsconfig.json b/tsconfig.json index 3de8b264..056e00dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,32 @@ { - "extends": "expo/tsconfig.base", + "extends": "expo/tsconfig.base.json", "compilerOptions": { "strict": true, + "baseUrl": ".", "paths": { "@/*": [ "./sources/*" ] - } + }, + "jsx": "react-jsx", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "incremental": true, + "plugins": [ + { + "name": "expo-router/typescript-plugin" + } + ] }, "include": [ "**/*.ts", @@ -16,6 +36,7 @@ "nativewind-env.d.ts" ], "exclude": [ - "sources/trash/**/*" + "sources/trash/**/*", + "node_modules" ] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3bc4ef26..ce5b12ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -97,6 +97,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": version "7.27.3" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" @@ -239,6 +250,11 @@ resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" @@ -285,6 +301,13 @@ dependencies: "@babel/types" "^7.28.4" +"@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== + dependencies: + "@babel/types" "^7.28.5" + "@babel/plugin-proposal-decorators@^7.12.9": version "7.28.0" resolved "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz" @@ -448,7 +471,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-arrow-functions@^7.0.0-0", "@babel/plugin-transform-arrow-functions@^7.24.7": +"@babel/plugin-transform-arrow-functions@7.27.1", "@babel/plugin-transform-arrow-functions@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz" integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== @@ -480,7 +503,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-properties@^7.0.0-0", "@babel/plugin-transform-class-properties@^7.25.4": +"@babel/plugin-transform-class-properties@7.27.1", "@babel/plugin-transform-class-properties@^7.25.4": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz" integrity sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA== @@ -496,7 +519,19 @@ "@babel/helper-create-class-features-plugin" "^7.28.3" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-classes@^7.0.0-0", "@babel/plugin-transform-classes@^7.25.4": +"@babel/plugin-transform-classes@7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz#75d66175486788c56728a73424d67cbc7473495c" + integrity sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/traverse" "^7.28.4" + +"@babel/plugin-transform-classes@^7.25.4": version "7.28.0" resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz" integrity sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA== @@ -586,7 +621,7 @@ "@babel/helper-create-regexp-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-nullish-coalescing-operator@^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": +"@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== @@ -618,7 +653,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-optional-chaining@^7.0.0-0", "@babel/plugin-transform-optional-chaining@^7.24.8": +"@babel/plugin-transform-optional-chaining@7.27.1", "@babel/plugin-transform-optional-chaining@^7.24.8": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz" integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== @@ -716,7 +751,7 @@ babel-plugin-polyfill-regenerator "^0.6.5" semver "^6.3.1" -"@babel/plugin-transform-shorthand-properties@^7.0.0-0", "@babel/plugin-transform-shorthand-properties@^7.24.7": +"@babel/plugin-transform-shorthand-properties@7.27.1", "@babel/plugin-transform-shorthand-properties@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz" integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== @@ -738,7 +773,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-template-literals@^7.0.0-0": +"@babel/plugin-transform-template-literals@7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== @@ -756,7 +791,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.27.1" -"@babel/plugin-transform-unicode-regex@^7.0.0-0", "@babel/plugin-transform-unicode-regex@^7.24.7": +"@babel/plugin-transform-unicode-regex@7.27.1", "@babel/plugin-transform-unicode-regex@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz" integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== @@ -776,7 +811,7 @@ "@babel/plugin-transform-react-jsx-development" "^7.27.1" "@babel/plugin-transform-react-pure-annotations" "^7.27.1" -"@babel/preset-typescript@^7.16.7", "@babel/preset-typescript@^7.23.0", "@babel/preset-typescript@^7.26.0": +"@babel/preset-typescript@7.27.1", "@babel/preset-typescript@^7.23.0", "@babel/preset-typescript@^7.26.0": version "7.27.1" resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz" integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== @@ -801,7 +836,20 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": + version "7.28.0" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz" + integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.0" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.0" + debug "^4.3.1" + +"@babel/traverse@^7.25.3", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0": version "7.28.0" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz" integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== @@ -827,6 +875,19 @@ "@babel/types" "^7.28.4" debug "^4.3.1" +"@babel/traverse@^7.28.4": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" + integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.5" + debug "^4.3.1" + "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.2", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6", "@babel/types@^7.28.0", "@babel/types@^7.3.3": version "7.28.1" resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz" @@ -843,6 +904,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" +"@babel/types@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + "@braintree/sanitize-url@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz#15e19737d946559289b915e5dad3b4c28407735e" @@ -924,6 +993,11 @@ resolved "https://registry.yarnpkg.com/@elevenlabs/types/-/types-0.4.0.tgz#f5a45ef2b8ed5a304eef9f28ba0da0af8ccdd7c2" integrity sha512-6CQb4r9DjepILhLybDxW0h8rvbsR8jPAp74HvFGYuvYdXcQRTkYTD9BQ4PV6VcuC1iqx0UljzJj8js3ADQ8EXw== +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@esbuild/aix-ppc64@0.25.6": version "0.25.6" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz#164b19122e2ed54f85469df9dea98ddb01d5e79e" @@ -4121,7 +4195,7 @@ connect@^3.6.5, connect@^3.7.0: parseurl "~1.3.3" utils-merge "1.0.1" -convert-source-map@^2.0.0: +convert-source-map@2.0.0, convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== @@ -4157,6 +4231,14 @@ cosmiconfig@^5.0.5: js-yaml "^3.13.1" parse-json "^4.0.0" +cross-env@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + dependencies: + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" + cross-fetch@^3.1.5: version "3.2.0" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz" @@ -8413,7 +8495,7 @@ react-native-incall-manager@^4.2.1: resolved "https://registry.yarnpkg.com/react-native-incall-manager/-/react-native-incall-manager-4.2.1.tgz#6a261693d8906f6e69c79356e5048e95d0e3e239" integrity sha512-HTdtzQ/AswUbuNhcL0gmyZLAXo8VqBO7SIh+BwbeeM1YMXXlR+Q2MvKxhD4yanjJPeyqMfuRhryCQCJhPlsdAw== -react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1: +react-native-is-edge-to-edge@1.2.1, react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz" integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== @@ -8467,13 +8549,13 @@ react-native-quick-base64@^2.2.1: resolved "https://registry.yarnpkg.com/react-native-quick-base64/-/react-native-quick-base64-2.2.1.tgz#a16954adb7ea21bcdd9fa391389cfb01c76e9785" integrity sha512-rAECaDhq3v+P8IM10cLgUVvt3kPJq3v+Jznp7tQRLXk1LlV/VCepump3am0ObwHlE6EoXblm4cddPJoXAlO+CQ== -react-native-reanimated@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.1.0.tgz#dd0a2495b14fa344d7f482131ecae79110fa59cd" - integrity sha512-L8FqZn8VjZyBaCUMYFyx1Y+T+ZTbblaudpxReOXJ66RnOf52g6UM4Pa/IjwLD1XAw1FUxLRQrtpdjbkEc74FiQ== +react-native-reanimated@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz#fbdee721bff0946a6e5ae67c8c38c37ca4a0a057" + integrity sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg== dependencies: - react-native-is-edge-to-edge "^1.2.1" - semver "7.7.2" + react-native-is-edge-to-edge "1.2.1" + semver "7.7.3" react-native-safe-area-context@~5.6.0: version "5.6.1" @@ -8556,22 +8638,22 @@ react-native-webview@13.15.0: escape-string-regexp "^4.0.0" invariant "2.2.4" -react-native-worklets@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.5.1.tgz#d153242655e3757b6c62a474768831157316ad33" - integrity sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w== - dependencies: - "@babel/plugin-transform-arrow-functions" "^7.0.0-0" - "@babel/plugin-transform-class-properties" "^7.0.0-0" - "@babel/plugin-transform-classes" "^7.0.0-0" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.0.0-0" - "@babel/plugin-transform-optional-chaining" "^7.0.0-0" - "@babel/plugin-transform-shorthand-properties" "^7.0.0-0" - "@babel/plugin-transform-template-literals" "^7.0.0-0" - "@babel/plugin-transform-unicode-regex" "^7.0.0-0" - "@babel/preset-typescript" "^7.16.7" - convert-source-map "^2.0.0" - semver "7.7.2" +react-native-worklets@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.7.1.tgz#263da5216b0b5342b9f1b36e0ab897c5ca5c863b" + integrity sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA== + dependencies: + "@babel/plugin-transform-arrow-functions" "7.27.1" + "@babel/plugin-transform-class-properties" "7.27.1" + "@babel/plugin-transform-classes" "7.28.4" + "@babel/plugin-transform-nullish-coalescing-operator" "7.27.1" + "@babel/plugin-transform-optional-chaining" "7.27.1" + "@babel/plugin-transform-shorthand-properties" "7.27.1" + "@babel/plugin-transform-template-literals" "7.27.1" + "@babel/plugin-transform-unicode-regex" "7.27.1" + "@babel/preset-typescript" "7.27.1" + convert-source-map "2.0.0" + semver "7.7.3" react-native@0.81.4: version "0.81.4" @@ -9069,16 +9151,21 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" integrity sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA== -semver@7.7.2, semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: + version "7.7.2" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + semver@~7.6.3: version "7.6.3" resolved "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz" @@ -9424,7 +9511,16 @@ strict-uri-encode@^2.0.0: resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9474,7 +9570,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9488,6 +9584,13 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz" @@ -10294,7 +10397,7 @@ wonka@^6.3.2: resolved "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz" integrity sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10312,6 +10415,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"