From 23b0498e0c672b26c8fe484ba2dc691d594e51bc Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:40:25 +0100 Subject: [PATCH 01/38] chore(crypto): patch react-native-libsodium --- ...re-tech+react-native-libsodium+1.5.5.patch | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 patches/@more-tech+react-native-libsodium+1.5.5.patch diff --git a/patches/@more-tech+react-native-libsodium+1.5.5.patch b/patches/@more-tech+react-native-libsodium+1.5.5.patch new file mode 100644 index 000000000..dbd45c3a1 --- /dev/null +++ b/patches/@more-tech+react-native-libsodium+1.5.5.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec b/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec +index 5dbd9f1..bc3da26 100644 +--- a/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec ++++ b/node_modules/@more-tech/react-native-libsodium/react-native-libsodium.podspec +@@ -30,7 +30,14 @@ Pod::Spec.new do |s| + } + s.dependency "React-Codegen" + if ENV['RCT_USE_RN_DEP'] != '1' +- s.dependency 'RCT-Folly', folly_version ++ # `folly_version` is not always defined during podspec evaluation ++ # (e.g. Expo/RN >= 0.81), so fall back to an unpinned dependency. ++ folly_ver = defined?(folly_version) ? folly_version : nil ++ if folly_ver ++ s.dependency 'RCT-Folly', folly_ver ++ else ++ s.dependency 'RCT-Folly' ++ end + end + s.dependency "RCTRequired" + s.dependency "RCTTypeSafety" From af1411a226a875030b32d29588c9ef750ee8bca8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:40:33 +0100 Subject: [PATCH 02/38] test(deps): add @types/react-test-renderer --- package.json | 1 + yarn.lock | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/package.json b/package.json index 591e10d3a..f7e4e46e1 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "@material/material-color-utilities": "^0.3.0", "@stablelib/hex": "^2.0.1", "@types/react": "~19.1.10", + "@types/react-test-renderer": "^19.1.0", "babel-plugin-transform-remove-console": "^6.9.4", "cross-env": "^10.1.0", "patch-package": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index ce5b12ad1..f2481eef6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3109,6 +3109,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@^19.1.0": + version "19.1.0" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10" + integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "19.1.8" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.8.tgz#ff8395f2afb764597265ced15f8dddb0720ae1c3" From f5a472791d796a67700a3f1eaa9ff9f0cee2acc2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:40:41 +0100 Subject: [PATCH 03/38] chore(config): harden app variant defaults --- app.config.js | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/app.config.js b/app.config.js index 655f8542a..112c9e725 100644 --- a/app.config.js +++ b/app.config.js @@ -1,14 +1,29 @@ const variant = process.env.APP_ENV || 'development'; -const name = { + +// Allow opt-in overrides for local dev tooling without changing upstream defaults. +const nameOverride = (process.env.EXPO_APP_NAME || '').trim(); +const bundleIdOverride = (process.env.EXPO_APP_BUNDLE_ID || '').trim(); + +const namesByVariant = { development: "Happy (dev)", preview: "Happy (preview)", production: "Happy" -}[variant]; -const bundleId = { +}; +const bundleIdsByVariant = { development: "com.slopus.happy.dev", preview: "com.slopus.happy.preview", production: "com.ex3ndr.happy" -}[variant]; +}; + +// If APP_ENV is unknown, fall back to development-safe defaults to avoid generating +// an invalid Expo config with undefined name/bundle id. +const name = nameOverride || namesByVariant[variant] || namesByVariant.development; +const bundleId = bundleIdOverride || bundleIdsByVariant[variant] || bundleIdsByVariant.development; +// NOTE: +// The URL scheme is used for deep linking *and* by the Expo development client launcher flow. +// Keep the default stable for upstream users, but allow opt-in overrides for local dev variants +// (e.g. to avoid iOS scheme collisions between multiple installs). +const scheme = (process.env.EXPO_APP_SCHEME || '').trim() || "happy"; export default { expo: { @@ -18,7 +33,7 @@ export default { runtimeVersion: "18", orientation: "default", icon: "./sources/assets/images/icon.png", - scheme: "happy", + scheme, userInterfaceStyle: "automatic", newArchEnabled: true, notification: { @@ -174,4 +189,4 @@ export default { }, owner: "bulkacorp" } -}; \ No newline at end of file +}; From a2c26bec6024ee4fc54c42bac30b0afd6e57bdfe Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:40:47 +0100 Subject: [PATCH 04/38] fix(auth): harden tokenStorage web persistence --- sources/auth/tokenStorage.test.ts | 91 +++++++++++++++++++++++++++++++ sources/auth/tokenStorage.ts | 55 ++++++++++++++++--- 2 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 sources/auth/tokenStorage.test.ts diff --git a/sources/auth/tokenStorage.test.ts b/sources/auth/tokenStorage.test.ts new file mode 100644 index 000000000..5a2ef25d2 --- /dev/null +++ b/sources/auth/tokenStorage.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('react-native', () => ({ + Platform: { OS: 'web' }, +})); + +vi.mock('expo-secure-store', () => ({})); + +function installLocalStorage() { + const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const store = new Map(); + const getItem = vi.fn((key: string) => store.get(key) ?? null); + const setItem = vi.fn((key: string, value: string) => { + store.set(key, value); + }); + const removeItem = vi.fn((key: string) => { + store.delete(key); + }); + + Object.defineProperty(globalThis, 'localStorage', { + value: { getItem, setItem, removeItem }, + configurable: true, + }); + + const restore = () => { + if (previousDescriptor) { + Object.defineProperty(globalThis, 'localStorage', previousDescriptor); + return; + } + // @ts-expect-error localStorage may not exist in this runtime. + delete globalThis.localStorage; + }; + + return { store, getItem, setItem, removeItem, restore }; +} + +describe('TokenStorage (web)', () => { + let restoreLocalStorage: (() => void) | null = null; + + beforeEach(() => { + vi.resetModules(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + restoreLocalStorage?.(); + restoreLocalStorage = null; + }); + + it('returns null when localStorage JSON is invalid', async () => { + const { setItem, restore } = installLocalStorage(); + restoreLocalStorage = restore; + setItem('auth_credentials', '{not valid json'); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.getCredentials()).resolves.toBeNull(); + }); + + it('returns false when localStorage.setItem throws', async () => { + const { restore } = installLocalStorage(); + restoreLocalStorage = restore; + (globalThis.localStorage.setItem as any).mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.setCredentials({ token: 't', secret: 's' })).resolves.toBe(false); + }); + + it('returns false when localStorage.removeItem throws', async () => { + const { restore } = installLocalStorage(); + restoreLocalStorage = restore; + (globalThis.localStorage.removeItem as any).mockImplementation(() => { + throw new Error('SecurityError'); + }); + + const { TokenStorage } = await import('./tokenStorage'); + await expect(TokenStorage.removeCredentials()).resolves.toBe(false); + }); + + it('calls localStorage.getItem at most once per getCredentials call', async () => { + const { getItem, setItem, restore } = installLocalStorage(); + restoreLocalStorage = restore; + setItem('auth_credentials', JSON.stringify({ token: 't', secret: 's' })); + + const { TokenStorage } = await import('./tokenStorage'); + await TokenStorage.getCredentials(); + expect(getItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/sources/auth/tokenStorage.ts b/sources/auth/tokenStorage.ts index b69060ef9..a557a43aa 100644 --- a/sources/auth/tokenStorage.ts +++ b/sources/auth/tokenStorage.ts @@ -1,10 +1,17 @@ import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; const AUTH_KEY = 'auth_credentials'; +function getAuthKey(): string { + const scope = Platform.OS === 'web' ? null : readStorageScopeFromEnv(); + return scopedStorageId(AUTH_KEY, scope); +} + // Cache for synchronous access let credentialsCache: string | null = null; +let credentialsCacheKey: string | null = null; export interface AuthCredentials { token: string; @@ -13,13 +20,29 @@ export interface AuthCredentials { export const TokenStorage = { async getCredentials(): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - return localStorage.getItem(AUTH_KEY) ? JSON.parse(localStorage.getItem(AUTH_KEY)!) as AuthCredentials : null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as AuthCredentials; + } catch (error) { + console.error('Error getting credentials:', error); + return null; + } + } + if (credentialsCache && credentialsCacheKey === key) { + try { + return JSON.parse(credentialsCache) as AuthCredentials; + } catch { + // Ignore cache parse errors, fall through to secure store read. + } } try { - const stored = await SecureStore.getItemAsync(AUTH_KEY); + const stored = await SecureStore.getItemAsync(key); if (!stored) return null; credentialsCache = stored; // Update cache + credentialsCacheKey = key; return JSON.parse(stored) as AuthCredentials; } catch (error) { console.error('Error getting credentials:', error); @@ -28,14 +51,21 @@ export const TokenStorage = { }, async setCredentials(credentials: AuthCredentials): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.setItem(AUTH_KEY, JSON.stringify(credentials)); - return true; + try { + localStorage.setItem(key, JSON.stringify(credentials)); + return true; + } catch (error) { + console.error('Error setting credentials:', error); + return false; + } } try { const json = JSON.stringify(credentials); - await SecureStore.setItemAsync(AUTH_KEY, json); + await SecureStore.setItemAsync(key, json); credentialsCache = json; // Update cache + credentialsCacheKey = key; return true; } catch (error) { console.error('Error setting credentials:', error); @@ -44,17 +74,24 @@ export const TokenStorage = { }, async removeCredentials(): Promise { + const key = getAuthKey(); if (Platform.OS === 'web') { - localStorage.removeItem(AUTH_KEY); - return true; + try { + localStorage.removeItem(key); + return true; + } catch (error) { + console.error('Error removing credentials:', error); + return false; + } } try { - await SecureStore.deleteItemAsync(AUTH_KEY); + await SecureStore.deleteItemAsync(key); credentialsCache = null; // Clear cache + credentialsCacheKey = null; return true; } catch (error) { console.error('Error removing credentials:', error); return false; } }, -}; \ No newline at end of file +}; From 438b221b548cf316444a9ae15324654c86b36a21 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:41:05 +0100 Subject: [PATCH 05/38] feat(sync): add permission mode types and mapping --- sources/sync/permissionMapping.test.ts | 39 ++++++++++++++++ sources/sync/permissionMapping.ts | 52 +++++++++++++++++++++ sources/sync/permissionTypes.test.ts | 65 ++++++++++++++++++++++++++ sources/sync/permissionTypes.ts | 62 ++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 sources/sync/permissionMapping.test.ts create mode 100644 sources/sync/permissionMapping.ts create mode 100644 sources/sync/permissionTypes.test.ts create mode 100644 sources/sync/permissionTypes.ts diff --git a/sources/sync/permissionMapping.test.ts b/sources/sync/permissionMapping.test.ts new file mode 100644 index 000000000..52bc50c20 --- /dev/null +++ b/sources/sync/permissionMapping.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { mapPermissionModeAcrossAgents } from './permissionMapping'; + +describe('mapPermissionModeAcrossAgents', () => { + it('returns the same mode when from and to are the same', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'claude')).toBe('plan'); + }); + + it('maps Claude plan to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('plan', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Claude bypassPermissions to Gemini yolo', () => { + expect(mapPermissionModeAcrossAgents('bypassPermissions', 'claude', 'gemini')).toBe('yolo'); + }); + + it('maps Claude acceptEdits to Gemini safe-yolo', () => { + expect(mapPermissionModeAcrossAgents('acceptEdits', 'claude', 'gemini')).toBe('safe-yolo'); + }); + + it('maps Codex yolo to Claude bypassPermissions', () => { + expect(mapPermissionModeAcrossAgents('yolo', 'codex', 'claude')).toBe('bypassPermissions'); + }); + + it('maps Gemini safe-yolo to Claude plan', () => { + expect(mapPermissionModeAcrossAgents('safe-yolo', 'gemini', 'claude')).toBe('plan'); + }); + + it('preserves read-only across agents', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'claude', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('read-only', 'codex', 'claude')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'claude')).toBe('read-only'); + }); + + it('keeps Codex/Gemini modes unchanged when switching between them', () => { + expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'codex')).toBe('read-only'); + expect(mapPermissionModeAcrossAgents('safe-yolo', 'codex', 'gemini')).toBe('safe-yolo'); + }); +}); diff --git a/sources/sync/permissionMapping.ts b/sources/sync/permissionMapping.ts new file mode 100644 index 000000000..5330454c6 --- /dev/null +++ b/sources/sync/permissionMapping.ts @@ -0,0 +1,52 @@ +import type { PermissionMode } from './permissionTypes'; +import type { AgentType } from './modelOptions'; + +function isCodexLike(agent: AgentType) { + return agent === 'codex' || agent === 'gemini'; +} + +export function mapPermissionModeAcrossAgents( + mode: PermissionMode, + from: AgentType, + to: AgentType, +): PermissionMode { + if (from === to) return mode; + + const fromCodexLike = isCodexLike(from); + const toCodexLike = isCodexLike(to); + + // Codex <-> Gemini uses the same permission mode set. + if (fromCodexLike && toCodexLike) return mode; + + if (!fromCodexLike && toCodexLike) { + // Claude -> Codex/Gemini + switch (mode) { + case 'bypassPermissions': + return 'yolo'; + case 'plan': + return 'safe-yolo'; + case 'acceptEdits': + return 'safe-yolo'; + case 'read-only': + return 'read-only'; + case 'default': + return 'default'; + default: + return 'default'; + } + } + + // Codex/Gemini -> Claude + switch (mode) { + case 'yolo': + return 'bypassPermissions'; + case 'safe-yolo': + return 'plan'; + case 'read-only': + return 'read-only'; + case 'default': + return 'default'; + default: + return 'default'; + } +} diff --git a/sources/sync/permissionTypes.test.ts b/sources/sync/permissionTypes.test.ts new file mode 100644 index 000000000..c585b4c41 --- /dev/null +++ b/sources/sync/permissionTypes.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import type { PermissionMode } from './permissionTypes'; +import { + isModelMode, + isPermissionMode, + normalizePermissionModeForAgentFlavor, + normalizeProfileDefaultPermissionMode, +} from './permissionTypes'; + +describe('normalizePermissionModeForAgentFlavor', () => { + it('clamps non-codex permission modes to default for codex', () => { + expect(normalizePermissionModeForAgentFlavor('plan', 'codex')).toBe('default'); + }); + + it('clamps codex-like permission modes to default for claude', () => { + expect(normalizePermissionModeForAgentFlavor('read-only', 'claude')).toBe('default'); + }); + + it('preserves codex-like modes for gemini', () => { + expect(normalizePermissionModeForAgentFlavor('safe-yolo', 'gemini')).toBe('safe-yolo'); + expect(normalizePermissionModeForAgentFlavor('yolo', 'gemini')).toBe('yolo'); + }); + + it('preserves claude modes for claude', () => { + const modes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + for (const mode of modes) { + expect(normalizePermissionModeForAgentFlavor(mode, 'claude')).toBe(mode); + } + }); +}); + +describe('isPermissionMode', () => { + it('returns true for valid permission modes', () => { + expect(isPermissionMode('default')).toBe(true); + expect(isPermissionMode('read-only')).toBe(true); + expect(isPermissionMode('plan')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isPermissionMode('bogus')).toBe(false); + expect(isPermissionMode(null)).toBe(false); + expect(isPermissionMode(123)).toBe(false); + }); +}); + +describe('normalizeProfileDefaultPermissionMode', () => { + it('clamps codex-like modes to default for profile defaultPermissionMode', () => { + expect(normalizeProfileDefaultPermissionMode('read-only')).toBe('default'); + expect(normalizeProfileDefaultPermissionMode('safe-yolo')).toBe('default'); + expect(normalizeProfileDefaultPermissionMode('yolo')).toBe('default'); + }); +}); + +describe('isModelMode', () => { + it('returns true for valid model modes', () => { + expect(isModelMode('default')).toBe(true); + expect(isModelMode('adaptiveUsage')).toBe(true); + expect(isModelMode('gemini-2.5-pro')).toBe(true); + }); + + it('returns false for invalid values', () => { + expect(isModelMode('bogus')).toBe(false); + expect(isModelMode(null)).toBe(false); + }); +}); diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts new file mode 100644 index 000000000..b85972a1d --- /dev/null +++ b/sources/sync/permissionTypes.ts @@ -0,0 +1,62 @@ +export type PermissionMode = + | 'default' + | 'acceptEdits' + | 'bypassPermissions' + | 'plan' + | 'read-only' + | 'safe-yolo' + | 'yolo'; + +const ALL_PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + +export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const; +export const CODEX_LIKE_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const; + +export type AgentFlavor = 'claude' | 'codex' | 'gemini'; + +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === 'string' && (ALL_PERMISSION_MODES as readonly string[]).includes(value); +} + +export function normalizePermissionModeForAgentFlavor(mode: PermissionMode, flavor: AgentFlavor): PermissionMode { + if (flavor === 'codex' || flavor === 'gemini') { + return (CODEX_LIKE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; + } + return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; +} + +export function normalizeProfileDefaultPermissionMode(mode: PermissionMode | null | undefined): PermissionMode { + if (!mode) return 'default'; + return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default'; +} + +export const MODEL_MODES = [ + 'default', + 'adaptiveUsage', + 'sonnet', + 'opus', + 'gpt-5-codex-high', + 'gpt-5-codex-medium', + 'gpt-5-codex-low', + 'gpt-5-minimal', + 'gpt-5-low', + 'gpt-5-medium', + 'gpt-5-high', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +] as const; + +export type ModelMode = (typeof MODEL_MODES)[number]; + +export function isModelMode(value: unknown): value is ModelMode { + return typeof value === 'string' && (MODEL_MODES as readonly string[]).includes(value); +} From 8d235e73ee0557befa3c6fcd8f629e6558dd99ee Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:41:41 +0100 Subject: [PATCH 06/38] feat(persistence): scope storage and validate draft modes --- sources/sync/persistence.test.ts | 114 +++++++++++++++++++++++++++++ sources/sync/persistence.ts | 69 +++++++++++++++-- sources/utils/storageScope.test.ts | 46 ++++++++++++ sources/utils/storageScope.ts | 32 ++++++++ 4 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 sources/sync/persistence.test.ts create mode 100644 sources/utils/storageScope.test.ts create mode 100644 sources/utils/storageScope.ts diff --git a/sources/sync/persistence.test.ts b/sources/sync/persistence.test.ts new file mode 100644 index 000000000..0e15b8c3c --- /dev/null +++ b/sources/sync/persistence.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = new Map(); + +vi.mock('react-native-mmkv', () => { + class MMKV { + getString(key: string) { + return store.get(key); + } + + set(key: string, value: string) { + store.set(key, value); + } + + delete(key: string) { + store.delete(key); + } + + clearAll() { + store.clear(); + } + } + + return { MMKV }; +}); + +import { clearPersistence, loadNewSessionDraft, loadSessionModelModes, saveSessionModelModes } from './persistence'; + +describe('persistence', () => { + beforeEach(() => { + clearPersistence(); + }); + + describe('session model modes', () => { + it('returns an empty object when nothing is persisted', () => { + expect(loadSessionModelModes()).toEqual({}); + }); + + it('roundtrips session model modes', () => { + saveSessionModelModes({ abc: 'gemini-2.5-pro' }); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + + it('filters out invalid persisted model modes', () => { + store.set( + 'session-model-modes', + JSON.stringify({ abc: 'gemini-2.5-pro', bad: 'adaptiveUsage' }), + ); + expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' }); + }); + }); + + describe('new session draft', () => { + it('preserves valid non-session modelMode values', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'claude', + permissionMode: 'default', + modelMode: 'adaptiveUsage', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('adaptiveUsage'); + }); + + it('clamps invalid permissionMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'bogus', + modelMode: 'default', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.permissionMode).toBe('default'); + }); + + it('clamps invalid modelMode to default', () => { + store.set( + 'new-session-draft-v1', + JSON.stringify({ + input: '', + selectedMachineId: null, + selectedPath: null, + selectedProfileId: null, + agentType: 'gemini', + permissionMode: 'default', + modelMode: 'not-a-real-model', + sessionType: 'simple', + updatedAt: Date.now(), + }), + ); + + const draft = loadNewSessionDraft(); + expect(draft?.modelMode).toBe('default'); + }); + }); +}); diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 2f9367523..afe07faca 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -3,20 +3,42 @@ import { Settings, settingsDefaults, settingsParse, SettingsSchema } from './set import { LocalSettings, localSettingsDefaults, localSettingsParse } from './localSettings'; import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { Session } from './storageTypes'; +import { isModelMode, isPermissionMode, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; -const mmkv = new MMKV(); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const storageScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const mmkv = storageScope ? new MMKV({ id: scopedStorageId('default', storageScope) }) : new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; export type NewSessionAgentType = 'claude' | 'codex' | 'gemini'; export type NewSessionSessionType = 'simple' | 'worktree'; +type SessionModelMode = NonNullable; + +// NOTE: +// This set must stay in sync with the configurable Session model modes. +// TypeScript will catch invalid entries here, but it won't force adding new Session modes. +const SESSION_MODEL_MODES = new Set([ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', +]); + +function isSessionModelMode(value: unknown): value is SessionModelMode { + return typeof value === 'string' && SESSION_MODEL_MODES.has(value as SessionModelMode); +} + export interface NewSessionDraft { input: string; selectedMachineId: string | null; selectedPath: string | null; + selectedProfileId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; + modelMode: ModelMode; sessionType: NewSessionSessionType; updatedAt: number; } @@ -26,7 +48,8 @@ export function loadSettings(): { settings: Settings, version: number | null } { if (settings) { try { const parsed = JSON.parse(settings); - return { settings: settingsParse(parsed.settings), version: parsed.version }; + const version = typeof parsed.version === 'number' ? parsed.version : null; + return { settings: settingsParse(parsed.settings), version }; } catch (e) { console.error('Failed to parse settings', e); return { settings: { ...settingsDefaults }, version: null }; @@ -139,11 +162,15 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const input = typeof parsed.input === 'string' ? parsed.input : ''; const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null; const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null; + const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null; const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' ? parsed.agentType : 'claude'; - const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string' - ? (parsed.permissionMode as PermissionMode) + const permissionMode: PermissionMode = isPermissionMode(parsed.permissionMode) + ? parsed.permissionMode + : 'default'; + const modelMode: ModelMode = isModelMode(parsed.modelMode) + ? parsed.modelMode : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(); @@ -152,8 +179,10 @@ export function loadNewSessionDraft(): NewSessionDraft | null { input, selectedMachineId, selectedPath, + selectedProfileId, agentType, permissionMode, + modelMode, sessionType, updatedAt, }; @@ -188,6 +217,34 @@ export function saveSessionPermissionModes(modes: Record mmkv.set('session-permission-modes', JSON.stringify(modes)); } +export function loadSessionModelModes(): Record { + const modes = mmkv.getString('session-model-modes'); + if (modes) { + try { + const parsed: unknown = JSON.parse(modes); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + + const result: Record = {}; + Object.entries(parsed as Record).forEach(([sessionId, mode]) => { + if (isSessionModelMode(mode)) { + result[sessionId] = mode; + } + }); + return result; + } catch (e) { + console.error('Failed to parse session model modes', e); + return {}; + } + } + return {}; +} + +export function saveSessionModelModes(modes: Record) { + mmkv.set('session-model-modes', JSON.stringify(modes)); +} + export function loadProfile(): Profile { const profile = mmkv.getString('profile'); if (profile) { @@ -225,4 +282,4 @@ export function retrieveTempText(id: string): string | null { export function clearPersistence() { mmkv.clearAll(); -} \ No newline at end of file +} diff --git a/sources/utils/storageScope.test.ts b/sources/utils/storageScope.test.ts new file mode 100644 index 000000000..5436c31e1 --- /dev/null +++ b/sources/utils/storageScope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR, + normalizeStorageScope, + readStorageScopeFromEnv, + scopedStorageId, +} from './storageScope'; + +describe('storageScope', () => { + describe('normalizeStorageScope', () => { + it('returns null for non-strings and empty strings', () => { + expect(normalizeStorageScope(undefined)).toBeNull(); + expect(normalizeStorageScope(null)).toBeNull(); + expect(normalizeStorageScope(123)).toBeNull(); + expect(normalizeStorageScope('')).toBeNull(); + expect(normalizeStorageScope(' ')).toBeNull(); + }); + + it('sanitizes unsafe characters and clamps length', () => { + expect(normalizeStorageScope(' pr272-107 ')).toBe('pr272-107'); + expect(normalizeStorageScope('a/b:c')).toBe('a_b_c'); + expect(normalizeStorageScope('a__b')).toBe('a_b'); + + const long = 'x'.repeat(100); + expect(normalizeStorageScope(long)?.length).toBe(64); + }); + }); + + describe('readStorageScopeFromEnv', () => { + it('reads from EXPO_PUBLIC_HAPPY_STORAGE_SCOPE', () => { + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: 'stack-1' })).toBe('stack-1'); + expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: ' ' })).toBeNull(); + }); + }); + + describe('scopedStorageId', () => { + it('returns baseId when scope is null', () => { + expect(scopedStorageId('auth_credentials', null)).toBe('auth_credentials'); + }); + + it('namespaces when scope is present', () => { + expect(scopedStorageId('auth_credentials', 'stack-1')).toBe('auth_credentials__stack-1'); + }); + }); +}); diff --git a/sources/utils/storageScope.ts b/sources/utils/storageScope.ts new file mode 100644 index 000000000..8bfebde1a --- /dev/null +++ b/sources/utils/storageScope.ts @@ -0,0 +1,32 @@ +export const EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR = 'EXPO_PUBLIC_HAPPY_STORAGE_SCOPE'; + +/** + * Returns a sanitized storage scope suitable for identifiers/keys, or null. + * + * Notes: + * - This is intentionally conservative (stable, URL/key friendly). + * - If unset/empty, callers should behave exactly as they did before (no scoping). + */ +export function normalizeStorageScope(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + // Keep only safe characters to avoid backend/storage quirks (keychain, MMKV id, etc.) + // Replace everything else with '_' for stability. + const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_'); + const collapsed = sanitized.replace(/_+/g, '_'); + const clamped = collapsed.slice(0, 64); + return clamped || null; +} + +export function readStorageScopeFromEnv( + env: Record = process.env, +): string | null { + return normalizeStorageScope(env[EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]); +} + +export function scopedStorageId(baseId: string, scope: string | null): string { + // Must be compatible with all underlying stores (SecureStore keys are especially strict). + return scope ? `${baseId}__${scope}` : baseId; +} From fabb336d23492ee3785b2ac702a8c624815f1411 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:42:31 +0100 Subject: [PATCH 07/38] fix(session): clamp configurable model modes --- sources/-session/SessionView.tsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index 457419294..530c928dd 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -22,6 +22,7 @@ import { isRunningOnMac } from '@/utils/platform'; import { useDeviceType, useHeaderHeight, useIsLandscape, useIsTablet } from '@/utils/responsive'; import { formatPathRelativeToHome, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; +import type { ModelMode } from '@/sync/permissionTypes'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import * as React from 'react'; @@ -196,10 +197,24 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); + const CONFIGURABLE_MODEL_MODES = [ + 'default', + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + ] as const; + type ConfigurableModelMode = (typeof CONFIGURABLE_MODEL_MODES)[number]; + const isConfigurableModelMode = React.useCallback((mode: ModelMode): mode is ConfigurableModelMode => { + return (CONFIGURABLE_MODEL_MODES as readonly string[]).includes(mode); + }, []); + // Function to update model mode (for Gemini sessions) - const updateModelMode = React.useCallback((mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => { - storage.getState().updateSessionModelMode(sessionId, mode); - }, [sessionId]); + const updateModelMode = React.useCallback((mode: ModelMode) => { + // Only Gemini model modes are configurable from the UI today. + if (isConfigurableModelMode(mode)) { + storage.getState().updateSessionModelMode(sessionId, mode); + } + }, [isConfigurableModelMode, sessionId]); // Memoize header-dependent styles to prevent re-renders const headerDependentStyles = React.useMemo(() => ({ @@ -280,8 +295,8 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: sessionId={sessionId} permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} - modelMode={modelMode as any} - onModelModeChange={updateModelMode as any} + modelMode={modelMode} + onModelModeChange={updateModelMode} metadata={session.metadata} connectionStatus={{ text: sessionStatus.statusText, From b7b412a349dbd018a23ca13150ba0f9e8f2a0cb2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:42:41 +0100 Subject: [PATCH 08/38] fix(tools): harden ACP tool parsing and titles --- sources/components/tools/knownTools.tsx | 32 +++-- .../tools/views/GeminiExecuteView.tsx | 4 +- sources/sync/typesRaw.spec.ts | 132 ++++++++++++++++++ sources/sync/typesRaw.ts | 62 ++++++-- 4 files changed, 202 insertions(+), 28 deletions(-) diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 696f8315e..55e991b08 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -181,9 +181,12 @@ export const knownTools = { return path; } // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } } return t('tools.names.readFile'); }, @@ -211,9 +214,12 @@ export const knownTools = { 'read': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Gemini uses 'locations' array with 'path' field - if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { - const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); - return path; + if (Array.isArray(opts.tool.input.locations)) { + const maybePath = opts.tool.input.locations[0]?.path; + if (typeof maybePath === 'string' && maybePath.length > 0) { + const path = resolvePath(maybePath, opts.metadata); + return path; + } } if (typeof opts.tool.input.file_path === 'string') { const path = resolvePath(opts.tool.input.file_path, opts.metadata); @@ -592,7 +598,7 @@ export const knownTools = { } }, 'change_title': { - title: 'Change Title', + title: t('tools.names.changeTitle'), icon: ICON_EDIT, minimal: true, noStatus: true, @@ -617,15 +623,15 @@ export const knownTools = { let filePath: string | undefined; // 1. Check toolCall.content[0].path - if (opts.tool.input?.toolCall?.content?.[0]?.path) { + if (typeof opts.tool.input?.toolCall?.content?.[0]?.path === 'string') { filePath = opts.tool.input.toolCall.content[0].path; } // 2. Check toolCall.title (has nice "Writing to ..." format) - else if (opts.tool.input?.toolCall?.title) { + else if (typeof opts.tool.input?.toolCall?.title === 'string') { return opts.tool.input.toolCall.title; } // 3. Check input[0].path (array format) - else if (Array.isArray(opts.tool.input?.input) && opts.tool.input.input[0]?.path) { + else if (Array.isArray(opts.tool.input?.input) && typeof opts.tool.input.input[0]?.path === 'string') { filePath = opts.tool.input.input[0].path; } // 4. Check direct path field @@ -633,7 +639,7 @@ export const knownTools = { filePath = opts.tool.input.path; } - if (filePath) { + if (typeof filePath === 'string' && filePath.length > 0) { return resolvePath(filePath, opts.metadata); } return t('tools.names.editFile'); @@ -657,7 +663,7 @@ export const knownTools = { 'execute': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Gemini sends nice title in toolCall.title - if (opts.tool.input?.toolCall?.title) { + if (typeof opts.tool.input?.toolCall?.title === 'string') { // Title is like "rm file.txt [cwd /path] (description)" // Extract just the command part before [ const fullTitle = opts.tool.input.toolCall.title; @@ -674,7 +680,7 @@ export const knownTools = { input: z.object({}).partial().loose(), extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Extract description from parentheses at the end - if (opts.tool.input?.toolCall?.title) { + if (typeof opts.tool.input?.toolCall?.title === 'string') { const title = opts.tool.input.toolCall.title; const parenMatch = title.match(/\(([^)]+)\)$/); if (parenMatch) { diff --git a/sources/components/tools/views/GeminiExecuteView.tsx b/sources/components/tools/views/GeminiExecuteView.tsx index 86fe20e84..3101a78f9 100644 --- a/sources/components/tools/views/GeminiExecuteView.tsx +++ b/sources/components/tools/views/GeminiExecuteView.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from 'react-native-unistyles'; import { ToolSectionView } from '../../tools/ToolSectionView'; import { ToolViewProps } from './_all'; import { CodeView } from '@/components/CodeView'; +import { t } from '@/text'; /** * Extract execute command info from Gemini's nested input format. @@ -62,7 +63,7 @@ export const GeminiExecuteView = React.memo(({ tool }) => { {(description || cwd) && ( {cwd && ( - 📁 {cwd} + {t('tools.geminiExecute.cwd', { cwd })} )} {description && ( {description} @@ -89,4 +90,3 @@ const styles = StyleSheet.create((theme) => ({ fontStyle: 'italic', }, })); - diff --git a/sources/sync/typesRaw.spec.ts b/sources/sync/typesRaw.spec.ts index 29178a25d..55851f426 100644 --- a/sources/sync/typesRaw.spec.ts +++ b/sources/sync/typesRaw.spec.ts @@ -1489,4 +1489,136 @@ describe('Zod Transform - WOLOG Content Normalization', () => { } }); }); + + describe('ACP tool result normalization', () => { + it('normalizes ACP tool-result output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-1', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-1', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('hello'); + } + } + }); + + it('normalizes ACP tool-call-result output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-call-result' as const, + callId: 'call_abc123', + output: [{ type: 'text', text: 'hello' }], + id: 'acp-msg-2', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-2', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('hello'); + } + } + }); + + it('normalizes ACP tool-result string output to text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: 'direct string', + id: 'acp-msg-3', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-3', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe('direct string'); + } + } + }); + + it('normalizes ACP tool-result object output to JSON text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: { key: 'value' }, + id: 'acp-msg-4', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-4', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe(JSON.stringify({ key: 'value' })); + } + } + }); + + it('normalizes ACP tool-result null output to empty text', () => { + const raw = { + role: 'agent' as const, + content: { + type: 'acp' as const, + provider: 'gemini' as const, + data: { + type: 'tool-result' as const, + callId: 'call_abc123', + output: null, + id: 'acp-msg-5', + }, + }, + }; + + const normalized = normalizeRawMessage('msg-5', null, Date.now(), raw); + expect(normalized?.role).toBe('agent'); + if (normalized && normalized.role === 'agent') { + const item = normalized.content[0]; + expect(item.type).toBe('tool-result'); + if (item.type === 'tool-result') { + expect(item.content).toBe(''); + } + } + }); + }); }); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index aa7b2ed82..b408a9053 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -47,7 +47,9 @@ export type RawToolUseContent = z.infer; const rawToolResultContentSchema = z.object({ type: z.literal('tool_result'), tool_use_id: z.string(), - content: z.union([z.array(z.object({ type: z.literal('text'), text: z.string() })), z.string()]), + // Tool results can be strings, Claude-style arrays of text blocks, or structured JSON (Codex/Gemini). + // We accept any here and normalize later for display. + content: z.any(), is_error: z.boolean().optional(), permissions: z.object({ date: z.number(), @@ -246,13 +248,13 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ oldContent: z.string().optional(), newContent: z.string().optional(), id: z.string() - }), + }).passthrough(), // Terminal/command output z.object({ type: z.literal('terminal-output'), data: z.string(), callId: z.string() - }), + }).passthrough(), // Task lifecycle events z.object({ type: z.literal('task_started'), id: z.string() }), z.object({ type: z.literal('task_complete'), id: z.string() }), @@ -264,7 +266,7 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ toolName: z.string(), description: z.string(), options: z.any().optional() - }), + }).passthrough(), // Usage/metrics z.object({ type: z.literal('token_count') }).passthrough() ]) @@ -402,13 +404,46 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA // Zod transform handles normalization during validation let parsed = rawRecordSchema.safeParse(raw); if (!parsed.success) { - console.error('=== VALIDATION ERROR ==='); - console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); - console.error('Raw message:', JSON.stringify(raw, null, 2)); - console.error('=== END ERROR ==='); + // Never log full raw messages in production: tool outputs and user text may contain secrets. + // Keep enough context for debugging in dev builds only. + console.error(`[typesRaw] Message validation failed (id=${id})`); + if (__DEV__) { + console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); + console.error('Raw summary:', { + role: raw?.role, + contentType: (raw as any)?.content?.type, + }); + } return null; } raw = parsed.data; + + const toolResultContentToText = (content: unknown): string => { + if (content === null || content === undefined) return ''; + if (typeof content === 'string') return content; + + // Claude sometimes sends tool_result.content as [{ type: 'text', text: '...' }] + if (Array.isArray(content)) { + const maybeTextBlocks = content as Array<{ type?: unknown; text?: unknown }>; + const isTextBlocks = maybeTextBlocks.every((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string'); + if (isTextBlocks) { + return maybeTextBlocks.map((b) => b.text as string).join(''); + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + }; + if (raw.role === 'user') { return { id, @@ -525,10 +560,11 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } else { for (let c of raw.content.data.message.content) { if (c.type === 'tool_result') { + const rawResultContent = raw.content.data.toolUseResult ?? c.content; content.push({ ...c, // WOLOG: Preserve all fields including unknown ones type: 'tool-result', - content: raw.content.data.toolUseResult ? raw.content.data.toolUseResult : (typeof c.content === 'string' ? c.content : c.content[0].text), + content: toolResultContentToText(rawResultContent), is_error: c.is_error || false, uuid: raw.content.data.uuid, parentUUID: raw.content.data.parentUuid ?? null, @@ -630,7 +666,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null @@ -702,7 +738,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: raw.content.data.isError ?? false, uuid: raw.content.data.id, parentUUID: null @@ -721,7 +757,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null @@ -815,4 +851,4 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } } return null; -} \ No newline at end of file +} From 8d1d69fa2a892c2933d6c4bee8a53d1209ba98df Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:42:53 +0100 Subject: [PATCH 09/38] refactor(sync): centralize outgoing message metadata --- sources/sync/messageMeta.test.ts | 54 ++++++++++++++++++++++++++ sources/sync/messageMeta.ts | 19 +++++++++ sources/sync/sync.ts | 66 ++++++-------------------------- 3 files changed, 84 insertions(+), 55 deletions(-) create mode 100644 sources/sync/messageMeta.test.ts create mode 100644 sources/sync/messageMeta.ts diff --git a/sources/sync/messageMeta.test.ts b/sources/sync/messageMeta.test.ts new file mode 100644 index 000000000..558485cc4 --- /dev/null +++ b/sources/sync/messageMeta.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { buildOutgoingMessageMeta } from './messageMeta'; + +describe('buildOutgoingMessageMeta', () => { + it('does not include model fields by default', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.sentFrom).toBe('web'); + expect(meta.permissionMode).toBe('default'); + expect(meta.appendSystemPrompt).toBe('PROMPT'); + expect('model' in meta).toBe(false); + expect('fallbackModel' in meta).toBe(false); + }); + + it('includes model when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + model: 'gemini-2.5-pro', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.model).toBe('gemini-2.5-pro'); + expect('model' in meta).toBe(true); + }); + + it('includes displayText when explicitly provided (including empty string)', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + displayText: '', + }); + + expect('displayText' in meta).toBe(true); + expect(meta.displayText).toBe(''); + }); + + it('includes fallbackModel when explicitly provided', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + fallbackModel: 'gemini-2.5-flash', + }); + + expect('fallbackModel' in meta).toBe(true); + expect(meta.fallbackModel).toBe('gemini-2.5-flash'); + }); +}); diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts new file mode 100644 index 000000000..d97b22055 --- /dev/null +++ b/sources/sync/messageMeta.ts @@ -0,0 +1,19 @@ +import type { MessageMeta } from './typesMessageMeta'; + +export function buildOutgoingMessageMeta(params: { + sentFrom: string; + permissionMode: NonNullable; + model?: MessageMeta['model']; + fallbackModel?: MessageMeta['fallbackModel']; + appendSystemPrompt: string; + displayText?: string; +}): MessageMeta { + return { + sentFrom: params.sentFrom, + permissionMode: params.permissionMode, + appendSystemPrompt: params.appendSystemPrompt, + ...(params.displayText !== undefined ? { displayText: params.displayText } : {}), + ...(params.model !== undefined ? { model: params.model } : {}), + ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}), + }; +} diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 5393a3651..e2c43a708 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -39,6 +39,7 @@ import { fetchFeed } from './apiFeed'; import { FeedItem } from './feedTypes'; import { UserProfile } from './friendTypes'; import { initializeTodoSync } from '../-zen/model/ops'; +import { buildOutgoingMessageMeta } from './messageMeta'; class Sync { // Spawned agents (especially in spawn mode) can take noticeable time to connect. @@ -251,14 +252,7 @@ class Sync { sentFrom = 'web'; // fallback } - // Model settings - for Gemini, we pass the selected model; for others, CLI handles it - let model: string | null = null; - if (isGemini && modelMode !== 'default') { - // For Gemini ACP, pass the selected model to CLI - model = modelMode; - } - const fallbackModel: string | null = null; - + const model = isGemini && modelMode !== 'default' ? modelMode : undefined; // Create user message content with metadata const content: RawRecord = { role: 'user', @@ -266,14 +260,13 @@ class Sync { type: 'text', text }, - meta: { + meta: buildOutgoingMessageMeta({ sentFrom, permissionMode: permissionMode || 'default', model, - fallbackModel, appendSystemPrompt: systemPrompt, - ...(displayText && { displayText }) // Add displayText if provided - } + displayText, + }) }; const encryptedRawRecord = await encryption.encryptRawRecord(content); @@ -843,7 +836,6 @@ class Sync { private fetchMachines = async () => { if (!this.credentials) return; - console.log('📊 Sync: Fetching machines...'); const API_ENDPOINT = getServerUrl(); const response = await fetch(`${API_ENDPOINT}/v1/machines`, { headers: { @@ -858,7 +850,6 @@ class Sync { } const data = await response.json(); - console.log(`📊 Sync: Fetched ${Array.isArray(data) ? data.length : 0} machines from server`); const machines = data as Array<{ id: string; metadata: string; @@ -1189,11 +1180,6 @@ class Sync { } // Log and retry - console.log('settings version-mismatch, retrying', { - serverVersion: data.currentVersion, - retry: retryCount + 1, - pendingKeys: Object.keys(this.pendingSettings) - }); retryCount++; continue; } else { @@ -1230,12 +1216,6 @@ class Sync { parsedSettings = { ...settingsDefaults }; } - // Log - console.log('settings', JSON.stringify({ - settings: parsedSettings, - version: data.settingsVersion - })); - // Apply settings to storage storage.getState().applySettings(parsedSettings, data.settingsVersion); @@ -1267,16 +1247,6 @@ class Sync { const data = await response.json(); const parsedProfile = profileParse(data); - // Log profile data for debugging - console.log('profile', JSON.stringify({ - id: parsedProfile.id, - timestamp: parsedProfile.timestamp, - firstName: parsedProfile.firstName, - lastName: parsedProfile.lastName, - hasAvatar: !!parsedProfile.avatar, - hasGitHub: !!parsedProfile.github - })); - // Apply profile to storage storage.getState().applyProfile(parsedProfile); } @@ -1314,12 +1284,11 @@ class Sync { }); if (!response.ok) { - console.log(`[fetchNativeUpdate] Request failed: ${response.status}`); + log.log(`[fetchNativeUpdate] Request failed: ${response.status}`); return; } const data = await response.json(); - console.log('[fetchNativeUpdate] Data:', data); // Apply update status to storage if (data.update_required && data.update_url) { @@ -1333,7 +1302,7 @@ class Sync { }); } } catch (error) { - console.log('[fetchNativeUpdate] Error:', error); + console.error('[fetchNativeUpdate] Error:', error); storage.getState().applyNativeUpdateStatus(null); } } @@ -1354,7 +1323,6 @@ class Sync { } if (!apiKey) { - console.log(`RevenueCat: No API key found for platform ${Platform.OS}`); return; } @@ -1371,7 +1339,6 @@ class Sync { }); this.revenueCatInitialized = true; - console.log('RevenueCat initialized successfully'); } // Sync purchases @@ -1438,9 +1405,6 @@ class Sync { } } } - console.log('Batch decrypted and normalized messages in', Date.now() - start, 'ms'); - console.log('normalizedMessages', JSON.stringify(normalizedMessages)); - // console.log('messages', JSON.stringify(normalizedMessages)); // Apply to storage this.applyMessages(sessionId, normalizedMessages); @@ -1467,7 +1431,7 @@ class Sync { log.log('finalStatus: ' + JSON.stringify(finalStatus)); if (finalStatus !== 'granted') { - console.log('Failed to get push token for push notification!'); + log.log('Failed to get push token for push notification!'); return; } @@ -1515,15 +1479,12 @@ class Sync { } private handleUpdate = async (update: unknown) => { - console.log('🔄 Sync: handleUpdate called with:', JSON.stringify(update).substring(0, 300)); const validatedUpdate = ApiUpdateContainerSchema.safeParse(update); if (!validatedUpdate.success) { - console.log('❌ Sync: Invalid update received:', validatedUpdate.error); console.error('❌ Sync: Invalid update data:', update); return; } const updateData = validatedUpdate.data; - console.log(`🔄 Sync: Validated update type: ${updateData.body.t}`); if (updateData.body.t === 'new-message') { @@ -1549,7 +1510,8 @@ class Sync { const dataType = rawContent?.content?.data?.type; // Debug logging to trace lifecycle events - if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + if (isDev && (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started')) { console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`); } @@ -1560,7 +1522,7 @@ class Sync { const isTaskStarted = ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'); - if (isTaskComplete || isTaskStarted) { + if (isDev && (isTaskComplete || isTaskStarted)) { console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`); } @@ -1582,7 +1544,6 @@ class Sync { // Update messages if (lastMessage) { - console.log('🔄 Sync: Applying message:', JSON.stringify(lastMessage)); this.applyMessages(updateData.body.sid, [lastMessage]); let hasMutableTool = false; if (lastMessage.role === 'agent' && lastMessage.content[0] && lastMessage.content[0].type === 'tool-result') { @@ -1968,7 +1929,6 @@ class Sync { } if (sessions.length > 0) { - // console.log('flushing activity updates ' + sessions.length); this.applySessions(sessions); // log.log(`🔄 Activity updates flushed - updated ${sessions.length} sessions`); } @@ -1977,17 +1937,13 @@ class Sync { private handleEphemeralUpdate = (update: unknown) => { const validatedUpdate = ApiEphemeralUpdateSchema.safeParse(update); if (!validatedUpdate.success) { - console.log('Invalid ephemeral update received:', validatedUpdate.error); console.error('Invalid ephemeral update received:', update); return; - } else { - // console.log('Ephemeral update received:', update); } const updateData = validatedUpdate.data; // Process activity updates through smart debounce accumulator if (updateData.type === 'activity') { - // console.log('adding activity update ' + updateData.id); this.activityAccumulator.addUpdate(updateData); } From bb67dfe891c28af714b8d920835de458b78ef4bd Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:43:30 +0100 Subject: [PATCH 10/38] feat(storage): persist session model modes --- sources/sync/serverConfig.ts | 5 +++- sources/sync/storage.ts | 52 +++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/sources/sync/serverConfig.ts b/sources/sync/serverConfig.ts index fedea04df..b52f452d0 100644 --- a/sources/sync/serverConfig.ts +++ b/sources/sync/serverConfig.ts @@ -1,7 +1,10 @@ import { MMKV } from 'react-native-mmkv'; +import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope'; // Separate MMKV instance for server config that persists across logouts -const serverConfigStorage = new MMKV({ id: 'server-config' }); +const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'; +const serverConfigScope = isWebRuntime ? null : readStorageScopeFromEnv(); +const serverConfigStorage = new MMKV({ id: scopedStorageId('server-config', serverConfigScope) }); const SERVER_KEY = 'custom-server-url'; const DEFAULT_SERVER_URL = 'https://api.cluster-fluster.com'; diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index 48e7ab771..83d5c716d 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -11,8 +11,8 @@ import { Purchases, customerInfoToPurchases } from "./purchases"; import { TodoState } from "../-zen/model/ops"; import { Profile } from "./profile"; import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; -import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence"; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionModelModes, saveSessionModelModes } from "./persistence"; +import type { PermissionMode } from '@/sync/permissionTypes'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; import { sync } from "./sync"; @@ -46,6 +46,8 @@ function isSessionActive(session: { active: boolean; activeAt: number }): boolea // Known entitlement IDs export type KnownEntitlements = 'pro'; +type SessionModelMode = NonNullable; + interface SessionMessages { messages: Message[]; messagesMap: Record; @@ -102,6 +104,7 @@ interface StorageState { applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[], hasReadyEvent: boolean }; applyMessagesLoaded: (sessionId: string) => void; applySettings: (settings: Settings, version: number) => void; + replaceSettings: (settings: Settings, version: number) => void; applySettingsLocal: (settings: Partial) => void; applyLocalSettings: (settings: Partial) => void; applyPurchases: (customerInfo: CustomerInfo) => void; @@ -250,6 +253,7 @@ export const storage = create()((set, get) => { let profile = loadProfile(); let sessionDrafts = loadSessionDrafts(); let sessionPermissionModes = loadSessionPermissionModes(); + let sessionModelModes = loadSessionModelModes(); return { settings, settingsVersion: version, @@ -303,6 +307,7 @@ export const storage = create()((set, get) => { // Load drafts and permission modes if sessions are empty (initial load) const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {}; const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {}; + const savedModelModes = Object.keys(state.sessions).length === 0 ? sessionModelModes : {}; // Merge new sessions with existing ones const mergedSessions: Record = { ...state.sessions }; @@ -317,11 +322,14 @@ export const storage = create()((set, get) => { const savedDraft = savedDrafts[session.id]; const existingPermissionMode = state.sessions[session.id]?.permissionMode; const savedPermissionMode = savedPermissionModes[session.id]; + const existingModelMode = state.sessions[session.id]?.modelMode; + const savedModelMode = savedModelModes[session.id]; mergedSessions[session.id] = { ...session, presence, draft: existingDraft || savedDraft || session.draft || null, - permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default' + permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default', + modelMode: existingModelMode || savedModelMode || session.modelMode || 'default', }; }); @@ -366,8 +374,6 @@ export const storage = create()((set, get) => { listData.push(...inactiveSessions); } - // console.log(`📊 Storage: applySessions called with ${sessions.length} sessions, active: ${activeSessions.length}, inactive: ${inactiveSessions.length}`); - // Process AgentState updates for sessions that already have messages loaded const updatedSessionMessages = { ...state.sessionMessages }; @@ -384,15 +390,6 @@ export const storage = create()((set, get) => { const currentRealtimeSessionId = getCurrentRealtimeSessionId(); const voiceSession = getVoiceSession(); - // console.log('[REALTIME DEBUG] Permission check:', { - // currentRealtimeSessionId, - // sessionId: session.id, - // match: currentRealtimeSessionId === session.id, - // hasVoiceSession: !!voiceSession, - // oldRequests: Object.keys(oldSession?.agentState?.requests || {}), - // newRequests: Object.keys(newSession.agentState?.requests || {}) - // }); - if (currentRealtimeSessionId === session.id && voiceSession) { const oldRequests = oldSession?.agentState?.requests || {}; const newRequests = newSession.agentState?.requests || {}; @@ -402,7 +399,6 @@ export const storage = create()((set, get) => { if (!oldRequests[requestId]) { // This is a NEW permission request const toolName = request.tool; - // console.log('[REALTIME DEBUG] Sending permission notification for:', toolName); voiceSession.sendTextMessage( `Claude is requesting permission to use the ${toolName} tool` ); @@ -629,7 +625,7 @@ export const storage = create()((set, get) => { }; }), applySettings: (settings: Settings, version: number) => set((state) => { - if (state.settingsVersion === null || state.settingsVersion < version) { + if (state.settingsVersion == null || state.settingsVersion < version) { saveSettings(settings, version); return { ...state, @@ -640,6 +636,14 @@ export const storage = create()((set, get) => { return state; } }), + replaceSettings: (settings: Settings, version: number) => set((state) => { + saveSettings(settings, version); + return { + ...state, + settings, + settingsVersion: version + }; + }), applyLocalSettings: (delta: Partial) => set((state) => { const updatedLocalSettings = applyLocalSettings(state.localSettings, delta); saveLocalSettings(updatedLocalSettings); @@ -821,6 +825,16 @@ export const storage = create()((set, get) => { } }; + // Collect all model modes for persistence (only non-default values to save space) + const allModes: Record = {}; + Object.entries(updatedSessions).forEach(([id, sess]) => { + if (sess.modelMode && sess.modelMode !== 'default') { + allModes[id] = sess.modelMode; + } + }); + + saveSessionModelModes(allModes); + // No need to rebuild sessionListViewData since model mode doesn't affect the list display return { ...state, @@ -871,12 +885,10 @@ export const storage = create()((set, get) => { }), // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => set((state) => { - console.log(`🗂️ Storage.applyArtifacts: Applying ${artifacts.length} artifacts`); const mergedArtifacts = { ...state.artifacts }; artifacts.forEach(artifact => { mergedArtifacts[artifact.id] = artifact; }); - console.log(`🗂️ Storage.applyArtifacts: Total artifacts after merge: ${Object.keys(mergedArtifacts).length}`); return { ...state, @@ -931,6 +943,10 @@ export const storage = create()((set, get) => { const modes = loadSessionPermissionModes(); delete modes[sessionId]; saveSessionPermissionModes(modes); + + const modelModes = loadSessionModelModes(); + delete modelModes[sessionId]; + saveSessionModelModes(modelModes); // Rebuild sessionListViewData without the deleted session const sessionListViewData = buildSessionListViewData(remainingSessions); From 9fc57136a9c2649aaafc0b1653b2e6d1c0d6e2d2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:43:50 +0100 Subject: [PATCH 11/38] fix(settings): make parsing tolerant for profiles --- sources/sync/settings.spec.ts | 78 ++++++++-- sources/sync/settings.ts | 268 ++++++++++++++++++---------------- sources/sync/sync.ts | 19 ++- 3 files changed, 224 insertions(+), 141 deletions(-) diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 4f36ce46f..1f38fef48 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -89,6 +89,37 @@ describe('settings', () => { } }); }); + + it('should migrate legacy provider config objects into environmentVariables', () => { + const settingsWithLegacyProfileConfig: any = { + profiles: [ + { + id: 'legacy-profile', + name: 'Legacy Profile', + isBuiltIn: false, + compatibility: { claude: true, codex: true, gemini: true }, + environmentVariables: [{ name: 'FOO', value: 'bar' }], + openaiConfig: { + apiKey: 'sk-test', + baseUrl: 'https://example.com', + model: 'gpt-test', + }, + }, + ], + }; + + const parsed = settingsParse(settingsWithLegacyProfileConfig); + expect(parsed.profiles).toHaveLength(1); + + const profile = parsed.profiles[0]!; + expect(profile.environmentVariables).toEqual(expect.arrayContaining([ + { name: 'FOO', value: 'bar' }, + { name: 'OPENAI_API_KEY', value: 'sk-test' }, + { name: 'OPENAI_BASE_URL', value: 'https://example.com' }, + { name: 'OPENAI_MODEL', value: 'gpt-test' }, + ])); + expect((profile as any).openaiConfig).toBeUndefined(); + }); }); describe('applySettings', () => { @@ -103,7 +134,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -122,6 +157,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -137,7 +173,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -156,6 +196,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); @@ -171,7 +212,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -190,6 +235,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = {}; @@ -207,7 +253,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -226,6 +276,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -248,7 +299,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -267,6 +322,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; expect(applySettings(currentSettings, {})).toEqual(currentSettings); @@ -298,7 +354,11 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -317,6 +377,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: any = { @@ -360,8 +421,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, alwaysShowContextSize: false, - avatarStyle: 'brutalist', + useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, + avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, agentInputEnterToSend: true, @@ -376,10 +442,10 @@ describe('settings', () => { lastUsedModelMode: null, profiles: [], lastUsedProfile: null, - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, - useEnhancedSessionWizard: false, }); }); @@ -560,7 +626,6 @@ describe('settings', () => { { id: 'server-profile', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -578,7 +643,6 @@ describe('settings', () => { { id: 'local-profile', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -680,7 +744,6 @@ describe('settings', () => { profiles: [{ id: 'test-profile', name: 'Test', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -713,7 +776,6 @@ describe('settings', () => { profiles: [{ id: 'device-b-profile', name: 'Device B Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -825,7 +887,6 @@ describe('settings', () => { profiles: [{ id: 'server-profile-1', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -844,7 +905,6 @@ describe('settings', () => { profiles: [{ id: 'local-profile-1', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 5746c863d..c42eb8391 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -4,77 +4,10 @@ import * as z from 'zod'; // Configuration Profile Schema (for environment variable profiles) // -// Environment variable schemas for different AI providers -// Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings -const AnthropicConfigSchema = z.object({ - baseUrl: z.string().refine( - (val) => { - if (!val) return true; // Optional - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - // Otherwise validate as URL - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - authToken: z.string().optional(), - model: z.string().optional(), -}); - -const OpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - baseUrl: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - model: z.string().optional(), -}); - -const AzureOpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - endpoint: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), - apiVersion: z.string().optional(), - deploymentName: z.string().optional(), -}); - -const TogetherAIConfigSchema = z.object({ - apiKey: z.string().optional(), - model: z.string().optional(), -}); - // Tmux configuration schema const TmuxConfigSchema = z.object({ sessionName: z.string().optional(), tmpDir: z.string().optional(), - updateEnvironment: z.boolean().optional(), }); // Environment variables schema with validation @@ -97,18 +30,9 @@ export const AIBackendProfileSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - // Agent-specific configurations - anthropicConfig: AnthropicConfigSchema.optional(), - openaiConfig: OpenAIConfigSchema.optional(), - azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), - togetherAIConfig: TogetherAIConfigSchema.optional(), - // Tmux configuration tmuxConfig: TmuxConfigSchema.optional(), - // Startup bash script (executed before spawning session) - startupBashScript: z.string().optional(), - // Environment variables (validated) environmentVariables: z.array(EnvironmentVariableSchema).default([]), @@ -140,6 +64,61 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud return profile.compatibility[agent]; } +function mergeEnvironmentVariables( + existing: unknown, + additions: Record +): Array<{ name: string; value: string }> { + const map = new Map(); + + if (Array.isArray(existing)) { + for (const entry of existing) { + if (!entry || typeof entry !== 'object') continue; + const name = (entry as any).name; + const value = (entry as any).value; + if (typeof name !== 'string' || typeof value !== 'string') continue; + map.set(name, value); + } + } + + for (const [name, value] of Object.entries(additions)) { + if (typeof value !== 'string') continue; + if (!map.has(name)) { + map.set(name, value); + } + } + + return Array.from(map.entries()).map(([name, value]) => ({ name, value })); +} + +function normalizeLegacyProfileConfig(profile: unknown): unknown { + if (!profile || typeof profile !== 'object') return profile; + + const raw = profile as Record; + const additions: Record = { + ANTHROPIC_BASE_URL: raw.anthropicConfig?.baseUrl, + ANTHROPIC_AUTH_TOKEN: raw.anthropicConfig?.authToken, + ANTHROPIC_MODEL: raw.anthropicConfig?.model, + OPENAI_API_KEY: raw.openaiConfig?.apiKey, + OPENAI_BASE_URL: raw.openaiConfig?.baseUrl, + OPENAI_MODEL: raw.openaiConfig?.model, + AZURE_OPENAI_API_KEY: raw.azureOpenAIConfig?.apiKey, + AZURE_OPENAI_ENDPOINT: raw.azureOpenAIConfig?.endpoint, + AZURE_OPENAI_API_VERSION: raw.azureOpenAIConfig?.apiVersion, + AZURE_OPENAI_DEPLOYMENT_NAME: raw.azureOpenAIConfig?.deploymentName, + TOGETHER_API_KEY: raw.togetherAIConfig?.apiKey, + TOGETHER_MODEL: raw.togetherAIConfig?.model, + }; + + const environmentVariables = mergeEnvironmentVariables(raw.environmentVariables, additions); + + // Remove legacy provider config objects. Any values are preserved via environmentVariables migration above. + const { anthropicConfig, openaiConfig, azureOpenAIConfig, togetherAIConfig, ...rest } = raw; + return { + ...rest, + environmentVariables, + }; +} + /** * Converts a profile into environment variables for session spawning. * @@ -157,8 +136,8 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * Sent: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} (literal string with placeholder) * * 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session: - * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching - * - Non-tmux mode: Node.js spawn with env: { ...process.env, ...profileEnvVars } (shell expansion in child) + * - Tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before launching (shells do not expand placeholders inside env values automatically) + * - Non-tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before calling spawn() (Node does not expand placeholders) * * 5. SESSION RECEIVES actual expanded values: * ANTHROPIC_AUTH_TOKEN=sk-real-key (expanded from daemon's Z_AI_AUTH_TOKEN, not literal ${Z_AI_AUTH_TOKEN}) @@ -172,7 +151,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * - Each session uses its selected backend for its entire lifetime (no mid-session switching) * - Keep secrets in shell environment, not in GUI/profile storage * - * PRIORITY ORDER when spawning (daemon/run.ts): + * PRIORITY ORDER when spawning: * Final env = { ...daemon.process.env, ...expandedProfileVars, ...authVars } * authVars override profile, profile overrides daemon.process.env */ @@ -184,43 +163,12 @@ export function getProfileEnvironmentVariables(profile: AIBackendProfile): Recor envVars[envVar.name] = envVar.value; }); - // Add Anthropic config - if (profile.anthropicConfig) { - if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; - if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; - if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; - } - - // Add OpenAI config - if (profile.openaiConfig) { - if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; - if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; - if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; - } - - // Add Azure OpenAI config - if (profile.azureOpenAIConfig) { - if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; - if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; - if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; - if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; - } - - // Add Together AI config - if (profile.togetherAIConfig) { - if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; - if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; - } - // Add Tmux config if (profile.tmuxConfig) { // Empty string means "use current/most recent session", so include it if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; // Empty string may be valid for tmpDir to use tmux defaults if (profile.tmuxConfig.tmpDir !== undefined) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; - if (profile.tmuxConfig.updateEnvironment !== undefined) { - envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); - } } return envVars; @@ -249,6 +197,8 @@ export function isProfileVersionCompatible(profileVersion: string, requiredVersi // // Current schema version for backward compatibility +// NOTE: This schemaVersion is for the Happy app's settings blob (synced via the server). +// happy-cli maintains its own local settings schemaVersion separately. export const SUPPORTED_SCHEMA_VERSION = 2; export const SettingsSchema = z.object({ @@ -263,7 +213,12 @@ export const SettingsSchema = z.object({ wrapLinesInDiffs: z.boolean().describe('Whether to wrap long lines in diff views'), analyticsOptOut: z.boolean().describe('Whether to opt out of anonymous analytics'), experiments: z.boolean().describe('Whether to enable experimental features'), + useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), + // Legacy combined toggle (kept for backward compatibility; see settingsParse migration) + usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs (legacy combined toggle)'), + useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), + usePathPickerSearch: z.boolean().describe('Whether to show search in path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), avatarStyle: z.string().describe('Avatar display style'), @@ -288,6 +243,8 @@ export const SettingsSchema = z.object({ favoriteDirectories: z.array(z.string()).describe('User-defined favorite directories for quick access in path selection'), // Favorite machines for quick machine selection favoriteMachines: z.array(z.string()).describe('User-defined favorite machines (machine IDs) for quick access in machine selection'), + // Favorite profiles for quick profile selection (built-in or custom profile IDs) + favoriteProfiles: z.array(z.string()).describe('User-defined favorite profiles (profile IDs) for quick access in profile selection'), // Dismissed CLI warning banners (supports both per-machine and global dismissal) dismissedCLIWarnings: z.object({ perMachine: z.record(z.string(), z.object({ @@ -332,7 +289,11 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -350,10 +311,12 @@ export const settingsDefaults: Settings = { // Profile management defaults profiles: [], lastUsedProfile: null, - // Default favorite directories (real common directories on Unix-like systems) - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + // Favorite directories (empty by default) + favoriteDirectories: [], // Favorite machines (empty by default) favoriteMachines: [], + // Favorite profiles (empty by default) + favoriteProfiles: [], // Dismissed CLI warnings (empty by default) dismissedCLIWarnings: { perMachine: {}, global: {} }, }; @@ -369,28 +332,75 @@ export function settingsParse(settings: unknown): Settings { return { ...settingsDefaults }; } - const parsed = SettingsSchemaPartial.safeParse(settings); - if (!parsed.success) { - // For invalid settings, preserve unknown fields but use defaults for known fields - const unknownFields = { ...(settings as any) }; - // Remove all known schema fields from unknownFields - const knownFields = Object.keys(SettingsSchema.shape); - knownFields.forEach(key => delete unknownFields[key]); - return { ...settingsDefaults, ...unknownFields }; - } + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + + // IMPORTANT: be tolerant of partially-invalid settings objects. + // A single invalid field (e.g. one malformed profile) must not reset all other known settings to defaults. + const input = settings as Record; + const result: any = { ...settingsDefaults }; + + // Parse known fields individually to avoid whole-object failure. + (Object.keys(SettingsSchema.shape) as Array).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(input, key)) return; + + // Special-case profiles: validate per profile entry, keep valid ones. + if (key === 'profiles') { + const profilesValue = input[key]; + if (Array.isArray(profilesValue)) { + const parsedProfiles: AIBackendProfile[] = []; + for (const rawProfile of profilesValue) { + const parsedProfile = AIBackendProfileSchema.safeParse(normalizeLegacyProfileConfig(rawProfile)); + if (parsedProfile.success) { + parsedProfiles.push(parsedProfile.data); + } else if (isDev) { + console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues); + } + } + result.profiles = parsedProfiles; + } + return; + } + + const schema = SettingsSchema.shape[key]; + const parsedField = schema.safeParse(input[key]); + if (parsedField.success) { + result[key] = parsedField.data; + } else if (isDev) { + console.warn(`[settingsParse] Invalid settings field "${String(key)}" - using default`, parsedField.error.issues); + } + }); // Migration: Convert old 'zh' language code to 'zh-Hans' - if (parsed.data.preferredLanguage === 'zh') { - console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); - parsed.data.preferredLanguage = 'zh-Hans'; + if (result.preferredLanguage === 'zh') { + result.preferredLanguage = 'zh-Hans'; } - // Merge defaults, parsed settings, and preserve unknown fields - const unknownFields = { ...(settings as any) }; - // Remove known fields from unknownFields to preserve only the unknown ones - Object.keys(parsed.data).forEach(key => delete unknownFields[key]); + // Migration: Convert legacy combined picker-search toggle into per-picker toggles. + // Only apply if new fields were not present in persisted settings. + const hasMachineSearch = 'useMachinePickerSearch' in input; + const hasPathSearch = 'usePathPickerSearch' in input; + if (!hasMachineSearch && !hasPathSearch) { + const legacy = SettingsSchema.shape.usePickerSearch.safeParse(input.usePickerSearch); + if (legacy.success && legacy.data === true) { + result.useMachinePickerSearch = true; + result.usePathPickerSearch = true; + } + } + + // Preserve unknown fields (forward compatibility). + for (const [key, value] of Object.entries(input)) { + if (key === '__proto__') continue; + if (!Object.prototype.hasOwnProperty.call(SettingsSchema.shape, key)) { + Object.defineProperty(result, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + } - return { ...settingsDefaults, ...parsed.data, ...unknownFields }; + return result as Settings; } // diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index e2c43a708..6234a2a1d 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1131,6 +1131,7 @@ class Sync { const API_ENDPOINT = getServerUrl(); const maxRetries = 3; let retryCount = 0; + let lastVersionMismatch: { expectedVersion: number; currentVersion: number; pendingKeys: string[] } | null = null; // Apply pending settings if (Object.keys(this.pendingSettings).length > 0) { @@ -1163,6 +1164,11 @@ class Sync { break; } if (data.error === 'version-mismatch') { + lastVersionMismatch = { + expectedVersion: version ?? 0, + currentVersion: data.currentVersion, + pendingKeys: Object.keys(this.pendingSettings).sort(), + }; // Parse server settings const serverSettings = data.currentSettings ? settingsParse(await this.encryption.decryptRaw(data.currentSettings)) @@ -1171,8 +1177,12 @@ class Sync { // Merge: server base + our pending changes (our changes win) const mergedSettings = applySettings(serverSettings, this.pendingSettings); - // Update local storage with merged result at server's version - storage.getState().applySettings(mergedSettings, data.currentVersion); + // Update local storage with merged result at server's version. + // + // Important: `data.currentVersion` can be LOWER than our local `settingsVersion` + // (e.g. when switching accounts/servers, or after server-side reset). If we only + // "apply when newer", we'd never converge and would retry forever. + storage.getState().replaceSettings(mergedSettings, data.currentVersion); // Sync tracking state with merged settings if (tracking) { @@ -1190,7 +1200,10 @@ class Sync { // 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`); + const mismatchHint = lastVersionMismatch + ? ` (expected=${lastVersionMismatch.expectedVersion}, current=${lastVersionMismatch.currentVersion}, pendingKeys=${lastVersionMismatch.pendingKeys.join(',')})` + : ''; + throw new Error(`Settings sync failed after ${maxRetries} retries due to version conflicts${mismatchHint}`); } // Run request From 74f486020906f14554df8a98b2e7c561d113ce73 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:44:00 +0100 Subject: [PATCH 12/38] fix(dev): gate CLI detection logging --- sources/hooks/useCLIDetection.ts | 17 +++++++---- sources/realtime/RealtimeVoiceSession.tsx | 19 +++++++----- sources/realtime/RealtimeVoiceSession.web.tsx | 29 +++++++++++-------- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts index bda5c547b..7a839a9ae 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -1,6 +1,13 @@ import { useState, useEffect } from 'react'; import { machineBash } from '@/sync/ops'; +function debugLog(...args: unknown[]) { + if (__DEV__) { + // eslint-disable-next-line no-console + console.log(...args); + } +} + interface CLIAvailability { claude: boolean | null; // null = unknown/loading, true = installed, false = not installed codex: boolean | null; @@ -52,7 +59,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { const detectCLIs = async () => { // Set detecting flag (non-blocking - UI stays responsive) setAvailability(prev => ({ ...prev, isDetecting: true })); - console.log('[useCLIDetection] Starting detection for machineId:', machineId); + debugLog('[useCLIDetection] Starting detection for machineId:', machineId); try { // Use single bash command to check both CLIs efficiently @@ -66,7 +73,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { ); if (cancelled) return; - console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); + debugLog('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); if (result.success && result.exitCode === 0) { // Parse output: "claude:true\ncodex:false\ngemini:false" @@ -80,7 +87,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { } }); - console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + debugLog('[useCLIDetection] Parsed CLI status:', cliStatus); setAvailability({ claude: cliStatus.claude ?? null, codex: cliStatus.codex ?? null, @@ -90,7 +97,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { }); } else { // Detection command failed - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); + debugLog('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); setAvailability({ claude: null, codex: null, @@ -104,7 +111,7 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { if (cancelled) return; // Network/RPC error - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Network/RPC error:', error); + debugLog('[useCLIDetection] Network/RPC error:', error); setAvailability({ claude: null, codex: null, diff --git a/sources/realtime/RealtimeVoiceSession.tsx b/sources/realtime/RealtimeVoiceSession.tsx index da558e1ec..71445ca04 100644 --- a/sources/realtime/RealtimeVoiceSession.tsx +++ b/sources/realtime/RealtimeVoiceSession.tsx @@ -9,6 +9,11 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { @@ -93,18 +98,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: (data) => { - console.log('Realtime session connected:', data); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -116,10 +121,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -129,7 +134,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -157,4 +162,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/sources/realtime/RealtimeVoiceSession.web.tsx b/sources/realtime/RealtimeVoiceSession.web.tsx index 54edb4672..1aa82a06d 100644 --- a/sources/realtime/RealtimeVoiceSession.web.tsx +++ b/sources/realtime/RealtimeVoiceSession.web.tsx @@ -9,11 +9,16 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { async startSession(config: VoiceSessionConfig): Promise { - console.log('[RealtimeVoiceSessionImpl] conversationInstance:', conversationInstance); + debugLog('[RealtimeVoiceSessionImpl] startSession'); if (!conversationInstance) { console.warn('Realtime voice session not initialized - conversationInstance is null'); return; @@ -55,7 +60,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { const conversationId = await conversationInstance.startSession(sessionConfig); - console.log('Started conversation with ID:', conversationId); + debugLog('Started conversation'); } catch (error) { console.error('Failed to start realtime session:', error); storage.getState().setRealtimeStatus('error'); @@ -98,18 +103,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: () => { - console.log('Realtime session connected'); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -121,10 +126,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -134,7 +139,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -142,16 +147,16 @@ export const RealtimeVoiceSession: React.FC = () => { useEffect(() => { // Store the conversation instance globally - console.log('[RealtimeVoiceSession] Setting conversationInstance:', conversation); + debugLog('[RealtimeVoiceSession] Setting conversationInstance'); conversationInstance = conversation; // Register the voice session once if (!hasRegistered.current) { try { - console.log('[RealtimeVoiceSession] Registering voice session'); + debugLog('[RealtimeVoiceSession] Registering voice session'); registerVoiceSession(new RealtimeVoiceSessionImpl()); hasRegistered.current = true; - console.log('[RealtimeVoiceSession] Voice session registered successfully'); + debugLog('[RealtimeVoiceSession] Voice session registered successfully'); } catch (error) { console.error('Failed to register voice session:', error); } @@ -165,4 +170,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; From 4866acd42fcde62aaf6a9487c59e7b05efe986f7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:44:04 +0100 Subject: [PATCH 13/38] fix(command-palette): include navigate dependency --- .../components/CommandPalette/CommandPaletteProvider.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sources/components/CommandPalette/CommandPaletteProvider.tsx b/sources/components/CommandPalette/CommandPaletteProvider.tsx index 558241472..748250c28 100644 --- a/sources/components/CommandPalette/CommandPaletteProvider.tsx +++ b/sources/components/CommandPalette/CommandPaletteProvider.tsx @@ -121,7 +121,7 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode } return cmds; - }, [router, logout, sessions]); + }, [router, logout, sessions, navigateToSession]); const showCommandPalette = useCallback(() => { if (Platform.OS !== 'web' || !commandPaletteEnabled) return; @@ -131,11 +131,11 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode props: { commands, } - } as any); + }); }, [commands, commandPaletteEnabled]); // Set up global keyboard handler only if feature is enabled useGlobalKeyboard(commandPaletteEnabled ? showCommandPalette : () => {}); return <>{children}; -} \ No newline at end of file +} From 87bcfc1e49390f6f8ead517c6dd310982a9a3e3d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:44:20 +0100 Subject: [PATCH 14/38] feat(env): add env var template parsing --- sources/hooks/envVarUtils.ts | 20 ++++++++------ sources/utils/envVarTemplate.test.ts | 31 +++++++++++++++++++++ sources/utils/envVarTemplate.ts | 40 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 sources/utils/envVarTemplate.test.ts create mode 100644 sources/utils/envVarTemplate.ts diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index 325404655..e839a6b10 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -32,23 +32,27 @@ export function resolveEnvVarSubstitution( value: string, daemonEnv: EnvironmentVariables ): string | null { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset). // Group 1: Variable name (required) - // Group 2: Default value (optional) - includes the :- or := prefix - // Group 3: The actual default value without prefix (optional) - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-(.*))?(:=(.*))?}$/); + // Group 2: Default value (optional) + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=](.*))?\}$/); if (match) { const varName = match[1]; - const defaultValue = match[3] ?? match[5]; // :- default or := default + const defaultValue = match[2]; // :- default const daemonValue = daemonEnv[varName]; - if (daemonValue !== undefined && daemonValue !== null) { + // For ${VAR:-default} and ${VAR:=default}, treat empty string as "missing" (bash semantics). + // For plain ${VAR}, preserve empty string (it is an explicit value). + if (daemonValue !== undefined && daemonValue !== null && daemonValue !== '') { return daemonValue; } // Variable not set - use default if provided if (defaultValue !== undefined) { return defaultValue; } + if (daemonValue === '') { + return ''; + } return null; } // Not a substitution - return literal value @@ -76,9 +80,9 @@ export function extractEnvVarReferences( const refs = new Set(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR}, ${VAR:-default}, or ${VAR:=default} (bash parameter expansion subset). // Only capture the variable name, not the default value - const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*|:=.*)?\}$/); + const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=].*)?\}$/); if (match) { // Variable name is already validated by regex pattern [A-Z_][A-Z0-9_]* refs.add(match[1]); diff --git a/sources/utils/envVarTemplate.test.ts b/sources/utils/envVarTemplate.test.ts new file mode 100644 index 000000000..52ca30646 --- /dev/null +++ b/sources/utils/envVarTemplate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from './envVarTemplate'; + +describe('envVarTemplate', () => { + it('preserves := operator during parse/format round-trip', () => { + const input = '${FOO:=bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':=', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('preserves :- operator during parse/format round-trip', () => { + const input = '${FOO:-bar}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':-', fallback: 'bar' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('round-trips templates without a fallback', () => { + const input = '${FOO}'; + const parsed = parseEnvVarTemplate(input); + expect(parsed).toEqual({ sourceVar: 'FOO', operator: null, fallback: '' }); + expect(formatEnvVarTemplate(parsed!)).toBe(input); + }); + + it('formats an empty fallback when operator is explicitly provided', () => { + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':=', fallback: '' })).toBe('${FOO:=}'); + expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':-', fallback: '' })).toBe('${FOO:-}'); + }); +}); + diff --git a/sources/utils/envVarTemplate.ts b/sources/utils/envVarTemplate.ts new file mode 100644 index 000000000..493ca41eb --- /dev/null +++ b/sources/utils/envVarTemplate.ts @@ -0,0 +1,40 @@ +export type EnvVarTemplateOperator = ':-' | ':='; + +export type EnvVarTemplate = Readonly<{ + sourceVar: string; + fallback: string; + operator: EnvVarTemplateOperator | null; +}>; + +export function parseEnvVarTemplate(value: string): EnvVarTemplate | null { + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-|:=)(.*)\}$/); + if (withFallback) { + return { + sourceVar: withFallback[1], + operator: withFallback[2] as EnvVarTemplateOperator, + fallback: withFallback[3], + }; + } + + const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + if (noFallback) { + return { + sourceVar: noFallback[1], + operator: null, + fallback: '', + }; + } + + return null; +} + +export function formatEnvVarTemplate(params: { + sourceVar: string; + fallback: string; + operator?: EnvVarTemplateOperator | null; +}): string { + const operator: EnvVarTemplateOperator | null = params.operator ?? (params.fallback !== '' ? ':-' : null); + const suffix = operator ? `${operator}${params.fallback}` : ''; + return `\${${params.sourceVar}${suffix}}`; +} + From 89d6cf512f5efa090cd87ede5d769cb7a28b253d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:44:30 +0100 Subject: [PATCH 15/38] fix(env): improve remote env resolution and previews --- sources/hooks/useEnvironmentVariables.test.ts | 4 + sources/hooks/useEnvironmentVariables.ts | 218 ++++++++++++++++-- sources/sync/ops.ts | 142 +++++++++++- sources/sync/settings.ts | 5 + 4 files changed, 343 insertions(+), 26 deletions(-) diff --git a/sources/hooks/useEnvironmentVariables.test.ts b/sources/hooks/useEnvironmentVariables.test.ts index e1bae6d24..45a978263 100644 --- a/sources/hooks/useEnvironmentVariables.test.ts +++ b/sources/hooks/useEnvironmentVariables.test.ts @@ -89,6 +89,10 @@ describe('resolveEnvVarSubstitution', () => { expect(resolveEnvVarSubstitution('${VAR:-fallback}', envWithNull)).toBe('fallback'); }); + it('returns default when VAR is empty string in ${VAR:-default}', () => { + expect(resolveEnvVarSubstitution('${EMPTY:-fallback}', daemonEnv)).toBe('fallback'); + }); + it('returns literal for non-substitution values', () => { expect(resolveEnvVarSubstitution('literal-value', daemonEnv)).toBe('literal-value'); }); diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 568bb0583..55160bcaf 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -1,18 +1,37 @@ import { useState, useEffect, useMemo } from 'react'; -import { machineBash } from '@/sync/ops'; +import { machineBash, machinePreviewEnv, type EnvPreviewSecretsPolicy, type PreviewEnvValue } from '@/sync/ops'; // Re-export pure utility functions from envVarUtils for backwards compatibility export { resolveEnvVarSubstitution, extractEnvVarReferences } from './envVarUtils'; +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + interface EnvironmentVariables { [varName: string]: string | null; // null = variable not set in daemon environment } interface UseEnvironmentVariablesResult { variables: EnvironmentVariables; + meta: Record; + policy: EnvPreviewSecretsPolicy | null; + isPreviewEnvSupported: boolean; isLoading: boolean; } +interface UseEnvironmentVariablesOptions { + /** + * When provided, the daemon will compute an effective spawn environment: + * effective = { ...daemon.process.env, ...expand(extraEnv) } + * This makes previews exactly match what sessions will receive. + */ + extraEnv?: Record; + /** + * Marks variables as sensitive (at minimum). The daemon may also treat vars as sensitive + * based on name heuristics (TOKEN/KEY/etc). + */ + sensitiveKeys?: string[]; +} + /** * Queries environment variable values from the daemon's process environment. * @@ -36,18 +55,33 @@ interface UseEnvironmentVariablesResult { */ export function useEnvironmentVariables( machineId: string | null, - varNames: string[] + varNames: string[], + options?: UseEnvironmentVariablesOptions ): UseEnvironmentVariablesResult { const [variables, setVariables] = useState({}); + const [meta, setMeta] = useState>({}); + const [policy, setPolicy] = useState(null); + const [isPreviewEnvSupported, setIsPreviewEnvSupported] = useState(false); const [isLoading, setIsLoading] = useState(false); // Memoize sorted var names for stable dependency (avoid unnecessary re-queries) const sortedVarNames = useMemo(() => [...varNames].sort().join(','), [varNames]); + const extraEnvKey = useMemo(() => { + const entries = Object.entries(options?.extraEnv ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.extraEnv]); + const sensitiveKeysKey = useMemo(() => { + const entries = [...(options?.sensitiveKeys ?? [])].sort((a, b) => a.localeCompare(b)); + return JSON.stringify(entries); + }, [options?.sensitiveKeys]); useEffect(() => { // Early exit conditions if (!machineId || varNames.length === 0) { setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } @@ -57,6 +91,7 @@ export function useEnvironmentVariables( const fetchVars = async () => { const results: EnvironmentVariables = {}; + const metaResults: Record = {}; // SECURITY: Validate all variable names to prevent bash injection // Only accept valid environment variable names: [A-Z_][A-Z0-9_]* @@ -65,43 +100,168 @@ export function useEnvironmentVariables( if (validVarNames.length === 0) { // No valid variables to query setVariables({}); + setMeta({}); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); return; } - // Build batched command: query all variables in single bash invocation - // Format: echo "VAR1=$VAR1" && echo "VAR2=$VAR2" && ... - // Using echo with variable expansion ensures we get daemon's environment - const command = validVarNames - .map(name => `echo "${name}=$${name}"`) - .join(' && '); + // Prefer daemon-native env preview if supported (more accurate + supports secret policy). + const preview = await machinePreviewEnv(machineId, { + keys: validVarNames, + extraEnv: options?.extraEnv, + sensitiveKeys: options?.sensitiveKeys, + }); + + if (cancelled) return; + + if (preview.supported) { + const response = preview.response; + validVarNames.forEach((name) => { + const entry = response.values[name]; + if (entry) { + metaResults[name] = entry; + results[name] = entry.value; + } else { + // Defensive fallback: treat as unset. + metaResults[name] = { + value: null, + isSet: false, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: 'unset', + }; + results[name] = null; + } + }); + + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(response.policy); + setIsPreviewEnvSupported(true); + setIsLoading(false); + } + return; + } + + // Fallback (older daemon): use bash probing for non-sensitive variables only. + // Never fetch secret-like values into UI memory via bash. + const sensitiveKeysSet = new Set(options?.sensitiveKeys ?? []); + const safeVarNames = validVarNames.filter((name) => !SECRET_NAME_REGEX.test(name) && !sensitiveKeysSet.has(name)); + + // Mark excluded keys as hidden (conservative). + validVarNames.forEach((name) => { + if (safeVarNames.includes(name)) return; + const isForcedSensitive = SECRET_NAME_REGEX.test(name); + metaResults[name] = { + value: null, + isSet: true, + isSensitive: true, + isForcedSensitive, + sensitivitySource: isForcedSensitive ? 'forced' : 'hinted', + display: 'hidden', + }; + results[name] = null; + }); + + // Query variables in a single machineBash() call. + // + // IMPORTANT: This runs inside the daemon process environment on the machine, because the + // RPC handler executes commands using Node's `exec()` without overriding `env`. + // That means this matches what `${VAR}` expansion uses when spawning sessions on the daemon + // (see happy-cli: expandEnvironmentVariables(..., process.env)). + // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty. + // Fallback to bash-only output if node isn't available. + const nodeScript = [ + // node -e sets argv[1] to "-e", so args start at argv[2] + "const keys = process.argv.slice(2);", + "const out = {};", + "for (const k of keys) {", + " out[k] = Object.prototype.hasOwnProperty.call(process.env, k) ? process.env[k] : null;", + "}", + "process.stdout.write(JSON.stringify(out));", + ].join(""); + const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${safeVarNames.join(' ')}`; + // Shell fallback uses `printenv` to distinguish unset vs empty via exit code. + // Note: values containing newlines may not round-trip here; the node/JSON path preserves them. + const shellFallback = [ + `for name in ${safeVarNames.join(' ')}; do`, + `if printenv "$name" >/dev/null 2>&1; then`, + `printf "%s=%s\\n" "$name" "$(printenv "$name")";`, + `else`, + `printf "%s=__HAPPY_UNSET__\\n" "$name";`, + `fi;`, + `done`, + ].join(' '); + + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; try { + if (safeVarNames.length === 0) { + if (!cancelled) { + setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); + setIsLoading(false); + } + return; + } + const result = await machineBash(machineId, command, '/'); if (cancelled) return; if (result.success && result.exitCode === 0) { - // Parse output: "VAR1=value1\nVAR2=value2\nVAR3=" - const lines = result.stdout.trim().split('\n'); - lines.forEach(line => { - const equalsIndex = line.indexOf('='); - if (equalsIndex !== -1) { - const name = line.substring(0, equalsIndex); - const value = line.substring(equalsIndex + 1); - results[name] = value || null; // Empty string → null (not set) + const stdout = result.stdout; + + // JSON protocol: {"VAR":"value","MISSING":null} + // Be resilient to any stray output (log lines, warnings) by extracting the last JSON object. + let parsedJson = false; + const trimmed = stdout.trim(); + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1); + try { + const parsed = JSON.parse(jsonSlice) as Record; + safeVarNames.forEach((name) => { + results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; + }); + parsedJson = true; + } catch { + // Fall through to line parser if JSON is malformed. } - }); + } + + // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__" + if (!parsedJson) { + // Do not trim each line: it can corrupt values with meaningful whitespace. + const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0); + lines.forEach((line) => { + // Ignore unrelated output (warnings, prompts, etc). + if (!/^[A-Z_][A-Z0-9_]*=/.test(line)) return; + const equalsIndex = line.indexOf('='); + if (equalsIndex !== -1) { + const name = line.substring(0, equalsIndex); + const value = line.substring(equalsIndex + 1); + results[name] = value === '__HAPPY_UNSET__' ? null : value; + } + }); + } // Ensure all requested variables have entries (even if missing from output) - validVarNames.forEach(name => { + safeVarNames.forEach(name => { if (!(name in results)) { results[name] = null; } }); } else { // Bash command failed - mark all variables as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } @@ -109,13 +269,27 @@ export function useEnvironmentVariables( if (cancelled) return; // RPC error (network, encryption, etc.) - mark all as not set - validVarNames.forEach(name => { + safeVarNames.forEach(name => { results[name] = null; }); } if (!cancelled) { + safeVarNames.forEach((name) => { + const value = results[name]; + metaResults[name] = { + value, + isSet: value !== null, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: value === null ? 'unset' : 'full', + }; + }); setVariables(results); + setMeta(metaResults); + setPolicy(null); + setIsPreviewEnvSupported(false); setIsLoading(false); } }; @@ -126,7 +300,7 @@ export function useEnvironmentVariables( return () => { cancelled = true; }; - }, [machineId, sortedVarNames]); + }, [extraEnvKey, machineId, sensitiveKeysKey, sortedVarNames]); - return { variables, isLoading }; + return { variables, meta, policy, isPreviewEnvSupported, isLoading }; } diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index 07f70e694..acf01c9e4 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -139,6 +139,8 @@ export interface SpawnSessionOptions { approvedNewDirectoryCreation?: boolean; token?: string; agent?: 'codex' | 'claude' | 'gemini'; + // Session-scoped profile identity (non-secret). Empty string means "no profile". + profileId?: string; // Environment variables from AI backend profile // Accepts any environment variables - daemon will pass them to the agent process // Common variables include: @@ -146,7 +148,7 @@ export interface SpawnSessionOptions { // - OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_TIMEOUT_MS // - AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME // - TOGETHER_API_KEY, TOGETHER_MODEL - // - TMUX_SESSION_NAME, TMUX_TMPDIR, TMUX_UPDATE_ENVIRONMENT + // - TMUX_SESSION_NAME, TMUX_TMPDIR // - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC // - Custom variables (DEEPSEEK_*, Z_AI_*, etc.) environmentVariables?: Record; @@ -159,7 +161,7 @@ export interface SpawnSessionOptions { */ export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise { - const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables } = options; + const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables, profileId } = options; try { const result = await apiSocket.machineRPC; }>( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, environmentVariables } + { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, profileId, environmentVariables } ); return result; } catch (error) { @@ -234,6 +237,137 @@ export async function machineBash( } } +export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full'; + +export type PreviewEnvSensitivitySource = 'forced' | 'hinted' | 'none'; + +export interface PreviewEnvValue { + value: string | null; + isSet: boolean; + isSensitive: boolean; + isForcedSensitive: boolean; + sensitivitySource: PreviewEnvSensitivitySource; + display: 'full' | 'redacted' | 'hidden' | 'unset'; +} + +export interface PreviewEnvResponse { + policy: EnvPreviewSecretsPolicy; + values: Record; +} + +interface PreviewEnvRequest { + keys: string[]; + extraEnv?: Record; + sensitiveKeys?: string[]; +} + +export type MachinePreviewEnvResult = + | { supported: true; response: PreviewEnvResponse } + | { supported: false }; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Preview environment variables exactly as the daemon will spawn them. + * + * This calls the daemon's `preview-env` RPC (if supported). The daemon computes: + * - effective env = { ...daemon.process.env, ...expand(extraEnv) } + * - applies `HAPPY_ENV_PREVIEW_SECRETS` policy for sensitive variables + * + * If the daemon is old and doesn't support `preview-env`, returns `{ supported: false }`. + */ +export async function machinePreviewEnv( + machineId: string, + params: PreviewEnvRequest +): Promise { + try { + const result = await apiSocket.machineRPC( + machineId, + 'preview-env', + params + ); + + if (isPlainObject(result) && typeof result.error === 'string') { + // Older daemons (or errors) return an encrypted `{ error: ... }` payload. + // Treat method-not-found as “unsupported” and fallback to bash-based probing. + if (result.error === 'Method not found') { + return { supported: false }; + } + // For any other error, degrade gracefully in UI by using fallback behavior. + return { supported: false }; + } + + // Basic shape validation (be defensive for mixed daemon versions). + if ( + !isPlainObject(result) || + (result.policy !== 'none' && result.policy !== 'redacted' && result.policy !== 'full') || + !isPlainObject(result.values) + ) { + return { supported: false }; + } + + const response: PreviewEnvResponse = { + policy: result.policy as EnvPreviewSecretsPolicy, + values: Object.fromEntries( + Object.entries(result.values as Record).map(([k, v]) => { + if (!isPlainObject(v)) { + const fallback: PreviewEnvValue = { + value: null, + isSet: false, + isSensitive: false, + isForcedSensitive: false, + sensitivitySource: 'none', + display: 'unset', + }; + return [k, fallback] as const; + } + + const display = v.display; + const safeDisplay = + display === 'full' || display === 'redacted' || display === 'hidden' || display === 'unset' + ? display + : 'unset'; + + const value = v.value; + const safeValue = typeof value === 'string' ? value : null; + + const isSet = v.isSet; + const safeIsSet = typeof isSet === 'boolean' ? isSet : safeValue !== null; + + const isSensitive = v.isSensitive; + const safeIsSensitive = typeof isSensitive === 'boolean' ? isSensitive : false; + + // Back-compat for intermediate daemons: default to “not forced” if missing. + const isForcedSensitive = v.isForcedSensitive; + const safeIsForcedSensitive = typeof isForcedSensitive === 'boolean' ? isForcedSensitive : false; + + const sensitivitySource = v.sensitivitySource; + const safeSensitivitySource: PreviewEnvSensitivitySource = + sensitivitySource === 'forced' || sensitivitySource === 'hinted' || sensitivitySource === 'none' + ? sensitivitySource + : (safeIsSensitive ? 'hinted' : 'none'); + + const entry: PreviewEnvValue = { + value: safeValue, + isSet: safeIsSet, + isSensitive: safeIsSensitive, + isForcedSensitive: safeIsForcedSensitive, + sensitivitySource: safeSensitivitySource, + display: safeDisplay, + }; + + return [k, entry] as const; + }), + ) as Record, + }; + return { supported: true, response }; + } catch { + return { supported: false }; + } +} + /** * Update machine metadata with optimistic concurrency control and automatic retry */ @@ -532,4 +666,4 @@ export type { TreeNode, SessionRipgrepResponse, SessionKillResponse -}; \ No newline at end of file +}; diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index c42eb8391..81b45ac7c 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -14,6 +14,11 @@ const TmuxConfigSchema = z.object({ const EnvironmentVariableSchema = z.object({ name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), value: z.string(), + // User override: + // - true: force secret handling in UI (and hint daemon) + // - false: force non-secret handling in UI (unless daemon enforces) + // - undefined: auto classification + isSecret: z.boolean().optional(), }); // Profile compatibility schema From d9b60892cabe92bd11ad70156555017f7f0c5886 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:44:41 +0100 Subject: [PATCH 16/38] feat(env): update env var list, cards, and preview modal --- .../EnvironmentVariableCard.test.ts | 189 +++++ .../components/EnvironmentVariableCard.tsx | 664 ++++++++++++------ .../EnvironmentVariablesList.test.ts | 210 ++++++ .../components/EnvironmentVariablesList.tsx | 451 +++++++----- .../EnvironmentVariablesPreviewModal.tsx | 316 +++++++++ .../newSession/ProfileCompatibilityIcon.tsx | 84 +++ 6 files changed, 1533 insertions(+), 381 deletions(-) create mode 100644 sources/components/EnvironmentVariableCard.test.ts create mode 100644 sources/components/EnvironmentVariablesList.test.ts create mode 100644 sources/components/newSession/EnvironmentVariablesPreviewModal.tsx create mode 100644 sources/components/newSession/ProfileCompatibilityIcon.tsx diff --git a/sources/components/EnvironmentVariableCard.test.ts b/sources/components/EnvironmentVariableCard.test.ts new file mode 100644 index 000000000..417816577 --- /dev/null +++ b/sources/components/EnvironmentVariableCard.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, + }, +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + groupped: { sectionTitle: '#666', background: '#fff' }, + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + margins: { md: 8 }, + iconSize: { small: 12, large: 16 }, + colors: { + surface: '#fff', + groupped: { sectionTitle: '#666', background: '#fff' }, + shadow: { color: '#000', opacity: 0.1 }, + text: '#000', + textSecondary: '#666', + textDestructive: '#f00', + divider: '#ddd', + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + deleteAction: '#f00', + warning: '#f90', + success: '#0a0', + }, + }), + }, +})); + +vi.mock('@/components/Switch', () => { + const React = require('react'); + return { + Switch: (props: unknown) => React.createElement('Switch', props), + }; +}); + +import { EnvironmentVariableCard } from './EnvironmentVariableCard'; + +describe('EnvironmentVariableCard', () => { + it('syncs remote-variable state when variable.value changes externally', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:-baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const firstSwitches = tree?.root.findAllByType('Switch' as any) ?? []; + const firstUseMachineSwitch = firstSwitches.find((s: any) => !s?.props?.disabled); + expect(firstUseMachineSwitch?.props.value).toBe(true); + + act(() => { + tree?.update( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: 'literal' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const secondSwitches = tree?.root.findAllByType('Switch' as any) ?? []; + const secondUseMachineSwitch = secondSwitches.find((s: any) => !s?.props?.disabled); + expect(secondUseMachineSwitch?.props.value).toBe(false); + }); + + it('adds a fallback operator when user enters a fallback for a template without one', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.('baz'); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR:-baz}'); + }); + + it('removes the operator when user clears the fallback value', () => { + const onUpdate = vi.fn(); + + let tree: ReturnType | undefined; + + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariableCard, { + variable: { name: 'FOO', value: '${BAR:=baz}' }, + index: 0, + machineId: 'machine-1', + onUpdate, + onDelete: () => {}, + onDuplicate: () => {}, + }), + ); + }); + + const inputs = tree?.root.findAllByType('TextInput' as any); + expect(inputs?.length).toBeGreaterThan(0); + + act(() => { + inputs?.[0]?.props.onChangeText?.(''); + }); + + expect(onUpdate).toHaveBeenCalled(); + const lastCall = onUpdate.mock.calls.at(-1) as unknown as [number, string]; + expect(lastCall[0]).toBe(0); + expect(lastCall[1]).toBe('${BAR}'); + }); +}); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 2185e0b21..1e090f54a 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -1,19 +1,31 @@ import React from 'react'; -import { View, Text, TextInput, Pressable } from 'react-native'; +import { View, Text, TextInput, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; -import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { Switch } from '@/components/Switch'; +import { formatEnvVarTemplate, parseEnvVarTemplate, type EnvVarTemplateOperator } from '@/utils/envVarTemplate'; +import { t } from '@/text'; +import type { EnvPreviewSecretsPolicy, PreviewEnvValue } from '@/sync/ops'; export interface EnvironmentVariableCardProps { - variable: { name: string; value: string }; + variable: { name: string; value: string; isSecret?: boolean }; + index: number; machineId: string | null; + machineName?: string | null; + machineEnv?: Record; + machineEnvPolicy?: EnvPreviewSecretsPolicy | null; + isMachineEnvLoading?: boolean; expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) - onUpdate: (newValue: string) => void; - onDelete: () => void; - onDuplicate: () => void; + secretOverride?: boolean; // user override (true/false) or undefined for auto + autoSecret?: boolean; // UI auto classification (docs + heuristic) + isForcedSensitive?: boolean; // daemon-enforced sensitivity + onUpdateSecretOverride?: (index: number, isSecret: boolean | undefined) => void; + onUpdate: (index: number, newValue: string) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; } /** @@ -23,24 +35,15 @@ function parseVariableValue(value: string): { useRemoteVariable: boolean; remoteVariableName: string; defaultValue: string; + fallbackOperator: EnvVarTemplateOperator | null; } { - // Match: ${VARIABLE_NAME:-default_value} - const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); - if (matchWithFallback) { + const parsedTemplate = parseEnvVarTemplate(value); + if (parsedTemplate) { return { useRemoteVariable: true, - remoteVariableName: matchWithFallback[1], - defaultValue: matchWithFallback[2] - }; - } - - // Match: ${VARIABLE_NAME} (no fallback) - const matchNoFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); - if (matchNoFallback) { - return { - useRemoteVariable: true, - remoteVariableName: matchNoFallback[1], - defaultValue: '' + remoteVariableName: parsedTemplate.sourceVar, + defaultValue: parsedTemplate.fallback, + fallbackOperator: parsedTemplate.operator, }; } @@ -48,7 +51,8 @@ function parseVariableValue(value: string): { return { useRemoteVariable: false, remoteVariableName: '', - defaultValue: value + defaultValue: value, + fallbackOperator: null, }; } @@ -58,77 +62,144 @@ function parseVariableValue(value: string): { */ export function EnvironmentVariableCard({ variable, + index, machineId, + machineName, + machineEnv, + machineEnvPolicy = null, + isMachineEnvLoading = false, expectedValue, description, isSecret = false, + secretOverride, + autoSecret = false, + isForcedSensitive = false, + onUpdateSecretOverride, onUpdate, onDelete, onDuplicate, }: EnvironmentVariableCardProps) { const { theme } = useUnistyles(); + const styles = stylesheet; // Parse current value - const parsed = parseVariableValue(variable.value); + const parsed = React.useMemo(() => parseVariableValue(variable.value), [variable.value]); const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); + const fallbackOperator = parsed.fallbackOperator; - // Query remote machine for variable value (only if checkbox enabled and not secret) - const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; - const { variables: remoteValues } = useEnvironmentVariables( - machineId, - shouldQueryRemote ? [remoteVariableName] : [] - ); + React.useEffect(() => { + setUseRemoteVariable(parsed.useRemoteVariable); + setRemoteVariableName(parsed.remoteVariableName); + setDefaultValue(parsed.defaultValue); + }, [parsed.defaultValue, parsed.remoteVariableName, parsed.useRemoteVariable]); + + const remoteEntry = remoteVariableName ? machineEnv?.[remoteVariableName] : undefined; + const remoteValue = remoteEntry?.value; + const hasFallback = defaultValue.trim() !== ''; + const computedOperator: EnvVarTemplateOperator | null = hasFallback ? (fallbackOperator ?? ':-') : null; + const machineLabel = machineName?.trim() ? machineName.trim() : t('common.machine'); - const remoteValue = remoteValues[remoteVariableName]; + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); + + const canEditSecret = Boolean(onUpdateSecretOverride) && !isForcedSensitive; + const showResetToAuto = canEditSecret && secretOverride !== undefined; // Update parent when local state changes React.useEffect(() => { - const newValue = useRemoteVariable && remoteVariableName.trim() !== '' - ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + // Important UX: when "use machine env" is enabled, allow the user to clear/edit the + // source variable name without implicitly disabling the mode or overwriting the stored + // template value. Only persist when source var is non-empty. + if (useRemoteVariable && remoteVariableName.trim() === '') { + return; + } + + const newValue = useRemoteVariable + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) : defaultValue; if (newValue !== variable.value) { - onUpdate(newValue); + onUpdate(index, newValue); } - }, [useRemoteVariable, remoteVariableName, defaultValue, variable.value, onUpdate]); + }, [computedOperator, defaultValue, index, onUpdate, remoteVariableName, useRemoteVariable, variable.value]); // Determine status const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + const computedTemplateValue = + useRemoteVariable && remoteVariableName.trim() !== '' + ? formatEnvVarTemplate({ sourceVar: remoteVariableName, fallback: defaultValue, operator: computedOperator }) + : defaultValue; + + const targetEntry = machineEnv?.[variable.name]; + const resolvedSessionValue = (() => { + // Prefer daemon-computed effective value for the target env var (matches spawn exactly). + if (machineId && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + return targetEntry.value ?? emptyValue; + } + if (targetEntry.display === 'hidden') { + return t('profiles.environmentVariables.preview.hiddenValue'); + } + return emptyValue; // unset + } + + // Fallback (no machine context / older daemon): best-effort preview. + if (isSecret) { + // If daemon policy is known and allows showing secrets, targetEntry would have handled it above. + // Otherwise, keep secrets hidden in UI. + if (useRemoteVariable && remoteVariableName) { + return t('profiles.environmentVariables.preview.secretValueHidden', { + value: formatEnvVarTemplate({ + sourceVar: remoteVariableName, + fallback: defaultValue !== '' ? '***' : '', + operator: computedOperator, + }), + }); + } + return defaultValue ? t('profiles.environmentVariables.preview.hiddenValue') : emptyValue; + } + + if (useRemoteVariable && machineId && remoteEntry !== undefined) { + // Note: remoteEntry may be hidden/redacted by daemon policy. We do NOT treat hidden as missing. + if (remoteEntry.display === 'hidden') return t('profiles.environmentVariables.preview.hiddenValue'); + if (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') { + return hasFallback ? defaultValue : emptyValue; + } + return remoteValue; + } + + return computedTemplateValue || emptyValue; + })(); + return ( - + {/* Header row with variable name and action buttons */} - - + + {variable.name} {isSecret && ( - + )} - + onDelete(index)} > onDuplicate(index)} > @@ -137,200 +208,357 @@ export function EnvironmentVariableCard({ {/* Description */} {description && ( - + {description} )} - {/* Checkbox: First try copying variable from remote machine */} - setUseRemoteVariable(!useRemoteVariable)} - > - - {useRemoteVariable && ( - - )} - - - First try copying variable from remote machine: - - + {/* Value label */} + + {(useRemoteVariable + ? t('profiles.environmentVariables.card.fallbackValueLabel') + : t('profiles.environmentVariables.card.valueLabel') + ).replace(/:$/, '')} + - {/* Remote variable name input */} + {/* Value input */} - {/* Remote variable status */} - {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - - {remoteValue === undefined ? ( - - ⏳ Checking remote machine... - - ) : remoteValue === null ? ( - - ✗ Value not found - - ) : ( - <> - - ✓ Value found: {remoteValue} + + + + {t('profiles.environmentVariables.card.secretToggleLabel')} + + + {isForcedSensitive + ? t('profiles.environmentVariables.card.secretToggleEnforcedByDaemon') + : t('profiles.environmentVariables.card.secretToggleSubtitle')} + + + + {showResetToAuto && ( + onUpdateSecretOverride?.(index, undefined)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + {t('profiles.environmentVariables.card.secretToggleResetToAuto')} - {showRemoteDiffersWarning && ( - - ⚠️ Differs from documented value: {expectedValue} - - )} - + )} + { + if (!canEditSecret) return; + onUpdateSecretOverride?.(index, next); + }} + disabled={!canEditSecret} + /> - )} + - {useRemoteVariable && !isSecret && !machineId && ( - - ℹ️ Select a machine to check if variable exists + {/* Security message for secrets */} + {isSecret && (machineEnvPolicy === null || machineEnvPolicy === 'none') && ( + + {t('profiles.environmentVariables.card.secretNotRetrieved')} )} - {/* Security message for secrets */} - {isSecret && ( - - 🔒 Secret value - not retrieved for security + {/* Default override warning */} + {showDefaultOverrideWarning && !isSecret && ( + + {t('profiles.environmentVariables.card.overridingDefault', { expectedValue })} )} - {/* Default value label */} - - Default value: + + + {/* Toggle: Use value from machine environment */} + + + {t('profiles.environmentVariables.card.useMachineEnvToggle')} + + + + + + {t('profiles.environmentVariables.card.resolvedOnSessionStart')} - {/* Default value input */} - + {/* Source variable name input (only when enabled) */} + {useRemoteVariable && ( + <> + + {t('profiles.environmentVariables.card.sourceVariableLabel')} + - {/* Default override warning */} - {showDefaultOverrideWarning && !isSecret && ( - - ⚠️ Overriding documented default: {expectedValue} - + setRemoteVariableName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + )} + + {/* Machine environment status (only with machine context) */} + {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( + + {isMachineEnvLoading || remoteEntry === undefined ? ( + + {t('profiles.environmentVariables.card.checkingMachine', { machine: machineLabel })} + + ) : (remoteEntry.display === 'unset' || remoteValue === null || remoteValue === '') ? ( + + {remoteValue === '' ? ( + hasFallback + ? t('profiles.environmentVariables.card.emptyOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.emptyOnMachine', { machine: machineLabel }) + ) : ( + hasFallback + ? t('profiles.environmentVariables.card.notFoundOnMachineUsingFallback', { machine: machineLabel }) + : t('profiles.environmentVariables.card.notFoundOnMachine', { machine: machineLabel }) + )} + + ) : ( + <> + + {t('profiles.environmentVariables.card.valueFoundOnMachine', { machine: machineLabel })} + + {showRemoteDiffersWarning && ( + + {t('profiles.environmentVariables.card.differsFromDocumented', { expectedValue })} + + )} + + )} + )} {/* Session preview */} - - Session will receive: {variable.name} = { - isSecret - ? (useRemoteVariable && remoteVariableName - ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) - : (useRemoteVariable && remoteValue !== undefined && remoteValue !== null - ? remoteValue - : defaultValue || '(empty)') - } + + {t('profiles.environmentVariables.preview.sessionWillReceive', { + name: variable.name, + value: resolvedSessionValue ?? emptyValue, + })} ); } + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '100%', + backgroundColor: theme.colors.surface, + borderRadius: 16, + padding: 16, + marginBottom: 12, + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + nameText: { + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + lockIcon: { + marginLeft: 4, + }, + secretRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 8, + marginBottom: 4, + }, + secretRowLeft: { + flex: 1, + paddingRight: 10, + }, + secretLabel: { + color: theme.colors.textSecondary, + }, + secretSubtitleText: { + marginTop: 2, + color: theme.colors.textSecondary, + }, + secretRowRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + resetToAutoText: { + color: theme.colors.button.secondary.tint, + fontSize: Platform.select({ ios: 13, default: 12 }), + ...Typography.default('semiBold'), + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.margins.md, + }, + secondaryText: { + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + descriptionText: { + color: theme.colors.textSecondary, + marginBottom: 8, + }, + labelText: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 4, + }, + valueInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 4, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + secretMessage: { + color: theme.colors.textSecondary, + marginBottom: 8, + fontStyle: 'italic', + }, + defaultOverrideWarning: { + color: theme.colors.textSecondary, + marginBottom: 8, + }, + divider: { + height: 1, + backgroundColor: theme.colors.divider, + marginVertical: 12, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 6, + }, + toggleLabelText: { + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + toggleLabel: { + flex: 1, + color: theme.colors.textSecondary, + }, + resolvedOnStartText: { + color: theme.colors.textSecondary, + marginBottom: 0, + }, + resolvedOnStartWithRemote: { + marginBottom: 10, + }, + sourceLabel: { + color: theme.colors.textSecondary, + marginBottom: 4, + }, + sourceInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + marginBottom: 6, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + machineStatusContainer: { + marginBottom: 8, + }, + machineStatusLoading: { + color: theme.colors.textSecondary, + fontStyle: 'italic', + }, + machineStatusWarning: { + color: theme.colors.warning, + }, + machineStatusSuccess: { + color: theme.colors.success, + }, + machineStatusDiffers: { + color: theme.colors.textSecondary, + marginTop: 2, + }, + sessionPreview: { + color: theme.colors.textSecondary, + marginTop: 4, + }, +})); diff --git a/sources/components/EnvironmentVariablesList.test.ts b/sources/components/EnvironmentVariablesList.test.ts new file mode 100644 index 000000000..603f6adae --- /dev/null +++ b/sources/components/EnvironmentVariablesList.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; +import React from 'react'; +import type { ProfileDocumentation } from '@/sync/profileUtils'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('@/modal', () => ({ + Modal: { alert: vi.fn() }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + TextInput: 'TextInput', + Platform: { + OS: 'web', + select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, + }, +})); + +const useEnvironmentVariablesMock = vi.fn((_machineId: any, _refs: any, _options?: any) => ({ + variables: {}, + meta: {}, + policy: null as any, + isPreviewEnvSupported: false, + isLoading: false, +})); + +vi.mock('@/hooks/useEnvironmentVariables', () => ({ + useEnvironmentVariables: (machineId: any, refs: any, options?: any) => useEnvironmentVariablesMock(machineId, refs, options), +})); + +vi.mock('@expo/vector-icons', () => { + const React = require('react'); + return { + Ionicons: (props: unknown) => React.createElement('Ionicons', props), + }; +}); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ + theme: { + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }, + }), + StyleSheet: { + create: (factory: (theme: any) => any) => factory({ + colors: { + groupped: { sectionTitle: '#000' }, + input: { background: '#fff', text: '#000', placeholder: '#999' }, + button: { + primary: { background: '#000', tint: '#fff' }, + secondary: { tint: '#000' }, + }, + surface: '#fff', + shadow: { color: '#000', opacity: 0.1 }, + }, + }), + }, +})); + +vi.mock('@/components/Item', () => { + const React = require('react'); + return { + Item: (props: unknown) => React.createElement('Item', props), + }; +}); + +vi.mock('./EnvironmentVariableCard', () => { + const React = require('react'); + return { + EnvironmentVariableCard: (props: unknown) => React.createElement('EnvironmentVariableCard', props), + }; +}); + +import { EnvironmentVariablesList } from './EnvironmentVariablesList'; + +describe('EnvironmentVariablesList', () => { + beforeEach(() => { + useEnvironmentVariablesMock.mockClear(); + }); + + it('marks documented secret refs as sensitive keys (daemon-controlled disclosure)', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret but name is not secret-like', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + act(() => { + renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [ + { name: 'FOO', value: '${MAGIC}' }, + { name: 'BAR', value: '${HOME}' }, + ], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('FOO'); + expect(keys).toContain('BAR'); + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(Array.isArray(options?.sensitiveKeys) ? options.sensitiveKeys : []).toContain('MAGIC'); + }); + + it('treats a documented-secret variable name as secret even when its value references another var', () => { + const profileDocs: ProfileDocumentation = { + description: 'test', + environmentVariables: [ + { + name: 'MAGIC', + expectedValue: '***', + description: 'secret', + isSecret: true, + }, + ], + shellConfigExample: '', + }; + + let tree: ReturnType | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [{ name: 'MAGIC', value: '${HOME}' }], + machineId: 'machine-1', + profileDocs, + onChange: () => {}, + }), + ); + }); + + expect(useEnvironmentVariablesMock).toHaveBeenCalledTimes(1); + const [_machineId, keys, options] = useEnvironmentVariablesMock.mock.calls[0] as unknown as [string, string[], any]; + expect(keys).toContain('MAGIC'); + expect(keys).toContain('HOME'); + expect(Array.isArray(options?.sensitiveKeys) ? options.sensitiveKeys : []).toContain('MAGIC'); + expect(Array.isArray(options?.sensitiveKeys) ? options.sensitiveKeys : []).toContain('HOME'); + + const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.props.isSecret).toBe(true); + expect(cards?.[0]?.props.expectedValue).toBe('***'); + }); + + it('treats daemon-forced-sensitive vars as secret and marks toggle as forced', () => { + useEnvironmentVariablesMock.mockReturnValueOnce({ + variables: {}, + meta: { + AUTH_MODE: { + value: null, + isSet: true, + isSensitive: true, + isForcedSensitive: true, + sensitivitySource: 'forced', + display: 'hidden', + }, + }, + policy: 'none', + isPreviewEnvSupported: true, + isLoading: false, + }); + + let tree: ReturnType | undefined; + act(() => { + tree = renderer.create( + React.createElement(EnvironmentVariablesList, { + environmentVariables: [{ name: 'AUTH_MODE', value: 'interactive', isSecret: false }], + machineId: 'machine-1', + profileDocs: null, + onChange: () => {}, + }), + ); + }); + + const cards = tree?.root.findAllByType('EnvironmentVariableCard' as any); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.props.isSecret).toBe(true); + expect(cards?.[0]?.props.isForcedSensitive).toBe(true); + expect(cards?.[0]?.props.secretOverride).toBe(false); + }); +}); diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index e42e61415..9795b7a60 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -1,18 +1,26 @@ import React from 'react'; -import { View, Text, Pressable, TextInput } from 'react-native'; +import { View, Text, Pressable, TextInput, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { EnvironmentVariableCard } from './EnvironmentVariableCard'; import type { ProfileDocumentation } from '@/sync/profileUtils'; +import { Item } from '@/components/Item'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; export interface EnvironmentVariablesListProps { - environmentVariables: Array<{ name: string; value: string }>; + environmentVariables: Array<{ name: string; value: string; isSecret?: boolean }>; machineId: string | null; + machineName?: string | null; profileDocs?: ProfileDocumentation | null; - onChange: (newVariables: Array<{ name: string; value: string }>) => void; + onChange: (newVariables: Array<{ name: string; value: string; isSecret?: boolean }>) => void; } +const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + /** * Complete environment variables section with title, add button, and editable cards * Matches profile list pattern from index.tsx:1159-1308 @@ -20,10 +28,75 @@ export interface EnvironmentVariablesListProps { export function EnvironmentVariablesList({ environmentVariables, machineId, + machineName, profileDocs, onChange, }: EnvironmentVariablesListProps) { const { theme } = useUnistyles(); + const styles = stylesheet; + + const extractVarRefsFromValue = React.useCallback((value: string): string[] => { + const refs: string[] = []; + if (!value) return refs; + let match: RegExpExecArray | null; + // Reset regex state defensively (global regex). + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; + }, []); + + const documentedSecretNames = React.useMemo(() => { + if (!profileDocs) return new Set(); + + return new Set( + profileDocs.environmentVariables + .filter((envVar) => envVar.isSecret) + .map((envVar) => envVar.name), + ); + }, [profileDocs]); + + const { keysToQuery, extraEnv, sensitiveKeys } = React.useMemo(() => { + const keys = new Set(); + const env: Record = {}; + const sensitive = new Set(); + + const isSecretName = (name: string) => + documentedSecretNames.has(name) || SECRET_NAME_REGEX.test(name); + + environmentVariables.forEach((envVar) => { + keys.add(envVar.name); + env[envVar.name] = envVar.value; + + const valueRefs = extractVarRefsFromValue(envVar.value); + valueRefs.forEach((ref) => keys.add(ref)); + + const isSensitive = isSecretName(envVar.name) || valueRefs.some(isSecretName); + if (isSensitive) { + sensitive.add(envVar.name); + valueRefs.forEach((ref) => { sensitive.add(ref); }); + } else { + if (SECRET_NAME_REGEX.test(envVar.name)) sensitive.add(envVar.name); + valueRefs.forEach((ref) => { + if (SECRET_NAME_REGEX.test(ref)) sensitive.add(ref); + }); + } + }); + + return { + keysToQuery: Array.from(keys), + extraEnv: env, + sensitiveKeys: Array.from(sensitive), + }; + }, [documentedSecretNames, environmentVariables, extractVarRefsFromValue]); + + const { meta: machineEnv, isLoading: isMachineEnvLoading, policy: machineEnvPolicy } = useEnvironmentVariables( + machineId, + keysToQuery, + { extraEnv, sensitiveKeys }, + ); // Add variable inline form state const [showAddForm, setShowAddForm] = React.useState(false); @@ -42,18 +115,18 @@ export function EnvironmentVariablesList({ }; }, [profileDocs]); - // Extract variable name from value (for matching documentation) - const extractVarNameFromValue = React.useCallback((value: string): string | null => { - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/); - return match ? match[1] : null; - }, []); - const handleUpdateVariable = React.useCallback((index: number, newValue: string) => { const updated = [...environmentVariables]; updated[index] = { ...updated[index], value: newValue }; onChange(updated); }, [environmentVariables, onChange]); + const handleUpdateSecretOverride = React.useCallback((index: number, isSecret: boolean | undefined) => { + const updated = [...environmentVariables]; + updated[index] = { ...updated[index], isSecret }; + onChange(updated); + }, [environmentVariables, onChange]); + const handleDeleteVariable = React.useCallback((index: number) => { onChange(environmentVariables.filter((_, i) => i !== index)); }, [environmentVariables, onChange]); @@ -70,189 +143,241 @@ export function EnvironmentVariablesList({ const duplicated = { name: `${baseName}_COPY${copyNum}`, - value: envVar.value + value: envVar.value, + isSecret: envVar.isSecret, }; onChange([...environmentVariables, duplicated]); }, [environmentVariables, onChange]); const handleAddVariable = React.useCallback(() => { - if (!newVarName.trim()) return; + const normalizedName = newVarName.trim().toUpperCase(); + if (!normalizedName) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.nameRequired')); + return; + } // Validate variable name format - if (!/^[A-Z_][A-Z0-9_]*$/.test(newVarName.trim())) { + if (!/^[A-Z_][A-Z0-9_]*$/.test(normalizedName)) { + Modal.alert( + t('common.error'), + t('profiles.environmentVariables.validation.invalidNameFormat'), + ); return; } // Check for duplicates - if (environmentVariables.some(v => v.name === newVarName.trim())) { + if (environmentVariables.some(v => v.name === normalizedName)) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.duplicateName')); return; } onChange([...environmentVariables, { - name: newVarName.trim(), - value: newVarValue.trim() || '' + name: normalizedName, + value: newVarValue.trim() || '', }]); // Reset form setNewVarName(''); setNewVarValue(''); setShowAddForm(false); - }, [newVarName, newVarValue, environmentVariables, onChange]); + }, [environmentVariables, newVarName, newVarValue, onChange]); return ( - - {/* Section header */} - - Environment Variables - - - {/* Add Variable Button */} - setShowAddForm(true)} - > - - - Add Variable + + + + {t('profiles.environmentVariables.title')} - - - {/* Add variable inline form */} - {showAddForm && ( - - - - - { - setShowAddForm(false); - setNewVarName(''); - setNewVarValue(''); - }} - > - - Cancel - - + + + {environmentVariables.length > 0 && ( + + {environmentVariables.map((envVar, index) => { + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0] ?? null; + const primaryDocs = getDocumentation(envVar.name); + const refDocs = primaryRef ? getDocumentation(primaryRef) : undefined; + const autoSecret = + primaryDocs.isSecret || + refDocs?.isSecret || + SECRET_NAME_REGEX.test(envVar.name) || + refs.some((ref) => SECRET_NAME_REGEX.test(ref)); + const forcedSecret = Boolean(machineEnv?.[envVar.name]?.isForcedSensitive); + const effectiveIsSecret = forcedSecret + ? true + : envVar.isSecret !== undefined + ? envVar.isSecret + : autoSecret; + const expectedValue = primaryDocs.expectedValue ?? refDocs?.expectedValue; + const description = primaryDocs.description ?? refDocs?.description; + + return ( + + ); + })} + + )} + + + + } + showChevron={false} + onPress={() => { + if (showAddForm) { + setShowAddForm(false); + setNewVarName(''); + setNewVarValue(''); + } else { + setShowAddForm(true); + } + }} + /> + + {showAddForm && ( + + + setNewVarName(text.toUpperCase())} + autoCapitalize="characters" + autoCorrect={false} + /> + + + + + + [ + styles.addButton, + { opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1 }, + ]} > - - Add + + {t('common.add')} - - )} - - {/* Variable cards */} - {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); - - // Auto-detect secrets if not explicitly documented - const isSecret = docs.isSecret || /TOKEN|KEY|SECRET|AUTH/i.test(envVar.name) || /TOKEN|KEY|SECRET|AUTH/i.test(varNameFromValue || ''); - - return ( - handleUpdateVariable(index, newValue)} - onDelete={() => handleDeleteVariable(index)} - onDuplicate={() => handleDuplicateVariable(index)} - /> - ); - })} + )} + ); } + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + marginBottom: 16, + }, + titleContainer: { + paddingTop: Platform.select({ ios: 35, default: 16 }), + paddingBottom: Platform.select({ ios: 6, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + titleText: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: '500', + }, + envVarListContainer: { + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + }, + addContainer: { + backgroundColor: theme.colors.surface, + marginHorizontal: Platform.select({ ios: 16, default: 12 }), + borderRadius: Platform.select({ ios: 10, default: 16 }), + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 0.33 }, + shadowOpacity: theme.colors.shadow.opacity, + shadowRadius: 0, + elevation: 1, + }, + addFormContainer: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + addInputRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + marginBottom: 8, + }, + addInputRowLast: { + marginBottom: 12, + }, + addTextInput: { + flex: 1, + fontSize: 16, + color: theme.colors.input.text, + ...Typography.default('regular'), + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + addButton: { + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + }, + addButtonText: { + color: theme.colors.button.primary.tint, + ...Typography.default('semiBold'), + }, +})); diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx new file mode 100644 index 000000000..d9de8a566 --- /dev/null +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { t } from '@/text'; +import { formatEnvVarTemplate, parseEnvVarTemplate } from '@/utils/envVarTemplate'; + +export interface EnvironmentVariablesPreviewModalProps { + environmentVariables: Record; + machineId: string | null; + machineName?: string | null; + profileName?: string | null; + onClose: () => void; +} + +function isSecretLike(name: string) { + return /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i.test(name); +} + +const ENV_VAR_TEMPLATE_REF_REGEX = /\$\{([A-Z_][A-Z0-9_]*)(?::[-=][^}]*)?\}/g; + +function extractVarRefsFromValue(value: string): string[] { + const refs: string[] = []; + if (!value) return refs; + ENV_VAR_TEMPLATE_REF_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = ENV_VAR_TEMPLATE_REF_REGEX.exec(value)) !== null) { + const name = match[1]; + if (name) refs.push(name); + } + return refs; +} + +const stylesheet = StyleSheet.create((theme, runtime) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingBottom: 16, + flexGrow: 1, + }, + section: { + paddingHorizontal: 16, + paddingTop: 12, + }, + descriptionText: { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }, + machineNameText: { + color: theme.colors.status.connected, + ...Typography.default('semiBold'), + }, + detailText: { + fontSize: 13, + ...Typography.default('semiBold'), + }, +})); + +export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { height: windowHeight } = useWindowDimensions(); + const scrollRef = React.useRef(null); + const scrollYRef = React.useRef(0); + + const handleScroll = React.useCallback((e: any) => { + scrollYRef.current = e?.nativeEvent?.contentOffset?.y ?? 0; + }, []); + + // On web, RN ScrollView inside a modal doesn't reliably respond to mouse wheel / trackpad scroll. + // Manually translate wheel deltas into scrollTo. + const handleWheel = React.useCallback((e: any) => { + if (Platform.OS !== 'web') return; + const deltaY = e?.deltaY; + if (typeof deltaY !== 'number' || Number.isNaN(deltaY)) return; + + if (e?.cancelable) { + e?.preventDefault?.(); + } + e?.stopPropagation?.(); + scrollRef.current?.scrollTo({ y: Math.max(0, scrollYRef.current + deltaY), animated: false }); + }, []); + + const envVarEntries = React.useMemo(() => { + return Object.entries(props.environmentVariables) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [props.environmentVariables]); + + const refsToQuery = React.useMemo(() => { + const refs = new Set(); + envVarEntries.forEach((envVar) => { + // Query both target keys and any referenced keys so preview can show the effective spawned value. + refs.add(envVar.name); + extractVarRefsFromValue(envVar.value).forEach((ref) => refs.add(ref)); + }); + return Array.from(refs); + }, [envVarEntries]); + + const sensitiveKeys = React.useMemo(() => { + const keys = new Set(); + envVarEntries.forEach((envVar) => { + const refs = extractVarRefsFromValue(envVar.value); + const isSensitive = isSecretLike(envVar.name) || refs.some(isSecretLike); + if (isSensitive) { + keys.add(envVar.name); + refs.forEach((ref) => { keys.add(ref); }); + } + }); + return Array.from(keys); + }, [envVarEntries]); + + const { meta: machineEnv, policy: machineEnvPolicy } = useEnvironmentVariables( + props.machineId, + refsToQuery, + { extraEnv: props.environmentVariables, sensitiveKeys }, + ); + + const title = props.profileName + ? t('profiles.environmentVariables.previewModal.titleWithProfile', { profileName: props.profileName }) + : t('profiles.environmentVariables.title'); + const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); + const emptyValue = t('profiles.environmentVariables.preview.emptyValue'); + + return ( + + + + {title} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + + {t('profiles.environmentVariables.previewModal.descriptionPrefix')}{' '} + {props.machineName ? ( + + {props.machineName} + + ) : ( + t('profiles.environmentVariables.previewModal.descriptionFallbackMachine') + )} + {t('profiles.environmentVariables.previewModal.descriptionSuffix')} + + + + {envVarEntries.length === 0 ? ( + + + {t('profiles.environmentVariables.previewModal.emptyMessage')} + + + ) : ( + + {envVarEntries.map((envVar, idx) => { + const parsed = parseEnvVarTemplate(envVar.value); + const refs = extractVarRefsFromValue(envVar.value); + const primaryRef = refs[0]; + const secret = isSecretLike(envVar.name) || (primaryRef ? isSecretLike(primaryRef) : false); + + const hasMachineContext = Boolean(props.machineId); + const targetEntry = machineEnv?.[envVar.name]; + const resolvedValue = parsed?.sourceVar ? machineEnv?.[parsed.sourceVar] : undefined; + const isMachineBased = Boolean(refs.length > 0); + + let displayValue: string; + if (hasMachineContext && targetEntry) { + if (targetEntry.display === 'full' || targetEntry.display === 'redacted') { + displayValue = targetEntry.value ?? emptyValue; + } else if (targetEntry.display === 'hidden') { + displayValue = '•••'; + } else { + displayValue = emptyValue; + } + } else if (secret) { + // If daemon policy is known and allows showing secrets, we would have used targetEntry above. + displayValue = machineEnvPolicy === 'full' || machineEnvPolicy === 'redacted' ? (envVar.value || emptyValue) : '•••'; + } else if (parsed) { + if (!hasMachineContext) { + displayValue = formatEnvVarTemplate(parsed); + } else if (resolvedValue === undefined) { + displayValue = `${formatEnvVarTemplate(parsed)} ${t('profiles.environmentVariables.previewModal.checkingSuffix')}`; + } else if (resolvedValue.display === 'hidden') { + displayValue = '•••'; + } else if (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '') { + displayValue = parsed.fallback ? parsed.fallback : emptyValue; + } else { + displayValue = resolvedValue.value ?? emptyValue; + } + } else { + displayValue = envVar.value || emptyValue; + } + + type DetailKind = 'fixed' | 'machine' | 'checking' | 'fallback' | 'missing'; + + const detailKind: DetailKind | undefined = (() => { + if (secret) return undefined; + if (!isMachineBased) return 'fixed'; + if (!hasMachineContext) return 'machine'; + if (parsed?.sourceVar && resolvedValue === undefined) return 'checking'; + if (parsed?.sourceVar && resolvedValue && (resolvedValue.display === 'unset' || resolvedValue.value === null || resolvedValue.value === '')) { + return parsed?.fallback ? 'fallback' : 'missing'; + } + return 'machine'; + })(); + + const detailLabel = (() => { + if (!detailKind) return undefined; + return detailKind === 'fixed' + ? t('profiles.environmentVariables.previewModal.detail.fixed') + : detailKind === 'machine' + ? t('profiles.environmentVariables.previewModal.detail.machine') + : detailKind === 'checking' + ? t('profiles.environmentVariables.previewModal.detail.checking') + : detailKind === 'fallback' + ? t('profiles.environmentVariables.previewModal.detail.fallback') + : t('profiles.environmentVariables.previewModal.detail.missing'); + })(); + + const detailColor = + detailKind === 'machine' + ? theme.colors.status.connected + : detailKind === 'fallback' || detailKind === 'missing' + ? theme.colors.warning + : theme.colors.textSecondary; + + const rightElement = (() => { + if (secret) return undefined; + if (!isMachineBased) return undefined; + if (!hasMachineContext || detailKind === 'checking') { + return ; + } + return ; + })(); + + const canCopy = (() => { + if (secret) return false; + return Boolean(displayValue); + })(); + + return ( + + ); + })} + + )} + + + ); +} diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx new file mode 100644 index 000000000..e856acb47 --- /dev/null +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Text, View, type ViewStyle } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import type { AIBackendProfile } from '@/sync/settings'; +import { useSetting } from '@/sync/storage'; + +type Props = { + profile: Pick; + size?: number; + style?: ViewStyle; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + stack: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0, + }, + glyph: { + color: theme.colors.textSecondary, + ...Typography.default(), + }, +})); + +export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { + useUnistyles(); // Subscribe to theme changes for re-render + const styles = stylesheet; + const experimentsEnabled = useSetting('experiments'); + + // iOS can render some dingbat glyphs as emoji; force text presentation (U+FE0E). + const CLAUDE_GLYPH = '\u2733\uFE0E'; + const GEMINI_GLYPH = '\u2726\uFE0E'; + + const hasClaude = !!profile.compatibility?.claude; + const hasCodex = !!profile.compatibility?.codex; + const hasGemini = experimentsEnabled && !!profile.compatibility?.gemini; + + const glyphs = React.useMemo(() => { + const items: Array<{ key: string; glyph: string; factor: number }> = []; + if (hasClaude) items.push({ key: 'claude', glyph: CLAUDE_GLYPH, factor: 1.14 }); + if (hasCodex) items.push({ key: 'codex', glyph: '꩜', factor: 0.82 }); + if (hasGemini) items.push({ key: 'gemini', glyph: GEMINI_GLYPH, factor: 0.88 }); + if (items.length === 0) items.push({ key: 'none', glyph: '•', factor: 0.85 }); + return items; + }, [hasClaude, hasCodex, hasGemini]); + + const multiScale = glyphs.length === 1 ? 1 : glyphs.length === 2 ? 0.6 : 0.5; + + return ( + + {glyphs.length === 1 ? ( + + {glyphs[0].glyph} + + ) : ( + + {glyphs.map((item) => { + const fontSize = Math.round(size * multiScale * item.factor); + return ( + + {item.glyph} + + ); + })} + + )} + + ); +} From c311c2b3fc8df18ef1a6cd78657bca806a80b8ea Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:45:10 +0100 Subject: [PATCH 17/38] refactor(i18n): separate translation types and content --- sources/text/README.md | 17 +- sources/text/_default.ts | 937 --------------------------- sources/text/_types.ts | 3 + sources/text/index.ts | 14 +- sources/text/translations/ca.ts | 207 +++++- sources/text/translations/en.ts | 215 +++++- sources/text/translations/es.ts | 207 +++++- sources/text/translations/it.ts | 203 +++++- sources/text/translations/ja.ts | 213 +++++- sources/text/translations/pl.ts | 207 +++++- sources/text/translations/pt.ts | 205 +++++- sources/text/translations/ru.ts | 193 +++++- sources/text/translations/zh-Hans.ts | 205 +++++- sources/theme.css | 14 +- 14 files changed, 1795 insertions(+), 1045 deletions(-) delete mode 100644 sources/text/_default.ts create mode 100644 sources/text/_types.ts diff --git a/sources/text/README.md b/sources/text/README.md index 09128f3ef..38551135d 100644 --- a/sources/text/README.md +++ b/sources/text/README.md @@ -82,8 +82,8 @@ t('invalid.key') // Error: Key doesn't exist ## Files Structure -### `_default.ts` -Contains the main translation object with mixed string/function values: +### `translations/en.ts` +Contains the canonical English translation object with mixed string/function values: ```typescript export const en = { @@ -97,6 +97,13 @@ export const en = { } as const; ``` +### `_types.ts` +Contains the TypeScript types derived from the English translation structure. + +This keeps the canonical translation object (`translations/en.ts`) separate from the type-level API: +- `Translations` / `TranslationStructure` are derived from `en` and used to type-check other locales. +- `TranslationKey` / `TranslationParams` are derived from `Translations` (in `index.ts`) to type `t(...)`. + ### `index.ts` Main module with the `t` function and utilities: - `t()` - Main translation function with strict typing @@ -164,7 +171,7 @@ The API stays the same, but you get: ## Adding New Translations -1. **Add to `_default.ts`**: +1. **Add to `translations/en.ts`**: ```typescript // String constant newConstant: 'My New Text', @@ -215,9 +222,9 @@ statusMessage: ({ files, online, syncing }: { ## Future Expansion To add more languages: -1. Create new translation files (e.g., `_spanish.ts`) +1. Create new translation files (e.g., `translations/es.ts`) 2. Update types to include new locales 3. Add locale switching logic 4. All existing type safety is preserved -This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. \ No newline at end of file +This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. diff --git a/sources/text/_default.ts b/sources/text/_default.ts deleted file mode 100644 index 0a94f0590..000000000 --- a/sources/text/_default.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * English translations for the Happy app - * Values can be: - * - String constants for static text - * - Functions with typed object parameters for dynamic text - */ - -/** - * English plural helper function - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - -export const en = { - tabs: { - // Tab navigation labels - inbox: 'Inbox', - sessions: 'Terminals', - settings: 'Settings', - }, - - inbox: { - // Inbox screen - emptyTitle: 'Empty Inbox', - emptyDescription: 'Connect with friends to start sharing sessions', - updates: 'Updates', - }, - - common: { - // Simple string constants - cancel: 'Cancel', - authenticate: 'Authenticate', - save: 'Save', - saveAs: 'Save As', - error: 'Error', - success: 'Success', - ok: 'OK', - continue: 'Continue', - back: 'Back', - create: 'Create', - rename: 'Rename', - reset: 'Reset', - logout: 'Logout', - yes: 'Yes', - no: 'No', - discard: 'Discard', - version: 'Version', - copied: 'Copied', - copy: 'Copy', - scanning: 'Scanning...', - urlPlaceholder: 'https://example.com', - home: 'Home', - message: 'Message', - files: 'Files', - fileViewer: 'File Viewer', - loading: 'Loading...', - retry: 'Retry', - delete: 'Delete', - optional: 'optional', - }, - - profile: { - userProfile: 'User Profile', - details: 'Details', - firstName: 'First Name', - lastName: 'Last Name', - username: 'Username', - status: 'Status', - }, - - status: { - connected: 'connected', - connecting: 'connecting', - disconnected: 'disconnected', - error: 'error', - online: 'online', - offline: 'offline', - lastSeen: ({ time }: { time: string }) => `last seen ${time}`, - permissionRequired: 'permission required', - activeNow: 'Active now', - unknown: 'unknown', - }, - - time: { - justNow: 'just now', - minutesAgo: ({ count }: { count: number }) => `${count} minute${count !== 1 ? 's' : ''} ago`, - hoursAgo: ({ count }: { count: number }) => `${count} hour${count !== 1 ? 's' : ''} ago`, - }, - - connect: { - restoreAccount: 'Restore Account', - enterSecretKey: 'Please enter a secret key', - invalidSecretKey: 'Invalid secret key. Please check and try again.', - enterUrlManually: 'Enter URL manually', - }, - - settings: { - title: 'Settings', - connectedAccounts: 'Connected Accounts', - connectAccount: 'Connect account', - github: 'GitHub', - machines: 'Machines', - features: 'Features', - social: 'Social', - account: 'Account', - accountSubtitle: 'Manage your account details', - appearance: 'Appearance', - appearanceSubtitle: 'Customize how the app looks', - voiceAssistant: 'Voice Assistant', - voiceAssistantSubtitle: 'Configure voice interaction preferences', - featuresTitle: 'Features', - featuresSubtitle: 'Enable or disable app features', - developer: 'Developer', - developerTools: 'Developer Tools', - about: 'About', - aboutFooter: 'Happy Coder is a Codex and Claude Code mobile client. It\'s fully end-to-end encrypted and your account is stored only on your device. Not affiliated with Anthropic.', - whatsNew: 'What\'s New', - whatsNewSubtitle: 'See the latest updates and improvements', - reportIssue: 'Report an Issue', - privacyPolicy: 'Privacy Policy', - termsOfService: 'Terms of Service', - eula: 'EULA', - supportUs: 'Support us', - supportUsSubtitlePro: 'Thank you for your support!', - supportUsSubtitle: 'Support project development', - scanQrCodeToAuthenticate: 'Scan QR code to authenticate', - githubConnected: ({ login }: { login: string }) => `Connected as @${login}`, - connectGithubAccount: 'Connect your GitHub account', - claudeAuthSuccess: 'Successfully connected to Claude', - exchangingTokens: 'Exchanging tokens...', - usage: 'Usage', - usageSubtitle: 'View your API usage and costs', - profiles: 'Profiles', - profilesSubtitle: 'Manage environment variable profiles for sessions', - - // Dynamic settings messages - accountConnected: ({ service }: { service: string }) => `${service} account connected`, - machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} is ${status}`, - featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => - `${feature} ${enabled ? 'enabled' : 'disabled'}`, - }, - - settingsAppearance: { - // Appearance settings screen - theme: 'Theme', - themeDescription: 'Choose your preferred color scheme', - themeOptions: { - adaptive: 'Adaptive', - light: 'Light', - dark: 'Dark', - }, - themeDescriptions: { - adaptive: 'Match system settings', - light: 'Always use light theme', - dark: 'Always use dark theme', - }, - display: 'Display', - displayDescription: 'Control layout and spacing', - inlineToolCalls: 'Inline Tool Calls', - inlineToolCallsDescription: 'Display tool calls directly in chat messages', - expandTodoLists: 'Expand Todo Lists', - expandTodoListsDescription: 'Show all todos instead of just changes', - showLineNumbersInDiffs: 'Show Line Numbers in Diffs', - showLineNumbersInDiffsDescription: 'Display line numbers in code diffs', - showLineNumbersInToolViews: 'Show Line Numbers in Tool Views', - showLineNumbersInToolViewsDescription: 'Display line numbers in tool view diffs', - wrapLinesInDiffs: 'Wrap Lines in Diffs', - wrapLinesInDiffsDescription: 'Wrap long lines instead of horizontal scrolling in diff views', - alwaysShowContextSize: 'Always Show Context Size', - alwaysShowContextSizeDescription: 'Display context usage even when not near limit', - avatarStyle: 'Avatar Style', - avatarStyleDescription: 'Choose session avatar appearance', - avatarOptions: { - pixelated: 'Pixelated', - gradient: 'Gradient', - brutalist: 'Brutalist', - }, - showFlavorIcons: 'Show AI Provider Icons', - showFlavorIconsDescription: 'Display AI provider icons on session avatars', - compactSessionView: 'Compact Session View', - compactSessionViewDescription: 'Show active sessions in a more compact layout', - }, - - settingsFeatures: { - // Features settings screen - experiments: 'Experiments', - experimentsDescription: 'Enable experimental features that are still in development. These features may be unstable or change without notice.', - experimentalFeatures: 'Experimental Features', - experimentalFeaturesEnabled: 'Experimental features enabled', - experimentalFeaturesDisabled: 'Using stable features only', - webFeatures: 'Web Features', - webFeaturesDescription: 'Features available only in the web version of the app.', - enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)', - enterToSendDisabled: 'Enter inserts a new line', - commandPalette: 'Command Palette', - commandPaletteEnabled: 'Press ⌘K to open', - commandPaletteDisabled: 'Quick command access disabled', - markdownCopyV2: 'Markdown Copy v2', - markdownCopyV2Subtitle: 'Long press opens copy modal', - hideInactiveSessions: 'Hide inactive sessions', - hideInactiveSessionsSubtitle: 'Show only active chats in your list', - enhancedSessionWizard: 'Enhanced Session Wizard', - enhancedSessionWizardEnabled: 'Profile-first session launcher active', - enhancedSessionWizardDisabled: 'Using standard session launcher', - }, - - errors: { - networkError: 'Network error occurred', - serverError: 'Server error occurred', - unknownError: 'An unknown error occurred', - connectionTimeout: 'Connection timed out', - authenticationFailed: 'Authentication failed', - permissionDenied: 'Permission denied', - fileNotFound: 'File not found', - invalidFormat: 'Invalid format', - operationFailed: 'Operation failed', - tryAgain: 'Please try again', - contactSupport: 'Contact support if the problem persists', - sessionNotFound: 'Session not found', - voiceSessionFailed: 'Failed to start voice session', - voiceServiceUnavailable: 'Voice service is temporarily unavailable', - oauthInitializationFailed: 'Failed to initialize OAuth flow', - tokenStorageFailed: 'Failed to store authentication tokens', - oauthStateMismatch: 'Security validation failed. Please try again', - tokenExchangeFailed: 'Failed to exchange authorization code', - oauthAuthorizationDenied: 'Authorization was denied', - webViewLoadFailed: 'Failed to load authentication page', - failedToLoadProfile: 'Failed to load user profile', - userNotFound: 'User not found', - sessionDeleted: 'Session has been deleted', - sessionDeletedDescription: 'This session has been permanently removed', - - // Error functions with context - fieldError: ({ field, reason }: { field: string; reason: string }) => - `${field}: ${reason}`, - validationError: ({ field, min, max }: { field: string; min: number; max: number }) => - `${field} must be between ${min} and ${max}`, - retryIn: ({ seconds }: { seconds: number }) => - `Retry in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`, - errorWithCode: ({ message, code }: { message: string; code: number | string }) => - `${message} (Error ${code})`, - disconnectServiceFailed: ({ service }: { service: string }) => - `Failed to disconnect ${service}`, - connectServiceFailed: ({ service }: { service: string }) => - `Failed to connect ${service}. Please try again.`, - failedToLoadFriends: 'Failed to load friends list', - failedToAcceptRequest: 'Failed to accept friend request', - failedToRejectRequest: 'Failed to reject friend request', - failedToRemoveFriend: 'Failed to remove friend', - searchFailed: 'Search failed. Please try again.', - failedToSendRequest: 'Failed to send friend request', - }, - - newSession: { - // Used by new-session screen and launch flows - title: 'Start New Session', - noMachinesFound: 'No machines found. Start a Happy session on your computer first.', - allMachinesOffline: 'All machines appear offline', - machineDetails: 'View machine details →', - directoryDoesNotExist: 'Directory Not Found', - createDirectoryConfirm: ({ directory }: { directory: string }) => `The directory ${directory} does not exist. Do you want to create it?`, - sessionStarted: 'Session Started', - sessionStartedMessage: 'The session has been started successfully.', - sessionSpawningFailed: 'Session spawning failed - no session ID returned.', - startingSession: 'Starting session...', - startNewSessionInFolder: 'New session here', - failedToStart: 'Failed to start session. Make sure the daemon is running on the target machine.', - sessionTimeout: 'Session startup timed out. The machine may be slow or the daemon may not be responding.', - notConnectedToServer: 'Not connected to server. Check your internet connection.', - noMachineSelected: 'Please select a machine to start the session', - noPathSelected: 'Please select a directory to start the session in', - sessionType: { - title: 'Session Type', - simple: 'Simple', - worktree: 'Worktree', - comingSoon: 'Coming soon', - }, - worktree: { - creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`, - notGitRepo: 'Worktrees require a git repository', - failed: ({ error }: { error: string }) => `Failed to create worktree: ${error}`, - success: 'Worktree created successfully', - } - }, - - sessionHistory: { - // Used by session history screen - title: 'Session History', - empty: 'No sessions found', - today: 'Today', - yesterday: 'Yesterday', - daysAgo: ({ count }: { count: number }) => `${count} ${count === 1 ? 'day' : 'days'} ago`, - viewAll: 'View all sessions', - }, - - session: { - inputPlaceholder: 'Type a message ...', - }, - - commandPalette: { - placeholder: 'Type a command or search...', - }, - - server: { - // Used by Server Configuration screen (app/(app)/server.tsx) - serverConfiguration: 'Server Configuration', - enterServerUrl: 'Please enter a server URL', - notValidHappyServer: 'Not a valid Happy Server', - changeServer: 'Change Server', - continueWithServer: 'Continue with this server?', - resetToDefault: 'Reset to Default', - resetServerDefault: 'Reset server to default?', - validating: 'Validating...', - validatingServer: 'Validating server...', - serverReturnedError: 'Server returned an error', - failedToConnectToServer: 'Failed to connect to server', - currentlyUsingCustomServer: 'Currently using custom server', - customServerUrlLabel: 'Custom Server URL', - advancedFeatureFooter: "This is an advanced feature. Only change the server if you know what you're doing. You will need to log out and log in again after changing servers." - }, - - sessionInfo: { - // Used by Session Info screen (app/(app)/session/[id]/info.tsx) - killSession: 'Kill Session', - killSessionConfirm: 'Are you sure you want to terminate this session?', - archiveSession: 'Archive Session', - archiveSessionConfirm: 'Are you sure you want to archive this session?', - happySessionIdCopied: 'Happy Session ID copied to clipboard', - failedToCopySessionId: 'Failed to copy Happy Session ID', - happySessionId: 'Happy Session ID', - claudeCodeSessionId: 'Claude Code Session ID', - claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', - aiProvider: 'AI Provider', - failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', - metadataCopied: 'Metadata copied to clipboard', - failedToCopyMetadata: 'Failed to copy metadata', - failedToKillSession: 'Failed to kill session', - failedToArchiveSession: 'Failed to archive session', - connectionStatus: 'Connection Status', - created: 'Created', - lastUpdated: 'Last Updated', - sequence: 'Sequence', - quickActions: 'Quick Actions', - viewMachine: 'View Machine', - viewMachineSubtitle: 'View machine details and sessions', - killSessionSubtitle: 'Immediately terminate the session', - archiveSessionSubtitle: 'Archive this session and stop it', - metadata: 'Metadata', - host: 'Host', - path: 'Path', - operatingSystem: 'Operating System', - processId: 'Process ID', - happyHome: 'Happy Home', - copyMetadata: 'Copy Metadata', - agentState: 'Agent State', - controlledByUser: 'Controlled by User', - pendingRequests: 'Pending Requests', - activity: 'Activity', - thinking: 'Thinking', - thinkingSince: 'Thinking Since', - cliVersion: 'CLI Version', - cliVersionOutdated: 'CLI Update Required', - cliVersionOutdatedMessage: ({ currentVersion, requiredVersion }: { currentVersion: string; requiredVersion: string }) => - `Version ${currentVersion} installed. Update to ${requiredVersion} or later`, - updateCliInstructions: 'Please run npm install -g happy-coder@latest', - deleteSession: 'Delete Session', - deleteSessionSubtitle: 'Permanently remove this session', - deleteSessionConfirm: 'Delete Session Permanently?', - deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', - failedToDeleteSession: 'Failed to delete session', - sessionDeleted: 'Session deleted successfully', - - }, - - components: { - emptyMainScreen: { - // Used by EmptyMainScreen component - readyToCode: 'Ready to code?', - installCli: 'Install the Happy CLI', - runIt: 'Run it', - scanQrCode: 'Scan the QR code', - openCamera: 'Open Camera', - }, - }, - - agentInput: { - permissionMode: { - title: 'PERMISSION MODE', - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - agent: { - claude: 'Claude', - codex: 'Codex', - gemini: 'Gemini', - }, - model: { - title: 'MODEL', - configureInCli: 'Configure models in CLI settings', - }, - codexPermissionMode: { - title: 'CODEX PERMISSION MODE', - default: 'CLI Settings', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', - }, - codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', - }, - geminiPermissionMode: { - title: 'GEMINI PERMISSION MODE', - default: 'Default', - readOnly: 'Read Only', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', - }, - context: { - remaining: ({ percent }: { percent: number }) => `${percent}% left`, - }, - suggestion: { - fileLabel: 'FILE', - folderLabel: 'FOLDER', - }, - noMachinesAvailable: 'No machines', - }, - - machineLauncher: { - showLess: 'Show less', - showAll: ({ count }: { count: number }) => `Show all (${count} paths)`, - enterCustomPath: 'Enter custom path', - offlineUnableToSpawn: 'Unable to spawn new session, offline', - }, - - sidebar: { - sessionsTitle: 'Happy', - }, - - toolView: { - input: 'Input', - output: 'Output', - }, - - tools: { - fullView: { - description: 'Description', - inputParams: 'Input Parameters', - output: 'Output', - error: 'Error', - completed: 'Tool completed successfully', - noOutput: 'No output was produced', - running: 'Tool is running...', - rawJsonDevMode: 'Raw JSON (Dev Mode)', - }, - taskView: { - initializing: 'Initializing agent...', - moreTools: ({ count }: { count: number }) => `+${count} more ${plural({ count, singular: 'tool', plural: 'tools' })}`, - }, - multiEdit: { - editNumber: ({ index, total }: { index: number; total: number }) => `Edit ${index} of ${total}`, - replaceAll: 'Replace All', - }, - names: { - task: 'Task', - terminal: 'Terminal', - searchFiles: 'Search Files', - search: 'Search', - searchContent: 'Search Content', - listFiles: 'List Files', - planProposal: 'Plan proposal', - readFile: 'Read File', - editFile: 'Edit File', - writeFile: 'Write File', - fetchUrl: 'Fetch URL', - readNotebook: 'Read Notebook', - editNotebook: 'Edit Notebook', - todoList: 'Todo List', - webSearch: 'Web Search', - reasoning: 'Reasoning', - applyChanges: 'Update file', - viewDiff: 'Current file changes', - question: 'Question', - }, - askUserQuestion: { - submit: 'Submit Answer', - multipleQuestions: ({ count }: { count: number }) => `${count} questions`, - }, - desc: { - terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, - searchPattern: ({ pattern }: { pattern: string }) => `Search(pattern: ${pattern})`, - searchPath: ({ basename }: { basename: string }) => `Search(path: ${basename})`, - fetchUrlHost: ({ host }: { host: string }) => `Fetch URL(url: ${host})`, - editNotebookMode: ({ path, mode }: { path: string; mode: string }) => `Edit Notebook(file: ${path}, mode: ${mode})`, - todoListCount: ({ count }: { count: number }) => `Todo List(count: ${count})`, - webSearchQuery: ({ query }: { query: string }) => `Web Search(query: ${query})`, - grepPattern: ({ pattern }: { pattern: string }) => `grep(pattern: ${pattern})`, - multiEditEdits: ({ path, count }: { path: string; count: number }) => `${path} (${count} edits)`, - readingFile: ({ file }: { file: string }) => `Reading ${file}`, - writingFile: ({ file }: { file: string }) => `Writing ${file}`, - modifyingFile: ({ file }: { file: string }) => `Modifying ${file}`, - modifyingFiles: ({ count }: { count: number }) => `Modifying ${count} files`, - modifyingMultipleFiles: ({ file, count }: { file: string; count: number }) => `${file} and ${count} more`, - showingDiff: 'Showing changes', - } - }, - - files: { - searchPlaceholder: 'Search files...', - detachedHead: 'detached HEAD', - summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `${staged} staged • ${unstaged} unstaged`, - notRepo: 'Not a git repository', - notUnderGit: 'This directory is not under git version control', - searching: 'Searching files...', - noFilesFound: 'No files found', - noFilesInProject: 'No files in project', - tryDifferentTerm: 'Try a different search term', - searchResults: ({ count }: { count: number }) => `Search Results (${count})`, - projectRoot: 'Project root', - stagedChanges: ({ count }: { count: number }) => `Staged Changes (${count})`, - unstagedChanges: ({ count }: { count: number }) => `Unstaged Changes (${count})`, - // File viewer strings - loadingFile: ({ fileName }: { fileName: string }) => `Loading ${fileName}...`, - binaryFile: 'Binary File', - cannotDisplayBinary: 'Cannot display binary file content', - diff: 'Diff', - file: 'File', - fileEmpty: 'File is empty', - noChanges: 'No changes to display', - }, - - settingsVoice: { - // Voice settings screen - languageTitle: 'Language', - languageDescription: 'Choose your preferred language for voice assistant interactions. This setting syncs across all your devices.', - preferredLanguage: 'Preferred Language', - preferredLanguageSubtitle: 'Language used for voice assistant responses', - language: { - searchPlaceholder: 'Search languages...', - title: 'Languages', - footer: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'language', plural: 'languages' })} available`, - autoDetect: 'Auto-detect', - } - }, - - settingsAccount: { - // Account settings screen - accountInformation: 'Account Information', - status: 'Status', - statusActive: 'Active', - statusNotAuthenticated: 'Not Authenticated', - anonymousId: 'Anonymous ID', - publicId: 'Public ID', - notAvailable: 'Not available', - linkNewDevice: 'Link New Device', - linkNewDeviceSubtitle: 'Scan QR code to link device', - profile: 'Profile', - name: 'Name', - github: 'GitHub', - tapToDisconnect: 'Tap to disconnect', - server: 'Server', - backup: 'Backup', - backupDescription: 'Your secret key is the only way to recover your account. Save it in a secure place like a password manager.', - secretKey: 'Secret Key', - tapToReveal: 'Tap to reveal', - tapToHide: 'Tap to hide', - secretKeyLabel: 'SECRET KEY (TAP TO COPY)', - secretKeyCopied: 'Secret key copied to clipboard. Store it in a safe place!', - secretKeyCopyFailed: 'Failed to copy secret key', - privacy: 'Privacy', - privacyDescription: 'Help improve the app by sharing anonymous usage data. No personal information is collected.', - analytics: 'Analytics', - analyticsDisabled: 'No data is shared', - analyticsEnabled: 'Anonymous usage data is shared', - dangerZone: 'Danger Zone', - logout: 'Logout', - logoutSubtitle: 'Sign out and clear local data', - logoutConfirm: 'Are you sure you want to logout? Make sure you have backed up your secret key!', - }, - - settingsLanguage: { - // Language settings screen - title: 'Language', - description: 'Choose your preferred language for the app interface. This will sync across all your devices.', - currentLanguage: 'Current Language', - automatic: 'Automatic', - automaticSubtitle: 'Detect from device settings', - needsRestart: 'Language Changed', - needsRestartMessage: 'The app needs to restart to apply the new language setting.', - restartNow: 'Restart Now', - }, - - connectButton: { - authenticate: 'Authenticate Terminal', - authenticateWithUrlPaste: 'Authenticate Terminal with URL paste', - pasteAuthUrl: 'Paste the auth URL from your terminal', - }, - - updateBanner: { - updateAvailable: 'Update available', - pressToApply: 'Press to apply the update', - whatsNew: "What's new", - seeLatest: 'See the latest updates and improvements', - nativeUpdateAvailable: 'App Update Available', - tapToUpdateAppStore: 'Tap to update in App Store', - tapToUpdatePlayStore: 'Tap to update in Play Store', - }, - - changelog: { - // Used by the changelog screen - version: ({ version }: { version: number }) => `Version ${version}`, - noEntriesAvailable: 'No changelog entries available.', - }, - - terminal: { - // Used by terminal connection screens - webBrowserRequired: 'Web Browser Required', - webBrowserRequiredDescription: 'Terminal connection links can only be opened in a web browser for security reasons. Please use the QR code scanner or open this link on a computer.', - processingConnection: 'Processing connection...', - invalidConnectionLink: 'Invalid Connection Link', - invalidConnectionLinkDescription: 'The connection link is missing or invalid. Please check the URL and try again.', - connectTerminal: 'Connect Terminal', - terminalRequestDescription: 'A terminal is requesting to connect to your Happy Coder account. This will allow the terminal to send and receive messages securely.', - connectionDetails: 'Connection Details', - publicKey: 'Public Key', - encryption: 'Encryption', - endToEndEncrypted: 'End-to-end encrypted', - acceptConnection: 'Accept Connection', - connecting: 'Connecting...', - reject: 'Reject', - security: 'Security', - securityFooter: 'This connection link was processed securely in your browser and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - securityFooterDevice: 'This connection was processed securely on your device and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - clientSideProcessing: 'Client-Side Processing', - linkProcessedLocally: 'Link processed locally in browser', - linkProcessedOnDevice: 'Link processed locally on device', - }, - - modals: { - // Used across connect flows and settings - authenticateTerminal: 'Authenticate Terminal', - pasteUrlFromTerminal: 'Paste the authentication URL from your terminal', - deviceLinkedSuccessfully: 'Device linked successfully', - terminalConnectedSuccessfully: 'Terminal connected successfully', - invalidAuthUrl: 'Invalid authentication URL', - developerMode: 'Developer Mode', - developerModeEnabled: 'Developer mode enabled', - developerModeDisabled: 'Developer mode disabled', - disconnectGithub: 'Disconnect GitHub', - disconnectGithubConfirm: 'Are you sure you want to disconnect your GitHub account?', - disconnectService: ({ service }: { service: string }) => - `Disconnect ${service}`, - disconnectServiceConfirm: ({ service }: { service: string }) => - `Are you sure you want to disconnect ${service} from your account?`, - disconnect: 'Disconnect', - failedToConnectTerminal: 'Failed to connect terminal', - cameraPermissionsRequiredToConnectTerminal: 'Camera permissions are required to connect terminal', - failedToLinkDevice: 'Failed to link device', - cameraPermissionsRequiredToScanQr: 'Camera permissions are required to scan QR codes' - }, - - navigation: { - // Navigation titles and screen headers - connectTerminal: 'Connect Terminal', - linkNewDevice: 'Link New Device', - restoreWithSecretKey: 'Restore with Secret Key', - whatsNew: "What's New", - friends: 'Friends', - }, - - welcome: { - // Main welcome screen for unauthenticated users - title: 'Codex and Claude Code mobile client', - subtitle: 'End-to-end encrypted and your account is stored only on your device.', - createAccount: 'Create account', - linkOrRestoreAccount: 'Link or restore account', - loginWithMobileApp: 'Login with mobile app', - }, - - review: { - // Used by utils/requestReview.ts - enjoyingApp: 'Enjoying the app?', - feedbackPrompt: "We'd love to hear your feedback!", - yesILoveIt: 'Yes, I love it!', - notReally: 'Not really' - }, - - items: { - // Used by Item component for copy toast - copiedToClipboard: ({ label }: { label: string }) => `${label} copied to clipboard` - }, - - machine: { - launchNewSessionInDirectory: 'Launch New Session in Directory', - offlineUnableToSpawn: 'Launcher disabled while machine is offline', - offlineHelp: '• Make sure your computer is online\n• Run `happy daemon status` to diagnose\n• Are you running the latest CLI version? Upgrade with `npm install -g happy-coder@latest`', - daemon: 'Daemon', - status: 'Status', - stopDaemon: 'Stop Daemon', - lastKnownPid: 'Last Known PID', - lastKnownHttpPort: 'Last Known HTTP Port', - startedAt: 'Started At', - cliVersion: 'CLI Version', - daemonStateVersion: 'Daemon State Version', - activeSessions: ({ count }: { count: number }) => `Active Sessions (${count})`, - machineGroup: 'Machine', - host: 'Host', - machineId: 'Machine ID', - username: 'Username', - homeDirectory: 'Home Directory', - platform: 'Platform', - architecture: 'Architecture', - lastSeen: 'Last Seen', - never: 'Never', - metadataVersion: 'Metadata Version', - untitledSession: 'Untitled Session', - back: 'Back', - }, - - message: { - switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, - unknownEvent: 'Unknown event', - usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, - unknownTime: 'unknown time', - }, - - codex: { - // Codex permission dialog buttons - permissions: { - yesForSession: "Yes, and don't ask for a session", - stopAndExplain: 'Stop, and explain what to do', - } - }, - - claude: { - // Claude permission dialog buttons - permissions: { - yesAllowAllEdits: 'Yes, allow all edits during this session', - yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and provide feedback', - } - }, - - textSelection: { - // Text selection screen - selectText: 'Select text range', - title: 'Select Text', - noTextProvided: 'No text provided', - textNotFound: 'Text not found or expired', - textCopied: 'Text copied to clipboard', - failedToCopy: 'Failed to copy text to clipboard', - noTextToCopy: 'No text available to copy', - }, - - markdown: { - // Markdown copy functionality - codeCopied: 'Code copied', - copyFailed: 'Copy failed', - mermaidRenderFailed: 'Failed to render mermaid diagram', - }, - - artifacts: { - // Artifacts feature - title: 'Artifacts', - countSingular: '1 artifact', - countPlural: ({ count }: { count: number }) => `${count} artifacts`, - empty: 'No artifacts yet', - emptyDescription: 'Create your first artifact to get started', - new: 'New Artifact', - edit: 'Edit Artifact', - delete: 'Delete', - updateError: 'Failed to update artifact. Please try again.', - notFound: 'Artifact not found', - discardChanges: 'Discard changes?', - discardChangesDescription: 'You have unsaved changes. Are you sure you want to discard them?', - deleteConfirm: 'Delete artifact?', - deleteConfirmDescription: 'This action cannot be undone', - titleLabel: 'TITLE', - titlePlaceholder: 'Enter a title for your artifact', - bodyLabel: 'CONTENT', - bodyPlaceholder: 'Write your content here...', - emptyFieldsError: 'Please enter a title or content', - createError: 'Failed to create artifact. Please try again.', - save: 'Save', - saving: 'Saving...', - loading: 'Loading artifacts...', - error: 'Failed to load artifact', - }, - - friends: { - // Friends feature - title: 'Friends', - manageFriends: 'Manage your friends and connections', - searchTitle: 'Find Friends', - pendingRequests: 'Friend Requests', - myFriends: 'My Friends', - noFriendsYet: "You don't have any friends yet", - findFriends: 'Find Friends', - remove: 'Remove', - pendingRequest: 'Pending', - sentOn: ({ date }: { date: string }) => `Sent on ${date}`, - accept: 'Accept', - reject: 'Reject', - addFriend: 'Add Friend', - alreadyFriends: 'Already Friends', - requestPending: 'Request Pending', - searchInstructions: 'Enter a username to search for friends', - searchPlaceholder: 'Enter username...', - searching: 'Searching...', - userNotFound: 'User not found', - noUserFound: 'No user found with that username', - checkUsername: 'Please check the username and try again', - howToFind: 'How to Find Friends', - findInstructions: 'Search for friends by their username. Both you and your friend need to have GitHub connected to send friend requests.', - requestSent: 'Friend request sent!', - requestAccepted: 'Friend request accepted!', - requestRejected: 'Friend request rejected', - friendRemoved: 'Friend removed', - confirmRemove: 'Remove Friend', - confirmRemoveMessage: 'Are you sure you want to remove this friend?', - cannotAddYourself: 'You cannot send a friend request to yourself', - bothMustHaveGithub: 'Both users must have GitHub connected to become friends', - status: { - none: 'Not connected', - requested: 'Request sent', - pending: 'Request pending', - friend: 'Friends', - rejected: 'Rejected', - }, - acceptRequest: 'Accept Request', - removeFriend: 'Remove Friend', - removeFriendConfirm: ({ name }: { name: string }) => `Are you sure you want to remove ${name} as a friend?`, - requestSentDescription: ({ name }: { name: string }) => `Your friend request has been sent to ${name}`, - requestFriendship: 'Request friendship', - cancelRequest: 'Cancel friendship request', - cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, - denyRequest: 'Deny friendship', - nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, - }, - - usage: { - // Usage panel strings - today: 'Today', - last7Days: 'Last 7 days', - last30Days: 'Last 30 days', - totalTokens: 'Total Tokens', - totalCost: 'Total Cost', - tokens: 'Tokens', - cost: 'Cost', - usageOverTime: 'Usage over time', - byModel: 'By Model', - noData: 'No usage data available', - }, - - feed: { - // Feed notifications for friend requests and acceptances - friendRequestFrom: ({ name }: { name: string }) => `${name} sent you a friend request`, - friendRequestGeneric: 'New friend request', - friendAccepted: ({ name }: { name: string }) => `You are now friends with ${name}`, - friendAcceptedGeneric: 'Friend request accepted', - }, - - profiles: { - // Profile management feature - title: 'Profiles', - subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', - defaultModel: 'Default Model', - addProfile: 'Add Profile', - profileName: 'Profile Name', - enterName: 'Enter profile name', - baseURL: 'Base URL', - authToken: 'Auth Token', - enterToken: 'Enter auth token', - model: 'Model', - tmuxSession: 'Tmux Session', - enterTmuxSession: 'Enter tmux session name', - tmuxTempDir: 'Tmux Temp Directory', - enterTmuxTempDir: 'Enter temp directory path', - tmuxUpdateEnvironment: 'Update environment automatically', - nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', - editProfile: 'Edit Profile', - addProfileTitle: 'Add New Profile', - delete: { - title: 'Delete Profile', - message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, - confirm: 'Delete', - cancel: 'Cancel', - }, - } -} as const; - -export type Translations = typeof en; - -/** - * Generic translation type that matches the structure of Translations - * but allows different string values (for other languages) - */ -export type TranslationStructure = { - readonly [K in keyof Translations]: { - readonly [P in keyof Translations[K]]: Translations[K][P] extends string - ? string - : Translations[K][P] extends (...args: any[]) => string - ? Translations[K][P] - : Translations[K][P] extends object - ? { - readonly [Q in keyof Translations[K][P]]: Translations[K][P][Q] extends string - ? string - : Translations[K][P][Q] - } - : Translations[K][P] - } -}; diff --git a/sources/text/_types.ts b/sources/text/_types.ts new file mode 100644 index 000000000..435f5471e --- /dev/null +++ b/sources/text/_types.ts @@ -0,0 +1,3 @@ +export type { TranslationStructure } from './translations/en'; + +export type Translations = import('./translations/en').TranslationStructure; diff --git a/sources/text/index.ts b/sources/text/index.ts index e627bb855..a05afb9d6 100644 --- a/sources/text/index.ts +++ b/sources/text/index.ts @@ -1,4 +1,5 @@ -import { en, type Translations, type TranslationStructure } from './_default'; +import { en } from './translations/en'; +import type { Translations, TranslationStructure } from './_types'; import { ru } from './translations/ru'; import { pl } from './translations/pl'; import { es } from './translations/es'; @@ -98,13 +99,11 @@ let found = false; if (settings.settings.preferredLanguage && settings.settings.preferredLanguage in translations) { currentLanguage = settings.settings.preferredLanguage as SupportedLanguage; found = true; - console.log(`[i18n] Using preferred language: ${currentLanguage}`); } // Read from device if (!found) { let locales = Localization.getLocales(); - console.log(`[i18n] Device locales:`, locales.map(l => l.languageCode)); for (let l of locales) { if (l.languageCode) { // Expo added special handling for Chinese variants using script code https://github.com/expo/expo/pull/34984 @@ -114,35 +113,26 @@ if (!found) { // We only have translations for simplified Chinese right now, but looking for help with traditional Chinese. if (l.languageScriptCode === 'Hans') { chineseVariant = 'zh-Hans'; - // } else if (l.languageScriptCode === 'Hant') { - // chineseVariant = 'zh-Hant'; } - console.log(`[i18n] Chinese script code: ${l.languageScriptCode} -> ${chineseVariant}`); - if (chineseVariant && chineseVariant in translations) { currentLanguage = chineseVariant as SupportedLanguage; - console.log(`[i18n] Using Chinese variant: ${currentLanguage}`); break; } currentLanguage = 'zh-Hans'; - console.log(`[i18n] Falling back to simplified Chinese: zh-Hans`); break; } // Direct match for non-Chinese languages if (l.languageCode in translations) { currentLanguage = l.languageCode as SupportedLanguage; - console.log(`[i18n] Using device locale: ${currentLanguage}`); break; } } } } -console.log(`[i18n] Final language: ${currentLanguage}`); - /** * Main translation function with strict typing * diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 46f9d4f9c..0a8c94ed7 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Catalan plural helper function @@ -31,6 +31,8 @@ export const ca: TranslationStructure = { common: { // Simple string constants + add: 'Afegeix', + actions: 'Accions', cancel: 'Cancel·la', authenticate: 'Autentica', save: 'Desa', @@ -47,6 +49,9 @@ export const ca: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descarta', + discardChanges: 'Descarta els canvis', + unsavedChangesWarning: 'Tens canvis sense desar.', + keepEditing: 'Continua editant', version: 'Versió', copied: 'Copiat', copy: 'Copiar', @@ -60,6 +65,10 @@ export const ca: TranslationStructure = { retry: 'Torna-ho a provar', delete: 'Elimina', optional: 'Opcional', + noMatches: 'Sense coincidències', + all: 'Tots', + machine: 'màquina', + clearSearch: 'Neteja la cerca', }, profile: { @@ -208,6 +217,15 @@ export const ca: TranslationStructure = { enhancedSessionWizard: 'Assistent de sessió millorat', enhancedSessionWizardEnabled: 'Llançador de sessió amb perfil actiu', enhancedSessionWizardDisabled: 'Usant el llançador de sessió estàndard', + profiles: 'Perfils d\'IA', + profilesEnabled: 'Selecció de perfils activada', + profilesDisabled: 'Selecció de perfils desactivada', + pickerSearch: 'Cerca als selectors', + pickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquina i camí', + machinePickerSearch: 'Cerca de màquines', + machinePickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquines', + pathPickerSearch: 'Cerca de camins', + pathPickerSearchSubtitle: 'Mostra un camp de cerca als selectors de camins', }, errors: { @@ -260,6 +278,9 @@ export const ca: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Inicia una nova sessió', + selectMachineTitle: 'Selecciona màquina', + selectPathTitle: 'Selecciona camí', + searchPathsPlaceholder: 'Cerca camins...', noMachinesFound: 'No s\'han trobat màquines. Inicia una sessió de Happy al teu ordinador primer.', allMachinesOffline: 'Totes les màquines estan fora de línia', machineDetails: 'Veure detalls de la màquina →', @@ -275,6 +296,26 @@ export const ca: TranslationStructure = { startNewSessionInFolder: 'Nova sessió aquí', noMachineSelected: 'Si us plau, selecciona una màquina per iniciar la sessió', noPathSelected: 'Si us plau, selecciona un directori per iniciar la sessió', + machinePicker: { + searchPlaceholder: 'Cerca màquines...', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + allTitle: 'Totes', + emptyMessage: 'No hi ha màquines disponibles', + }, + pathPicker: { + enterPathTitle: 'Introdueix el camí', + enterPathPlaceholder: 'Introdueix un camí...', + customPathTitle: 'Camí personalitzat', + recentTitle: 'Recents', + favoritesTitle: 'Preferits', + suggestedTitle: 'Suggerits', + allTitle: 'Totes', + emptyRecent: 'No hi ha camins recents', + emptyFavorites: 'No hi ha camins preferits', + emptySuggested: 'No hi ha camins suggerits', + emptyAll: 'No hi ha camins', + }, sessionType: { title: 'Tipus de sessió', simple: 'Simple', @@ -336,6 +377,7 @@ export const ca: TranslationStructure = { happySessionId: 'ID de la sessió de Happy', claudeCodeSessionId: 'ID de la sessió de Claude Code', claudeCodeSessionIdCopied: 'ID de la sessió de Claude Code copiat al porta-retalls', + aiProfile: 'Perfil d\'IA', aiProvider: 'Proveïdor d\'IA', failedToCopyClaudeCodeSessionId: 'Ha fallat copiar l\'ID de la sessió de Claude Code', metadataCopied: 'Metadades copiades al porta-retalls', @@ -390,12 +432,19 @@ export const ca: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Variables d\'entorn', + titleWithCount: ({ count }: { count: number }) => `Variables d'entorn (${count})`, + }, permissionMode: { title: 'MODE DE PERMISOS', default: 'Per defecte', acceptEdits: 'Accepta edicions', plan: 'Mode de planificació', bypassPermissions: 'Mode Yolo', + badgeAccept: 'Accepta', + badgePlan: 'Pla', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Accepta totes les edicions', badgeBypassAllPermissions: 'Omet tots els permisos', badgePlanMode: 'Mode de planificació', @@ -415,7 +464,7 @@ export const ca: TranslationStructure = { readOnly: 'Read Only Mode', safeYolo: 'Safe YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', + badgeReadOnly: 'Només lectura', badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, @@ -430,14 +479,29 @@ export const ca: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODE DE PERMISOS', + title: 'MODE DE PERMISOS GEMINI', default: 'Per defecte', - acceptEdits: 'Accepta edicions', - plan: 'Mode de planificació', - bypassPermissions: 'Mode Yolo', - badgeAcceptAllEdits: 'Accepta totes les edicions', - badgeBypassAllPermissions: 'Omet tots els permisos', - badgePlanMode: 'Mode de planificació', + readOnly: 'Només lectura', + safeYolo: 'YOLO segur', + yolo: 'YOLO', + badgeReadOnly: 'Només lectura', + badgeSafeYolo: 'YOLO segur', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Més capaç', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Ràpid i eficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Més ràpid', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restant`, @@ -504,6 +568,10 @@ export const ca: TranslationStructure = { applyChanges: 'Actualitza fitxer', viewDiff: 'Canvis del fitxer actual', question: 'Pregunta', + changeTitle: 'Canvia el títol', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -894,8 +962,127 @@ export const ca: TranslationStructure = { tmuxTempDir: 'Directori temporal tmux', enterTmuxTempDir: 'Introdueix el directori temporal tmux', tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux', - deleteConfirm: 'Segur que vols eliminar aquest perfil?', + deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`, nameRequired: 'El nom del perfil és obligatori', + builtIn: 'Integrat', + custom: 'Personalitzat', + builtInSaveAsHint: 'Desar un perfil integrat crea un nou perfil personalitzat.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Preferits', + custom: 'Els teus perfils', + builtIn: 'Perfils integrats', + }, + actions: { + viewEnvironmentVariables: 'Variables d\'entorn', + addToFavorites: 'Afegeix als preferits', + removeFromFavorites: 'Treu dels preferits', + editProfile: 'Edita el perfil', + duplicateProfile: 'Duplica el perfil', + deleteProfile: 'Elimina el perfil', + }, + copySuffix: '(Còpia)', + duplicateName: 'Ja existeix un perfil amb aquest nom', + setupInstructions: { + title: 'Instruccions de configuració', + viewOfficialGuide: 'Veure la guia oficial de configuració', + }, + defaultSessionType: 'Tipus de sessió predeterminat', + defaultPermissionMode: { + title: 'Mode de permisos predeterminat', + descriptions: { + default: 'Demana permisos', + acceptEdits: 'Aprova edicions automàticament', + plan: 'Planifica abans d\'executar', + bypassPermissions: 'Salta tots els permisos', + }, + }, + aiBackend: { + title: 'Backend d\'IA', + selectAtLeastOneError: 'Selecciona com a mínim un backend d\'IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Inicia sessions a Tmux', + spawnSessionsEnabledSubtitle: 'Les sessions s\'inicien en noves finestres de tmux.', + spawnSessionsDisabledSubtitle: 'Les sessions s\'inicien en un shell normal (sense integració amb tmux)', + sessionNamePlaceholder: 'Buit = sessió actual/més recent', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Previsualitza màquina', + itemTitle: 'Màquina de previsualització per a variables d\'entorn', + selectMachine: 'Selecciona màquina', + resolveSubtitle: 'S\'usa només per previsualitzar els valors resolts a continuació (no canvia el que es desa).', + selectSubtitle: 'Selecciona una màquina per previsualitzar els valors resolts a continuació.', + }, + environmentVariables: { + title: 'Variables d\'entorn', + addVariable: 'Afegeix variable', + namePlaceholder: 'Nom de variable (p. ex., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ex., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Introdueix un nom de variable.', + invalidNameFormat: 'Els noms de variable han de ser lletres majúscules, números i guions baixos, i no poden començar amb un número.', + duplicateName: 'Aquesta variable ja existeix.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de reserva:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor per defecte', + secretNotRetrieved: 'Valor secret - no es recupera per seguretat', + secretToggleLabel: 'Secret', + secretToggleSubtitle: 'Amaga el valor a la UI i evita obtenir-lo de la màquina per a la previsualització.', + secretToggleEnforcedByDaemon: 'Imposat pel dimoni', + secretToggleResetToAuto: 'Restablir a automàtic', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `S'està substituint el valor predeterminat documentat: ${expectedValue}`, + useMachineEnvToggle: 'Utilitza el valor de l\'entorn de la màquina', + resolvedOnSessionStart: 'Es resol quan la sessió s\'inicia a la màquina seleccionada.', + sourceVariableLabel: 'Variable d\'origen', + sourceVariablePlaceholder: 'Nom de variable d\'origen (p. ex., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Comprovant ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Buit a ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Buit a ${machine} (utilitzant reserva)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No trobat a ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No trobat a ${machine} (utilitzant reserva)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor trobat a ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiereix del valor documentat: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ocult per seguretat`, + hiddenValue: '***ocult***', + emptyValue: '(buit)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessió rebrà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variables d'entorn · ${profileName}`, + descriptionPrefix: 'Aquestes variables d\'entorn s\'envien en iniciar la sessió. Els valors es resolen usant el dimoni a', + descriptionFallbackMachine: 'la màquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hi ha variables d\'entorn configurades per a aquest perfil.', + checkingSuffix: '(comprovant…)', + detail: { + fixed: 'Fix', + machine: 'Màquina', + checking: 'Comprovant', + fallback: 'Reserva', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `Estàs segur que vols eliminar "${name}"? Aquesta acció no es pot desfer.`, diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 7bddc729b..62e9701af 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -1,5 +1,3 @@ -import type { TranslationStructure } from '../_default'; - /** * English plural helper function * English has 2 plural forms: singular, plural @@ -14,10 +12,10 @@ function plural({ count, singular, plural }: { count: number; singular: string; * ENGLISH TRANSLATIONS - DEDICATED FILE * * This file represents the new translation architecture where each language - * has its own dedicated file instead of being embedded in _default.ts. + * has its own dedicated file instead of being embedded in _types.ts. * * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects + * - Previously: All languages in a single default file * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) * - Benefit: Better maintainability, smaller files, easier language management * @@ -29,7 +27,7 @@ function plural({ count, singular, plural }: { count: number; singular: string; * - Type safety enforced by TranslationStructure interface * - New translation keys must be added to ALL language files */ -export const en: TranslationStructure = { +export const en = { tabs: { // Tab navigation labels inbox: 'Inbox', @@ -46,6 +44,8 @@ export const en: TranslationStructure = { common: { // Simple string constants + add: 'Add', + actions: 'Actions', cancel: 'Cancel', authenticate: 'Authenticate', save: 'Save', @@ -62,6 +62,9 @@ export const en: TranslationStructure = { yes: 'Yes', no: 'No', discard: 'Discard', + discardChanges: 'Discard changes', + unsavedChangesWarning: 'You have unsaved changes.', + keepEditing: 'Keep editing', version: 'Version', copy: 'Copy', copied: 'Copied', @@ -75,6 +78,10 @@ export const en: TranslationStructure = { retry: 'Retry', delete: 'Delete', optional: 'optional', + noMatches: 'No matches', + all: 'All', + machine: 'machine', + clearSearch: 'Clear search', }, profile: { @@ -211,8 +218,8 @@ export const en: TranslationStructure = { webFeatures: 'Web Features', webFeaturesDescription: 'Features available only in the web version of the app.', enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send messages', - enterToSendDisabled: 'Press ⌘+Enter to send messages', + enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)', + enterToSendDisabled: 'Enter inserts a new line', commandPalette: 'Command Palette', commandPaletteEnabled: 'Press ⌘K to open', commandPaletteDisabled: 'Quick command access disabled', @@ -223,6 +230,15 @@ export const en: TranslationStructure = { enhancedSessionWizard: 'Enhanced Session Wizard', enhancedSessionWizardEnabled: 'Profile-first session launcher active', enhancedSessionWizardDisabled: 'Using standard session launcher', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { @@ -275,6 +291,9 @@ export const en: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Start New Session', + selectMachineTitle: 'Select Machine', + selectPathTitle: 'Select Path', + searchPathsPlaceholder: 'Search paths...', noMachinesFound: 'No machines found. Start a Happy session on your computer first.', allMachinesOffline: 'All machines appear offline', machineDetails: 'View machine details →', @@ -290,6 +309,26 @@ export const en: TranslationStructure = { notConnectedToServer: 'Not connected to server. Check your internet connection.', noMachineSelected: 'Please select a machine to start the session', noPathSelected: 'Please select a directory to start the session in', + machinePicker: { + searchPlaceholder: 'Search machines...', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + allTitle: 'All', + emptyMessage: 'No machines available', + }, + pathPicker: { + enterPathTitle: 'Enter Path', + enterPathPlaceholder: 'Enter a path...', + customPathTitle: 'Custom Path', + recentTitle: 'Recent', + favoritesTitle: 'Favorites', + suggestedTitle: 'Suggested', + allTitle: 'All', + emptyRecent: 'No recent paths', + emptyFavorites: 'No favorite paths', + emptySuggested: 'No suggested paths', + emptyAll: 'No paths', + }, sessionType: { title: 'Session Type', simple: 'Simple', @@ -315,7 +354,7 @@ export const en: TranslationStructure = { }, session: { - inputPlaceholder: 'Type a message ...', + inputPlaceholder: 'What would you like to work on?', }, commandPalette: { @@ -351,6 +390,7 @@ export const en: TranslationStructure = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', + aiProfile: 'AI Profile', aiProvider: 'AI Provider', failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', metadataCopied: 'Metadata copied to clipboard', @@ -405,12 +445,19 @@ export const en: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Env Vars', + titleWithCount: ({ count }: { count: number }) => `Env Vars (${count})`, + }, permissionMode: { title: 'PERMISSION MODE', default: 'Default', acceptEdits: 'Accept Edits', plan: 'Plan Mode', bypassPermissions: 'Yolo Mode', + badgeAccept: 'Accept', + badgePlan: 'Plan', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Accept All Edits', badgeBypassAllPermissions: 'Bypass All Permissions', badgePlanMode: 'Plan Mode', @@ -430,7 +477,7 @@ export const en: TranslationStructure = { readOnly: 'Read Only Mode', safeYolo: 'Safe YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', + badgeReadOnly: 'Read Only', badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, @@ -454,6 +501,21 @@ export const en: TranslationStructure = { badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Most capable', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Fast & efficient', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Fastest', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, }, @@ -519,6 +581,10 @@ export const en: TranslationStructure = { applyChanges: 'Update file', viewDiff: 'Current file changes', question: 'Question', + changeTitle: 'Change Title', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, askUserQuestion: { submit: 'Submit Answer', @@ -902,8 +968,8 @@ export const en: TranslationStructure = { // Profile management feature title: 'Profiles', subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', + noProfile: 'Default Environment', + noProfileDescription: 'Use the machine environment without profile variables', defaultModel: 'Default Model', addProfile: 'Add Profile', profileName: 'Profile Name', @@ -918,9 +984,128 @@ export const en: TranslationStructure = { enterTmuxTempDir: 'Enter temp directory path', tmuxUpdateEnvironment: 'Update environment automatically', nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`, editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', + builtIn: 'Built-in', + custom: 'Custom', + builtInSaveAsHint: 'Saving a built-in profile creates a new custom profile.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Favorites', + custom: 'Your Profiles', + builtIn: 'Built-in Profiles', + }, + actions: { + viewEnvironmentVariables: 'Environment Variables', + addToFavorites: 'Add to favorites', + removeFromFavorites: 'Remove from favorites', + editProfile: 'Edit profile', + duplicateProfile: 'Duplicate profile', + deleteProfile: 'Delete profile', + }, + copySuffix: '(Copy)', + duplicateName: 'A profile with this name already exists', + setupInstructions: { + title: 'Setup Instructions', + viewOfficialGuide: 'View Official Setup Guide', + }, + defaultSessionType: 'Default Session Type', + defaultPermissionMode: { + title: 'Default Permission Mode', + descriptions: { + default: 'Ask for permissions', + acceptEdits: 'Auto-approve edits', + plan: 'Plan before executing', + bypassPermissions: 'Skip all permissions', + }, + }, + aiBackend: { + title: 'AI Backend', + selectAtLeastOneError: 'Select at least one AI backend.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Spawn Sessions in Tmux', + spawnSessionsEnabledSubtitle: 'Sessions spawn in new tmux windows.', + spawnSessionsDisabledSubtitle: 'Sessions spawn in regular shell (no tmux integration)', + sessionNamePlaceholder: 'Empty = current/most recent session', + tempDirPlaceholder: '/tmp (optional)', + }, + previewMachine: { + title: 'Preview Machine', + itemTitle: 'Preview machine for environment variables preview', + selectMachine: 'Select machine', + resolveSubtitle: 'Used only to preview the resolved values below (does not change what is saved).', + selectSubtitle: 'Select a machine to preview the resolved values below.', + }, + environmentVariables: { + title: 'Environment Variables', + addVariable: 'Add Variable', + namePlaceholder: 'Variable name (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Value (e.g., my-value or ${MY_VAR})', + validation: { + nameRequired: 'Enter a variable name.', + invalidNameFormat: 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.', + duplicateName: 'That variable already exists.', + }, + card: { + valueLabel: 'Value:', + fallbackValueLabel: 'Fallback value:', + valueInputPlaceholder: 'Value', + defaultValueInputPlaceholder: 'Default value', + secretNotRetrieved: 'Secret value - not retrieved for security', + secretToggleLabel: 'Secret', + secretToggleSubtitle: 'Hide the value in the UI and avoid fetching it from the machine for preview.', + secretToggleEnforcedByDaemon: 'Enforced by daemon', + secretToggleResetToAuto: 'Reset to auto', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Overriding documented default: ${expectedValue}`, + useMachineEnvToggle: 'Use value from machine environment', + resolvedOnSessionStart: 'Resolved when the session starts on the selected machine.', + sourceVariableLabel: 'Source variable', + sourceVariablePlaceholder: 'Source variable name (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Checking ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Empty on ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Empty on ${machine} (using fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Not found on ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Not found on ${machine} (using fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Value found on ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Differs from documented value: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - hidden for security`, + hiddenValue: '***hidden***', + emptyValue: '(empty)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Session will receive: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Env Vars · ${profileName}`, + descriptionPrefix: 'These environment variables are sent when starting the session. Values are resolved using the daemon on', + descriptionFallbackMachine: 'the selected machine', + descriptionSuffix: '.', + emptyMessage: 'No environment variables are set for this profile.', + checkingSuffix: '(checking…)', + detail: { + fixed: 'Fixed', + machine: 'Machine', + checking: 'Checking', + fallback: 'Fallback', + missing: 'Missing', + }, + }, + }, delete: { title: 'Delete Profile', message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, @@ -928,6 +1113,8 @@ export const en: TranslationStructure = { cancel: 'Cancel', }, } -} as const; +}; + +export type TranslationStructure = typeof en; -export type TranslationsEn = typeof en; \ No newline at end of file +export type TranslationsEn = typeof en; diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index a79953775..ced04cfee 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Spanish plural helper function @@ -31,6 +31,8 @@ export const es: TranslationStructure = { common: { // Simple string constants + add: 'Añadir', + actions: 'Acciones', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Guardar', @@ -47,6 +49,9 @@ export const es: TranslationStructure = { yes: 'Sí', no: 'No', discard: 'Descartar', + discardChanges: 'Descartar cambios', + unsavedChangesWarning: 'Tienes cambios sin guardar.', + keepEditing: 'Seguir editando', version: 'Versión', copied: 'Copiado', copy: 'Copiar', @@ -60,6 +65,10 @@ export const es: TranslationStructure = { retry: 'Reintentar', delete: 'Eliminar', optional: 'opcional', + noMatches: 'Sin coincidencias', + all: 'Todo', + machine: 'máquina', + clearSearch: 'Limpiar búsqueda', }, profile: { @@ -208,6 +217,15 @@ export const es: TranslationStructure = { enhancedSessionWizard: 'Asistente de sesión mejorado', enhancedSessionWizardEnabled: 'Lanzador de sesión con perfil activo', enhancedSessionWizardDisabled: 'Usando el lanzador de sesión estándar', + profiles: 'Perfiles de IA', + profilesEnabled: 'Selección de perfiles habilitada', + profilesDisabled: 'Selección de perfiles deshabilitada', + pickerSearch: 'Búsqueda en selectores', + pickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquina y ruta', + machinePickerSearch: 'Búsqueda de máquinas', + machinePickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquinas', + pathPickerSearch: 'Búsqueda de rutas', + pathPickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de rutas', }, errors: { @@ -260,6 +278,9 @@ export const es: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nueva sesión', + selectMachineTitle: 'Seleccionar máquina', + selectPathTitle: 'Seleccionar ruta', + searchPathsPlaceholder: 'Buscar rutas...', noMachinesFound: 'No se encontraron máquinas. Inicia una sesión de Happy en tu computadora primero.', allMachinesOffline: 'Todas las máquinas están desconectadas', machineDetails: 'Ver detalles de la máquina →', @@ -275,6 +296,26 @@ export const es: TranslationStructure = { startNewSessionInFolder: 'Nueva sesión aquí', noMachineSelected: 'Por favor, selecciona una máquina para iniciar la sesión', noPathSelected: 'Por favor, selecciona un directorio para iniciar la sesión', + machinePicker: { + searchPlaceholder: 'Buscar máquinas...', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'No hay máquinas disponibles', + }, + pathPicker: { + enterPathTitle: 'Ingresar ruta', + enterPathPlaceholder: 'Ingresa una ruta...', + customPathTitle: 'Ruta personalizada', + recentTitle: 'Recientes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridas', + allTitle: 'Todas', + emptyRecent: 'No hay rutas recientes', + emptyFavorites: 'No hay rutas favoritas', + emptySuggested: 'No hay rutas sugeridas', + emptyAll: 'No hay rutas', + }, sessionType: { title: 'Tipo de sesión', simple: 'Simple', @@ -336,6 +377,7 @@ export const es: TranslationStructure = { happySessionId: 'ID de sesión de Happy', claudeCodeSessionId: 'ID de sesión de Claude Code', claudeCodeSessionIdCopied: 'ID de sesión de Claude Code copiado al portapapeles', + aiProfile: 'Perfil de IA', aiProvider: 'Proveedor de IA', failedToCopyClaudeCodeSessionId: 'Falló al copiar ID de sesión de Claude Code', metadataCopied: 'Metadatos copiados al portapapeles', @@ -390,12 +432,19 @@ export const es: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Variables de entorno', + titleWithCount: ({ count }: { count: number }) => `Variables de entorno (${count})`, + }, permissionMode: { title: 'MODO DE PERMISOS', default: 'Por defecto', acceptEdits: 'Aceptar ediciones', plan: 'Modo de planificación', bypassPermissions: 'Modo Yolo', + badgeAccept: 'Aceptar', + badgePlan: 'Plan', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Aceptar todas las ediciones', badgeBypassAllPermissions: 'Omitir todos los permisos', badgePlanMode: 'Modo de planificación', @@ -415,7 +464,7 @@ export const es: TranslationStructure = { readOnly: 'Read Only Mode', safeYolo: 'Safe YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', + badgeReadOnly: 'Solo lectura', badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, @@ -430,14 +479,29 @@ export const es: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODO DE PERMISOS', + title: 'MODO DE PERMISOS GEMINI', default: 'Por defecto', - acceptEdits: 'Aceptar ediciones', - plan: 'Modo de planificación', - bypassPermissions: 'Modo Yolo', - badgeAcceptAllEdits: 'Aceptar todas las ediciones', - badgeBypassAllPermissions: 'Omitir todos los permisos', - badgePlanMode: 'Modo de planificación', + readOnly: 'Solo lectura', + safeYolo: 'YOLO seguro', + yolo: 'YOLO', + badgeReadOnly: 'Solo lectura', + badgeSafeYolo: 'YOLO seguro', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Más capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido y eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Más rápido', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -504,6 +568,10 @@ export const es: TranslationStructure = { applyChanges: 'Actualizar archivo', viewDiff: 'Cambios del archivo actual', question: 'Pregunta', + changeTitle: 'Cambiar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -903,9 +971,128 @@ export const es: TranslationStructure = { enterTmuxTempDir: 'Ingrese la ruta del directorio temporal', tmuxUpdateEnvironment: 'Actualizar entorno automáticamente', nameRequired: 'El nombre del perfil es requerido', - deleteConfirm: '¿Estás seguro de que quieres eliminar el perfil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar el perfil "${name}"?`, editProfile: 'Editar Perfil', addProfileTitle: 'Agregar Nuevo Perfil', + builtIn: 'Integrado', + custom: 'Personalizado', + builtInSaveAsHint: 'Guardar un perfil integrado crea un nuevo perfil personalizado.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Favoritos', + custom: 'Tus perfiles', + builtIn: 'Perfiles integrados', + }, + actions: { + viewEnvironmentVariables: 'Variables de entorno', + addToFavorites: 'Agregar a favoritos', + removeFromFavorites: 'Quitar de favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Eliminar perfil', + }, + copySuffix: '(Copiar)', + duplicateName: 'Ya existe un perfil con este nombre', + setupInstructions: { + title: 'Instrucciones de configuración', + viewOfficialGuide: 'Ver la guía oficial de configuración', + }, + defaultSessionType: 'Tipo de sesión predeterminado', + defaultPermissionMode: { + title: 'Modo de permisos predeterminado', + descriptions: { + default: 'Pedir permisos', + acceptEdits: 'Aprobar ediciones automáticamente', + plan: 'Planificar antes de ejecutar', + bypassPermissions: 'Omitir todos los permisos', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecciona al menos un backend de IA.', + claudeSubtitle: 'CLI de Claude', + codexSubtitle: 'CLI de Codex', + geminiSubtitleExperimental: 'CLI de Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sesiones en Tmux', + spawnSessionsEnabledSubtitle: 'Las sesiones se abren en nuevas ventanas de tmux.', + spawnSessionsDisabledSubtitle: 'Las sesiones se abren en una shell normal (sin integración con tmux)', + sessionNamePlaceholder: 'Vacío = sesión actual/más reciente', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Vista previa de la máquina', + itemTitle: 'Máquina de vista previa para variables de entorno', + selectMachine: 'Seleccionar máquina', + resolveSubtitle: 'Se usa solo para previsualizar los valores resueltos abajo (no cambia lo que se guarda).', + selectSubtitle: 'Selecciona una máquina para previsualizar los valores resueltos abajo.', + }, + environmentVariables: { + title: 'Variables de entorno', + addVariable: 'Añadir variable', + namePlaceholder: 'Nombre de variable (p. ej., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (p. ej., mi-valor o ${MY_VAR})', + validation: { + nameRequired: 'Introduce un nombre de variable.', + invalidNameFormat: 'Los nombres de variables deben ser letras mayúsculas, números y guiones bajos, y no pueden empezar por un número.', + duplicateName: 'Esa variable ya existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de respaldo:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor predeterminado', + secretNotRetrieved: 'Valor secreto: no se recupera por seguridad', + secretToggleLabel: 'Secreto', + secretToggleSubtitle: 'Oculta el valor en la UI y evita obtenerlo de la máquina para la vista previa.', + secretToggleEnforcedByDaemon: 'Impuesto por el daemon', + secretToggleResetToAuto: 'Restablecer a automático', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sobrescribiendo el valor documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor del entorno de la máquina', + resolvedOnSessionStart: 'Se resuelve al iniciar la sesión en la máquina seleccionada.', + sourceVariableLabel: 'Variable de origen', + sourceVariablePlaceholder: 'Nombre de variable de origen (p. ej., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vacío en ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vacío en ${machine} (usando respaldo)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `No encontrado en ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No encontrado en ${machine} (usando respaldo)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado en ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Difiere del valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por seguridad`, + hiddenValue: '***oculto***', + emptyValue: '(vacío)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sesión recibirá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de entorno · ${profileName}`, + descriptionPrefix: 'Estas variables de entorno se envían al iniciar la sesión. Los valores se resuelven usando el daemon en', + descriptionFallbackMachine: 'la máquina seleccionada', + descriptionSuffix: '.', + emptyMessage: 'No hay variables de entorno configuradas para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fijo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Respaldo', + missing: 'Falta', + }, + }, + }, delete: { title: 'Eliminar Perfil', message: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar "${name}"? Esta acción no se puede deshacer.`, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index bfa52467a..498aa0cc3 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Italian plural helper function @@ -31,6 +31,8 @@ export const it: TranslationStructure = { common: { // Simple string constants + add: 'Aggiungi', + actions: 'Azioni', cancel: 'Annulla', authenticate: 'Autentica', save: 'Salva', @@ -46,6 +48,9 @@ export const it: TranslationStructure = { yes: 'Sì', no: 'No', discard: 'Scarta', + discardChanges: 'Scarta modifiche', + unsavedChangesWarning: 'Hai modifiche non salvate.', + keepEditing: 'Continua a modificare', version: 'Versione', copied: 'Copiato', copy: 'Copia', @@ -59,6 +64,10 @@ export const it: TranslationStructure = { retry: 'Riprova', delete: 'Elimina', optional: 'opzionale', + noMatches: 'Nessuna corrispondenza', + all: 'All', + machine: 'macchina', + clearSearch: 'Clear search', saveAs: 'Salva con nome', }, @@ -90,9 +99,128 @@ export const it: TranslationStructure = { enterTmuxTempDir: 'Inserisci percorso directory temporanea', tmuxUpdateEnvironment: 'Aggiorna ambiente automaticamente', nameRequired: 'Il nome del profilo è obbligatorio', - deleteConfirm: 'Sei sicuro di voler eliminare il profilo "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Sei sicuro di voler eliminare il profilo "${name}"?`, editProfile: 'Modifica profilo', addProfileTitle: 'Aggiungi nuovo profilo', + builtIn: 'Integrato', + custom: 'Personalizzato', + builtInSaveAsHint: 'Salvare un profilo integrato crea un nuovo profilo personalizzato.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Preferiti', + custom: 'I tuoi profili', + builtIn: 'Profili integrati', + }, + actions: { + viewEnvironmentVariables: 'Variabili ambiente', + addToFavorites: 'Aggiungi ai preferiti', + removeFromFavorites: 'Rimuovi dai preferiti', + editProfile: 'Modifica profilo', + duplicateProfile: 'Duplica profilo', + deleteProfile: 'Elimina profilo', + }, + copySuffix: '(Copy)', + duplicateName: 'Esiste già un profilo con questo nome', + setupInstructions: { + title: 'Istruzioni di configurazione', + viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione', + }, + defaultSessionType: 'Tipo di sessione predefinito', + defaultPermissionMode: { + title: 'Modalità di permesso predefinita', + descriptions: { + default: 'Chiedi permessi', + acceptEdits: 'Approva automaticamente le modifiche', + plan: 'Pianifica prima di eseguire', + bypassPermissions: 'Salta tutti i permessi', + }, + }, + aiBackend: { + title: 'Backend IA', + selectAtLeastOneError: 'Seleziona almeno un backend IA.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (sperimentale)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Avvia sessioni in Tmux', + spawnSessionsEnabledSubtitle: 'Le sessioni vengono avviate in nuove finestre di tmux.', + spawnSessionsDisabledSubtitle: 'Le sessioni vengono avviate in una shell normale (senza integrazione tmux)', + sessionNamePlaceholder: 'Vuoto = sessione corrente/più recente', + tempDirPlaceholder: '/tmp (opzionale)', + }, + previewMachine: { + title: 'Anteprima macchina', + itemTitle: 'Macchina di anteprima per variabili d\'ambiente', + selectMachine: 'Seleziona macchina', + resolveSubtitle: 'Usata solo per l\'anteprima dei valori risolti sotto (non cambia ciò che viene salvato).', + selectSubtitle: 'Seleziona una macchina per l\'anteprima dei valori risolti sotto.', + }, + environmentVariables: { + title: 'Variabili ambiente', + addVariable: 'Aggiungi variabile', + namePlaceholder: 'Nome variabile (es., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valore (es., my-value o ${MY_VAR})', + validation: { + nameRequired: 'Inserisci un nome variabile.', + invalidNameFormat: 'I nomi delle variabili devono usare lettere maiuscole, numeri e underscore e non possono iniziare con un numero.', + duplicateName: 'Questa variabile esiste già.', + }, + card: { + valueLabel: 'Valore:', + fallbackValueLabel: 'Valore di fallback:', + valueInputPlaceholder: 'Valore', + defaultValueInputPlaceholder: 'Valore predefinito', + secretNotRetrieved: 'Valore segreto - non recuperato per sicurezza', + secretToggleLabel: 'Segreto', + secretToggleSubtitle: 'Nasconde il valore nella UI ed evita di recuperarlo dalla macchina per l\'anteprima.', + secretToggleEnforcedByDaemon: 'Imposto dal daemon', + secretToggleResetToAuto: 'Ripristina su automatico', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Sostituzione del valore predefinito documentato: ${expectedValue}`, + useMachineEnvToggle: 'Usa valore dall\'ambiente della macchina', + resolvedOnSessionStart: 'Risolto quando la sessione viene avviata sulla macchina selezionata.', + sourceVariableLabel: 'Variabile sorgente', + sourceVariablePlaceholder: 'Nome variabile sorgente (es., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verifica ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vuoto su ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vuoto su ${machine} (uso fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Non trovato su ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Non trovato su ${machine} (uso fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valore trovato su ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diverso dal valore documentato: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - nascosto per sicurezza`, + hiddenValue: '***nascosto***', + emptyValue: '(vuoto)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `La sessione riceverà: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Variabili ambiente · ${profileName}`, + descriptionPrefix: 'Queste variabili ambiente vengono inviate all\'avvio della sessione. I valori vengono risolti dal daemon su', + descriptionFallbackMachine: 'la macchina selezionata', + descriptionSuffix: '.', + emptyMessage: 'Nessuna variabile ambiente è impostata per questo profilo.', + checkingSuffix: '(verifica…)', + detail: { + fixed: 'Fisso', + machine: 'Macchina', + checking: 'Verifica', + fallback: 'Fallback', + missing: 'Mancante', + }, + }, + }, delete: { title: 'Elimina profilo', message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`, @@ -237,6 +365,15 @@ export const it: TranslationStructure = { enhancedSessionWizard: 'Wizard sessione avanzato', enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo', enhancedSessionWizardDisabled: 'Usando avvio sessioni standard', + profiles: 'Profili IA', + profilesEnabled: 'Selezione profili abilitata', + profilesDisabled: 'Selezione profili disabilitata', + pickerSearch: 'Ricerca nei selettori', + pickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchina e percorso', + machinePickerSearch: 'Ricerca macchine', + machinePickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchine', + pathPickerSearch: 'Ricerca percorsi', + pathPickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di percorsi', }, errors: { @@ -289,6 +426,9 @@ export const it: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Avvia nuova sessione', + selectMachineTitle: 'Seleziona macchina', + selectPathTitle: 'Seleziona percorso', + searchPathsPlaceholder: 'Cerca percorsi...', noMachinesFound: 'Nessuna macchina trovata. Avvia prima una sessione Happy sul tuo computer.', allMachinesOffline: 'Tutte le macchine sembrano offline', machineDetails: 'Visualizza dettagli macchina →', @@ -304,6 +444,26 @@ export const it: TranslationStructure = { notConnectedToServer: 'Non connesso al server. Controlla la tua connessione Internet.', noMachineSelected: 'Seleziona una macchina per avviare la sessione', noPathSelected: 'Seleziona una directory in cui avviare la sessione', + machinePicker: { + searchPlaceholder: 'Cerca macchine...', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + allTitle: 'Tutte', + emptyMessage: 'Nessuna macchina disponibile', + }, + pathPicker: { + enterPathTitle: 'Inserisci percorso', + enterPathPlaceholder: 'Inserisci un percorso...', + customPathTitle: 'Percorso personalizzato', + recentTitle: 'Recenti', + favoritesTitle: 'Preferiti', + suggestedTitle: 'Suggeriti', + allTitle: 'Tutte', + emptyRecent: 'Nessun percorso recente', + emptyFavorites: 'Nessun percorso preferito', + emptySuggested: 'Nessun percorso suggerito', + emptyAll: 'Nessun percorso', + }, sessionType: { title: 'Tipo di sessione', simple: 'Semplice', @@ -365,6 +525,7 @@ export const it: TranslationStructure = { happySessionId: 'ID sessione Happy', claudeCodeSessionId: 'ID sessione Claude Code', claudeCodeSessionIdCopied: 'ID sessione Claude Code copiato negli appunti', + aiProfile: 'Profilo IA', aiProvider: 'Provider IA', failedToCopyClaudeCodeSessionId: 'Impossibile copiare l\'ID sessione Claude Code', metadataCopied: 'Metadati copiati negli appunti', @@ -419,12 +580,19 @@ export const it: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Var env', + titleWithCount: ({ count }: { count: number }) => `Var env (${count})`, + }, permissionMode: { title: 'MODALITÀ PERMESSI', default: 'Predefinito', acceptEdits: 'Accetta modifiche', plan: 'Modalità piano', bypassPermissions: 'Modalità YOLO', + badgeAccept: 'Accetta', + badgePlan: 'Piano', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Accetta tutte le modifiche', badgeBypassAllPermissions: 'Bypassa tutti i permessi', badgePlanMode: 'Modalità piano', @@ -461,12 +629,27 @@ export const it: TranslationStructure = { geminiPermissionMode: { title: 'MODALITÀ PERMESSI GEMINI', default: 'Predefinito', - acceptEdits: 'Accetta modifiche', - plan: 'Modalità piano', - bypassPermissions: 'Modalità YOLO', - badgeAcceptAllEdits: 'Accetta tutte le modifiche', - badgeBypassAllPermissions: 'Bypassa tutti i permessi', - badgePlanMode: 'Modalità piano', + readOnly: 'Modalità sola lettura', + safeYolo: 'YOLO sicuro', + yolo: 'YOLO', + badgeReadOnly: 'Modalità sola lettura', + badgeSafeYolo: 'YOLO sicuro', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODELLO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Il più potente', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Veloce ed efficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Il più veloce', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -537,6 +720,10 @@ export const it: TranslationStructure = { applyChanges: 'Aggiorna file', viewDiff: 'Modifiche file attuali', question: 'Domanda', + changeTitle: 'Cambia titolo', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminale(cmd: ${cmd})`, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index fe1007884..4118d55f9 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -5,17 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; - -/** - * Japanese plural helper function - * Japanese doesn't have grammatical plurals, so this just returns the appropriate form - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} +import type { TranslationStructure } from '../_types'; export const ja: TranslationStructure = { tabs: { @@ -34,6 +24,8 @@ export const ja: TranslationStructure = { common: { // Simple string constants + add: '追加', + actions: '操作', cancel: 'キャンセル', authenticate: '認証', save: '保存', @@ -49,6 +41,9 @@ export const ja: TranslationStructure = { yes: 'はい', no: 'いいえ', discard: '破棄', + discardChanges: '変更を破棄', + unsavedChangesWarning: '未保存の変更があります。', + keepEditing: '編集を続ける', version: 'バージョン', copied: 'コピーしました', copy: 'コピー', @@ -62,6 +57,10 @@ export const ja: TranslationStructure = { retry: '再試行', delete: '削除', optional: '任意', + noMatches: '一致するものがありません', + all: 'すべて', + machine: 'マシン', + clearSearch: '検索をクリア', saveAs: '名前を付けて保存', }, @@ -93,9 +92,128 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', + builtIn: '組み込み', + custom: 'カスタム', + builtInSaveAsHint: '組み込みプロファイルを保存すると、新しいカスタムプロファイルが作成されます。', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'お気に入り', + custom: 'あなたのプロファイル', + builtIn: '組み込みプロファイル', + }, + actions: { + viewEnvironmentVariables: '環境変数', + addToFavorites: 'お気に入りに追加', + removeFromFavorites: 'お気に入りから削除', + editProfile: 'プロファイルを編集', + duplicateProfile: 'プロファイルを複製', + deleteProfile: 'プロファイルを削除', + }, + copySuffix: '(コピー)', + duplicateName: '同じ名前のプロファイルが既に存在します', + setupInstructions: { + title: 'セットアップ手順', + viewOfficialGuide: '公式セットアップガイドを表示', + }, + defaultSessionType: 'デフォルトのセッションタイプ', + defaultPermissionMode: { + title: 'デフォルトの権限モード', + descriptions: { + default: '権限を要求する', + acceptEdits: '編集を自動承認', + plan: '実行前に計画', + bypassPermissions: 'すべての権限をスキップ', + }, + }, + aiBackend: { + title: 'AIバックエンド', + selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI(実験)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Tmuxでセッションを起動', + spawnSessionsEnabledSubtitle: 'セッションは新しいtmuxウィンドウで起動します。', + spawnSessionsDisabledSubtitle: 'セッションは通常のシェルで起動します(tmux連携なし)', + sessionNamePlaceholder: '空 = 現在/最近のセッション', + tempDirPlaceholder: '/tmp(任意)', + }, + previewMachine: { + title: 'マシンをプレビュー', + itemTitle: '環境変数のプレビュー用マシン', + selectMachine: 'マシンを選択', + resolveSubtitle: '下の解決後の値をプレビューするためだけに使用します(保存内容は変わりません)。', + selectSubtitle: '下の解決後の値をプレビューするマシンを選択してください。', + }, + environmentVariables: { + title: '環境変数', + addVariable: '変数を追加', + namePlaceholder: '変数名(例: MY_CUSTOM_VAR)', + valuePlaceholder: '値(例: my-value または ${MY_VAR})', + validation: { + nameRequired: '変数名を入力してください。', + invalidNameFormat: '変数名は大文字、数字、アンダースコアのみで、数字から始めることはできません。', + duplicateName: 'その変数は既に存在します。', + }, + card: { + valueLabel: '値:', + fallbackValueLabel: 'フォールバック値:', + valueInputPlaceholder: '値', + defaultValueInputPlaceholder: 'デフォルト値', + secretNotRetrieved: 'シークレット値 — セキュリティのため取得しません', + secretToggleLabel: 'シークレット', + secretToggleSubtitle: 'UIで値を非表示にし、プレビューのためにマシンから取得しません。', + secretToggleEnforcedByDaemon: 'デーモンで強制', + secretToggleResetToAuto: '自動に戻す', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `ドキュメントのデフォルト値を上書き: ${expectedValue}`, + useMachineEnvToggle: 'マシン環境から値を使用', + resolvedOnSessionStart: '選択したマシンでセッション開始時に解決されます。', + sourceVariableLabel: '参照元変数', + sourceVariablePlaceholder: '参照元変数名(例: Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `${machine} を確認中...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} では空です`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} では空です(フォールバック使用)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で見つかりません`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} で見つかりません(フォールバック使用)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で値を確認`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `ドキュメント値と異なります: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - セキュリティのため非表示`, + hiddenValue: '***非表示***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `セッションに渡される値: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `環境変数 · ${profileName}`, + descriptionPrefix: 'これらの環境変数はセッション開始時に送信されます。値はデーモンが', + descriptionFallbackMachine: '選択したマシン', + descriptionSuffix: 'で解決します。', + emptyMessage: 'このプロファイルには環境変数が設定されていません。', + checkingSuffix: '(確認中…)', + detail: { + fixed: '固定', + machine: 'マシン', + checking: '確認中', + fallback: 'フォールバック', + missing: '未設定', + }, + }, + }, delete: { title: 'プロファイルを削除', message: ({ name }: { name: string }) => `「${name}」を削除してもよろしいですか?この操作は元に戻せません。`, @@ -240,6 +358,15 @@ export const ja: TranslationStructure = { enhancedSessionWizard: '拡張セッションウィザード', enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', enhancedSessionWizardDisabled: '標準セッションランチャーを使用', + profiles: 'AIプロファイル', + profilesEnabled: 'プロファイル選択を有効化', + profilesDisabled: 'プロファイル選択を無効化', + pickerSearch: 'ピッカー検索', + pickerSearchSubtitle: 'マシンとパスのピッカーに検索欄を表示', + machinePickerSearch: 'マシン検索', + machinePickerSearchSubtitle: 'マシンピッカーに検索欄を表示', + pathPickerSearch: 'パス検索', + pathPickerSearchSubtitle: 'パスピッカーに検索欄を表示', }, errors: { @@ -292,6 +419,9 @@ export const ja: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '新しいセッションを開始', + selectMachineTitle: 'マシンを選択', + selectPathTitle: 'パスを選択', + searchPathsPlaceholder: 'パスを検索...', noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。', allMachinesOffline: 'すべてのマシンがオフラインです', machineDetails: 'マシンの詳細を表示 →', @@ -307,6 +437,26 @@ export const ja: TranslationStructure = { notConnectedToServer: 'サーバーに接続されていません。インターネット接続を確認してください。', noMachineSelected: 'セッションを開始するマシンを選択してください', noPathSelected: 'セッションを開始するディレクトリを選択してください', + machinePicker: { + searchPlaceholder: 'マシンを検索...', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + allTitle: 'すべて', + emptyMessage: '利用可能なマシンがありません', + }, + pathPicker: { + enterPathTitle: 'パスを入力', + enterPathPlaceholder: 'パスを入力...', + customPathTitle: 'カスタムパス', + recentTitle: '最近', + favoritesTitle: 'お気に入り', + suggestedTitle: 'おすすめ', + allTitle: 'すべて', + emptyRecent: '最近のパスはありません', + emptyFavorites: 'お気に入りのパスはありません', + emptySuggested: 'おすすめのパスはありません', + emptyAll: 'パスがありません', + }, sessionType: { title: 'セッションタイプ', simple: 'シンプル', @@ -368,6 +518,7 @@ export const ja: TranslationStructure = { happySessionId: 'Happy Session ID', claudeCodeSessionId: 'Claude Code Session ID', claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました', + aiProfile: 'AIプロファイル', aiProvider: 'AIプロバイダー', failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました', metadataCopied: 'メタデータがクリップボードにコピーされました', @@ -422,12 +573,19 @@ export const ja: TranslationStructure = { }, agentInput: { + envVars: { + title: '環境変数', + titleWithCount: ({ count }: { count: number }) => `環境変数 (${count})`, + }, permissionMode: { title: '権限モード', default: 'デフォルト', acceptEdits: '編集を許可', plan: 'プランモード', bypassPermissions: 'Yoloモード', + badgeAccept: '許可', + badgePlan: 'プラン', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'すべての編集を許可', badgeBypassAllPermissions: 'すべての権限をバイパス', badgePlanMode: 'プランモード', @@ -464,12 +622,27 @@ export const ja: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI権限モード', default: 'デフォルト', - acceptEdits: '編集を許可', - plan: 'プランモード', - bypassPermissions: 'Yoloモード', - badgeAcceptAllEdits: 'すべての編集を許可', - badgeBypassAllPermissions: 'すべての権限をバイパス', - badgePlanMode: 'プランモード', + readOnly: '読み取り専用モード', + safeYolo: 'セーフYOLO', + yolo: 'YOLO', + badgeReadOnly: '読み取り専用モード', + badgeSafeYolo: 'セーフYOLO', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'GEMINIモデル', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最高性能', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '高速・効率的', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最速', + }, }, context: { remaining: ({ percent }: { percent: number }) => `残り ${percent}%`, @@ -540,6 +713,10 @@ export const ja: TranslationStructure = { applyChanges: 'ファイルを更新', viewDiff: '現在のファイル変更', question: '質問', + changeTitle: 'タイトルを変更', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 1c8e2f087..eb535306c 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Polish plural helper function @@ -42,6 +42,8 @@ export const pl: TranslationStructure = { common: { // Simple string constants + add: 'Dodaj', + actions: 'Akcje', cancel: 'Anuluj', authenticate: 'Uwierzytelnij', save: 'Zapisz', @@ -58,6 +60,9 @@ export const pl: TranslationStructure = { yes: 'Tak', no: 'Nie', discard: 'Odrzuć', + discardChanges: 'Odrzuć zmiany', + unsavedChangesWarning: 'Masz niezapisane zmiany.', + keepEditing: 'Kontynuuj edycję', version: 'Wersja', copied: 'Skopiowano', copy: 'Kopiuj', @@ -71,6 +76,10 @@ export const pl: TranslationStructure = { retry: 'Ponów', delete: 'Usuń', optional: 'opcjonalnie', + noMatches: 'Brak dopasowań', + all: 'Wszystko', + machine: 'maszyna', + clearSearch: 'Wyczyść wyszukiwanie', }, profile: { @@ -219,6 +228,15 @@ export const pl: TranslationStructure = { enhancedSessionWizard: 'Ulepszony kreator sesji', enhancedSessionWizardEnabled: 'Aktywny launcher z profilem', enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji', + profiles: 'Profile AI', + profilesEnabled: 'Wybór profili włączony', + profilesDisabled: 'Wybór profili wyłączony', + pickerSearch: 'Wyszukiwanie w selektorach', + pickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn i ścieżek', + machinePickerSearch: 'Wyszukiwanie maszyn', + machinePickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn', + pathPickerSearch: 'Wyszukiwanie ścieżek', + pathPickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach ścieżek', }, errors: { @@ -271,6 +289,9 @@ export const pl: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Rozpocznij nową sesję', + selectMachineTitle: 'Wybierz maszynę', + selectPathTitle: 'Wybierz ścieżkę', + searchPathsPlaceholder: 'Szukaj ścieżek...', noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.', allMachinesOffline: 'Wszystkie maszyny są offline', machineDetails: 'Zobacz szczegóły maszyny →', @@ -286,6 +307,26 @@ export const pl: TranslationStructure = { startNewSessionInFolder: 'Nowa sesja tutaj', noMachineSelected: 'Proszę wybrać maszynę do rozpoczęcia sesji', noPathSelected: 'Proszę wybrać katalog do rozpoczęcia sesji', + machinePicker: { + searchPlaceholder: 'Szukaj maszyn...', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + allTitle: 'Wszystkie', + emptyMessage: 'Brak dostępnych maszyn', + }, + pathPicker: { + enterPathTitle: 'Wpisz ścieżkę', + enterPathPlaceholder: 'Wpisz ścieżkę...', + customPathTitle: 'Niestandardowa ścieżka', + recentTitle: 'Ostatnie', + favoritesTitle: 'Ulubione', + suggestedTitle: 'Sugerowane', + allTitle: 'Wszystkie', + emptyRecent: 'Brak ostatnich ścieżek', + emptyFavorites: 'Brak ulubionych ścieżek', + emptySuggested: 'Brak sugerowanych ścieżek', + emptyAll: 'Brak ścieżek', + }, sessionType: { title: 'Typ sesji', simple: 'Prosta', @@ -347,6 +388,7 @@ export const pl: TranslationStructure = { happySessionId: 'ID sesji Happy', claudeCodeSessionId: 'ID sesji Claude Code', claudeCodeSessionIdCopied: 'ID sesji Claude Code skopiowane do schowka', + aiProfile: 'Profil AI', aiProvider: 'Dostawca AI', failedToCopyClaudeCodeSessionId: 'Nie udało się skopiować ID sesji Claude Code', metadataCopied: 'Metadane skopiowane do schowka', @@ -400,12 +442,19 @@ export const pl: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Zmienne środowiskowe', + titleWithCount: ({ count }: { count: number }) => `Zmienne środowiskowe (${count})`, + }, permissionMode: { title: 'TRYB UPRAWNIEŃ', default: 'Domyślny', acceptEdits: 'Akceptuj edycje', plan: 'Tryb planowania', bypassPermissions: 'Tryb YOLO', + badgeAccept: 'Akceptuj', + badgePlan: 'Plan', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Akceptuj wszystkie edycje', badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia', badgePlanMode: 'Tryb planowania', @@ -425,7 +474,7 @@ export const pl: TranslationStructure = { readOnly: 'Read Only Mode', safeYolo: 'Safe YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', + badgeReadOnly: 'Tylko do odczytu', badgeSafeYolo: 'Safe YOLO', badgeYolo: 'YOLO', }, @@ -440,14 +489,29 @@ export const pl: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'TRYB UPRAWNIEŃ', + title: 'TRYB UPRAWNIEŃ GEMINI', default: 'Domyślny', - acceptEdits: 'Akceptuj edycje', - plan: 'Tryb planowania', - bypassPermissions: 'Tryb YOLO', - badgeAcceptAllEdits: 'Akceptuj wszystkie edycje', - badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia', - badgePlanMode: 'Tryb planowania', + readOnly: 'Tylko do odczytu', + safeYolo: 'Bezpieczne YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Tylko do odczytu', + badgeSafeYolo: 'Bezpieczne YOLO', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODEL GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Najbardziej zaawansowany', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Szybki i wydajny', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Najszybszy', + }, }, context: { remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`, @@ -514,6 +578,10 @@ export const pl: TranslationStructure = { applyChanges: 'Zaktualizuj plik', viewDiff: 'Bieżące zmiany pliku', question: 'Pytanie', + changeTitle: 'Zmień tytuł', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -926,9 +994,128 @@ export const pl: TranslationStructure = { enterTmuxTempDir: 'Wprowadź ścieżkę do katalogu tymczasowego', tmuxUpdateEnvironment: 'Aktualizuj środowisko automatycznie', nameRequired: 'Nazwa profilu jest wymagana', - deleteConfirm: 'Czy na pewno chcesz usunąć profil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć profil "${name}"?`, editProfile: 'Edytuj Profil', addProfileTitle: 'Dodaj Nowy Profil', + builtIn: 'Wbudowane', + custom: 'Niestandardowe', + builtInSaveAsHint: 'Zapisanie wbudowanego profilu tworzy nowy profil niestandardowy.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Ulubione', + custom: 'Twoje profile', + builtIn: 'Profile wbudowane', + }, + actions: { + viewEnvironmentVariables: 'Zmienne środowiskowe', + addToFavorites: 'Dodaj do ulubionych', + removeFromFavorites: 'Usuń z ulubionych', + editProfile: 'Edytuj profil', + duplicateProfile: 'Duplikuj profil', + deleteProfile: 'Usuń profil', + }, + copySuffix: '(Kopia)', + duplicateName: 'Profil o tej nazwie już istnieje', + setupInstructions: { + title: 'Instrukcje konfiguracji', + viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji', + }, + defaultSessionType: 'Domyślny typ sesji', + defaultPermissionMode: { + title: 'Domyślny tryb uprawnień', + descriptions: { + default: 'Pytaj o uprawnienia', + acceptEdits: 'Automatycznie zatwierdzaj edycje', + plan: 'Zaplanuj przed wykonaniem', + bypassPermissions: 'Pomiń wszystkie uprawnienia', + }, + }, + aiBackend: { + title: 'Backend AI', + selectAtLeastOneError: 'Wybierz co najmniej jeden backend AI.', + claudeSubtitle: 'CLI Claude', + codexSubtitle: 'CLI Codex', + geminiSubtitleExperimental: 'CLI Gemini (eksperymentalne)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Uruchamiaj sesje w Tmux', + spawnSessionsEnabledSubtitle: 'Sesje uruchamiają się w nowych oknach tmux.', + spawnSessionsDisabledSubtitle: 'Sesje uruchamiają się w zwykłej powłoce (bez integracji z tmux)', + sessionNamePlaceholder: 'Puste = bieżąca/najnowsza sesja', + tempDirPlaceholder: '/tmp (opcjonalne)', + }, + previewMachine: { + title: 'Podgląd maszyny', + itemTitle: 'Maszyna podglądu dla zmiennych środowiskowych', + selectMachine: 'Wybierz maszynę', + resolveSubtitle: 'Służy tylko do podglądu rozwiązanych wartości poniżej (nie zmienia tego, co zostanie zapisane).', + selectSubtitle: 'Wybierz maszynę, aby podejrzeć rozwiązane wartości poniżej.', + }, + environmentVariables: { + title: 'Zmienne środowiskowe', + addVariable: 'Dodaj zmienną', + namePlaceholder: 'Nazwa zmiennej (np. MY_CUSTOM_VAR)', + valuePlaceholder: 'Wartość (np. my-value lub ${MY_VAR})', + validation: { + nameRequired: 'Wprowadź nazwę zmiennej.', + invalidNameFormat: 'Nazwy zmiennych muszą zawierać wielkie litery, cyfry i podkreślenia oraz nie mogą zaczynać się od cyfry.', + duplicateName: 'Taka zmienna już istnieje.', + }, + card: { + valueLabel: 'Wartość:', + fallbackValueLabel: 'Wartość fallback:', + valueInputPlaceholder: 'Wartość', + defaultValueInputPlaceholder: 'Wartość domyślna', + secretNotRetrieved: 'Wartość sekretna - nie jest pobierana ze względów bezpieczeństwa', + secretToggleLabel: 'Sekret', + secretToggleSubtitle: 'Ukrywa wartość w UI i nie pobiera jej z maszyny na potrzeby podglądu.', + secretToggleEnforcedByDaemon: 'Wymuszone przez daemon', + secretToggleResetToAuto: 'Przywróć automatyczne', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Nadpisywanie udokumentowanej wartości domyślnej: ${expectedValue}`, + useMachineEnvToggle: 'Użyj wartości ze środowiska maszyny', + resolvedOnSessionStart: 'Rozwiązywane podczas uruchamiania sesji na wybranej maszynie.', + sourceVariableLabel: 'Zmienna źródłowa', + sourceVariablePlaceholder: 'Nazwa zmiennej źródłowej (np. Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Sprawdzanie ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Pusto na ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Pusto na ${machine} (używam fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine} (używam fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Znaleziono wartość na ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Różni się od udokumentowanej wartości: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - ukryte ze względów bezpieczeństwa`, + hiddenValue: '***ukryte***', + emptyValue: '(puste)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Sesja otrzyma: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Zmienne środowiskowe · ${profileName}`, + descriptionPrefix: 'Te zmienne środowiskowe są wysyłane podczas uruchamiania sesji. Wartości są rozwiązywane przez daemon na', + descriptionFallbackMachine: 'wybranej maszynie', + descriptionSuffix: '.', + emptyMessage: 'Dla tego profilu nie ustawiono zmiennych środowiskowych.', + checkingSuffix: '(sprawdzanie…)', + detail: { + fixed: 'Stała', + machine: 'Maszyna', + checking: 'Sprawdzanie', + fallback: 'Fallback', + missing: 'Brak', + }, + }, + }, delete: { title: 'Usuń Profil', message: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć "${name}"? Tej czynności nie można cofnąć.`, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 859a7ae8b..55615a3d1 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Portuguese plural helper function @@ -31,6 +31,8 @@ export const pt: TranslationStructure = { common: { // Simple string constants + add: 'Adicionar', + actions: 'Ações', cancel: 'Cancelar', authenticate: 'Autenticar', save: 'Salvar', @@ -47,6 +49,9 @@ export const pt: TranslationStructure = { yes: 'Sim', no: 'Não', discard: 'Descartar', + discardChanges: 'Descartar alterações', + unsavedChangesWarning: 'Você tem alterações não salvas.', + keepEditing: 'Continuar editando', version: 'Versão', copied: 'Copiado', copy: 'Copiar', @@ -60,6 +65,10 @@ export const pt: TranslationStructure = { retry: 'Tentar novamente', delete: 'Excluir', optional: 'Opcional', + noMatches: 'Nenhuma correspondência', + all: 'Todos', + machine: 'máquina', + clearSearch: 'Limpar pesquisa', }, profile: { @@ -208,6 +217,15 @@ export const pt: TranslationStructure = { enhancedSessionWizard: 'Assistente de sessão aprimorado', enhancedSessionWizardEnabled: 'Lançador de sessão com perfil ativo', enhancedSessionWizardDisabled: 'Usando o lançador de sessão padrão', + profiles: 'Perfis de IA', + profilesEnabled: 'Seleção de perfis ativada', + profilesDisabled: 'Seleção de perfis desativada', + pickerSearch: 'Busca nos seletores', + pickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquina e caminho', + machinePickerSearch: 'Busca de máquinas', + machinePickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquinas', + pathPickerSearch: 'Busca de caminhos', + pathPickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de caminhos', }, errors: { @@ -260,6 +278,9 @@ export const pt: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nova sessão', + selectMachineTitle: 'Selecionar máquina', + selectPathTitle: 'Selecionar caminho', + searchPathsPlaceholder: 'Pesquisar caminhos...', noMachinesFound: 'Nenhuma máquina encontrada. Inicie uma sessão Happy no seu computador primeiro.', allMachinesOffline: 'Todas as máquinas estão offline', machineDetails: 'Ver detalhes da máquina →', @@ -275,6 +296,26 @@ export const pt: TranslationStructure = { startNewSessionInFolder: 'Nova sessão aqui', noMachineSelected: 'Por favor, selecione uma máquina para iniciar a sessão', noPathSelected: 'Por favor, selecione um diretório para iniciar a sessão', + machinePicker: { + searchPlaceholder: 'Pesquisar máquinas...', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + allTitle: 'Todas', + emptyMessage: 'Nenhuma máquina disponível', + }, + pathPicker: { + enterPathTitle: 'Inserir caminho', + enterPathPlaceholder: 'Insira um caminho...', + customPathTitle: 'Caminho personalizado', + recentTitle: 'Recentes', + favoritesTitle: 'Favoritos', + suggestedTitle: 'Sugeridos', + allTitle: 'Todas', + emptyRecent: 'Nenhum caminho recente', + emptyFavorites: 'Nenhum caminho favorito', + emptySuggested: 'Nenhum caminho sugerido', + emptyAll: 'Nenhum caminho', + }, sessionType: { title: 'Tipo de sessão', simple: 'Simples', @@ -336,6 +377,7 @@ export const pt: TranslationStructure = { happySessionId: 'ID da sessão Happy', claudeCodeSessionId: 'ID da sessão Claude Code', claudeCodeSessionIdCopied: 'ID da sessão Claude Code copiado para a área de transferência', + aiProfile: 'Perfil de IA', aiProvider: 'Provedor de IA', failedToCopyClaudeCodeSessionId: 'Falha ao copiar ID da sessão Claude Code', metadataCopied: 'Metadados copiados para a área de transferência', @@ -390,12 +432,19 @@ export const pt: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Vars env', + titleWithCount: ({ count }: { count: number }) => `Vars env (${count})`, + }, permissionMode: { title: 'MODO DE PERMISSÃO', default: 'Padrão', acceptEdits: 'Aceitar edições', plan: 'Modo de planejamento', bypassPermissions: 'Modo Yolo', + badgeAccept: 'Aceitar', + badgePlan: 'Plano', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Aceitar todas as edições', badgeBypassAllPermissions: 'Ignorar todas as permissões', badgePlanMode: 'Modo de planejamento', @@ -430,14 +479,29 @@ export const pt: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODO DE PERMISSÃO', + title: 'MODO DE PERMISSÃO GEMINI', default: 'Padrão', - acceptEdits: 'Aceitar edições', - plan: 'Modo de planejamento', - bypassPermissions: 'Modo Yolo', - badgeAcceptAllEdits: 'Aceitar todas as edições', - badgeBypassAllPermissions: 'Ignorar todas as permissões', - badgePlanMode: 'Modo de planejamento', + readOnly: 'Somente leitura', + safeYolo: 'YOLO seguro', + yolo: 'YOLO', + badgeReadOnly: 'Somente leitura', + badgeSafeYolo: 'YOLO seguro', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'MODELO GEMINI', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Mais capaz', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Rápido e eficiente', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Mais rápido', + }, }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, @@ -504,6 +568,10 @@ export const pt: TranslationStructure = { applyChanges: 'Atualizar arquivo', viewDiff: 'Alterações do arquivo atual', question: 'Pergunta', + changeTitle: 'Alterar título', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, @@ -894,8 +962,127 @@ export const pt: TranslationStructure = { tmuxTempDir: 'Diretório temporário tmux', enterTmuxTempDir: 'Digite o diretório temporário tmux', tmuxUpdateEnvironment: 'Atualizar ambiente tmux', - deleteConfirm: 'Tem certeza de que deseja excluir este perfil?', + deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`, nameRequired: 'O nome do perfil é obrigatório', + builtIn: 'Integrado', + custom: 'Personalizado', + builtInSaveAsHint: 'Salvar um perfil integrado cria um novo perfil personalizado.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Favoritos', + custom: 'Seus perfis', + builtIn: 'Perfis integrados', + }, + actions: { + viewEnvironmentVariables: 'Variáveis de ambiente', + addToFavorites: 'Adicionar aos favoritos', + removeFromFavorites: 'Remover dos favoritos', + editProfile: 'Editar perfil', + duplicateProfile: 'Duplicar perfil', + deleteProfile: 'Excluir perfil', + }, + copySuffix: '(Cópia)', + duplicateName: 'Já existe um perfil com este nome', + setupInstructions: { + title: 'Instruções de configuração', + viewOfficialGuide: 'Ver guia oficial de configuração', + }, + defaultSessionType: 'Tipo de sessão padrão', + defaultPermissionMode: { + title: 'Modo de permissão padrão', + descriptions: { + default: 'Solicitar permissões', + acceptEdits: 'Aprovar edições automaticamente', + plan: 'Planejar antes de executar', + bypassPermissions: 'Ignorar todas as permissões', + }, + }, + aiBackend: { + title: 'Backend de IA', + selectAtLeastOneError: 'Selecione pelo menos um backend de IA.', + claudeSubtitle: 'CLI do Claude', + codexSubtitle: 'CLI do Codex', + geminiSubtitleExperimental: 'CLI do Gemini (experimental)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Iniciar sessões no Tmux', + spawnSessionsEnabledSubtitle: 'As sessões são iniciadas em novas janelas do tmux.', + spawnSessionsDisabledSubtitle: 'As sessões são iniciadas no shell comum (sem integração com tmux)', + sessionNamePlaceholder: 'Vazio = sessão atual/mais recente', + tempDirPlaceholder: '/tmp (opcional)', + }, + previewMachine: { + title: 'Pré-visualizar máquina', + itemTitle: 'Máquina de pré-visualização para variáveis de ambiente', + selectMachine: 'Selecionar máquina', + resolveSubtitle: 'Usada apenas para pré-visualizar os valores resolvidos abaixo (não altera o que é salvo).', + selectSubtitle: 'Selecione uma máquina para pré-visualizar os valores resolvidos abaixo.', + }, + environmentVariables: { + title: 'Variáveis de ambiente', + addVariable: 'Adicionar variável', + namePlaceholder: 'Nome da variável (e.g., MY_CUSTOM_VAR)', + valuePlaceholder: 'Valor (e.g., my-value ou ${MY_VAR})', + validation: { + nameRequired: 'Digite um nome de variável.', + invalidNameFormat: 'Os nomes das variáveis devem conter letras maiúsculas, números e sublinhados, e não podem começar com um número.', + duplicateName: 'Essa variável já existe.', + }, + card: { + valueLabel: 'Valor:', + fallbackValueLabel: 'Valor de fallback:', + valueInputPlaceholder: 'Valor', + defaultValueInputPlaceholder: 'Valor padrão', + secretNotRetrieved: 'Valor secreto - não é recuperado por segurança', + secretToggleLabel: 'Segredo', + secretToggleSubtitle: 'Oculta o valor na interface e evita buscá-lo da máquina para pré-visualização.', + secretToggleEnforcedByDaemon: 'Imposto pelo daemon', + secretToggleResetToAuto: 'Redefinir para automático', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Substituindo o valor padrão documentado: ${expectedValue}`, + useMachineEnvToggle: 'Usar valor do ambiente da máquina', + resolvedOnSessionStart: 'Resolvido quando a sessão começa na máquina selecionada.', + sourceVariableLabel: 'Variável de origem', + sourceVariablePlaceholder: 'Nome da variável de origem (e.g., Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Vazio em ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vazio em ${machine} (usando fallback)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Não encontrado em ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Não encontrado em ${machine} (usando fallback)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado em ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Diferente do valor documentado: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por segurança`, + hiddenValue: '***oculto***', + emptyValue: '(vazio)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `A sessão receberá: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de ambiente · ${profileName}`, + descriptionPrefix: 'Estas variáveis de ambiente são enviadas ao iniciar a sessão. Os valores são resolvidos usando o daemon em', + descriptionFallbackMachine: 'a máquina selecionada', + descriptionSuffix: '.', + emptyMessage: 'Nenhuma variável de ambiente está definida para este perfil.', + checkingSuffix: '(verificando…)', + detail: { + fixed: 'Fixo', + machine: 'Máquina', + checking: 'Verificando', + fallback: 'Fallback', + missing: 'Ausente', + }, + }, + }, delete: { title: 'Excluir Perfil', message: ({ name }: { name: string }) => `Tem certeza de que deseja excluir "${name}"? Esta ação não pode ser desfeita.`, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index aa533ea82..9b500bdf4 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Russian plural helper function @@ -42,6 +42,8 @@ export const ru: TranslationStructure = { common: { // Simple string constants + add: 'Добавить', + actions: 'Действия', cancel: 'Отмена', authenticate: 'Авторизация', save: 'Сохранить', @@ -58,6 +60,9 @@ export const ru: TranslationStructure = { yes: 'Да', no: 'Нет', discard: 'Отменить', + discardChanges: 'Отменить изменения', + unsavedChangesWarning: 'У вас есть несохранённые изменения.', + keepEditing: 'Продолжить редактирование', version: 'Версия', copied: 'Скопировано', copy: 'Копировать', @@ -71,6 +76,10 @@ export const ru: TranslationStructure = { retry: 'Повторить', delete: 'Удалить', optional: 'необязательно', + noMatches: 'Нет совпадений', + all: 'Все', + machine: 'машина', + clearSearch: 'Очистить поиск', }, connect: { @@ -190,6 +199,15 @@ export const ru: TranslationStructure = { enhancedSessionWizard: 'Улучшенный мастер сессий', enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', + profiles: 'Профили ИИ', + profilesEnabled: 'Выбор профилей включён', + profilesDisabled: 'Выбор профилей отключён', + pickerSearch: 'Поиск в выборе', + pickerSearchSubtitle: 'Показывать поле поиска в выборе машины и пути', + machinePickerSearch: 'Поиск машин', + machinePickerSearchSubtitle: 'Показывать поле поиска при выборе машины', + pathPickerSearch: 'Поиск путей', + pathPickerSearchSubtitle: 'Показывать поле поиска при выборе пути', }, errors: { @@ -242,6 +260,9 @@ export const ru: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Начать новую сессию', + selectMachineTitle: 'Выбрать машину', + selectPathTitle: 'Выбрать путь', + searchPathsPlaceholder: 'Поиск путей...', noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.', allMachinesOffline: 'Все машины находятся offline', machineDetails: 'Посмотреть детали машины →', @@ -257,6 +278,26 @@ export const ru: TranslationStructure = { startNewSessionInFolder: 'Новая сессия здесь', noMachineSelected: 'Пожалуйста, выберите машину для запуска сессии', noPathSelected: 'Пожалуйста, выберите директорию для запуска сессии', + machinePicker: { + searchPlaceholder: 'Поиск машин...', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + allTitle: 'Все', + emptyMessage: 'Нет доступных машин', + }, + pathPicker: { + enterPathTitle: 'Введите путь', + enterPathPlaceholder: 'Введите путь...', + customPathTitle: 'Пользовательский путь', + recentTitle: 'Недавние', + favoritesTitle: 'Избранное', + suggestedTitle: 'Рекомендуемые', + allTitle: 'Все', + emptyRecent: 'Нет недавних путей', + emptyFavorites: 'Нет избранных путей', + emptySuggested: 'Нет рекомендуемых путей', + emptyAll: 'Нет путей', + }, sessionType: { title: 'Тип сессии', simple: 'Простая', @@ -310,6 +351,7 @@ export const ru: TranslationStructure = { happySessionId: 'ID сессии Happy', claudeCodeSessionId: 'ID сессии Claude Code', claudeCodeSessionIdCopied: 'ID сессии Claude Code скопирован в буфер обмена', + aiProfile: 'Профиль ИИ', aiProvider: 'Поставщик ИИ', failedToCopyClaudeCodeSessionId: 'Не удалось скопировать ID сессии Claude Code', metadataCopied: 'Метаданные скопированы в буфер обмена', @@ -400,12 +442,19 @@ export const ru: TranslationStructure = { }, agentInput: { + envVars: { + title: 'Переменные окружения', + titleWithCount: ({ count }: { count: number }) => `Переменные окружения (${count})`, + }, permissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', acceptEdits: 'Принимать правки', plan: 'Режим планирования', bypassPermissions: 'YOLO режим', + badgeAccept: 'Принять', + badgePlan: 'План', + badgeYolo: 'YOLO', badgeAcceptAllEdits: 'Принимать все правки', badgeBypassAllPermissions: 'Обход всех разрешений', badgePlanMode: 'Режим планирования', @@ -449,6 +498,21 @@ export const ru: TranslationStructure = { badgeSafeYolo: 'Безопасный YOLO', badgeYolo: 'YOLO', }, + geminiModel: { + title: 'GEMINI MODEL', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: 'Самая мощная', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: 'Быстро и эффективно', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: 'Самая быстрая', + }, + }, context: { remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`, }, @@ -514,6 +578,10 @@ export const ru: TranslationStructure = { applyChanges: 'Обновить файл', viewDiff: 'Текущие изменения файла', question: 'Вопрос', + changeTitle: 'Изменить заголовок', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `Терминал(команда: ${cmd})`, @@ -925,9 +993,130 @@ export const ru: TranslationStructure = { enterTmuxTempDir: 'Введите путь к временному каталогу', tmuxUpdateEnvironment: 'Обновлять окружение автоматически', nameRequired: 'Имя профиля обязательно', - deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`, editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', + builtIn: 'Встроенный', + custom: 'Пользовательский', + builtInSaveAsHint: 'Сохранение встроенного профиля создаёт новый пользовательский профиль.', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: 'Избранное', + custom: 'Ваши профили', + builtIn: 'Встроенные профили', + }, + actions: { + viewEnvironmentVariables: 'Переменные окружения', + addToFavorites: 'Добавить в избранное', + removeFromFavorites: 'Убрать из избранного', + editProfile: 'Редактировать профиль', + duplicateProfile: 'Дублировать профиль', + deleteProfile: 'Удалить профиль', + }, + copySuffix: '(Копия)', + duplicateName: 'Профиль с таким названием уже существует', + setupInstructions: { + title: 'Инструкции по настройке', + viewOfficialGuide: 'Открыть официальное руководство', + }, + defaultSessionType: 'Тип сессии по умолчанию', + defaultPermissionMode: { + title: 'Режим разрешений по умолчанию', + descriptions: { + default: 'Запрашивать разрешения', + acceptEdits: 'Авто-одобрять правки', + plan: 'Планировать перед выполнением', + bypassPermissions: 'Пропускать все разрешения', + }, + }, + aiBackend: { + title: 'Бекенд ИИ', + selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI (экспериментально)', + }, + tmux: { + title: 'Tmux', + spawnSessionsTitle: 'Запускать сессии в Tmux', + spawnSessionsEnabledSubtitle: 'Сессии запускаются в новых окнах tmux.', + spawnSessionsDisabledSubtitle: 'Сессии запускаются в обычной оболочке (без интеграции с tmux)', + sessionNamePlaceholder: 'Пусто = текущая/последняя сессия', + tempDirPlaceholder: '/tmp (необязательно)', + }, + previewMachine: { + title: 'Предпросмотр машины', + itemTitle: 'Машина предпросмотра для переменных окружения', + selectMachine: 'Выбрать машину', + resolveSubtitle: 'Используется только для предпросмотра вычисленных значений ниже (не меняет то, что сохраняется).', + selectSubtitle: 'Выберите машину, чтобы просмотреть вычисленные значения ниже.', + }, + environmentVariables: { + title: 'Переменные окружения', + addVariable: 'Добавить переменную', + namePlaceholder: 'Имя переменной (например, MY_CUSTOM_VAR)', + valuePlaceholder: 'Значение (например, my-value или ${MY_VAR})', + validation: { + nameRequired: 'Введите имя переменной.', + invalidNameFormat: 'Имена переменных должны содержать заглавные буквы, цифры и подчёркивания и не могут начинаться с цифры.', + duplicateName: 'Такая переменная уже существует.', + }, + card: { + valueLabel: 'Значение:', + fallbackValueLabel: 'Значение по умолчанию:', + valueInputPlaceholder: 'Значение', + defaultValueInputPlaceholder: 'Значение по умолчанию', + secretNotRetrieved: 'Секретное значение — не извлекается из соображений безопасности', + secretToggleLabel: 'Секрет', + secretToggleSubtitle: 'Скрывает значение в UI и не извлекает его с машины для предварительного просмотра.', + secretToggleEnforcedByDaemon: 'Принудительно демоном', + secretToggleResetToAuto: 'Сбросить на авто', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `Переопределение документированного значения: ${expectedValue}`, + useMachineEnvToggle: 'Использовать значение из окружения машины', + resolvedOnSessionStart: 'Разрешается при запуске сессии на выбранной машине.', + sourceVariableLabel: 'Переменная-источник', + sourceVariablePlaceholder: 'Имя переменной-источника (например, Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `Проверка ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `Пусто на ${machine}`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Пусто на ${machine} (используется значение по умолчанию)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `Не найдено на ${machine}`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => + `Не найдено на ${machine} (используется значение по умолчанию)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `Значение найдено на ${machine}`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `Отличается от документированного значения: ${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} — скрыто из соображений безопасности`, + hiddenValue: '***скрыто***', + emptyValue: '(пусто)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `Сессия получит: ${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `Переменные окружения · ${profileName}`, + descriptionPrefix: 'Эти переменные окружения отправляются при запуске сессии. Значения разрешаются демоном на', + descriptionFallbackMachine: 'выбранной машине', + descriptionSuffix: '.', + emptyMessage: 'Для этого профиля не заданы переменные окружения.', + checkingSuffix: '(проверка…)', + detail: { + fixed: 'Фиксированное', + machine: 'Машина', + checking: 'Проверка', + fallback: 'По умолчанию', + missing: 'Отсутствует', + }, + }, + }, delete: { title: 'Удалить Профиль', message: ({ name }: { name: string }) => `Вы уверены, что хотите удалить "${name}"? Это действие нельзя отменить.`, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index b77851fde..91b6e694c 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -5,7 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; +import type { TranslationStructure } from '../_types'; /** * Chinese plural helper function @@ -33,6 +33,8 @@ export const zhHans: TranslationStructure = { common: { // Simple string constants + add: '添加', + actions: '操作', cancel: '取消', authenticate: '认证', save: '保存', @@ -49,6 +51,9 @@ export const zhHans: TranslationStructure = { yes: '是', no: '否', discard: '放弃', + discardChanges: '放弃更改', + unsavedChangesWarning: '你有未保存的更改。', + keepEditing: '继续编辑', version: '版本', copied: '已复制', copy: '复制', @@ -62,6 +67,10 @@ export const zhHans: TranslationStructure = { retry: '重试', delete: '删除', optional: '可选的', + noMatches: '无匹配结果', + all: 'All', + machine: '机器', + clearSearch: 'Clear search', }, profile: { @@ -210,6 +219,15 @@ export const zhHans: TranslationStructure = { enhancedSessionWizard: '增强会话向导', enhancedSessionWizardEnabled: '配置文件优先启动器已激活', enhancedSessionWizardDisabled: '使用标准会话启动器', + profiles: 'AI 配置文件', + profilesEnabled: '已启用配置文件选择', + profilesDisabled: '已禁用配置文件选择', + pickerSearch: '选择器搜索', + pickerSearchSubtitle: '在设备和路径选择器中显示搜索框', + machinePickerSearch: '设备搜索', + machinePickerSearchSubtitle: '在设备选择器中显示搜索框', + pathPickerSearch: '路径搜索', + pathPickerSearchSubtitle: '在路径选择器中显示搜索框', }, errors: { @@ -262,6 +280,9 @@ export const zhHans: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '启动新会话', + selectMachineTitle: '选择设备', + selectPathTitle: '选择路径', + searchPathsPlaceholder: '搜索路径...', noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。', allMachinesOffline: '所有设备似乎都已离线', machineDetails: '查看设备详情 →', @@ -277,6 +298,26 @@ export const zhHans: TranslationStructure = { notConnectedToServer: '未连接到服务器。请检查您的网络连接。', noMachineSelected: '请选择一台设备以启动会话', noPathSelected: '请选择一个目录以启动会话', + machinePicker: { + searchPlaceholder: '搜索设备...', + recentTitle: '最近', + favoritesTitle: '收藏', + allTitle: '全部', + emptyMessage: '没有可用设备', + }, + pathPicker: { + enterPathTitle: '输入路径', + enterPathPlaceholder: '输入路径...', + customPathTitle: '自定义路径', + recentTitle: '最近', + favoritesTitle: '收藏', + suggestedTitle: '推荐', + allTitle: '全部', + emptyRecent: '没有最近的路径', + emptyFavorites: '没有收藏的路径', + emptySuggested: '没有推荐的路径', + emptyAll: '没有路径', + }, sessionType: { title: '会话类型', simple: '简单', @@ -338,6 +379,7 @@ export const zhHans: TranslationStructure = { happySessionId: 'Happy 会话 ID', claudeCodeSessionId: 'Claude Code 会话 ID', claudeCodeSessionIdCopied: 'Claude Code 会话 ID 已复制到剪贴板', + aiProfile: 'AI 配置文件', aiProvider: 'AI 提供商', failedToCopyClaudeCodeSessionId: '复制 Claude Code 会话 ID 失败', metadataCopied: '元数据已复制到剪贴板', @@ -392,12 +434,19 @@ export const zhHans: TranslationStructure = { }, agentInput: { + envVars: { + title: '环境变量', + titleWithCount: ({ count }: { count: number }) => `环境变量 (${count})`, + }, permissionMode: { title: '权限模式', default: '默认', acceptEdits: '接受编辑', plan: '计划模式', bypassPermissions: 'Yolo 模式', + badgeAccept: '接受', + badgePlan: '计划', + badgeYolo: 'YOLO', badgeAcceptAllEdits: '接受所有编辑', badgeBypassAllPermissions: '绕过所有权限', badgePlanMode: '计划模式', @@ -432,14 +481,29 @@ export const zhHans: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: '权限模式', + title: 'GEMINI 权限模式', default: '默认', - acceptEdits: '接受编辑', - plan: '计划模式', - bypassPermissions: 'Yolo 模式', - badgeAcceptAllEdits: '接受所有编辑', - badgeBypassAllPermissions: '绕过所有权限', - badgePlanMode: '计划模式', + readOnly: '只读', + safeYolo: '安全 YOLO', + yolo: 'YOLO', + badgeReadOnly: '只读', + badgeSafeYolo: '安全 YOLO', + badgeYolo: 'YOLO', + }, + geminiModel: { + title: 'GEMINI 模型', + gemini25Pro: { + label: 'Gemini 2.5 Pro', + description: '最强能力', + }, + gemini25Flash: { + label: 'Gemini 2.5 Flash', + description: '快速且高效', + }, + gemini25FlashLite: { + label: 'Gemini 2.5 Flash Lite', + description: '最快', + }, }, context: { remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`, @@ -506,6 +570,10 @@ export const zhHans: TranslationStructure = { applyChanges: '更新文件', viewDiff: '当前文件更改', question: '问题', + changeTitle: '更改标题', + }, + geminiExecute: { + cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`, }, desc: { terminalCmd: ({ cmd }: { cmd: string }) => `终端(命令: ${cmd})`, @@ -896,8 +964,127 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', + builtIn: '内置', + custom: '自定义', + builtInSaveAsHint: '保存内置配置文件会创建一个新的自定义配置文件。', + builtInNames: { + anthropic: 'Anthropic (Default)', + deepseek: 'DeepSeek (Reasoner)', + zai: 'Z.AI (GLM-4.6)', + openai: 'OpenAI (GPT-5)', + azureOpenai: 'Azure OpenAI', + }, + groups: { + favorites: '收藏', + custom: '你的配置文件', + builtIn: '内置配置文件', + }, + actions: { + viewEnvironmentVariables: '环境变量', + addToFavorites: '添加到收藏', + removeFromFavorites: '从收藏中移除', + editProfile: '编辑配置文件', + duplicateProfile: '复制配置文件', + deleteProfile: '删除配置文件', + }, + copySuffix: '(Copy)', + duplicateName: '已存在同名配置文件', + setupInstructions: { + title: '设置说明', + viewOfficialGuide: '查看官方设置指南', + }, + defaultSessionType: '默认会话类型', + defaultPermissionMode: { + title: '默认权限模式', + descriptions: { + default: '询问权限', + acceptEdits: '自动批准编辑', + plan: '执行前先规划', + bypassPermissions: '跳过所有权限', + }, + }, + aiBackend: { + title: 'AI 后端', + selectAtLeastOneError: '至少选择一个 AI 后端。', + claudeSubtitle: 'Claude CLI', + codexSubtitle: 'Codex CLI', + geminiSubtitleExperimental: 'Gemini CLI(实验)', + }, + tmux: { + title: 'tmux', + spawnSessionsTitle: '在 tmux 中启动会话', + spawnSessionsEnabledSubtitle: '会话将在新的 tmux 窗口中启动。', + spawnSessionsDisabledSubtitle: '会话将在普通 shell 中启动(无 tmux 集成)', + sessionNamePlaceholder: '留空 = 当前/最近会话', + tempDirPlaceholder: '/tmp(可选)', + }, + previewMachine: { + title: '预览设备', + itemTitle: '用于环境变量预览的设备', + selectMachine: '选择设备', + resolveSubtitle: '仅用于预览下面解析后的值(不会改变已保存的内容)。', + selectSubtitle: '选择设备以预览下面解析后的值。', + }, + environmentVariables: { + title: '环境变量', + addVariable: '添加变量', + namePlaceholder: '变量名(例如 MY_CUSTOM_VAR)', + valuePlaceholder: '值(例如 my-value 或 ${MY_VAR})', + validation: { + nameRequired: '请输入变量名。', + invalidNameFormat: '变量名必须由大写字母、数字和下划线组成,且不能以数字开头。', + duplicateName: '该变量已存在。', + }, + card: { + valueLabel: '值:', + fallbackValueLabel: '备用值:', + valueInputPlaceholder: '值', + defaultValueInputPlaceholder: '默认值', + secretNotRetrieved: '秘密值——出于安全原因不会读取', + secretToggleLabel: '秘密', + secretToggleSubtitle: '在 UI 中隐藏该值,并避免为预览从机器获取它。', + secretToggleEnforcedByDaemon: '由守护进程强制', + secretToggleResetToAuto: '重置为自动', + overridingDefault: ({ expectedValue }: { expectedValue: string }) => + `正在覆盖文档默认值:${expectedValue}`, + useMachineEnvToggle: '使用设备环境中的值', + resolvedOnSessionStart: '会话在所选设备上启动时解析。', + sourceVariableLabel: '来源变量', + sourceVariablePlaceholder: '来源变量名(例如 Z_AI_MODEL)', + checkingMachine: ({ machine }: { machine: string }) => `正在检查 ${machine}...`, + emptyOnMachine: ({ machine }: { machine: string }) => `${machine} 上为空`, + emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} 上为空(使用备用值)`, + notFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上未找到`, + notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `在 ${machine} 上未找到(使用备用值)`, + valueFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上找到值`, + differsFromDocumented: ({ expectedValue }: { expectedValue: string }) => + `与文档值不同:${expectedValue}`, + }, + preview: { + secretValueHidden: ({ value }: { value: string }) => `${value} - 出于安全已隐藏`, + hiddenValue: '***已隐藏***', + emptyValue: '(空)', + sessionWillReceive: ({ name, value }: { name: string; value: string }) => + `会话将收到:${name} = ${value}`, + }, + previewModal: { + titleWithProfile: ({ profileName }: { profileName: string }) => `环境变量 · ${profileName}`, + descriptionPrefix: '这些环境变量会在启动会话时发送。值会通过守护进程解析于', + descriptionFallbackMachine: '所选设备', + descriptionSuffix: '。', + emptyMessage: '该配置文件未设置环境变量。', + checkingSuffix: '(检查中…)', + detail: { + fixed: '固定', + machine: '设备', + checking: '检查中', + fallback: '备用', + missing: '缺失', + }, + }, + }, delete: { title: '删除配置', message: ({ name }: { name: string }) => `确定要删除"${name}"吗?此操作无法撤销。`, diff --git a/sources/theme.css b/sources/theme.css index 7e241b5ae..7bc81abac 100644 --- a/sources/theme.css +++ b/sources/theme.css @@ -33,6 +33,18 @@ scrollbar-color: var(--colors-divider) var(--colors-surface-high); } +/* Expo Router (web) modal sizing + - Expo Router uses a Vaul/Radix drawer for `presentation: 'modal'` on web. + - Default sizing is a bit short on large screens; override via attribute selectors + so we don't rely on hashed classnames. */ +@media (min-width: 700px) { + [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] { + height: min(820px, calc(100vh - 96px)) !important; + max-height: min(820px, calc(100vh - 96px)) !important; + min-height: min(820px, calc(100vh - 96px)) !important; + } +} + /* Ensure scrollbars are visible on hover for macOS */ ::-webkit-scrollbar:horizontal { height: 12px; @@ -40,4 +52,4 @@ ::-webkit-scrollbar:vertical { width: 12px; -} \ No newline at end of file +} From 16dfe95791ecfa5bc6b5fd6a132fa1aa75b46053 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:45:30 +0100 Subject: [PATCH 18/38] fix(new-session): restore standard modal flow --- sources/__tests__/app/new/pick/path.test.ts | 88 + sources/app/(app)/_layout.tsx | 17 +- sources/app/(app)/machine/[id].tsx | 1 - sources/app/(app)/new/index.tsx | 2368 +++++++++-------- sources/app/(app)/new/pick/machine.tsx | 153 +- sources/app/(app)/new/pick/path.tsx | 311 +-- sources/app/(app)/new/pick/profile-edit.tsx | 280 +- sources/app/(app)/new/pick/profile.tsx | 380 +++ sources/app/(app)/settings/features.tsx | 36 +- sources/components/AgentInput.tsx | 143 +- sources/components/SessionTypeSelector.tsx | 157 +- .../components/newSession/MachineSelector.tsx | 113 + .../components/newSession/PathSelector.tsx | 614 +++++ .../newSession/ProfileCompatibilityIcon.tsx | 1 - sources/sync/modelOptions.ts | 33 + sources/utils/recentMachines.ts | 31 + sources/utils/recentPaths.ts | 45 + 17 files changed, 3142 insertions(+), 1629 deletions(-) create mode 100644 sources/__tests__/app/new/pick/path.test.ts create mode 100644 sources/app/(app)/new/pick/profile.tsx create mode 100644 sources/components/newSession/MachineSelector.tsx create mode 100644 sources/components/newSession/PathSelector.tsx create mode 100644 sources/sync/modelOptions.ts create mode 100644 sources/utils/recentMachines.ts create mode 100644 sources/utils/recentPaths.ts diff --git a/sources/__tests__/app/new/pick/path.test.ts b/sources/__tests__/app/new/pick/path.test.ts new file mode 100644 index 000000000..18113bdfb --- /dev/null +++ b/sources/__tests__/app/new/pick/path.test.ts @@ -0,0 +1,88 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import renderer, { act } from 'react-test-renderer'; + +let lastPathSelectorProps: any = null; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Text: 'Text', + Pressable: 'Pressable', + Platform: { + OS: 'web', + select: (options: { web?: unknown; ios?: unknown; default?: unknown }) => options.web ?? options.ios ?? options.default, + }, + TurboModuleRegistry: { + getEnforcing: () => ({}), + }, +})); + +vi.mock('expo-router', () => ({ + Stack: { Screen: () => null }, + useRouter: () => ({ back: vi.fn() }), + useNavigation: () => ({ getState: () => ({ index: 1, routes: [{ key: 'a' }, { key: 'b' }] }) }), + useLocalSearchParams: () => ({ machineId: 'm1', selectedPath: '/tmp' }), +})); + +vi.mock('react-native-unistyles', () => ({ + useUnistyles: () => ({ theme: { colors: { header: { tint: '#000' }, textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } } }), + StyleSheet: { create: (fn: any) => fn({ colors: { textSecondary: '#666', input: { background: '#fff', placeholder: '#aaa', text: '#000' }, divider: '#ddd' } }) }, +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: 'Ionicons', +})); + +vi.mock('@/components/ItemList', () => ({ + ItemList: ({ children }: any) => React.createElement(React.Fragment, null, children), +})); + +vi.mock('@/components/layout', () => ({ + layout: { maxWidth: 900 }, +})); + +vi.mock('@/components/SearchHeader', () => ({ + SearchHeader: () => null, +})); + +vi.mock('@/components/newSession/PathSelector', () => ({ + PathSelector: (props: any) => { + lastPathSelectorProps = props; + return null; + }, +})); + +vi.mock('@/sync/storage', () => ({ + useAllMachines: () => [{ id: 'm1', metadata: { homeDir: '/home' } }], + useSessions: () => [], + useSetting: (key: string) => { + if (key === 'recentMachinePaths') return []; + if (key === 'usePathPickerSearch') return false; + return null; + }, + useSettingMutable: (key: string) => { + if (key === 'favoriteDirectories') return [undefined, vi.fn()]; + return [null, vi.fn()]; + }, +})); + +describe('PathPickerScreen', () => { + beforeEach(() => { + lastPathSelectorProps = null; + }); + + it('defaults favoriteDirectories to an empty array when setting is undefined', async () => { + const PathPickerScreen = (await import('@/app/(app)/new/pick/path')).default; + act(() => { + renderer.create(React.createElement(PathPickerScreen)); + }); + + expect(lastPathSelectorProps).toBeTruthy(); + expect(lastPathSelectorProps.favoriteDirectories).toEqual([]); + expect(typeof lastPathSelectorProps.onChangeFavoriteDirectories).toBe('function'); + }); +}); diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 408d7ad24..64367c054 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -117,6 +117,12 @@ export default function RootLayout() { headerTitle: t('settings.features'), }} /> + + ); diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 783dc2a19..74fed3a4c 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native'; -import Constants from 'expo-constants'; +import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView } from 'react-native'; import { Typography } from '@/constants/Typography'; import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { useUnistyles } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { t } from '@/text'; @@ -16,38 +15,33 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { machineSpawnNewSession } from '@/sync/ops'; import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; import { createWorktree } from '@/utils/createWorktree'; import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; -import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; +import { mapPermissionModeAcrossAgents } from '@/sync/permissionMapping'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; -import { randomUUID } from 'expo-crypto'; import { useCLIDetection } from '@/hooks/useCLIDetection'; -import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; -import { formatPathRelativeToHome } from '@/utils/sessionUtils'; -import { resolveAbsolutePath } from '@/utils/pathUtils'; -import { MultiTextInput } from '@/components/MultiTextInput'; + import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; -import { SearchableListSelector, SelectorConfig } from '@/components/SearchableListSelector'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; - -// Simple temporary state for passing selections back from picker screens -let onMachineSelected: (machineId: string) => void = () => { }; -let onProfileSaved: (profile: AIBackendProfile) => void = () => { }; - -export const callbacks = { - onMachineSelected: (machineId: string) => { - onMachineSelected(machineId); - }, - onProfileSaved: (profile: AIBackendProfile) => { - onProfileSaved(profile); - } -} +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import { buildProfileActions } from '@/components/profileActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { consumeProfileIdParam } from '@/profileRouteParams'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -59,15 +53,15 @@ const useProfileMap = (profiles: AIBackendProfile[]) => { // Environment variable transformation helper // Returns ALL profile environment variables - daemon will use them as-is -const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => { +const transformProfileToEnvironmentVars = (profile: AIBackendProfile) => { // getProfileEnvironmentVariables already returns ALL env vars from profile - // including custom environmentVariables array and provider-specific configs + // including custom environmentVariables array return getProfileEnvironmentVariables(profile); }; // Helper function to get the most recent path for a machine // Returns the path from the most recently CREATED session for this machine -const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ machineId: string; path: string }>): string => { +const getRecentPathForMachine = (machineId: string | null): string => { if (!machineId) return ''; const machine = storage.getState().machines[machineId]; @@ -95,32 +89,42 @@ const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{ // 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 - const styles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', - paddingTop: Platform.OS === 'web' ? 0 : 40, + paddingTop: Platform.OS === 'web' ? 20 : 10, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), }, scrollContainer: { flex: 1, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), }, contentContainer: { width: '100%', alignSelf: 'center', - paddingTop: rt.insets.top, + paddingTop: 0, paddingBottom: 16, }, wizardContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, - marginHorizontal: 16, - padding: 16, marginBottom: 16, }, + wizardSectionHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 6, + marginTop: 12, + paddingHorizontal: 16, + }, sectionHeader: { - fontSize: 14, + fontSize: 17, fontWeight: '600', color: theme.colors.text, marginBottom: 8, @@ -130,8 +134,9 @@ const styles = StyleSheet.create((theme, rt) => ({ sectionDescription: { fontSize: 12, color: theme.colors.textSecondary, - marginBottom: 12, + marginBottom: Platform.OS === 'web' ? 8 : 0, lineHeight: 18, + paddingHorizontal: 16, ...Typography.default() }, profileListItem: { @@ -202,18 +207,6 @@ const styles = StyleSheet.create((theme, rt) => ({ flex: 1, ...Typography.default() }, - advancedHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 12, - }, - advancedHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.textSecondary, - ...Typography.default(), - }, permissionGrid: { flexDirection: 'row', flexWrap: 'wrap', @@ -257,12 +250,20 @@ const styles = StyleSheet.create((theme, rt) => ({ function NewSessionWizard() { const { theme, rt } = useUnistyles(); const router = useRouter(); + const navigation = useNavigation(); const safeArea = useSafeAreaInsets(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{ + const headerHeight = useHeaderHeight(); + const { width: screenWidth } = useWindowDimensions(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + + const newSessionSidePadding = 16; + const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; machineId?: string; path?: string; + profileId?: string; }>(); // Try to get data from temporary store first @@ -284,13 +285,16 @@ function NewSessionWizard() { // Control A (false): Simpler AgentInput-driven layout // Variant B (true): Enhanced profile-first wizard with sections const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); + const useProfiles = useSetting('useProfiles'); const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -300,30 +304,60 @@ function NewSessionWizard() { }, [profiles]); const profileMap = useProfileMap(allProfiles); + + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }, [favoriteProfileIds, setFavoriteProfileIds]); const machines = useAllMachines(); const sessions = useSessions(); // Wizard state const [selectedProfileId, setSelectedProfileId] = React.useState(() => { + if (!useProfiles) { + return null; + } + const draftProfileId = persistedDraft?.selectedProfileId; + if (draftProfileId && profileMap.has(draftProfileId)) { + return draftProfileId; + } if (lastUsedProfile && profileMap.has(lastUsedProfile)) { return lastUsedProfile; } - return 'anthropic'; // Default to Anthropic + // Default to "no profile" so default session creation remains unchanged. + return null; }); + + React.useEffect(() => { + if (!useProfiles && selectedProfileId !== null) { + setSelectedProfileId(null); + } + }, [useProfiles, selectedProfileId]); + + const allowGemini = experimentsEnabled; + const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { // Check if agent type was provided in temp data if (tempSessionData?.agentType) { - // Only allow gemini if experiments are enabled - if (tempSessionData.agentType === 'gemini' && !experimentsEnabled) { + if (tempSessionData.agentType === 'gemini' && !allowGemini) { return 'claude'; } return tempSessionData.agentType; } - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - // Only allow gemini if experiments are enabled - if (lastUsedAgent === 'gemini' && experimentsEnabled) { + if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex' || lastUsedAgent === 'gemini') { + if (lastUsedAgent === 'gemini' && !allowGemini) { + return 'claude'; + } return lastUsedAgent; } return 'claude'; @@ -331,14 +365,14 @@ function NewSessionWizard() { // Agent cycling handler (for cycling through claude -> codex -> gemini) // Note: Does NOT persist immediately - persistence is handled by useEffect below - const handleAgentClick = React.useCallback(() => { + const handleAgentCycle = React.useCallback(() => { setAgentType(prev => { - // Cycle: claude -> codex -> gemini (if experiments) -> claude + // Cycle: claude -> codex -> (gemini?) -> claude if (prev === 'claude') return 'codex'; - if (prev === 'codex') return experimentsEnabled ? 'gemini' : 'claude'; + if (prev === 'codex') return allowGemini ? 'gemini' : 'claude'; return 'claude'; }); - }, [experimentsEnabled]); + }, [allowGemini]); // Persist agent selection changes (separate from setState to avoid race condition) // This runs after agentType state is updated, ensuring the value is stable @@ -367,22 +401,24 @@ function NewSessionWizard() { // 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', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; - // Note: 'default' is NOT valid for Gemini - we want explicit model selection - const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; - - if (lastUsedModelMode) { - if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'gemini' && validGeminiModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } + const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; + const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; + // Note: 'default' is NOT valid for Gemini - we want explicit model selection + const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; + + if (persistedDraft?.modelMode) { + const draftMode = persistedDraft.modelMode as ModelMode; + if (agentType === 'codex' && validCodexModes.includes(draftMode)) { + return draftMode; + } else if (agentType === 'claude' && validClaudeModes.includes(draftMode)) { + return draftMode; + } else if (agentType === 'gemini' && validGeminiModes.includes(draftMode)) { + return draftMode; } + } return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; }); + const modelOptions = React.useMemo(() => getModelOptionsForAgentType(agentType), [agentType]); // Session details state const [selectedMachineId, setSelectedMachineId] = React.useState(() => { @@ -399,24 +435,35 @@ function NewSessionWizard() { return null; }); - const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + const hasUserSelectedPermissionModeRef = React.useRef(false); + const permissionModeRef = React.useRef(permissionMode); + React.useEffect(() => { + permissionModeRef.current = permissionMode; + }, [permissionMode]); + + const applyPermissionMode = React.useCallback((mode: PermissionMode, source: 'user' | 'auto') => { setPermissionMode(mode); - // Save the new selection immediately sync.applySettings({ lastUsedPermissionMode: mode }); + if (source === 'user') { + hasUserSelectedPermissionModeRef.current = true; + } }, []); + const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + applyPermissionMode(mode, 'user'); + }, [applyPermissionMode]); + // // Path selection // const [selectedPath, setSelectedPath] = React.useState(() => { - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); + return getRecentPathForMachine(selectedMachineId); }); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); const [isCreating, setIsCreating] = React.useState(false); - const [showAdvanced, setShowAdvanced] = React.useState(false); // Handle machineId route param from picker screens (main's navigation pattern) React.useEffect(() => { @@ -428,11 +475,37 @@ function NewSessionWizard() { } if (machineIdParam !== selectedMachineId) { setSelectedMachineId(machineIdParam); - const bestPath = getRecentPathForMachine(machineIdParam, recentMachinePaths); + const bestPath = getRecentPathForMachine(machineIdParam); setSelectedPath(bestPath); } }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); + // Ensure a machine is pre-selected once machines have loaded (wizard expects this). + React.useEffect(() => { + if (selectedMachineId !== null) { + return; + } + if (machines.length === 0) { + return; + } + + let machineIdToUse: string | null = null; + if (recentMachinePaths.length > 0) { + for (const recent of recentMachinePaths) { + if (machines.find(m => m.id === recent.machineId)) { + machineIdToUse = recent.machineId; + break; + } + } + } + if (!machineIdToUse) { + machineIdToUse = machines[0].id; + } + + setSelectedMachineId(machineIdToUse); + setSelectedPath(getRecentPathForMachine(machineIdToUse)); + }, [machines, recentMachinePaths, selectedMachineId]); + // Handle path route param from picker screens (main's navigation pattern) React.useEffect(() => { if (typeof pathParam !== 'string') { @@ -449,6 +522,7 @@ function NewSessionWizard() { // Refs for scrolling to sections const scrollViewRef = React.useRef(null); const profileSectionRef = React.useRef(null); + const modelSectionRef = React.useRef(null); const machineSectionRef = React.useRef(null); const pathSectionRef = React.useRef(null); const permissionSectionRef = React.useRef(null); @@ -478,19 +552,6 @@ function NewSessionWizard() { } }, [cliAvailability.timestamp, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, agentType, experimentsEnabled]); - // Extract all ${VAR} references from profiles to query daemon environment - const envVarRefs = React.useMemo(() => { - const refs = new Set(); - allProfiles.forEach(profile => { - extractEnvVarReferences(profile.environmentVariables || []) - .forEach(ref => refs.add(ref)); - }); - return Array.from(refs); - }, [allProfiles]); - - // Query daemon environment for ${VAR} resolution - const { variables: daemonEnv } = useEnvironmentVariables(selectedMachineId, envVarRefs); - // Temporary banner dismissal (X button) - resets when component unmounts or machine changes const [hiddenBanners, setHiddenBanners] = React.useState<{ claude: boolean; codex: boolean; gemini: boolean }>({ claude: false, codex: false, gemini: false }); @@ -534,40 +595,43 @@ function NewSessionWizard() { } }, [selectedMachineId, dismissedCLIWarnings, setDismissedCLIWarnings]); - // Helper to check if profile is available (compatible + CLI detected) + // Helper to check if profile is available (CLI detected + experiments gating) const isProfileAvailable = React.useCallback((profile: AIBackendProfile): { available: boolean; reason?: string } => { - // Check profile compatibility with selected agent type - if (!validateProfileForAgent(profile, agentType)) { - // Build list of agents this profile supports (excluding current) - // Uses Object.entries to iterate over compatibility flags - scales automatically with new agents - const supportedAgents = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([agent, supported]) => supported && agent !== agentType) - .map(([agent]) => agent.charAt(0).toUpperCase() + agent.slice(1)); // 'claude' -> 'Claude' - const required = supportedAgents.join(' or ') || 'another agent'; + const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini'); + + const allowedCLIs = supportedCLIs.filter((cli) => cli !== 'gemini' || experimentsEnabled); + + if (allowedCLIs.length === 0) { return { available: false, - reason: `requires-agent:${required}`, + reason: 'no-supported-cli', }; } - // Check if required CLI is detected on machine (only if detection completed) - // Determine required CLI: if profile supports exactly one CLI, that CLI is required - // Uses Object.entries to iterate - scales automatically when new agents are added - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([, supported]) => supported) - .map(([agent]) => agent); - const requiredCLI = supportedCLIs.length === 1 ? supportedCLIs[0] as 'claude' | 'codex' | 'gemini' : null; + // If a profile requires exactly one CLI, enforce that one. + if (allowedCLIs.length === 1) { + const requiredCLI = allowedCLIs[0]; + if (cliAvailability[requiredCLI] === false) { + return { + available: false, + reason: `cli-not-detected:${requiredCLI}`, + }; + } + return { available: true }; + } - if (requiredCLI && cliAvailability[requiredCLI] === false) { + // Multi-CLI profiles: available if *any* supported CLI is available (or detection not finished). + const anyAvailable = allowedCLIs.some((cli) => cliAvailability[cli] !== false); + if (!anyAvailable) { return { available: false, - reason: `cli-not-detected:${requiredCLI}`, + reason: 'cli-not-detected:any', }; } - - // Optimistic: If detection hasn't completed (null) or profile supports both, assume available return { available: true }; - }, [agentType, cliAvailability]); + }, [cliAvailability, experimentsEnabled]); // Computed values const compatibleProfiles = React.useMemo(() => { @@ -591,6 +655,58 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); + const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => { + // Persist wizard state before navigating so selection doesn't reset on return. + saveNewSessionDraft({ + input: sessionPrompt, + selectedMachineId, + selectedPath, + selectedProfileId: useProfiles ? selectedProfileId : null, + agentType, + permissionMode, + modelMode, + sessionType, + updatedAt: Date.now(), + }); + + router.push({ + pathname: '/new/pick/profile-edit', + params: { + ...params, + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + } as any); + }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + + const handleAddProfile = React.useCallback(() => { + openProfileEdit({}); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + openProfileEdit({ cloneFromProfileId: profile.id }); + }, [openProfileEdit]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedProfileId === profile.id) { + setSelectedProfileId(null); + } + }, + }, + ], + ); + }, [profiles, selectedProfileId, setProfiles]); + // Get recent paths for the selected machine // Recent machines computed from sessions (for inline machine selection) const recentMachines = React.useMemo(() => { @@ -617,6 +733,10 @@ function NewSessionWizard() { .map(item => item.machine); }, [sessions, machines]); + const favoriteMachineItems = React.useMemo(() => { + return machines.filter(m => favoriteMachines.includes(m.id)); + }, [machines, favoriteMachines]); + const recentPaths = React.useMemo(() => { if (!selectedMachineId) return []; @@ -662,61 +782,120 @@ function NewSessionWizard() { // Validation const canCreate = React.useMemo(() => { - return ( - selectedProfileId !== null && - selectedMachineId !== null && - selectedPath.trim() !== '' - ); - }, [selectedProfileId, selectedMachineId, selectedPath]); + return selectedMachineId !== null && selectedPath.trim() !== ''; + }, [selectedMachineId, selectedPath]); const selectProfile = React.useCallback((profileId: string) => { + const prevSelectedProfileId = selectedProfileId; setSelectedProfileId(profileId); // Check both custom profiles and built-in profiles const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); if (profile) { - // Auto-select agent based on profile's EXCLUSIVE compatibility - // Only switch if profile supports exactly one CLI - scales automatically with new agents - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) .filter(([, supported]) => supported) - .map(([agent]) => agent); + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); - if (supportedCLIs.length === 1) { - const requiredAgent = supportedCLIs[0] as 'claude' | 'codex' | 'gemini'; - // Check if this agent is available and allowed - const isAvailable = cliAvailability[requiredAgent] !== false; - const isAllowed = requiredAgent !== 'gemini' || experimentsEnabled; - - if (isAvailable && isAllowed) { - setAgentType(requiredAgent); - } - // If the required CLI is unavailable or not allowed, keep current agent (profile will show as unavailable) + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? 'claude'); } - // If supportedCLIs.length > 1, profile supports multiple CLIs - don't force agent switch // Set session type from profile's default if (profile.defaultSessionType) { setSessionType(profile.defaultSessionType); } - // Set permission mode from profile's default - if (profile.defaultPermissionMode) { - setPermissionMode(profile.defaultPermissionMode as PermissionMode); + + // Apply permission defaults only on first selection (or if the user hasn't explicitly chosen one). + // Switching between profiles should not reset permissions when the backend stays the same. + if (!hasUserSelectedPermissionModeRef.current && profile.defaultPermissionMode) { + const nextMode = profile.defaultPermissionMode as PermissionMode; + // If the user is switching profiles (not initial selection), keep their current permissionMode. + const isInitialProfileSelection = prevSelectedProfileId === null; + if (isInitialProfileSelection) { + applyPermissionMode(nextMode, 'auto'); + } } } - }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); + }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]); + + // Handle profile route param from picker screens + React.useEffect(() => { + if (!useProfiles) { + return; + } + + const { nextSelectedProfileId, shouldClearParam } = consumeProfileIdParam({ + profileIdParam, + selectedProfileId, + }); + + if (nextSelectedProfileId === null) { + if (selectedProfileId !== null) { + setSelectedProfileId(null); + } + } else if (typeof nextSelectedProfileId === 'string') { + selectProfile(nextSelectedProfileId); + } + + if (shouldClearParam) { + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ profileId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: undefined } }, + } as never); + } + } + }, [navigation, profileIdParam, selectedProfileId, selectProfile, useProfiles]); + + // Keep agentType compatible with the currently selected profile. + React.useEffect(() => { + if (!useProfiles || selectedProfileId === null) { + return; + } - // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + if (!profile) { + return; + } + + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); + + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? 'claude'); + } + }, [agentType, allowGemini, profileMap, selectedProfileId, useProfiles]); + + const prevAgentTypeRef = React.useRef(agentType); + + // When agent type changes, keep the "permission level" consistent by mapping modes across backends. React.useEffect(() => { + const prev = prevAgentTypeRef.current; + if (prev === agentType) { + return; + } + prevAgentTypeRef.current = agentType; + + const current = permissionModeRef.current; const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - const isValidForCurrentAgent = (agentType === 'codex' || agentType === 'gemini') - ? validCodexGeminiModes.includes(permissionMode) - : validClaudeModes.includes(permissionMode); + const isValidForNewAgent = (agentType === 'codex' || agentType === 'gemini') + ? validCodexGeminiModes.includes(current) + : validClaudeModes.includes(current); - if (!isValidForCurrentAgent) { - setPermissionMode('default'); + if (isValidForNewAgent) { + return; } - }, [agentType, permissionMode]); + + const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); + applyPermissionMode(mapped, 'auto'); + }, [agentType, applyPermissionMode]); // Reset model mode when agent type changes to appropriate default React.useEffect(() => { @@ -747,241 +926,217 @@ function NewSessionWizard() { }, [agentType, modelMode]); // Scroll to section helpers - for AgentInput button clicks - const scrollToSection = React.useCallback((ref: React.RefObject) => { - if (!ref.current || !scrollViewRef.current) return; - - // Use requestAnimationFrame to ensure layout is painted before measuring - requestAnimationFrame(() => { - if (ref.current && scrollViewRef.current) { - ref.current.measureLayout( - scrollViewRef.current as any, - (x, y) => { - scrollViewRef.current?.scrollTo({ y: y - 20, animated: true }); - }, - () => { - console.warn('measureLayout failed'); - } - ); - } - }); + const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; model?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); + const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + return (e: any) => { + wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; + }; + }, []); + const scrollToWizardSection = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + const y = wizardSectionOffsets.current[key]; + if (typeof y !== 'number' || !scrollViewRef.current) return; + scrollViewRef.current.scrollTo({ y: Math.max(0, y - 20), animated: true }); }, []); const handleAgentInputProfileClick = React.useCallback(() => { - scrollToSection(profileSectionRef); - }, [scrollToSection]); + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); + scrollToWizardSection('machine'); + }, [scrollToWizardSection]); const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); + scrollToWizardSection('path'); + }, [scrollToWizardSection]); - const handleAgentInputPermissionChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - scrollToSection(permissionSectionRef); - }, [scrollToSection]); + const handleAgentInputPermissionClick = React.useCallback(() => { + scrollToWizardSection('permission'); + }, [scrollToWizardSection]); const handleAgentInputAgentClick = React.useCallback(() => { - scrollToSection(profileSectionRef); // Agent tied to profile section - }, [scrollToSection]); + scrollToWizardSection('agent'); + }, [scrollToWizardSection]); - const handleAddProfile = React.useCallback(() => { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - const profileData = encodeURIComponent(JSON.stringify(newProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); - }, [router]); + const ignoreProfileRowPressRef = React.useRef(false); - const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { - const profileData = encodeURIComponent(JSON.stringify(profile)); - const machineId = selectedMachineId || ''; - router.push(`/new/pick/profile-edit?profileData=${profileData}&machineId=${machineId}`); - }, [router, selectedMachineId]); - - const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); - }, [router]); + const openProfileEnvVarsPreview = React.useCallback((profile: AIBackendProfile) => { + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: getProfileEnvironmentVariables(profile), + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: profile.name, + }, + }); + }, [selectedMachine, selectedMachineId]); + + const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { + return ; + }, []); + + const renderDefaultEnvironmentRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavoriteProfile(''), + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [isDefaultEnvironmentFavorite, selectedIndicatorColor, theme.colors.textSecondary, toggleFavoriteProfile]); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; + + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => openProfileEdit({ profileId: profile.id }), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => handleDeleteProfile(profile), + onViewEnvironmentVariables: envVarCount > 0 ? () => openProfileEnvVarsPreview(profile) : undefined, + }); + + return ( + + + + + 0 ? ['envVars'] : [])]} + iconSize={20} + onActionPressIn={() => { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEnvVarsPreview, + openProfileEdit, + screenWidth, + selectedIndicatorColor, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, + ]); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { const parts: string[] = []; const availability = isProfileAvailable(profile); - // Add "Built-in" indicator first for built-in profiles if (profile.isBuiltIn) { parts.push('Built-in'); } - // Add CLI type second (before warnings/availability) if (profile.compatibility.claude && profile.compatibility.codex) { - parts.push('Claude & Codex CLI'); + parts.push('Claude & Codex'); } else if (profile.compatibility.claude) { - parts.push('Claude CLI'); + parts.push('Claude'); } else if (profile.compatibility.codex) { - parts.push('Codex CLI'); + parts.push('Codex'); } - // Add availability warning if unavailable if (!availability.available && availability.reason) { if (availability.reason.startsWith('requires-agent:')) { const required = availability.reason.split(':')[1]; - parts.push(`⚠️ This profile uses ${required} CLI only`); + parts.push(`Requires ${required}`); } else if (availability.reason.startsWith('cli-not-detected:')) { const cli = availability.reason.split(':')[1]; - const cliName = cli === 'claude' ? 'Claude' : 'Codex'; - parts.push(`⚠️ ${cliName} CLI not detected (this profile needs it)`); + parts.push(`${cli} CLI not detected`); } } - // Get model name - check both anthropicConfig and environmentVariables - let modelName: string | undefined; - if (profile.anthropicConfig?.model) { - // User set in GUI - literal value, no evaluation needed - modelName = profile.anthropicConfig.model; - } else if (profile.openaiConfig?.model) { - modelName = profile.openaiConfig.model; - } else { - // Check environmentVariables - may need ${VAR} evaluation - const modelEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_MODEL'); - if (modelEnvVar) { - const resolved = resolveEnvVarSubstitution(modelEnvVar.value, daemonEnv); - if (resolved) { - // Show as "VARIABLE: value" when evaluated from ${VAR} - const varName = modelEnvVar.value.match(/^\$\{(.+)\}$/)?.[1]; - modelName = varName ? `${varName}: ${resolved}` : resolved; - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - modelName = modelEnvVar.value; - } - } - } - - if (modelName) { - parts.push(modelName); - } - - // Add base URL if exists in environmentVariables - const baseUrlEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_BASE_URL'); - if (baseUrlEnvVar) { - const resolved = resolveEnvVarSubstitution(baseUrlEnvVar.value, daemonEnv); - if (resolved) { - // Extract hostname and show with variable name - const varName = baseUrlEnvVar.value.match(/^\$\{([A-Z_][A-Z0-9_]*)/)?.[1]; - try { - const url = new URL(resolved); - const display = varName ? `${varName}: ${url.hostname}` : url.hostname; - parts.push(display); - } catch { - // Not a valid URL, show as-is with variable name - parts.push(varName ? `${varName}: ${resolved}` : resolved); - } - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - parts.push(baseUrlEnvVar.value); - } - } - - return parts.join(', '); - }, [agentType, isProfileAvailable, daemonEnv]); - - 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]); + return parts.join(' · '); + }, [isProfileAvailable]); - React.useEffect(() => { - let handler = (savedProfile: AIBackendProfile) => { - // Handle saved profile from profile-edit screen - - // Check if this is a built-in profile being edited - const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === savedProfile.id); - let profileToSave = savedProfile; - - // For built-in profiles, create a new custom profile instead of modifying the built-in - if (isBuiltIn) { - profileToSave = { - ...savedProfile, - id: randomUUID(), // Generate new UUID for custom profile - isBuiltIn: false, - }; - } + const handleMachineClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); - const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); - let updatedProfiles: AIBackendProfile[]; + const handleProfileClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId]); - if (existingIndex >= 0) { - // Update existing profile - updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profileToSave; - } else { - // Add new profile - updatedProfiles = [...profiles, profileToSave]; + const handleAgentClick = React.useCallback(() => { + if (useProfiles && selectedProfileId !== null) { + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + const supportedAgents = profile + ? (Object.entries(profile.compatibility) as Array<[string, boolean]>) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini) + : []; + + if (supportedAgents.length <= 1) { + Modal.alert( + 'AI Backend', + 'AI backend is selected by your profile. To change it, select a different profile.', + [ + { text: t('common.ok'), style: 'cancel' }, + { text: 'Change Profile', onPress: handleProfileClick }, + ], + ); + return; } - setProfiles(updatedProfiles); // Use mutable setter for persistence - setSelectedProfileId(profileToSave.id); - }; - onProfileSaved = handler; - return () => { - onProfileSaved = () => { }; - }; - }, [profiles, setProfiles]); + const currentIndex = supportedAgents.indexOf(agentType); + const nextIndex = (currentIndex + 1) % supportedAgents.length; + setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? 'claude'); + return; + } - const handleMachineClick = React.useCallback(() => { - router.push('/new/pick/machine'); - }, [router]); + handleAgentCycle(); + }, [agentType, allowGemini, handleAgentCycle, handleProfileClick, profileMap, selectedProfileId, setAgentType, useProfiles]); const handlePathClick = React.useCallback(() => { if (selectedMachineId) { @@ -995,6 +1150,33 @@ function NewSessionWizard() { } }, [selectedMachineId, selectedPath, router]); + const selectedProfileForEnvVars = React.useMemo(() => { + if (!useProfiles || !selectedProfileId) return null; + return profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId) || null; + }, [profileMap, selectedProfileId, useProfiles]); + + const selectedProfileEnvVars = React.useMemo(() => { + if (!selectedProfileForEnvVars) return {}; + return transformProfileToEnvironmentVars(selectedProfileForEnvVars) ?? {}; + }, [selectedProfileForEnvVars]); + + const selectedProfileEnvVarsCount = React.useMemo(() => { + return Object.keys(selectedProfileEnvVars).length; + }, [selectedProfileEnvVars]); + + const handleEnvVarsClick = React.useCallback(() => { + if (!selectedProfileForEnvVars) return; + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: selectedProfileEnvVars, + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: selectedProfileForEnvVars.name, + }, + }); + }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]); + // Session creation const handleCreateSession = React.useCallback(async () => { if (!selectedMachineId) { @@ -1030,20 +1212,26 @@ function NewSessionWizard() { // Save settings const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10); - sync.applySettings({ + const profilesActive = useProfiles; + + // Keep prod session creation behavior unchanged: + // only persist/apply profiles & model when an explicit opt-in flag is enabled. + const settingsUpdate: Parameters[0] = { recentMachinePaths: updatedPaths, lastUsedAgent: agentType, - lastUsedProfile: selectedProfileId, lastUsedPermissionMode: permissionMode, - lastUsedModelMode: modelMode, - }); + }; + if (profilesActive) { + settingsUpdate.lastUsedProfile = selectedProfileId; + } + sync.applySettings(settingsUpdate); // Get environment variables from selected profile let environmentVariables = undefined; - if (selectedProfileId) { - const selectedProfile = profileMap.get(selectedProfileId); + if (profilesActive && selectedProfileId) { + const selectedProfile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); if (selectedProfile) { - environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); + environmentVariables = transformProfileToEnvironmentVars(selectedProfile); } } @@ -1052,6 +1240,7 @@ function NewSessionWizard() { directory: actualPath, approvedNewDirectoryCreation: true, agent: agentType, + profileId: profilesActive ? (selectedProfileId ?? '') : undefined, environmentVariables }); @@ -1093,17 +1282,31 @@ function NewSessionWizard() { Modal.alert(t('common.error'), errorMessage); setIsCreating(false); } - }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]); + }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); + + const handleCloseModal = React.useCallback(() => { + // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. + // Fall back to home so the user always has an exit. + if (Platform.OS === 'web') { + if (typeof window !== 'undefined' && window.history.length > 1) { + router.back(); + } else { + router.replace('/'); + } + return; + } - const screenWidth = useWindowDimensions().width; + router.back(); + }, [router]); // Machine online status for AgentInput (DRY - reused in info box too) const connectionStatus = React.useMemo(() => { if (!selectedMachine) return undefined; const isOnline = isMachineOnline(selectedMachine); - // Include CLI status only when in wizard AND detection completed - const includeCLI = selectedMachineId && cliAvailability.timestamp > 0; + // Always include CLI status when a machine is selected. + // Values may be `null` while detection is still in flight / failed; the UI renders them as informational. + const includeCLI = Boolean(selectedMachineId); return { text: isOnline ? 'online' : 'offline', @@ -1118,6 +1321,20 @@ function NewSessionWizard() { }; }, [selectedMachine, selectedMachineId, cliAvailability, experimentsEnabled, theme]); + const persistDraftNow = React.useCallback(() => { + saveNewSessionDraft({ + input: sessionPrompt, + selectedMachineId, + selectedPath, + selectedProfileId: useProfiles ? selectedProfileId : null, + agentType, + permissionMode, + modelMode, + sessionType, + updatedAt: Date.now(), + }); + }, [agentType, modelMode, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + // Persist the current wizard state so it survives remounts and screen navigation // Uses debouncing to avoid excessive writes const draftSaveTimerRef = React.useRef | null>(null); @@ -1126,22 +1343,14 @@ function NewSessionWizard() { clearTimeout(draftSaveTimerRef.current); } draftSaveTimerRef.current = setTimeout(() => { - saveNewSessionDraft({ - input: sessionPrompt, - selectedMachineId, - selectedPath, - agentType, - permissionMode, - sessionType, - updatedAt: Date.now(), - }); + persistDraftNow(); }, 250); return () => { if (draftSaveTimerRef.current) { clearTimeout(draftSaveTimerRef.current); } }; - }, [sessionPrompt, selectedMachineId, selectedPath, agentType, permissionMode, sessionType]); + }, [persistDraftNow]); // ======================================================================== // CONTROL A: Simpler AgentInput-driven layout (flag OFF) @@ -1151,46 +1360,81 @@ function NewSessionWizard() { return ( - + {/* Session type selector only if experiments enabled */} {experimentsEnabled && ( - 700 ? 16 : 8, marginBottom: 16 }}> + - + + + )} {/* AgentInput with inline chips - sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - []} - agentType={agentType} - onAgentClick={handleAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handlePermissionModeChange} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleMachineClick} - currentPath={selectedPath} - onPathClick={handlePathClick} - /> + + + + []} + agentType={agentType} + onAgentClick={handleAgentClick} + permissionMode={permissionMode} + onPermissionModeChange={handlePermissionModeChange} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleMachineClick} + currentPath={selectedPath} + onPathClick={handlePathClick} + contentPaddingHorizontal={0} + {...(useProfiles + ? { + profileId: selectedProfileId, + onProfileClick: handleProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } + : {})} + /> + @@ -1205,8 +1449,8 @@ function NewSessionWizard() { return ( - 700 ? 16 : 8 } - ]}> - - - {/* CLI Detection Status Banner - shows after detection completes */} - {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - - - - - {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - - - - {connectionStatus.text} + + + + {useProfiles && ( + <> + + + + Select AI Profile - - - {cliAvailability.claude ? '✓' : '✗'} - - - claude - - - - - {cliAvailability.codex ? '✓' : '✗'} - - - codex - - - {experimentsEnabled && ( - - - {cliAvailability.gemini ? '✓' : '✗'} - - - gemini - - + + Select an AI profile to apply environment variables and defaults to your session. + + + {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( + + {isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setSelectedProfileId(null); + }} + rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} + showDivider={favoriteProfileItems.length > 0} + /> + )} + {favoriteProfileItems.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + { + if (!availability.available) return; + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + selectProfile(profile.id); + }} + rightElement={renderProfileRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + { + if (!availability.available) return; + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + selectProfile(profile.id); + }} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + {!isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setSelectedProfileId(null); + }} + rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + )} + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + { + if (!availability.available) return; + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + selectProfile(profile.id); + }} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + } + onPress={handleAddProfile} + showChevron={false} + showDivider={false} + /> + + + + + )} + + {/* Section: AI Backend */} + + + + + Select AI Backend + - )} - - {/* 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, - }} - > + + {useProfiles && selectedProfileId + ? 'Limited by your selected profile and available CLIs on this machine.' + : 'Select which AI runs your session.'} + + + {/* Missing CLI Installation Banners */} + {selectedMachineId && cliAvailability.claude === false && !isWarningDismissed('claude') && !hiddenBanners.claude && ( + + + + + + Claude CLI Not Detected + + - this machine + 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', 'global')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} + onPress={() => handleCLIBannerDismiss('claude', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > - - 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 + + + Install: npm install -g @anthropic-ai/claude-code • - - - 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 + { + if (Platform.OS === 'web') { + window.open('https://docs.anthropic.com/en/docs/claude-code/installation', '_blank'); + } + }}> + + View Installation Guide → - 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, - }} - > + )} + + {selectedMachineId && cliAvailability.codex === false && !isWarningDismissed('codex') && !hiddenBanners.codex && ( + + + + + + Codex CLI Not Detected + + - this machine + 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('gemini', 'global')} - style={{ - borderRadius: 4, - borderWidth: 1, - borderColor: theme.colors.textSecondary, - paddingHorizontal: 8, - paddingVertical: 3, - }} + onPress={() => handleCLIBannerDismiss('codex', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > - - 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)} + + + Install: npm install -g codex-cli • - - - {selectedProfileId === profile.id && ( - - )} - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - + { + if (Platform.OS === 'web') { + window.open('https://github.com/openai/openai-codex', '_blank'); + } + }}> + + View Installation Guide → + + + + )} + + {selectedMachineId && cliAvailability.gemini === false && allowGemini && !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 + + + { - e.stopPropagation(); - handleEditProfile(profile); - }} + onPress={() => handleCLIBannerDismiss('gemini', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > - + - - ); - })} - - {/* 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 ? '✳' : '꩜'} + + + Install gemini CLI if available • - - - {profile.name} - - {getProfileSubtitle(profile)} - - - - {selectedProfileId === profile.id && ( - - )} - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - + { + if (Platform.OS === 'web') { + window.open('https://ai.google.dev/gemini-api/docs/get-started', '_blank'); + } + }}> + + View Gemini Docs → + - - ); - })} - - {/* Profile Action Buttons */} - - - - - Add - - - selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - > - - - Duplicate - - - selectedProfile && !selectedProfile.isBuiltIn && handleDeleteProfile(selectedProfile)} - disabled={!selectedProfile || selectedProfile.isBuiltIn} - > - - - Delete - - - + + )} + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + {(() => { + const selectedProfile = useProfiles && selectedProfileId + ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) + : null; + + const options: Array<{ + key: 'claude' | 'codex' | 'gemini'; + title: string; + subtitle: string; + icon: React.ComponentProps['name']; + }> = [ + { key: 'claude', title: 'Claude', subtitle: 'Claude CLI', icon: 'sparkles-outline' }, + { key: 'codex', title: 'Codex', subtitle: 'Codex CLI', icon: 'terminal-outline' }, + ...(allowGemini ? [{ key: 'gemini' as const, title: 'Gemini', subtitle: 'Gemini CLI', icon: 'planet-outline' as const }] : []), + ]; + + return options.map((option, index) => { + const compatible = !selectedProfile || !!selectedProfile.compatibility?.[option.key]; + const cliOk = cliAvailability[option.key] !== false; + const disabledReason = !compatible + ? 'Not compatible with the selected profile.' + : !cliOk + ? `${option.title} CLI not detected on this machine.` + : null; + + const isSelected = agentType === option.key; + + return ( + } + selected={isSelected} + disabled={!!disabledReason} + onPress={() => { + if (disabledReason) { + Modal.alert( + 'AI Backend', + disabledReason, + compatible + ? [{ text: t('common.ok'), style: 'cancel' }] + : [ + { text: t('common.ok'), style: 'cancel' }, + ...(useProfiles && selectedProfileId ? [{ text: 'Change Profile', onPress: handleAgentInputProfileClick }] : []), + ], + ); + return; + } + setAgentType(option.key); + }} + rightElement={( + + + + )} + showChevron={false} + showDivider={index < options.length - 1} + /> + ); + }); + })()} + + + {modelOptions.length > 0 && ( + + + + + Select AI Model + + + + Choose the model used by this session. + + + {modelOptions.map((option, index, options) => { + const isSelected = modelMode === option.value; + return ( + } + showChevron={false} + selected={isSelected} + onPress={() => setModelMode(option.value)} + rightElement={( + + + + )} + showDivider={index < options.length - 1} + /> + ); + })} + + + )} - {/* 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 2: Machine Selection */} + + + + Select Machine + + + + Choose where this session runs. + - {/* Section 3: Working Directory */} - - - 3. - - Select Working Directory + + { + setSelectedMachineId(machine.id); + const bestPath = getRecentPathForMachine(machine.id); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); + } + }} + /> - - - - 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 3: Working Directory */} + + + + Select Working Directory + + + + Pick the folder used for commands and context. + - {/* 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 4: Permission Mode */} + + + + Select Permission Mode + + + + Control how strictly actions require approval. + + + {(agentType === 'codex' || agentType === 'gemini' + ? [ + { value: 'default' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.default' : 'agentInput.geminiPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.readOnly' : 'agentInput.geminiPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.safeYolo' : 'agentInput.geminiPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.yolo' : 'agentInput.geminiPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: t('agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits' as PermissionMode, label: t('agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: t('agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: t('agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( + + } + rightElement={permissionMode === option.value ? ( + + ) : null} + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> + ))} + + + + + {/* Section 5: Session Type */} + + + + Select Session Type + - )} - - )} + + Choose a simple session or one tied to a Git worktree. + + + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + + + + - - - {/* Section 5: AgentInput - Sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - []} - agentType={agentType} - onAgentClick={handleAgentInputAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handleAgentInputPermissionChange} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleAgentInputMachineClick} - currentPath={selectedPath} - onPathClick={handleAgentInputPathClick} - profileId={selectedProfileId} - onProfileClick={handleAgentInputProfileClick} - /> + + {/* AgentInput - Sticky at bottom */} + + + + []} + agentType={agentType} + onAgentClick={handleAgentInputAgentClick} + permissionMode={permissionMode} + onPermissionClick={handleAgentInputPermissionClick} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleAgentInputMachineClick} + currentPath={selectedPath} + onPathClick={handleAgentInputPathClick} + contentPaddingHorizontal={0} + {...(useProfiles ? { + profileId: selectedProfileId, + onProfileClick: handleAgentInputProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c02580e8d..2ea2e0b72 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -1,36 +1,15 @@ import React from 'react'; import { View, Text } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions } from '@/sync/storage'; -import { Ionicons } from '@expo/vector-icons'; -import { isMachineOnline } from '@/utils/machineUtils'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; -import { SearchableListSelector } from '@/components/SearchableListSelector'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { getRecentMachinesFromSessions } from '@/utils/recentMachines'; -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, -})); - -export default function MachinePickerScreen() { +export default React.memo(function MachinePickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); @@ -38,6 +17,8 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -50,7 +31,8 @@ export default function MachinePickerScreen() { const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ - ...CommonActions.setParams({ machineId }), + type: 'SET_PARAMS', + payload: { params: { machineId } }, source: previousRoute.key, } as never); } @@ -60,27 +42,7 @@ export default function MachinePickerScreen() { // 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); + return getRecentMachinesFromSessions({ machines, sessions }); }, [sessions, machines]); if (machines.length === 0) { @@ -89,14 +51,14 @@ export default function MachinePickerScreen() { - No machines available + {t('newSession.noMachinesFound')} @@ -109,68 +71,47 @@ export default function MachinePickerScreen() { - - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - - ), - getRecentItemIcon: (machine) => ( - - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: false, // Simpler modal experience - no favorites in modal - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={[]} - selectedItem={selectedMachine} + favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} + showFavorites={true} + showSearch={useMachinePickerSearch} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + setFavoriteMachines(isInFavorites + ? favoriteMachines.filter(id => id !== machine.id) + : [...favoriteMachines, machine.id] + ); + }} /> ); -} \ No newline at end of file +}); + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.groupped.background, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, +})); diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b0214d6c6..e83b3914d 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -1,75 +1,31 @@ -import React, { useState, useMemo, useRef } from 'react'; -import { View, Text, ScrollView, Pressable } from 'react-native'; +import React, { useState, useMemo } from 'react'; +import { View, Text, Pressable } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { CommonActions, useNavigation } from '@react-navigation/native'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions, useSetting } from '@/sync/storage'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { layout } from '@/components/layout'; import { t } from '@/text'; -import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; - -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - alignItems: 'center', - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - emptyText: { - fontSize: 16, - color: theme.colors.textSecondary, - textAlign: 'center', - ...Typography.default(), - }, - pathInputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingVertical: 16, - }, - pathInput: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: 10, - paddingHorizontal: 12, - minHeight: 36, - position: 'relative', - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, -})); +import { ItemList } from '@/components/ItemList'; +import { layout } from '@/components/layout'; +import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; +import { getRecentPathsForMachine } from '@/utils/recentPaths'; -export default function PathPickerScreen() { +export default React.memo(function PathPickerScreen() { const { theme } = useUnistyles(); const styles = stylesheet; const router = useRouter(); - const navigation = useNavigation(); const params = useLocalSearchParams<{ machineId?: string; selectedPath?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const inputRef = useRef(null); const recentMachinePaths = useSetting('recentMachinePaths'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); + const [favoriteDirectoriesRaw, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const favoriteDirectories = favoriteDirectoriesRaw ?? []; const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [pathSearchQuery, setPathSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -79,61 +35,20 @@ export default function PathPickerScreen() { // Get recent paths for this machine - prioritize from settings, then fall back to sessions const recentPaths = useMemo(() => { if (!params.machineId) return []; - - const paths: string[] = []; - const pathSet = new Set(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === params.machineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } + return getRecentPathsForMachine({ + machineId: params.machineId, + recentMachinePaths, + sessions, }); + }, [params.machineId, recentMachinePaths, sessions]); - // 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 === params.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 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, params.machineId, recentMachinePaths]); - - - 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) { - navigation.dispatch({ - ...CommonActions.setParams({ path: pathToUse }), - source: previousRoute.key, - } as never); - } + const handleSelectPath = React.useCallback((pathOverride?: string) => { + const rawPath = typeof pathOverride === 'string' ? pathOverride : customPath; + const pathToUse = rawPath.trim() || machine?.metadata?.homeDir || '/home'; + router.setParams({ path: pathToUse }); router.back(); - }, [customPath, router, machine, navigation]); + }, [customPath, router, machine]); if (!machine) { return ( @@ -141,11 +56,11 @@ export default function PathPickerScreen() { ( handleSelectPath()} disabled={!customPath.trim()} style={({ pressed }) => ({ marginRight: 16, @@ -162,13 +77,11 @@ export default function PathPickerScreen() { ) }} /> - + - - No machine selected - + {t('newSession.noMachineSelected')} - + ); } @@ -178,11 +91,11 @@ export default function PathPickerScreen() { ( handleSelectPath()} disabled={!customPath.trim()} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1, @@ -195,107 +108,71 @@ export default function PathPickerScreen() { color={theme.colors.header.tint} /> - ) + ), }} /> - - - - - - - - - - - - {recentPaths.length > 0 && ( - - {recentPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === recentPaths.length - 1; - - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={!isLast} - /> - ); - })} - - )} - - {recentPaths.length === 0 && ( - - {(() => { - const homeDir = machine.metadata?.homeDir || '/home'; - const suggestedPaths = [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop` - ]; - return suggestedPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < 3} - /> - ); - }); - })()} - - )} - - - + + {usePathPickerSearch && ( + + )} + + + + ); -} \ No newline at end of file +}); + +const stylesheet = StyleSheet.create((theme) => ({ + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyText: { + fontSize: 16, + color: theme.colors.textSecondary, + textAlign: 'center', + ...Typography.default(), + }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, +})); diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..973e8d520 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { View, KeyboardAvoidingView, Platform, useWindowDimensions, Pressable } from 'react-native'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -9,48 +9,237 @@ import { t } from '@/text'; import { ProfileEditForm } from '@/components/ProfileEditForm'; import { AIBackendProfile } from '@/sync/settings'; import { layout } from '@/components/layout'; -import { callbacks } from '../index'; +import { useSettingMutable } from '@/sync/storage'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { Modal } from '@/modal'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; +import { Ionicons } from '@expo/vector-icons'; -export default function ProfileEditScreen() { +export default React.memo(function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); - const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ + profileId?: string | string[]; + cloneFromProfileId?: string | string[]; + profileData?: string | string[]; + machineId?: string | string[]; + }>(); + const profileIdParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const cloneFromProfileIdParam = Array.isArray(params.cloneFromProfileId) ? params.cloneFromProfileId[0] : params.cloneFromProfileId; + const profileDataParam = Array.isArray(params.profileData) ? params.profileData[0] : params.profileData; + const machineIdParam = Array.isArray(params.machineId) ? params.machineId[0] : params.machineId; const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [isDirty, setIsDirty] = React.useState(false); + const isDirtyRef = React.useRef(false); + const saveRef = React.useRef<(() => boolean) | null>(null); + + React.useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); + + React.useEffect(() => { + // On iOS native-stack modals, swipe-down dismissal can bypass `beforeRemove` in practice. + // The only reliable way to ensure unsaved edits aren't lost is to disable the gesture + // while the form is dirty, and rely on the header back/cancel flow (which we guard). + const setOptions = (navigation as any)?.setOptions; + if (typeof setOptions !== 'function') return; + setOptions({ gestureEnabled: !isDirty }); + }, [isDirty, navigation]); + + React.useEffect(() => { + const setOptions = (navigation as any)?.setOptions; + if (typeof setOptions !== 'function') return; + return () => { + // Always re-enable the gesture when leaving this screen. + setOptions({ gestureEnabled: true }); + }; + }, [navigation]); // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { - if (params.profileData) { + if (profileDataParam) { try { - return JSON.parse(decodeURIComponent(params.profileData)); + // Params may arrive already decoded (native) or URL-encoded (web / manual encodeURIComponent). + // Try raw JSON first, then fall back to decodeURIComponent. + try { + return JSON.parse(profileDataParam); + } catch { + return JSON.parse(decodeURIComponent(profileDataParam)); + } } catch (error) { console.error('Failed to parse profile data:', error); } } + const resolveById = (id: string) => profiles.find((p) => p.id === id) ?? getBuiltInProfile(id) ?? null; + + if (cloneFromProfileIdParam) { + const base = resolveById(cloneFromProfileIdParam); + if (base) { + return duplicateProfileForEdit(base, { copySuffix: t('profiles.copySuffix') }); + } + } + + if (profileIdParam) { + const existing = resolveById(profileIdParam); + if (existing) { + return existing; + } + } + // Return empty profile for new profile creation - return { - id: '', - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - }, [params.profileData]); + return createEmptyCustomProfile(); + }, [cloneFromProfileIdParam, profileDataParam, profileIdParam, profiles]); - const handleSave = (savedProfile: AIBackendProfile) => { - // Call the callback to notify wizard of saved profile - callbacks.onProfileSaved(savedProfile); - router.back(); - }; + const confirmDiscard = React.useCallback(async () => { + const saveText = profile.isBuiltIn ? t('common.saveAs') : t('common.save'); + const message = profile.isBuiltIn + ? `${t('common.unsavedChangesWarning')}\n\n${t('profiles.builtInSaveAsHint')}` + : t('common.unsavedChangesWarning'); + return promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), + { + title: t('common.discardChanges'), + message, + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), + }, + ); + }, [profile.isBuiltIn]); + + React.useEffect(() => { + const addListener = (navigation as any)?.addListener; + if (typeof addListener !== 'function') { + return; + } + + const subscription = addListener.call(navigation, 'beforeRemove', (e: any) => { + if (!isDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const decision = await confirmDiscard(); + if (decision === 'discard') { + isDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } else if (decision === 'save') { + saveRef.current?.(); + } + })(); + }); + + return () => subscription?.remove?.(); + }, [confirmDiscard, navigation]); - const handleCancel = () => { + const handleSave = (savedProfile: AIBackendProfile): boolean => { + if (!savedProfile.name || savedProfile.name.trim() === '') { + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return false; + } + + const isBuiltIn = + savedProfile.isBuiltIn === true || + DEFAULT_PROFILES.some((bp) => bp.id === savedProfile.id) || + !!getBuiltInProfile(savedProfile.id); + + let profileToSave = savedProfile; + if (isBuiltIn) { + profileToSave = convertBuiltInProfileToCustom(savedProfile); + } + + const builtInNames = DEFAULT_PROFILES + .map((bp) => getBuiltInProfile(bp.id)) + .filter((p): p is AIBackendProfile => !!p) + .map((p) => p.name.trim()); + const hasBuiltInNameConflict = builtInNames.includes(profileToSave.name.trim()); + + // Duplicate name guard (same behavior as settings/profiles) + const isDuplicateName = profiles.some((p) => { + if (isBuiltIn) { + return p.name.trim() === profileToSave.name.trim(); + } + return p.id !== profileToSave.id && p.name.trim() === profileToSave.name.trim(); + }); + if (isDuplicateName || hasBuiltInNameConflict) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return false; + } + + const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id); + const isNewProfile = existingIndex < 0; + const updatedProfiles = existingIndex >= 0 + ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) + : [...profiles, profileToSave]; + + setProfiles(updatedProfiles); + + // Update last used profile for convenience in other screens. + if (isNewProfile) { + setLastUsedProfile(profileToSave.id); + // For newly created profiles (including "Save As" from a built-in profile), prefer passing the id + // back to the previous picker route (if present). The picker already knows how to forward the + // selection to /new and close itself. This avoids stacking /new on top of /new (wizard case). + isDirtyRef.current = false; + setIsDirty(false); + const state = (navigation as any).getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + (navigation as any).dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: profileToSave.id } }, + source: previousRoute.key, + } as never); + router.back(); + return true; + } + + // Fallback: if we can't find a previous route to set params on, go to /new directly. + router.replace({ + pathname: '/new', + params: { profileId: profileToSave.id }, + } as any); + return true; + } + + // Pass selection back to the /new screen via navigation params (unmount-safe). + const state = (navigation as any).getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + (navigation as any).dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId: profileToSave.id } }, + source: previousRoute.key, + } as never); + } + // Prevent the unsaved-changes guard from triggering on successful save. + isDirtyRef.current = false; + setIsDirty(false); router.back(); + return true; }; + const handleCancel = React.useCallback(() => { + void (async () => { + if (!isDirtyRef.current) { + router.back(); + return; + } + const decision = await confirmDiscard(); + if (decision === 'discard') { + isDirtyRef.current = false; + router.back(); + } else if (decision === 'save') { + saveRef.current?.(); + } + })(); + }, [confirmDiscard, router]); + return ( ( + ({ + opacity: pressed ? 0.7 : 1, + padding: 4, + })} + > + + + ), + headerRight: () => ( + saveRef.current?.()} + accessibilityRole="button" + accessibilityLabel={t('common.save')} + hitSlop={12} + style={({ pressed }) => ({ + opacity: pressed ? 0.7 : 1, + padding: 4, + })} + > + + + ), + } + : {}), }} /> ); -} +}); const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, - backgroundColor: theme.colors.surface, - paddingTop: rt.insets.top, + backgroundColor: theme.colors.groupped.background, paddingBottom: rt.insets.bottom, }, })); diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx new file mode 100644 index 000000000..ddc76b732 --- /dev/null +++ b/sources/app/(app)/new/pick/profile.tsx @@ -0,0 +1,380 @@ +import React from 'react'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; +import { View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ItemList'; +import { useSetting, useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { AIBackendProfile } from '@/sync/settings'; +import { Modal } from '@/modal'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import { buildProfileActions } from '@/components/profileActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; + +export default React.memo(function ProfilePickerScreen() { + const { theme } = useUnistyles(); + const styles = stylesheet; + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); + const useProfiles = useSetting('useProfiles'); + const experimentsEnabled = useSetting('experiments'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); + + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const ignoreProfileRowPressRef = React.useRef(false); + + const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { + return ; + }, []); + + const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { + const parts: string[] = []; + if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); + if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); + if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); + return parts.length > 0 ? parts.join(' • ') : ''; + }, [experimentsEnabled]); + + const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { + const backend = getProfileBackendSubtitle(profile); + if (profile.isBuiltIn) { + const builtInLabel = t('profiles.builtIn'); + return backend ? `${builtInLabel} · ${backend}` : builtInLabel; + } + const customLabel = t('profiles.custom'); + return backend ? `${customLabel} · ${backend}` : customLabel; + }, [getProfileBackendSubtitle]); + + const setProfileParamAndClose = React.useCallback((profileId: string) => { + const state = navigation.getState(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { profileId } }, + source: previousRoute.key, + } as never); + } + router.back(); + }, [navigation, router]); + + const handleProfileRowPress = React.useCallback((profileId: string) => { + if (ignoreProfileRowPressRef.current) { + ignoreProfileRowPressRef.current = false; + return; + } + setProfileParamAndClose(profileId); + }, [setProfileParamAndClose]); + + React.useEffect(() => { + if (typeof profileId === 'string' && profileId.length > 0) { + setProfileParamAndClose(profileId); + } + }, [profileId, setProfileParamAndClose]); + + const openProfileCreate = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { machineId } : {}, + }); + }, [machineId, router]); + + const openProfileEdit = React.useCallback((profileId: string) => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { profileId, machineId } : { profileId }, + }); + }, [machineId, router]); + + const openProfileDuplicate = React.useCallback((cloneFromProfileId: string) => { + router.push({ + pathname: '/new/pick/profile-edit', + params: machineId ? { cloneFromProfileId, machineId } : { cloneFromProfileId }, + }); + }, [machineId, router]); + + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }, [favoriteProfileIds, setFavoriteProfileIds]); + + const handleAddProfile = React.useCallback(() => { + openProfileCreate(); + }, [openProfileCreate]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + // Only custom profiles live in `profiles` setting. + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedId === profile.id) { + setProfileParamAndClose(''); + } + }, + }, + ], + ); + }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); + + const renderProfileRowRightElement = React.useCallback( + (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: theme.colors.text, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => openProfileEdit(profile.id), + onDuplicate: () => openProfileDuplicate(profile.id), + onDelete: () => handleDeleteProfile(profile), + }); + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, + [ + handleDeleteProfile, + openProfileEdit, + openProfileDuplicate, + theme.colors.text, + theme.colors.textSecondary, + toggleFavoriteProfile, + ], + ); + + const renderDefaultEnvironmentRowRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavoriteProfile(''), + color: isFavorite ? theme.colors.text : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + { + ignoreNextRowPress(ignoreProfileRowPressRef); + }} + /> + + ); + }, [isDefaultEnvironmentFavorite, theme.colors.text, theme.colors.textSecondary, toggleFavoriteProfile]); + + return ( + <> + + + + {!useProfiles ? ( + + } + showChevron={false} + /> + } + onPress={() => router.push('/settings/features')} + /> + + ) : ( + <> + {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( + + {isDefaultEnvironmentFavorite && ( + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} + showDivider={favoriteProfileItems.length > 0} + /> + )} + {favoriteProfileItems.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + {!isDefaultEnvironmentFavorite && ( + } + onPress={() => handleProfileRowPress('')} + showChevron={false} + selected={selectedId === ''} + rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + )} + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + handleProfileRowPress(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + + } + onPress={handleAddProfile} + showChevron={false} + /> + + + )} + + + ); +}); + +const stylesheet = StyleSheet.create(() => ({ + itemList: { + paddingTop: 0, + }, + rowRightElement: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + indicatorSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, + selectedIndicatorVisible: { + opacity: 1, + }, + selectedIndicatorHidden: { + opacity: 0, + }, +})); diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index ac7261455..589e3e99b 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; @@ -7,13 +8,16 @@ import { useSettingMutable, useLocalSettingMutable } from '@/sync/storage'; import { Switch } from '@/components/Switch'; import { t } from '@/text'; -export default function FeaturesSettingsScreen() { +export default React.memo(function FeaturesSettingsScreen() { const [experiments, setExperiments] = useSettingMutable('experiments'); + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend'); const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled'); const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch'); return ( @@ -72,6 +76,34 @@ export default function FeaturesSettingsScreen() { } showChevron={false} /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={ + + } + showChevron={false} + /> {/* Web-only Features */} @@ -108,4 +140,4 @@ export default function FeaturesSettingsScreen() { )} ); -} +}); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index a2481e38a..e73f97b97 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -201,6 +201,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ statusRow: { flexDirection: 'row', alignItems: 'center', + flexWrap: 'wrap', }, statusText: { fontSize: 11, @@ -322,6 +323,25 @@ export const AgentInput = React.memo(React.forwardRef { + const cliStatus = props.connectionStatus?.cliStatus; + if (!cliStatus) return null; + + const format = (name: string, value: boolean | null | undefined) => { + if (value === true) return `${name}✓`; + if (value === false) return `${name}✗`; + return `${name}?`; + }; + + const parts = [ + format('claude', cliStatus.claude), + format('codex', cliStatus.codex), + ...(Object.prototype.hasOwnProperty.call(cliStatus, 'gemini') ? [format('gemini', cliStatus.gemini)] : []), + ]; + + return ` · CLI: ${parts.join(' ')}`; + }, [props.connectionStatus?.cliStatus]); + // Abort button state const [isAborting, setIsAborting] = React.useState(false); @@ -714,86 +734,19 @@ export const AgentInput = React.memo(React.forwardRef {props.connectionStatus && ( <> - - - - {props.connectionStatus.text} + + + {props.connectionStatus.text} + + {cliStatusText && ( + + {cliStatusText} - - {/* 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 - - - )} - )} )} @@ -1150,9 +1103,37 @@ export const AgentInput = React.memo(React.forwardRef )} + + , + + // Row 2: Path selector (separate line to match pre-PR272 layout) + props.currentPath && props.onPathClick ? ( + + + { + hapticsLight(); + props.onPathClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.currentPath} + + + - - + ) : null, + ]} diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index 33aefd357..bc1f2d3c2 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -1,142 +1,81 @@ import React from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; import { t } from '@/text'; -interface SessionTypeSelectorProps { +export interface SessionTypeSelectorProps { value: 'simple' | 'worktree'; onChange: (value: 'simple' | 'worktree') => void; + title?: string | null; } const stylesheet = StyleSheet.create((theme) => ({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: Platform.select({ default: 12, android: 16 }), - marginBottom: 12, - overflow: 'hidden', - }, - title: { - fontSize: 13, - color: theme.colors.textSecondary, - marginBottom: 8, - marginLeft: 16, - marginTop: 12, - ...Typography.default('semiBold'), - }, - optionContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - minHeight: 44, - }, - optionPressed: { - backgroundColor: theme.colors.surfacePressed, - }, - radioButton: { + radioOuter: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center', - marginRight: 12, }, - radioButtonActive: { + radioActive: { borderColor: theme.colors.radio.active, }, - radioButtonInactive: { + radioInactive: { borderColor: theme.colors.radio.inactive, }, - radioButtonDot: { + radioDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.colors.radio.dot, }, - optionContent: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - optionLabel: { - fontSize: 16, - ...Typography.default('regular'), - }, - optionLabelActive: { - color: theme.colors.text, - }, - optionLabelInactive: { - color: theme.colors.text, - }, - divider: { - height: Platform.select({ ios: 0.33, default: 0.5 }), - backgroundColor: theme.colors.divider, - marginLeft: 48, - }, })); -export const SessionTypeSelector: React.FC = ({ value, onChange }) => { - const { theme } = useUnistyles(); +export function SessionTypeSelectorRows({ value, onChange }: Pick) { const styles = stylesheet; - const handlePress = (type: 'simple' | 'worktree') => { - onChange(type); - }; - return ( - - {t('newSession.sessionType.title')} - - handlePress('simple')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'simple' && } - - - - {t('newSession.sessionType.simple')} - - - + <> + + {value === 'simple' && } + + )} + selected={value === 'simple'} + onPress={() => onChange('simple')} + showChevron={false} + showDivider={true} + /> + + + {value === 'worktree' && } + + )} + selected={value === 'worktree'} + onPress={() => onChange('worktree')} + showChevron={false} + showDivider={false} + /> + + ); +} - +export function SessionTypeSelector({ value, onChange, title = t('newSession.sessionType.title') }: SessionTypeSelectorProps) { + if (title === null) { + return ; + } - handlePress('worktree')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'worktree' && } - - - - {t('newSession.sessionType.worktree')} - - - - + return ( + + + ); -}; \ No newline at end of file +} diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx new file mode 100644 index 000000000..e2ef825d8 --- /dev/null +++ b/sources/components/newSession/MachineSelector.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; +import type { Machine } from '@/sync/storageTypes'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { t } from '@/text'; + +export interface MachineSelectorProps { + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines?: Machine[]; + favoriteMachines?: Machine[]; + onSelect: (machine: Machine) => void; + onToggleFavorite?: (machine: Machine) => void; + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + allSectionTitle?: string; + noItemsMessage?: string; +} + +export function MachineSelector({ + machines, + selectedMachine, + recentMachines = [], + favoriteMachines = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + searchPlacement = 'header', + searchPlaceholder: searchPlaceholderProp, + recentSectionTitle: recentSectionTitleProp, + favoritesSectionTitle: favoritesSectionTitleProp, + allSectionTitle: allSectionTitleProp, + noItemsMessage: noItemsMessageProp, +}: MachineSelectorProps) { + const { theme } = useUnistyles(); + + const searchPlaceholder = searchPlaceholderProp ?? t('newSession.machinePicker.searchPlaceholder'); + const recentSectionTitle = recentSectionTitleProp ?? t('newSession.machinePicker.recentTitle'); + const favoritesSectionTitle = favoritesSectionTitleProp ?? t('newSession.machinePicker.favoritesTitle'); + const allSectionTitle = allSectionTitleProp ?? t('newSession.machinePicker.allTitle'); + const noItemsMessage = noItemsMessageProp ?? t('newSession.machinePicker.emptyMessage'); + + return ( + + config={{ + getItemId: (machine) => machine.id, + getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + getItemSubtitle: undefined, + getItemIcon: () => ( + + ), + getRecentItemIcon: () => ( + + ), + getItemStatus: (machine) => { + const offline = !isMachineOnline(machine); + return { + text: offline ? t('status.offline') : t('status.online'), + color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + isPulsing: !offline, + }; + }, + formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + parseFromDisplay: (text) => { + return machines.find(m => + m.metadata?.displayName === text || m.metadata?.host === text || m.id === text + ) || null; + }, + filterItem: (machine, searchText) => { + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const id = machine.id.toLowerCase(); + const search = searchText.toLowerCase(); + return displayName.includes(search) || host.includes(search) || id.includes(search); + }, + searchPlaceholder, + recentSectionTitle, + favoritesSectionTitle, + allSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + allowCustomInput: false, + }} + items={machines} + recentItems={recentMachines} + favoriteItems={favoriteMachines} + selectedItem={selectedMachine} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + searchPlacement={searchPlacement} + /> + ); +} diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx new file mode 100644 index 000000000..c9506ce62 --- /dev/null +++ b/sources/components/newSession/PathSelector.tsx @@ -0,0 +1,614 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { View, Pressable, TextInput, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { SearchHeader } from '@/components/SearchHeader'; +import { Typography } from '@/constants/Typography'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; +import { t } from '@/text'; + +type PathSelectorBaseProps = { + machineHomeDir: string; + selectedPath: string; + onChangeSelectedPath: (path: string) => void; + onSubmitSelectedPath?: (path: string) => void; + submitBehavior?: 'showRow' | 'confirm'; + recentPaths: string[]; + usePickerSearch: boolean; + searchVariant?: 'header' | 'group' | 'none'; + favoriteDirectories: string[]; + onChangeFavoriteDirectories: (dirs: string[]) => void; + /** + * When true, clicking a path row will focus the input (and try to place cursor at the end). + * Wizard UX generally wants this OFF; the dedicated picker screen wants this ON. + */ + focusInputOnSelect?: boolean; +}; + +type PathSelectorControlledSearchProps = { + searchQuery: string; + onChangeSearchQuery: (text: string) => void; +}; + +type PathSelectorUncontrolledSearchProps = { + searchQuery?: undefined; + onChangeSearchQuery?: undefined; +}; + +export type PathSelectorProps = + & PathSelectorBaseProps + & (PathSelectorControlledSearchProps | PathSelectorUncontrolledSearchProps); + +const ITEM_RIGHT_GAP = 16; + +const stylesheet = StyleSheet.create((theme) => ({ + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, + searchHeaderContainer: { + backgroundColor: 'transparent', + borderBottomWidth: 0, + }, + rightElementRow: { + flexDirection: 'row', + alignItems: 'center', + gap: ITEM_RIGHT_GAP, + }, + iconSlot: { + width: 24, + alignItems: 'center', + justifyContent: 'center', + }, +})); + +export function PathSelector({ + machineHomeDir, + selectedPath, + onChangeSelectedPath, + recentPaths, + usePickerSearch, + searchVariant = 'header', + searchQuery: controlledSearchQuery, + onChangeSearchQuery: onChangeSearchQueryProp, + favoriteDirectories, + onChangeFavoriteDirectories, + onSubmitSelectedPath, + submitBehavior = 'showRow', + focusInputOnSelect = true, +}: PathSelectorProps) { + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const styles = stylesheet; + const inputRef = useRef(null); + const searchInputRef = useRef(null); + const searchWasFocusedRef = useRef(false); + + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); + const isSearchQueryControlled = controlledSearchQuery !== undefined && onChangeSearchQueryProp !== undefined; + const searchQuery = isSearchQueryControlled ? controlledSearchQuery : uncontrolledSearchQuery; + const setSearchQuery = isSearchQueryControlled ? onChangeSearchQueryProp : setUncontrolledSearchQuery; + const [submittedCustomPath, setSubmittedCustomPath] = useState(null); + + const suggestedPaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + }, [machineHomeDir]); + + const favoritePaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + const seen = new Set(); + const ordered: string[] = []; + for (const p of paths) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoriteDirectories, machineHomeDir]); + + const filteredFavoritePaths = useMemo(() => { + if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; + const query = searchQuery.toLowerCase(); + return favoritePaths.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, usePickerSearch]); + + const filteredRecentPaths = useMemo(() => { + const base = recentPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); + + const filteredSuggestedPaths = useMemo(() => { + const base = suggestedPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); + + const baseRecentPaths = useMemo(() => { + return recentPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, recentPaths]); + + const baseSuggestedPaths = useMemo(() => { + return suggestedPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, suggestedPaths]); + + const effectiveGroupSearchPlacement = useMemo(() => { + if (!usePickerSearch || searchVariant !== 'group') return null as null | 'favorites' | 'recent' | 'suggested' | 'fallback'; + const preferred: 'suggested' | 'recent' | 'favorites' | 'fallback' = + baseSuggestedPaths.length > 0 ? 'suggested' + : baseRecentPaths.length > 0 ? 'recent' + : favoritePaths.length > 0 ? 'favorites' + : 'fallback'; + + if (preferred === 'suggested') { + if (filteredSuggestedPaths.length > 0) return 'suggested'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + return 'suggested'; + } + + if (preferred === 'recent') { + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'recent'; + } + + if (preferred === 'favorites') { + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'favorites'; + } + + return 'fallback'; + }, [ + baseRecentPaths.length, + baseSuggestedPaths.length, + favoritePaths.length, + filteredFavoritePaths.length, + filteredRecentPaths.length, + filteredSuggestedPaths.length, + searchVariant, + usePickerSearch, + ]); + + useEffect(() => { + if (!usePickerSearch || searchVariant !== 'group') return; + if (!searchWasFocusedRef.current) return; + + const id = setTimeout(() => { + // Keep the search box usable while it moves between groups by restoring focus. + // (The underlying TextInput unmounts/remounts as placement changes.) + try { + searchInputRef.current?.focus?.(); + } catch { } + }, 0); + return () => clearTimeout(id); + }, [effectiveGroupSearchPlacement, searchVariant, usePickerSearch]); + + const showNoMatchesRow = usePickerSearch && searchQuery.trim().length > 0; + const shouldRenderFavoritesGroup = filteredFavoritePaths.length > 0 || effectiveGroupSearchPlacement === 'favorites'; + const shouldRenderRecentGroup = filteredRecentPaths.length > 0 || effectiveGroupSearchPlacement === 'recent'; + const shouldRenderSuggestedGroup = filteredSuggestedPaths.length > 0 || effectiveGroupSearchPlacement === 'suggested'; + const shouldRenderFallbackGroup = effectiveGroupSearchPlacement === 'fallback'; + + const toggleFavorite = React.useCallback((absolutePath: string) => { + const homeDir = machineHomeDir || '/home'; + + const relativePath = formatPathRelativeToHome(absolutePath, homeDir); + const resolved = resolveAbsolutePath(relativePath, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); + + onChangeFavoriteDirectories(isInFavorites + ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) + : [...favoriteDirectories, relativePath] + ); + }, [favoriteDirectories, machineHomeDir, onChangeFavoriteDirectories]); + + const handleChangeSelectedPath = React.useCallback((text: string) => { + onChangeSelectedPath(text); + if (submittedCustomPath && text.trim() !== submittedCustomPath) { + setSubmittedCustomPath(null); + } + }, [onChangeSelectedPath, submittedCustomPath]); + + const focusInputAtEnd = React.useCallback((value: string) => { + if (!focusInputOnSelect) return; + // Small delay so RN has applied the value before selection. + setTimeout(() => { + const input = inputRef.current; + input?.focus?.(); + try { + input?.setNativeProps?.({ selection: { start: value.length, end: value.length } }); + } catch { } + }, 50); + }, [focusInputOnSelect]); + + const setPathAndFocus = React.useCallback((path: string) => { + onChangeSelectedPath(path); + setSubmittedCustomPath(null); + focusInputAtEnd(path); + }, [focusInputAtEnd, onChangeSelectedPath]); + + const handleSubmitPath = React.useCallback(() => { + const trimmed = selectedPath.trim(); + if (!trimmed) return; + + if (trimmed !== selectedPath) { + onChangeSelectedPath(trimmed); + } + + onSubmitSelectedPath?.(trimmed); + if (submitBehavior !== 'confirm') { + setSubmittedCustomPath(trimmed); + } + }, [onChangeSelectedPath, onSubmitSelectedPath, selectedPath, submitBehavior]); + + const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + + ); + }, [selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const renderCustomRightElement = React.useCallback((absolutePath: string) => { + const isFavorite = favoritePaths.includes(absolutePath); + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + { + e.stopPropagation(); + setSubmittedCustomPath(null); + onChangeSelectedPath(''); + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + + + + ); + }, [favoritePaths, onChangeSelectedPath, selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const showSubmittedCustomPathRow = useMemo(() => { + if (!submittedCustomPath) return null; + const trimmed = selectedPath.trim(); + if (!trimmed) return null; + if (trimmed !== submittedCustomPath) return null; + + const visiblePaths = new Set([ + ...filteredFavoritePaths, + ...filteredRecentPaths, + ...filteredSuggestedPaths, + ]); + if (visiblePaths.has(trimmed)) return null; + + return trimmed; + }, [filteredFavoritePaths, filteredRecentPaths, filteredSuggestedPaths, selectedPath, submittedCustomPath]); + + return ( + <> + {usePickerSearch && searchVariant === 'header' && ( + + )} + + + + + + + + + + {showSubmittedCustomPathRow && ( + + } + onPress={() => focusInputAtEnd(showSubmittedCustomPathRow)} + selected={true} + showChevron={false} + rightElement={renderCustomRightElement(showSubmittedCustomPathRow)} + showDivider={false} + /> + + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( + + {effectiveGroupSearchPlacement === 'recent' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredRecentPaths.length === 0 + ? ( + + ) + : filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {shouldRenderFavoritesGroup && ( + + {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredFavoritePaths.length === 0 + ? ( + + ) + : filteredFavoritePaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length > 0 && searchVariant !== 'group' && ( + + {filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && ( + + {effectiveGroupSearchPlacement === 'suggested' && ( + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + )} + {filteredSuggestedPaths.length === 0 + ? ( + + ) + : filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && searchVariant !== 'group' && ( + + {filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && ( + + { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} + containerStyle={styles.searchHeaderContainer} + /> + + + )} + + ); +} diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index e856acb47..f7b21d243 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -36,7 +36,6 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { // iOS can render some dingbat glyphs as emoji; force text presentation (U+FE0E). const CLAUDE_GLYPH = '\u2733\uFE0E'; const GEMINI_GLYPH = '\u2726\uFE0E'; - const hasClaude = !!profile.compatibility?.claude; const hasCodex = !!profile.compatibility?.codex; const hasGemini = experimentsEnabled && !!profile.compatibility?.gemini; diff --git a/sources/sync/modelOptions.ts b/sources/sync/modelOptions.ts new file mode 100644 index 000000000..0278fd621 --- /dev/null +++ b/sources/sync/modelOptions.ts @@ -0,0 +1,33 @@ +import type { ModelMode } from './permissionTypes'; +import { t } from '@/text'; + +export type AgentType = 'claude' | 'codex' | 'gemini'; + +export type ModelOption = Readonly<{ + value: ModelMode; + label: string; + description: string; +}>; + +export function getModelOptionsForAgentType(agentType: AgentType): readonly ModelOption[] { + if (agentType === 'gemini') { + return [ + { + value: 'gemini-2.5-pro', + label: t('agentInput.geminiModel.gemini25Pro.label'), + description: t('agentInput.geminiModel.gemini25Pro.description'), + }, + { + value: 'gemini-2.5-flash', + label: t('agentInput.geminiModel.gemini25Flash.label'), + description: t('agentInput.geminiModel.gemini25Flash.description'), + }, + { + value: 'gemini-2.5-flash-lite', + label: t('agentInput.geminiModel.gemini25FlashLite.label'), + description: t('agentInput.geminiModel.gemini25FlashLite.description'), + }, + ]; + } + return []; +} diff --git a/sources/utils/recentMachines.ts b/sources/utils/recentMachines.ts new file mode 100644 index 000000000..9c098d641 --- /dev/null +++ b/sources/utils/recentMachines.ts @@ -0,0 +1,31 @@ +import type { Machine } from '@/sync/storageTypes'; +import type { Session } from '@/sync/storageTypes'; + +export function getRecentMachinesFromSessions(params: { + machines: Machine[]; + sessions: Array | null | undefined; +}): Machine[] { + if (!params.sessions || params.machines.length === 0) return []; + + const byId = new Map(params.machines.map((m) => [m.id, m] as const)); + const seen = new Set(); + const machinesWithTimestamp: Array<{ machine: Machine; timestamp: number }> = []; + + params.sessions.forEach((item) => { + if (typeof item === 'string') return; + const machineId = item.metadata?.machineId; + if (!machineId || seen.has(machineId)) return; + const machine = byId.get(machineId); + if (!machine) return; + seen.add(machineId); + machinesWithTimestamp.push({ + machine, + timestamp: item.updatedAt || item.createdAt, + }); + }); + + return machinesWithTimestamp + .sort((a, b) => b.timestamp - a.timestamp) + .map((item) => item.machine); +} + diff --git a/sources/utils/recentPaths.ts b/sources/utils/recentPaths.ts new file mode 100644 index 000000000..09eaa93d8 --- /dev/null +++ b/sources/utils/recentPaths.ts @@ -0,0 +1,45 @@ +import type { Session } from '@/sync/storageTypes'; + +export function getRecentPathsForMachine(params: { + machineId: string; + recentMachinePaths: Array<{ machineId: string; path: string }>; + sessions: Array | null | undefined; +}): string[] { + const paths: string[] = []; + const pathSet = new Set(); + + // First, add paths from recentMachinePaths (most recent first by storage order) + for (const entry of params.recentMachinePaths) { + if (entry.machineId === params.machineId && !pathSet.has(entry.path)) { + paths.push(entry.path); + pathSet.add(entry.path); + } + } + + // Then add paths from sessions if we need more + if (params.sessions) { + const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; + + params.sessions.forEach((item) => { + if (typeof item === 'string') return; + const session = item; + if (session.metadata?.machineId === params.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 + .sort((a, b) => b.timestamp - a.timestamp) + .forEach((item) => paths.push(item.path)); + } + + return paths; +} + From 243c530ed3126a15c53cf03bb69e1dcbfe45dea9 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:45:55 +0100 Subject: [PATCH 19/38] fix(profiles): harden routing, grouping, and editing --- sources/app/(app)/session/[id]/info.tsx | 92 ++- sources/app/(app)/settings/profiles.tsx | 670 ++++++++------- sources/components/ProfileEditForm.tsx | 1007 ++++++++++++----------- sources/components/SettingsView.tsx | 31 +- sources/components/profileActions.ts | 65 ++ sources/profileRouteParams.test.ts | 46 ++ sources/profileRouteParams.ts | 32 + sources/sync/profileGrouping.test.ts | 44 + sources/sync/profileGrouping.ts | 67 ++ sources/sync/profileMutations.ts | 38 + sources/sync/profileUtils.test.ts | 26 + sources/sync/profileUtils.ts | 51 +- sources/sync/storageTypes.ts | 7 +- 13 files changed, 1319 insertions(+), 857 deletions(-) create mode 100644 sources/components/profileActions.ts create mode 100644 sources/profileRouteParams.test.ts create mode 100644 sources/profileRouteParams.ts create mode 100644 sources/sync/profileGrouping.test.ts create mode 100644 sources/sync/profileGrouping.ts create mode 100644 sources/sync/profileMutations.ts create mode 100644 sources/sync/profileUtils.test.ts diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..bac2f1f5e 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -7,7 +7,7 @@ import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { Avatar } from '@/components/Avatar'; -import { useSession, useIsDataReady } from '@/sync/storage'; +import { useSession, useIsDataReady, useSetting } from '@/sync/storage'; import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId } from '@/utils/sessionUtils'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; @@ -20,6 +20,7 @@ import { CodeView } from '@/components/CodeView'; import { Session } from '@/sync/storageTypes'; import { useHappyAction } from '@/hooks/useHappyAction'; import { HappyError } from '@/utils/errors'; +import { getBuiltInProfile, getBuiltInProfileNameKey } from '@/sync/profileUtils'; // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -66,10 +67,27 @@ function SessionInfoContent({ session }: { session: Session }) { const devModeEnabled = __DEV__; const sessionName = getSessionName(session); const sessionStatus = useSessionStatus(session); - + const useProfiles = useSetting('useProfiles'); + const profiles = useSetting('profiles'); + // Check if CLI version is outdated const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION); + const profileLabel = React.useMemo(() => { + const profileId = session.metadata?.profileId; + if (profileId === null || profileId === '') return t('profiles.noProfile'); + if (typeof profileId !== 'string') return t('status.unknown'); + + const builtIn = getBuiltInProfile(profileId); + if (builtIn) { + const key = getBuiltInProfileNameKey(profileId); + return key ? t(key) : builtIn.name; + } + + const custom = profiles.find(p => p.id === profileId); + return custom?.name ?? t('status.unknown'); + }, [profiles, session.metadata?.profileId]); + const handleCopySessionId = useCallback(async () => { if (!session) return; try { @@ -198,10 +216,10 @@ function SessionInfoContent({ session }: { session: Session }) { )} - {/* Session Details */} - - + } onPress={handleCopySessionId} @@ -221,17 +239,17 @@ function SessionInfoContent({ session }: { session: Session }) { }} /> )} - } - showChevron={false} - /> - } - showChevron={false} + } + showChevron={false} + /> + } + showChevron={false} /> )} - { - const flavor = session.metadata.flavor || 'claude'; - if (flavor === 'claude') return 'Claude'; - if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; - if (flavor === 'gemini') return 'Gemini'; - return flavor; - })()} - icon={} - showChevron={false} - /> - {session.metadata.hostPid && ( - { + const flavor = session.metadata.flavor || 'claude'; + if (flavor === 'claude') return t('agentInput.agent.claude'); + if (flavor === 'gpt' || flavor === 'openai' || flavor === 'codex') return t('agentInput.agent.codex'); + if (flavor === 'gemini') return t('agentInput.agent.gemini'); + return flavor; + })()} + icon={} + showChevron={false} + /> + {useProfiles && session.metadata?.profileId !== undefined && ( + } + showChevron={false} + /> + )} + {session.metadata.hostPid && ( + } showChevron={false} /> diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fa4522023..38cdcf8c4 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,25 +1,26 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { View, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from 'expo-router'; import { useSettingMutable } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; import { t } from '@/text'; -import { Modal as HappyModal } from '@/modal/ModalManager'; -import { layout } from '@/components/layout'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useWindowDimensions } from 'react-native'; +import { Modal } from '@/modal'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; import { AIBackendProfile } from '@/sync/settings'; import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { ProfileEditForm } from '@/components/ProfileEditForm'; -import { randomUUID } from 'expo-crypto'; - -interface ProfileDisplay { - id: string; - name: string; - isBuiltIn: boolean; -} +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import { buildProfileActions } from '@/components/profileActions'; +import { Switch } from '@/components/Switch'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { useSetting } from '@/sync/storage'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -27,28 +28,27 @@ interface ProfileManagerProps { } // Profile utilities now imported from @/sync/profileUtils - -function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { - const { theme } = useUnistyles(); +const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { + const { theme, rt } = useUnistyles(); + const navigation = useNavigation(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); - const safeArea = useSafeAreaInsets(); - const screenWidth = useWindowDimensions().width; + const [isEditingDirty, setIsEditingDirty] = React.useState(false); + const isEditingDirtyRef = React.useRef(false); + const saveRef = React.useRef<(() => boolean) | null>(null); + const experimentsEnabled = useSetting('experiments'); + + React.useEffect(() => { + isEditingDirtyRef.current = isEditingDirty; + }, [isEditingDirty]); const handleAddProfile = () => { - setEditingProfile({ - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }); + setEditingProfile(createEmptyCustomProfile()); setShowAddForm(true); }; @@ -57,37 +57,116 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setShowAddForm(true); }; - const handleDeleteProfile = (profile: AIBackendProfile) => { - // Show confirmation dialog before deleting - Alert.alert( - t('profiles.delete.title'), - t('profiles.delete.message', { name: profile.name }), - [ + const handleDuplicateProfile = (profile: AIBackendProfile) => { + setEditingProfile(duplicateProfileForEdit(profile, { copySuffix: t('profiles.copySuffix') })); + setShowAddForm(true); + }; + + const closeEditor = React.useCallback(() => { + setShowAddForm(false); + setEditingProfile(null); + setIsEditingDirty(false); + }, []); + + const requestCloseEditor = React.useCallback(() => { + void (async () => { + if (!isEditingDirtyRef.current) { + closeEditor(); + return; + } + const isBuiltIn = !!editingProfile && DEFAULT_PROFILES.some((bp) => bp.id === editingProfile.id); + const saveText = isBuiltIn ? t('common.saveAs') : t('common.save'); + const message = isBuiltIn + ? `${t('common.unsavedChangesWarning')}\n\n${t('profiles.builtInSaveAsHint')}` + : t('common.unsavedChangesWarning'); + const decision = await promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), { - text: t('profiles.delete.cancel'), - style: 'cancel', + title: t('common.discardChanges'), + message, + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), }, - { - 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); - } + if (decision === 'discard') { + isEditingDirtyRef.current = false; + closeEditor(); + } else if (decision === 'save') { + // Save the form state (not the initial profile snapshot). + saveRef.current?.(); + } + })(); + }, [closeEditor, editingProfile]); + + React.useEffect(() => { + const addListener = (navigation as any)?.addListener; + if (typeof addListener !== 'function') { + return; + } + + const subscription = addListener.call(navigation, 'beforeRemove', (e: any) => { + if (!showAddForm || !isEditingDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const isBuiltIn = !!editingProfile && DEFAULT_PROFILES.some((bp) => bp.id === editingProfile.id); + const saveText = isBuiltIn ? t('common.saveAs') : t('common.save'); + const message = isBuiltIn + ? `${t('common.unsavedChangesWarning')}\n\n${t('profiles.builtInSaveAsHint')}` + : t('common.unsavedChangesWarning'); + + const decision = await promptUnsavedChangesAlert( + (title, message, buttons) => Modal.alert(title, message, buttons), + { + title: t('common.discardChanges'), + message, + discardText: t('common.discard'), + saveText, + keepEditingText: t('common.keepEditing'), }, - }, - ], - { cancelable: true } + ); + + if (decision === 'discard') { + isEditingDirtyRef.current = false; + closeEditor(); + (navigation as any).dispatch(e.data.action); + } else if (decision === 'save') { + // Save form state; only continue navigation if save succeeded. + const didSave = saveRef.current?.() ?? false; + if (didSave) { + isEditingDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } + } + })(); + }); + + return () => subscription?.remove?.(); + }, [closeEditor, editingProfile, navigation, showAddForm]); + + const handleDeleteProfile = async (profile: AIBackendProfile) => { + const confirmed = await Modal.confirm( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + { cancelText: t('profiles.delete.cancel'), confirmText: t('profiles.delete.confirm'), destructive: true } ); + if (!confirmed) return; + + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + + // Clear last used profile if it was deleted + if (lastUsedProfile === profile.id) { + setLastUsedProfile(null); + } + + // Notify parent if this was the selected profile + if (selectedProfileId === profile.id && onProfileSelect) { + onProfileSelect(null); + } }; const handleSelectProfile = (profileId: string | null) => { @@ -110,28 +189,63 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setLastUsedProfile(profileId); }; - const handleSaveProfile = (profile: AIBackendProfile) => { + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const toggleFavoriteProfile = (profileId: string) => { + setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); + }; + + const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { + const parts: string[] = []; + if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); + if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); + if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); + return parts.length > 0 ? parts.join(' • ') : ''; + }, [experimentsEnabled]); + + const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { + const backend = getProfileBackendSubtitle(profile); + if (profile.isBuiltIn) { + const builtInLabel = t('profiles.builtIn'); + return backend ? `${builtInLabel} · ${backend}` : builtInLabel; + } + const customLabel = t('profiles.custom'); + return backend ? `${customLabel} · ${backend}` : customLabel; + }, [getProfileBackendSubtitle]); + + function handleSaveProfile(profile: AIBackendProfile): boolean { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { - return; + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return false; } // Check if this is a built-in profile being edited const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === profile.id); + const builtInNames = DEFAULT_PROFILES + .map((bp) => getBuiltInProfile(bp.id)) + .filter((p): p is AIBackendProfile => !!p) + .map((p) => p.name.trim()); // For built-in profiles, create a new custom profile instead of modifying the built-in if (isBuiltIn) { - const newProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), // Generate new UUID for custom profile - }; + const newProfile = convertBuiltInProfileToCustom(profile); + const hasBuiltInNameConflict = builtInNames.includes(newProfile.name.trim()); // Check for duplicate names (excluding the new profile) const isDuplicate = profiles.some(p => p.name.trim() === newProfile.name.trim() ); - if (isDuplicate) { - return; + if (isDuplicate || hasBuiltInNameConflict) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return false; } setProfiles([...profiles, newProfile]); @@ -141,8 +255,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr const isDuplicate = profiles.some(p => p.id !== profile.id && p.name.trim() === profile.name.trim() ); - if (isDuplicate) { - return; + const hasBuiltInNameConflict = builtInNames.includes(profile.name.trim()); + if (isDuplicate || hasBuiltInNameConflict) { + Modal.alert(t('common.error'), t('profiles.duplicateName')); + return false; } const existingIndex = profiles.findIndex(p => p.id === profile.id); @@ -151,7 +267,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr if (existingIndex >= 0) { // Update existing profile updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profile; + updatedProfiles[existingIndex] = { + ...profile, + updatedAt: Date.now(), + }; } else { // Add new profile updatedProfiles = [...profiles, profile]; @@ -160,257 +279,209 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setProfiles(updatedProfiles); } - setShowAddForm(false); - setEditingProfile(null); - }; + closeEditor(); + return true; + } + + if (!useProfiles) { + return ( + + + } + rightElement={ + + } + showChevron={false} + /> + + + ); + } return ( - - 700 ? 16 : 8, - paddingBottom: safeArea.bottom + 100, - }} - > - - - {t('profiles.title')} - - - {/* None option - no profile */} - handleSelectProfile(null)} - > - - - - - - {t('profiles.noProfile')} - - - {t('profiles.noProfileDescription')} - - - {selectedProfileId === null && ( - - )} - + + + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); + return ( + } + onPress={() => handleEditProfile(profile)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> + ); + })} + + )} - {/* Built-in profiles */} - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + onDelete: () => { void handleDeleteProfile(profile); }, + }); + return ( + } + onPress={() => handleEditProfile(profile)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> + ); + })} + + )} + + {nonFavoriteBuiltInProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions = buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onEdit: () => handleEditProfile(profile), + onDuplicate: () => handleDuplicateProfile(profile), + }); return ( - handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || 'Default model'} - {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - - + title={profile.name} + subtitle={getProfileSubtitle(profile)} + leftElement={} + onPress={() => handleEditProfile(profile)} + showChevron={false} + selected={isSelected} + rightElement={( + + + + + + + )} + /> ); })} + - {/* Custom profiles */} - {profiles.map((profile) => ( - handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || t('profiles.defaultModel')} - {profile.tmuxConfig?.sessionName && ` • tmux: ${profile.tmuxConfig.sessionName}`} - {profile.tmuxConfig?.tmpDir && ` • dir: ${profile.tmuxConfig.tmpDir}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} - > - - - - - ))} - - {/* Add profile button */} - + } onPress={handleAddProfile} - > - - - {t('profiles.addProfile')} - - - - + showChevron={false} + /> + + {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( - - + + { }}> { - setShowAddForm(false); - setEditingProfile(null); - }} + onCancel={requestCloseEditor} + onDirtyChange={setIsEditingDirty} + saveRef={saveRef} /> - - + + )} ); -} +}); // ProfileEditForm now imported from @/components/ProfileEditForm @@ -428,9 +499,12 @@ const profileManagerStyles = StyleSheet.create((theme) => ({ }, modalContent: { width: '100%', - maxWidth: Math.min(layout.maxWidth, 600), + maxWidth: 600, maxHeight: '90%', + borderRadius: 16, + overflow: 'hidden', + backgroundColor: theme.colors.groupped.background, }, })); -export default ProfileManager; \ No newline at end of file +export default ProfileManager; diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 8a3864d44..89b388b3b 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,25 +1,99 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, TextInput, ViewStyle, Linking, Platform } from 'react-native'; +import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { AIBackendProfile } from '@/sync/settings'; -import { PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; +import { normalizeProfileDefaultPermissionMode, type PermissionMode } from '@/sync/permissionTypes'; import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; +import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; -import { useEnvironmentVariables, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; +import { useSetting, useAllMachines, useMachine, useSettingMutable } from '@/sync/storage'; +import { Modal } from '@/modal'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import type { Machine } from '@/sync/storageTypes'; +import { isMachineOnline } from '@/utils/machineUtils'; export interface ProfileEditFormProps { profile: AIBackendProfile; machineId: string | null; - onSave: (profile: AIBackendProfile) => void; + /** + * Return true when the profile was successfully saved. + * Return false when saving failed (e.g. validation error). + */ + onSave: (profile: AIBackendProfile) => boolean; onCancel: () => void; + onDirtyChange?: (isDirty: boolean) => void; containerStyle?: ViewStyle; + saveRef?: React.MutableRefObject<(() => boolean) | null>; +} + +interface MachinePreviewModalProps { + machines: Machine[]; + favoriteMachineIds: string[]; + selectedMachineId: string | null; + onSelect: (machineId: string) => void; + onToggleFavorite: (machineId: string) => void; + onClose: () => void; +} + +function MachinePreviewModal(props: MachinePreviewModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { height: windowHeight } = useWindowDimensions(); + + const selectedMachine = React.useMemo(() => { + if (!props.selectedMachineId) return null; + return props.machines.find((m) => m.id === props.selectedMachineId) ?? null; + }, [props.machines, props.selectedMachineId]); + + const favoriteMachines = React.useMemo(() => { + const byId = new Map(props.machines.map((m) => [m.id, m] as const)); + return props.favoriteMachineIds.map((id) => byId.get(id)).filter(Boolean) as Machine[]; + }, [props.favoriteMachineIds, props.machines]); + + const maxHeight = Math.min(720, Math.max(420, Math.floor(windowHeight * 0.85))); + + return ( + + + + {t('profiles.previewMachine.title')} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + 0} + showSearch + searchPlacement={favoriteMachines.length > 0 ? 'favorites' : 'all'} + onSelect={(machine) => { + props.onSelect(machine.id); + props.onClose(); + }} + onToggleFavorite={(machine) => props.onToggleFavorite(machine.id)} + /> + + + ); } export function ProfileEditForm({ @@ -27,554 +101,483 @@ export function ProfileEditForm({ machineId, onSave, onCancel, - containerStyle + onDirtyChange, + containerStyle, + saveRef, }: ProfileEditFormProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const styles = stylesheet; + const experimentsEnabled = useSetting('experiments'); + const machines = useAllMachines(); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const routeMachine = machineId; + const [previewMachineId, setPreviewMachineId] = React.useState(routeMachine); + + React.useEffect(() => { + setPreviewMachineId(routeMachine); + }, [routeMachine]); + + const resolvedMachineId = routeMachine ?? previewMachineId; + const resolvedMachine = useMachine(resolvedMachineId ?? ''); + + const toggleFavoriteMachineId = React.useCallback((machineIdToToggle: string) => { + if (favoriteMachines.includes(machineIdToToggle)) { + setFavoriteMachines(favoriteMachines.filter((id) => id !== machineIdToToggle)); + } else { + setFavoriteMachines([machineIdToToggle, ...favoriteMachines]); + } + }, [favoriteMachines, setFavoriteMachines]); + + const MachinePreviewModalWrapper = React.useCallback(({ onClose }: { onClose: () => void }) => { + return ( + + ); + }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); + + const showMachinePreviewPicker = React.useCallback(() => { + Modal.show({ + component: MachinePreviewModalWrapper, + props: {}, + }); + }, [MachinePreviewModalWrapper]); - // Get documentation for built-in profiles const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; return getBuiltInProfileDocumentation(profile.id); - }, [profile.isBuiltIn, profile.id]); + }, [profile.id, profile.isBuiltIn]); - // Local state for environment variables (unified for all config) - const [environmentVariables, setEnvironmentVariables] = React.useState>( - profile.environmentVariables || [] + 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 = () => { + const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>( + profile.defaultSessionType || 'simple', + ); + const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( + normalizeProfileDefaultPermissionMode(profile.defaultPermissionMode as PermissionMode), + ); + const [compatibility, setCompatibility] = React.useState>( + profile.compatibility || { claude: true, codex: true, gemini: true }, + ); + + const initialSnapshotRef = React.useRef(null); + if (initialSnapshotRef.current === null) { + initialSnapshotRef.current = JSON.stringify({ + name, + environmentVariables, + useTmux, + tmuxSession, + tmuxTmpDir, + defaultSessionType, + defaultPermissionMode, + compatibility, + }); + } + + const isDirty = React.useMemo(() => { + const currentSnapshot = JSON.stringify({ + name, + environmentVariables, + useTmux, + tmuxSession, + tmuxTmpDir, + defaultSessionType, + defaultPermissionMode, + compatibility, + }); + return currentSnapshot !== initialSnapshotRef.current; + }, [ + compatibility, + defaultPermissionMode, + defaultSessionType, + environmentVariables, + name, + tmuxSession, + tmuxTmpDir, + useTmux, + ]); + + React.useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + + const toggleCompatibility = React.useCallback((key: keyof AIBackendProfile['compatibility']) => { + setCompatibility((prev) => { + const next = { ...prev, [key]: !prev[key] }; + const enabledCount = Object.values(next).filter(Boolean).length; + if (enabledCount === 0) { + Modal.alert(t('common.error'), t('profiles.aiBackend.selectAtLeastOneError')); + return prev; + } + return next; + }); + }, []); + + const openSetupGuide = React.useCallback(async () => { + const url = profileDocs?.setupGuideUrl; + if (!url) return; + try { + if (Platform.OS === 'web') { + window.open(url, '_blank'); + } else { + await Linking.openURL(url); + } + } catch (error) { + console.error('Failed to open URL:', error); + } + }, [profileDocs?.setupGuideUrl]); + + const handleSave = React.useCallback((): boolean => { if (!name.trim()) { - // Profile name validation - prevent saving empty profiles - return; + Modal.alert(t('common.error'), t('profiles.nameRequired')); + return false; } - onSave({ + return onSave({ ...profile, name: name.trim(), - // Clear all config objects - ALL configuration now in environmentVariables - anthropicConfig: {}, - openaiConfig: {}, - azureOpenAIConfig: {}, - // Use environment variables from state (managed by EnvironmentVariablesList) environmentVariables, - // Keep non-env-var configuration - tmuxConfig: useTmux ? { - sessionName: tmuxSession.trim() || '', // Empty string = use current/most recent tmux session - tmpDir: tmuxTmpDir.trim() || undefined, - updateEnvironment: undefined, // Preserve schema compatibility, not used by daemon - } : { - sessionName: undefined, - tmpDir: undefined, - updateEnvironment: undefined, - }, - startupBashScript: useStartupScript ? (startupScript.trim() || undefined) : undefined, - defaultSessionType: defaultSessionType, - defaultPermissionMode: defaultPermissionMode, + tmuxConfig: useTmux + ? { + ...(profile.tmuxConfig ?? {}), + sessionName: tmuxSession.trim() || '', + tmpDir: tmuxTmpDir.trim() || undefined, + } + : undefined, + defaultSessionType, + defaultPermissionMode, + compatibility, updatedAt: Date.now(), }); - }; + }, [ + compatibility, + defaultPermissionMode, + defaultSessionType, + environmentVariables, + name, + onSave, + profile, + tmuxSession, + tmuxTmpDir, + useTmux, + ]); - return ( - - - {/* Profile Name */} - - {t('profiles.profileName')} - - - - {/* Built-in Profile Documentation - Setup Instructions */} - {profile.isBuiltIn && profileDocs && ( - - - - - Setup Instructions - - - - - {profileDocs.description} - + React.useEffect(() => { + if (!saveRef) { + return; + } + saveRef.current = handleSave; + return () => { + saveRef.current = null; + }; + }, [handleSave, saveRef]); - {profileDocs.setupGuideUrl && ( - { - try { - const url = profileDocs.setupGuideUrl!; - // On web/Tauri desktop, use window.open - if (Platform.OS === 'web') { - window.open(url, '_blank'); - } else { - // On native (iOS/Android), use Linking API - await Linking.openURL(url); - } - } catch (error) { - console.error('Failed to open URL:', error); - } - }} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.button.primary.background, - borderRadius: 8, - padding: 12, - marginBottom: 16, - }} - > - - - View Official Setup Guide - - - - )} - - )} - - {/* Session Type */} - - Default Session Type - - - + + + + + + - {/* Permission Mode */} - - Default Permission Mode - - - {[ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ].map((option, index, array) => ( - - } - rightElement={defaultPermissionMode === option.value ? ( - - ) : null} - onPress={() => setDefaultPermissionMode(option.value)} - showChevron={false} - selected={defaultPermissionMode === option.value} - showDivider={index < array.length - 1} - style={defaultPermissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: 8, - } : undefined} + {profile.isBuiltIn && profileDocs?.setupGuideUrl && ( + + } + onPress={() => void openSetupGuide()} + /> + + )} + + + + + + + {[ + { + value: 'default' as PermissionMode, + label: t('agentInput.permissionMode.default'), + description: t('profiles.defaultPermissionMode.descriptions.default'), + icon: 'shield-outline' + }, + { + value: 'acceptEdits' as PermissionMode, + label: t('agentInput.permissionMode.acceptEdits'), + description: t('profiles.defaultPermissionMode.descriptions.acceptEdits'), + icon: 'checkmark-outline' + }, + { + value: 'plan' as PermissionMode, + label: t('agentInput.permissionMode.plan'), + description: t('profiles.defaultPermissionMode.descriptions.plan'), + icon: 'list-outline' + }, + { + value: 'bypassPermissions' as PermissionMode, + label: t('agentInput.permissionMode.bypassPermissions'), + description: t('profiles.defaultPermissionMode.descriptions.bypassPermissions'), + icon: 'flash-outline' + }, + ].map((option, index, array) => ( + - ))} - - - - {/* Tmux Enable/Disable */} - - setUseTmux(!useTmux)} - > - - {useTmux && ( - - )} - - - - Spawn Sessions in Tmux - - - - {useTmux ? 'Sessions spawn in new tmux windows. Configure session name and temp directory below.' : 'Sessions spawn in regular shell (no tmux integration)'} - - - {/* Tmux Session Name */} - - Tmux Session Name ({t('common.optional')}) - - - Leave empty to use first existing tmux session (or create "happy" if none exist). Specify name (e.g., "my-work") for specific session. - - + ) : null + } + onPress={() => setDefaultPermissionMode(option.value)} + showChevron={false} + selected={defaultPermissionMode === option.value} + showDivider={index < array.length - 1} /> + ))} + - {/* Tmux Temp Directory */} - - Tmux Temp Directory ({t('common.optional')}) - - - Temporary directory for tmux session files. Leave empty for system default. - - + } + rightElement={ toggleCompatibility('claude')} />} + showChevron={false} + onPress={() => toggleCompatibility('claude')} + /> + } + rightElement={ toggleCompatibility('codex')} />} + showChevron={false} + onPress={() => toggleCompatibility('codex')} + /> + {experimentsEnabled && ( + } + rightElement={ toggleCompatibility('gemini')} />} + showChevron={false} + onPress={() => toggleCompatibility('gemini')} + showDivider={false} /> + )} + - {/* Startup Bash Script */} - - - setUseStartupScript(!useStartupScript)} - > - - {useStartupScript && ( - - )} - - - - Startup Bash Script - + + } + showChevron={false} + onPress={() => setUseTmux((v) => !v)} + /> + {useTmux && ( + + + {t('profiles.tmuxSession')} ({t('common.optional')}) + - - {useStartupScript - ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.' - : 'No startup script - sessions spawn directly'} - - + + {t('profiles.tmuxTempDir')} ({t('common.optional')}) - {useStartupScript && startupScript.trim() && ( - { - if (Platform.OS === 'web') { - navigator.clipboard.writeText(startupScript); - } - }} - > - - - )} - + + )} + - {/* Environment Variables Section - Unified configuration */} - + } + onPress={showMachinePreviewPicker} /> + + )} - {/* Action buttons */} - + + + + + ({ backgroundColor: theme.colors.surface, - borderRadius: 8, - padding: 12, + borderRadius: 10, + paddingVertical: 12, alignItems: 'center', - }} - onPress={onCancel} + opacity: pressed ? 0.85 : 1, + })} > - + {t('common.cancel')} - {profile.isBuiltIn ? ( - // For built-in profiles, show "Save As" button (creates custom copy) - - - {t('common.saveAs')} - - - ) : ( - // For custom profiles, show regular "Save" button - - - {t('common.save')} - - - )} + + + ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + + {profile.isBuiltIn ? t('common.saveAs') : t('common.save')} + + - + + ); } -const profileEditFormStyles = StyleSheet.create((theme, rt) => ({ - scrollView: { - flex: 1, +const stylesheet = StyleSheet.create((theme) => ({ + machinePreviewModalContainer: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + machinePreviewModalHeader: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + machinePreviewModalTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, }, - scrollContent: { - padding: 20, + selectorContainer: { + paddingHorizontal: 12, + paddingBottom: 4, }, - formContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, // Matches new session panel main container - padding: 20, - width: '100%', + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + multilineInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 14, + lineHeight: 20, + color: theme.colors.input.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + minHeight: 120, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), }, })); diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 249345e97..540603230 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -37,6 +37,7 @@ export const SettingsView = React.memo(function SettingsView() { const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled'); const isPro = __DEV__ || useEntitlement('pro'); const experiments = useSetting('experiments'); + const useProfiles = useSetting('useProfiles'); const isCustomServer = isUsingCustomServer(); const allMachines = useAllMachines(); const profile = useProfile(); @@ -110,7 +111,7 @@ export const SettingsView = React.memo(function SettingsView() { // Anthropic connection const [connectingAnthropic, connectAnthropic] = useHappyAction(async () => { - router.push('/settings/connect/claude'); + router.push('/(app)/settings/connect/claude'); }); // Anthropic disconnection @@ -302,38 +303,40 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.account')} subtitle={t('settings.accountSubtitle')} icon={} - onPress={() => router.push('/settings/account')} + onPress={() => router.push('/(app)/settings/account')} /> } - onPress={() => router.push('/settings/appearance')} + onPress={() => router.push('/(app)/settings/appearance')} /> } - onPress={() => router.push('/settings/voice')} + onPress={() => router.push('/(app)/settings/voice')} /> } - onPress={() => router.push('/settings/features')} - /> - } - onPress={() => router.push('/settings/profiles')} + onPress={() => router.push('/(app)/settings/features')} /> + {useProfiles && ( + } + onPress={() => router.push('/(app)/settings/profiles')} + /> + )} {experiments && ( } - onPress={() => router.push('/settings/usage')} + onPress={() => router.push('/(app)/settings/usage')} /> )} @@ -344,7 +347,7 @@ export const SettingsView = React.memo(function SettingsView() { } - onPress={() => router.push('/dev')} + onPress={() => router.push('/(app)/dev')} /> )} @@ -357,7 +360,7 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => { trackWhatsNewClicked(); - router.push('/changelog'); + router.push('/(app)/changelog'); }} /> void; + onEdit: () => void; + onDuplicate: () => void; + onDelete?: () => void; + onViewEnvironmentVariables?: () => void; +}): ItemAction[] { + const actions: ItemAction[] = []; + + if (params.onViewEnvironmentVariables) { + actions.push({ + id: 'envVars', + title: t('profiles.actions.viewEnvironmentVariables'), + icon: 'list-outline', + onPress: params.onViewEnvironmentVariables, + }); + } + + const favoriteColor = params.isFavorite ? params.favoriteActionColor : params.nonFavoriteActionColor; + const favoriteAction: ItemAction = { + id: 'favorite', + title: params.isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: params.isFavorite ? 'star' : 'star-outline', + onPress: params.onToggleFavorite, + }; + if (favoriteColor) { + favoriteAction.color = favoriteColor; + } + actions.push({ + id: 'edit', + title: t('profiles.actions.editProfile'), + icon: 'create-outline', + onPress: params.onEdit, + }); + + actions.push({ + id: 'copy', + title: t('profiles.actions.duplicateProfile'), + icon: 'copy-outline', + onPress: params.onDuplicate, + }); + + if (!params.profile.isBuiltIn && params.onDelete) { + actions.push({ + id: 'delete', + title: t('profiles.actions.deleteProfile'), + icon: 'trash-outline', + destructive: true, + onPress: params.onDelete, + }); + } + + // Keep favorite as the far-right inline action (and last in compact rows too). + actions.push(favoriteAction); + + return actions; +} diff --git a/sources/profileRouteParams.test.ts b/sources/profileRouteParams.test.ts new file mode 100644 index 000000000..166f0d0b3 --- /dev/null +++ b/sources/profileRouteParams.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { consumeProfileIdParam } from './profileRouteParams'; + +describe('consumeProfileIdParam', () => { + it('does nothing when param is missing', () => { + expect(consumeProfileIdParam({ profileIdParam: undefined, selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); + + it('clears param and deselects when param is empty string', () => { + expect(consumeProfileIdParam({ profileIdParam: '', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: null, + shouldClearParam: true, + }); + }); + + it('clears param without changing selection when it matches current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'abc', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: true, + }); + }); + + it('clears param and selects when it differs from current selection', () => { + expect(consumeProfileIdParam({ profileIdParam: 'next', selectedProfileId: 'abc' })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('accepts array params and uses the first value', () => { + expect(consumeProfileIdParam({ profileIdParam: ['next', 'ignored'], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: 'next', + shouldClearParam: true, + }); + }); + + it('treats empty array params as missing', () => { + expect(consumeProfileIdParam({ profileIdParam: [], selectedProfileId: null })).toEqual({ + nextSelectedProfileId: undefined, + shouldClearParam: false, + }); + }); +}); diff --git a/sources/profileRouteParams.ts b/sources/profileRouteParams.ts new file mode 100644 index 000000000..99eae054a --- /dev/null +++ b/sources/profileRouteParams.ts @@ -0,0 +1,32 @@ +export function normalizeOptionalParam(value?: string | string[]) { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +export function consumeProfileIdParam(params: { + profileIdParam?: string | string[]; + selectedProfileId: string | null; +}): { + nextSelectedProfileId: string | null | undefined; + shouldClearParam: boolean; +} { + const nextProfileIdFromParams = normalizeOptionalParam(params.profileIdParam); + + if (typeof nextProfileIdFromParams !== 'string') { + return { nextSelectedProfileId: undefined, shouldClearParam: false }; + } + + if (nextProfileIdFromParams === '') { + return { nextSelectedProfileId: null, shouldClearParam: true }; + } + + if (nextProfileIdFromParams === params.selectedProfileId) { + // Nothing to do, but still clear it so it doesn't lock the selection. + return { nextSelectedProfileId: undefined, shouldClearParam: true }; + } + + return { nextSelectedProfileId: nextProfileIdFromParams, shouldClearParam: true }; +} + diff --git a/sources/sync/profileGrouping.test.ts b/sources/sync/profileGrouping.test.ts new file mode 100644 index 000000000..5a08b3ac5 --- /dev/null +++ b/sources/sync/profileGrouping.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildProfileGroups, toggleFavoriteProfileId } from './profileGrouping'; + +describe('toggleFavoriteProfileId', () => { + it('adds the profile id to the front when missing', () => { + expect(toggleFavoriteProfileId([], 'anthropic')).toEqual(['anthropic']); + }); + + it('removes the profile id when already present', () => { + expect(toggleFavoriteProfileId(['anthropic', 'openai'], 'anthropic')).toEqual(['openai']); + }); + + it('supports favoriting the default environment (empty profile id)', () => { + expect(toggleFavoriteProfileId(['anthropic'], '')).toEqual(['', 'anthropic']); + expect(toggleFavoriteProfileId(['', 'anthropic'], '')).toEqual(['anthropic']); + }); +}); + +describe('buildProfileGroups', () => { + it('filters favoriteIds to resolvable profiles (preserves default environment favorite)', () => { + const customProfiles = [ + { + id: 'custom-profile', + name: 'Custom Profile', + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: 0, + updatedAt: 0, + version: '1.0.0', + }, + ]; + + const groups = buildProfileGroups({ + customProfiles, + favoriteProfileIds: ['', 'anthropic', 'missing-profile', 'custom-profile'], + }); + + expect(groups.favoriteIds.has('')).toBe(true); + expect(groups.favoriteIds.has('anthropic')).toBe(true); + expect(groups.favoriteIds.has('custom-profile')).toBe(true); + expect(groups.favoriteIds.has('missing-profile')).toBe(false); + }); +}); diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts new file mode 100644 index 000000000..d493bc7d9 --- /dev/null +++ b/sources/sync/profileGrouping.ts @@ -0,0 +1,67 @@ +import { AIBackendProfile } from '@/sync/settings'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; + +export interface ProfileGroups { + favoriteProfiles: AIBackendProfile[]; + customProfiles: AIBackendProfile[]; + builtInProfiles: AIBackendProfile[]; + favoriteIds: Set; + builtInIds: Set; +} + +function isProfile(profile: AIBackendProfile | null | undefined): profile is AIBackendProfile { + return Boolean(profile); +} + +export function toggleFavoriteProfileId(favoriteProfileIds: string[], profileId: string): string[] { + const normalized: string[] = []; + const seen = new Set(); + for (const id of favoriteProfileIds) { + if (seen.has(id)) continue; + seen.add(id); + normalized.push(id); + } + + if (seen.has(profileId)) { + return normalized.filter((id) => id !== profileId); + } + + return [profileId, ...normalized]; +} + +export function buildProfileGroups({ + customProfiles, + favoriteProfileIds, +}: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; +}): ProfileGroups { + const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id)); + + const customById = new Map(customProfiles.map((profile) => [profile.id, profile] as const)); + + const favoriteProfiles = favoriteProfileIds + .map((id) => customById.get(id) ?? getBuiltInProfile(id)) + .filter(isProfile); + + const favoriteIds = new Set(favoriteProfiles.map((profile) => profile.id)); + // Preserve "default environment" favorite marker (not a real profile object). + if (favoriteProfileIds.includes('')) { + favoriteIds.add(''); + } + + const nonFavoriteCustomProfiles = customProfiles.filter((profile) => !favoriteIds.has(profile.id)); + + const nonFavoriteBuiltInProfiles = DEFAULT_PROFILES + .map((profile) => getBuiltInProfile(profile.id)) + .filter(isProfile) + .filter((profile) => !favoriteIds.has(profile.id)); + + return { + favoriteProfiles, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds, + builtInIds, + }; +} diff --git a/sources/sync/profileMutations.ts b/sources/sync/profileMutations.ts new file mode 100644 index 000000000..340093911 --- /dev/null +++ b/sources/sync/profileMutations.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'expo-crypto'; +import { AIBackendProfile } from '@/sync/settings'; + +export function createEmptyCustomProfile(): AIBackendProfile { + return { + id: randomUUID(), + name: '', + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; +} + +export function duplicateProfileForEdit(profile: AIBackendProfile, opts?: { copySuffix?: string }): AIBackendProfile { + const suffix = opts?.copySuffix ?? '(Copy)'; + const separator = profile.name.trim().length > 0 ? ' ' : ''; + return { + ...profile, + id: randomUUID(), + name: `${profile.name}${separator}${suffix}`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function convertBuiltInProfileToCustom(profile: AIBackendProfile): AIBackendProfile { + return { + ...profile, + id: randomUUID(), + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} diff --git a/sources/sync/profileUtils.test.ts b/sources/sync/profileUtils.test.ts new file mode 100644 index 000000000..f6f1553c8 --- /dev/null +++ b/sources/sync/profileUtils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getBuiltInProfileNameKey, getProfilePrimaryCli } from './profileUtils'; + +describe('getProfilePrimaryCli', () => { + it('ignores unknown compatibility keys', () => { + const profile = { + compatibility: { unknownCli: true }, + } as any; + + expect(getProfilePrimaryCli(profile)).toBe('none'); + }); +}); + +describe('getBuiltInProfileNameKey', () => { + it('returns the translation key for known built-in profile ids', () => { + expect(getBuiltInProfileNameKey('anthropic')).toBe('profiles.builtInNames.anthropic'); + expect(getBuiltInProfileNameKey('deepseek')).toBe('profiles.builtInNames.deepseek'); + expect(getBuiltInProfileNameKey('zai')).toBe('profiles.builtInNames.zai'); + expect(getBuiltInProfileNameKey('openai')).toBe('profiles.builtInNames.openai'); + expect(getBuiltInProfileNameKey('azure-openai')).toBe('profiles.builtInNames.azureOpenai'); + }); + + it('returns null for unknown ids', () => { + expect(getBuiltInProfileNameKey('unknown')).toBeNull(); + }); +}); diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index d90a98a93..ca04c41bb 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -1,5 +1,47 @@ import { AIBackendProfile } from './settings'; +export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none'; + +export type BuiltInProfileId = 'anthropic' | 'deepseek' | 'zai' | 'openai' | 'azure-openai'; + +export type BuiltInProfileNameKey = + | 'profiles.builtInNames.anthropic' + | 'profiles.builtInNames.deepseek' + | 'profiles.builtInNames.zai' + | 'profiles.builtInNames.openai' + | 'profiles.builtInNames.azureOpenai'; + +const ALLOWED_PROFILE_CLIS = new Set(['claude', 'codex', 'gemini']); + +export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { + if (!profile) return 'none'; + const supported = Object.entries(profile.compatibility ?? {}) + .filter(([, isSupported]) => isSupported) + .map(([cli]) => cli) + .filter((cli): cli is 'claude' | 'codex' | 'gemini' => ALLOWED_PROFILE_CLIS.has(cli)); + + if (supported.length === 0) return 'none'; + if (supported.length === 1) return supported[0]; + return 'multi'; +} + +export function getBuiltInProfileNameKey(id: string): BuiltInProfileNameKey | null { + switch (id as BuiltInProfileId) { + case 'anthropic': + return 'profiles.builtInNames.anthropic'; + case 'deepseek': + return 'profiles.builtInNames.deepseek'; + case 'zai': + return 'profiles.builtInNames.zai'; + case 'openai': + return 'profiles.builtInNames.openai'; + case 'azure-openai': + return 'profiles.builtInNames.azureOpenai'; + default: + return null; + } +} + /** * Documentation and expected values for built-in profiles. * These help users understand what environment variables to set and their expected values. @@ -242,7 +284,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'anthropic', name: 'Anthropic (Default)', - anthropicConfig: {}, environmentVariables: [], defaultPermissionMode: 'default', compatibility: { claude: true, codex: false, gemini: false }, @@ -256,11 +297,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Launch daemon with: DEEPSEEK_AUTH_TOKEN=sk-... DEEPSEEK_BASE_URL=https://api.deepseek.com/anthropic // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden (getProfileEnvironmentVariables priority) + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'deepseek', name: 'DeepSeek (Reasoner)', - anthropicConfig: {}, environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${DEEPSEEK_AUTH_TOKEN}' }, // Secret - no fallback @@ -282,11 +322,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Model mappings: Z_AI_OPUS_MODEL=GLM-4.6, Z_AI_SONNET_MODEL=GLM-4.6, Z_AI_HAIKU_MODEL=GLM-4.5-Air // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'zai', name: 'Z.AI (GLM-4.6)', - anthropicConfig: {}, environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' }, // Secret - no fallback @@ -307,7 +346,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'openai', name: 'OpenAI (GPT-5)', - openaiConfig: {}, environmentVariables: [ { name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' }, @@ -326,7 +364,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'azure-openai', name: 'Azure OpenAI', - azureOpenAIConfig: {}, environmentVariables: [ { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, { name: 'AZURE_OPENAI_DEPLOYMENT_NAME', value: 'gpt-5-codex' }, diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index 82fedb5c1..a42b46cd1 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -10,6 +10,7 @@ export const MetadataSchema = z.object({ version: z.string().optional(), name: z.string().optional(), os: z.string().optional(), + profileId: z.string().nullable().optional(), // Session-scoped profile identity (non-secret) summary: z.object({ text: z.string(), updatedAt: z.number() @@ -69,8 +70,8 @@ export interface Session { id: string; }>; draft?: string | null; // Local draft message, not synced to server - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server - modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; // Local permission mode, not synced to server + modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; // Local model mode, not synced to server // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. // We store it directly on Session to ensure it's available immediately on load. // Do NOT store reducerState itself on Session - it's mutable and should only exist in SessionMessages. @@ -153,4 +154,4 @@ export interface GitStatus { aheadCount?: number; // Commits ahead of upstream behindCount?: number; // Commits behind upstream stashCount?: number; // Number of stash entries -} \ No newline at end of file +} From 5cbf1f1784f195b74eeb1247a287e0487540eaa5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 12:46:18 +0100 Subject: [PATCH 20/38] refactor(ui): unify list selectors and modal primitives --- CONTRIBUTING.md | 70 +- sources/app/(app)/settings/voice/language.tsx | 60 +- sources/components/AgentInput.tsx | 849 ++++---- sources/components/Item.tsx | 11 +- sources/components/ItemActionsMenuModal.tsx | 105 + sources/components/ItemGroup.dividers.test.ts | 67 + sources/components/ItemGroup.dividers.ts | 49 + .../ItemGroup.selectableCount.test.ts | 47 + .../components/ItemGroup.selectableCount.ts | 24 + sources/components/ItemGroup.tsx | 41 +- sources/components/ItemRowActions.tsx | 98 + sources/components/NewSessionWizard.tsx | 1917 ----------------- sources/components/PermissionModeSelector.tsx | 110 - sources/components/SearchHeader.tsx | 125 ++ sources/components/SearchableListSelector.tsx | 699 ++---- sources/components/Switch.web.tsx | 64 + sources/modal/ModalManager.ts | 18 +- sources/modal/components/BaseModal.tsx | 24 +- sources/modal/components/CustomModal.tsx | 9 +- sources/modal/components/WebAlertModal.tsx | 249 ++- sources/modal/types.ts | 19 +- sources/sync/profileSync.ts | 453 ---- sources/sync/reducer/phase0-skipping.spec.ts | 8 +- sources/theme.ts | 2 +- sources/utils/ignoreNextRowPress.test.ts | 19 + sources/utils/ignoreNextRowPress.ts | 7 + .../utils/promptUnsavedChangesAlert.test.ts | 55 + sources/utils/promptUnsavedChangesAlert.ts | 35 + 28 files changed, 1646 insertions(+), 3588 deletions(-) create mode 100644 sources/components/ItemActionsMenuModal.tsx create mode 100644 sources/components/ItemGroup.dividers.test.ts create mode 100644 sources/components/ItemGroup.dividers.ts create mode 100644 sources/components/ItemGroup.selectableCount.test.ts create mode 100644 sources/components/ItemGroup.selectableCount.ts create mode 100644 sources/components/ItemRowActions.tsx delete mode 100644 sources/components/NewSessionWizard.tsx delete mode 100644 sources/components/PermissionModeSelector.tsx create mode 100644 sources/components/SearchHeader.tsx create mode 100644 sources/components/Switch.web.tsx delete mode 100644 sources/sync/profileSync.ts create mode 100644 sources/utils/ignoreNextRowPress.test.ts create mode 100644 sources/utils/ignoreNextRowPress.ts create mode 100644 sources/utils/promptUnsavedChangesAlert.test.ts create mode 100644 sources/utils/promptUnsavedChangesAlert.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aa5635cc..a7ca4f9aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,42 +23,42 @@ This allows you to test production-like builds with real users before releasing ```bash # Development variant (default) -npm run ios:dev +yarn ios:dev # Preview variant -npm run ios:preview +yarn ios:preview # Production variant -npm run ios:production +yarn ios:production ``` ### Android Development ```bash # Development variant -npm run android:dev +yarn android:dev # Preview variant -npm run android:preview +yarn android:preview # Production variant -npm run android:production +yarn android:production ``` ### macOS Desktop (Tauri) ```bash # Development variant - run with hot reload -npm run tauri:dev +yarn tauri:dev # Build development variant -npm run tauri:build:dev +yarn tauri:build:dev # Build preview variant -npm run tauri:build:preview +yarn tauri:build:preview # Build production variant -npm run tauri:build:production +yarn tauri:build:production ``` **How Tauri Variants Work:** @@ -71,13 +71,13 @@ npm run tauri:build:production ```bash # Start dev server for development variant -npm run start:dev +yarn start:dev # Start dev server for preview variant -npm run start:preview +yarn start:preview # Start dev server for production variant -npm run start:production +yarn start:production ``` ## Visual Differences @@ -95,7 +95,7 @@ This makes it easy to distinguish which version you're testing! 1. **Build development variant:** ```bash - npm run ios:dev + yarn ios:dev ``` 2. **Make your changes** to the code @@ -104,19 +104,19 @@ This makes it easy to distinguish which version you're testing! 4. **Rebuild if needed** for native changes: ```bash - npm run ios:dev + yarn ios:dev ``` ### Testing Preview (Pre-Release) 1. **Build preview variant:** ```bash - npm run ios:preview + yarn ios:preview ``` 2. **Test OTA updates:** ```bash - npm run ota # Publishes to preview branch + yarn ota # Publishes to preview branch ``` 3. **Verify** the preview build works as expected @@ -125,17 +125,17 @@ This makes it easy to distinguish which version you're testing! 1. **Build production variant:** ```bash - npm run ios:production + yarn ios:production ``` 2. **Submit to App Store:** ```bash - npm run submit + yarn submit ``` 3. **Deploy OTA updates:** ```bash - npm run ota:production + yarn ota:production ``` ## All Variants Simultaneously @@ -144,9 +144,9 @@ You can install all three variants on the same device: ```bash # Build all three variants -npm run ios:dev -npm run ios:preview -npm run ios:production +yarn ios:dev +yarn ios:preview +yarn ios:production ``` All three apps appear on your device with different icons and names! @@ -195,12 +195,12 @@ You can connect different variants to different Happy CLI instances: ```bash # Development app → Dev CLI daemon -npm run android:dev -# Connect to CLI running: npm run dev:daemon:start +yarn android:dev +# Connect to CLI running: yarn dev:daemon:start # Production app → Stable CLI daemon -npm run android:production -# Connect to CLI running: npm run stable:daemon:start +yarn android:production +# Connect to CLI running: yarn stable:daemon:start ``` Each app maintains separate authentication and sessions! @@ -210,7 +210,7 @@ Each app maintains separate authentication and sessions! To test with a local Happy server: ```bash -npm run start:local-server +yarn start:local-server ``` This sets: @@ -227,8 +227,8 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. Check `app.config.js` - verify `bundleId` is set correctly for the variant 2. Clean build: ```bash - npm run prebuild - npm run ios:dev # or whichever variant + yarn prebuild + yarn ios:dev # or whichever variant ``` ### App not updating after changes @@ -236,12 +236,12 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. **For JS changes**: Hot reload should work automatically 2. **For native changes**: Rebuild the variant: ```bash - npm run ios:dev # Force rebuild + yarn ios:dev # Force rebuild ``` 3. **For config changes**: Clean and prebuild: ```bash - npm run prebuild - npm run ios:dev + yarn prebuild + yarn ios:dev ``` ### All three apps look the same @@ -258,7 +258,7 @@ If they're all the same name, the variant might not be set correctly. Verify: echo $APP_ENV # Or look at the build output -npm run ios:dev # Should show "Happy (dev)" as the name +yarn ios:dev # Should show "Happy (dev)" as the name ``` ### Connected device not found @@ -270,7 +270,7 @@ For iOS connected device testing: xcrun devicectl list devices # Run on specific connected device -npm run ios:connected-device +yarn ios:connected-device ``` ## Tips diff --git a/sources/app/(app)/settings/voice/language.tsx b/sources/app/(app)/settings/voice/language.tsx index 74799de38..38ad5e0e8 100644 --- a/sources/app/(app)/settings/voice/language.tsx +++ b/sources/app/(app)/settings/voice/language.tsx @@ -1,17 +1,16 @@ import React, { useState, useMemo } from 'react'; -import { View, TextInput, FlatList } from 'react-native'; +import { FlatList } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; +import { SearchHeader } from '@/components/SearchHeader'; import { useSettingMutable } from '@/sync/storage'; -import { useUnistyles } from 'react-native-unistyles'; import { LANGUAGES, getLanguageDisplayName, type Language } from '@/constants/Languages'; import { t } from '@/text'; -export default function LanguageSelectionScreen() { - const { theme } = useUnistyles(); +export default React.memo(function LanguageSelectionScreen() { const router = useRouter(); const [voiceAssistantLanguage, setVoiceAssistantLanguage] = useSettingMutable('voiceAssistantLanguage'); const [searchQuery, setSearchQuery] = useState(''); @@ -37,52 +36,11 @@ export default function LanguageSelectionScreen() { return ( - {/* Search Header */} - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')} - style={{ marginLeft: 8 }} - /> - )} - - + {/* Language List */} ); -} +}); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index e73f97b97..b621c3f89 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -1,11 +1,12 @@ import { Ionicons, Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, TouchableWithoutFeedback, Image as RNImage, Pressable } from 'react-native'; +import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Image as RNImage, Pressable } from 'react-native'; import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; -import { PermissionMode, ModelMode } from './PermissionModeSelector'; +import { normalizePermissionModeForAgentFlavor, type PermissionMode, type ModelMode } from '@/sync/permissionTypes'; +import { getModelOptionsForAgentType } from '@/sync/modelOptions'; import { hapticsLight, hapticsError } from './haptics'; import { Shaker, ShakeInstance } from './Shaker'; import { StatusDot } from './StatusDot'; @@ -35,6 +36,7 @@ interface AgentInputProps { isMicActive?: boolean; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; + onPermissionClick?: () => void; modelMode?: ModelMode; onModelModeChange?: (mode: ModelMode) => void; metadata?: Metadata | null; @@ -73,10 +75,19 @@ interface AgentInputProps { minHeight?: number; profileId?: string | null; onProfileClick?: () => void; + envVarsCount?: number; + onEnvVarsClick?: () => void; + contentPaddingHorizontal?: number; + panelStyle?: ViewStyle; } const MAX_CONTEXT_SIZE = 190000; +function truncateWithEllipsis(value: string, maxChars: number) { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}…`; +} + const stylesheet = StyleSheet.create((theme, runtime) => ({ container: { alignItems: 'center', @@ -207,6 +218,9 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ fontSize: 11, ...Typography.default(), }, + statusDot: { + marginRight: 6, + }, permissionModeContainer: { flexDirection: 'column', alignItems: 'flex-end', @@ -224,15 +238,114 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ // Button styles actionButtonsContainer: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 0, }, + actionButtonsColumn: { + flexDirection: 'column', + flex: 1, + gap: 3, + }, + actionButtonsColumnNarrow: { + flexDirection: 'column', + flex: 1, + gap: 2, + }, + actionButtonsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + pathRow: { + flexDirection: 'row', + alignItems: 'center', + }, actionButtonsLeft: { flexDirection: 'row', - gap: 8, + columnGap: 6, + rowGap: 3, flex: 1, - overflow: 'hidden', + flexWrap: 'wrap', + overflow: 'visible', + }, + actionButtonsLeftNarrow: { + columnGap: 4, + }, + actionButtonsLeftNoFlex: { + flex: 0, + }, + actionChip: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + gap: 6, + }, + actionChipPressed: { + opacity: 0.7, + }, + actionChipText: { + fontSize: 13, + color: theme.colors.button.secondary.tint, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + overlayOptionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + }, + overlayOptionRowPressed: { + backgroundColor: theme.colors.surfacePressed, + }, + overlayRadioOuter: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + overlayRadioOuterSelected: { + borderColor: theme.colors.radio.active, + }, + overlayRadioOuterUnselected: { + borderColor: theme.colors.radio.inactive, + }, + overlayRadioInner: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.radio.dot, + }, + overlayOptionLabel: { + fontSize: 14, + color: theme.colors.text, + ...Typography.default(), + }, + overlayOptionLabelSelected: { + color: theme.colors.radio.active, + }, + overlayOptionLabelUnselected: { + color: theme.colors.text, + }, + overlayOptionDescription: { + fontSize: 11, + color: theme.colors.textSecondary, + ...Typography.default(), + }, + overlayEmptyText: { + fontSize: 13, + color: theme.colors.textSecondary, + paddingHorizontal: 16, + paddingVertical: 8, + ...Typography.default(), }, actionButton: { flexDirection: 'row', @@ -257,6 +370,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ alignItems: 'center', flexShrink: 0, marginLeft: 8, + marginRight: 8, }, sendButtonActive: { backgroundColor: theme.colors.button.primary.background, @@ -301,14 +415,22 @@ export const AgentInput = React.memo(React.forwardRef 0; // Check if this is a Codex or Gemini session - // Use metadata.flavor for existing sessions, agentType prop for new sessions - const isCodex = props.metadata?.flavor === 'codex' || props.agentType === 'codex'; - const isGemini = props.metadata?.flavor === 'gemini' || props.agentType === 'gemini'; + const effectiveFlavor = props.metadata?.flavor ?? props.agentType; + const isCodex = effectiveFlavor === 'codex'; + const isGemini = effectiveFlavor === 'gemini'; + const modelOptions = React.useMemo(() => { + if (effectiveFlavor === 'claude' || effectiveFlavor === 'codex' || effectiveFlavor === 'gemini') { + return getModelOptionsForAgentType(effectiveFlavor); + } + return []; + }, [effectiveFlavor]); // Profile data const profiles = useSetting('profiles'); const currentProfile = React.useMemo(() => { - if (!props.profileId) return null; + if (props.profileId === undefined || props.profileId === null || props.profileId.trim() === '') { + return null; + } // Check custom profiles first const customProfile = profiles.find(p => p.id === props.profileId); if (customProfile) return customProfile; @@ -316,6 +438,25 @@ export const AgentInput = React.memo(React.forwardRef { + if (props.profileId === undefined) { + return null; + } + if (props.profileId === null || props.profileId.trim() === '') { + return t('profiles.noProfile'); + } + if (currentProfile) { + return currentProfile.name; + } + const shortId = props.profileId.length > 8 ? `${props.profileId.slice(0, 8)}…` : props.profileId; + return `${t('status.unknown')} (${shortId})`; + }, [props.profileId, currentProfile]); + + const profileIcon = React.useMemo(() => { + // Always show a stable "profile" icon so the chip reads as Profile selection (not "current provider"). + return 'person-circle-outline'; + }, []); + // Calculate context warning const contextWarning = props.usageData?.contextSize ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) @@ -359,7 +500,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('📝 Input state changed:', JSON.stringify(newState)); setInputState(newState); }, []); @@ -369,18 +509,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('🔍 Autocomplete Debug:', JSON.stringify({ - // value: props.value, - // inputState, - // activeWord, - // suggestionsCount: suggestions.length, - // selected, - // prefixes: props.autocompletePrefixes - // }, null, 2)); - // }, [props.value, inputState, activeWord, suggestions.length, selected]); - // Handle suggestion selection const handleSuggestionSelect = React.useCallback((index: number) => { if (!suggestions[index] || !inputRef.current) return; @@ -402,8 +530,6 @@ export const AgentInput = React.memo(React.forwardRef { + return normalizePermissionModeForAgentFlavor( + props.permissionMode ?? 'default', + isCodex ? 'codex' : isGemini ? 'gemini' : 'claude', + ); + }, [isCodex, isGemini, props.permissionMode]); + + const permissionChipLabel = React.useMemo(() => { + if (isCodex) { + return normalizedPermissionMode === 'default' + ? t('agentInput.codexPermissionMode.default') + : normalizedPermissionMode === 'read-only' + ? t('agentInput.codexPermissionMode.readOnly') + : normalizedPermissionMode === 'safe-yolo' + ? t('agentInput.codexPermissionMode.safeYolo') + : normalizedPermissionMode === 'yolo' + ? t('agentInput.codexPermissionMode.yolo') + : ''; + } + + if (isGemini) { + return normalizedPermissionMode === 'default' + ? t('agentInput.geminiPermissionMode.default') + : normalizedPermissionMode === 'read-only' + ? t('agentInput.geminiPermissionMode.readOnly') + : normalizedPermissionMode === 'safe-yolo' + ? t('agentInput.geminiPermissionMode.safeYolo') + : normalizedPermissionMode === 'yolo' + ? t('agentInput.geminiPermissionMode.yolo') + : ''; + } + + return normalizedPermissionMode === 'default' + ? t('agentInput.permissionMode.default') + : normalizedPermissionMode === 'acceptEdits' + ? t('agentInput.permissionMode.acceptEdits') + : normalizedPermissionMode === 'plan' + ? t('agentInput.permissionMode.plan') + : normalizedPermissionMode === 'bypassPermissions' + ? t('agentInput.permissionMode.bypassPermissions') + : ''; + }, [isCodex, isGemini, normalizedPermissionMode]); + // Handle settings button press const handleSettingsPress = React.useCallback(() => { hapticsLight(); setShowSettings(prev => !prev); }, []); + const showPermissionChip = Boolean(props.onPermissionModeChange || props.onPermissionClick); + // Handle settings selection const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { hapticsLight(); @@ -496,7 +667,9 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 16 : 8 } + { paddingHorizontal: props.contentPaddingHorizontal ?? (screenWidth > 700 ? 16 : 8) } ]}> - setShowSettings(false)}> - - + setShowSettings(false)} style={styles.overlayBackdrop} /> 700 ? 0 : 8 } @@ -576,44 +747,35 @@ export const AgentInput = React.memo(React.forwardRef handleSettingsSelect(mode)} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} > - + {isSelected && ( - + )} - + {config.label} @@ -622,96 +784,60 @@ export const AgentInput = React.memo(React.forwardRef {/* Divider */} - + {/* Model Section */} - - + + {t('agentInput.model.title')} - {isGemini ? ( - // Gemini model selector - (['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'] as const).map((model) => { - const modelConfig = { - 'gemini-2.5-pro': { label: 'Gemini 2.5 Pro', description: 'Most capable' }, - 'gemini-2.5-flash': { label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, - 'gemini-2.5-flash-lite': { label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, - }; - const config = modelConfig[model]; - const isSelected = props.modelMode === model; - + {modelOptions.length > 0 ? ( + modelOptions.map((option) => { + const isSelected = props.modelMode === option.value; return ( { hapticsLight(); - props.onModelModeChange?.(model); + props.onModelModeChange?.(option.value); }} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' - })} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} > - + {isSelected && ( - + )} - - {config.label} + + {option.label} - - {config.description} + + {option.description} ); }) ) : ( - + {t('agentInput.model.configureInCli')} )} @@ -722,16 +848,9 @@ export const AgentInput = React.memo(React.forwardRef - + {(props.connectionStatus || contextWarning) && ( + + {props.connectionStatus && ( <> )} {contextWarning && ( - + {props.connectionStatus ? '• ' : ''}{contextWarning.text} )} - + {props.permissionMode && ( - + {isCodex ? ( - props.permissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' + normalizedPermissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : + normalizedPermissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : + normalizedPermissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : + normalizedPermissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' ) : isGemini ? ( - props.permissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' + normalizedPermissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : + normalizedPermissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : + normalizedPermissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : + normalizedPermissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' ) : ( - props.permissionMode === 'default' ? t('agentInput.permissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' + normalizedPermissionMode === 'default' ? t('agentInput.permissionMode.default') : + normalizedPermissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : + normalizedPermissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : + normalizedPermissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' )} )} @@ -800,89 +921,8 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Box 1: Context Information (Machine + Path) - Only show if either exists */} - {(props.machineName !== undefined || props.currentPath) && ( - - {/* Machine chip */} - {props.machineName !== undefined && props.onMachineClick && ( - { - hapticsLight(); - props.onMachineClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} - - {/* Path chip */} - {props.currentPath && props.onPathClick && ( - { - hapticsLight(); - props.onPathClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.currentPath} - - - )} - - )} - {/* Box 2: Action Area (Input + Send) */} - + {/* Input field */} - - {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - - + {[ + // Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status + + + {/* Permission chip (popover in standard flow, scroll in wizard) */} + {showPermissionChip && ( + { + hapticsLight(); + if (props.onPermissionClick) { + props.onPermissionClick(); + return; + } + handleSettingsPress(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {permissionChipLabel} + + + )} - {/* Settings button */} - {props.onPermissionModeChange && ( - ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} - > - - - )} + {/* Profile selector button - FIRST */} + {props.onProfileClick && ( + { + hapticsLight(); + props.onProfileClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {profileLabel ?? t('profiles.noProfile')} + + + )} - {/* Profile selector button - FIRST */} - {props.profileId && props.onProfileClick && ( - { - hapticsLight(); - props.onProfileClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {currentProfile?.name || 'Select Profile'} - - - )} + {/* Env vars preview (standard flow) */} + {props.onEnvVarsClick && ( + { + hapticsLight(); + props.onEnvVarsClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} + + + )} - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( - { - hapticsLight(); - props.onAgentClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} - - - )} + {/* Agent selector button */} + {props.agentType && props.onAgentClick && ( + { + hapticsLight(); + props.onAgentClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} + > + + + {props.agentType === 'claude' + ? t('agentInput.agent.claude') + : props.agentType === 'codex' + ? t('agentInput.agent.codex') + : t('agentInput.agent.gemini')} + + + )} - {/* Abort button */} - {props.onAbort && ( - + {/* Machine selector button */} + {(props.machineName !== undefined) && props.onMachineClick && ( ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - })} + onPress={() => { + hapticsLight(); + props.onMachineClick?.(); + }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - onPress={handleAbortPress} - disabled={isAborting} + style={(p) => [ + styles.actionChip, + p.pressed ? styles.actionChipPressed : null, + ]} > - {isAborting ? ( - - ) : ( - - )} + + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + - - )} + )} + + {/* Abort button */} + {props.onAbort && ( + + [ + styles.actionButton, + p.pressed ? styles.actionButtonPressed : null, + ]} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + onPress={handleAbortPress} + disabled={isAborting} + > + {isAborting ? ( + + ) : ( + + )} + + + )} - {/* Git Status Badge */} - + {/* Git Status Badge */} + {/* Send/Voice button - aligned with first row */} @@ -1049,13 +1118,10 @@ export const AgentInput = React.memo(React.forwardRef ({ - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - opacity: p.pressed ? 0.7 : 1, - })} + style={(p) => [ + styles.sendButtonInner, + p.pressed ? styles.sendButtonInnerPressed : null, + ]} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} onPress={() => { hapticsLight(); @@ -1085,10 +1151,7 @@ export const AgentInput = React.memo(React.forwardRef ) : ( diff --git a/sources/components/Item.tsx b/sources/components/Item.tsx index 379a815d4..9869a768b 100644 --- a/sources/components/Item.tsx +++ b/sources/components/Item.tsx @@ -15,6 +15,7 @@ import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; import { t } from '@/text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroupSelectionContext } from '@/components/ItemGroup'; export interface ItemProps { title: string; @@ -111,7 +112,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ export const Item = React.memo((props) => { const { theme } = useUnistyles(); const styles = stylesheet; - + const selectionContext = React.useContext(ItemGroupSelectionContext); + // Platform-specific measurements const isIOS = Platform.OS === 'ios'; const isAndroid = Platform.OS === 'android'; @@ -196,10 +198,11 @@ export const Item = React.memo((props) => { // If copy is enabled and no onPress is provided, don't set a regular press handler // The copy will be handled by long press instead const handlePress = onPress; - + const isInteractive = handlePress || onLongPress || (copy && !isWeb); const showAccessory = isInteractive && showChevron && !rightElement; const chevronSize = (isIOS && !isWeb) ? 17 : 24; + const showSelectedBackground = !!selected && ((selectionContext?.selectableItemCount ?? 2) > 1); const titleColor = destructive ? styles.titleDestructive : (selected ? styles.titleSelected : styles.titleNormal); const containerPadding = subtitle ? styles.containerWithSubtitle : styles.containerWithoutSubtitle; @@ -295,7 +298,9 @@ export const Item = React.memo((props) => { disabled={disabled || loading} style={({ pressed }) => [ { - backgroundColor: pressed && isIOS && !isWeb ? theme.colors.surfacePressedOverlay : 'transparent', + backgroundColor: pressed && isIOS && !isWeb + ? theme.colors.surfacePressedOverlay + : (showSelectedBackground ? theme.colors.surfaceSelected : 'transparent'), opacity: disabled ? 0.5 : 1 }, pressableStyle diff --git a/sources/components/ItemActionsMenuModal.tsx b/sources/components/ItemActionsMenuModal.tsx new file mode 100644 index 000000000..dc4cb1b42 --- /dev/null +++ b/sources/components/ItemActionsMenuModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { t } from '@/text'; + +export type ItemAction = { + id: string; + title: string; + icon: React.ComponentProps['name']; + onPress: () => void; + destructive?: boolean; + color?: string; +}; + +export interface ItemActionsMenuModalProps { + title: string; + actions: ItemAction[]; + onClose: () => void; +} + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 420, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + scroll: { + flexGrow: 0, + }, + scrollContent: { + paddingBottom: 12, + }, +})); + +export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const closeThen = React.useCallback((fn: () => void) => { + props.onClose(); + setTimeout(() => fn(), 0); + }, [props.onClose]); + + return ( + + + + {props.title} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + {props.actions.map((action, idx) => ( + + } + onPress={() => closeThen(action.onPress)} + showChevron={false} + showDivider={idx < props.actions.length - 1} + /> + ))} + + + + ); +} diff --git a/sources/components/ItemGroup.dividers.test.ts b/sources/components/ItemGroup.dividers.test.ts new file mode 100644 index 000000000..ad8161b9f --- /dev/null +++ b/sources/components/ItemGroup.dividers.test.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { withItemGroupDividers } from './ItemGroup.dividers'; + +type FragmentProps = { + children?: React.ReactNode; +}; + +function TestItem(_props: { id: string; showDivider?: boolean }) { + return null; +} + +function collectShowDividers(node: React.ReactNode): Array { + const values: Array = []; + + const walk = (n: React.ReactNode) => { + React.Children.forEach(n, (child) => { + if (!React.isValidElement(child)) return; + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + walk(fragment.props.children); + return; + } + if (child.type === TestItem) { + const element = child as React.ReactElement<{ showDivider?: boolean }>; + values.push(element.props.showDivider); + return; + } + // Ignore other element types. + }); + }; + + walk(node); + return values; +} + +describe('withItemGroupDividers', () => { + it('treats fragment children as part of the divider sequence', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a' }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([true, true, false]); + }); + + it('preserves explicit showDivider={false} overrides', () => { + const children = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { id: 'a', showDivider: false }), + React.createElement(TestItem, { id: 'b' }), + React.createElement(TestItem, { id: 'c' }), + ); + + const processed = withItemGroupDividers(children); + expect(collectShowDividers(processed)).toEqual([false, true, false]); + }); +}); diff --git a/sources/components/ItemGroup.dividers.ts b/sources/components/ItemGroup.dividers.ts new file mode 100644 index 000000000..c14b531e0 --- /dev/null +++ b/sources/components/ItemGroup.dividers.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; + +type DividerChildProps = { + showDivider?: boolean; +}; + +type FragmentProps = { + children?: React.ReactNode; +}; + +export function withItemGroupDividers(children: React.ReactNode): React.ReactNode { + const countNonFragmentElements = (node: React.ReactNode): number => { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + return count + countNonFragmentElements(fragment.props.children); + } + return count + 1; + }, 0); + }; + + const total = countNonFragmentElements(children); + if (total === 0) return children; + + let index = 0; + const apply = (node: React.ReactNode): React.ReactNode => { + return React.Children.map(node, (child) => { + if (!React.isValidElement(child)) { + return child; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement; + return React.cloneElement(fragment, {}, apply(fragment.props.children)); + } + + const isLast = index === total - 1; + index += 1; + + const element = child as React.ReactElement; + const showDivider = !isLast && element.props.showDivider !== false; + return React.cloneElement(element, { showDivider }); + }); + }; + + return apply(children); +} diff --git a/sources/components/ItemGroup.selectableCount.test.ts b/sources/components/ItemGroup.selectableCount.test.ts new file mode 100644 index 000000000..ee7b0de51 --- /dev/null +++ b/sources/components/ItemGroup.selectableCount.test.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { countSelectableItems } from './ItemGroup.selectableCount'; + +function TestItem(_props: { title?: React.ReactNode; onPress?: () => void; onLongPress?: () => void }) { + return null; +} + +describe('countSelectableItems', () => { + it('counts items with ReactNode titles as selectable', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'X'), onPress: () => {} }), + React.createElement(TestItem, { title: 'Y', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(2); + }); + + it('does not count items with empty-string titles', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: '', onPress: () => {} }), + React.createElement(TestItem, { title: 'ok', onPress: () => {} }), + ); + + expect(countSelectableItems(node)).toBe(1); + }); + + it('recurse-counts Fragment children', () => { + const node = React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: 'a', onPress: () => {} }), + React.createElement( + React.Fragment, + null, + React.createElement(TestItem, { title: React.createElement('span', null, 'b'), onPress: () => {} }), + React.createElement(TestItem, { title: undefined, onPress: () => {} }), + ), + ); + + expect(countSelectableItems(node)).toBe(2); + }); +}); diff --git a/sources/components/ItemGroup.selectableCount.ts b/sources/components/ItemGroup.selectableCount.ts new file mode 100644 index 000000000..1265140dc --- /dev/null +++ b/sources/components/ItemGroup.selectableCount.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; + +type ItemChildProps = { + title?: unknown; + onPress?: unknown; + onLongPress?: unknown; +}; + +export function countSelectableItems(node: React.ReactNode): number { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + const fragment = child as React.ReactElement<{ children?: React.ReactNode }>; + return count + countSelectableItems(fragment.props.children); + } + const propsAny = (child as React.ReactElement).props as any; + const title = propsAny?.title; + const hasTitle = title !== null && title !== undefined && title !== ''; + const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function'; + return count + (hasTitle && isSelectable ? 1 : 0); + }, 0); +} diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index 0e046fb86..f71199e89 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -10,11 +10,12 @@ import { import { Typography } from '@/constants/Typography'; import { layout } from './layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { withItemGroupDividers } from './ItemGroup.dividers'; +import { countSelectableItems } from './ItemGroup.selectableCount'; -interface ItemChildProps { - showDivider?: boolean; - [key: string]: any; -} +export { withItemGroupDividers } from './ItemGroup.dividers'; + +export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); export interface ItemGroupProps { title?: string | React.ReactNode; @@ -38,8 +39,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ paddingHorizontal: Platform.select({ ios: 0, default: 4 }), }, header: { - paddingTop: Platform.select({ ios: 35, default: 16 }), - paddingBottom: Platform.select({ ios: 6, default: 8 }), + paddingTop: Platform.select({ ios: 26, default: 20 }), + paddingBottom: Platform.select({ ios: 8, default: 8 }), paddingHorizontal: Platform.select({ ios: 32, default: 24 }), }, headerNoTitle: { @@ -95,6 +96,14 @@ export const ItemGroup = React.memo((props) => { containerStyle } = props; + const selectableItemCount = React.useMemo(() => { + return countSelectableItems(children); + }, [children]); + + const selectionContextValue = React.useMemo(() => { + return { selectableItemCount }; + }, [selectableItemCount]); + return ( @@ -116,21 +125,9 @@ export const ItemGroup = React.memo((props) => { {/* Content Container */} - {React.Children.map(children, (child, index) => { - if (React.isValidElement(child)) { - // Don't add props to React.Fragment - if (child.type === React.Fragment) { - return child; - } - const isLast = index === React.Children.count(children) - 1; - const childProps = child.props as ItemChildProps; - return React.cloneElement(child, { - ...childProps, - showDivider: !isLast && childProps.showDivider !== false - }); - } - return child; - })} + + {withItemGroupDividers(children)} + {/* Footer */} @@ -144,4 +141,4 @@ export const ItemGroup = React.memo((props) => { ); -}); \ No newline at end of file +}); diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx new file mode 100644 index 000000000..c039618bc --- /dev/null +++ b/sources/components/ItemRowActions.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { View, Pressable, useWindowDimensions, type GestureResponderEvent } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Modal } from '@/modal'; +import { ItemActionsMenuModal, type ItemAction } from '@/components/ItemActionsMenuModal'; + +export interface ItemRowActionsProps { + title: string; + actions: ItemAction[]; + compactThreshold?: number; + compactActionIds?: string[]; + iconSize?: number; + gap?: number; + onActionPressIn?: () => void; +} + +export function ItemRowActions(props: ItemRowActionsProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const { width } = useWindowDimensions(); + const compact = width < (props.compactThreshold ?? 420); + + const compactIds = React.useMemo(() => new Set(props.compactActionIds ?? []), [props.compactActionIds]); + const inlineActions = React.useMemo(() => { + if (!compact) return props.actions; + return props.actions.filter((a) => compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + const overflowActions = React.useMemo(() => { + if (!compact) return []; + return props.actions.filter((a) => !compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + + const openMenu = React.useCallback(() => { + if (overflowActions.length === 0) return; + Modal.show({ + component: ItemActionsMenuModal, + props: { + title: props.title, + actions: overflowActions, + }, + }); + }, [overflowActions, props.title]); + + const iconSize = props.iconSize ?? 20; + const gap = props.gap ?? 16; + + return ( + + {inlineActions.map((action) => ( + props.onActionPressIn?.()} + onPress={(e: GestureResponderEvent) => { + e?.stopPropagation?.(); + action.onPress(); + }} + accessibilityRole="button" + accessibilityLabel={action.title} + > + + + ))} + + {compact && overflowActions.length > 0 && ( + props.onActionPressIn?.()} + onPress={(e: GestureResponderEvent) => { + e?.stopPropagation?.(); + openMenu(); + }} + accessibilityRole="button" + accessibilityLabel="More actions" + accessibilityHint="Opens a menu with more actions" + > + + + )} + + ); +} + +const stylesheet = StyleSheet.create(() => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, +})); diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx deleted file mode 100644 index ea556c99f..000000000 --- a/sources/components/NewSessionWizard.tsx +++ /dev/null @@ -1,1917 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { View, Text, Pressable, ScrollView, TextInput } from 'react-native'; -import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { t } from '@/text'; -import { Ionicons } from '@expo/vector-icons'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; -import { PermissionModeSelector, PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; -import { useAllMachines, useSessions, useSetting, storage } from '@/sync/storage'; -import { useRouter } from 'expo-router'; -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from '@/sync/settings'; -import { Modal } from '@/modal'; -import { sync } from '@/sync/sync'; -import { profileSyncService } from '@/sync/profileSync'; - -const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - stepIndicator: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - stepDot: { - width: 8, - height: 8, - borderRadius: 4, - marginHorizontal: 4, - }, - stepDotActive: { - backgroundColor: theme.colors.button.primary.background, - }, - stepDotInactive: { - backgroundColor: theme.colors.divider, - }, - stepContent: { - flex: 1, - paddingHorizontal: 24, - paddingTop: 24, - paddingBottom: 0, // No bottom padding since footer is separate - }, - stepTitle: { - fontSize: 20, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold'), - }, - stepDescription: { - fontSize: 16, - color: theme.colors.textSecondary, - marginBottom: 24, - ...Typography.default(), - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - backgroundColor: theme.colors.surface, // Ensure footer has solid background - }, - button: { - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 8, - minWidth: 100, - alignItems: 'center', - justifyContent: 'center', - }, - buttonPrimary: { - backgroundColor: theme.colors.button.primary.background, - }, - buttonSecondary: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }, - buttonText: { - fontSize: 16, - fontWeight: '600', - ...Typography.default('semiBold'), - }, - buttonTextPrimary: { - color: '#FFFFFF', - }, - buttonTextSecondary: { - color: theme.colors.text, - }, - textInput: { - backgroundColor: theme.colors.input.background, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, - color: theme.colors.text, - borderWidth: 1, - borderColor: theme.colors.divider, - ...Typography.default(), - }, - agentOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 12, - borderWidth: 2, - marginBottom: 12, - }, - agentOptionSelected: { - borderColor: theme.colors.button.primary.background, - backgroundColor: theme.colors.input.background, - }, - agentOptionUnselected: { - borderColor: theme.colors.divider, - backgroundColor: theme.colors.input.background, - }, - agentIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.button.primary.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: 16, - }, - agentInfo: { - flex: 1, - }, - agentName: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - agentDescription: { - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 4, - ...Typography.default(), - }, -})); - -type WizardStep = 'profile' | 'profileConfig' | 'sessionType' | 'agent' | 'options' | 'machine' | 'path' | 'prompt'; - -// Profile selection item component with management actions -interface ProfileSelectionItemProps { - profile: AIBackendProfile; - isSelected: boolean; - onSelect: () => void; - onUseAsIs: () => void; - onEdit: () => void; - onDuplicate?: () => void; - onDelete?: () => void; - showManagementActions?: boolean; -} - -function ProfileSelectionItem({ profile, isSelected, onSelect, onUseAsIs, onEdit, onDuplicate, onDelete, showManagementActions = false }: ProfileSelectionItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - - {/* Profile Header */} - - - - - - - - {profile.name} - - - {profile.description} - - {profile.isBuiltIn && ( - - Built-in profile - - )} - - {isSelected && ( - - )} - - - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - - {/* Primary Actions */} - - - - - Use As-Is - - - - - - - Edit - - - - - {/* Management Actions - Only show for custom profiles */} - {showManagementActions && !profile.isBuiltIn && ( - - - - - Duplicate - - - - - - - Delete - - - - )} - - )} - - ); -} - -// Manual configuration item component -interface ManualConfigurationItemProps { - isSelected: boolean; - onSelect: () => void; - onUseCliVars: () => void; - onConfigureManually: () => void; -} - -function ManualConfigurationItem({ isSelected, onSelect, onUseCliVars, onConfigureManually }: ManualConfigurationItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - - {/* Profile Header */} - - - - - - - - Manual Configuration - - - Use CLI environment variables or configure manually - - - {isSelected && ( - - )} - - - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - - - - - Use CLI Vars - - - - - - - Configure - - - - )} - - ); -} - -interface NewSessionWizardProps { - onComplete: (config: { - sessionType: 'simple' | 'worktree'; - profileId: string | null; - agentType: 'claude' | 'codex'; - permissionMode: PermissionMode; - modelMode: ModelMode; - machineId: string; - path: string; - prompt: string; - environmentVariables?: Record; - }) => void; - onCancel: () => void; - initialPrompt?: string; -} - -export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: NewSessionWizardProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - const router = useRouter(); - const machines = useAllMachines(); - const sessions = useSessions(); - const experimentsEnabled = useSetting('experiments'); - const recentMachinePaths = useSetting('recentMachinePaths'); - const lastUsedAgent = useSetting('lastUsedAgent'); - const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); - const profiles = useSetting('profiles'); - const lastUsedProfile = useSetting('lastUsedProfile'); - - // Wizard state - const [currentStep, setCurrentStep] = useState('profile'); - const [sessionType, setSessionType] = useState<'simple' | 'worktree'>('simple'); - const [agentType, setAgentType] = useState<'claude' | 'codex'>(() => { - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - return 'claude'; - }); - const [permissionMode, setPermissionMode] = useState('default'); - const [modelMode, setModelMode] = useState('default'); - const [selectedProfileId, setSelectedProfileId] = useState(() => { - return lastUsedProfile; - }); - - // Built-in profiles - const builtInProfiles: AIBackendProfile[] = useMemo(() => [ - { - id: 'anthropic', - name: 'Anthropic (Default)', - description: 'Default Claude configuration', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'deepseek', - name: 'DeepSeek (Reasoner)', - description: 'DeepSeek reasoning model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.deepseek.com/anthropic', - model: 'deepseek-reasoner', - }, - environmentVariables: [ - { name: 'API_TIMEOUT_MS', value: '600000' }, - { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'deepseek-chat' }, - { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' }, - ], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'openai', - name: 'OpenAI (GPT-4/Codex)', - description: 'OpenAI GPT-4 and Codex models', - openaiConfig: { - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai-codex', - name: 'Azure OpenAI (Codex)', - description: 'Microsoft Azure OpenAI for Codex agents', - azureOpenAIConfig: { - endpoint: 'https://your-resource.openai.azure.com/', - apiVersion: '2024-02-15-preview', - deploymentName: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai', - name: 'Azure OpenAI', - description: 'Microsoft Azure OpenAI configuration', - azureOpenAIConfig: { - apiVersion: '2024-02-15-preview', - }, - environmentVariables: [ - { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, - ], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'zai', - name: 'Z.ai (GLM-4.6)', - description: 'Z.ai GLM-4.6 model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.z.ai/api/anthropic', - model: 'glm-4.6', - }, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Microsoft Azure AI services', - openaiConfig: { - baseUrl: 'https://api.openai.azure.com', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - ], []); - - // Combined profiles - const allProfiles = useMemo(() => { - return [...builtInProfiles, ...profiles]; - }, [profiles, builtInProfiles]); - - const [selectedMachineId, setSelectedMachineId] = useState(() => { - if (machines.length > 0) { - // Check if we have a recently used machine that's currently available - if (recentMachinePaths.length > 0) { - for (const recent of recentMachinePaths) { - if (machines.find(m => m.id === recent.machineId)) { - return recent.machineId; - } - } - } - return machines[0].id; - } - return ''; - }); - const [selectedPath, setSelectedPath] = useState(() => { - if (machines.length > 0 && selectedMachineId) { - const machine = machines.find(m => m.id === selectedMachineId); - return machine?.metadata?.homeDir || '/home'; - } - return '/home'; - }); - const [prompt, setPrompt] = useState(initialPrompt); - const [customPath, setCustomPath] = useState(''); - const [showCustomPathInput, setShowCustomPathInput] = useState(false); - - // Profile configuration state - const [profileApiKeys, setProfileApiKeys] = useState>>({}); - const [profileConfigs, setProfileConfigs] = useState>>({}); - - // Dynamic steps based on whether profile needs configuration - const steps: WizardStep[] = React.useMemo(() => { - const baseSteps: WizardStep[] = experimentsEnabled - ? ['profile', 'sessionType', 'agent', 'options', 'machine', 'path', 'prompt'] - : ['profile', 'agent', 'options', 'machine', 'path', 'prompt']; - - // Insert profileConfig step after profile if needed - if (profileNeedsConfiguration(selectedProfileId)) { - const profileIndex = baseSteps.indexOf('profile'); - const beforeProfile = baseSteps.slice(0, profileIndex + 1) as WizardStep[]; - const afterProfile = baseSteps.slice(profileIndex + 1) as WizardStep[]; - return [ - ...beforeProfile, - 'profileConfig', - ...afterProfile - ] as WizardStep[]; - } - - return baseSteps; - }, [experimentsEnabled, selectedProfileId]); - - // Helper function to check if profile needs API keys - const profileNeedsConfiguration = (profileId: string | null): boolean => { - if (!profileId) return false; // Manual configuration doesn't need API keys - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return false; - - // Check if profile is one that requires API keys - const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; - return profilesNeedingKeys.includes(profile.id); - }; - - // Get required fields for profile configuration - const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { - if (!profileId) return []; - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return []; - - switch (profile.id) { - case 'deepseek': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'DeepSeek API Key', placeholder: 'DEEPSEEK_API_KEY', isPassword: true } - ]; - case 'openai': - return [ - { key: 'OPENAI_API_KEY', label: 'OpenAI API Key', placeholder: 'sk-...', isPassword: true } - ]; - case 'azure-openai': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'zai': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'Z.ai API Key', placeholder: 'Z_AI_API_KEY', isPassword: true } - ]; - case 'microsoft': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure API Key', placeholder: 'Enter your Azure API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'azure-openai-codex': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - default: - return []; - } - }; - - // Auto-load profile settings and sync with CLI - React.useEffect(() => { - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Auto-select agent type based on profile compatibility - if (selectedProfile.compatibility.claude && !selectedProfile.compatibility.codex) { - setAgentType('claude'); - } else if (selectedProfile.compatibility.codex && !selectedProfile.compatibility.claude) { - setAgentType('codex'); - } - - // Sync active profile to CLI - profileSyncService.setActiveProfile(selectedProfileId).catch(error => { - console.error('[Wizard] Failed to sync active profile to CLI:', error); - }); - } - } - }, [selectedProfileId, allProfiles]); - - // Sync profiles with CLI on component mount and when profiles change - React.useEffect(() => { - const syncProfiles = async () => { - try { - await profileSyncService.bidirectionalSync(allProfiles); - } catch (error) { - console.error('[Wizard] Failed to sync profiles with CLI:', error); - // Continue without sync - profiles work locally - } - }; - - // Sync on mount - syncProfiles(); - - // Set up sync listener for profile changes - const handleSyncEvent = (event: any) => { - if (event.status === 'error') { - console.warn('[Wizard] Profile sync error:', event.error); - } - }; - - profileSyncService.addEventListener(handleSyncEvent); - - return () => { - profileSyncService.removeEventListener(handleSyncEvent); - }; - }, [allProfiles]); - - // Get recent paths for the selected machine - const recentPaths = useMemo(() => { - if (!selectedMachineId) return []; - - const paths: string[] = []; - const pathSet = new Set(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } - }); - - // Then add paths from sessions if we need more - if (sessions) { - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - - sessions.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - - const session = item as any; - if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } - - return paths; - }, [sessions, selectedMachineId, recentMachinePaths]); - - const currentStepIndex = steps.indexOf(currentStep); - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; - - // Handler for "Use Profile As-Is" - quick session creation - const handleUseProfileAsIs = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // Get environment variables from profile (no user configuration) - const environmentVariables = getProfileEnvironmentVariables(profile); - - // Complete wizard immediately with profile settings - onComplete({ - sessionType, - profileId: profile.id, - agentType: agentType || (profile.compatibility.claude ? 'claude' : 'codex'), - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - }; - - // Handler for "Edit Profile" - load profile and go to configuration step - const handleEditProfile = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // If profile needs configuration, go to profileConfig step - if (profileNeedsConfiguration(profile.id)) { - setCurrentStep('profileConfig'); - } else { - // If no configuration needed, proceed to next step in the normal flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - } - }; - - // Handler for "Create New Profile" - const handleCreateProfile = () => { - Modal.prompt( - 'Create New Profile', - 'Enter a name for your new profile:', - { - defaultValue: 'My Custom Profile', - confirmText: 'Create', - cancelText: 'Cancel' - } - ).then((profileName) => { - if (profileName && profileName.trim()) { - const newProfile: AIBackendProfile = { - id: crypto.randomUUID(), - name: profileName.trim(), - description: 'Custom AI profile', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, newProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync new profile with CLI:', error); - }); - - // Auto-select the newly created profile - setSelectedProfileId(newProfile.id); - } - }); - }; - - // Handler for "Duplicate Profile" - const handleDuplicateProfile = (profile: AIBackendProfile) => { - Modal.prompt( - 'Duplicate Profile', - `Enter a name for the duplicate of "${profile.name}":`, - { - defaultValue: `${profile.name} (Copy)`, - confirmText: 'Duplicate', - cancelText: 'Cancel' - } - ).then((newName) => { - if (newName && newName.trim()) { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: crypto.randomUUID(), - name: newName.trim(), - description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, duplicatedProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync duplicated profile with CLI:', error); - }); - } - }); - }; - - // Handler for "Delete Profile" - const handleDeleteProfile = (profile: AIBackendProfile) => { - Modal.confirm( - 'Delete Profile', - `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`, - { - confirmText: 'Delete', - destructive: true - } - ).then((confirmed) => { - if (confirmed) { - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = currentProfiles.filter(p => p.id !== profile.id); - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync profile deletion with CLI:', error); - }); - - // Clear selection if deleted profile was selected - if (selectedProfileId === profile.id) { - setSelectedProfileId(null); - } - } - }); - }; - - // Handler for "Use CLI Environment Variables" - quick session creation with CLI vars - const handleUseCliEnvironmentVariables = () => { - setSelectedProfileId(null); - - // Complete wizard immediately with no profile (rely on CLI environment variables) - onComplete({ - sessionType, - profileId: null, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables: undefined, // Let CLI handle environment variables - }); - }; - - // Handler for "Manual Configuration" - go through normal wizard flow - const handleManualConfiguration = () => { - setSelectedProfileId(null); - - // Proceed to next step in normal wizard flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - }; - - const handleNext = () => { - // Special handling for profileConfig step - skip if profile doesn't need configuration - if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { - setCurrentStep(steps[currentStepIndex + 1]); - return; - } - - if (isLastStep) { - // Get environment variables from selected profile with proper precedence handling - let environmentVariables: Record | undefined; - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Start with profile environment variables (base configuration) - environmentVariables = getProfileEnvironmentVariables(selectedProfile); - - // Only add user-provided API keys if they're non-empty - // This preserves CLI environment variable precedence when wizard fields are empty - const userApiKeys = profileApiKeys[selectedProfileId]; - if (userApiKeys) { - Object.entries(userApiKeys).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - - // Only add user configurations if they're non-empty - const userConfigs = profileConfigs[selectedProfileId]; - if (userConfigs) { - Object.entries(userConfigs).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - } - } - - onComplete({ - sessionType, - profileId: selectedProfileId, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - } else { - setCurrentStep(steps[currentStepIndex + 1]); - } - }; - - const handleBack = () => { - if (isFirstStep) { - onCancel(); - } else { - setCurrentStep(steps[currentStepIndex - 1]); - } - }; - - const canProceed = useMemo(() => { - switch (currentStep) { - case 'profile': - return true; // Always valid (profile can be null for manual config) - case 'profileConfig': - if (!selectedProfileId) return false; - const requiredFields = getProfileRequiredFields(selectedProfileId); - // Profile configuration step is always shown when needed - // Users can leave fields empty to preserve CLI environment variables - return true; - case 'sessionType': - return true; // Always valid - case 'agent': - return true; // Always valid - case 'options': - return true; // Always valid - case 'machine': - return selectedMachineId.length > 0; - case 'path': - return (selectedPath.trim().length > 0) || (showCustomPathInput && customPath.trim().length > 0); - case 'prompt': - return prompt.trim().length > 0; - default: - return false; - } - }, [currentStep, selectedMachineId, selectedPath, prompt, showCustomPathInput, customPath, selectedProfileId, profileApiKeys, profileConfigs, getProfileRequiredFields]); - - const renderStepContent = () => { - switch (currentStep) { - case 'profile': - return ( - - Choose AI Profile - - Select a pre-configured AI profile or set up manually - - - - {builtInProfiles.map((profile) => ( - setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - /> - ))} - - - {profiles.length > 0 && ( - - {profiles.map((profile) => ( - setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - onDuplicate={() => handleDuplicateProfile(profile)} - onDelete={() => handleDeleteProfile(profile)} - showManagementActions={true} - /> - ))} - - )} - - {/* Create New Profile Button */} - - - - - - - - Create New Profile - - - Set up a custom AI backend configuration - - - - - - - setSelectedProfileId(null)} - onUseCliVars={() => handleUseCliEnvironmentVariables()} - onConfigureManually={() => handleManualConfiguration()} - /> - - - - - 💡 **Profile Selection Options:** - - - • **Use As-Is**: Quick session creation with current profile settings - - - • **Edit**: Configure API keys and settings before session creation - - - • **Manual**: Use CLI environment variables without profile configuration - - - - ); - - case 'profileConfig': - if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // Skip configuration if no profile selected or profile doesn't need configuration - setCurrentStep(steps[currentStepIndex + 1]); - return null; - } - - return ( - - Configure {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Profile'} - - Enter your API keys and configuration details - - - - {getProfileRequiredFields(selectedProfileId).map((field) => ( - - - {field.label} - - { - if (field.isPassword) { - // API key - setProfileApiKeys(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record || {}), - [field.key]: text - } - })); - } else { - // Configuration field - setProfileConfigs(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record || {}), - [field.key]: text - } - })); - } - }} - secureTextEntry={field.isPassword} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - /> - - ))} - - - - - 💡 Tip: Your API keys are only used for this session and are not stored permanently - - - 📝 Note: Leave fields empty to use CLI environment variables if they're already set - - - - ); - - case 'sessionType': - return ( - - Choose AI Backend & Session Type - - Select your AI provider and how you want to work with your code - - - - {[ - { - id: 'anthropic', - name: 'Anthropic Claude', - description: 'Advanced reasoning and coding assistant', - icon: 'cube-outline', - agentType: 'claude' as const - }, - { - id: 'openai', - name: 'OpenAI GPT-5', - description: 'Specialized coding assistant', - icon: 'code-outline', - agentType: 'codex' as const - }, - { - id: 'deepseek', - name: 'DeepSeek Reasoner', - description: 'Advanced reasoning model', - icon: 'analytics-outline', - agentType: 'claude' as const - }, - { - id: 'zai', - name: 'Z.ai', - description: 'AI assistant for development', - icon: 'flash-outline', - agentType: 'claude' as const - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Enterprise AI services', - icon: 'cloud-outline', - agentType: 'codex' as const - }, - ].map((backend) => ( - - } - rightElement={agentType === backend.agentType ? ( - - ) : null} - onPress={() => setAgentType(backend.agentType)} - showChevron={false} - selected={agentType === backend.agentType} - showDivider={true} - /> - ))} - - - - - ); - - case 'agent': - return ( - - Choose AI Agent - - Select which AI assistant you want to use - - - {selectedProfileId && ( - - - Profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - - - {allProfiles.find(p => p.id === selectedProfileId)?.description} - - - )} - - p.id === selectedProfileId)?.compatibility.claude && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude) { - setAgentType('claude'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude)} - > - - C - - - Claude - - Anthropic's AI assistant, great for coding and analysis - - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && ( - - Not compatible with selected profile - - )} - - {agentType === 'claude' && ( - - )} - - - p.id === selectedProfileId)?.compatibility.codex && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex) { - setAgentType('codex'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex)} - > - - X - - - Codex - - OpenAI's specialized coding assistant - - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && ( - - Not compatible with selected profile - - )} - - {agentType === 'codex' && ( - - )} - - - ); - - case 'options': - return ( - - Agent Options - - Configure how the AI agent should behave - - - {selectedProfileId && ( - - - Using profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - - - Environment variables will be applied automatically - - - )} - - {([ - { value: 'default', label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits', label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan', label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions', label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] as const).map((option, index, array) => ( - - } - rightElement={permissionMode === option.value ? ( - - ) : null} - onPress={() => setPermissionMode(option.value as PermissionMode)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - - {(agentType === 'claude' ? [ - { value: 'default', label: 'Default', description: 'Balanced performance', icon: 'cube-outline' }, - { value: 'adaptiveUsage', label: 'Adaptive Usage', description: 'Automatically choose model', icon: 'analytics-outline' }, - { value: 'sonnet', label: 'Sonnet', description: 'Fast and efficient', icon: 'speedometer-outline' }, - { value: 'opus', label: 'Opus', description: 'Most capable model', icon: 'diamond-outline' }, - ] as const : [ - { value: 'gpt-5-codex-high', label: 'GPT-5 Codex High', description: 'Best for complex coding', icon: 'diamond-outline' }, - { value: 'gpt-5-codex-medium', label: 'GPT-5 Codex Medium', description: 'Balanced coding assistance', icon: 'cube-outline' }, - { value: 'gpt-5-codex-low', label: 'GPT-5 Codex Low', description: 'Fast coding help', icon: 'speedometer-outline' }, - ] as const).map((option, index, array) => ( - - } - rightElement={modelMode === option.value ? ( - - ) : null} - onPress={() => setModelMode(option.value as ModelMode)} - showChevron={false} - selected={modelMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - ); - - case 'machine': - return ( - - Select Machine - - Choose which machine to run your session on - - - - {machines.map((machine, index) => ( - - } - rightElement={selectedMachineId === machine.id ? ( - - ) : null} - onPress={() => { - setSelectedMachineId(machine.id); - // Update path when machine changes - const homeDir = machine.metadata?.homeDir || '/home'; - setSelectedPath(homeDir); - }} - showChevron={false} - selected={selectedMachineId === machine.id} - showDivider={index < machines.length - 1} - /> - ))} - - - ); - - case 'path': - return ( - - Working Directory - - Choose the directory to work in - - - {/* Recent Paths */} - {recentPaths.length > 0 && ( - - {recentPaths.map((path, index) => ( - - } - rightElement={selectedPath === path && !showCustomPathInput ? ( - - ) : null} - onPress={() => { - setSelectedPath(path); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === path && !showCustomPathInput} - showDivider={index < recentPaths.length - 1} - /> - ))} - - )} - - {/* Common Directories */} - - {(() => { - const machine = machines.find(m => m.id === selectedMachineId); - const homeDir = machine?.metadata?.homeDir || '/home'; - const pathOptions = [ - { value: homeDir, label: homeDir, description: 'Home directory' }, - { value: `${homeDir}/projects`, label: `${homeDir}/projects`, description: 'Projects folder' }, - { value: `${homeDir}/Documents`, label: `${homeDir}/Documents`, description: 'Documents folder' }, - { value: `${homeDir}/Desktop`, label: `${homeDir}/Desktop`, description: 'Desktop folder' }, - ]; - return pathOptions.map((option, index) => ( - - } - rightElement={selectedPath === option.value && !showCustomPathInput ? ( - - ) : null} - onPress={() => { - setSelectedPath(option.value); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === option.value && !showCustomPathInput} - showDivider={index < pathOptions.length - 1} - /> - )); - })()} - - - {/* Custom Path Option */} - - - } - rightElement={showCustomPathInput ? ( - - ) : null} - onPress={() => setShowCustomPathInput(true)} - showChevron={false} - selected={showCustomPathInput} - showDivider={false} - /> - {showCustomPathInput && ( - - - - )} - - - ); - - case 'prompt': - return ( - - Initial Message - - Write your first message to the AI agent - - - - - ); - - default: - return null; - } - }; - - return ( - - - New Session - - - - - - - {steps.map((step, index) => ( - - ))} - - - - {renderStepContent()} - - - - - - {isFirstStep ? 'Cancel' : 'Back'} - - - - - - {isLastStep ? 'Create Session' : 'Next'} - - - - - ); -} \ No newline at end of file diff --git a/sources/components/PermissionModeSelector.tsx b/sources/components/PermissionModeSelector.tsx deleted file mode 100644 index 5c9f0850e..000000000 --- a/sources/components/PermissionModeSelector.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import { Text, Pressable, Platform } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { Typography } from '@/constants/Typography'; -import { hapticsLight } from './haptics'; - -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; - -export type ModelMode = 'default' | 'adaptiveUsage' | 'sonnet' | 'opus' | 'gpt-5-codex-high' | 'gpt-5-codex-medium' | 'gpt-5-codex-low' | 'gpt-5-minimal' | 'gpt-5-low' | 'gpt-5-medium' | 'gpt-5-high' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; - -interface PermissionModeSelectorProps { - mode: PermissionMode; - onModeChange: (mode: PermissionMode) => void; - disabled?: boolean; -} - -const modeConfig = { - default: { - label: 'Default', - icon: 'shield-checkmark' as const, - description: 'Ask for permissions' - }, - acceptEdits: { - label: 'Accept Edits', - icon: 'create' as const, - description: 'Auto-approve edits' - }, - plan: { - label: 'Plan', - icon: 'list' as const, - description: 'Plan before executing' - }, - bypassPermissions: { - label: 'Yolo', - icon: 'flash' as const, - description: 'Skip all permissions' - }, - // Codex modes (not displayed in this component, but needed for type compatibility) - 'read-only': { - label: 'Read-only', - icon: 'eye' as const, - description: 'Read-only mode' - }, - 'safe-yolo': { - label: 'Safe YOLO', - icon: 'shield' as const, - description: 'Safe YOLO mode' - }, - 'yolo': { - label: 'YOLO', - icon: 'rocket' as const, - description: 'YOLO mode' - }, -}; - -const modeOrder: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - -export const PermissionModeSelector: React.FC = ({ - mode, - onModeChange, - disabled = false -}) => { - const currentConfig = modeConfig[mode]; - - const handleTap = () => { - hapticsLight(); - const currentIndex = modeOrder.indexOf(mode); - const nextIndex = (currentIndex + 1) % modeOrder.length; - onModeChange(modeOrder[nextIndex]); - }; - - return ( - - - {/* - {currentConfig.label} - */} - - ); -}; \ No newline at end of file diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx new file mode 100644 index 000000000..458c26bad --- /dev/null +++ b/sources/components/SearchHeader.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { View, TextInput, Platform, Pressable, StyleProp, ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/layout'; +import { t } from '@/text'; + +export interface SearchHeaderProps { + value: string; + onChangeText: (text: string) => void; + placeholder: string; + containerStyle?: StyleProp; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + autoCorrect?: boolean; + inputRef?: React.Ref; + onFocus?: () => void; + onBlur?: () => void; +} + +const INPUT_BORDER_RADIUS = 10; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + content: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: INPUT_BORDER_RADIUS, + paddingHorizontal: 12, + paddingVertical: 8, + }, + textInput: { + flex: 1, + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + paddingVertical: 0, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, + clearIcon: { + marginLeft: 8, + }, +})); + +export function SearchHeader({ + value, + onChangeText, + placeholder, + containerStyle, + autoCapitalize = 'none', + autoCorrect = false, + inputRef, + onFocus, + onBlur, +}: SearchHeaderProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + return ( + + + + + + {value.length > 0 && ( + onChangeText('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.clearSearch')} + > + + + )} + + + + ); +} diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index c81ba79e2..a95d6040c 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -5,10 +5,9 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { MultiTextInput } from '@/components/MultiTextInput'; -import { Modal } from '@/modal'; import { t } from '@/text'; import { StatusDot } from '@/components/StatusDot'; +import { SearchHeader } from '@/components/SearchHeader'; /** * Configuration object for customizing the SearchableListSelector component. @@ -40,12 +39,14 @@ export interface SelectorConfig { searchPlaceholder: string; recentSectionTitle: string; favoritesSectionTitle: string; + allSectionTitle?: string; noItemsMessage: string; // Optional features showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + showAll?: boolean; allowCustomInput?: boolean; // Item subtitle override (for recent items, e.g., "Recently used") @@ -59,9 +60,6 @@ export interface SelectorConfig { // Check if a favorite item can be removed (e.g., home directory can't be removed) canRemoveFavorite?: (item: T) => boolean; - - // Visual customization - compactItems?: boolean; // Use reduced padding for more compact lists (default: false) } /** @@ -75,142 +73,28 @@ export interface SearchableListSelectorProps { selectedItem: T | null; onSelect: (item: T) => void; onToggleFavorite?: (item: T) => void; - context?: any; // Additional context (e.g., homeDir for paths) + context?: any; // Additional context (e.g., homeDir for paths) // Optional overrides showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; - - // Controlled collapse states (optional - defaults to uncontrolled internal state) - collapsedSections?: { - recent?: boolean; - favorites?: boolean; - all?: boolean; - }; - onCollapsedSectionsChange?: (collapsed: { recent?: boolean; favorites?: boolean; all?: boolean }) => void; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; } const RECENT_ITEMS_DEFAULT_VISIBLE = 5; -// Spacing constants (match existing codebase patterns) -const STATUS_DOT_TEXT_GAP = 4; // Gap between StatusDot and text (used throughout app for status indicators) -const ITEM_SPACING_GAP = 4; // Gap between elements and spacing between items (compact) -const COMPACT_ITEM_PADDING = 4; // Vertical padding for compact lists -// Border radius constants (consistent rounding) -const INPUT_BORDER_RADIUS = 10; // Input field and containers -const BUTTON_BORDER_RADIUS = 8; // Buttons and actionable elements -// ITEM_BORDER_RADIUS must match ItemGroup's contentContainer borderRadius to prevent clipping -// ItemGroup uses Platform.select({ ios: 10, default: 16 }) -const ITEM_BORDER_RADIUS = Platform.select({ ios: 10, default: 16 }); // Match ItemGroup container radius +const STATUS_DOT_TEXT_GAP = 4; +const ITEM_SPACING_GAP = 16; const stylesheet = StyleSheet.create((theme) => ({ - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingBottom: 8, - }, - inputWrapper: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: INPUT_BORDER_RADIUS, - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, - inputInner: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - }, - inputField: { - flex: 1, - }, - clearButton: { - width: 20, - height: 20, - borderRadius: INPUT_BORDER_RADIUS, - backgroundColor: theme.colors.textSecondary, - justifyContent: 'center', - alignItems: 'center', - marginLeft: 8, - }, - favoriteButton: { - borderRadius: BUTTON_BORDER_RADIUS, - padding: 8, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 10, - }, - sectionHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.text, - ...Typography.default(), - }, - selectedItemStyle: { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: ITEM_BORDER_RADIUS, - }, - compactItemStyle: { - paddingVertical: COMPACT_ITEM_PADDING, - minHeight: 0, // Override Item's default minHeight (44-56px) for compact mode - }, - itemBackground: { - backgroundColor: theme.colors.input.background, - borderRadius: ITEM_BORDER_RADIUS, - marginBottom: ITEM_SPACING_GAP, - }, showMoreTitle: { textAlign: 'center', - color: theme.colors.button.primary.tint, + color: theme.colors.textLink, }, })); -/** - * Generic searchable list selector component with recent items, favorites, and filtering. - * - * Pattern extracted from Working Directory section in new session wizard. - * Supports any data type through TypeScript generics and configuration object. - * - * Features: - * - Search/filter with smart skip (doesn't filter when input matches selection) - * - Recent items with "Show More" toggle - * - Favorites with add/remove - * - Collapsible sections - * - Custom input support (optional) - * - * @example - * // For machines: - * - * config={machineConfig} - * items={machines} - * recentItems={recentMachines} - * favoriteItems={favoriteMachines} - * selectedItem={selectedMachine} - * onSelect={(machine) => setSelectedMachine(machine)} - * onToggleFavorite={(machine) => toggleFavorite(machine.id)} - * /> - * - * // For paths: - * - * config={pathConfig} - * items={allPaths} - * recentItems={recentPaths} - * favoriteItems={favoritePaths} - * selectedItem={selectedPath} - * onSelect={(path) => setSelectedPath(path)} - * onToggleFavorite={(path) => toggleFavorite(path)} - * context={{ homeDir }} - * /> - */ export function SearchableListSelector(props: SearchableListSelectorProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const styles = stylesheet; const { config, @@ -224,167 +108,51 @@ export function SearchableListSelector(props: SearchableListSelectorProps) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, - collapsedSections, - onCollapsedSectionsChange, + searchPlacement = 'header', } = props; + const showAll = config.showAll !== false; - // Use controlled state if provided, otherwise use internal state - const isControlled = collapsedSections !== undefined && onCollapsedSectionsChange !== undefined; - - // State management (matches Working Directory pattern) - const [inputText, setInputText] = React.useState(() => { - if (selectedItem) { - return config.formatForDisplay(selectedItem, context); - } - return ''; - }); + // Search query is intentionally decoupled from the selected value so pickers don't start pre-filtered. + const [inputText, setInputText] = React.useState(''); const [showAllRecent, setShowAllRecent] = React.useState(false); - // Internal uncontrolled state (used when not controlled from parent) - const [internalShowRecentSection, setInternalShowRecentSection] = React.useState(false); - const [internalShowFavoritesSection, setInternalShowFavoritesSection] = React.useState(false); - const [internalShowAllItemsSection, setInternalShowAllItemsSection] = React.useState(true); - - // Use controlled or uncontrolled state - const showRecentSection = isControlled ? !collapsedSections?.recent : internalShowRecentSection; - const showFavoritesSection = isControlled ? !collapsedSections?.favorites : internalShowFavoritesSection; - const showAllItemsSection = isControlled ? !collapsedSections?.all : internalShowAllItemsSection; - - // Toggle handlers that work for both controlled and uncontrolled - const toggleRecentSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, recent: !collapsedSections?.recent }); - } else { - setInternalShowRecentSection(!internalShowRecentSection); - } - }; - - const toggleFavoritesSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, favorites: !collapsedSections?.favorites }); - } else { - setInternalShowFavoritesSection(!internalShowFavoritesSection); - } - }; - - const toggleAllItemsSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, all: !collapsedSections?.all }); - } else { - setInternalShowAllItemsSection(!internalShowAllItemsSection); - } - }; - - // Track if user is actively typing (vs clicking from list) to control expansion behavior - const isUserTyping = React.useRef(false); + const favoriteIds = React.useMemo(() => { + return new Set(favoriteItems.map((item) => config.getItemId(item))); + }, [favoriteItems, config]); - // Update input text when selected item changes externally - React.useEffect(() => { - if (selectedItem && !isUserTyping.current) { - setInputText(config.formatForDisplay(selectedItem, context)); - } - }, [selectedItem, config, context]); - - // Filtering logic with smart skip (matches Working Directory pattern) - const filteredRecentItems = React.useMemo(() => { - if (!inputText.trim()) return recentItems; - - // Don't filter if text matches the currently selected item (user clicked from list) - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return recentItems; // Show all items, don't filter - } + const baseRecentItems = React.useMemo(() => { + return recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + }, [recentItems, favoriteIds, config]); - // User is typing - filter the list - return recentItems.filter(item => config.filterItem(item, inputText, context)); - }, [recentItems, inputText, selectedItem, config, context]); + const baseAllItems = React.useMemo(() => { + const recentIds = new Set(baseRecentItems.map((item) => config.getItemId(item))); + return items.filter((item) => !favoriteIds.has(config.getItemId(item)) && !recentIds.has(config.getItemId(item))); + }, [items, baseRecentItems, favoriteIds, config]); const filteredFavoriteItems = React.useMemo(() => { if (!inputText.trim()) return favoriteItems; + return favoriteItems.filter((item) => config.filterItem(item, inputText, context)); + }, [favoriteItems, inputText, config, context]); - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return favoriteItems; // Show all favorites, don't filter - } - - // Don't filter if text matches a favorite (user clicked from list) - if (favoriteItems.some(item => config.formatForDisplay(item, context) === inputText)) { - return favoriteItems; // Show all favorites, don't filter - } - - return favoriteItems.filter(item => config.filterItem(item, inputText, context)); - }, [favoriteItems, inputText, selectedItem, config, context]); - - // Check if current input can be added to favorites - const canAddToFavorites = React.useMemo(() => { - if (!onToggleFavorite || !inputText.trim()) return false; - - // Parse input to see if it's a valid item - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (!parsedItem) return false; + const filteredRecentItems = React.useMemo(() => { + if (!inputText.trim()) return baseRecentItems; + return baseRecentItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseRecentItems, inputText, config, context]); - // Check if already in favorites - const parsedId = config.getItemId(parsedItem); - return !favoriteItems.some(fav => config.getItemId(fav) === parsedId); - }, [inputText, favoriteItems, config, context, onToggleFavorite]); + const filteredItems = React.useMemo(() => { + if (!inputText.trim()) return baseAllItems; + return baseAllItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseAllItems, inputText, config, context]); - // Handle input text change const handleInputChange = (text: string) => { - isUserTyping.current = true; // User is actively typing setInputText(text); - // If allowCustomInput, try to parse and select if (config.allowCustomInput && text.trim()) { const parsedItem = config.parseFromDisplay(text.trim(), context); - if (parsedItem) { - onSelect(parsedItem); - } - } - }; - - // Handle item selection from list - const handleSelectItem = (item: T) => { - isUserTyping.current = false; // User clicked from list - setInputText(config.formatForDisplay(item, context)); - onSelect(item); - }; - - // Handle clear button - const handleClear = () => { - isUserTyping.current = false; - setInputText(''); - // Don't clear selection - just clear input - }; - - // Handle add to favorites - const handleAddToFavorites = () => { - if (!canAddToFavorites || !onToggleFavorite) return; - - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (parsedItem) { - onToggleFavorite(parsedItem); + if (parsedItem) onSelect(parsedItem); } }; - // Handle remove from favorites - const handleRemoveFavorite = (item: T) => { - if (!onToggleFavorite) return; - - Modal.alert( - 'Remove Favorite', - `Remove "${config.getItemTitle(item)}" from ${config.favoritesSectionTitle.toLowerCase()}?`, - [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: 'Remove', - style: 'destructive', - onPress: () => onToggleFavorite(item) - } - ] - ); - }; - - // Render status with StatusDot (DRY helper - matches Item.tsx detail style) const renderStatus = (status: { text: string; color: string; dotColor: string; isPulsing?: boolean } | null | undefined) => { if (!status) return null; return ( @@ -394,22 +162,50 @@ export function SearchableListSelector(props: SearchableListSelectorProps) isPulsing={status.isPulsing} size={6} /> - + {status.text} ); }; - // Render individual item (for recent items) - const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false) => { + const renderFavoriteToggle = (item: T, isFavorite: boolean) => { + if (!showFavorites || !onToggleFavorite) return null; + + const canRemove = config.canRemoveFavorite?.(item) ?? true; + const disabled = isFavorite && !canRemove; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const color = isFavorite ? selectedColor : theme.colors.textSecondary; + + return ( + { + e.stopPropagation(); + if (disabled) return; + onToggleFavorite(item); + }} + > + + + ); + }; + + const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false, forFavorite = false) => { const itemId = config.getItemId(item); const title = config.getItemTitle(item); const subtitle = forRecent && config.getRecentItemSubtitle @@ -417,8 +213,12 @@ export function SearchableListSelector(props: SearchableListSelectorProps) : config.getItemSubtitle?.(item); const icon = forRecent && config.getRecentItemIcon ? config.getRecentItemIcon(item) - : config.getItemIcon(item); + : forFavorite && config.getFavoriteItemIcon + ? config.getFavoriteItemIcon(item) + : config.getItemIcon(item); const status = config.getItemStatus?.(item, theme); + const isFavorite = favoriteIds.has(itemId) || forFavorite; + const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; return ( (props: SearchableListSelectorProps) subtitle={subtitle} subtitleLines={0} leftElement={icon} - rightElement={ + rightElement={( {renderStatus(status)} - {isSelected && ( + - )} + + {renderFavoriteToggle(item, isFavorite)} - } - onPress={() => handleSelectItem(item)} + )} + onPress={() => onSelect(item)} showChevron={false} selected={isSelected} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} /> ); }; - // "Show More" logic (matches Working Directory pattern) - const itemsToShow = (inputText.trim() && isUserTyping.current) || showAllRecent + const showAllRecentItems = showAllRecent || inputText.trim().length > 0; + const recentItemsToShow = showAllRecentItems ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); + const hasRecentGroupBase = showRecent && baseRecentItems.length > 0; + const hasFavoritesGroupBase = showFavorites && favoriteItems.length > 0; + const hasAllGroupBase = showAll && baseAllItems.length > 0; + + const effectiveSearchPlacement = React.useMemo(() => { + if (!showSearch) return 'header' as const; + if (searchPlacement === 'header') return 'header' as const; + + if (searchPlacement === 'favorites' && hasFavoritesGroupBase) return 'favorites' as const; + if (searchPlacement === 'recent' && hasRecentGroupBase) return 'recent' as const; + if (searchPlacement === 'all' && hasAllGroupBase) return 'all' as const; + + // Fall back to the first visible group so the search never disappears. + if (hasFavoritesGroupBase) return 'favorites' as const; + if (hasRecentGroupBase) return 'recent' as const; + if (hasAllGroupBase) return 'all' as const; + return 'header' as const; + }, [hasAllGroupBase, hasFavoritesGroupBase, hasRecentGroupBase, searchPlacement, showSearch]); + + const showNoMatches = inputText.trim().length > 0; + const shouldRenderRecentGroup = showRecent && (filteredRecentItems.length > 0 || (effectiveSearchPlacement === 'recent' && showSearch && hasRecentGroupBase)); + const shouldRenderFavoritesGroup = showFavorites && (filteredFavoriteItems.length > 0 || (effectiveSearchPlacement === 'favorites' && showSearch && hasFavoritesGroupBase)); + const shouldRenderAllGroup = showAll && (filteredItems.length > 0 || (effectiveSearchPlacement === 'all' && showSearch && hasAllGroupBase)); + + const searchNodeHeader = showSearch ? ( + + ) : null; + + const searchNodeEmbedded = showSearch ? ( + + ) : null; + + const renderEmptyRow = (title: string) => ( + + ); + return ( <> - {/* Search Input */} - {showSearch && ( - - - - - - - {inputText.trim() && ( - ([ - styles.clearButton, - { opacity: pressed ? 0.6 : 0.8 } - ])} - > - - - )} - - - {showFavorites && onToggleFavorite && ( - ([ - styles.favoriteButton, - { - backgroundColor: canAddToFavorites - ? theme.colors.button.primary.background - : theme.colors.divider, - opacity: pressed ? 0.7 : 1, - } - ])} - > - - + {effectiveSearchPlacement === 'header' && searchNodeHeader} + + {shouldRenderRecentGroup && ( + + {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} + {recentItemsToShow.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : recentItemsToShow.map((item, index, arr) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === arr.length - 1; + + const showDivider = !isLast || + (!inputText.trim() && + !showAllRecent && + filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); + + return renderItem(item, isSelected, isLast, showDivider, true, false); + })} + + {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && recentItemsToShow.length > 0 && ( + setShowAllRecent(!showAllRecent)} + showChevron={false} + showDivider={false} + titleStyle={styles.showMoreTitle} + /> )} - + )} - {/* Recent Items Section */} - {showRecent && filteredRecentItems.length > 0 && ( - <> - - {config.recentSectionTitle} - - - - {showRecentSection && ( - - {itemsToShow.map((item, index, arr) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === arr.length - 1; - - // Override divider logic for "Show More" button - const showDivider = !isLast || - (!(inputText.trim() && isUserTyping.current) && - !showAllRecent && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); - - return renderItem(item, isSelected, isLast, showDivider, true); - })} - - {/* Show More Button */} - {!(inputText.trim() && isUserTyping.current) && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && ( - setShowAllRecent(!showAllRecent)} - showChevron={false} - showDivider={false} - titleStyle={styles.showMoreTitle} - /> - )} - - )} - + {shouldRenderFavoritesGroup && ( + + {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} + {filteredFavoriteItems.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : filteredFavoriteItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredFavoriteItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, true); + })} + )} - {/* Favorites Section */} - {showFavorites && filteredFavoriteItems.length > 0 && ( - <> - - {config.favoritesSectionTitle} - - - - {showFavoritesSection && ( - - {filteredFavoriteItems.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === filteredFavoriteItems.length - 1; - - const title = config.getItemTitle(item); - const subtitle = config.getItemSubtitle?.(item); - const icon = config.getFavoriteItemIcon?.(item) || config.getItemIcon(item); - const status = config.getItemStatus?.(item, theme); - const canRemove = config.canRemoveFavorite?.(item) ?? true; - - return ( - - {renderStatus(status)} - {isSelected && ( - - )} - {onToggleFavorite && canRemove && ( - { - e.stopPropagation(); - handleRemoveFavorite(item); - }} - > - - - )} - - } - onPress={() => handleSelectItem(item)} - showChevron={false} - selected={isSelected} - showDivider={!isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} - /> - ); - })} - - )} - + {shouldRenderAllGroup && ( + + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} + {filteredItems.length === 0 + ? renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage) + : filteredItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, false); + })} + )} - {/* All Items Section - always shown when items provided */} - {items.length > 0 && ( - <> - - - {config.recentSectionTitle.replace('Recent ', 'All ')} - - - - - {showAllItemsSection && ( - - {items.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === items.length - 1; - - return renderItem(item, isSelected, isLast, !isLast, false); - })} - - )} - + {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && ( + + {effectiveSearchPlacement !== 'header' && searchNodeEmbedded} + {renderEmptyRow(showNoMatches ? t('common.noMatches') : config.noItemsMessage)} + )} ); diff --git a/sources/components/Switch.web.tsx b/sources/components/Switch.web.tsx new file mode 100644 index 000000000..150d37d5d --- /dev/null +++ b/sources/components/Switch.web.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import type { SwitchProps } from 'react-native'; +import { Pressable, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +const TRACK_WIDTH = 44; +const TRACK_HEIGHT = 24; +const THUMB_SIZE = 20; +const PADDING = 2; + +const stylesheet = StyleSheet.create(() => ({ + track: { + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + padding: PADDING, + justifyContent: 'center', + }, + thumb: { + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2, + }, +})); + +export const Switch = ({ value, disabled, onValueChange, style, ...rest }: SwitchProps) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const translateX = value ? TRACK_WIDTH - THUMB_SIZE - PADDING * 2 : 0; + + return ( + onValueChange?.(!value)} + style={({ pressed }) => [ + style as any, + { opacity: disabled ? 0.6 : pressed ? 0.85 : 1 }, + ]} + > + + + + + ); +}; diff --git a/sources/modal/ModalManager.ts b/sources/modal/ModalManager.ts index 1e0cf0aaf..d94423d7c 100644 --- a/sources/modal/ModalManager.ts +++ b/sources/modal/ModalManager.ts @@ -1,6 +1,6 @@ import { Platform, Alert } from 'react-native'; import { t } from '@/text'; -import { AlertButton, ModalConfig, CustomModalConfig, IModal } from './types'; +import { AlertButton, ModalConfig, CustomModalConfig, IModal, type CustomModalInjectedProps } from './types'; class ModalManagerClass implements IModal { private showModalFn: ((config: Omit) => string) | null = null; @@ -95,16 +95,22 @@ class ModalManagerClass implements IModal { } } - show(config: Omit): string { + show

(config: { + component: CustomModalConfig

['component']; + props?: CustomModalConfig

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

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

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

(config: { + component: ComponentType

; + props?: Omit; + }): string; hide(id: string): void; hideAll(): void; -} \ No newline at end of file +} diff --git a/sources/sync/profileSync.ts b/sources/sync/profileSync.ts deleted file mode 100644 index 694ea1410..000000000 --- a/sources/sync/profileSync.ts +++ /dev/null @@ -1,453 +0,0 @@ -/** - * Profile Synchronization Service - * - * Handles bidirectional synchronization of profiles between GUI and CLI storage. - * Ensures consistent profile data across both systems with proper conflict resolution. - */ - -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from './settings'; -import { sync } from './sync'; -import { storage } from './storage'; -import { apiSocket } from './apiSocket'; -import { Modal } from '@/modal'; - -// Profile sync status types -export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; -export type SyncDirection = 'gui-to-cli' | 'cli-to-gui' | 'bidirectional'; - -// Profile sync conflict resolution strategies -export type ConflictResolution = 'gui-wins' | 'cli-wins' | 'most-recent' | 'merge'; - -// Profile sync event data -export interface ProfileSyncEvent { - direction: SyncDirection; - status: SyncStatus; - profilesSynced?: number; - error?: string; - timestamp: number; - message?: string; - warning?: string; -} - -// Profile sync configuration -export interface ProfileSyncConfig { - autoSync: boolean; - conflictResolution: ConflictResolution; - syncOnProfileChange: boolean; - syncOnAppStart: boolean; -} - -// Default sync configuration -const DEFAULT_SYNC_CONFIG: ProfileSyncConfig = { - autoSync: true, - conflictResolution: 'most-recent', - syncOnProfileChange: true, - syncOnAppStart: true, -}; - -class ProfileSyncService { - private static instance: ProfileSyncService; - private syncStatus: SyncStatus = 'idle'; - private lastSyncTime: number = 0; - private config: ProfileSyncConfig = DEFAULT_SYNC_CONFIG; - private eventListeners: Array<(event: ProfileSyncEvent) => void> = []; - - private constructor() { - // Private constructor for singleton - } - - public static getInstance(): ProfileSyncService { - if (!ProfileSyncService.instance) { - ProfileSyncService.instance = new ProfileSyncService(); - } - return ProfileSyncService.instance; - } - - /** - * Add event listener for sync events - */ - public addEventListener(listener: (event: ProfileSyncEvent) => void): void { - this.eventListeners.push(listener); - } - - /** - * Remove event listener - */ - public removeEventListener(listener: (event: ProfileSyncEvent) => void): void { - const index = this.eventListeners.indexOf(listener); - if (index > -1) { - this.eventListeners.splice(index, 1); - } - } - - /** - * Emit sync event to all listeners - */ - private emitEvent(event: ProfileSyncEvent): void { - this.eventListeners.forEach(listener => { - try { - listener(event); - } catch (error) { - console.error('[ProfileSync] Event listener error:', error); - } - }); - } - - /** - * Update sync configuration - */ - public updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - /** - * Get current sync configuration - */ - public getConfig(): ProfileSyncConfig { - return { ...this.config }; - } - - /** - * Get current sync status - */ - public getSyncStatus(): SyncStatus { - return this.syncStatus; - } - - /** - * Get last sync time - */ - public getLastSyncTime(): number { - return this.lastSyncTime; - } - - /** - * Sync profiles from GUI to CLI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncGuiToCli(profiles: AIBackendProfile[]): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'gui-to-cli', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Profiles are stored in GUI settings and available through existing Happy sync system - // CLI daemon reads profiles from GUI settings via existing channels - // TODO: Implement machine RPC endpoints for profile management in CLI daemon - console.log(`[ProfileSync] GUI profiles stored in Happy settings. CLI access via existing infrastructure.`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'success', - profilesSynced: profiles.length, - timestamp: Date.now(), - message: 'Profiles available through Happy settings system' - }); - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Sync profiles from CLI to GUI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncCliToGui(): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'cli-to-gui', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // CLI profiles are accessed through Happy settings system, not direct file access - // Return profiles from current GUI settings - const currentProfiles = storage.getState().settings.profiles || []; - - console.log(`[ProfileSync] Retrieved ${currentProfiles.length} profiles from Happy settings`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'success', - profilesSynced: currentProfiles.length, - timestamp: Date.now(), - message: 'Profiles retrieved from Happy settings system' - }); - - return currentProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Perform bidirectional sync with conflict resolution - */ - public async bidirectionalSync(guiProfiles: AIBackendProfile[]): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'bidirectional', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Get CLI profiles - const cliProfiles = await this.syncCliToGui(); - - // Resolve conflicts based on configuration - const resolvedProfiles = await this.resolveConflicts(guiProfiles, cliProfiles); - - // Update CLI with resolved profiles - await this.syncGuiToCli(resolvedProfiles); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'success', - profilesSynced: resolvedProfiles.length, - timestamp: Date.now(), - }); - - return resolvedProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Resolve conflicts between GUI and CLI profiles - */ - private async resolveConflicts( - guiProfiles: AIBackendProfile[], - cliProfiles: AIBackendProfile[] - ): Promise { - const { conflictResolution } = this.config; - const resolvedProfiles: AIBackendProfile[] = []; - const processedIds = new Set(); - - // Process profiles that exist in both GUI and CLI - for (const guiProfile of guiProfiles) { - const cliProfile = cliProfiles.find(p => p.id === guiProfile.id); - - if (cliProfile) { - let resolvedProfile: AIBackendProfile; - - switch (conflictResolution) { - case 'gui-wins': - resolvedProfile = { ...guiProfile, updatedAt: Date.now() }; - break; - case 'cli-wins': - resolvedProfile = { ...cliProfile, updatedAt: Date.now() }; - break; - case 'most-recent': - resolvedProfile = guiProfile.updatedAt! >= cliProfile.updatedAt! - ? { ...guiProfile } - : { ...cliProfile }; - break; - case 'merge': - resolvedProfile = await this.mergeProfiles(guiProfile, cliProfile); - break; - default: - resolvedProfile = { ...guiProfile }; - } - - resolvedProfiles.push(resolvedProfile); - processedIds.add(guiProfile.id); - } else { - // Profile exists only in GUI - resolvedProfiles.push({ ...guiProfile, updatedAt: Date.now() }); - processedIds.add(guiProfile.id); - } - } - - // Add profiles that exist only in CLI - for (const cliProfile of cliProfiles) { - if (!processedIds.has(cliProfile.id)) { - resolvedProfiles.push({ ...cliProfile, updatedAt: Date.now() }); - } - } - - return resolvedProfiles; - } - - /** - * Merge two profiles, preferring non-null values from both - */ - private async mergeProfiles( - guiProfile: AIBackendProfile, - cliProfile: AIBackendProfile - ): Promise { - const merged: AIBackendProfile = { - id: guiProfile.id, - name: guiProfile.name || cliProfile.name, - description: guiProfile.description || cliProfile.description, - anthropicConfig: { ...cliProfile.anthropicConfig, ...guiProfile.anthropicConfig }, - openaiConfig: { ...cliProfile.openaiConfig, ...guiProfile.openaiConfig }, - azureOpenAIConfig: { ...cliProfile.azureOpenAIConfig, ...guiProfile.azureOpenAIConfig }, - togetherAIConfig: { ...cliProfile.togetherAIConfig, ...guiProfile.togetherAIConfig }, - tmuxConfig: { ...cliProfile.tmuxConfig, ...guiProfile.tmuxConfig }, - environmentVariables: this.mergeEnvironmentVariables( - cliProfile.environmentVariables || [], - guiProfile.environmentVariables || [] - ), - compatibility: { ...cliProfile.compatibility, ...guiProfile.compatibility }, - isBuiltIn: guiProfile.isBuiltIn || cliProfile.isBuiltIn, - createdAt: Math.min(guiProfile.createdAt || 0, cliProfile.createdAt || 0), - updatedAt: Math.max(guiProfile.updatedAt || 0, cliProfile.updatedAt || 0), - version: guiProfile.version || cliProfile.version || '1.0.0', - }; - - return merged; - } - - /** - * Merge environment variables from two profiles - */ - private mergeEnvironmentVariables( - cliVars: Array<{ name: string; value: string }>, - guiVars: Array<{ name: string; value: string }> - ): Array<{ name: string; value: string }> { - const mergedVars = new Map(); - - // Add CLI variables first - cliVars.forEach(v => mergedVars.set(v.name, v.value)); - - // Override with GUI variables - guiVars.forEach(v => mergedVars.set(v.name, v.value)); - - return Array.from(mergedVars.entries()).map(([name, value]) => ({ name, value })); - } - - /** - * Set active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async setActiveProfile(profileId: string): Promise { - try { - // Store in GUI settings using Happy's settings system - sync.applySettings({ lastUsedProfile: profileId }); - - console.log(`[ProfileSync] Set active profile ${profileId} in Happy settings`); - - // Note: CLI daemon accesses active profile through Happy settings system - // TODO: Implement machine RPC endpoint for setting active profile in CLI daemon - } catch (error) { - console.error('[ProfileSync] Failed to set active profile:', error); - throw error; - } - } - - /** - * Get active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async getActiveProfile(): Promise { - try { - // Get active profile from Happy settings system - const lastUsedProfileId = storage.getState().settings.lastUsedProfile; - - if (!lastUsedProfileId) { - return null; - } - - const profiles = storage.getState().settings.profiles || []; - const activeProfile = profiles.find((p: AIBackendProfile) => p.id === lastUsedProfileId); - - if (activeProfile) { - console.log(`[ProfileSync] Retrieved active profile ${activeProfile.name} from Happy settings`); - return activeProfile; - } - - return null; - } catch (error) { - console.error('[ProfileSync] Failed to get active profile:', error); - return null; - } - } - - /** - * Auto-sync if enabled and conditions are met - */ - public async autoSyncIfNeeded(guiProfiles: AIBackendProfile[]): Promise { - if (!this.config.autoSync) { - return; - } - - const timeSinceLastSync = Date.now() - this.lastSyncTime; - const AUTO_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes - - if (timeSinceLastSync > AUTO_SYNC_INTERVAL) { - try { - await this.bidirectionalSync(guiProfiles); - } catch (error) { - console.error('[ProfileSync] Auto-sync failed:', error); - // Don't throw for auto-sync failures - } - } - } -} - -// Export singleton instance -export const profileSyncService = ProfileSyncService.getInstance(); - -// Export convenience functions -export const syncGuiToCli = (profiles: AIBackendProfile[]) => profileSyncService.syncGuiToCli(profiles); -export const syncCliToGui = () => profileSyncService.syncCliToGui(); -export const bidirectionalSync = (guiProfiles: AIBackendProfile[]) => profileSyncService.bidirectionalSync(guiProfiles); -export const setActiveProfile = (profileId: string) => profileSyncService.setActiveProfile(profileId); -export const getActiveProfile = () => profileSyncService.getActiveProfile(); \ No newline at end of file diff --git a/sources/sync/reducer/phase0-skipping.spec.ts b/sources/sync/reducer/phase0-skipping.spec.ts index 5e005ab59..c1bb0e2ff 100644 --- a/sources/sync/reducer/phase0-skipping.spec.ts +++ b/sources/sync/reducer/phase0-skipping.spec.ts @@ -93,12 +93,6 @@ describe('Phase 0 permission skipping issue', () => { // Process messages and AgentState together (simulates opening chat) const result = reducer(state, toolMessages, agentState); - // Log what happened (for debugging) - console.log('Result messages:', result.messages.length); - console.log('Permission mappings:', { - toolIdToMessageId: Array.from(state.toolIdToMessageId.entries()) - }); - // Find the tool messages in the result const webFetchTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'WebFetch'); const writeTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'Write'); @@ -203,4 +197,4 @@ describe('Phase 0 permission skipping issue', () => { expect(toolAfterPermission?.tool?.permission?.id).toBe('tool1'); expect(toolAfterPermission?.tool?.permission?.status).toBe('approved'); }); -}); \ No newline at end of file +}); diff --git a/sources/theme.ts b/sources/theme.ts index c612581e3..a769757a7 100644 --- a/sources/theme.ts +++ b/sources/theme.ts @@ -49,7 +49,7 @@ export const lightTheme = { surface: '#ffffff', surfaceRipple: 'rgba(0, 0, 0, 0.08)', surfacePressed: '#f0f0f2', - surfaceSelected: Platform.select({ ios: '#C6C6C8', default: '#eaeaea' }), + surfaceSelected: Platform.select({ ios: '#eaeaea', default: '#eaeaea' }), surfacePressedOverlay: Platform.select({ ios: '#D1D1D6', default: 'transparent' }), surfaceHigh: '#F8F8F8', surfaceHighest: '#f0f0f0', diff --git a/sources/utils/ignoreNextRowPress.test.ts b/sources/utils/ignoreNextRowPress.test.ts new file mode 100644 index 000000000..807780c5b --- /dev/null +++ b/sources/utils/ignoreNextRowPress.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ignoreNextRowPress } from './ignoreNextRowPress'; + +describe('ignoreNextRowPress', () => { + it('resets the ignore flag on the next tick', () => { + vi.useFakeTimers(); + try { + const ref = { current: false }; + + ignoreNextRowPress(ref); + expect(ref.current).toBe(true); + + vi.runAllTimers(); + expect(ref.current).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/sources/utils/ignoreNextRowPress.ts b/sources/utils/ignoreNextRowPress.ts new file mode 100644 index 000000000..55c95e473 --- /dev/null +++ b/sources/utils/ignoreNextRowPress.ts @@ -0,0 +1,7 @@ +export function ignoreNextRowPress(ref: { current: boolean }): void { + ref.current = true; + setTimeout(() => { + ref.current = false; + }, 0); +} + diff --git a/sources/utils/promptUnsavedChangesAlert.test.ts b/sources/utils/promptUnsavedChangesAlert.test.ts new file mode 100644 index 000000000..85daab85f --- /dev/null +++ b/sources/utils/promptUnsavedChangesAlert.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { AlertButton } from '@/modal/types'; +import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert'; + +const basePromptOptions = { + title: 'Discard changes', + message: 'You have unsaved changes.', + discardText: 'Discard', + saveText: 'Save', + keepEditingText: 'Keep editing', +} as const; + +function createPromptHarness() { + let lastButtons: AlertButton[] | undefined; + + const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => { + lastButtons = buttons; + }; + + const promise = promptUnsavedChangesAlert(alert, basePromptOptions); + + function press(text: string) { + const button = lastButtons?.find((b) => b.text === text); + expect(button).toBeDefined(); + button?.onPress?.(); + } + + return { promise, press }; +} + +describe('promptUnsavedChangesAlert', () => { + it('resolves to save when the Save button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Save'); + + await expect(promise).resolves.toBe('save'); + }); + + it('resolves to discard when the Discard button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Discard'); + + await expect(promise).resolves.toBe('discard'); + }); + + it('resolves to keepEditing when the Keep editing button is pressed', async () => { + const { promise, press } = createPromptHarness(); + + press('Keep editing'); + + await expect(promise).resolves.toBe('keepEditing'); + }); +}); diff --git a/sources/utils/promptUnsavedChangesAlert.ts b/sources/utils/promptUnsavedChangesAlert.ts new file mode 100644 index 000000000..867580f3a --- /dev/null +++ b/sources/utils/promptUnsavedChangesAlert.ts @@ -0,0 +1,35 @@ +import type { AlertButton } from '@/modal/types'; + +export type UnsavedChangesDecision = 'discard' | 'save' | 'keepEditing'; + +export function promptUnsavedChangesAlert( + alert: (title: string, message?: string, buttons?: AlertButton[]) => void, + params: { + title: string; + message: string; + discardText: string; + saveText: string; + keepEditingText: string; + }, +): Promise { + return new Promise((resolve) => { + alert(params.title, params.message, [ + { + text: params.discardText, + style: 'destructive', + onPress: () => resolve('discard'), + }, + { + text: params.saveText, + style: 'default', + onPress: () => resolve('save'), + }, + { + text: params.keepEditingText, + style: 'cancel', + onPress: () => resolve('keepEditing'), + }, + ]); + }); +} + From 9e5a5519c80bcec4f83caf79cdebc89f9a680609 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 20:01:24 +0100 Subject: [PATCH 21/38] feat(cli-detection): add daemon detect-cli RPC support --- sources/hooks/useCLIDetection.ts | 29 +++++++++++-- sources/sync/ops.ts | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts index 7a839a9ae..233b3c2e2 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { machineBash } from '@/sync/ops'; +import { machineBash, machineDetectCli } from '@/sync/ops'; function debugLog(...args: unknown[]) { if (__DEV__) { @@ -23,8 +23,9 @@ interface CLIAvailability { * 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. + * Detection is automatic when machineId changes. Prefers a dedicated `detect-cli` + * RPC (daemon PATH resolution; no shell). Falls back to machineBash() probing + * for older daemons that don't support `detect-cli`. * * CONSERVATIVE FALLBACK: If detection fails (network error, timeout, bash error), * sets all CLIs to null and timestamp to 0, hiding status from UI. @@ -62,6 +63,28 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { debugLog('[useCLIDetection] Starting detection for machineId:', machineId); try { + // Preferred path: ask the daemon directly (no shell). + const cliStatus = await Promise.race([ + machineDetectCli(machineId), + new Promise<{ supported: false }>((resolve) => { + // If the daemon is older/broken and never responds to unknown RPCs, + // don't hang the UI—fallback to bash probing quickly. + setTimeout(() => resolve({ supported: false }), 2000); + }), + ]); + if (cancelled) return; + + if (cliStatus.supported) { + setAvailability({ + claude: cliStatus.response.clis.claude.available, + codex: cliStatus.response.clis.codex.available, + gemini: cliStatus.response.clis.gemini.available, + isDetecting: false, + timestamp: Date.now(), + }); + return; + } + // Use single bash command to check both CLIs efficiently // command -v is POSIX compliant and more reliable than which const result = await machineBash( diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index acf01c9e4..921fdec19 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -237,6 +237,79 @@ export async function machineBash( } } +export interface DetectCliEntry { + available: boolean; + resolvedPath?: string; +} + +export interface DetectCliResponse { + path: string | null; + clis: Record<'claude' | 'codex' | 'gemini', DetectCliEntry>; +} + +export type MachineDetectCliResult = + | { supported: true; response: DetectCliResponse } + | { supported: false }; + +/** + * Query daemon CLI availability using a dedicated RPC (preferred). + * + * Falls back to `{ supported: false }` for older daemons that don't implement it. + */ +export async function machineDetectCli(machineId: string): Promise { + try { + const result = await apiSocket.machineRPC( + machineId, + 'detect-cli', + {} + ); + + if (isPlainObject(result) && typeof result.error === 'string') { + // Older daemons (or errors) return an encrypted `{ error: ... }` payload. + if (result.error === 'Method not found') { + return { supported: false }; + } + return { supported: false }; + } + + if (!isPlainObject(result)) { + return { supported: false }; + } + + const clisRaw = result.clis; + if (!isPlainObject(clisRaw)) { + return { supported: false }; + } + + const getEntry = (name: 'claude' | 'codex' | 'gemini'): DetectCliEntry | null => { + const raw = (clisRaw as Record)[name]; + if (!isPlainObject(raw) || typeof raw.available !== 'boolean') return null; + const resolvedPath = raw.resolvedPath; + return { + available: raw.available, + ...(typeof resolvedPath === 'string' ? { resolvedPath } : {}), + }; + }; + + const claude = getEntry('claude'); + const codex = getEntry('codex'); + const gemini = getEntry('gemini'); + if (!claude || !codex || !gemini) { + return { supported: false }; + } + + const pathValue = result.path; + const response: DetectCliResponse = { + path: typeof pathValue === 'string' ? pathValue : null, + clis: { claude, codex, gemini }, + }; + + return { supported: true, response }; + } catch { + return { supported: false }; + } +} + export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full'; export type PreviewEnvSensitivitySource = 'forced' | 'hinted' | 'none'; From 10f06df8fa95ab5d955ce3fcb03e64efadbdfc4b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 17 Jan 2026 20:07:40 +0100 Subject: [PATCH 22/38] fix(agent-input): use compact permission badges --- sources/components/AgentInput.tsx | 39 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index b621c3f89..3b0c16d30 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -546,37 +546,40 @@ export const AgentInput = React.memo(React.forwardRef { if (isCodex) { + // Hide default (use icon-only for the common case). return normalizedPermissionMode === 'default' - ? t('agentInput.codexPermissionMode.default') + ? '' : normalizedPermissionMode === 'read-only' - ? t('agentInput.codexPermissionMode.readOnly') + ? t('agentInput.codexPermissionMode.badgeReadOnly') : normalizedPermissionMode === 'safe-yolo' - ? t('agentInput.codexPermissionMode.safeYolo') + ? t('agentInput.codexPermissionMode.badgeSafeYolo') : normalizedPermissionMode === 'yolo' - ? t('agentInput.codexPermissionMode.yolo') + ? t('agentInput.codexPermissionMode.badgeYolo') : ''; } if (isGemini) { + // Hide default (use icon-only for the common case). return normalizedPermissionMode === 'default' - ? t('agentInput.geminiPermissionMode.default') + ? '' : normalizedPermissionMode === 'read-only' - ? t('agentInput.geminiPermissionMode.readOnly') + ? t('agentInput.geminiPermissionMode.badgeReadOnly') : normalizedPermissionMode === 'safe-yolo' - ? t('agentInput.geminiPermissionMode.safeYolo') + ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : normalizedPermissionMode === 'yolo' - ? t('agentInput.geminiPermissionMode.yolo') + ? t('agentInput.geminiPermissionMode.badgeYolo') : ''; } + // Hide default (use icon-only for the common case). return normalizedPermissionMode === 'default' - ? t('agentInput.permissionMode.default') + ? '' : normalizedPermissionMode === 'acceptEdits' - ? t('agentInput.permissionMode.acceptEdits') - : normalizedPermissionMode === 'plan' - ? t('agentInput.permissionMode.plan') - : normalizedPermissionMode === 'bypassPermissions' - ? t('agentInput.permissionMode.bypassPermissions') + ? t('agentInput.permissionMode.badgeAccept') + : normalizedPermissionMode === 'plan' + ? t('agentInput.permissionMode.badgePlan') + : normalizedPermissionMode === 'bypassPermissions' + ? t('agentInput.permissionMode.badgeYolo') : ''; }, [isCodex, isGemini, normalizedPermissionMode]); @@ -966,9 +969,11 @@ export const AgentInput = React.memo(React.forwardRef - - {permissionChipLabel} - + {permissionChipLabel ? ( + + {permissionChipLabel} + + ) : null} )} From 04495c5d2a43dffe41d07c37ab8eb90ea06cc349 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:16:09 +0100 Subject: [PATCH 23/38] chore(test): define __DEV__ for vitest --- vitest.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 1836de229..74dff9b45 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config' import { resolve } from 'node:path' export default defineConfig({ + define: { + __DEV__: false, + }, test: { globals: false, environment: 'node', @@ -23,4 +26,4 @@ export default defineConfig({ '@': resolve('./sources'), }, }, -}) \ No newline at end of file +}) From e2bba661cc02e5158cfa9732689108275857ccb8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:16:17 +0100 Subject: [PATCH 24/38] feat(settings): add api keys and experiment toggles --- sources/profileRouteParams.ts | 24 ++++ sources/sync/persistence.ts | 3 + sources/sync/settings.spec.ts | 200 ++++++++++++++++++++++++++++++++++ sources/sync/settings.ts | 104 ++++++++++++++++++ 4 files changed, 331 insertions(+) diff --git a/sources/profileRouteParams.ts b/sources/profileRouteParams.ts index 99eae054a..de0729fbd 100644 --- a/sources/profileRouteParams.ts +++ b/sources/profileRouteParams.ts @@ -30,3 +30,27 @@ export function consumeProfileIdParam(params: { return { nextSelectedProfileId: nextProfileIdFromParams, shouldClearParam: true }; } +export function consumeApiKeyIdParam(params: { + apiKeyIdParam?: string | string[]; + selectedApiKeyId: string | null; +}): { + nextSelectedApiKeyId: string | null | undefined; + shouldClearParam: boolean; +} { + const nextApiKeyIdFromParams = normalizeOptionalParam(params.apiKeyIdParam); + + if (typeof nextApiKeyIdFromParams !== 'string') { + return { nextSelectedApiKeyId: undefined, shouldClearParam: false }; + } + + if (nextApiKeyIdFromParams === '') { + return { nextSelectedApiKeyId: null, shouldClearParam: true }; + } + + if (nextApiKeyIdFromParams === params.selectedApiKeyId) { + return { nextSelectedApiKeyId: undefined, shouldClearParam: true }; + } + + return { nextSelectedApiKeyId: nextApiKeyIdFromParams, shouldClearParam: true }; +} + diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index afe07faca..aa15da4cd 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -36,6 +36,7 @@ export interface NewSessionDraft { selectedMachineId: string | null; selectedPath: string | null; selectedProfileId: string | null; + selectedApiKeyId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; modelMode: ModelMode; @@ -163,6 +164,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null; const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null; const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null; + const selectedApiKeyId = typeof parsed.selectedApiKeyId === 'string' ? parsed.selectedApiKeyId : null; const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' ? parsed.agentType : 'claude'; @@ -180,6 +182,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { selectedMachineId, selectedPath, selectedProfileId, + selectedApiKeyId, agentType, permissionMode, modelMode, diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 1f38fef48..936077915 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -120,6 +120,57 @@ describe('settings', () => { ])); expect((profile as any).openaiConfig).toBeUndefined(); }); + + it('should default per-experiment toggles to true when experiments is true (migration)', () => { + const parsed = settingsParse({ + experiments: true, + // Note: per-experiment keys intentionally omitted (older clients) + } as any); + + expect((parsed as any).expGemini).toBe(true); + expect((parsed as any).expUsageReporting).toBe(true); + expect((parsed as any).expFileViewer).toBe(true); + expect((parsed as any).expShowThinkingMessages).toBe(true); + expect((parsed as any).expSessionType).toBe(true); + expect((parsed as any).expZen).toBe(true); + expect((parsed as any).expVoiceAuthFlow).toBe(true); + }); + + it('should default per-experiment toggles to false when experiments is false (migration)', () => { + const parsed = settingsParse({ + experiments: false, + // Note: per-experiment keys intentionally omitted (older clients) + } as any); + + expect((parsed as any).expGemini).toBe(false); + expect((parsed as any).expUsageReporting).toBe(false); + expect((parsed as any).expFileViewer).toBe(false); + expect((parsed as any).expShowThinkingMessages).toBe(false); + expect((parsed as any).expSessionType).toBe(false); + expect((parsed as any).expZen).toBe(false); + expect((parsed as any).expVoiceAuthFlow).toBe(false); + }); + + it('should preserve explicit per-experiment toggles when present (no forced override)', () => { + const parsed = settingsParse({ + experiments: true, + expGemini: false, + expUsageReporting: true, + expFileViewer: false, + expShowThinkingMessages: true, + expSessionType: false, + expZen: true, + expVoiceAuthFlow: false, + } as any); + + expect((parsed as any).expGemini).toBe(false); + expect((parsed as any).expUsageReporting).toBe(true); + expect((parsed as any).expFileViewer).toBe(false); + expect((parsed as any).expShowThinkingMessages).toBe(true); + expect((parsed as any).expSessionType).toBe(false); + expect((parsed as any).expZen).toBe(true); + expect((parsed as any).expVoiceAuthFlow).toBe(false); + }); }); describe('applySettings', () => { @@ -134,6 +185,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -141,6 +199,8 @@ describe('settings', () => { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'gradient', showFlavorIcons: false, compactSessionView: false, @@ -158,6 +218,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -173,6 +235,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -180,6 +249,8 @@ describe('settings', () => { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'gradient', // This should be preserved from currentSettings showFlavorIcons: false, compactSessionView: false, @@ -197,6 +268,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); @@ -212,6 +285,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -219,6 +299,8 @@ describe('settings', () => { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'gradient', showFlavorIcons: false, compactSessionView: false, @@ -236,6 +318,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = {}; @@ -253,6 +337,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -260,6 +351,8 @@ describe('settings', () => { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'gradient', showFlavorIcons: false, compactSessionView: false, @@ -277,6 +370,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -299,6 +394,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -306,6 +408,8 @@ describe('settings', () => { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'gradient', showFlavorIcons: false, compactSessionView: false, @@ -323,6 +427,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }; expect(applySettings(currentSettings, {})).toEqual(currentSettings); @@ -354,6 +460,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -361,6 +474,8 @@ describe('settings', () => { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'gradient', showFlavorIcons: false, compactSessionView: false, @@ -378,6 +493,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: any = { @@ -421,6 +538,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, alwaysShowContextSize: false, useEnhancedSessionWizard: false, @@ -431,6 +555,8 @@ describe('settings', () => { showFlavorIcons: false, compactSessionView: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', hideInactiveSessions: false, reviewPromptAnswered: false, reviewPromptLikedApp: null, @@ -445,6 +571,8 @@ describe('settings', () => { favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], + apiKeys: [], + defaultApiKeyByProfileId: {}, dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); @@ -608,6 +736,78 @@ describe('settings', () => { }; expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); }); + + it('rejects profiles with more than one required secret env var (V1 constraint)', () => { + const invalidProfile = { + id: crypto.randomUUID(), + name: 'Test Profile', + authMode: 'apiKeyEnv', + requiredEnvVars: [ + { name: 'OPENAI_API_KEY', kind: 'secret' }, + { name: 'ANTHROPIC_AUTH_TOKEN', kind: 'secret' }, + ], + compatibility: { claude: true, codex: true, gemini: true }, + }; + expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); + }); + + it('rejects machine-login profiles that declare required secret env vars', () => { + const invalidProfile = { + id: crypto.randomUUID(), + name: 'Test Profile', + authMode: 'machineLogin', + requiredEnvVars: [ + { name: 'OPENAI_API_KEY', kind: 'secret' }, + ], + compatibility: { claude: true, codex: true, gemini: true }, + }; + expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); + }); + + it('rejects requiresMachineLogin when authMode is not machineLogin', () => { + const invalidProfile = { + id: crypto.randomUUID(), + name: 'Test Profile', + authMode: 'apiKeyEnv', + requiresMachineLogin: 'claude-code', + requiredEnvVars: [ + { name: 'OPENAI_API_KEY', kind: 'secret' }, + ], + compatibility: { claude: true, codex: true, gemini: true }, + }; + expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow(); + }); + }); + + describe('SavedApiKey validation', () => { + it('accepts valid apiKeys entries in settingsParse', () => { + const now = Date.now(); + const parsed = settingsParse({ + apiKeys: [ + { id: 'k1', name: 'My Key', value: 'sk-test', createdAt: now, updatedAt: now }, + ], + }); + expect(parsed.apiKeys.length).toBe(1); + expect(parsed.apiKeys[0]?.name).toBe('My Key'); + expect(parsed.apiKeys[0]?.value).toBe('sk-test'); + }); + + it('drops invalid apiKeys entries (missing value)', () => { + const parsed = settingsParse({ + apiKeys: [ + { id: 'k1', name: 'Missing value' }, + ], + } as any); + // settingsParse validates per-field, so invalid field should fall back to default. + expect(parsed.apiKeys).toEqual([]); + }); + }); + + describe('defaultApiKeyByProfileId', () => { + it('defaults to an empty object', () => { + const parsed = settingsParse({}); + expect(parsed.defaultApiKeyByProfileId).toEqual({}); + }); }); describe('version-mismatch scenario (bug fix)', () => { diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 81b45ac7c..1b55d4f12 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -21,6 +21,16 @@ const EnvironmentVariableSchema = z.object({ isSecret: z.boolean().optional(), }); +const RequiredEnvVarKindSchema = z.enum(['secret', 'config']); + +const RequiredEnvVarSchema = z.object({ + name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'), + // Defaults to secret so older serialized forms (missing kind) remain safe/strict. + kind: RequiredEnvVarKindSchema.default('secret'), +}); + +const RequiresMachineLoginSchema = z.enum(['codex', 'claude-code', 'gemini-cli']); + // Profile compatibility schema const ProfileCompatibilitySchema = z.object({ claude: z.boolean().default(true), @@ -53,6 +63,19 @@ export const AIBackendProfileSchema = z.object({ // Compatibility metadata compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }), + // Authentication / requirements metadata (used by UI gating) + // - apiKeyEnv: profile expects required env vars to be present (optionally injected at spawn) + // - machineLogin: profile relies on a machine-local CLI login cache (no API key injection) + authMode: z.enum(['apiKeyEnv', 'machineLogin']).optional(), + + // For machine-login profiles, specify which CLI must be logged in on the target machine. + // This is used for UX copy and for optional login-status detection. + requiresMachineLogin: RequiresMachineLoginSchema.optional(), + + // Explicit environment variable requirements for this profile at runtime. + // V1 constraint: at most one required secret per profile (avoids ambiguous precedence/billing behavior). + requiredEnvVars: z.array(RequiredEnvVarSchema).optional(), + // Built-in profile indicator isBuiltIn: z.boolean().default(false), @@ -60,10 +83,48 @@ export const AIBackendProfileSchema = z.object({ createdAt: z.number().default(() => Date.now()), updatedAt: z.number().default(() => Date.now()), version: z.string().default('1.0.0'), +}).superRefine((profile, ctx) => { + const requiredEnvVars = profile.requiredEnvVars ?? []; + const secretCount = requiredEnvVars.filter(v => (v?.kind ?? 'secret') === 'secret').length; + + if (secretCount > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requiredEnvVars'], + message: 'V1 constraint: profiles may declare at most one required secret environment variable', + }); + } + + if (profile.authMode === 'machineLogin' && secretCount > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requiredEnvVars'], + message: 'Profiles with authMode=machineLogin must not declare required secret environment variables', + }); + } + + if (profile.requiresMachineLogin && profile.authMode !== 'machineLogin') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['requiresMachineLogin'], + message: 'requiresMachineLogin may only be set when authMode=machineLogin', + }); + } }); export type AIBackendProfile = z.infer; +export const SavedApiKeySchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(100), + // Secret. The UI must never re-display this after entry. + value: z.string().min(1), + createdAt: z.number().default(() => Date.now()), + updatedAt: z.number().default(() => Date.now()), +}); + +export type SavedApiKey = z.infer; + // Helper functions for profile validation and compatibility export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex' | 'gemini'): boolean { return profile.compatibility[agent]; @@ -218,6 +279,14 @@ 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'), + // Per-experiment toggles (gated by `experiments` master switch in UI/usage) + expGemini: z.boolean().describe('Experimental: enable Gemini backend + Gemini-related UX'), + expUsageReporting: z.boolean().describe('Experimental: enable usage reporting UI'), + expFileViewer: z.boolean().describe('Experimental: enable session file viewer'), + expShowThinkingMessages: z.boolean().describe('Experimental: show assistant thinking messages'), + expSessionType: z.boolean().describe('Experimental: show session type selector (simple vs worktree)'), + expZen: z.boolean().describe('Experimental: enable Zen navigation/experience'), + expVoiceAuthFlow: z.boolean().describe('Experimental: enable authenticated voice token flow'), useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), // Legacy combined toggle (kept for backward compatibility; see settingsParse migration) @@ -226,6 +295,8 @@ export const SettingsSchema = z.object({ usePathPickerSearch: z.boolean().describe('Whether to show search in path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), + agentInputActionBarLayout: z.enum(['auto', 'wrap', 'scroll', 'collapsed']).describe('Agent input action bar layout'), + agentInputChipDensity: z.enum(['auto', 'labels', 'icons']).describe('Agent input action chip density'), avatarStyle: z.string().describe('Avatar display style'), showFlavorIcons: z.boolean().describe('Whether to show AI provider icons in avatars'), compactSessionView: z.boolean().describe('Whether to use compact view for active sessions'), @@ -244,6 +315,8 @@ export const SettingsSchema = z.object({ // 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'), + apiKeys: z.array(SavedApiKeySchema).default([]).describe('Saved API keys (encrypted settings). Value is never re-displayed in UI.'), + defaultApiKeyByProfileId: z.record(z.string(), z.string()).default({}).describe('Default saved API key ID to use per profile'), // 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 @@ -294,6 +367,13 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + expGemini: false, + expUsageReporting: false, + expFileViewer: false, + expShowThinkingMessages: false, + expSessionType: false, + expZen: false, + expVoiceAuthFlow: false, useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, @@ -301,6 +381,8 @@ export const settingsDefaults: Settings = { usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, + agentInputActionBarLayout: 'auto', + agentInputChipDensity: 'auto', avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, @@ -316,6 +398,8 @@ export const settingsDefaults: Settings = { // Profile management defaults profiles: [], lastUsedProfile: null, + apiKeys: [], + defaultApiKeyByProfileId: {}, // Favorite directories (empty by default) favoriteDirectories: [], // Favorite machines (empty by default) @@ -392,6 +476,26 @@ export function settingsParse(settings: unknown): Settings { } } + // Migration: Introduce per-experiment toggles. + // If persisted settings only had `experiments` (older clients), default ALL experiment toggles + // to match the master switch so existing users keep the same behavior. + const experimentKeys = [ + 'expGemini', + 'expUsageReporting', + 'expFileViewer', + 'expShowThinkingMessages', + 'expSessionType', + 'expZen', + 'expVoiceAuthFlow', + ] as const; + const hasAnyExperimentKey = experimentKeys.some((k) => k in input); + if (!hasAnyExperimentKey) { + const enableAll = result.experiments === true; + for (const key of experimentKeys) { + result[key] = enableAll; + } + } + // Preserve unknown fields (forward compatibility). for (const [key, value] of Object.entries(input)) { if (key === '__proto__') continue; From 3620ec5dc83662c204aa49c3070650f9676ae0c4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:16:47 +0100 Subject: [PATCH 25/38] feat(api-keys): add saved API keys UI --- sources/app/(app)/new/pick/api-key.tsx | 42 ++++ sources/app/(app)/settings/api-keys.tsx | 29 +++ sources/components/ApiKeyAddModal.tsx | 204 +++++++++++++++++++ sources/components/SettingsView.tsx | 95 ++++++++- sources/components/apiKeys/ApiKeysList.tsx | 220 +++++++++++++++++++++ 5 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 sources/app/(app)/new/pick/api-key.tsx create mode 100644 sources/app/(app)/settings/api-keys.tsx create mode 100644 sources/components/ApiKeyAddModal.tsx create mode 100644 sources/components/apiKeys/ApiKeysList.tsx diff --git a/sources/app/(app)/new/pick/api-key.tsx b/sources/app/(app)/new/pick/api-key.tsx new file mode 100644 index 000000000..db5a94cfe --- /dev/null +++ b/sources/app/(app)/new/pick/api-key.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; + +import { useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { ApiKeysList } from '@/components/apiKeys/ApiKeysList'; + +export default React.memo(function ApiKeyPickerScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ selectedId?: string }>(); + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + + const [apiKeys, setApiKeys] = useSettingMutable('apiKeys'); + + const setApiKeyParamAndClose = React.useCallback((apiKeyId: string) => { + router.setParams({ apiKeyId }); + router.back(); + }, [router]); + + return ( + <> + + + + + ); +}); diff --git a/sources/app/(app)/settings/api-keys.tsx b/sources/app/(app)/settings/api-keys.tsx new file mode 100644 index 000000000..fa388eaa5 --- /dev/null +++ b/sources/app/(app)/settings/api-keys.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Stack } from 'expo-router'; + +import { useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { ApiKeysList } from '@/components/apiKeys/ApiKeysList'; + +export default React.memo(function ApiKeysSettingsScreen() { + const [apiKeys, setApiKeys] = useSettingMutable('apiKeys'); + + return ( + <> + + + + + ); +}); diff --git a/sources/components/ApiKeyAddModal.tsx b/sources/components/ApiKeyAddModal.tsx new file mode 100644 index 000000000..b57f8fbcd --- /dev/null +++ b/sources/components/ApiKeyAddModal.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { View, Text, TextInput, Pressable, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { ItemListStatic } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; + +export interface ApiKeyAddModalResult { + name: string; + value: string; +} + +export interface ApiKeyAddModalProps { + onClose: () => void; + onSubmit: (result: ApiKeyAddModalResult) => void; + title?: string; +} + +export function ApiKeyAddModal(props: ApiKeyAddModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const [name, setName] = React.useState(''); + const [value, setValue] = React.useState(''); + + const submit = React.useCallback(() => { + const trimmedName = name.trim(); + const trimmedValue = value.trim(); + if (!trimmedName) { + return; + } + if (!trimmedValue) { + return; + } + props.onSubmit({ name: trimmedName, value: trimmedValue }); + props.onClose(); + }, [name, props, value]); + + return ( + + + {props.title ?? t('apiKeys.addTitle')} + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + {t('settings.apiKeysSubtitle')} + + + + + + {t('apiKeys.fields.name')} + + + + + {t('apiKeys.fields.value')} + + + + + + + + + + ({ + backgroundColor: theme.colors.surface, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + + {t('common.cancel')} + + + + + ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: (!name.trim() || !value.trim()) ? 0.5 : (pressed ? 0.85 : 1), + })} + > + + {t('common.save')} + + + + + + + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + body: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + helpText: { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + marginBottom: 12, + ...Typography.default(), + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, + }, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, +})); diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 540603230..30f677167 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -1,9 +1,10 @@ -import { View, ScrollView, Pressable, Platform, Linking } from 'react-native'; +import { View, ScrollView, Pressable, Platform, Linking, Text as RNText, ActivityIndicator } from 'react-native'; import { Image } from 'expo-image'; import * as React from 'react'; import { Text } from '@/components/StyledText'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; import Constants from 'expo-constants'; import { useAuth } from '@/auth/AuthContext'; import { Typography } from "@/constants/Typography"; @@ -28,6 +29,7 @@ import { useProfile } from '@/sync/storage'; import { getDisplayName, getAvatarUrl, getBio } from '@/sync/profile'; import { Avatar } from '@/components/Avatar'; import { t } from '@/text'; +import { MachineCliGlyphs } from '@/components/newSession/MachineCliGlyphs'; export const SettingsView = React.memo(function SettingsView() { const { theme } = useUnistyles(); @@ -37,6 +39,7 @@ export const SettingsView = React.memo(function SettingsView() { const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled'); const isPro = __DEV__ || useEntitlement('pro'); const experiments = useSetting('experiments'); + const expUsageReporting = useSetting('expUsageReporting'); const useProfiles = useSetting('useProfiles'); const isCustomServer = isUsingCustomServer(); const allMachines = useAllMachines(); @@ -46,6 +49,47 @@ export const SettingsView = React.memo(function SettingsView() { const bio = getBio(profile); const { connectTerminal, connectWithUrl, isLoading } = useConnectTerminal(); + const [refreshingMachines, refreshMachines] = useHappyAction(async () => { + await sync.refreshMachinesThrottled({ force: true }); + }); + + useFocusEffect( + React.useCallback(() => { + void sync.refreshMachinesThrottled({ staleMs: 30_000 }); + }, []) + ); + + const machinesTitle = React.useMemo(() => { + const headerTextStyle = [ + Typography.default('regular'), + { + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase' as const, + fontWeight: Platform.select({ ios: 'normal', default: '500' }) as any, + }, + ]; + + return ( + + {t('settings.machines')} + + {refreshingMachines + ? + : } + + + ); + }, [refreshMachines, refreshingMachines, theme.colors.groupped.sectionTitle, theme.colors.textSecondary]); const handleGitHub = async () => { const url = 'https://github.com/slopus/happy'; @@ -211,7 +255,7 @@ export const SettingsView = React.memo(function SettingsView() { 0 && ( - + {[...allMachines].map((machine) => { const isOnline = isMachineOnline(machine); const host = machine.metadata?.host || 'Unknown'; @@ -269,14 +313,37 @@ export const SettingsView = React.memo(function SettingsView() { const title = displayName || host; // Build subtitle: show hostname if different from title, plus platform and status - let subtitle = ''; + let subtitleTop = ''; if (displayName && displayName !== host) { - subtitle = host; - } - if (platform) { - subtitle = subtitle ? `${subtitle} • ${platform}` : platform; + subtitleTop = host; } - subtitle = subtitle ? `${subtitle} • ${isOnline ? t('status.online') : t('status.offline')}` : (isOnline ? t('status.online') : t('status.offline')); + const statusText = isOnline ? t('status.online') : t('status.offline'); + const statusLineText = platform ? `${platform} • ${statusText}` : statusText; + + const subtitle = ( + + {subtitleTop ? ( + + {subtitleTop} + + ) : null} + + + {statusLineText} + + + {' • '} + + + + + ); return ( router.push('/(app)/settings/profiles')} /> )} - {experiments && ( + {useProfiles && ( + } + onPress={() => router.push('/(app)/settings/api-keys')} + /> + )} + {experiments && expUsageReporting && ( void; + + title?: string; + footer?: string | null; + + selectedId?: string; + onSelectId?: (id: string) => void; + + includeNoneRow?: boolean; + noneSubtitle?: string; + + defaultId?: string | null; + onSetDefaultId?: (id: string | null) => void; + + allowAdd?: boolean; + allowEdit?: boolean; + onAfterAddSelectId?: (id: string) => void; + + wrapInItemList?: boolean; +} + +export function ApiKeysList(props: ApiKeysListProps) { + const { theme } = useUnistyles(); + + const addApiKey = React.useCallback(async () => { + Modal.show({ + component: ApiKeyAddModal, + props: { + onSubmit: ({ name, value }) => { + const now = Date.now(); + const next: SavedApiKey = { id: newId(), name, value, createdAt: now, updatedAt: now }; + props.onChangeApiKeys([next, ...props.apiKeys]); + props.onAfterAddSelectId?.(next.id); + }, + }, + }); + }, [props]); + + const renameApiKey = React.useCallback(async (key: SavedApiKey) => { + const name = await Modal.prompt( + t('apiKeys.prompts.renameTitle'), + t('apiKeys.prompts.renameDescription'), + { defaultValue: key.name, placeholder: t('apiKeys.fields.name'), cancelText: t('common.cancel'), confirmText: t('common.rename') }, + ); + if (name === null) return; + if (!name.trim()) { + Modal.alert(t('common.error'), t('apiKeys.validation.nameRequired')); + return; + } + const now = Date.now(); + props.onChangeApiKeys(props.apiKeys.map((k) => (k.id === key.id ? { ...k, name: name.trim(), updatedAt: now } : k))); + }, [props]); + + const replaceApiKeyValue = React.useCallback(async (key: SavedApiKey) => { + const value = await Modal.prompt( + t('apiKeys.prompts.replaceValueTitle'), + t('apiKeys.prompts.replaceValueDescription'), + { placeholder: 'sk-...', inputType: 'secure-text', cancelText: t('common.cancel'), confirmText: t('apiKeys.actions.replace') }, + ); + if (value === null) return; + if (!value.trim()) { + Modal.alert(t('common.error'), t('apiKeys.validation.valueRequired')); + return; + } + const now = Date.now(); + props.onChangeApiKeys(props.apiKeys.map((k) => (k.id === key.id ? { ...k, value: value.trim(), updatedAt: now } : k))); + }, [props]); + + const deleteApiKey = React.useCallback(async (key: SavedApiKey) => { + const confirmed = await Modal.confirm( + t('apiKeys.prompts.deleteTitle'), + t('apiKeys.prompts.deleteConfirm', { name: key.name }), + { cancelText: t('common.cancel'), confirmText: t('common.delete'), destructive: true }, + ); + if (!confirmed) return; + props.onChangeApiKeys(props.apiKeys.filter((k) => k.id !== key.id)); + if (props.selectedId === key.id) { + props.onSelectId?.(''); + } + if (props.defaultId === key.id) { + props.onSetDefaultId?.(null); + } + }, [props]); + + const groupTitle = props.title ?? t('settings.apiKeys'); + const groupFooter = props.footer === undefined ? t('settings.apiKeysSubtitle') : (props.footer ?? undefined); + + const group = ( + <> + + {props.includeNoneRow && ( + } + onPress={() => props.onSelectId?.('')} + showChevron={false} + selected={props.selectedId === ''} + showDivider + /> + )} + + {props.apiKeys.length === 0 ? ( + } + showChevron={false} + /> + ) : ( + props.apiKeys.map((key, idx) => { + const isSelected = props.selectedId === key.id; + const isDefault = props.defaultId === key.id; + return ( + } + onPress={props.onSelectId ? () => props.onSelectId?.(key.id) : undefined} + showChevron={false} + selected={Boolean(props.onSelectId) ? isSelected : false} + showDivider={idx < props.apiKeys.length - 1} + rightElement={( + + {props.onSetDefaultId && ( + props.onSetDefaultId?.(isDefault ? null : key.id), + }, + ]} + /> + )} + + {props.onSelectId && ( + + + + )} + + {props.allowEdit !== false && ( + { void renameApiKey(key); } }, + { id: 'replace', title: t('apiKeys.actions.replaceValue'), icon: 'refresh-outline', onPress: () => { void replaceApiKeyValue(key); } }, + { id: 'delete', title: t('common.delete'), icon: 'trash-outline', destructive: true, onPress: () => { void deleteApiKey(key); } }, + ]} + /> + )} + + )} + /> + ); + }) + )} + + + {props.allowAdd !== false ? ( + } + onPress={() => { void addApiKey(); }} + showChevron={false} + showDivider={false} + /> + ) : null} + + + ); + + if (props.wrapInItemList === false) { + return group; + } + + return ( + + {group} + + ); +} From 55bbe9164eb5fb7114a0d3a0141ab3391ed571fc Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:18:54 +0100 Subject: [PATCH 26/38] feat(machine): surface detected CLI status --- sources/app/(app)/machine/[id].tsx | 144 +++++++++-- sources/components/SearchableListSelector.tsx | 12 +- .../components/machine/DetectedClisList.tsx | 123 ++++++++++ .../components/machine/DetectedClisModal.tsx | 106 ++++++++ .../newSession/MachineCliGlyphs.tsx | 112 +++++++++ .../components/newSession/MachineSelector.tsx | 24 ++ sources/hooks/useCLIDetection.ts | 223 +++++++++++------ sources/hooks/useMachineDetectCliCache.ts | 230 ++++++++++++++++++ sources/sync/ops.ts | 24 +- 9 files changed, 894 insertions(+), 104 deletions(-) create mode 100644 sources/components/machine/DetectedClisList.tsx create mode 100644 sources/components/machine/DetectedClisModal.tsx create mode 100644 sources/components/newSession/MachineCliGlyphs.tsx create mode 100644 sources/hooks/useMachineDetectCliCache.ts diff --git a/sources/app/(app)/machine/[id].tsx b/sources/app/(app)/machine/[id].tsx index 68438d54d..a0b9fa91f 100644 --- a/sources/app/(app)/machine/[id].tsx +++ b/sources/app/(app)/machine/[id].tsx @@ -8,7 +8,7 @@ import { Typography } from '@/constants/Typography'; import { useSessions, useAllMachines, useMachine } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; import type { Session } from '@/sync/storageTypes'; -import { machineStopDaemon, machineUpdateMetadata } from '@/sync/ops'; +import { machineDetectCli, type DetectCliResponse, machineStopDaemon, machineUpdateMetadata } from '@/sync/ops'; import { Modal } from '@/modal'; import { formatPathRelativeToHome, getSessionName, getSessionSubtitle } from '@/utils/sessionUtils'; import { isMachineOnline } from '@/utils/machineUtils'; @@ -19,6 +19,8 @@ import { useNavigateToSession } from '@/hooks/useNavigateToSession'; import { machineSpawnNewSession } from '@/sync/ops'; import { resolveAbsolutePath } from '@/utils/pathUtils'; import { MultiTextInput, type MultiTextInputHandle } from '@/components/MultiTextInput'; +import { DetectedClisList } from '@/components/machine/DetectedClisList'; +import type { MachineDetectCliCacheState } from '@/hooks/useMachineDetectCliCache'; const styles = StyleSheet.create((theme) => ({ pathInputContainer: { @@ -76,6 +78,13 @@ export default function MachineDetailScreen() { const [isSpawning, setIsSpawning] = useState(false); const inputRef = useRef(null); const [showAllPaths, setShowAllPaths] = useState(false); + const [detectedClis, setDetectedClis] = useState< + | { status: 'loading'; response?: DetectCliResponse } + | { status: 'loaded'; response: DetectCliResponse } + | { status: 'not-supported' } + | { status: 'error' } + | null + >(null); // Variant D only const machineSessions = useMemo(() => { @@ -126,25 +135,25 @@ export default function MachineDetailScreen() { const handleStopDaemon = async () => { // Show confirmation modal using alert with buttons Modal.alert( - 'Stop Daemon?', - 'You will not be able to spawn new sessions on this machine until you restart the daemon on your computer again. Your current sessions will stay alive.', + t('machine.stopDaemonConfirmTitle'), + t('machine.stopDaemonConfirmBody'), [ { - text: 'Cancel', + text: t('common.cancel'), style: 'cancel' }, { - text: 'Stop Daemon', + text: t('machine.stopDaemon'), style: 'destructive', onPress: async () => { setIsStoppingDaemon(true); try { const result = await machineStopDaemon(machineId!); - Modal.alert('Daemon Stopped', result.message); + Modal.alert(t('machine.daemonStoppedTitle'), result.message); // Refresh to get updated metadata await sync.refreshMachines(); } catch (error) { - Modal.alert(t('common.error'), 'Failed to stop daemon. It may not be running.'); + Modal.alert(t('common.error'), t('machine.stopDaemonFailed')); } finally { setIsStoppingDaemon(false); } @@ -159,18 +168,104 @@ export default function MachineDetailScreen() { const handleRefresh = async () => { setIsRefreshing(true); await sync.refreshMachines(); + if (machineId) { + try { + setDetectedClis((prev) => ({ status: 'loading', ...(prev && 'response' in prev ? { response: prev.response } : {}) })); + const result = await machineDetectCli(machineId); + if (result.supported) { + setDetectedClis({ status: 'loaded', response: result.response }); + } else { + setDetectedClis(result.reason === 'not-supported' ? { status: 'not-supported' } : { status: 'error' }); + } + } catch { + setDetectedClis({ status: 'error' }); + } + } setIsRefreshing(false); }; + const refreshDetectedClis = useCallback(async () => { + if (!machineId) return; + try { + setDetectedClis((prev) => ({ status: 'loading', ...(prev && 'response' in prev ? { response: prev.response } : {}) })); + // On direct loads/refreshes, machine encryption/socket may not be ready yet. + // Refreshing machines first makes this much more reliable and avoids misclassifying + // transient failures as “not supported / update CLI”. + await sync.refreshMachines(); + const result = await machineDetectCli(machineId); + if (result.supported) { + setDetectedClis({ status: 'loaded', response: result.response }); + return; + } + setDetectedClis(result.reason === 'not-supported' ? { status: 'not-supported' } : { status: 'error' }); + } catch { + setDetectedClis({ status: 'error' }); + } + }, [machineId]); + + React.useEffect(() => { + void refreshDetectedClis(); + }, [refreshDetectedClis]); + + const detectedClisState: MachineDetectCliCacheState = useMemo(() => { + if (!detectedClis) return { status: 'idle' }; + if (detectedClis.status === 'loaded') return { status: 'loaded', response: detectedClis.response }; + if (detectedClis.status === 'loading') return { status: 'loading' }; + if (detectedClis.status === 'not-supported') return { status: 'not-supported' }; + return { status: 'error' }; + }, [detectedClis]); + + const detectedClisTitle = useMemo(() => { + const headerTextStyle = [ + Typography.default('regular'), + { + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase' as const, + fontWeight: Platform.select({ ios: 'normal', default: '500' }) as any, + }, + ]; + + const isOnline = !!machine && isMachineOnline(machine); + const canRefresh = isOnline && detectedClisState.status !== 'loading'; + + return ( + + {t('machine.detectedClis')} + refreshDetectedClis()} + hitSlop={10} + style={{ padding: 2 }} + accessibilityRole="button" + accessibilityLabel={t('common.refresh')} + disabled={!canRefresh} + > + {detectedClisState.status === 'loading' + ? + : } + + + ); + }, [ + detectedClisState.status, + machine, + refreshDetectedClis, + theme.colors.divider, + theme.colors.groupped.sectionTitle, + theme.colors.textSecondary, + ]); + const handleRenameMachine = async () => { if (!machine || !machineId) return; const newDisplayName = await Modal.prompt( - 'Rename Machine', - 'Give this machine a custom name. Leave empty to use the default hostname.', + t('machine.renameTitle'), + t('machine.renameDescription'), { defaultValue: machine.metadata?.displayName || '', - placeholder: machine.metadata?.host || 'Enter machine name', + placeholder: machine.metadata?.host || t('machine.renamePlaceholder'), cancelText: t('common.cancel'), confirmText: t('common.rename') } @@ -190,11 +285,11 @@ export default function MachineDetailScreen() { machine.metadataVersion ); - Modal.alert(t('common.success'), 'Machine renamed successfully'); + Modal.alert(t('common.success'), t('machine.renamedSuccess')); } catch (error) { Modal.alert( - 'Error', - error instanceof Error ? error.message : 'Failed to rename machine' + t('common.error'), + error instanceof Error ? error.message : t('machine.renameFailed') ); // Refresh to get latest state await sync.refreshMachines(); @@ -224,7 +319,11 @@ export default function MachineDetailScreen() { navigateToSession(result.sessionId); break; case 'requestToApproveDirectoryCreation': { - const approved = await Modal.confirm('Create Directory?', `The directory '${result.directory}' does not exist. Would you like to create it?`, { cancelText: t('common.cancel'), confirmText: t('common.create') }); + const approved = await Modal.confirm( + t('newSession.directoryDoesNotExist'), + t('newSession.createDirectoryConfirm', { directory: result.directory }), + { cancelText: t('common.cancel'), confirmText: t('common.create') } + ); if (approved) { await handleStartSession(true); } @@ -235,7 +334,7 @@ export default function MachineDetailScreen() { break; } } catch (error) { - let errorMessage = 'Failed to start session. Make sure the daemon is running on the target machine.'; + let errorMessage = t('newSession.failedToStart'); if (error instanceof Error && !error.message.includes('Failed to spawn session')) { errorMessage = error.message; } @@ -246,7 +345,7 @@ export default function MachineDetailScreen() { }; const pastUsedRelativePath = useCallback((session: Session) => { - if (!session.metadata) return 'unknown path'; + if (!session.metadata) return t('machine.unknownPath'); return formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir); }, []); @@ -262,7 +361,7 @@ export default function MachineDetailScreen() { /> - Machine not found + {t('machine.notFound')} @@ -270,7 +369,7 @@ export default function MachineDetailScreen() { } const metadata = machine.metadata; - const machineName = metadata?.displayName || metadata?.host || 'unknown machine'; + const machineName = metadata?.displayName || metadata?.host || t('machine.unknownMachine'); const spawnButtonDisabled = !customPath.trim() || isSpawning || !isMachineOnline(machine!); @@ -280,7 +379,7 @@ export default function MachineDetailScreen() { options={{ headerShown: true, headerTitle: () => ( - + @@ -420,6 +519,11 @@ export default function MachineDetailScreen() { )} + {/* Detected CLIs */} + + + + {/* Daemon */} { isPulsing?: boolean; } | null; + /** + * Optional extra element rendered next to the status (e.g. small CLI glyphs). + * Kept separate from status.text so it can be interactive (tap/hover). + */ + getItemStatusExtra?: (item: T) => React.ReactNode; + // Display formatting (e.g., formatPathRelativeToHome for paths, displayName for machines) formatForDisplay: (item: T, context?: any) => string; parseFromDisplay: (text: string, context?: any) => T | null; @@ -217,6 +223,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) ? config.getFavoriteItemIcon(item) : config.getItemIcon(item); const status = config.getItemStatus?.(item, theme); + const statusExtra = config.getItemStatusExtra?.(item); const isFavorite = favoriteIds.has(itemId) || forFavorite; const selectedColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; @@ -229,7 +236,10 @@ export function SearchableListSelector(props: SearchableListSelectorProps) leftElement={icon} rightElement={( - {renderStatus(status)} + + {renderStatus(status)} + {statusExtra} + { + if (!value) return null; + const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/); + return match?.[0] ?? null; + }, []); + + const subtitleBaseStyle = React.useMemo(() => { + return [ + Typography.default('regular'), + { + color: theme.colors.textSecondary, + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + flexWrap: 'wrap' as const, + }, + ]; + }, [theme.colors.textSecondary]); + + if (state.status === 'not-supported') { + return ; + } + + if (state.status === 'error') { + return ; + } + + if (state.status === 'loading' || state.status === 'idle') { + return ( + } + /> + ); + } + + const entries = [ + ['claude', state.response.clis.claude] as const, + ['codex', state.response.clis.codex] as const, + ['gemini', state.response.clis.gemini] as const, + ].filter(([name]) => name !== 'gemini' || allowGemini); + + return ( + <> + {entries.map(([name, entry], index) => { + const available = entry.available; + const iconName = available ? 'checkmark-circle' : 'close-circle'; + const iconColor = available ? theme.colors.status.connected : theme.colors.textSecondary; + const version = extractSemver(entry.version); + + const subtitle = !available + ? t('machine.detectedCliNotDetected') + : ( + layout === 'stacked' ? ( + + {version ? ( + + {version} + + ) : null} + {entry.resolvedPath ? ( + + {entry.resolvedPath} + + ) : null} + {!version && !entry.resolvedPath ? ( + + {t('machine.detectedCliUnknown')} + + ) : null} + + ) : ( + + {version ?? null} + {version && entry.resolvedPath ? ' • ' : null} + {entry.resolvedPath ? ( + + {entry.resolvedPath} + + ) : null} + {!version && !entry.resolvedPath ? t('machine.detectedCliUnknown') : null} + + ) + ); + + return ( + } + /> + ); + })} + + ); +} + diff --git a/sources/components/machine/DetectedClisModal.tsx b/sources/components/machine/DetectedClisModal.tsx new file mode 100644 index 000000000..3ad6d4e6e --- /dev/null +++ b/sources/components/machine/DetectedClisModal.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { View, Text, Pressable, ActivityIndicator } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { RoundButton } from '@/components/RoundButton'; +import { useMachineDetectCliCache } from '@/hooks/useMachineDetectCliCache'; +import { DetectedClisList } from '@/components/machine/DetectedClisList'; +import { t } from '@/text'; +import type { CustomModalInjectedProps } from '@/modal'; + +type Props = CustomModalInjectedProps & { + machineId: string; + isOnline: boolean; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + borderRadius: 14, + width: 360, + maxWidth: '92%', + overflow: 'hidden', + shadowColor: theme.colors.shadow.color, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + header: { + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + title: { + fontSize: 17, + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + body: { + paddingVertical: 4, + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 14, + borderTopWidth: 1, + borderTopColor: theme.colors.divider, + alignItems: 'center', + }, +})); + +export function DetectedClisModal({ onClose, machineId, isOnline }: Props) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const { state, refresh } = useMachineDetectCliCache({ + machineId, + // Cache-first: never auto-fetch on mount; user can explicitly refresh. + enabled: false, + }); + + return ( + + + {t('machine.detectedClis')} + + refresh()} + hitSlop={10} + style={{ padding: 2 }} + accessibilityRole="button" + accessibilityLabel="Refresh" + disabled={!isOnline || state.status === 'loading'} + > + {state.status === 'loading' + ? + : } + + + + + + + + + + + + + + + + ); +} + diff --git a/sources/components/newSession/MachineCliGlyphs.tsx b/sources/components/newSession/MachineCliGlyphs.tsx new file mode 100644 index 000000000..aa0c6f4fa --- /dev/null +++ b/sources/components/newSession/MachineCliGlyphs.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { useSetting } from '@/sync/storage'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { useMachineDetectCliCache } from '@/hooks/useMachineDetectCliCache'; +import { DetectedClisModal } from '@/components/machine/DetectedClisModal'; + +type Props = { + machineId: string; + isOnline: boolean; + /** + * When true, the component may trigger detect-cli fetches. + * When false, it will render cached results only (no automatic fetching). + */ + autoDetect?: boolean; +}; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + paddingHorizontal: 4, + paddingVertical: 2, + borderRadius: 6, + }, + glyph: { + color: theme.colors.textSecondary, + ...Typography.default(), + }, + glyphMuted: { + opacity: 0.35, + }, +})); + +// iOS can render some dingbat glyphs as emoji; force text presentation (U+FE0E). +const CLAUDE_GLYPH = '\u2733\uFE0E'; +const CODEX_GLYPH = '꩜'; +const GEMINI_GLYPH = '\u2726\uFE0E'; + +export const MachineCliGlyphs = React.memo(({ machineId, isOnline, autoDetect = true }: Props) => { + useUnistyles(); // re-render on theme changes + const styles = stylesheet; + const experimentsEnabled = useSetting('experiments'); + const expGemini = useSetting('expGemini'); + const allowGemini = experimentsEnabled && expGemini; + + const { state, refresh } = useMachineDetectCliCache({ + machineId, + enabled: autoDetect && isOnline, + }); + + const onPress = React.useCallback(() => { + // Cache-first: opening this modal should NOT fetch by default. + // Users can explicitly refresh inside the modal if needed. + Modal.show({ + component: DetectedClisModal, + props: { + machineId, + isOnline, + }, + }); + }, [isOnline, machineId]); + + const glyphs = React.useMemo(() => { + if (state.status !== 'loaded') { + return [{ key: 'unknown', glyph: '•', factor: 0.85, muted: true }]; + } + + const items: Array<{ key: string; glyph: string; factor: number; muted: boolean }> = []; + const hasClaude = state.response.clis.claude.available; + const hasCodex = state.response.clis.codex.available; + const hasGemini = allowGemini && state.response.clis.gemini.available; + + if (hasClaude) items.push({ key: 'claude', glyph: CLAUDE_GLYPH, factor: 1.0, muted: false }); + if (hasCodex) items.push({ key: 'codex', glyph: CODEX_GLYPH, factor: 0.92, muted: false }); + if (hasGemini) items.push({ key: 'gemini', glyph: GEMINI_GLYPH, factor: 1.0, muted: false }); + + if (items.length === 0) { + items.push({ key: 'none', glyph: '•', factor: 0.85, muted: true }); + } + + return items; + }, [allowGemini, state.status, state]); + + return ( + [ + styles.container, + { opacity: !isOnline ? 0.5 : (pressed ? 0.7 : 1) }, + ]} + > + {glyphs.map((item) => ( + + {item.glyph} + + ))} + + ); +}); + diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index e2ef825d8..26c6ee434 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -5,6 +5,7 @@ import { SearchableListSelector } from '@/components/SearchableListSelector'; import type { Machine } from '@/sync/storageTypes'; import { isMachineOnline } from '@/utils/machineUtils'; import { t } from '@/text'; +import { MachineCliGlyphs } from '@/components/newSession/MachineCliGlyphs'; export interface MachineSelectorProps { machines: Machine[]; @@ -16,6 +17,18 @@ export interface MachineSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + /** + * When true, show small CLI glyphs per machine row. + * + * NOTE: This can be expensive on iOS because each glyph can trigger CLI detection + * work; keep this off in high-interaction contexts like the new session wizard. + */ + showCliGlyphs?: boolean; + /** + * When false, glyphs will render from cache only and will not auto-trigger detection. + * You can still refresh from the Detected CLIs modal by tapping the glyphs. + */ + autoDetectCliGlyphs?: boolean; searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; searchPlaceholder?: string; recentSectionTitle?: string; @@ -34,6 +47,8 @@ export function MachineSelector({ showFavorites = true, showRecent = true, showSearch = true, + showCliGlyphs = true, + autoDetectCliGlyphs = true, searchPlacement = 'header', searchPlaceholder: searchPlaceholderProp, recentSectionTitle: recentSectionTitleProp, @@ -78,6 +93,15 @@ export function MachineSelector({ isPulsing: !offline, }; }, + ...(showCliGlyphs ? { + getItemStatusExtra: (machine: Machine) => ( + + ), + } : {}), formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, parseFromDisplay: (text) => { return machines.find(m => diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts index 233b3c2e2..6fe94f725 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -1,5 +1,8 @@ -import { useState, useEffect } from 'react'; -import { machineBash, machineDetectCli } from '@/sync/ops'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { machineBash } from '@/sync/ops'; +import { useMachine } from '@/sync/storage'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { useMachineDetectCliCache } from '@/hooks/useMachineDetectCliCache'; function debugLog(...args: unknown[]) { if (__DEV__) { @@ -12,11 +15,28 @@ interface CLIAvailability { claude: boolean | null; // null = unknown/loading, true = installed, false = not installed codex: boolean | null; gemini: boolean | null; + login: { + claude: boolean | null; // null = unknown/unsupported + codex: boolean | null; + gemini: boolean | null; + }; isDetecting: boolean; // Explicit loading state timestamp: number; // When detection completed error?: string; // Detection error message (for debugging) } +export interface UseCLIDetectionOptions { + /** + * When false, the hook will be cache-only (no automatic detect-cli fetches, + * and no bash fallback probing). Intended for cache-first UIs. + */ + autoDetect?: boolean; + /** + * When true, requests login status detection (can be heavier than basic detection). + */ + includeLoginStatus?: boolean; +} + /** * Detects which CLI tools (claude, codex, gemini) are installed on a remote machine. * @@ -40,53 +60,47 @@ interface CLIAvailability { * // 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, +export function useCLIDetection(machineId: string | null, options?: UseCLIDetectionOptions): CLIAvailability { + const machine = useMachine(machineId ?? ''); + const isOnline = useMemo(() => { + if (!machineId || !machine) return false; + return isMachineOnline(machine); + }, [machine, machineId]); + + const autoDetect = options?.autoDetect !== false; + + const { state: cached } = useMachineDetectCliCache({ + machineId, + enabled: isOnline && autoDetect, + includeLoginStatus: Boolean(options?.includeLoginStatus), }); - useEffect(() => { - if (!machineId) { - setAvailability({ claude: null, codex: null, gemini: null, isDetecting: false, timestamp: 0 }); + const lastSuccessfulDetectAtRef = useRef(0); + const bashInFlightRef = useRef | null>(null); + const bashLastRanAtRef = useRef(0); + + const [bashAvailability, setBashAvailability] = useState<{ + machineId: string; + claude: boolean | null; + codex: boolean | null; + gemini: boolean | null; + timestamp: number; + error?: string; + } | null>(null); + + const runBashFallback = useCallback(async () => { + if (!machineId) return; + if (bashInFlightRef.current) return bashInFlightRef.current; + + const now = Date.now(); + // Avoid hammering bash probing if something is wrong. + if ((now - bashLastRanAtRef.current) < 15_000) { return; } + bashLastRanAtRef.current = now; - let cancelled = false; - - const detectCLIs = async () => { - // Set detecting flag (non-blocking - UI stays responsive) - setAvailability(prev => ({ ...prev, isDetecting: true })); - debugLog('[useCLIDetection] Starting detection for machineId:', machineId); - + bashInFlightRef.current = (async () => { try { - // Preferred path: ask the daemon directly (no shell). - const cliStatus = await Promise.race([ - machineDetectCli(machineId), - new Promise<{ supported: false }>((resolve) => { - // If the daemon is older/broken and never responds to unknown RPCs, - // don't hang the UI—fallback to bash probing quickly. - setTimeout(() => resolve({ supported: false }), 2000); - }), - ]); - if (cancelled) return; - - if (cliStatus.supported) { - setAvailability({ - claude: cliStatus.response.clis.claude.available, - codex: cliStatus.response.clis.codex.available, - gemini: cliStatus.response.clis.gemini.available, - isDetecting: false, - timestamp: Date.now(), - }); - return; - } - - // 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") && ' + @@ -95,11 +109,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { '/' ); - if (cancelled) return; - debugLog('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); + debugLog('[useCLIDetection] bash fallback 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 } = {}; @@ -110,49 +122,112 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { } }); - debugLog('[useCLIDetection] Parsed CLI status:', cliStatus); - setAvailability({ + setBashAvailability({ + machineId, 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) - debugLog('[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'}`, - }); + return; } - } catch (error) { - if (cancelled) return; - // Network/RPC error - CONSERVATIVE fallback (don't assume availability) - debugLog('[useCLIDetection] Network/RPC error:', error); - setAvailability({ + setBashAvailability({ + machineId, + claude: null, + codex: null, + gemini: null, + timestamp: 0, + error: `Detection failed: ${result.stderr || 'Unknown error'}`, + }); + } catch (error) { + setBashAvailability({ + machineId, claude: null, codex: null, gemini: null, - isDetecting: false, timestamp: 0, error: error instanceof Error ? error.message : 'Detection error', }); + } finally { + bashInFlightRef.current = null; } - }; + })(); - detectCLIs(); - - // Cleanup: Cancel detection if component unmounts or machineId changes - return () => { - cancelled = true; - }; + return bashInFlightRef.current; }, [machineId]); - return availability; + useEffect(() => { + if (!machineId || !isOnline) { + setBashAvailability(null); + return; + } + + // If detect-cli isn't supported or errored, fall back to bash probing (once). + if (autoDetect && (cached.status === 'not-supported' || cached.status === 'error')) { + void runBashFallback(); + } + }, [autoDetect, cached.status, isOnline, machineId, runBashFallback]); + + return useMemo((): CLIAvailability => { + if (!machineId || !isOnline) { + return { + claude: null, + codex: null, + gemini: null, + login: { claude: null, codex: null, gemini: null }, + isDetecting: false, + timestamp: 0 + }; + } + + const cachedResponse = + cached.status === 'loaded' + ? cached.response + : cached.status === 'loading' + ? cached.response + : null; + + if (cachedResponse) { + const now = Date.now(); + if (cached.status === 'loaded') { + lastSuccessfulDetectAtRef.current = now; + } + return { + claude: cachedResponse.clis.claude.available, + codex: cachedResponse.clis.codex.available, + gemini: cachedResponse.clis.gemini.available, + login: { + claude: options?.includeLoginStatus ? (cachedResponse.clis.claude.isLoggedIn ?? null) : null, + codex: options?.includeLoginStatus ? (cachedResponse.clis.codex.isLoggedIn ?? null) : null, + gemini: options?.includeLoginStatus ? (cachedResponse.clis.gemini.isLoggedIn ?? null) : null, + }, + isDetecting: cached.status === 'loading', + timestamp: lastSuccessfulDetectAtRef.current || now, + }; + } + + // No cached response yet. If bash fallback has data for this machine, use it. + if (bashAvailability?.machineId === machineId) { + return { + claude: bashAvailability.claude, + codex: bashAvailability.codex, + gemini: bashAvailability.gemini, + login: { claude: null, codex: null, gemini: null }, + isDetecting: cached.status === 'loading' || bashInFlightRef.current !== null, + timestamp: bashAvailability.timestamp, + ...(bashAvailability.error ? { error: bashAvailability.error } : {}), + }; + } + + return { + claude: null, + codex: null, + gemini: null, + login: { claude: null, codex: null, gemini: null }, + isDetecting: cached.status === 'loading', + timestamp: 0, + ...(cached.status === 'error' ? { error: 'Detection error' } : {}), + }; + }, [bashAvailability, cached, isOnline, machineId, options?.includeLoginStatus]); } diff --git a/sources/hooks/useMachineDetectCliCache.ts b/sources/hooks/useMachineDetectCliCache.ts new file mode 100644 index 000000000..b2c6b9a9a --- /dev/null +++ b/sources/hooks/useMachineDetectCliCache.ts @@ -0,0 +1,230 @@ +import * as React from 'react'; +import { machineDetectCli, type DetectCliResponse } from '@/sync/ops'; + +export type MachineDetectCliCacheState = + | { status: 'idle' } + | { status: 'loading'; response?: DetectCliResponse } + | { status: 'loaded'; response: DetectCliResponse } + | { status: 'not-supported' } + | { status: 'error' }; + +type CacheEntry = + | { + state: MachineDetectCliCacheState; + updatedAt: number; + inFlight?: Promise; + }; + +const cache = new Map(); +const listeners = new Map void>>(); + +const DEFAULT_STALE_MS = 10 * 60 * 1000; // 10 minutes +const DEFAULT_FETCH_TIMEOUT_MS = 2500; + +function getEntry(cacheKey: string): CacheEntry | null { + return cache.get(cacheKey) ?? null; +} + +function notify(cacheKey: string) { + const entry = getEntry(cacheKey); + if (!entry) return; + const subs = listeners.get(cacheKey); + if (!subs || subs.size === 0) return; + for (const cb of subs) cb(entry.state); +} + +function setEntry(cacheKey: string, entry: CacheEntry) { + cache.set(cacheKey, entry); + notify(cacheKey); +} + +function subscribe(cacheKey: string, cb: (state: MachineDetectCliCacheState) => void): () => void { + let set = listeners.get(cacheKey); + if (!set) { + set = new Set(); + listeners.set(cacheKey, set); + } + set.add(cb); + return () => { + const current = listeners.get(cacheKey); + if (!current) return; + current.delete(cb); + if (current.size === 0) listeners.delete(cacheKey); + }; +} + +async function fetchAndCache(params: { machineId: string; includeLoginStatus: boolean }): Promise { + const cacheKey = `${params.machineId}:${params.includeLoginStatus ? 'login' : 'basic'}`; + const existing = getEntry(cacheKey); + if (existing?.inFlight) { + return existing.inFlight; + } + + const prevResponse = + existing?.state.status === 'loaded' + ? existing.state.response + : existing?.state.status === 'loading' + ? existing.state.response + : undefined; + + // Create the in-flight promise first, then store it in cache (avoid TDZ/self-reference bugs). + const inFlight = (async () => { + try { + const result = await Promise.race([ + machineDetectCli(params.machineId, params.includeLoginStatus ? { includeLoginStatus: true } : undefined), + new Promise<{ supported: false; reason: 'error' }>((resolve) => { + // Old daemons can hang on unknown RPCs; don't let the UI get stuck in "loading". + setTimeout(() => resolve({ supported: false, reason: 'error' }), DEFAULT_FETCH_TIMEOUT_MS); + }), + ]); + if (result.supported) { + setEntry(cacheKey, { state: { status: 'loaded', response: result.response }, updatedAt: Date.now() }); + } else { + setEntry(cacheKey, { + state: result.reason === 'not-supported' ? { status: 'not-supported' } : { status: 'error' }, + updatedAt: Date.now(), + }); + } + } catch { + setEntry(cacheKey, { state: { status: 'error' }, updatedAt: Date.now() }); + } finally { + const current = getEntry(cacheKey); + if (current?.inFlight) { + // Clear inFlight marker so future refreshes can run. + setEntry(cacheKey, { state: current.state, updatedAt: current.updatedAt }); + } + } + })(); + + // Mark as loading immediately (stale-while-revalidate: keep prior response if available). + setEntry(cacheKey, { + state: { status: 'loading', ...(prevResponse ? { response: prevResponse } : {}) }, + updatedAt: Date.now(), + inFlight, + }); + + return inFlight; +} + +/** + * Prefetch detect-cli data into the UI cache. + * + * Intended for cases like the New Session wizard where we want to populate glyphs + * once on screen open, without triggering per-row auto-detect work during taps. + */ +export function prefetchMachineDetectCli(params: { machineId: string; includeLoginStatus?: boolean }): Promise { + return fetchAndCache({ machineId: params.machineId, includeLoginStatus: Boolean(params.includeLoginStatus) }); +} + +/** + * Prefetch detect-cli data only if missing (no cache entry yet). + * + * This matches the "detect once, then only refresh on explicit user action" rule. + */ +export function prefetchMachineDetectCliIfMissing(params: { machineId: string; includeLoginStatus?: boolean }): Promise { + const cacheKey = `${params.machineId}:${params.includeLoginStatus ? 'login' : 'basic'}`; + const existing = getEntry(cacheKey); + if (!existing) { + return fetchAndCache({ machineId: params.machineId, includeLoginStatus: Boolean(params.includeLoginStatus) }); + } + if (existing.state.status === 'idle') { + return fetchAndCache({ machineId: params.machineId, includeLoginStatus: Boolean(params.includeLoginStatus) }); + } + // If we already have data (or even an error), do not auto-refetch. + return Promise.resolve(); +} + +/** + * Prefetch detect-cli data only if missing or stale. + * + * Intended for screen-open "background refresh" where we want to pick up + * newly-installed CLIs, but avoid fetches on every tap/navigation. + */ +export function prefetchMachineDetectCliIfStale(params: { + machineId: string; + staleMs: number; + includeLoginStatus?: boolean; +}): Promise { + const cacheKey = `${params.machineId}:${params.includeLoginStatus ? 'login' : 'basic'}`; + const existing = getEntry(cacheKey); + if (!existing || existing.state.status === 'idle') { + return fetchAndCache({ machineId: params.machineId, includeLoginStatus: Boolean(params.includeLoginStatus) }); + } + const now = Date.now(); + const isStale = (now - existing.updatedAt) > params.staleMs; + if (isStale) { + return fetchAndCache({ machineId: params.machineId, includeLoginStatus: Boolean(params.includeLoginStatus) }); + } + return Promise.resolve(); +} + +/** + * UI-level cached wrapper around the daemon `detect-cli` RPC. + * + * - Per-machine cache with TTL + * - "Stale while revalidate" behavior (keeps last response while loading) + * - Caller controls whether fetching is enabled (e.g. only for online machines) + */ +export function useMachineDetectCliCache(params: { + machineId: string | null; + enabled: boolean; + staleMs?: number; + includeLoginStatus?: boolean; +}): { state: MachineDetectCliCacheState; refresh: () => void } { + const { machineId, enabled, staleMs = DEFAULT_STALE_MS, includeLoginStatus = false } = params; + const cacheKey = machineId ? `${machineId}:${includeLoginStatus ? 'login' : 'basic'}` : null; + + const [state, setState] = React.useState(() => { + if (!cacheKey) return { status: 'idle' }; + const entry = getEntry(cacheKey); + return entry?.state ?? { status: 'idle' }; + }); + + const refresh = React.useCallback(() => { + if (!machineId) return; + // Update local state immediately (e.g. to show loading UI) since fetchAndCache + // synchronously sets the cache entry to { status: 'loading', ... }. + void fetchAndCache({ machineId, includeLoginStatus }); + const next = cacheKey ? getEntry(cacheKey) : null; + if (next) setState(next.state); + const inFlight = next?.inFlight; + if (inFlight) { + void inFlight.finally(() => { + const entry = cacheKey ? getEntry(cacheKey) : null; + if (entry) setState(entry.state); + }); + } + }, [cacheKey, includeLoginStatus, machineId]); + + React.useEffect(() => { + if (!cacheKey) { + setState({ status: 'idle' }); + return; + } + + const unsubscribe = subscribe(cacheKey, (nextState) => { + setState(nextState); + }); + + const entry = getEntry(cacheKey); + if (entry) { + setState(entry.state); + } + + if (!enabled) { + return unsubscribe; + } + + const now = Date.now(); + const shouldFetch = !entry || (now - entry.updatedAt) > staleMs; + if (!shouldFetch) { + return unsubscribe; + } + + refresh(); + return unsubscribe; + }, [cacheKey, enabled, refresh, staleMs]); + + return { state, refresh }; +} + diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index 921fdec19..7f83cd456 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -240,6 +240,8 @@ export async function machineBash( export interface DetectCliEntry { available: boolean; resolvedPath?: string; + version?: string; + isLoggedIn?: boolean | null; } export interface DetectCliResponse { @@ -249,45 +251,49 @@ export interface DetectCliResponse { export type MachineDetectCliResult = | { supported: true; response: DetectCliResponse } - | { supported: false }; + | { supported: false; reason: 'not-supported' | 'error' }; /** * Query daemon CLI availability using a dedicated RPC (preferred). * * Falls back to `{ supported: false }` for older daemons that don't implement it. */ -export async function machineDetectCli(machineId: string): Promise { +export async function machineDetectCli(machineId: string, params?: { includeLoginStatus?: boolean }): Promise { try { const result = await apiSocket.machineRPC( machineId, 'detect-cli', - {} + { ...(params?.includeLoginStatus ? { includeLoginStatus: true } : {}) } ); if (isPlainObject(result) && typeof result.error === 'string') { // Older daemons (or errors) return an encrypted `{ error: ... }` payload. if (result.error === 'Method not found') { - return { supported: false }; + return { supported: false, reason: 'not-supported' }; } - return { supported: false }; + return { supported: false, reason: 'error' }; } if (!isPlainObject(result)) { - return { supported: false }; + return { supported: false, reason: 'error' }; } const clisRaw = result.clis; if (!isPlainObject(clisRaw)) { - return { supported: false }; + return { supported: false, reason: 'error' }; } const getEntry = (name: 'claude' | 'codex' | 'gemini'): DetectCliEntry | null => { const raw = (clisRaw as Record)[name]; if (!isPlainObject(raw) || typeof raw.available !== 'boolean') return null; const resolvedPath = raw.resolvedPath; + const version = raw.version; + const isLoggedInRaw = (raw as any).isLoggedIn; return { available: raw.available, ...(typeof resolvedPath === 'string' ? { resolvedPath } : {}), + ...(typeof version === 'string' ? { version } : {}), + ...((typeof isLoggedInRaw === 'boolean' || isLoggedInRaw === null) ? { isLoggedIn: isLoggedInRaw } : {}), }; }; @@ -295,7 +301,7 @@ export async function machineDetectCli(machineId: string): Promise Date: Sun, 18 Jan 2026 22:19:44 +0100 Subject: [PATCH 27/38] feat(profiles): add API key requirements flow --- sources/app/(app)/settings/profiles.tsx | 232 ++------- sources/components/ApiKeyRequirementModal.tsx | 348 ++++++++++++++ sources/components/OptionTiles.tsx | 119 +++++ sources/components/ProfileEditForm.tsx | 292 ++++++++++-- .../components/ProfileRequirementsBadge.tsx | 91 ++++ sources/components/profiles/ProfilesList.tsx | 443 ++++++++++++++++++ .../profiles/profileListModel.test.ts | 37 ++ .../components/profiles/profileListModel.ts | 59 +++ sources/hooks/useProfileEnvRequirements.ts | 80 ++++ sources/sync/profileSecrets.ts | 12 + sources/sync/profileUtils.ts | 24 +- 11 files changed, 1515 insertions(+), 222 deletions(-) create mode 100644 sources/components/ApiKeyRequirementModal.tsx create mode 100644 sources/components/OptionTiles.tsx create mode 100644 sources/components/ProfileRequirementsBadge.tsx create mode 100644 sources/components/profiles/ProfilesList.tsx create mode 100644 sources/components/profiles/profileListModel.test.ts create mode 100644 sources/components/profiles/profileListModel.ts create mode 100644 sources/hooks/useProfileEnvRequirements.ts create mode 100644 sources/sync/profileSecrets.ts diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 38cdcf8c4..89efc87ab 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -14,13 +14,11 @@ import { ProfileEditForm } from '@/components/ProfileEditForm'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { ItemRowActions } from '@/components/ItemRowActions'; -import { buildProfileActions } from '@/components/profileActions'; import { Switch } from '@/components/Switch'; -import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; -import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { useSetting } from '@/sync/storage'; +import { ProfilesList } from '@/components/profiles/ProfilesList'; +import { ApiKeyRequirementModal, type ApiKeyRequirementModalResult } from '@/components/ApiKeyRequirementModal'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -29,9 +27,8 @@ interface ProfileManagerProps { // Profile utilities now imported from @/sync/profileUtils const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { - const { theme, rt } = useUnistyles(); + const { theme } = useUnistyles(); const navigation = useNavigation(); - const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); @@ -42,6 +39,32 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel const isEditingDirtyRef = React.useRef(false); const saveRef = React.useRef<(() => boolean) | null>(null); const experimentsEnabled = useSetting('experiments'); + const [apiKeys, setApiKeys] = useSettingMutable('apiKeys'); + const [defaultApiKeyByProfileId, setDefaultApiKeyByProfileId] = useSettingMutable('defaultApiKeyByProfileId'); + + const openApiKeyModal = React.useCallback((profile: AIBackendProfile) => { + const handleResolve = (result: ApiKeyRequirementModalResult) => { + if (result.action !== 'selectSaved') return; + setDefaultApiKeyByProfileId({ + ...defaultApiKeyByProfileId, + [profile.id]: result.apiKeyId, + }); + }; + + Modal.show({ + component: ApiKeyRequirementModal, + props: { + profile, + machineId: null, + apiKeys, + defaultApiKeyId: defaultApiKeyByProfileId[profile.id] ?? null, + onChangeApiKeys: setApiKeys, + allowSessionOnly: false, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' } as ApiKeyRequirementModalResult), + }, + }); + }, [apiKeys, defaultApiKeyByProfileId, setDefaultApiKeyByProfileId]); React.useEffect(() => { isEditingDirtyRef.current = isEditingDirty; @@ -189,37 +212,6 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setLastUsedProfile(profileId); }; - const { - favoriteProfiles: favoriteProfileItems, - customProfiles: nonFavoriteCustomProfiles, - builtInProfiles: nonFavoriteBuiltInProfiles, - favoriteIds: favoriteProfileIdSet, - } = React.useMemo(() => { - return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); - }, [favoriteProfileIds, profiles]); - - const toggleFavoriteProfile = (profileId: string) => { - setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); - }; - - const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { - const parts: string[] = []; - if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); - if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); - if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); - return parts.length > 0 ? parts.join(' • ') : ''; - }, [experimentsEnabled]); - - const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { - const backend = getProfileBackendSubtitle(profile); - if (profile.isBuiltIn) { - const builtInLabel = t('profiles.builtIn'); - return backend ? `${builtInLabel} · ${backend}` : builtInLabel; - } - const customLabel = t('profiles.custom'); - return backend ? `${customLabel} · ${backend}` : customLabel; - }, [getProfileBackendSubtitle]); - function handleSaveProfile(profile: AIBackendProfile): boolean { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { @@ -309,157 +301,21 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel return ( - - {favoriteProfileItems.length > 0 && ( - - {favoriteProfileItems.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: selectedIndicatorColor, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => handleEditProfile(profile), - onDuplicate: () => handleDuplicateProfile(profile), - onDelete: () => { void handleDeleteProfile(profile); }, - }); - return ( - } - onPress={() => handleEditProfile(profile)} - showChevron={false} - selected={isSelected} - rightElement={( - - - - - - - )} - /> - ); - })} - - )} - - {nonFavoriteCustomProfiles.length > 0 && ( - - {nonFavoriteCustomProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: selectedIndicatorColor, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => handleEditProfile(profile), - onDuplicate: () => handleDuplicateProfile(profile), - onDelete: () => { void handleDeleteProfile(profile); }, - }); - return ( - } - onPress={() => handleEditProfile(profile)} - showChevron={false} - selected={isSelected} - rightElement={( - - - - - - - )} - /> - ); - })} - - )} - - - {nonFavoriteBuiltInProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: selectedIndicatorColor, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => handleEditProfile(profile), - onDuplicate: () => handleDuplicateProfile(profile), - }); - return ( - } - onPress={() => handleEditProfile(profile)} - showChevron={false} - selected={isSelected} - rightElement={( - - - - - - - )} - /> - ); - })} - - - - } - onPress={handleAddProfile} - showChevron={false} - /> - - + handleEditProfile(profile)} + machineId={null} + includeAddProfileRow + onAddProfilePress={handleAddProfile} + onEditProfile={(profile) => handleEditProfile(profile)} + onDuplicateProfile={(profile) => handleDuplicateProfile(profile)} + onDeleteProfile={(profile) => { void handleDeleteProfile(profile); }} + onApiKeyBadgePress={openApiKeyModal} + /> {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( diff --git a/sources/components/ApiKeyRequirementModal.tsx b/sources/components/ApiKeyRequirementModal.tsx new file mode 100644 index 000000000..bfc773d03 --- /dev/null +++ b/sources/components/ApiKeyRequirementModal.tsx @@ -0,0 +1,348 @@ +import React from 'react'; +import { View, Text, Pressable, TextInput, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import type { AIBackendProfile, SavedApiKey } from '@/sync/settings'; +import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; +import { getRequiredSecretEnvVarName } from '@/sync/profileSecrets'; +import { useProfileEnvRequirements } from '@/hooks/useProfileEnvRequirements'; +import { ApiKeysList } from '@/components/apiKeys/ApiKeysList'; +import { ItemListStatic } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { useMachine } from '@/sync/storage'; +import { isMachineOnline } from '@/utils/machineUtils'; +import { OptionTiles } from '@/components/OptionTiles'; + +const apiKeyRequirementSelectionMemory = new Map(); + +export type ApiKeyRequirementModalResult = + | { action: 'cancel' } + | { action: 'useMachine' } + | { action: 'selectSaved'; apiKeyId: string; setDefault: boolean } + | { action: 'enterOnce'; value: string }; + +export interface ApiKeyRequirementModalProps { + profile: AIBackendProfile; + machineId: string | null; + apiKeys: SavedApiKey[]; + defaultApiKeyId: string | null; + onChangeApiKeys?: (next: SavedApiKey[]) => void; + onResolve: (result: ApiKeyRequirementModalResult) => void; + onClose: () => void; + /** + * Optional hook invoked when the modal is dismissed (e.g. backdrop tap). + * Used by the modal host to route dismiss -> cancel. + */ + onRequestClose?: () => void; + allowSessionOnly?: boolean; +} + +export function ApiKeyRequirementModal(props: ApiKeyRequirementModalProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const requiredSecretName = React.useMemo(() => getRequiredSecretEnvVarName(props.profile), [props.profile]); + const requirements = useProfileEnvRequirements(props.machineId, props.machineId ? props.profile : null); + const machine = useMachine(props.machineId ?? ''); + + const [sessionOnlyValue, setSessionOnlyValue] = React.useState(''); + const selectionKey = `${props.profile.id}:${props.machineId ?? 'no-machine'}`; + const [selectedSource, setSelectedSource] = React.useState<'machine' | 'saved' | 'once' | null>(() => { + return apiKeyRequirementSelectionMemory.get(selectionKey) ?? null; + }); + + const machineIsConfigured = requirements.isLoading ? null : requirements.isReady; + + const machineName = React.useMemo(() => { + if (!props.machineId) return null; + if (!machine) return props.machineId; + return machine.metadata?.displayName || machine.metadata?.host || machine.id; + }, [machine, props.machineId]); + + const machineNameColor = React.useMemo(() => { + if (!props.machineId) return theme.colors.textSecondary; + if (!machine) return theme.colors.textSecondary; + return isMachineOnline(machine) ? theme.colors.status.connected : theme.colors.status.disconnected; + }, [machine, props.machineId, theme.colors.status.connected, theme.colors.status.disconnected, theme.colors.textSecondary]); + + const allowedSources = React.useMemo(() => { + const sources: Array<'machine' | 'saved' | 'once'> = []; + if (props.machineId) sources.push('machine'); + sources.push('saved'); + if (props.allowSessionOnly !== false) sources.push('once'); + return sources; + }, [props.allowSessionOnly, props.machineId]); + + React.useEffect(() => { + if (selectedSource && allowedSources.includes(selectedSource)) return; + // Default selection: + // - If we have a machine, recommend machine env first. + // - Otherwise, default to saved keys. + setSelectedSource(props.machineId ? 'machine' : 'saved'); + }, [allowedSources, props.machineId, selectedSource]); + + React.useEffect(() => { + if (!selectedSource) return; + apiKeyRequirementSelectionMemory.set(selectionKey, selectedSource); + }, [selectionKey, selectedSource]); + + const machineEnvTitle = React.useMemo(() => { + const envName = requiredSecretName ?? t('profiles.requirements.apiKeyRequired'); + if (!props.machineId) return t('profiles.requirements.machineEnvStatus.checkFor', { env: envName }); + const target = machineName ?? t('profiles.requirements.machineEnvStatus.theMachine'); + if (requirements.isLoading) return t('profiles.requirements.machineEnvStatus.checking', { env: envName }); + if (machineIsConfigured) return t('profiles.requirements.machineEnvStatus.found', { env: envName, machine: target }); + return t('profiles.requirements.machineEnvStatus.notFound', { env: envName, machine: target }); + }, [machineIsConfigured, machineName, props.machineId, requirements.isLoading, requiredSecretName]); + + const machineEnvSubtitle = React.useMemo(() => { + if (!props.machineId) return undefined; + if (requirements.isLoading) return t('profiles.requirements.machineEnvSubtitle.checking'); + if (machineIsConfigured) return t('profiles.requirements.machineEnvSubtitle.found'); + return t('profiles.requirements.machineEnvSubtitle.notFound'); + }, [machineIsConfigured, props.machineId, requirements.isLoading]); + + return ( + + + + + {t('profiles.requirements.modalTitle')} + + + {props.profile.name} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + + {requiredSecretName + ? t('profiles.requirements.modalHelpWithEnv', { env: requiredSecretName }) + : t('profiles.requirements.modalHelpGeneric')} + + + {t('profiles.requirements.modalRecommendation')} + + + + + + setSelectedSource(next)} + /> + + + {selectedSource === 'machine' && props.machineId && ( + + + } + showChevron={false} + showDivider={machineIsConfigured === true} + /> + {machineIsConfigured === true && ( + } + onPress={() => { + props.onResolve({ action: 'useMachine' }); + props.onClose(); + }} + showChevron={false} + showDivider={false} + /> + )} + + )} + + {selectedSource === 'saved' && ( + props.onChangeApiKeys?.(next)} + allowAdd={Boolean(props.onChangeApiKeys)} + allowEdit + title={t('apiKeys.savedTitle')} + footer={null} + defaultId={props.defaultApiKeyId} + onSetDefaultId={(id) => { + if (!id) return; + props.onResolve({ action: 'selectSaved', apiKeyId: id, setDefault: true }); + props.onClose(); + }} + selectedId={''} + onSelectId={(id) => { + if (!id) return; + props.onResolve({ action: 'selectSaved', apiKeyId: id, setDefault: false }); + props.onClose(); + }} + onAfterAddSelectId={(id) => { + props.onResolve({ action: 'selectSaved', apiKeyId: id, setDefault: false }); + props.onClose(); + }} + /> + )} + + {selectedSource === 'once' && props.allowSessionOnly !== false && ( + + + + + { + const v = sessionOnlyValue.trim(); + if (!v) return; + props.onResolve({ action: 'enterOnce', value: v }); + props.onClose(); + }} + style={({ pressed }) => [ + styles.primaryButton, + { + opacity: !sessionOnlyValue.trim() ? 0.5 : (pressed ? 0.85 : 1), + backgroundColor: theme.colors.button.primary.background, + }, + ]} + > + + {t('profiles.requirements.actions.useOnceButton')} + + + + + )} + + + + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + width: '92%', + maxWidth: 560, + backgroundColor: theme.colors.groupped.background, + borderRadius: 16, + overflow: 'hidden', + borderWidth: 1, + borderColor: theme.colors.divider, + flexShrink: 1, + paddingBottom: 18, + }, + header: { + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + paddingTop: 14, + paddingBottom: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + headerTitle: { + fontSize: 16, + fontWeight: '700', + color: theme.colors.text, + ...Typography.default('semiBold'), + }, + headerSubtitle: { + fontSize: 12, + color: theme.colors.textSecondary, + marginTop: 2, + ...Typography.default(), + }, + body: { + // Don't use flex here: in portal-mode the modal should size to content. + }, + helpContainer: { + width: '100%', + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + paddingTop: 14, + paddingBottom: 8, + alignSelf: 'center', + }, + helpText: { + fontSize: 13, + color: theme.colors.textSecondary, + lineHeight: 18, + ...Typography.default(), + }, + primaryButton: { + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + fontSize: 13, + fontWeight: '600', + ...Typography.default('semiBold'), + }, + textInput: { + backgroundColor: theme.colors.input.background, + borderRadius: 10, + borderWidth: 1, + borderColor: theme.colors.divider, + paddingHorizontal: 12, + paddingVertical: 10, + color: theme.colors.text, + ...Typography.default(), + }, +})); diff --git a/sources/components/OptionTiles.tsx b/sources/components/OptionTiles.tsx new file mode 100644 index 000000000..41bc78244 --- /dev/null +++ b/sources/components/OptionTiles.tsx @@ -0,0 +1,119 @@ +import 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'; + +export interface OptionTile { + id: T; + title: string; + subtitle?: string; + icon?: React.ComponentProps['name']; +} + +export interface OptionTilesProps { + options: Array>; + value: T | null; + onChange: (next: T | null) => void; +} + +export function OptionTiles(props: OptionTilesProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const [width, setWidth] = React.useState(0); + const columns = React.useMemo(() => { + // Avoid the awkward 2+1 layout for 3 options. + if (props.options.length === 3) { + return width >= 560 ? 3 : 1; + } + if (width >= 640) return Math.min(3, props.options.length); + if (width >= 420) return Math.min(2, props.options.length); + return 1; + }, [props.options.length, width]); + + const gap = 10; + const tileWidth = React.useMemo(() => { + if (width <= 0) return undefined; + const totalGap = gap * (columns - 1); + return Math.floor((width - totalGap) / columns); + }, [columns, width]); + + return ( + setWidth(e.nativeEvent.layout.width)} + style={[ + styles.grid, + { flexDirection: 'row', flexWrap: 'wrap', gap }, + ]} + > + {props.options.map((opt) => { + const selected = props.value === opt.id; + return ( + props.onChange(opt.id)} + style={({ pressed }) => [ + styles.tile, + tileWidth ? { width: tileWidth } : null, + { + borderColor: selected ? theme.colors.button.primary.background : theme.colors.divider, + opacity: pressed ? 0.85 : 1, + }, + ]} + > + + + + + + {opt.title} + {opt.subtitle ? ( + {opt.subtitle} + ) : null} + + + + ); + })} + + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + grid: { + // Intentionally transparent: this component is meant to sit directly on + // the screen/group background (so gutters are visible between tiles). + }, + tile: { + backgroundColor: theme.colors.surface, + borderRadius: 12, + borderWidth: 2, + padding: 12, + paddingHorizontal: 16, + }, + iconSlot: { + width: 29, + height: 29, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + }, + subtitle: { + marginTop: 4, + fontSize: 12, + color: theme.colors.textSecondary, + lineHeight: 16, + ...Typography.default(), + }, +})); + diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 89b388b3b..ebe386574 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -19,6 +19,9 @@ import { Modal } from '@/modal'; import { MachineSelector } from '@/components/newSession/MachineSelector'; import type { Machine } from '@/sync/storageTypes'; import { isMachineOnline } from '@/utils/machineUtils'; +import { OptionTiles } from '@/components/OptionTiles'; +import { useCLIDetection } from '@/hooks/useCLIDetection'; +import { layout } from '@/components/layout'; export interface ProfileEditFormProps { profile: AIBackendProfile; @@ -109,6 +112,8 @@ export function ProfileEditForm({ const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; const styles = stylesheet; const experimentsEnabled = useSetting('experiments'); + const expGemini = useSetting('expGemini'); + const allowGemini = experimentsEnabled && expGemini; const machines = useAllMachines(); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const routeMachine = machineId; @@ -120,6 +125,7 @@ export function ProfileEditForm({ const resolvedMachineId = routeMachine ?? previewMachineId; const resolvedMachine = useMachine(resolvedMachineId ?? ''); + const cliDetection = useCLIDetection(resolvedMachineId, { includeLoginStatus: Boolean(resolvedMachineId) }); const toggleFavoriteMachineId = React.useCallback((machineIdToToggle: string) => { if (favoriteMachines.includes(machineIdToToggle)) { @@ -172,6 +178,34 @@ export function ProfileEditForm({ profile.compatibility || { claude: true, codex: true, gemini: true }, ); + const [authMode, setAuthMode] = React.useState(profile.authMode); + const [requiresMachineLogin, setRequiresMachineLogin] = React.useState(profile.requiresMachineLogin); + const [requiredEnvVars, setRequiredEnvVars] = React.useState>(profile.requiredEnvVars ?? []); + + const allowedMachineLoginOptions = React.useMemo(() => { + const options: Array<'claude-code' | 'codex' | 'gemini-cli'> = []; + if (compatibility.claude) options.push('claude-code'); + if (compatibility.codex) options.push('codex'); + if (allowGemini && compatibility.gemini) options.push('gemini-cli'); + return options; + }, [allowGemini, compatibility.claude, compatibility.codex, compatibility.gemini]); + + React.useEffect(() => { + if (authMode !== 'machineLogin') return; + // If exactly one backend is enabled, we can persist the explicit CLI requirement. + // If multiple are enabled, the required CLI is derived at session-start from the selected backend. + if (allowedMachineLoginOptions.length === 1) { + const only = allowedMachineLoginOptions[0]; + if (requiresMachineLogin !== only) { + setRequiresMachineLogin(only); + } + return; + } + if (requiresMachineLogin) { + setRequiresMachineLogin(undefined); + } + }, [allowedMachineLoginOptions, authMode, requiresMachineLogin]); + const initialSnapshotRef = React.useRef(null); if (initialSnapshotRef.current === null) { initialSnapshotRef.current = JSON.stringify({ @@ -183,6 +217,9 @@ export function ProfileEditForm({ defaultSessionType, defaultPermissionMode, compatibility, + authMode, + requiresMachineLogin, + requiredEnvVars, }); } @@ -196,14 +233,20 @@ export function ProfileEditForm({ defaultSessionType, defaultPermissionMode, compatibility, + authMode, + requiresMachineLogin, + requiredEnvVars, }); return currentSnapshot !== initialSnapshotRef.current; }, [ + authMode, compatibility, defaultPermissionMode, defaultSessionType, environmentVariables, name, + requiredEnvVars, + requiresMachineLogin, tmuxSession, tmuxTmpDir, useTmux, @@ -249,6 +292,11 @@ export function ProfileEditForm({ ...profile, name: name.trim(), environmentVariables, + authMode, + requiresMachineLogin: authMode === 'machineLogin' && allowedMachineLoginOptions.length === 1 + ? allowedMachineLoginOptions[0] + : undefined, + requiredEnvVars: authMode === 'apiKeyEnv' ? requiredEnvVars : undefined, tmuxConfig: useTmux ? { ...(profile.tmuxConfig ?? {}), @@ -262,6 +310,7 @@ export function ProfileEditForm({ updatedAt: Date.now(), }); }, [ + allowedMachineLoginOptions, compatibility, defaultPermissionMode, defaultSessionType, @@ -269,11 +318,33 @@ export function ProfileEditForm({ name, onSave, profile, + authMode, + requiredEnvVars, tmuxSession, tmuxTmpDir, useTmux, ]); + const editRequiredSecretEnvVar = React.useCallback(async () => { + const current = requiredEnvVars.find((v) => (v?.kind ?? 'secret') === 'secret')?.name ?? ''; + const name = await Modal.prompt( + t('profiles.requirements.modalTitle'), + t('profiles.requirements.secretEnvVarPromptDescription'), + { defaultValue: current, placeholder: 'OPENAI_API_KEY', cancelText: t('common.cancel'), confirmText: t('common.save') }, + ); + if (name === null) return; + const normalized = name.trim().toUpperCase(); + if (!/^[A-Z_][A-Z0-9_]*$/.test(normalized)) { + Modal.alert(t('common.error'), t('profiles.environmentVariables.validation.invalidNameFormat')); + return; + } + + setRequiredEnvVars((prev) => { + const withoutSecret = prev.filter((v) => (v?.kind ?? 'secret') !== 'secret'); + return [{ name: normalized, kind: 'secret' }, ...withoutSecret]; + }); + }, [requiredEnvVars]); + React.useEffect(() => { if (!saveRef) { return; @@ -310,6 +381,157 @@ export function ProfileEditForm({ )} + + {t('profiles.requirements.sectionTitle')} + + {t('profiles.requirements.sectionSubtitle')} + + + + + { + if (next === 'none') { + setAuthMode(undefined); + setRequiresMachineLogin(undefined); + setRequiredEnvVars([]); + return; + } + if (next === 'apiKeyEnv') { + setAuthMode('apiKeyEnv'); + setRequiresMachineLogin(undefined); + return; + } + setAuthMode('machineLogin'); + setRequiresMachineLogin(undefined); + setRequiredEnvVars([]); + }} + /> + + + {authMode === 'apiKeyEnv' && ( + + } + showChevron={false} + /> + + {t('profiles.requirements.apiKeyEnvVar.label')} + (v?.kind ?? 'secret') === 'secret')?.name ?? ''} + onChangeText={(value) => { + const normalized = value.trim().toUpperCase(); + setRequiredEnvVars((prev) => { + const withoutSecret = prev.filter((v) => (v?.kind ?? 'secret') !== 'secret'); + if (!normalized) return withoutSecret; + return [{ name: normalized, kind: 'secret' }, ...withoutSecret]; + }); + }} + placeholder="OPENAI_API_KEY" + placeholderTextColor={theme.colors.input.placeholder} + autoCapitalize="characters" + autoCorrect={false} + style={styles.textInput} + /> + + + )} + + {authMode === 'machineLogin' && ( + + } + showChevron={false} + showDivider={false} + /> + + )} + + + {(() => { + const shouldShowLoginStatus = authMode === 'machineLogin' && Boolean(resolvedMachineId); + + const renderLoginStatus = (status: boolean) => ( + + {status ? 'Logged in' : 'Not logged in'} + + ); + + const claudeDefaultSubtitle = t('profiles.aiBackend.claudeSubtitle'); + const codexDefaultSubtitle = t('profiles.aiBackend.codexSubtitle'); + const geminiDefaultSubtitle = t('profiles.aiBackend.geminiSubtitleExperimental'); + + const claudeSubtitle = shouldShowLoginStatus + ? (typeof cliDetection.login.claude === 'boolean' ? renderLoginStatus(cliDetection.login.claude) : claudeDefaultSubtitle) + : claudeDefaultSubtitle; + const codexSubtitle = shouldShowLoginStatus + ? (typeof cliDetection.login.codex === 'boolean' ? renderLoginStatus(cliDetection.login.codex) : codexDefaultSubtitle) + : codexDefaultSubtitle; + const geminiSubtitle = shouldShowLoginStatus + ? (typeof cliDetection.login.gemini === 'boolean' ? renderLoginStatus(cliDetection.login.gemini) : geminiDefaultSubtitle) + : geminiDefaultSubtitle; + + return ( + <> + } + rightElement={ toggleCompatibility('claude')} />} + showChevron={false} + onPress={() => toggleCompatibility('claude')} + /> + } + rightElement={ toggleCompatibility('codex')} />} + showChevron={false} + onPress={() => toggleCompatibility('codex')} + /> + {allowGemini && ( + } + rightElement={ toggleCompatibility('gemini')} />} + showChevron={false} + onPress={() => toggleCompatibility('gemini')} + showDivider={false} + /> + )} + + ); + })()} + + @@ -365,36 +587,6 @@ export function ProfileEditForm({ ))} - - } - rightElement={ toggleCompatibility('claude')} />} - showChevron={false} - onPress={() => toggleCompatibility('claude')} - /> - } - rightElement={ toggleCompatibility('codex')} />} - showChevron={false} - onPress={() => toggleCompatibility('codex')} - /> - {experimentsEnabled && ( - } - rightElement={ toggleCompatibility('gemini')} />} - showChevron={false} - onPress={() => toggleCompatibility('gemini')} - showDivider={false} - /> - )} - - ({ paddingHorizontal: 12, paddingBottom: 4, }, + requirementsHeader: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + paddingTop: Platform.select({ ios: 26, default: 20 }), + paddingBottom: Platform.select({ ios: 8, default: 8 }), + paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + }, + requirementsTitle: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0.1 }), + textTransform: 'uppercase', + fontWeight: Platform.select({ ios: 'normal', default: '500' }), + }, + requirementsSubtitle: { + ...Typography.default('regular'), + color: theme.colors.groupped.sectionTitle, + fontSize: Platform.select({ ios: 13, default: 14 }), + lineHeight: Platform.select({ ios: 18, default: 20 }), + letterSpacing: Platform.select({ ios: -0.08, default: 0 }), + marginTop: Platform.select({ ios: 6, default: 8 }), + }, + requirementsTilesContainer: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + paddingHorizontal: Platform.select({ ios: 16, default: 12 }), + paddingBottom: 8, + }, fieldLabel: { ...Typography.default('semiBold'), fontSize: 13, color: theme.colors.groupped.sectionTitle, - marginBottom: 8, + marginBottom: 4, + }, + aiBackendStatus: { + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), }, textInput: { ...Typography.default('regular'), diff --git a/sources/components/ProfileRequirementsBadge.tsx b/sources/components/ProfileRequirementsBadge.tsx new file mode 100644 index 000000000..f6ce89688 --- /dev/null +++ b/sources/components/ProfileRequirementsBadge.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { t } from '@/text'; +import { useProfileEnvRequirements } from '@/hooks/useProfileEnvRequirements'; +import { hasRequiredSecret } from '@/sync/profileSecrets'; + +export interface ProfileRequirementsBadgeProps { + profile: AIBackendProfile; + machineId: string | null; + onPressIn?: () => void; + onPress?: () => void; +} + +export function ProfileRequirementsBadge(props: ProfileRequirementsBadgeProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const show = hasRequiredSecret(props.profile); + const requirements = useProfileEnvRequirements(props.machineId, show ? props.profile : null); + + if (!show) { + return null; + } + + const statusColor = requirements.isLoading + ? theme.colors.status.connecting + : requirements.isReady + ? theme.colors.status.connected + : theme.colors.status.disconnected; + + const label = requirements.isReady + ? t('apiKeys.badgeReady') + : t('apiKeys.badgeRequired'); + + const iconName = requirements.isLoading + ? 'time-outline' + : requirements.isReady + ? 'checkmark-circle-outline' + : 'key-outline'; + + return ( + { + e?.stopPropagation?.(); + props.onPressIn?.(); + }} + onPress={(e) => { + e?.stopPropagation?.(); + props.onPress?.(); + }} + style={({ pressed }) => [ + styles.badge, + { + borderColor: statusColor, + opacity: pressed ? 0.85 : 1, + }, + ]} + > + + + + {label} + + + + ); +} + +const stylesheet = StyleSheet.create((theme) => ({ + badge: { + maxWidth: 140, + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: theme.colors.surface, + }, + badgeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + badgeText: { + fontSize: 12, + fontWeight: '600', + }, +})); diff --git a/sources/components/profiles/ProfilesList.tsx b/sources/components/profiles/ProfilesList.tsx new file mode 100644 index 000000000..ecc9ce9d1 --- /dev/null +++ b/sources/components/profiles/ProfilesList.tsx @@ -0,0 +1,443 @@ +import React from 'react'; +import { View, Text, Platform } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; + +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { ProfileRequirementsBadge } from '@/components/ProfileRequirementsBadge'; +import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; +import { toggleFavoriteProfileId } from '@/sync/profileGrouping'; +import { buildProfileActions } from '@/components/profileActions'; +import { getDefaultProfileListStrings, getProfileSubtitle, buildProfilesListGroups } from '@/components/profiles/profileListModel'; +import { t } from '@/text'; +import { Typography } from '@/constants/Typography'; +import { hasRequiredSecret } from '@/sync/profileSecrets'; +import { useSetting } from '@/sync/storage'; + +export interface ProfilesListProps { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + onFavoriteProfileIdsChange: (next: string[]) => void; + experimentsEnabled: boolean; + + selectedProfileId: string | null; + onPressProfile?: (profile: AIBackendProfile) => void | Promise; + onPressDefaultEnvironment?: () => void; + + machineId: string | null; + + includeDefaultEnvironmentRow?: boolean; + includeAddProfileRow?: boolean; + onAddProfilePress?: () => void; + + getProfileDisabled?: (profile: AIBackendProfile) => boolean; + getProfileSubtitleExtra?: (profile: AIBackendProfile) => string | null; + + onEditProfile?: (profile: AIBackendProfile) => void; + onDuplicateProfile?: (profile: AIBackendProfile) => void; + onDeleteProfile?: (profile: AIBackendProfile) => void; + getHasEnvironmentVariables?: (profile: AIBackendProfile) => boolean; + onViewEnvironmentVariables?: (profile: AIBackendProfile) => void; + extraActions?: (profile: AIBackendProfile) => ItemAction[]; + + onApiKeyBadgePress?: (profile: AIBackendProfile) => void; + + groupTitles?: { + favorites?: string; + custom?: string; + builtIn?: string; + }; + builtInGroupFooter?: string; +} + +type ProfileRowProps = { + profile: AIBackendProfile; + isSelected: boolean; + isFavorite: boolean; + isDisabled: boolean; + showDivider: boolean; + isMobile: boolean; + machineId: string | null; + experimentsEnabled: boolean; + subtitleText: string; + showMobileBadge: boolean; + onPressProfile?: (profile: AIBackendProfile) => void | Promise; + onApiKeyBadgePress?: (profile: AIBackendProfile) => void; + rightElement: React.ReactNode; + ignoreRowPressRef: React.MutableRefObject; +}; + +const ProfileRow = React.memo(function ProfileRow(props: ProfileRowProps) { + const theme = useUnistyles().theme; + + const subtitle = React.useMemo(() => { + if (!props.showMobileBadge) return props.subtitleText; + return ( + + + {props.subtitleText} + + + ignoreNextRowPress(props.ignoreRowPressRef)} + onPress={() => { + props.onApiKeyBadgePress?.(props.profile); + }} + /> + + + ); + }, [props.ignoreRowPressRef, props.machineId, props.onApiKeyBadgePress, props.profile, props.showMobileBadge, props.subtitleText, theme.colors.textSecondary]); + + const onPress = React.useCallback(() => { + if (props.isDisabled) return; + if (props.ignoreRowPressRef.current) { + props.ignoreRowPressRef.current = false; + return; + } + void props.onPressProfile?.(props.profile); + }, [props.ignoreRowPressRef, props.isDisabled, props.onPressProfile, props.profile]); + + return ( + } + showChevron={false} + selected={props.isSelected} + disabled={props.isDisabled} + onPress={onPress} + rightElement={props.rightElement} + showDivider={props.showDivider} + /> + ); +}); + +export function ProfilesList(props: ProfilesListProps) { + const { theme, rt } = useUnistyles(); + const strings = React.useMemo(() => getDefaultProfileListStrings(), []); + const expGemini = useSetting('expGemini'); + const allowGemini = props.experimentsEnabled && expGemini; + const { + extraActions, + getHasEnvironmentVariables, + onDeleteProfile, + onDuplicateProfile, + onEditProfile, + onViewEnvironmentVariables, + } = props; + + const ignoreRowPressRef = React.useRef(false); + const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const isMobile = Platform.OS === 'ios' || Platform.OS === 'android'; + + const groups = React.useMemo(() => { + return buildProfilesListGroups({ customProfiles: props.customProfiles, favoriteProfileIds: props.favoriteProfileIds }); + }, [props.customProfiles, props.favoriteProfileIds]); + + const isDefaultEnvironmentFavorite = groups.favoriteIds.has(''); + + const toggleFavorite = React.useCallback((profileId: string) => { + props.onFavoriteProfileIdsChange(toggleFavoriteProfileId(props.favoriteProfileIds, profileId)); + }, [props.favoriteProfileIds, props.onFavoriteProfileIdsChange]); + + // Precompute action arrays so selection changes don't rebuild them for every row. + const actionsByProfileId = React.useMemo(() => { + const map = new Map(); + + const build = (profile: AIBackendProfile) => { + const isFavorite = groups.favoriteIds.has(profile.id); + const hasEnvVars = getHasEnvironmentVariables ? getHasEnvironmentVariables(profile) : false; + const canViewEnvVars = hasEnvVars && Boolean(onViewEnvironmentVariables); + const actions: ItemAction[] = [ + ...(extraActions ? extraActions(profile) : []), + ...buildProfileActions({ + profile, + isFavorite, + favoriteActionColor: selectedIndicatorColor, + nonFavoriteActionColor: theme.colors.textSecondary, + onToggleFavorite: () => toggleFavorite(profile.id), + onEdit: () => onEditProfile?.(profile), + onDuplicate: () => onDuplicateProfile?.(profile), + onDelete: onDeleteProfile ? () => onDeleteProfile?.(profile) : undefined, + onViewEnvironmentVariables: canViewEnvVars ? () => onViewEnvironmentVariables?.(profile) : undefined, + }), + ]; + const compactActionIds = ['favorite', ...(canViewEnvVars ? ['envVars'] : [])]; + map.set(profile.id, { actions, compactActionIds }); + }; + + for (const p of groups.favoriteProfiles) build(p); + for (const p of groups.customProfiles) build(p); + for (const p of groups.builtInProfiles) build(p); + + return map; + }, [ + groups.builtInProfiles, + groups.customProfiles, + groups.favoriteIds, + groups.favoriteProfiles, + extraActions, + getHasEnvironmentVariables, + onDeleteProfile, + onDuplicateProfile, + onEditProfile, + onViewEnvironmentVariables, + selectedIndicatorColor, + theme.colors.textSecondary, + toggleFavorite, + ]); + + const renderDefaultEnvironmentRightElement = React.useCallback((isSelected: boolean) => { + const isFavorite = isDefaultEnvironmentFavorite; + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), + icon: isFavorite ? 'star' : 'star-outline', + onPress: () => toggleFavorite(''), + color: isFavorite ? selectedIndicatorColor : theme.colors.textSecondary, + }, + ]; + + return ( + + + + + ignoreNextRowPress(ignoreRowPressRef)} + /> + + ); + }, [isDefaultEnvironmentFavorite, selectedIndicatorColor, theme.colors.textSecondary, toggleFavorite]); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const entry = actionsByProfileId.get(profile.id); + const actions = entry?.actions ?? []; + const compactActionIds = entry?.compactActionIds ?? ['favorite']; + + return ( + + {!isMobile && ( + ignoreNextRowPress(ignoreRowPressRef)} + onPress={props.onApiKeyBadgePress ? () => { + props.onApiKeyBadgePress?.(profile); + } : undefined} + /> + )} + + + + ignoreNextRowPress(ignoreRowPressRef)} + /> + + ); + }, [ + actionsByProfileId, + isMobile, + props, + selectedIndicatorColor, + ]); + + return ( + + {(props.includeDefaultEnvironmentRow || groups.favoriteProfiles.length > 0 || isDefaultEnvironmentFavorite) && ( + + {props.includeDefaultEnvironmentRow && isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!props.selectedProfileId} + onPress={() => { + if (ignoreRowPressRef.current) { + ignoreRowPressRef.current = false; + return; + } + props.onPressDefaultEnvironment?.(); + }} + rightElement={renderDefaultEnvironmentRightElement(!props.selectedProfileId)} + showDivider={groups.favoriteProfiles.length > 0} + /> + )} + {groups.favoriteProfiles.map((profile, index) => { + const isLast = index === groups.favoriteProfiles.length - 1; + const isSelected = props.selectedProfileId === profile.id; + const isDisabled = props.getProfileDisabled ? props.getProfileDisabled(profile) : false; + const baseSubtitle = getProfileSubtitle({ profile, experimentsEnabled: allowGemini, strings }); + const extra = props.getProfileSubtitleExtra?.(profile); + const subtitleText = extra ? `${baseSubtitle} · ${extra}` : baseSubtitle; + const showMobileBadge = isMobile && hasRequiredSecret(profile) && Boolean(props.onApiKeyBadgePress); + return ( + + ); + })} + + )} + + {groups.customProfiles.length > 0 && ( + + {groups.customProfiles.map((profile, index) => { + const isLast = index === groups.customProfiles.length - 1; + const isFavorite = groups.favoriteIds.has(profile.id); + const isSelected = props.selectedProfileId === profile.id; + const isDisabled = props.getProfileDisabled ? props.getProfileDisabled(profile) : false; + const baseSubtitle = getProfileSubtitle({ profile, experimentsEnabled: allowGemini, strings }); + const extra = props.getProfileSubtitleExtra?.(profile); + const subtitleText = extra ? `${baseSubtitle} · ${extra}` : baseSubtitle; + const showMobileBadge = isMobile && hasRequiredSecret(profile) && Boolean(props.onApiKeyBadgePress); + return ( + + ); + })} + + )} + + + {props.includeDefaultEnvironmentRow && !isDefaultEnvironmentFavorite && ( + } + showChevron={false} + selected={!props.selectedProfileId} + onPress={() => { + if (ignoreRowPressRef.current) { + ignoreRowPressRef.current = false; + return; + } + props.onPressDefaultEnvironment?.(); + }} + rightElement={renderDefaultEnvironmentRightElement(!props.selectedProfileId)} + showDivider={groups.builtInProfiles.length > 0} + /> + )} + {groups.builtInProfiles.map((profile, index) => { + const isLast = index === groups.builtInProfiles.length - 1; + const isFavorite = groups.favoriteIds.has(profile.id); + const isSelected = props.selectedProfileId === profile.id; + const isDisabled = props.getProfileDisabled ? props.getProfileDisabled(profile) : false; + const baseSubtitle = getProfileSubtitle({ profile, experimentsEnabled: allowGemini, strings }); + const extra = props.getProfileSubtitleExtra?.(profile); + const subtitleText = extra ? `${baseSubtitle} · ${extra}` : baseSubtitle; + const showMobileBadge = isMobile && hasRequiredSecret(profile) && Boolean(props.onApiKeyBadgePress); + return ( + + ); + })} + + + {props.includeAddProfileRow && props.onAddProfilePress && ( + + } + onPress={props.onAddProfilePress} + showChevron={false} + showDivider={false} + /> + + )} + + ); +} + diff --git a/sources/components/profiles/profileListModel.test.ts b/sources/components/profiles/profileListModel.test.ts new file mode 100644 index 000000000..8bcf947e1 --- /dev/null +++ b/sources/components/profiles/profileListModel.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AIBackendProfile } from '@/sync/settings'; +import { getProfileBackendSubtitle, getProfileSubtitle } from '@/components/profiles/profileListModel'; + +vi.mock('@/text', () => ({ + t: (key: string) => key, +})); + +describe('profileListModel', () => { + const strings = { + builtInLabel: 'Built-in', + customLabel: 'Custom', + agentClaude: 'Claude', + agentCodex: 'Codex', + agentGemini: 'Gemini', + }; + + it('builds backend subtitle with experiments disabled', () => { + const profile = { compatibility: { claude: true, codex: true, gemini: true } } as Pick; + expect(getProfileBackendSubtitle({ profile, experimentsEnabled: false, strings })).toBe('Claude • Codex'); + }); + + it('builds backend subtitle with experiments enabled', () => { + const profile = { compatibility: { claude: true, codex: false, gemini: true } } as Pick; + expect(getProfileBackendSubtitle({ profile, experimentsEnabled: true, strings })).toBe('Claude • Gemini'); + }); + + it('builds built-in subtitle with backend', () => { + const profile = { isBuiltIn: true, compatibility: { claude: true, codex: false, gemini: false } } as Pick; + expect(getProfileSubtitle({ profile, experimentsEnabled: false, strings })).toBe('Built-in · Claude'); + }); + + it('builds custom subtitle without backend', () => { + const profile = { isBuiltIn: false, compatibility: { claude: false, codex: false, gemini: false } } as Pick; + expect(getProfileSubtitle({ profile, experimentsEnabled: true, strings })).toBe('Custom'); + }); +}); diff --git a/sources/components/profiles/profileListModel.ts b/sources/components/profiles/profileListModel.ts new file mode 100644 index 000000000..6cba83cec --- /dev/null +++ b/sources/components/profiles/profileListModel.ts @@ -0,0 +1,59 @@ +import type { AIBackendProfile } from '@/sync/settings'; +import { buildProfileGroups, type ProfileGroups } from '@/sync/profileGrouping'; +import { t } from '@/text'; + +export interface ProfileListStrings { + builtInLabel: string; + customLabel: string; + agentClaude: string; + agentCodex: string; + agentGemini: string; +} + +export function getDefaultProfileListStrings(): ProfileListStrings { + return { + builtInLabel: t('profiles.builtIn'), + customLabel: t('profiles.custom'), + agentClaude: t('agentInput.agent.claude'), + agentCodex: t('agentInput.agent.codex'), + agentGemini: t('agentInput.agent.gemini'), + }; +} + +export function getProfileBackendSubtitle(params: { + profile: Pick; + experimentsEnabled: boolean; + strings: ProfileListStrings; +}): string { + const parts: string[] = []; + if (params.profile.compatibility?.claude) parts.push(params.strings.agentClaude); + if (params.profile.compatibility?.codex) parts.push(params.strings.agentCodex); + if (params.experimentsEnabled && params.profile.compatibility?.gemini) parts.push(params.strings.agentGemini); + return parts.length > 0 ? parts.join(' • ') : ''; +} + +export function getProfileSubtitle(params: { + profile: Pick; + experimentsEnabled: boolean; + strings: ProfileListStrings; +}): string { + const backend = getProfileBackendSubtitle({ + profile: params.profile, + experimentsEnabled: params.experimentsEnabled, + strings: params.strings, + }); + + const label = params.profile.isBuiltIn ? params.strings.builtInLabel : params.strings.customLabel; + return backend ? `${label} · ${backend}` : label; +} + +export function buildProfilesListGroups(params: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; +}): ProfileGroups { + return buildProfileGroups({ + customProfiles: params.customProfiles, + favoriteProfileIds: params.favoriteProfileIds, + }); +} + diff --git a/sources/hooks/useProfileEnvRequirements.ts b/sources/hooks/useProfileEnvRequirements.ts new file mode 100644 index 000000000..baf9930d9 --- /dev/null +++ b/sources/hooks/useProfileEnvRequirements.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; + +import type { AIBackendProfile } from '@/sync/settings'; +import { getProfileEnvironmentVariables } from '@/sync/settings'; +import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; + +export interface ProfileEnvRequirement { + name: string; + kind: 'secret' | 'config'; +} + +export interface ProfileEnvRequirementsResult { + required: ProfileEnvRequirement[]; + isReady: boolean; + isLoading: boolean; + isPreviewEnvSupported: boolean; + policy: 'none' | 'redacted' | 'full' | null; + /** + * Per-key presence info returned by daemon (never rely on raw value for secrets). + */ + meta: Record; +} + +/** + * Preflight-check a profile's required env vars on a specific machine using the daemon's `preview-env` RPC. + * + * - Uses `extraEnv = getProfileEnvironmentVariables(profile)` so the preview matches spawn-time expansion. + * - Marks required secret keys as sensitive so they are never fetched into UI memory via fallback probing. + */ +export function useProfileEnvRequirements( + machineId: string | null, + profile: AIBackendProfile | null | undefined, +): ProfileEnvRequirementsResult { + const required = useMemo(() => { + const raw = profile?.requiredEnvVars ?? []; + return raw.map((v) => ({ + name: v.name, + kind: v.kind ?? 'secret', + })); + }, [profile?.requiredEnvVars]); + + const keysToQuery = useMemo(() => required.map((r) => r.name), [required]); + const sensitiveKeys = useMemo(() => required.filter((r) => r.kind === 'secret').map((r) => r.name), [required]); + const extraEnv = useMemo(() => (profile ? getProfileEnvironmentVariables(profile) : undefined), [profile]); + + const { meta, policy, isLoading, isPreviewEnvSupported } = useEnvironmentVariables(machineId, keysToQuery, { + extraEnv, + sensitiveKeys, + }); + + const isReady = useMemo(() => { + if (required.length === 0) return true; + return required.every((req) => Boolean(meta[req.name]?.isSet)); + }, [meta, required]); + + const metaSummary = useMemo(() => { + return Object.fromEntries( + required.map((req) => { + const entry = meta[req.name]; + return [ + req.name, + { + isSet: Boolean(entry?.isSet), + display: entry?.display ?? 'unset', + }, + ] as const; + }), + ); + }, [meta, required]); + + return { + required, + isReady, + isLoading, + isPreviewEnvSupported, + policy, + meta: metaSummary, + }; +} + diff --git a/sources/sync/profileSecrets.ts b/sources/sync/profileSecrets.ts new file mode 100644 index 000000000..7c1b5a161 --- /dev/null +++ b/sources/sync/profileSecrets.ts @@ -0,0 +1,12 @@ +import type { AIBackendProfile } from '@/sync/settings'; + +export function getRequiredSecretEnvVarName(profile: AIBackendProfile | null | undefined): string | null { + const required = profile?.requiredEnvVars ?? []; + const secret = required.find((v) => (v?.kind ?? 'secret') === 'secret'); + return typeof secret?.name === 'string' && secret.name.length > 0 ? secret.name : null; +} + +export function hasRequiredSecret(profile: AIBackendProfile | null | undefined): boolean { + return Boolean(getRequiredSecretEnvVarName(profile)); +} + diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index ca04c41bb..3ddbce329 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -66,10 +66,15 @@ export const getBuiltInProfileDocumentation = (id: string): ProfileDocumentation switch (id) { case 'anthropic': return { - description: 'Official Anthropic Claude API - uses your default Anthropic credentials', + description: 'Official Anthropic backend (Claude Code). Requires being logged in on the selected machine.', environmentVariables: [], - shellConfigExample: `# No additional environment variables needed -# Uses ANTHROPIC_AUTH_TOKEN from your login session`, + shellConfigExample: `# No additional environment variables needed. +# Make sure you are logged in to Claude Code on the target machine: +# 1) Run: claude +# 2) Then run: /login +# +# If you want to use an API key instead of CLI login, set: +# export ANTHROPIC_AUTH_TOKEN="sk-..."`, }; case 'deepseek': return { @@ -284,6 +289,8 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'anthropic', name: 'Anthropic (Default)', + authMode: 'machineLogin', + requiresMachineLogin: 'claude-code', environmentVariables: [], defaultPermissionMode: 'default', compatibility: { claude: true, codex: false, gemini: false }, @@ -301,6 +308,8 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'deepseek', name: 'DeepSeek (Reasoner)', + authMode: 'apiKeyEnv', + requiredEnvVars: [{ name: 'DEEPSEEK_AUTH_TOKEN', kind: 'secret' }], 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 @@ -326,6 +335,8 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'zai', name: 'Z.AI (GLM-4.6)', + authMode: 'apiKeyEnv', + requiredEnvVars: [{ name: 'Z_AI_AUTH_TOKEN', kind: 'secret' }], 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 @@ -346,6 +357,8 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'openai', name: 'OpenAI (GPT-5)', + authMode: 'apiKeyEnv', + requiredEnvVars: [{ name: 'OPENAI_API_KEY', kind: 'secret' }], environmentVariables: [ { name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' }, @@ -364,6 +377,11 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'azure-openai', name: 'Azure OpenAI', + authMode: 'apiKeyEnv', + requiredEnvVars: [ + { name: 'AZURE_OPENAI_API_KEY', kind: 'secret' }, + { name: 'AZURE_OPENAI_ENDPOINT', kind: 'config' }, + ], environmentVariables: [ { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, { name: 'AZURE_OPENAI_DEPLOYMENT_NAME', value: 'gpt-5-codex' }, From e30e6bb1dfb112d3b9fa2233227a16e5fca0369b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:19:57 +0100 Subject: [PATCH 28/38] refactor(i18n): update translations and tooling --- sources/scripts/compareTranslations.ts | 8 +- sources/scripts/findUntranslatedLiterals.ts | 253 +++++++++++++++ sources/text/translations/ca.ts | 311 +++++++++++++++++- sources/text/translations/en.ts | 279 +++++++++++++++++ sources/text/translations/es.ts | 311 +++++++++++++++++- sources/text/translations/it.ts | 301 +++++++++++++++++- sources/text/translations/ja.ts | 305 +++++++++++++++++- sources/text/translations/pl.ts | 321 +++++++++++++++++-- sources/text/translations/pt.ts | 315 +++++++++++++++++-- sources/text/translations/ru.ts | 331 ++++++++++++++++++-- sources/text/translations/zh-Hans.ts | 321 +++++++++++++++++-- 11 files changed, 2913 insertions(+), 143 deletions(-) create mode 100644 sources/scripts/findUntranslatedLiterals.ts diff --git a/sources/scripts/compareTranslations.ts b/sources/scripts/compareTranslations.ts index 6e740716c..5f5316a53 100644 --- a/sources/scripts/compareTranslations.ts +++ b/sources/scripts/compareTranslations.ts @@ -14,8 +14,10 @@ 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 { it } from '../text/translations/it'; import { ca } from '../text/translations/ca'; import { zhHans } from '../text/translations/zh-Hans'; +import { ja } from '../text/translations/ja'; const translations = { en, @@ -23,8 +25,10 @@ const translations = { pl, es, pt, + it, ca, 'zh-Hans': zhHans, + ja, }; const languageNames: Record = { @@ -33,8 +37,10 @@ const languageNames: Record = { pl: 'Polish', es: 'Spanish', pt: 'Portuguese', + it: 'Italian', ca: 'Catalan', 'zh-Hans': 'Chinese (Simplified)', + ja: 'Japanese', }; // Function to recursively extract all keys from an object @@ -214,4 +220,4 @@ for (const key of sampleKeys) { console.log(`- **${languageNames[langCode]}**: ${typeof value === 'string' ? `"${value}"` : '(function)'}`); } console.log(''); -} \ No newline at end of file +} diff --git a/sources/scripts/findUntranslatedLiterals.ts b/sources/scripts/findUntranslatedLiterals.ts new file mode 100644 index 000000000..cd0351449 --- /dev/null +++ b/sources/scripts/findUntranslatedLiterals.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env tsx + +import * as fs from 'fs'; +import * as path from 'path'; +import ts from 'typescript'; + +type Finding = { + file: string; + line: number; + col: number; + kind: 'jsx-text' | 'jsx-attr' | 'call-arg'; + text: string; + context: string; +}; + +const projectRoot = path.resolve(__dirname, '../..'); +const sourcesRoot = path.join(projectRoot, 'sources'); + +const EXCLUDE_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + 'coverage', +]); + +function isUnder(dir: string, filePath: string): boolean { + const rel = path.relative(dir, filePath); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function walk(dir: string, out: string[]) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDE_DIRS.has(entry.name)) continue; + walk(full, out); + continue; + } + if (!entry.isFile()) continue; + if (!/\.(ts|tsx|js|jsx)$/.test(entry.name)) continue; + out.push(full); + } +} + +function getLineAndCol(sourceFile: ts.SourceFile, pos: number): { line: number; col: number } { + const lc = sourceFile.getLineAndCharacterOfPosition(pos); + return { line: lc.line + 1, col: lc.character + 1 }; +} + +function normalizeText(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} + +function shouldIgnoreLiteral(text: string): boolean { + const t = normalizeText(text); + if (!t) return true; + + // Likely not user-facing / or intentionally not translated + if (t.startsWith('http://') || t.startsWith('https://')) return true; + if (/^[A-Z0-9_]{3,}$/.test(t)) return true; // ENV keys, constants + if (/^[a-z0-9._/-]+$/.test(t) && t.length <= 32) return true; // ids/paths/slugs + if (/^#[0-9a-f]{3,8}$/i.test(t)) return true; + if (/^\d+(\.\d+)*$/.test(t)) return true; + + // Single punctuation / trivial + if (/^[•·\-\u2013\u2014]+$/.test(t)) return true; + + return false; +} + +const USER_FACING_ATTRS = new Set([ + 'title', + 'subtitle', + 'description', + 'message', + 'label', + 'placeholder', + 'hint', + 'helperText', + 'emptyTitle', + 'emptyDescription', + 'confirmText', + 'cancelText', + 'text', + 'header', +]); + +function isTCall(node: ts.Node): boolean { + if (!ts.isCallExpression(node)) return false; + if (ts.isIdentifier(node.expression)) return node.expression.text === 't'; + return false; +} + +function getNodeText(sourceFile: ts.SourceFile, node: ts.Node): string { + return sourceFile.text.slice(node.getStart(sourceFile), node.getEnd()); +} + +function takeContextLine(source: string, line: number): string { + const lines = source.split(/\r?\n/); + return lines[Math.max(0, Math.min(lines.length - 1, line - 1))]?.trim() ?? ''; +} + +function scanFile(filePath: string): Finding[] { + const rel = path.relative(projectRoot, filePath); + + // Ignore translation sources and scripts + if (rel.includes(`sources${path.sep}text${path.sep}translations${path.sep}`)) return []; + if (rel.includes(`sources${path.sep}text${path.sep}_default`)) return []; + if (rel.includes(`sources${path.sep}scripts${path.sep}`)) return []; + + const sourceText = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, filePath.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + + const findings: Finding[] = []; + + const visit = (node: ts.Node) => { + // JSX text nodes: Some string + if (ts.isJsxText(node)) { + const value = normalizeText(node.getText(sourceFile)); + if (value && !shouldIgnoreLiteral(value)) { + const { line, col } = getLineAndCol(sourceFile, node.getStart(sourceFile)); + findings.push({ + file: rel, + line, + col, + kind: 'jsx-text', + text: value, + context: takeContextLine(sourceText, line), + }); + } + } + + // JSX attributes: title="Some" + if (ts.isJsxAttribute(node) && node.initializer) { + const attrName = node.name.getText(sourceFile); + if (USER_FACING_ATTRS.has(attrName)) { + const init = node.initializer; + if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) { + const value = normalizeText(init.text); + if (value && !shouldIgnoreLiteral(value)) { + const { line, col } = getLineAndCol(sourceFile, init.getStart(sourceFile)); + findings.push({ + file: rel, + line, + col, + kind: 'jsx-attr', + text: value, + context: takeContextLine(sourceText, line), + }); + } + } + } + } + + // Call args: Modal.alert("Error", "…") + if (ts.isCallExpression(node) && !isTCall(node)) { + const exprText = getNodeText(sourceFile, node.expression); + const isLikelyUiAlert = + exprText.endsWith('.alert') || + exprText.endsWith('.confirm') || + exprText.endsWith('.prompt') || + exprText.includes('Toast') || + exprText.includes('Modal'); + + if (isLikelyUiAlert) { + for (const arg of node.arguments) { + if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) { + const value = normalizeText(arg.text); + if (value && !shouldIgnoreLiteral(value)) { + const { line, col } = getLineAndCol(sourceFile, arg.getStart(sourceFile)); + findings.push({ + file: rel, + line, + col, + kind: 'call-arg', + text: value, + context: takeContextLine(sourceText, line), + }); + } + } + } + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + // Deduplicate exact same hits (common when JSXText includes leading/trailing whitespace) + const seen = new Set(); + const unique: Finding[] = []; + for (const f of findings) { + const key = `${f.file}:${f.line}:${f.col}:${f.kind}:${f.text}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(f); + } + return unique; +} + +const files: string[] = []; +const args = process.argv.slice(2); +if (args.length === 0) { + walk(sourcesRoot, files); +} else { + for (const arg of args) { + const full = path.isAbsolute(arg) ? arg : path.join(projectRoot, arg); + if (!fs.existsSync(full)) continue; + const stat = fs.statSync(full); + if (stat.isDirectory()) { + walk(full, files); + } else if (stat.isFile() && /\.(ts|tsx|js|jsx)$/.test(full)) { + files.push(full); + } + } +} + +const all: Finding[] = []; +for (const filePath of files) { + all.push(...scanFile(filePath)); +} + +all.sort((a, b) => { + if (a.file !== b.file) return a.file.localeCompare(b.file); + if (a.line !== b.line) return a.line - b.line; + return a.col - b.col; +}); + +const grouped = new Map(); +for (const f of all) { + const key = `${f.kind}:${f.text}`; + const list = grouped.get(key) ?? []; + list.push(f); + grouped.set(key, list); +} + +console.log(`# Potential Untranslated UI Literals (${all.length} findings)\n`); +console.log(`Scanned: ${files.length} source files under ${path.relative(projectRoot, sourcesRoot)}\n`); + +for (const [key, list] of grouped.entries()) { + const [kind, text] = key.split(':', 2); + console.log(`- ${kind}: "${text}" (${list.length} occurrence${list.length === 1 ? '' : 's'})`); + for (const f of list.slice(0, 10)) { + console.log(` - ${f.file}:${f.line}:${f.col} ${f.context}`); + } + if (list.length > 10) { + console.log(` - … ${list.length - 10} more`); + } +} diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 0a8c94ed7..583e4132f 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -69,6 +69,7 @@ export const ca: TranslationStructure = { all: 'Tots', machine: 'màquina', clearSearch: 'Neteja la cerca', + refresh: 'Actualitza', }, profile: { @@ -105,6 +106,15 @@ export const ca: TranslationStructure = { enterSecretKey: 'Introdueix la teva clau secreta', invalidSecretKey: 'Clau secreta no vàlida. Comprova-ho i torna-ho a provar.', enterUrlManually: 'Introdueix l\'URL manualment', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Obre Happy al teu dispositiu mòbil\n2. Ves a Configuració → Compte\n3. Toca "Vincular nou dispositiu"\n4. Escaneja aquest codi QR', + restoreWithSecretKeyInstead: 'Restaura amb clau secreta', + restoreWithSecretKeyDescription: 'Introdueix la teva clau secreta per recuperar l’accés al teu compte.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Connecta ${name}`, + runCommandInTerminal: 'Executa l\'ordre següent al terminal:', + }, }, settings: { @@ -145,6 +155,8 @@ export const ca: TranslationStructure = { usageSubtitle: "Veure l'ús de l'API i costos", profiles: 'Perfils', profilesSubtitle: 'Gestiona els perfils d\'entorn i variables', + apiKeys: 'Claus d’API', + apiKeysSubtitle: 'Gestiona les claus d’API desades (no es tornaran a mostrar després d’introduir-les)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Compte de ${service} connectat`, @@ -182,11 +194,26 @@ export const ca: TranslationStructure = { wrapLinesInDiffsDescription: 'Ajusta les línies llargues en lloc de desplaçament horitzontal a les vistes de diferències', alwaysShowContextSize: 'Mostra sempre la mida del context', alwaysShowContextSizeDescription: 'Mostra l\'ús del context fins i tot quan no estigui prop del límit', + agentInputActionBarLayout: 'Barra d’accions d’entrada', + agentInputActionBarLayoutDescription: 'Tria com es mostren els xips d’acció sobre el camp d’entrada', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Ajusta', + scroll: 'Desplaçable', + collapsed: 'Plegat', + }, + agentInputChipDensity: 'Densitat dels xips d’acció', + agentInputChipDensityDescription: 'Tria si els xips d’acció mostren etiquetes o icones', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Etiquetes', + icons: 'Només icones', + }, avatarStyle: 'Estil d\'avatar', avatarStyleDescription: 'Tria l\'aparença de l\'avatar de la sessió', avatarOptions: { pixelated: 'Pixelat', - gradient: 'Gradient', + gradient: 'Degradat', brutalist: 'Brutalista', }, showFlavorIcons: "Mostrar icones de proveïdors d'IA", @@ -202,6 +229,22 @@ export const ca: TranslationStructure = { experimentalFeatures: 'Funcions experimentals', experimentalFeaturesEnabled: 'Funcions experimentals activades', experimentalFeaturesDisabled: 'Utilitzant només funcions estables', + experimentalOptions: 'Opcions experimentals', + experimentalOptionsDescription: 'Tria quines funcions experimentals estan activades.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Activa sessions de Gemini CLI i la UI relacionada', + expUsageReporting: 'Informe d’ús', + expUsageReportingSubtitle: 'Activa pantalles d’ús i tokens', + expFileViewer: 'Visor de fitxers', + expFileViewerSubtitle: 'Activa l’entrada al visor de fitxers de la sessió', + expShowThinkingMessages: 'Mostra missatges de pensament', + expShowThinkingMessagesSubtitle: 'Mostra missatges d’estat/pensament de l’assistent al xat', + expSessionType: 'Selector de tipus de sessió', + expSessionTypeSubtitle: 'Mostra el selector de tipus de sessió (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Activa l’entrada de navegació Zen', + expVoiceAuthFlow: 'Flux d’autenticació de veu', + expVoiceAuthFlowSubtitle: 'Utilitza el flux autenticat de tokens de veu (amb paywall)', webFeatures: 'Funcions web', webFeaturesDescription: 'Funcions disponibles només a la versió web de l\'app.', enterToSend: 'Enter per enviar', @@ -210,7 +253,7 @@ export const ca: TranslationStructure = { commandPalette: 'Paleta de comandes', commandPaletteEnabled: 'Prem ⌘K per obrir', commandPaletteDisabled: 'Accés ràpid a comandes desactivat', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Còpia de Markdown v2', markdownCopyV2Subtitle: 'Pulsació llarga obre modal de còpia', hideInactiveSessions: 'Amaga les sessions inactives', hideInactiveSessionsSubtitle: 'Mostra només els xats actius a la llista', @@ -278,8 +321,26 @@ export const ca: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Inicia una nova sessió', + selectAiProfileTitle: 'Selecciona el perfil d’IA', + selectAiProfileDescription: 'Selecciona un perfil d’IA per aplicar variables d’entorn i valors per defecte a la sessió.', + changeProfile: 'Canvia el perfil', + aiBackendSelectedByProfile: 'El backend d’IA el selecciona el teu perfil. Per canviar-lo, selecciona un perfil diferent.', + selectAiBackendTitle: 'Selecciona el backend d’IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitat pel perfil seleccionat i els CLI disponibles en aquesta màquina.', + aiBackendSelectWhichAiRuns: 'Selecciona quina IA executa la sessió.', + aiBackendNotCompatibleWithSelectedProfile: 'No és compatible amb el perfil seleccionat.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `No s’ha detectat el CLI de ${cli} en aquesta màquina.`, selectMachineTitle: 'Selecciona màquina', + selectMachineDescription: 'Tria on s’executa aquesta sessió.', selectPathTitle: 'Selecciona camí', + selectWorkingDirectoryTitle: 'Selecciona el directori de treball', + selectWorkingDirectoryDescription: 'Tria la carpeta usada per a ordres i context.', + selectPermissionModeTitle: 'Selecciona el mode de permisos', + selectPermissionModeDescription: 'Controla com d’estrictes són les aprovacions.', + selectModelTitle: 'Selecciona el model d’IA', + selectModelDescription: 'Tria el model usat per aquesta sessió.', + selectSessionTypeTitle: 'Selecciona el tipus de sessió', + selectSessionTypeDescription: 'Tria una sessió simple o una lligada a un worktree de Git.', searchPathsPlaceholder: 'Cerca camins...', noMachinesFound: 'No s\'han trobat màquines. Inicia una sessió de Happy al teu ordinador primer.', allMachinesOffline: 'Totes les màquines estan fora de línia', @@ -319,9 +380,23 @@ export const ca: TranslationStructure = { sessionType: { title: 'Tipus de sessió', simple: 'Simple', - worktree: 'Worktree', + worktree: 'Worktree (Git)', comingSoon: 'Properament', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requereix ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI no detectat`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI no detectat`, + dontShowFor: 'No mostris aquest avís per a', + thisMachine: 'aquesta màquina', + anyMachine: 'qualsevol màquina', + installCommand: ({ command }: { command: string }) => `Instal·la: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Instal·la el CLI de ${cli} si està disponible •`, + viewInstallationGuide: 'Veure la guia d’instal·lació →', + viewGeminiDocs: 'Veure la documentació de Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Creant worktree '${name}'...`, notGitRepo: 'Els worktrees requereixen un repositori git', @@ -346,6 +421,19 @@ export const ca: TranslationStructure = { commandPalette: { placeholder: 'Escriu una comanda o cerca...', + noCommandsFound: 'No s\'han trobat comandes', + }, + + commandView: { + completedWithNoOutput: '[Ordre completada sense sortida]', + }, + + voiceAssistant: { + connecting: 'Connectant...', + active: 'Assistent de veu actiu', + connectionError: 'Error de connexió', + label: 'Assistent de veu', + tapToEnd: 'Toca per acabar', }, server: { @@ -401,6 +489,9 @@ export const ca: TranslationStructure = { happyHome: 'Directori de Happy', copyMetadata: 'Copia les metadades', agentState: 'Estat de l\'agent', + rawJsonDevMode: 'JSON en brut (mode desenvolupador)', + sessionStatus: 'Estat de la sessió', + fullSessionObject: 'Objecte complet de la sessió', controlledByUser: 'Controlat per l\'usuari', pendingRequests: 'Sol·licituds pendents', activity: 'Activitat', @@ -428,6 +519,35 @@ export const ca: TranslationStructure = { runIt: 'Executa\'l', scanQrCode: 'Escaneja el codi QR', openCamera: 'Obre la càmera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Encara no hi ha missatges', + created: ({ time }: { time: string }) => `Creat ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'No hi ha sessions actives', + startNewSessionDescription: 'Inicia una sessió nova a qualsevol de les teves màquines connectades.', + startNewSessionButton: 'Inicia una sessió nova', + openTerminalToStart: 'Obre un nou terminal a l\'ordinador per iniciar una sessió.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Què s’ha de fer?', + }, + home: { + noTasksYet: 'Encara no hi ha tasques. Toca + per afegir-ne una.', + }, + view: { + workOnTask: 'Treballar en la tasca', + clarify: 'Aclarir', + delete: 'Suprimeix', + linkedSessions: 'Sessions enllaçades', + tapTaskTextToEdit: 'Toca el text de la tasca per editar', }, }, @@ -461,22 +581,22 @@ export const ca: TranslationStructure = { codexPermissionMode: { title: 'MODE DE PERMISOS CODEX', default: 'Configuració del CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Mode només lectura', + safeYolo: 'YOLO segur', yolo: 'YOLO', badgeReadOnly: 'Només lectura', - badgeSafeYolo: 'Safe YOLO', + badgeSafeYolo: 'YOLO segur', 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', + title: 'MODEL CODEX', + gpt5CodexLow: 'gpt-5-codex baix', + gpt5CodexMedium: 'gpt-5-codex mitjà', + gpt5CodexHigh: 'gpt-5-codex alt', + gpt5Minimal: 'GPT-5 Mínim', + gpt5Low: 'GPT-5 Baix', + gpt5Medium: 'GPT-5 Mitjà', + gpt5High: 'GPT-5 Alt', }, geminiPermissionMode: { title: 'MODE DE PERMISOS GEMINI', @@ -510,6 +630,11 @@ export const ca: TranslationStructure = { fileLabel: 'FITXER', folderLabel: 'CARPETA', }, + actionMenu: { + title: 'ACCIONS', + files: 'Fitxers', + stop: 'Atura', + }, noMachinesAvailable: 'Sense màquines', }, @@ -734,6 +859,11 @@ export const ca: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositiu enllaçat amb èxit', terminalConnectedSuccessfully: 'Terminal connectat amb èxit', invalidAuthUrl: 'URL d\'autenticació no vàlida', + microphoneAccessRequiredTitle: 'Cal accés al micròfon', + microphoneAccessRequiredRequestPermission: 'Happy necessita accés al micròfon per al xat de veu. Concedeix el permís quan se’t demani.', + microphoneAccessRequiredEnableInSettings: 'Happy necessita accés al micròfon per al xat de veu. Activa l’accés al micròfon a la configuració del dispositiu.', + microphoneAccessRequiredBrowserInstructions: 'Permet l’accés al micròfon a la configuració del navegador. Potser hauràs de fer clic a la icona del cadenat a la barra d’adreces i habilitar el permís del micròfon per a aquest lloc.', + openSettings: 'Obre la configuració', developerMode: 'Mode desenvolupador', developerModeEnabled: 'Mode desenvolupador activat', developerModeDisabled: 'Mode desenvolupador desactivat', @@ -788,6 +918,15 @@ export const ca: TranslationStructure = { daemon: 'Dimoni', status: 'Estat', stopDaemon: 'Atura el dimoni', + stopDaemonConfirmTitle: 'Aturar el dimoni?', + stopDaemonConfirmBody: 'No podràs iniciar sessions noves en aquesta màquina fins que reiniciïs el dimoni a l’ordinador. Les sessions actuals continuaran actives.', + daemonStoppedTitle: 'Dimoni aturat', + stopDaemonFailed: 'No s’ha pogut aturar el dimoni. Pot ser que no estigui en execució.', + renameTitle: 'Canvia el nom de la màquina', + renameDescription: 'Dona a aquesta màquina un nom personalitzat. Deixa-ho buit per usar el hostname per defecte.', + renamePlaceholder: 'Introdueix el nom de la màquina', + renamedSuccess: 'Màquina reanomenada correctament', + renameFailed: 'No s’ha pogut reanomenar la màquina', lastKnownPid: 'Últim PID conegut', lastKnownHttpPort: 'Últim port HTTP conegut', startedAt: 'Iniciat a', @@ -804,8 +943,15 @@ export const ca: TranslationStructure = { lastSeen: 'Vist per última vegada', never: 'Mai', metadataVersion: 'Versió de les metadades', + detectedClis: 'CLI detectats', + detectedCliNotDetected: 'No detectat', + detectedCliUnknown: 'Desconegut', + detectedCliNotSupported: 'No compatible (actualitza happy-cli)', untitledSession: 'Sessió sense títol', back: 'Enrere', + notFound: 'Màquina no trobada', + unknownMachine: 'màquina desconeguda', + unknownPath: 'camí desconegut', }, message: { @@ -815,6 +961,10 @@ export const ca: TranslationStructure = { unknownTime: 'temps desconegut', }, + chatFooter: { + permissionsTerminalOnly: 'Els permisos només es mostren al terminal. Reinicia o envia un missatge per controlar des de l\'app.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -841,6 +991,7 @@ export const ca: TranslationStructure = { textCopied: 'Text copiat al porta-retalls', failedToCopy: 'No s\'ha pogut copiar el text al porta-retalls', noTextToCopy: 'No hi ha text disponible per copiar', + failedToOpen: 'No s\'ha pogut obrir la selecció de text. Torna-ho a provar.', }, markdown: { @@ -860,11 +1011,14 @@ export const ca: TranslationStructure = { edit: 'Edita artefacte', delete: 'Elimina', updateError: 'No s\'ha pogut actualitzar l\'artefacte. Si us plau, torna-ho a provar.', + deleteError: 'No s\'ha pogut eliminar l\'artefacte. Torna-ho a provar.', notFound: 'Artefacte no trobat', discardChanges: 'Descartar els canvis?', discardChangesDescription: 'Tens canvis sense desar. Estàs segur que vols descartar-los?', deleteConfirm: 'Eliminar artefacte?', deleteConfirmDescription: 'Aquest artefacte s\'eliminarà permanentment.', + noContent: 'Sense contingut', + untitled: 'Sense títol', titlePlaceholder: 'Títol de l\'artefacte', bodyPlaceholder: 'Escriu aquí el contingut...', save: 'Desa', @@ -968,8 +1122,8 @@ export const ca: TranslationStructure = { custom: 'Personalitzat', builtInSaveAsHint: 'Desar un perfil integrat crea un nou perfil personalitzat.', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic (Per defecte)', + deepseek: 'DeepSeek (Raonament)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -993,6 +1147,92 @@ export const ca: TranslationStructure = { title: 'Instruccions de configuració', viewOfficialGuide: 'Veure la guia oficial de configuració', }, + machineLogin: { + title: 'Inici de sessió CLI', + subtitle: 'Aquest perfil depèn d’una memòria cau d’inici de sessió del CLI a la màquina seleccionada.', + claudeCode: { + title: 'Claude Code', + instructions: 'Executa `claude` i després escriu `/login` per iniciar sessió.', + warning: 'Nota: definir `ANTHROPIC_AUTH_TOKEN` substitueix l’inici de sessió del CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Executa `codex login` per iniciar sessió.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Executa `gemini auth` per iniciar sessió.', + }, + }, + requirements: { + apiKeyRequired: 'Clau d’API', + configured: 'Configurada a la màquina', + notConfigured: 'No configurada', + checking: 'Comprovant…', + modalTitle: 'Cal una clau d’API', + modalBody: 'Aquest perfil requereix una clau d’API.\n\nOpcions disponibles:\n• Fer servir l’entorn de la màquina (recomanat)\n• Fer servir una clau desada a la configuració de l’app\n• Introduir una clau només per a aquesta sessió', + sectionTitle: 'Requisits', + sectionSubtitle: 'Aquests camps s’utilitzen per comprovar l’estat i evitar fallades inesperades.', + secretEnvVarPromptDescription: 'Introdueix el nom de la variable d’entorn secreta necessària (p. ex., OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Aquest perfil necessita ${env}. Tria una opció a continuació.`, + modalHelpGeneric: 'Aquest perfil necessita una clau d’API. Tria una opció a continuació.', + modalRecommendation: 'Recomanat: defineix la clau a l’entorn del dimoni al teu ordinador (per no haver-la d’enganxar de nou). Després reinicia el dimoni perquè llegeixi la nova variable d’entorn.', + chooseOptionTitle: 'Tria una opció', + machineEnvStatus: { + theMachine: 'la màquina', + checkFor: ({ env }: { env: string }) => `Comprova ${env}`, + checking: ({ env }: { env: string }) => `Comprovant ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} trobat a ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} no trobat a ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Comprovant l’entorn del dimoni…', + found: 'Trobat a l’entorn del dimoni a la màquina.', + notFound: 'Configura-ho a l’entorn del dimoni a la màquina i reinicia el dimoni.', + }, + options: { + none: { + title: 'Cap', + subtitle: 'No requereix clau d’API ni inici de sessió per CLI.', + }, + apiKeyEnv: { + subtitle: 'Requereix una clau d’API que s’injectarà en iniciar la sessió.', + }, + machineLogin: { + subtitle: 'Requereix haver iniciat sessió via un CLI a la màquina de destinació.', + longSubtitle: 'Requereix haver iniciat sessió via el CLI del backend d’IA escollit a la màquina de destinació.', + }, + useMachineEnvironment: { + title: 'Fer servir l’entorn de la màquina', + subtitleWithEnv: ({ env }: { env: string }) => `Fer servir ${env} de l’entorn del dimoni.`, + subtitleGeneric: 'Fer servir la clau de l’entorn del dimoni.', + }, + useSavedApiKey: { + title: 'Fer servir una clau d’API desada', + subtitle: 'Selecciona (o afegeix) una clau desada a l’app.', + }, + enterOnce: { + title: 'Introduir una clau', + subtitle: 'Enganxa una clau només per a aquesta sessió (no es desarà).', + }, + }, + apiKeyEnvVar: { + title: 'Variable d’entorn de la clau d’API', + subtitle: 'Introdueix el nom de la variable d’entorn que aquest proveïdor espera per a la clau d’API (p. ex., OPENAI_API_KEY).', + label: 'Nom de la variable d’entorn', + }, + sections: { + machineEnvironment: 'Entorn de la màquina', + useOnceTitle: 'Fer servir una vegada', + useOnceFooter: 'Enganxa una clau només per a aquesta sessió. No es desarà.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Comença amb la clau que ja és present a la màquina.', + }, + useOnceButton: 'Fer servir una vegada (només sessió)', + }, + }, defaultSessionType: 'Tipus de sessió predeterminat', defaultPermissionMode: { title: 'Mode de permisos predeterminat', @@ -1091,6 +1331,45 @@ export const ca: TranslationStructure = { }, }, + apiKeys: { + addTitle: 'Nova clau d’API', + savedTitle: 'Claus d’API desades', + badgeReady: 'Clau d’API', + badgeRequired: 'Cal una clau d’API', + addSubtitle: 'Afegeix una clau d’API desada', + noneTitle: 'Cap', + noneSubtitle: 'Fes servir l’entorn de la màquina o introdueix una clau per a aquesta sessió', + emptyTitle: 'No hi ha claus desades', + emptySubtitle: 'Afegeix-ne una per utilitzar perfils amb clau d’API sense configurar variables d’entorn a la màquina.', + savedHiddenSubtitle: 'Desada (valor ocult)', + defaultLabel: 'Per defecte', + fields: { + name: 'Nom', + value: 'Valor', + }, + placeholders: { + nameExample: 'p. ex., Work OpenAI', + }, + validation: { + nameRequired: 'El nom és obligatori.', + valueRequired: 'El valor és obligatori.', + }, + actions: { + replace: 'Substitueix', + replaceValue: 'Substitueix el valor', + setDefault: 'Estableix com a per defecte', + unsetDefault: 'Treu com a per defecte', + }, + prompts: { + renameTitle: 'Reanomena la clau d’API', + renameDescription: 'Actualitza el nom descriptiu d’aquesta clau.', + replaceValueTitle: 'Substitueix el valor de la clau d’API', + replaceValueDescription: 'Enganxa el nou valor de la clau d’API. No es tornarà a mostrar després de desar-lo.', + deleteTitle: 'Elimina la clau d’API', + deleteConfirm: ({ name }: { name: string }) => `Vols eliminar “${name}”? Aquesta acció no es pot desfer.`, + }, + }, + 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 index 62e9701af..f50020874 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -82,6 +82,7 @@ export const en = { all: 'All', machine: 'machine', clearSearch: 'Clear search', + refresh: 'Refresh', }, profile: { @@ -118,6 +119,15 @@ export const en = { enterSecretKey: 'Please enter a secret key', invalidSecretKey: 'Invalid secret key. Please check and try again.', enterUrlManually: 'Enter URL manually', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Open Happy on your mobile device\n2. Go to Settings → Account\n3. Tap "Link New Device"\n4. Scan this QR code', + restoreWithSecretKeyInstead: 'Restore with Secret Key Instead', + restoreWithSecretKeyDescription: 'Enter your secret key to restore access to your account.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Connect ${name}`, + runCommandInTerminal: 'Run the following command in your terminal:', + }, }, settings: { @@ -158,6 +168,8 @@ export const en = { usageSubtitle: 'View your API usage and costs', profiles: 'Profiles', profilesSubtitle: 'Manage environment variable profiles for sessions', + apiKeys: 'API Keys', + apiKeysSubtitle: 'Manage saved API keys (never shown again after entry)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `${service} account connected`, @@ -195,6 +207,21 @@ export const en = { 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', + agentInputActionBarLayout: 'Input Action Bar', + agentInputActionBarLayoutDescription: 'Choose how action chips are displayed above the input', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Wrap', + scroll: 'Scrollable', + collapsed: 'Collapsed', + }, + agentInputChipDensity: 'Action Chip Density', + agentInputChipDensityDescription: 'Choose whether action chips show labels or icons', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Labels', + icons: 'Icons only', + }, avatarStyle: 'Avatar Style', avatarStyleDescription: 'Choose session avatar appearance', avatarOptions: { @@ -215,6 +242,22 @@ export const en = { experimentalFeatures: 'Experimental Features', experimentalFeaturesEnabled: 'Experimental features enabled', experimentalFeaturesDisabled: 'Using stable features only', + experimentalOptions: 'Experimental options', + experimentalOptionsDescription: 'Choose which experimental features are enabled.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Web Features', webFeaturesDescription: 'Features available only in the web version of the app.', enterToSend: 'Enter to Send', @@ -291,8 +334,26 @@ export const en = { newSession: { // Used by new-session screen and launch flows title: 'Start New Session', + selectAiProfileTitle: 'Select AI Profile', + selectAiProfileDescription: 'Select an AI profile to apply environment variables and defaults to your session.', + changeProfile: 'Change Profile', + aiBackendSelectedByProfile: 'AI backend is selected by your profile. To change it, select a different profile.', + selectAiBackendTitle: 'Select AI Backend', + aiBackendLimitedByProfileAndMachineClis: 'Limited by your selected profile and available CLIs on this machine.', + aiBackendSelectWhichAiRuns: 'Select which AI runs your session.', + aiBackendNotCompatibleWithSelectedProfile: 'Not compatible with the selected profile.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `${cli} CLI not detected on this machine.`, selectMachineTitle: 'Select Machine', + selectMachineDescription: 'Choose where this session runs.', selectPathTitle: 'Select Path', + selectWorkingDirectoryTitle: 'Select Working Directory', + selectWorkingDirectoryDescription: 'Pick the folder used for commands and context.', + selectPermissionModeTitle: 'Select Permission Mode', + selectPermissionModeDescription: 'Control how strictly actions require approval.', + selectModelTitle: 'Select AI Model', + selectModelDescription: 'Choose the model used by this session.', + selectSessionTypeTitle: 'Select Session Type', + selectSessionTypeDescription: 'Choose a simple session or one tied to a Git worktree.', searchPathsPlaceholder: 'Search paths...', noMachinesFound: 'No machines found. Start a Happy session on your computer first.', allMachinesOffline: 'All machines appear offline', @@ -335,6 +396,20 @@ export const en = { worktree: 'Worktree', comingSoon: 'Coming soon', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requires ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI not detected`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI Not Detected`, + dontShowFor: "Don't show this popup for", + thisMachine: 'this machine', + anyMachine: 'any machine', + installCommand: ({ command }: { command: string }) => `Install: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Install ${cli} CLI if available •`, + viewInstallationGuide: 'View Installation Guide →', + viewGeminiDocs: 'View Gemini Docs →', + }, worktree: { creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`, notGitRepo: 'Worktrees require a git repository', @@ -359,6 +434,19 @@ export const en = { commandPalette: { placeholder: 'Type a command or search...', + noCommandsFound: 'No commands found', + }, + + commandView: { + completedWithNoOutput: '[Command completed with no output]', + }, + + voiceAssistant: { + connecting: 'Connecting...', + active: 'Voice Assistant Active', + connectionError: 'Connection Error', + label: 'Voice Assistant', + tapToEnd: 'Tap to end', }, server: { @@ -414,6 +502,9 @@ export const en = { happyHome: 'Happy Home', copyMetadata: 'Copy Metadata', agentState: 'Agent State', + rawJsonDevMode: 'Raw JSON (Dev Mode)', + sessionStatus: 'Session Status', + fullSessionObject: 'Full Session Object', controlledByUser: 'Controlled by User', pendingRequests: 'Pending Requests', activity: 'Activity', @@ -441,6 +532,35 @@ export const en = { runIt: 'Run it', scanQrCode: 'Scan the QR code', openCamera: 'Open Camera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'No messages yet', + created: ({ time }: { time: string }) => `Created ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'No active sessions', + startNewSessionDescription: 'Start a new session on any of your connected machines.', + startNewSessionButton: 'Start New Session', + openTerminalToStart: 'Open a new terminal on your computer to start session.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'What needs to be done?', + }, + home: { + noTasksYet: 'No tasks yet. Tap + to add one.', + }, + view: { + workOnTask: 'Work on task', + clarify: 'Clarify', + delete: 'Delete', + linkedSessions: 'Linked Sessions', + tapTaskTextToEdit: 'Tap the task text to edit', }, }, @@ -523,6 +643,11 @@ export const en = { fileLabel: 'FILE', folderLabel: 'FOLDER', }, + actionMenu: { + title: 'ACTIONS', + files: 'Files', + stop: 'Stop', + }, noMachinesAvailable: 'No machines', }, @@ -747,6 +872,11 @@ export const en = { deviceLinkedSuccessfully: 'Device linked successfully', terminalConnectedSuccessfully: 'Terminal connected successfully', invalidAuthUrl: 'Invalid authentication URL', + microphoneAccessRequiredTitle: 'Microphone Access Required', + microphoneAccessRequiredRequestPermission: 'Happy needs access to your microphone for voice chat. Please grant permission when prompted.', + microphoneAccessRequiredEnableInSettings: 'Happy needs access to your microphone for voice chat. Please enable microphone access in your device settings.', + microphoneAccessRequiredBrowserInstructions: 'Please allow microphone access in your browser settings. You may need to click the lock icon in the address bar and enable microphone permission for this site.', + openSettings: 'Open Settings', developerMode: 'Developer Mode', developerModeEnabled: 'Developer mode enabled', developerModeDisabled: 'Developer mode disabled', @@ -801,6 +931,15 @@ export const en = { daemon: 'Daemon', status: 'Status', stopDaemon: 'Stop Daemon', + stopDaemonConfirmTitle: 'Stop Daemon?', + stopDaemonConfirmBody: 'You will not be able to spawn new sessions on this machine until you restart the daemon on your computer again. Your current sessions will stay alive.', + daemonStoppedTitle: 'Daemon Stopped', + stopDaemonFailed: 'Failed to stop daemon. It may not be running.', + renameTitle: 'Rename Machine', + renameDescription: 'Give this machine a custom name. Leave empty to use the default hostname.', + renamePlaceholder: 'Enter machine name', + renamedSuccess: 'Machine renamed successfully', + renameFailed: 'Failed to rename machine', lastKnownPid: 'Last Known PID', lastKnownHttpPort: 'Last Known HTTP Port', startedAt: 'Started At', @@ -817,8 +956,15 @@ export const en = { lastSeen: 'Last Seen', never: 'Never', metadataVersion: 'Metadata Version', + detectedClis: 'Detected CLIs', + detectedCliNotDetected: 'Not detected', + detectedCliUnknown: 'Unknown', + detectedCliNotSupported: 'Not supported (update happy-cli)', untitledSession: 'Untitled Session', back: 'Back', + notFound: 'Machine not found', + unknownMachine: 'unknown machine', + unknownPath: 'unknown path', }, message: { @@ -828,6 +974,10 @@ export const en = { unknownTime: 'unknown time', }, + chatFooter: { + permissionsTerminalOnly: 'Permissions are shown in the terminal only. Reset or send a message to control from the app.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -854,6 +1004,7 @@ export const en = { textCopied: 'Text copied to clipboard', failedToCopy: 'Failed to copy text to clipboard', noTextToCopy: 'No text available to copy', + failedToOpen: 'Failed to open text selection. Please try again.', }, markdown: { @@ -874,11 +1025,14 @@ export const en = { edit: 'Edit Artifact', delete: 'Delete', updateError: 'Failed to update artifact. Please try again.', + deleteError: 'Failed to delete 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', + noContent: 'No content', + untitled: 'Untitled', titleLabel: 'TITLE', titlePlaceholder: 'Enter a title for your artifact', bodyLabel: 'CONTENT', @@ -964,6 +1118,45 @@ export const en = { friendAcceptedGeneric: 'Friend request accepted', }, + apiKeys: { + addTitle: 'New API key', + savedTitle: 'Saved API keys', + badgeReady: 'API key', + badgeRequired: 'API key required', + addSubtitle: 'Add a saved API key', + noneTitle: 'None', + noneSubtitle: 'Use machine environment or enter a key for this session', + emptyTitle: 'No saved keys', + emptySubtitle: 'Add one to use API-key profiles without setting machine env vars.', + savedHiddenSubtitle: 'Saved (value hidden)', + defaultLabel: 'Default', + fields: { + name: 'Name', + value: 'Value', + }, + placeholders: { + nameExample: 'e.g. Work OpenAI', + }, + validation: { + nameRequired: 'Name is required.', + valueRequired: 'Value is required.', + }, + actions: { + replace: 'Replace', + replaceValue: 'Replace value', + setDefault: 'Set as default', + unsetDefault: 'Unset default', + }, + prompts: { + renameTitle: 'Rename API key', + renameDescription: 'Update the friendly name for this key.', + replaceValueTitle: 'Replace API key value', + replaceValueDescription: 'Paste the new API key value. This value will not be shown again after saving.', + deleteTitle: 'Delete API key', + deleteConfirm: ({ name }: { name: string }) => `Delete “${name}”? This cannot be undone.`, + }, + }, + profiles: { // Profile management feature title: 'Profiles', @@ -1016,6 +1209,92 @@ export const en = { title: 'Setup Instructions', viewOfficialGuide: 'View Official Setup Guide', }, + machineLogin: { + title: 'CLI login', + subtitle: 'This profile relies on a CLI login cache on the selected machine.', + claudeCode: { + title: 'Claude Code', + instructions: 'Run `claude`, then type `/login` to sign in.', + warning: 'Note: setting `ANTHROPIC_AUTH_TOKEN` overrides CLI login.', + }, + codex: { + title: 'Codex', + instructions: 'Run `codex login` to sign in.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Run `gemini auth` to sign in.', + }, + }, + requirements: { + apiKeyRequired: 'API key', + configured: 'Configured on machine', + notConfigured: 'Not configured', + checking: 'Checking…', + modalTitle: 'API key required', + modalBody: 'This profile requires an API key.\n\nSupported options:\n• Use machine environment (recommended)\n• Use saved key from app settings\n• Enter a key for this session only', + sectionTitle: 'Requirements', + sectionSubtitle: 'These fields are used to preflight readiness and to avoid surprise failures.', + secretEnvVarPromptDescription: 'Enter the required secret environment variable name (e.g. OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `This profile needs ${env}. Choose one option below.`, + modalHelpGeneric: 'This profile needs an API key. Choose one option below.', + modalRecommendation: 'Recommended: set the key in your daemon environment on your computer (so you don’t have to paste it again). Then restart the daemon so it picks up the new env var.', + chooseOptionTitle: 'Choose an option', + machineEnvStatus: { + theMachine: 'the machine', + checkFor: ({ env }: { env: string }) => `Check for ${env}`, + checking: ({ env }: { env: string }) => `Checking ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} found on ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} not found on ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Checking daemon environment…', + found: 'Found in the daemon environment on the machine.', + notFound: 'Set it in the daemon environment on the machine and restart the daemon.', + }, + options: { + none: { + title: 'None', + subtitle: 'Does not require an API key or CLI login.', + }, + apiKeyEnv: { + subtitle: 'Requires an API key to be injected at session start.', + }, + machineLogin: { + subtitle: 'Requires being logged in via a CLI on the target machine.', + longSubtitle: 'Requires being logged in via the CLI for the AI backend you choose on the target machine.', + }, + useMachineEnvironment: { + title: 'Use machine environment', + subtitleWithEnv: ({ env }: { env: string }) => `Use ${env} from the daemon environment.`, + subtitleGeneric: 'Use the key from the daemon environment.', + }, + useSavedApiKey: { + title: 'Use a saved API key', + subtitle: 'Select (or add) a saved key in the app.', + }, + enterOnce: { + title: 'Enter a key', + subtitle: 'Paste a key for this session only (won’t be saved).', + }, + }, + apiKeyEnvVar: { + title: 'API key environment variable', + subtitle: 'Enter the env var name this provider expects for its API key (e.g. OPENAI_API_KEY).', + label: 'Environment variable name', + }, + sections: { + machineEnvironment: 'Machine environment', + useOnceTitle: 'Use once', + useOnceFooter: 'Paste a key for this session only. It won’t be saved.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Start with the key already present on the machine.', + }, + useOnceButton: 'Use once (session only)', + }, + }, defaultSessionType: 'Default Session Type', defaultPermissionMode: { title: 'Default Permission Mode', diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index ced04cfee..f4f8640cb 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -69,6 +69,7 @@ export const es: TranslationStructure = { all: 'Todo', machine: 'máquina', clearSearch: 'Limpiar búsqueda', + refresh: 'Actualizar', }, profile: { @@ -105,6 +106,15 @@ export const es: TranslationStructure = { enterSecretKey: 'Ingresa tu clave secreta', invalidSecretKey: 'Clave secreta inválida. Verifica e intenta de nuevo.', enterUrlManually: 'Ingresar URL manualmente', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Abre Happy en tu dispositivo móvil\n2. Ve a Configuración → Cuenta\n3. Toca "Vincular nuevo dispositivo"\n4. Escanea este código QR', + restoreWithSecretKeyInstead: 'Restaurar con clave secreta', + restoreWithSecretKeyDescription: 'Ingresa tu clave secreta para recuperar el acceso a tu cuenta.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Conectar ${name}`, + runCommandInTerminal: 'Ejecuta el siguiente comando en tu terminal:', + }, }, settings: { @@ -145,6 +155,8 @@ export const es: TranslationStructure = { usageSubtitle: 'Ver tu uso de API y costos', profiles: 'Perfiles', profilesSubtitle: 'Gestionar perfiles de variables de entorno para sesiones', + apiKeys: 'Claves API', + apiKeysSubtitle: 'Gestiona las claves API guardadas (no se vuelven a mostrar después de ingresarlas)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Cuenta de ${service} conectada`, @@ -182,6 +194,21 @@ export const es: TranslationStructure = { wrapLinesInDiffsDescription: 'Ajustar líneas largas en lugar de desplazamiento horizontal en vistas de diferencias', alwaysShowContextSize: 'Mostrar siempre tamaño del contexto', alwaysShowContextSizeDescription: 'Mostrar uso del contexto incluso cuando no esté cerca del límite', + agentInputActionBarLayout: 'Barra de acciones de entrada', + agentInputActionBarLayoutDescription: 'Elige cómo se muestran los chips de acción encima del campo de entrada', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Ajustar', + scroll: 'Desplazable', + collapsed: 'Contraído', + }, + agentInputChipDensity: 'Densidad de chips de acción', + agentInputChipDensityDescription: 'Elige si los chips de acción muestran etiquetas o íconos', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Etiquetas', + icons: 'Solo íconos', + }, avatarStyle: 'Estilo de avatar', avatarStyleDescription: 'Elige la apariencia del avatar de sesión', avatarOptions: { @@ -202,6 +229,22 @@ export const es: TranslationStructure = { experimentalFeatures: 'Características experimentales', experimentalFeaturesEnabled: 'Características experimentales habilitadas', experimentalFeaturesDisabled: 'Usando solo características estables', + experimentalOptions: 'Opciones experimentales', + experimentalOptionsDescription: 'Elige qué funciones experimentales están activadas.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Características web', webFeaturesDescription: 'Características disponibles solo en la versión web de la aplicación.', enterToSend: 'Enter para enviar', @@ -210,7 +253,7 @@ export const es: TranslationStructure = { commandPalette: 'Paleta de comandos', commandPaletteEnabled: 'Presione ⌘K para abrir', commandPaletteDisabled: 'Acceso rápido a comandos deshabilitado', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Copia de Markdown v2', markdownCopyV2Subtitle: 'Pulsación larga abre modal de copiado', hideInactiveSessions: 'Ocultar sesiones inactivas', hideInactiveSessionsSubtitle: 'Muestra solo los chats activos en tu lista', @@ -278,8 +321,26 @@ export const es: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nueva sesión', + selectAiProfileTitle: 'Seleccionar perfil de IA', + selectAiProfileDescription: 'Selecciona un perfil de IA para aplicar variables de entorno y valores predeterminados a tu sesión.', + changeProfile: 'Cambiar perfil', + aiBackendSelectedByProfile: 'El backend de IA lo selecciona tu perfil. Para cambiarlo, selecciona un perfil diferente.', + selectAiBackendTitle: 'Seleccionar backend de IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitado por tu perfil seleccionado y los CLI disponibles en esta máquina.', + aiBackendSelectWhichAiRuns: 'Selecciona qué IA ejecuta tu sesión.', + aiBackendNotCompatibleWithSelectedProfile: 'No es compatible con el perfil seleccionado.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `No se detectó el CLI de ${cli} en esta máquina.`, selectMachineTitle: 'Seleccionar máquina', + selectMachineDescription: 'Elige dónde se ejecuta esta sesión.', selectPathTitle: 'Seleccionar ruta', + selectWorkingDirectoryTitle: 'Seleccionar directorio de trabajo', + selectWorkingDirectoryDescription: 'Elige la carpeta usada para comandos y contexto.', + selectPermissionModeTitle: 'Seleccionar modo de permisos', + selectPermissionModeDescription: 'Controla qué tan estrictamente las acciones requieren aprobación.', + selectModelTitle: 'Seleccionar modelo de IA', + selectModelDescription: 'Elige el modelo usado por esta sesión.', + selectSessionTypeTitle: 'Seleccionar tipo de sesión', + selectSessionTypeDescription: 'Elige una sesión simple o una vinculada a un worktree de Git.', searchPathsPlaceholder: 'Buscar rutas...', noMachinesFound: 'No se encontraron máquinas. Inicia una sesión de Happy en tu computadora primero.', allMachinesOffline: 'Todas las máquinas están desconectadas', @@ -322,6 +383,20 @@ export const es: TranslationStructure = { worktree: 'Worktree', comingSoon: 'Próximamente', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requiere ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI no detectado`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI no detectado`, + dontShowFor: 'No mostrar este aviso para', + thisMachine: 'esta máquina', + anyMachine: 'cualquier máquina', + installCommand: ({ command }: { command: string }) => `Instalar: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Instala ${cli} CLI si está disponible •`, + viewInstallationGuide: 'Ver guía de instalación →', + viewGeminiDocs: 'Ver documentación de Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Creando worktree '${name}'...`, notGitRepo: 'Los worktrees requieren un repositorio git', @@ -346,6 +421,19 @@ export const es: TranslationStructure = { commandPalette: { placeholder: 'Escriba un comando o busque...', + noCommandsFound: 'No se encontraron comandos', + }, + + commandView: { + completedWithNoOutput: '[Comando completado sin salida]', + }, + + voiceAssistant: { + connecting: 'Conectando...', + active: 'Asistente de voz activo', + connectionError: 'Error de conexión', + label: 'Asistente de voz', + tapToEnd: 'Toca para finalizar', }, server: { @@ -401,6 +489,9 @@ export const es: TranslationStructure = { happyHome: 'Directorio de Happy', copyMetadata: 'Copiar metadatos', agentState: 'Estado del agente', + rawJsonDevMode: 'JSON sin procesar (modo desarrollador)', + sessionStatus: 'Estado de la sesión', + fullSessionObject: 'Objeto de sesión completo', controlledByUser: 'Controlado por el usuario', pendingRequests: 'Solicitudes pendientes', activity: 'Actividad', @@ -428,6 +519,35 @@ export const es: TranslationStructure = { runIt: 'Ejecútelo', scanQrCode: 'Escanee el código QR', openCamera: 'Abrir cámara', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Aún no hay mensajes', + created: ({ time }: { time: string }) => `Creado ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'No hay sesiones activas', + startNewSessionDescription: 'Inicia una nueva sesión en cualquiera de tus máquinas conectadas.', + startNewSessionButton: 'Iniciar nueva sesión', + openTerminalToStart: 'Abre un nuevo terminal en tu computadora para iniciar una sesión.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: '¿Qué hay que hacer?', + }, + home: { + noTasksYet: 'Aún no hay tareas. Toca + para añadir una.', + }, + view: { + workOnTask: 'Trabajar en la tarea', + clarify: 'Aclarar', + delete: 'Eliminar', + linkedSessions: 'Sesiones vinculadas', + tapTaskTextToEdit: 'Toca el texto de la tarea para editar', }, }, @@ -461,22 +581,22 @@ export const es: TranslationStructure = { codexPermissionMode: { title: 'MODO DE PERMISOS CODEX', default: 'Configuración del CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Modo de solo lectura', + safeYolo: 'YOLO seguro', yolo: 'YOLO', badgeReadOnly: 'Solo lectura', - badgeSafeYolo: 'Safe YOLO', + badgeSafeYolo: 'YOLO seguro', 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', + title: 'MODELO CODEX', + gpt5CodexLow: 'gpt-5-codex bajo', + gpt5CodexMedium: 'gpt-5-codex medio', + gpt5CodexHigh: 'gpt-5-codex alto', + gpt5Minimal: 'GPT-5 Mínimo', + gpt5Low: 'GPT-5 Bajo', + gpt5Medium: 'GPT-5 Medio', + gpt5High: 'GPT-5 Alto', }, geminiPermissionMode: { title: 'MODO DE PERMISOS GEMINI', @@ -510,6 +630,11 @@ export const es: TranslationStructure = { fileLabel: 'ARCHIVO', folderLabel: 'CARPETA', }, + actionMenu: { + title: 'ACCIONES', + files: 'Archivos', + stop: 'Detener', + }, noMachinesAvailable: 'Sin máquinas', }, @@ -734,6 +859,11 @@ export const es: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo vinculado exitosamente', terminalConnectedSuccessfully: 'Terminal conectado exitosamente', invalidAuthUrl: 'URL de autenticación inválida', + microphoneAccessRequiredTitle: 'Se requiere acceso al micrófono', + microphoneAccessRequiredRequestPermission: 'Happy necesita acceso a tu micrófono para el chat de voz. Concede el permiso cuando se te solicite.', + microphoneAccessRequiredEnableInSettings: 'Happy necesita acceso a tu micrófono para el chat de voz. Activa el acceso al micrófono en la configuración de tu dispositivo.', + microphoneAccessRequiredBrowserInstructions: 'Permite el acceso al micrófono en la configuración del navegador. Puede que debas hacer clic en el icono de candado en la barra de direcciones y habilitar el permiso del micrófono para este sitio.', + openSettings: 'Abrir configuración', developerMode: 'Modo desarrollador', developerModeEnabled: 'Modo desarrollador habilitado', developerModeDisabled: 'Modo desarrollador deshabilitado', @@ -785,9 +915,18 @@ export const es: TranslationStructure = { offlineUnableToSpawn: 'El lanzador está deshabilitado mientras la máquina está desconectada', offlineHelp: '• Asegúrate de que tu computadora esté en línea\n• Ejecuta `happy daemon status` para diagnosticar\n• ¿Estás usando la última versión del CLI? Actualiza con `npm install -g happy-coder@latest`', launchNewSessionInDirectory: 'Iniciar nueva sesión en directorio', - daemon: 'Daemon', + daemon: 'Demonio', status: 'Estado', stopDaemon: 'Detener daemon', + stopDaemonConfirmTitle: '¿Detener daemon?', + stopDaemonConfirmBody: 'No podrás crear nuevas sesiones en esta máquina hasta que reinicies el daemon en tu computadora. Tus sesiones actuales seguirán activas.', + daemonStoppedTitle: 'Daemon detenido', + stopDaemonFailed: 'No se pudo detener el daemon. Puede que no esté en ejecución.', + renameTitle: 'Renombrar máquina', + renameDescription: 'Dale a esta máquina un nombre personalizado. Déjalo vacío para usar el hostname predeterminado.', + renamePlaceholder: 'Ingresa el nombre de la máquina', + renamedSuccess: 'Máquina renombrada correctamente', + renameFailed: 'No se pudo renombrar la máquina', lastKnownPid: 'Último PID conocido', lastKnownHttpPort: 'Último puerto HTTP conocido', startedAt: 'Iniciado en', @@ -804,8 +943,15 @@ export const es: TranslationStructure = { lastSeen: 'Visto por última vez', never: 'Nunca', metadataVersion: 'Versión de metadatos', + detectedClis: 'CLI detectados', + detectedCliNotDetected: 'No detectado', + detectedCliUnknown: 'Desconocido', + detectedCliNotSupported: 'No compatible (actualiza happy-cli)', untitledSession: 'Sesión sin título', back: 'Atrás', + notFound: 'Máquina no encontrada', + unknownMachine: 'máquina desconocida', + unknownPath: 'ruta desconocida', }, message: { @@ -815,6 +961,10 @@ export const es: TranslationStructure = { unknownTime: 'tiempo desconocido', }, + chatFooter: { + permissionsTerminalOnly: 'Los permisos se muestran solo en el terminal. Restablece o envía un mensaje para controlar desde la app.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -841,6 +991,7 @@ export const es: TranslationStructure = { textCopied: 'Texto copiado al portapapeles', failedToCopy: 'Error al copiar el texto al portapapeles', noTextToCopy: 'No hay texto disponible para copiar', + failedToOpen: 'No se pudo abrir la selección de texto. Intenta de nuevo.', }, markdown: { @@ -861,11 +1012,14 @@ export const es: TranslationStructure = { edit: 'Editar artefacto', delete: 'Eliminar', updateError: 'No se pudo actualizar el artefacto. Por favor, intenta de nuevo.', + deleteError: 'No se pudo eliminar el artefacto. Intenta de nuevo.', notFound: 'Artefacto no encontrado', discardChanges: '¿Descartar cambios?', discardChangesDescription: 'Tienes cambios sin guardar. ¿Estás seguro de que quieres descartarlos?', deleteConfirm: '¿Eliminar artefacto?', deleteConfirmDescription: 'Esta acción no se puede deshacer', + noContent: 'Sin contenido', + untitled: 'Sin título', titleLabel: 'TÍTULO', titlePlaceholder: 'Ingresa un título para tu artefacto', bodyLabel: 'CONTENIDO', @@ -951,6 +1105,45 @@ export const es: TranslationStructure = { friendAcceptedGeneric: 'Solicitud de amistad aceptada', }, + apiKeys: { + addTitle: 'Nueva clave API', + savedTitle: 'Claves API guardadas', + badgeReady: 'Clave API', + badgeRequired: 'Se requiere clave API', + addSubtitle: 'Agregar una clave API guardada', + noneTitle: 'Ninguna', + noneSubtitle: 'Usa el entorno de la máquina o ingresa una clave para esta sesión', + emptyTitle: 'No hay claves guardadas', + emptySubtitle: 'Agrega una para usar perfiles con clave API sin configurar variables de entorno en la máquina.', + savedHiddenSubtitle: 'Guardada (valor oculto)', + defaultLabel: 'Predeterminada', + fields: { + name: 'Nombre', + value: 'Valor', + }, + placeholders: { + nameExample: 'p. ej., Work OpenAI', + }, + validation: { + nameRequired: 'El nombre es obligatorio.', + valueRequired: 'El valor es obligatorio.', + }, + actions: { + replace: 'Reemplazar', + replaceValue: 'Reemplazar valor', + setDefault: 'Establecer como predeterminada', + unsetDefault: 'Quitar como predeterminada', + }, + prompts: { + renameTitle: 'Renombrar clave API', + renameDescription: 'Actualiza el nombre descriptivo de esta clave.', + replaceValueTitle: 'Reemplazar valor de la clave API', + replaceValueDescription: 'Pega el nuevo valor de la clave API. Este valor no se mostrará de nuevo después de guardarlo.', + deleteTitle: 'Eliminar clave API', + deleteConfirm: ({ name }: { name: string }) => `¿Eliminar “${name}”? Esto no se puede deshacer.`, + }, + }, + profiles: { // Profile management feature title: 'Perfiles', @@ -978,8 +1171,8 @@ export const es: TranslationStructure = { custom: 'Personalizado', builtInSaveAsHint: 'Guardar un perfil integrado crea un nuevo perfil personalizado.', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic (Predeterminado)', + deepseek: 'DeepSeek (Razonamiento)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -997,12 +1190,98 @@ export const es: TranslationStructure = { duplicateProfile: 'Duplicar perfil', deleteProfile: 'Eliminar perfil', }, - copySuffix: '(Copiar)', + copySuffix: '(Copia)', duplicateName: 'Ya existe un perfil con este nombre', setupInstructions: { title: 'Instrucciones de configuración', viewOfficialGuide: 'Ver la guía oficial de configuración', }, + machineLogin: { + title: 'Se requiere iniciar sesión en la máquina', + subtitle: 'Este perfil depende de una caché de inicio de sesión del CLI en la máquina seleccionada.', + claudeCode: { + title: 'Claude Code', + instructions: 'Ejecuta `claude` y luego escribe `/login` para iniciar sesión.', + warning: 'Nota: establecer `ANTHROPIC_AUTH_TOKEN` sobrescribe el inicio de sesión del CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Ejecuta `codex login` para iniciar sesión.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Ejecuta `gemini auth` para iniciar sesión.', + }, + }, + requirements: { + apiKeyRequired: 'Clave API', + configured: 'Configurada en la máquina', + notConfigured: 'No configurada', + checking: 'Comprobando…', + modalTitle: 'Se requiere clave API', + modalBody: 'Este perfil requiere una clave API.\n\nOpciones disponibles:\n• Usar entorno de la máquina (recomendado)\n• Usar una clave guardada en la configuración de la app\n• Ingresar una clave solo para esta sesión', + sectionTitle: 'Requisitos', + sectionSubtitle: 'Estos campos se usan para comprobar el estado y evitar fallos inesperados.', + secretEnvVarPromptDescription: 'Ingresa el nombre de la variable de entorno secreta requerida (p. ej., OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Este perfil necesita ${env}. Elige una opción abajo.`, + modalHelpGeneric: 'Este perfil necesita una clave API. Elige una opción abajo.', + modalRecommendation: 'Recomendado: configura la clave en el entorno del daemon en tu computadora (para no tener que pegarla de nuevo). Luego reinicia el daemon para que tome la nueva variable de entorno.', + chooseOptionTitle: 'Elige una opción', + machineEnvStatus: { + theMachine: 'la máquina', + checkFor: ({ env }: { env: string }) => `Comprobar ${env}`, + checking: ({ env }: { env: string }) => `Comprobando ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} encontrado en ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} no encontrado en ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Comprobando el entorno del daemon…', + found: 'Encontrado en el entorno del daemon en la máquina.', + notFound: 'Configúralo en el entorno del daemon en la máquina y reinicia el daemon.', + }, + options: { + none: { + title: 'Ninguna', + subtitle: 'No requiere clave API ni inicio de sesión por CLI.', + }, + apiKeyEnv: { + subtitle: 'Requiere una clave API que se inyectará al iniciar la sesión.', + }, + machineLogin: { + subtitle: 'Requiere iniciar sesión mediante un CLI en la máquina de destino.', + longSubtitle: 'Requiere haber iniciado sesión mediante el CLI para el backend de IA que elijas en la máquina de destino.', + }, + useMachineEnvironment: { + title: 'Usar entorno de la máquina', + subtitleWithEnv: ({ env }: { env: string }) => `Usar ${env} del entorno del daemon.`, + subtitleGeneric: 'Usar la clave del entorno del daemon.', + }, + useSavedApiKey: { + title: 'Usar una clave API guardada', + subtitle: 'Selecciona (o agrega) una clave guardada en la app.', + }, + enterOnce: { + title: 'Ingresar una clave', + subtitle: 'Pega una clave solo para esta sesión (no se guardará).', + }, + }, + apiKeyEnvVar: { + title: 'Variable de entorno de clave API', + subtitle: 'Ingresa el nombre de la variable de entorno que este proveedor espera para su clave API (p. ej., OPENAI_API_KEY).', + label: 'Nombre de la variable de entorno', + }, + sections: { + machineEnvironment: 'Entorno de la máquina', + useOnceTitle: 'Usar una vez', + useOnceFooter: 'Pega una clave solo para esta sesión. No se guardará.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Comenzar con la clave ya presente en la máquina.', + }, + useOnceButton: 'Usar una vez (solo sesión)', + }, + }, defaultSessionType: 'Tipo de sesión predeterminado', defaultPermissionMode: { title: 'Modo de permisos predeterminado', diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 498aa0cc3..de9ead16f 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -65,9 +65,10 @@ export const it: TranslationStructure = { delete: 'Elimina', optional: 'opzionale', noMatches: 'Nessuna corrispondenza', - all: 'All', + all: 'Tutti', machine: 'macchina', - clearSearch: 'Clear search', + clearSearch: 'Cancella ricerca', + refresh: 'Aggiorna', saveAs: 'Salva con nome', }, @@ -106,8 +107,8 @@ export const it: TranslationStructure = { custom: 'Personalizzato', builtInSaveAsHint: 'Salvare un profilo integrato crea un nuovo profilo personalizzato.', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic (Predefinito)', + deepseek: 'DeepSeek (Ragionamento)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -125,12 +126,98 @@ export const it: TranslationStructure = { duplicateProfile: 'Duplica profilo', deleteProfile: 'Elimina profilo', }, - copySuffix: '(Copy)', + copySuffix: '(Copia)', duplicateName: 'Esiste già un profilo con questo nome', setupInstructions: { title: 'Istruzioni di configurazione', viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione', }, + machineLogin: { + title: 'Login richiesto sulla macchina', + subtitle: 'Questo profilo si basa su una cache di login del CLI sulla macchina selezionata.', + claudeCode: { + title: 'Claude Code', + instructions: 'Esegui `claude`, poi digita `/login` per accedere.', + warning: 'Nota: impostare `ANTHROPIC_AUTH_TOKEN` sostituisce il login del CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Esegui `codex login` per accedere.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Esegui `gemini auth` per accedere.', + }, + }, + requirements: { + apiKeyRequired: 'Chiave API', + configured: 'Configurata sulla macchina', + notConfigured: 'Non configurata', + checking: 'Verifica…', + modalTitle: 'Chiave API richiesta', + modalBody: 'Questo profilo richiede una chiave API.\n\nOpzioni supportate:\n• Usa ambiente della macchina (consigliato)\n• Usa chiave salvata nelle impostazioni dell’app\n• Inserisci una chiave solo per questa sessione', + sectionTitle: 'Requisiti', + sectionSubtitle: 'Questi campi servono per verificare lo stato e evitare fallimenti inattesi.', + secretEnvVarPromptDescription: 'Inserisci il nome della variabile d’ambiente segreta richiesta (es. OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Questo profilo richiede ${env}. Scegli un’opzione qui sotto.`, + modalHelpGeneric: 'Questo profilo richiede una chiave API. Scegli un’opzione qui sotto.', + modalRecommendation: 'Consigliato: imposta la chiave nell’ambiente del daemon sul tuo computer (così non dovrai incollarla di nuovo). Poi riavvia il daemon per caricare la nuova variabile d’ambiente.', + chooseOptionTitle: 'Scegli un’opzione', + machineEnvStatus: { + theMachine: 'la macchina', + checkFor: ({ env }: { env: string }) => `Controlla ${env}`, + checking: ({ env }: { env: string }) => `Verifica ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} trovato su ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} non trovato su ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Verifica ambiente del daemon…', + found: 'Trovato nell’ambiente del daemon sulla macchina.', + notFound: 'Impostalo nell’ambiente del daemon sulla macchina e riavvia il daemon.', + }, + options: { + none: { + title: 'Nessuno', + subtitle: 'Non richiede chiave API né login CLI.', + }, + apiKeyEnv: { + subtitle: 'Richiede una chiave API da iniettare all’avvio della sessione.', + }, + machineLogin: { + subtitle: 'Richiede essere autenticati tramite un CLI sulla macchina di destinazione.', + longSubtitle: 'Richiede essere autenticati tramite il CLI per il backend IA scelto sulla macchina di destinazione.', + }, + useMachineEnvironment: { + title: 'Usa ambiente della macchina', + subtitleWithEnv: ({ env }: { env: string }) => `Usa ${env} dall’ambiente del daemon.`, + subtitleGeneric: 'Usa la chiave dall’ambiente del daemon.', + }, + useSavedApiKey: { + title: 'Usa una chiave API salvata', + subtitle: 'Seleziona (o aggiungi) una chiave salvata nell’app.', + }, + enterOnce: { + title: 'Inserisci una chiave', + subtitle: 'Incolla una chiave solo per questa sessione (non verrà salvata).', + }, + }, + apiKeyEnvVar: { + title: 'Variabile d’ambiente della chiave API', + subtitle: 'Inserisci il nome della variabile d’ambiente che questo provider si aspetta per la chiave API (es. OPENAI_API_KEY).', + label: 'Nome variabile d’ambiente', + }, + sections: { + machineEnvironment: 'Ambiente della macchina', + useOnceTitle: 'Usa una volta', + useOnceFooter: 'Incolla una chiave solo per questa sessione. Non verrà salvata.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Inizia con la chiave già presente sulla macchina.', + }, + useOnceButton: 'Usa una volta (solo sessione)', + }, + }, defaultSessionType: 'Tipo di sessione predefinito', defaultPermissionMode: { title: 'Modalità di permesso predefinita', @@ -216,7 +303,7 @@ export const it: TranslationStructure = { fixed: 'Fisso', machine: 'Macchina', checking: 'Verifica', - fallback: 'Fallback', + fallback: 'Alternativa', missing: 'Mancante', }, }, @@ -253,6 +340,15 @@ export const it: TranslationStructure = { enterSecretKey: 'Inserisci la chiave segreta', invalidSecretKey: 'Chiave segreta non valida. Controlla e riprova.', enterUrlManually: 'Inserisci URL manualmente', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Apri Happy sul tuo dispositivo mobile\n2. Vai su Impostazioni → Account\n3. Tocca "Collega nuovo dispositivo"\n4. Scansiona questo codice QR', + restoreWithSecretKeyInstead: 'Ripristina con chiave segreta', + restoreWithSecretKeyDescription: 'Inserisci la chiave segreta per ripristinare l’accesso al tuo account.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Connetti ${name}`, + runCommandInTerminal: 'Esegui il seguente comando nel terminale:', + }, }, settings: { @@ -293,6 +389,8 @@ export const it: TranslationStructure = { usageSubtitle: 'Vedi il tuo utilizzo API e i costi', profiles: 'Profili', profilesSubtitle: 'Gestisci i profili delle variabili ambiente per le sessioni', + apiKeys: 'Chiavi API', + apiKeysSubtitle: 'Gestisci le chiavi API salvate (non verranno più mostrate dopo l’inserimento)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Account ${service} collegato`, @@ -330,6 +428,21 @@ export const it: TranslationStructure = { wrapLinesInDiffsDescription: 'A capo delle righe lunghe invece dello scorrimento orizzontale nelle viste diff', alwaysShowContextSize: 'Mostra sempre dimensione contesto', alwaysShowContextSizeDescription: 'Mostra l\'uso del contesto anche quando non è vicino al limite', + agentInputActionBarLayout: 'Barra azioni di input', + agentInputActionBarLayoutDescription: 'Scegli come vengono mostrati i chip azione sopra il campo di input', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'A capo', + scroll: 'Scorrevole', + collapsed: 'Compresso', + }, + agentInputChipDensity: 'Densità dei chip azione', + agentInputChipDensityDescription: 'Scegli se i chip azione mostrano etichette o icone', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Etichette', + icons: 'Solo icone', + }, avatarStyle: 'Stile avatar', avatarStyleDescription: 'Scegli l\'aspetto dell\'avatar di sessione', avatarOptions: { @@ -350,6 +463,22 @@ export const it: TranslationStructure = { experimentalFeatures: 'Funzionalità sperimentali', experimentalFeaturesEnabled: 'Funzionalità sperimentali abilitate', experimentalFeaturesDisabled: 'Usando solo funzionalità stabili', + experimentalOptions: 'Opzioni sperimentali', + experimentalOptionsDescription: 'Scegli quali funzionalità sperimentali sono abilitate.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Funzionalità web', webFeaturesDescription: 'Funzionalità disponibili solo nella versione web dell\'app.', enterToSend: 'Invio con Enter', @@ -358,7 +487,7 @@ export const it: TranslationStructure = { commandPalette: 'Palette comandi', commandPaletteEnabled: 'Premi ⌘K per aprire', commandPaletteDisabled: 'Accesso rapido ai comandi disabilitato', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Copia Markdown v2', markdownCopyV2Subtitle: 'Pressione lunga apre la finestra di copia', hideInactiveSessions: 'Nascondi sessioni inattive', hideInactiveSessionsSubtitle: 'Mostra solo le chat attive nella tua lista', @@ -426,8 +555,26 @@ export const it: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Avvia nuova sessione', + selectAiProfileTitle: 'Seleziona profilo IA', + selectAiProfileDescription: 'Seleziona un profilo IA per applicare variabili d’ambiente e valori predefiniti alla sessione.', + changeProfile: 'Cambia profilo', + aiBackendSelectedByProfile: 'Il backend IA è determinato dal profilo. Per cambiarlo, seleziona un profilo diverso.', + selectAiBackendTitle: 'Seleziona backend IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitato dal profilo selezionato e dalle CLI disponibili su questa macchina.', + aiBackendSelectWhichAiRuns: 'Seleziona quale IA esegue la sessione.', + aiBackendNotCompatibleWithSelectedProfile: 'Non compatibile con il profilo selezionato.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata su questa macchina.`, selectMachineTitle: 'Seleziona macchina', + selectMachineDescription: 'Scegli dove viene eseguita questa sessione.', selectPathTitle: 'Seleziona percorso', + selectWorkingDirectoryTitle: 'Seleziona directory di lavoro', + selectWorkingDirectoryDescription: 'Scegli la cartella usata per comandi e contesto.', + selectPermissionModeTitle: 'Seleziona modalità di permessi', + selectPermissionModeDescription: 'Controlla quanto rigidamente le azioni richiedono approvazione.', + selectModelTitle: 'Seleziona modello IA', + selectModelDescription: 'Scegli il modello usato da questa sessione.', + selectSessionTypeTitle: 'Seleziona tipo di sessione', + selectSessionTypeDescription: 'Scegli una sessione semplice o una collegata a una worktree Git.', searchPathsPlaceholder: 'Cerca percorsi...', noMachinesFound: 'Nessuna macchina trovata. Avvia prima una sessione Happy sul tuo computer.', allMachinesOffline: 'Tutte le macchine sembrano offline', @@ -467,9 +614,23 @@ export const it: TranslationStructure = { sessionType: { title: 'Tipo di sessione', simple: 'Semplice', - worktree: 'Worktree', + worktree: 'Worktree (Git)', comingSoon: 'In arrivo', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Richiede ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata`, + dontShowFor: 'Non mostrare questo avviso per', + thisMachine: 'questa macchina', + anyMachine: 'qualsiasi macchina', + installCommand: ({ command }: { command: string }) => `Installa: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Installa la CLI di ${cli} se disponibile •`, + viewInstallationGuide: 'Vedi guida di installazione →', + viewGeminiDocs: 'Vedi documentazione Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Creazione worktree '${name}'...`, notGitRepo: 'Le worktree richiedono un repository git', @@ -494,6 +655,19 @@ export const it: TranslationStructure = { commandPalette: { placeholder: 'Digita un comando o cerca...', + noCommandsFound: 'Nessun comando trovato', + }, + + commandView: { + completedWithNoOutput: '[Comando completato senza output]', + }, + + voiceAssistant: { + connecting: 'Connessione...', + active: 'Assistente vocale attivo', + connectionError: 'Errore di connessione', + label: 'Assistente vocale', + tapToEnd: 'Tocca per terminare', }, server: { @@ -546,9 +720,12 @@ export const it: TranslationStructure = { path: 'Percorso', operatingSystem: 'Sistema operativo', processId: 'ID processo', - happyHome: 'Happy Home', + happyHome: 'Home di Happy', copyMetadata: 'Copia metadati', agentState: 'Stato agente', + rawJsonDevMode: 'JSON grezzo (modalità sviluppatore)', + sessionStatus: 'Stato sessione', + fullSessionObject: 'Oggetto sessione completo', controlledByUser: 'Controllato dall\'utente', pendingRequests: 'Richieste in sospeso', activity: 'Attività', @@ -576,6 +753,35 @@ export const it: TranslationStructure = { runIt: 'Avviala', scanQrCode: 'Scansiona il codice QR', openCamera: 'Apri fotocamera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Ancora nessun messaggio', + created: ({ time }: { time: string }) => `Creato ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Nessuna sessione attiva', + startNewSessionDescription: 'Avvia una nuova sessione su una delle tue macchine collegate.', + startNewSessionButton: 'Avvia nuova sessione', + openTerminalToStart: 'Apri un nuovo terminale sul computer per avviare una sessione.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Cosa bisogna fare?', + }, + home: { + noTasksYet: 'Ancora nessuna attività. Tocca + per aggiungerne una.', + }, + view: { + workOnTask: 'Lavora sul compito', + clarify: 'Chiarisci', + delete: 'Elimina', + linkedSessions: 'Sessioni collegate', + tapTaskTextToEdit: 'Tocca il testo del compito per modificarlo', }, }, @@ -658,6 +864,11 @@ export const it: TranslationStructure = { fileLabel: 'FILE', folderLabel: 'CARTELLA', }, + actionMenu: { + title: 'AZIONI', + files: 'File', + stop: 'Ferma', + }, noMachinesAvailable: 'Nessuna macchina', }, @@ -762,7 +973,7 @@ export const it: TranslationStructure = { loadingFile: ({ fileName }: { fileName: string }) => `Caricamento ${fileName}...`, binaryFile: 'File binario', cannotDisplayBinary: 'Impossibile mostrare il contenuto del file binario', - diff: 'Diff', + diff: 'Differenze', file: 'File', fileEmpty: 'File vuoto', noChanges: 'Nessuna modifica da mostrare', @@ -882,6 +1093,11 @@ export const it: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo collegato con successo', terminalConnectedSuccessfully: 'Terminale collegato con successo', invalidAuthUrl: 'URL di autenticazione non valido', + microphoneAccessRequiredTitle: 'Accesso al microfono richiesto', + microphoneAccessRequiredRequestPermission: 'Happy ha bisogno dell’accesso al microfono per la chat vocale. Concedi il permesso quando richiesto.', + microphoneAccessRequiredEnableInSettings: 'Happy ha bisogno dell’accesso al microfono per la chat vocale. Abilita l’accesso al microfono nelle impostazioni del dispositivo.', + microphoneAccessRequiredBrowserInstructions: 'Consenti l’accesso al microfono nelle impostazioni del browser. Potrebbe essere necessario fare clic sull’icona del lucchetto nella barra degli indirizzi e abilitare il permesso del microfono per questo sito.', + openSettings: 'Apri impostazioni', developerMode: 'Modalità sviluppatore', developerModeEnabled: 'Modalità sviluppatore attivata', developerModeDisabled: 'Modalità sviluppatore disattivata', @@ -933,9 +1149,18 @@ export const it: TranslationStructure = { launchNewSessionInDirectory: 'Avvia nuova sessione nella directory', offlineUnableToSpawn: 'Avvio disabilitato quando la macchina è offline', offlineHelp: '• Assicurati che il tuo computer sia online\n• Esegui `happy daemon status` per diagnosticare\n• Stai usando l\'ultima versione della CLI? Aggiorna con `npm install -g happy-coder@latest`', - daemon: 'Daemon', + daemon: 'Demone', status: 'Stato', stopDaemon: 'Arresta daemon', + stopDaemonConfirmTitle: 'Arrestare il daemon?', + stopDaemonConfirmBody: 'Non potrai avviare nuove sessioni su questa macchina finché non riavvii il daemon sul computer. Le sessioni correnti resteranno attive.', + daemonStoppedTitle: 'Daemon arrestato', + stopDaemonFailed: 'Impossibile arrestare il daemon. Potrebbe non essere in esecuzione.', + renameTitle: 'Rinomina macchina', + renameDescription: 'Assegna a questa macchina un nome personalizzato. Lascia vuoto per usare l’hostname predefinito.', + renamePlaceholder: 'Inserisci nome macchina', + renamedSuccess: 'Macchina rinominata correttamente', + renameFailed: 'Impossibile rinominare la macchina', lastKnownPid: 'Ultimo PID noto', lastKnownHttpPort: 'Ultima porta HTTP nota', startedAt: 'Avviato alle', @@ -952,8 +1177,15 @@ export const it: TranslationStructure = { lastSeen: 'Ultimo accesso', never: 'Mai', metadataVersion: 'Versione metadati', + detectedClis: 'CLI rilevate', + detectedCliNotDetected: 'Non rilevata', + detectedCliUnknown: 'Sconosciuta', + detectedCliNotSupported: 'Non supportata (aggiorna happy-cli)', untitledSession: 'Sessione senza titolo', back: 'Indietro', + notFound: 'Macchina non trovata', + unknownMachine: 'macchina sconosciuta', + unknownPath: 'percorso sconosciuto', }, message: { @@ -963,6 +1195,10 @@ export const it: TranslationStructure = { unknownTime: 'ora sconosciuta', }, + chatFooter: { + permissionsTerminalOnly: 'I permessi vengono mostrati solo nel terminale. Reimposta o invia un messaggio per controllare dall’app.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -989,6 +1225,7 @@ export const it: TranslationStructure = { textCopied: 'Testo copiato negli appunti', failedToCopy: 'Impossibile copiare il testo negli appunti', noTextToCopy: 'Nessun testo disponibile da copiare', + failedToOpen: 'Impossibile aprire la selezione del testo. Riprova.', }, markdown: { @@ -1009,11 +1246,14 @@ export const it: TranslationStructure = { edit: 'Modifica artefatto', delete: 'Elimina', updateError: 'Impossibile aggiornare l\'artefatto. Riprova.', + deleteError: 'Impossibile eliminare l’artefatto. Riprova.', notFound: 'Artefatto non trovato', discardChanges: 'Scartare le modifiche?', discardChangesDescription: 'Hai modifiche non salvate. Sei sicuro di volerle scartare?', deleteConfirm: 'Eliminare artefatto?', deleteConfirmDescription: 'Questa azione non può essere annullata', + noContent: 'Nessun contenuto', + untitled: 'Senza titolo', titleLabel: 'TITOLO', titlePlaceholder: 'Inserisci un titolo per il tuo artefatto', bodyLabel: 'CONTENUTO', @@ -1091,6 +1331,45 @@ export const it: TranslationStructure = { noData: 'Nessun dato di utilizzo disponibile', }, + apiKeys: { + addTitle: 'Nuova chiave API', + savedTitle: 'Chiavi API salvate', + badgeReady: 'Chiave API', + badgeRequired: 'Chiave API richiesta', + addSubtitle: 'Aggiungi una chiave API salvata', + noneTitle: 'Nessuna', + noneSubtitle: 'Usa l’ambiente della macchina o inserisci una chiave per questa sessione', + emptyTitle: 'Nessuna chiave salvata', + emptySubtitle: 'Aggiungine una per usare profili con chiave API senza impostare variabili d’ambiente sulla macchina.', + savedHiddenSubtitle: 'Salvata (valore nascosto)', + defaultLabel: 'Predefinita', + fields: { + name: 'Nome', + value: 'Valore', + }, + placeholders: { + nameExample: 'es. Work OpenAI', + }, + validation: { + nameRequired: 'Il nome è obbligatorio.', + valueRequired: 'Il valore è obbligatorio.', + }, + actions: { + replace: 'Sostituisci', + replaceValue: 'Sostituisci valore', + setDefault: 'Imposta come predefinita', + unsetDefault: 'Rimuovi predefinita', + }, + prompts: { + renameTitle: 'Rinomina chiave API', + renameDescription: 'Aggiorna il nome descrittivo di questa chiave.', + replaceValueTitle: 'Sostituisci valore della chiave API', + replaceValueDescription: 'Incolla il nuovo valore della chiave API. Questo valore non verrà mostrato di nuovo dopo il salvataggio.', + deleteTitle: 'Elimina chiave API', + deleteConfirm: ({ name }: { name: string }) => `Eliminare “${name}”? Questa azione non può essere annullata.`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} ti ha inviato una richiesta di amicizia`, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 4118d55f9..65f649ba4 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -61,6 +61,7 @@ export const ja: TranslationStructure = { all: 'すべて', machine: 'マシン', clearSearch: '検索をクリア', + refresh: '更新', saveAs: '名前を付けて保存', }, @@ -99,8 +100,8 @@ export const ja: TranslationStructure = { custom: 'カスタム', builtInSaveAsHint: '組み込みプロファイルを保存すると、新しいカスタムプロファイルが作成されます。', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic(デフォルト)', + deepseek: 'DeepSeek(推論)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -124,6 +125,92 @@ export const ja: TranslationStructure = { title: 'セットアップ手順', viewOfficialGuide: '公式セットアップガイドを表示', }, + machineLogin: { + title: 'マシンでのログインが必要', + subtitle: 'このプロファイルは、選択したマシン上の CLI ログインキャッシュに依存します。', + claudeCode: { + title: 'Claude Code', + instructions: '`claude` を実行し、`/login` と入力してログインしてください。', + warning: '注意: `ANTHROPIC_AUTH_TOKEN` を設定すると CLI ログインを上書きします。', + }, + codex: { + title: 'Codex', + instructions: '`codex login` を実行してログインしてください。', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: '`gemini auth` を実行してログインしてください。', + }, + }, + requirements: { + apiKeyRequired: 'APIキー', + configured: 'マシンで設定済み', + notConfigured: '未設定', + checking: '確認中…', + modalTitle: 'APIキーが必要です', + modalBody: 'このプロファイルにはAPIキーが必要です。\n\n利用可能な選択肢:\n• マシン環境を使用(推奨)\n• アプリ設定の保存済みキーを使用\n• このセッションのみキーを入力', + sectionTitle: '要件', + sectionSubtitle: 'これらの項目は事前チェックのために使用され、予期しない失敗を避けます。', + secretEnvVarPromptDescription: '必要な秘密環境変数名を入力してください(例: OPENAI_API_KEY)。', + modalHelpWithEnv: ({ env }: { env: string }) => `このプロファイルには${env}が必要です。以下から1つ選択してください。`, + modalHelpGeneric: 'このプロファイルにはAPIキーが必要です。以下から1つ選択してください。', + modalRecommendation: '推奨: コンピュータ上のデーモン環境にキーを設定してください(再度貼り付ける必要がなくなります)。その後デーモンを再起動して、新しい環境変数を読み込ませてください。', + chooseOptionTitle: '選択してください', + machineEnvStatus: { + theMachine: 'マシン', + checkFor: ({ env }: { env: string }) => `${env} を確認`, + checking: ({ env }: { env: string }) => `${env} を確認中…`, + found: ({ env, machine }: { env: string; machine: string }) => `${machine}で${env}が見つかりました`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${machine}で${env}が見つかりません`, + }, + machineEnvSubtitle: { + checking: 'デーモン環境を確認中…', + found: 'マシン上のデーモン環境で見つかりました。', + notFound: 'マシン上のデーモン環境に設定して、デーモンを再起動してください。', + }, + options: { + none: { + title: 'なし', + subtitle: 'APIキーもCLIログインも不要です。', + }, + apiKeyEnv: { + subtitle: 'セッション開始時に注入されるAPIキーが必要です。', + }, + machineLogin: { + subtitle: 'ターゲットマシンでCLIからログインしている必要があります。', + longSubtitle: 'ターゲットマシンで選択したAIバックエンドのCLIにログインしている必要があります。', + }, + useMachineEnvironment: { + title: 'マシン環境を使用', + subtitleWithEnv: ({ env }: { env: string }) => `デーモン環境から${env}を使用します。`, + subtitleGeneric: 'デーモン環境からキーを使用します。', + }, + useSavedApiKey: { + title: '保存済みAPIキーを使用', + subtitle: 'アプリ内の保存済みキーを選択(または追加)します。', + }, + enterOnce: { + title: 'キーを入力', + subtitle: 'このセッションのみキーを貼り付けます(保存されません)。', + }, + }, + apiKeyEnvVar: { + title: 'APIキーの環境変数', + subtitle: 'このプロバイダがAPIキーに期待する環境変数名を入力してください(例: OPENAI_API_KEY)。', + label: '環境変数名', + }, + sections: { + machineEnvironment: 'マシン環境', + useOnceTitle: '一度だけ使用', + useOnceFooter: 'このセッションのみキーを貼り付けます。保存されません。', + }, + actions: { + useMachineEnvironment: { + subtitle: 'マシンに既にあるキーを使用して開始します。', + }, + useOnceButton: '一度だけ使用(セッションのみ)', + }, + }, defaultSessionType: 'デフォルトのセッションタイプ', defaultPermissionMode: { title: 'デフォルトの権限モード', @@ -137,9 +224,9 @@ export const ja: TranslationStructure = { aiBackend: { title: 'AIバックエンド', selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。', - claudeSubtitle: 'Claude CLI', - codexSubtitle: 'Codex CLI', - geminiSubtitleExperimental: 'Gemini CLI(実験)', + claudeSubtitle: 'Claude コマンドライン', + codexSubtitle: 'Codex コマンドライン', + geminiSubtitleExperimental: 'Gemini コマンドライン(実験)', }, tmux: { title: 'Tmux', @@ -246,6 +333,15 @@ export const ja: TranslationStructure = { enterSecretKey: 'シークレットキーを入力してください', invalidSecretKey: 'シークレットキーが無効です。確認して再試行してください。', enterUrlManually: 'URLを手動で入力', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. モバイル端末で Happy を開く\n2. 設定 → アカウント に移動\n3. 「新しいデバイスをリンク」をタップ\n4. この QR コードをスキャン', + restoreWithSecretKeyInstead: '秘密鍵で復元する', + restoreWithSecretKeyDescription: 'アカウントへのアクセスを復元するには秘密鍵を入力してください。', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `${name} を接続`, + runCommandInTerminal: 'ターミナルで次のコマンドを実行してください:', + }, }, settings: { @@ -286,6 +382,8 @@ export const ja: TranslationStructure = { usageSubtitle: 'API使用量とコストを確認', profiles: 'プロファイル', profilesSubtitle: 'セッション用の環境変数プロファイルを管理', + apiKeys: 'APIキー', + apiKeysSubtitle: '保存したAPIキーを管理(入力後は再表示されません)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `${service}アカウントが接続されました`, @@ -323,6 +421,21 @@ export const ja: TranslationStructure = { wrapLinesInDiffsDescription: '差分表示で水平スクロールの代わりに長い行を折り返す', alwaysShowContextSize: '常にコンテキストサイズを表示', alwaysShowContextSizeDescription: '上限に近づいていなくてもコンテキスト使用量を表示', + agentInputActionBarLayout: '入力アクションバー', + agentInputActionBarLayoutDescription: '入力欄の上に表示するアクションチップの表示方法を選択します', + agentInputActionBarLayoutOptions: { + auto: '自動', + wrap: '折り返し', + scroll: 'スクロール', + collapsed: '折りたたみ', + }, + agentInputChipDensity: 'アクションチップ密度', + agentInputChipDensityDescription: 'アクションチップをラベル表示にするかアイコン表示にするか選択します', + agentInputChipDensityOptions: { + auto: '自動', + labels: 'ラベル', + icons: 'アイコンのみ', + }, avatarStyle: 'アバタースタイル', avatarStyleDescription: 'セッションアバターの外観を選択', avatarOptions: { @@ -343,6 +456,22 @@ export const ja: TranslationStructure = { experimentalFeatures: '実験的機能', experimentalFeaturesEnabled: '実験的機能が有効です', experimentalFeaturesDisabled: '安定版機能のみを使用', + experimentalOptions: '実験オプション', + experimentalOptionsDescription: '有効にする実験的機能を選択します。', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Web機能', webFeaturesDescription: 'Webバージョンでのみ利用可能な機能。', enterToSend: 'Enterで送信', @@ -419,8 +548,26 @@ export const ja: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '新しいセッションを開始', + selectAiProfileTitle: 'AIプロファイルを選択', + selectAiProfileDescription: '環境変数とデフォルト設定をセッションに適用するため、AIプロファイルを選択してください。', + changeProfile: 'プロファイルを変更', + aiBackendSelectedByProfile: 'AIバックエンドはプロファイルで選択されています。変更するには別のプロファイルを選択してください。', + selectAiBackendTitle: 'AIバックエンドを選択', + aiBackendLimitedByProfileAndMachineClis: '選択したプロファイルと、このマシンで利用可能なCLIによって制限されます。', + aiBackendSelectWhichAiRuns: 'セッションで実行するAIを選択してください。', + aiBackendNotCompatibleWithSelectedProfile: '選択したプロファイルと互換性がありません。', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `このマシンで${cli} CLIが検出されませんでした。`, selectMachineTitle: 'マシンを選択', + selectMachineDescription: 'このセッションを実行する場所を選択します。', selectPathTitle: 'パスを選択', + selectWorkingDirectoryTitle: '作業ディレクトリを選択', + selectWorkingDirectoryDescription: 'コマンドとコンテキストに使用するフォルダを選択してください。', + selectPermissionModeTitle: '権限モードを選択', + selectPermissionModeDescription: '操作にどの程度承認が必要かを設定します。', + selectModelTitle: 'AIモデルを選択', + selectModelDescription: 'このセッションで使用するモデルを選択してください。', + selectSessionTypeTitle: 'セッションタイプを選択', + selectSessionTypeDescription: 'シンプルなセッション、またはGitのワークツリーに紐づくセッションを選択してください。', searchPathsPlaceholder: 'パスを検索...', noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。', allMachinesOffline: 'すべてのマシンがオフラインです', @@ -463,6 +610,20 @@ export const ja: TranslationStructure = { worktree: 'ワークツリー', comingSoon: '近日公開', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `${agent} が必要`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI が検出されません`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI が検出されません`, + dontShowFor: 'このポップアップを表示しない:', + thisMachine: 'このマシン', + anyMachine: 'すべてのマシン', + installCommand: ({ command }: { command: string }) => `インストール: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `${cli} CLI が利用可能ならインストール •`, + viewInstallationGuide: 'インストールガイドを見る →', + viewGeminiDocs: 'Geminiドキュメントを見る →', + }, worktree: { creating: ({ name }: { name: string }) => `ワークツリー '${name}' を作成中...`, notGitRepo: 'ワークツリーにはGitリポジトリが必要です', @@ -487,6 +648,19 @@ export const ja: TranslationStructure = { commandPalette: { placeholder: 'コマンドを入力または検索...', + noCommandsFound: 'コマンドが見つかりません', + }, + + commandView: { + completedWithNoOutput: '[出力なしでコマンドが完了しました]', + }, + + voiceAssistant: { + connecting: '接続中...', + active: '音声アシスタントが有効です', + connectionError: '接続エラー', + label: '音声アシスタント', + tapToEnd: 'タップして終了', }, server: { @@ -513,14 +687,14 @@ export const ja: TranslationStructure = { killSessionConfirm: 'このセッションを終了してもよろしいですか?', archiveSession: 'セッションをアーカイブ', archiveSessionConfirm: 'このセッションをアーカイブしてもよろしいですか?', - happySessionIdCopied: 'Happy Session IDがクリップボードにコピーされました', - failedToCopySessionId: 'Happy Session IDのコピーに失敗しました', - happySessionId: 'Happy Session ID', - claudeCodeSessionId: 'Claude Code Session ID', - claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました', + happySessionIdCopied: 'Happy セッション ID をクリップボードにコピーしました', + failedToCopySessionId: 'Happy セッション ID のコピーに失敗しました', + happySessionId: 'Happy セッション ID', + claudeCodeSessionId: 'Claude Code セッション ID', + claudeCodeSessionIdCopied: 'Claude Code セッション ID をクリップボードにコピーしました', aiProfile: 'AIプロファイル', aiProvider: 'AIプロバイダー', - failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました', + failedToCopyClaudeCodeSessionId: 'Claude Code セッション ID のコピーに失敗しました', metadataCopied: 'メタデータがクリップボードにコピーされました', failedToCopyMetadata: 'メタデータのコピーに失敗しました', failedToKillSession: 'セッションの終了に失敗しました', @@ -539,9 +713,12 @@ export const ja: TranslationStructure = { path: 'パス', operatingSystem: 'オペレーティングシステム', processId: 'プロセスID', - happyHome: 'Happy Home', + happyHome: 'Happy のホーム', copyMetadata: 'メタデータをコピー', agentState: 'エージェント状態', + rawJsonDevMode: '生JSON(開発者モード)', + sessionStatus: 'セッションステータス', + fullSessionObject: 'セッションオブジェクト全体', controlledByUser: 'ユーザーによる制御', pendingRequests: '保留中のリクエスト', activity: 'アクティビティ', @@ -569,6 +746,35 @@ export const ja: TranslationStructure = { runIt: '実行する', scanQrCode: 'QRコードをスキャン', openCamera: 'カメラを開く', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'まだメッセージはありません', + created: ({ time }: { time: string }) => `作成 ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'アクティブなセッションはありません', + startNewSessionDescription: '接続済みのどのマシンでも新しいセッションを開始できます。', + startNewSessionButton: '新しいセッションを開始', + openTerminalToStart: 'セッションを開始するには、コンピュータで新しいターミナルを開いてください。', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'やることは?', + }, + home: { + noTasksYet: 'まだタスクはありません。+ をタップして追加します。', + }, + view: { + workOnTask: 'タスクに取り組む', + clarify: '明確化', + delete: '削除', + linkedSessions: 'リンクされたセッション', + tapTaskTextToEdit: 'タスクのテキストをタップして編集', }, }, @@ -651,6 +857,11 @@ export const ja: TranslationStructure = { fileLabel: 'ファイル', folderLabel: 'フォルダ', }, + actionMenu: { + title: '操作', + files: 'ファイル', + stop: '停止', + }, noMachinesAvailable: 'マシンなし', }, @@ -739,7 +950,7 @@ export const ja: TranslationStructure = { files: { searchPlaceholder: 'ファイルを検索...', - detachedHead: 'detached HEAD', + detachedHead: '切り離された HEAD', summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `ステージ済み ${staged} • 未ステージ ${unstaged}`, notRepo: 'Gitリポジトリではありません', notUnderGit: 'このディレクトリはGitバージョン管理下にありません', @@ -875,6 +1086,11 @@ export const ja: TranslationStructure = { deviceLinkedSuccessfully: 'デバイスが正常にリンクされました', terminalConnectedSuccessfully: 'ターミナルが正常に接続されました', invalidAuthUrl: '無効な認証URL', + microphoneAccessRequiredTitle: 'マイクへのアクセスが必要です', + microphoneAccessRequiredRequestPermission: 'Happy は音声チャットのためにマイクへのアクセスが必要です。求められたら許可してください。', + microphoneAccessRequiredEnableInSettings: 'Happy は音声チャットのためにマイクへのアクセスが必要です。端末の設定でマイクのアクセスを有効にしてください。', + microphoneAccessRequiredBrowserInstructions: 'ブラウザの設定でマイクへのアクセスを許可してください。アドレスバーの鍵アイコンをクリックし、このサイトのマイク権限を有効にする必要がある場合があります。', + openSettings: '設定を開く', developerMode: '開発者モード', developerModeEnabled: '開発者モードが有効になりました', developerModeDisabled: '開発者モードが無効になりました', @@ -929,6 +1145,15 @@ export const ja: TranslationStructure = { daemon: 'デーモン', status: 'ステータス', stopDaemon: 'デーモンを停止', + stopDaemonConfirmTitle: 'デーモンを停止しますか?', + stopDaemonConfirmBody: 'このマシンではデーモンを再起動するまで新しいセッションを作成できません。現在のセッションは継続します。', + daemonStoppedTitle: 'デーモンを停止しました', + stopDaemonFailed: 'デーモンを停止できませんでした。実行されていない可能性があります。', + renameTitle: 'マシン名を変更', + renameDescription: 'このマシンにカスタム名を設定します。空欄の場合はデフォルトのホスト名を使用します。', + renamePlaceholder: 'マシン名を入力', + renamedSuccess: 'マシン名を変更しました', + renameFailed: 'マシン名の変更に失敗しました', lastKnownPid: '最後に確認されたPID', lastKnownHttpPort: '最後に確認されたHTTPポート', startedAt: '開始時刻', @@ -945,8 +1170,15 @@ export const ja: TranslationStructure = { lastSeen: '最終確認', never: 'なし', metadataVersion: 'メタデータバージョン', + detectedClis: '検出されたCLI', + detectedCliNotDetected: '未検出', + detectedCliUnknown: '不明', + detectedCliNotSupported: '未対応(happy-cliを更新してください)', untitledSession: '無題のセッション', back: '戻る', + notFound: 'マシンが見つかりません', + unknownMachine: '不明なマシン', + unknownPath: '不明なパス', }, message: { @@ -956,6 +1188,10 @@ export const ja: TranslationStructure = { unknownTime: '不明な時間', }, + chatFooter: { + permissionsTerminalOnly: '権限はターミナルにのみ表示されます。リセットするかメッセージを送信して、アプリから制御してください。', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -982,6 +1218,7 @@ export const ja: TranslationStructure = { textCopied: 'テキストがクリップボードにコピーされました', failedToCopy: 'テキストのクリップボードへのコピーに失敗しました', noTextToCopy: 'コピーできるテキストがありません', + failedToOpen: 'テキスト選択を開けませんでした。もう一度お試しください。', }, markdown: { @@ -1002,11 +1239,14 @@ export const ja: TranslationStructure = { edit: 'アーティファクトを編集', delete: '削除', updateError: 'アーティファクトの更新に失敗しました。再試行してください。', + deleteError: 'アーティファクトを削除できませんでした。もう一度お試しください。', notFound: 'アーティファクトが見つかりません', discardChanges: '変更を破棄しますか?', discardChangesDescription: '保存されていない変更があります。破棄してもよろしいですか?', deleteConfirm: 'アーティファクトを削除しますか?', deleteConfirmDescription: 'この操作は取り消せません', + noContent: '内容がありません', + untitled: '無題', titleLabel: 'タイトル', titlePlaceholder: 'アーティファクトのタイトルを入力', bodyLabel: 'コンテンツ', @@ -1084,6 +1324,45 @@ export const ja: TranslationStructure = { noData: '使用データがありません', }, + apiKeys: { + addTitle: '新しいAPIキー', + savedTitle: '保存済みAPIキー', + badgeReady: 'APIキー', + badgeRequired: 'APIキーが必要', + addSubtitle: '保存済みAPIキーを追加', + noneTitle: 'なし', + noneSubtitle: 'マシン環境を使用するか、このセッション用にキーを入力してください', + emptyTitle: '保存済みキーがありません', + emptySubtitle: 'マシンの環境変数を設定せずにAPIキープロファイルを使うには、追加してください。', + savedHiddenSubtitle: '保存済み(値は非表示)', + defaultLabel: 'デフォルト', + fields: { + name: '名前', + value: '値', + }, + placeholders: { + nameExample: '例: Work OpenAI', + }, + validation: { + nameRequired: '名前は必須です。', + valueRequired: '値は必須です。', + }, + actions: { + replace: '置き換え', + replaceValue: '値を置き換え', + setDefault: 'デフォルトに設定', + unsetDefault: 'デフォルト解除', + }, + prompts: { + renameTitle: 'APIキー名を変更', + renameDescription: 'このキーの表示名を更新します。', + replaceValueTitle: 'APIキーの値を置き換え', + replaceValueDescription: '新しいAPIキーの値を貼り付けてください。保存後は再表示されません。', + deleteTitle: 'APIキーを削除', + deleteConfirm: ({ name }: { name: string }) => `「${name}」を削除しますか?元に戻せません。`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name}さんから友達リクエストが届きました`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index eb535306c..75a710a00 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -80,6 +80,7 @@ export const pl: TranslationStructure = { all: 'Wszystko', machine: 'maszyna', clearSearch: 'Wyczyść wyszukiwanie', + refresh: 'Odśwież', }, profile: { @@ -97,8 +98,8 @@ export const pl: TranslationStructure = { connecting: 'łączenie', disconnected: 'rozłączono', error: 'błąd', - online: 'online', - offline: 'offline', + online: 'w sieci', + offline: 'poza siecią', lastSeen: ({ time }: { time: string }) => `ostatnio widziano ${time}`, permissionRequired: 'wymagane uprawnienie', activeNow: 'Aktywny teraz', @@ -116,6 +117,15 @@ export const pl: TranslationStructure = { enterSecretKey: 'Proszę wprowadzić klucz tajny', invalidSecretKey: 'Nieprawidłowy klucz tajny. Sprawdź i spróbuj ponownie.', enterUrlManually: 'Wprowadź URL ręcznie', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Otwórz Happy na urządzeniu mobilnym\n2. Przejdź do Ustawienia → Konto\n3. Dotknij „Połącz nowe urządzenie”\n4. Zeskanuj ten kod QR', + restoreWithSecretKeyInstead: 'Przywróć za pomocą klucza tajnego', + restoreWithSecretKeyDescription: 'Wpisz swój klucz tajny, aby odzyskać dostęp do konta.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Połącz ${name}`, + runCommandInTerminal: 'Uruchom poniższe polecenie w terminalu:', + }, }, settings: { @@ -156,11 +166,13 @@ export const pl: TranslationStructure = { usageSubtitle: 'Zobacz użycie API i koszty', profiles: 'Profile', profilesSubtitle: 'Zarządzaj profilami zmiennych środowiskowych dla sesji', + apiKeys: 'Klucze API', + apiKeysSubtitle: 'Zarządzaj zapisanymi kluczami API (po wpisaniu nie będą ponownie pokazywane)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Konto ${service} połączone`, machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} jest ${status === 'online' ? 'online' : 'offline'}`, + `${name} jest ${status === 'online' ? 'w sieci' : 'poza siecią'}`, featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => `${feature} ${enabled ? 'włączona' : 'wyłączona'}`, }, @@ -193,6 +205,21 @@ export const pl: TranslationStructure = { wrapLinesInDiffsDescription: 'Zawijaj długie linie zamiast przewijania poziomego w widokach różnic', alwaysShowContextSize: 'Zawsze pokazuj rozmiar kontekstu', alwaysShowContextSizeDescription: 'Wyświetlaj użycie kontekstu nawet gdy nie jest blisko limitu', + agentInputActionBarLayout: 'Pasek akcji pola wpisywania', + agentInputActionBarLayoutDescription: 'Wybierz, jak wyświetlać chipy akcji nad polem wpisywania', + agentInputActionBarLayoutOptions: { + auto: 'Automatycznie', + wrap: 'Zawijanie', + scroll: 'Przewijany', + collapsed: 'Zwinięty', + }, + agentInputChipDensity: 'Gęstość chipów akcji', + agentInputChipDensityDescription: 'Wybierz, czy chipy akcji pokazują etykiety czy ikony', + agentInputChipDensityOptions: { + auto: 'Automatycznie', + labels: 'Etykiety', + icons: 'Tylko ikony', + }, avatarStyle: 'Styl awatara', avatarStyleDescription: 'Wybierz wygląd awatara sesji', avatarOptions: { @@ -213,6 +240,22 @@ export const pl: TranslationStructure = { experimentalFeatures: 'Funkcje eksperymentalne', experimentalFeaturesEnabled: 'Funkcje eksperymentalne włączone', experimentalFeaturesDisabled: 'Używane tylko stabilne funkcje', + experimentalOptions: 'Opcje eksperymentalne', + experimentalOptionsDescription: 'Wybierz, które funkcje eksperymentalne są włączone.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Funkcje webowe', webFeaturesDescription: 'Funkcje dostępne tylko w wersji webowej aplikacji.', enterToSend: 'Enter aby wysłać', @@ -221,7 +264,7 @@ export const pl: TranslationStructure = { commandPalette: 'Paleta poleceń', commandPaletteEnabled: 'Naciśnij ⌘K, aby otworzyć', commandPaletteDisabled: 'Szybki dostęp do poleceń wyłączony', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Kopiowanie Markdown v2', markdownCopyV2Subtitle: 'Długie naciśnięcie otwiera modal kopiowania', hideInactiveSessions: 'Ukryj nieaktywne sesje', hideInactiveSessionsSubtitle: 'Wyświetlaj tylko aktywne czaty na liście', @@ -289,11 +332,29 @@ export const pl: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Rozpocznij nową sesję', + selectAiProfileTitle: 'Wybierz profil AI', + selectAiProfileDescription: 'Wybierz profil AI, aby zastosować zmienne środowiskowe i domyślne ustawienia do sesji.', + changeProfile: 'Zmień profil', + aiBackendSelectedByProfile: 'Backend AI jest wybierany przez profil. Aby go zmienić, wybierz inny profil.', + selectAiBackendTitle: 'Wybierz backend AI', + aiBackendLimitedByProfileAndMachineClis: 'Ograniczone przez wybrany profil i dostępne CLI na tej maszynie.', + aiBackendSelectWhichAiRuns: 'Wybierz, które AI uruchamia Twoją sesję.', + aiBackendNotCompatibleWithSelectedProfile: 'Niezgodne z wybranym profilem.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli} na tej maszynie.`, selectMachineTitle: 'Wybierz maszynę', + selectMachineDescription: 'Wybierz, gdzie ta sesja działa.', selectPathTitle: 'Wybierz ścieżkę', + selectWorkingDirectoryTitle: 'Wybierz katalog roboczy', + selectWorkingDirectoryDescription: 'Wybierz folder używany dla poleceń i kontekstu.', + selectPermissionModeTitle: 'Wybierz tryb uprawnień', + selectPermissionModeDescription: 'Określ, jak ściśle akcje wymagają zatwierdzenia.', + selectModelTitle: 'Wybierz model AI', + selectModelDescription: 'Wybierz model używany przez tę sesję.', + selectSessionTypeTitle: 'Wybierz typ sesji', + selectSessionTypeDescription: 'Wybierz sesję prostą lub powiązaną z Git worktree.', searchPathsPlaceholder: 'Szukaj ścieżek...', noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.', - allMachinesOffline: 'Wszystkie maszyny są offline', + allMachinesOffline: 'Wszystkie maszyny są poza siecią', machineDetails: 'Zobacz szczegóły maszyny →', directoryDoesNotExist: 'Katalog nie został znaleziony', createDirectoryConfirm: ({ directory }: { directory: string }) => `Katalog ${directory} nie istnieje. Czy chcesz go utworzyć?`, @@ -330,9 +391,23 @@ export const pl: TranslationStructure = { sessionType: { title: 'Typ sesji', simple: 'Prosta', - worktree: 'Worktree', + worktree: 'Drzewo robocze', comingSoon: 'Wkrótce dostępne', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Wymaga ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli}`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli}`, + dontShowFor: 'Nie pokazuj tego komunikatu dla', + thisMachine: 'tej maszyny', + anyMachine: 'dowolnej maszyny', + installCommand: ({ command }: { command: string }) => `Zainstaluj: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Zainstaluj CLI ${cli}, jeśli jest dostępne •`, + viewInstallationGuide: 'Zobacz instrukcję instalacji →', + viewGeminiDocs: 'Zobacz dokumentację Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Tworzenie worktree '${name}'...`, notGitRepo: 'Worktree wymaga repozytorium git', @@ -357,6 +432,19 @@ export const pl: TranslationStructure = { commandPalette: { placeholder: 'Wpisz polecenie lub wyszukaj...', + noCommandsFound: 'Nie znaleziono poleceń', + }, + + commandView: { + completedWithNoOutput: '[Polecenie zakończone bez danych wyjściowych]', + }, + + voiceAssistant: { + connecting: 'Łączenie...', + active: 'Asystent głosowy aktywny', + connectionError: 'Błąd połączenia', + label: 'Asystent głosowy', + tapToEnd: 'Dotknij, aby zakończyć', }, server: { @@ -412,6 +500,9 @@ export const pl: TranslationStructure = { happyHome: 'Katalog domowy Happy', copyMetadata: 'Kopiuj metadane', agentState: 'Stan agenta', + rawJsonDevMode: 'Surowy JSON (tryb deweloperski)', + sessionStatus: 'Status sesji', + fullSessionObject: 'Pełny obiekt sesji', controlledByUser: 'Kontrolowany przez użytkownika', pendingRequests: 'Oczekujące żądania', activity: 'Aktywność', @@ -438,6 +529,35 @@ export const pl: TranslationStructure = { runIt: 'Uruchom je', scanQrCode: 'Zeskanuj kod QR', openCamera: 'Otwórz kamerę', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Brak wiadomości', + created: ({ time }: { time: string }) => `Utworzono ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Brak aktywnych sesji', + startNewSessionDescription: 'Rozpocznij nową sesję na dowolnej z połączonych maszyn.', + startNewSessionButton: 'Rozpocznij nową sesję', + openTerminalToStart: 'Otwórz nowy terminal na komputerze, aby rozpocząć sesję.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Co trzeba zrobić?', + }, + home: { + noTasksYet: 'Brak zadań. Stuknij +, aby dodać.', + }, + view: { + workOnTask: 'Pracuj nad zadaniem', + clarify: 'Doprecyzuj', + delete: 'Usuń', + linkedSessions: 'Powiązane sesje', + tapTaskTextToEdit: 'Stuknij tekst zadania, aby edytować', }, }, @@ -471,22 +591,22 @@ export const pl: TranslationStructure = { codexPermissionMode: { title: 'TRYB UPRAWNIEŃ CODEX', default: 'Ustawienia CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Tryb tylko do odczytu', + safeYolo: 'Bezpieczne YOLO', yolo: 'YOLO', badgeReadOnly: 'Tylko do odczytu', - badgeSafeYolo: 'Safe YOLO', + badgeSafeYolo: 'Bezpieczne 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', + title: 'MODEL CODEX', + gpt5CodexLow: 'gpt-5-codex niski', + gpt5CodexMedium: 'gpt-5-codex średni', + gpt5CodexHigh: 'gpt-5-codex wysoki', + gpt5Minimal: 'GPT-5 Minimalny', + gpt5Low: 'GPT-5 Niski', + gpt5Medium: 'GPT-5 Średni', + gpt5High: 'GPT-5 Wysoki', }, geminiPermissionMode: { title: 'TRYB UPRAWNIEŃ GEMINI', @@ -518,7 +638,12 @@ export const pl: TranslationStructure = { }, suggestion: { fileLabel: 'PLIK', - folderLabel: 'FOLDER', + folderLabel: 'KATALOG', + }, + actionMenu: { + title: 'AKCJE', + files: 'Pliki', + stop: 'Zatrzymaj', }, noMachinesAvailable: 'Brak maszyn', }, @@ -744,6 +869,11 @@ export const pl: TranslationStructure = { deviceLinkedSuccessfully: 'Urządzenie połączone pomyślnie', terminalConnectedSuccessfully: 'Terminal połączony pomyślnie', invalidAuthUrl: 'Nieprawidłowy URL uwierzytelnienia', + microphoneAccessRequiredTitle: 'Wymagany dostęp do mikrofonu', + microphoneAccessRequiredRequestPermission: 'Happy potrzebuje dostępu do mikrofonu do czatu głosowego. Udziel zgody, gdy pojawi się prośba.', + microphoneAccessRequiredEnableInSettings: 'Happy potrzebuje dostępu do mikrofonu do czatu głosowego. Włącz dostęp do mikrofonu w ustawieniach urządzenia.', + microphoneAccessRequiredBrowserInstructions: 'Zezwól na dostęp do mikrofonu w ustawieniach przeglądarki. Być może musisz kliknąć ikonę kłódki na pasku adresu i włączyć uprawnienie mikrofonu dla tej witryny.', + openSettings: 'Otwórz ustawienia', developerMode: 'Tryb deweloperski', developerModeEnabled: 'Tryb deweloperski włączony', developerModeDisabled: 'Tryb deweloperski wyłączony', @@ -795,9 +925,18 @@ export const pl: TranslationStructure = { offlineUnableToSpawn: 'Launcher wyłączony, gdy maszyna jest offline', offlineHelp: '• Upewnij się, że komputer jest online\n• Uruchom `happy daemon status`, aby zdiagnozować\n• Czy używasz najnowszej wersji CLI? Zaktualizuj poleceniem `npm install -g happy-coder@latest`', launchNewSessionInDirectory: 'Uruchom nową sesję w katalogu', - daemon: 'Daemon', + daemon: 'Demon', status: 'Status', stopDaemon: 'Zatrzymaj daemon', + stopDaemonConfirmTitle: 'Zatrzymać daemon?', + stopDaemonConfirmBody: 'Nie będziesz mógł tworzyć nowych sesji na tej maszynie, dopóki nie uruchomisz ponownie daemona na komputerze. Obecne sesje pozostaną aktywne.', + daemonStoppedTitle: 'Daemon zatrzymany', + stopDaemonFailed: 'Nie udało się zatrzymać daemona. Może nie działa.', + renameTitle: 'Zmień nazwę maszyny', + renameDescription: 'Nadaj tej maszynie własną nazwę. Pozostaw puste, aby użyć domyślnej nazwy hosta.', + renamePlaceholder: 'Wpisz nazwę maszyny', + renamedSuccess: 'Nazwa maszyny została zmieniona', + renameFailed: 'Nie udało się zmienić nazwy maszyny', lastKnownPid: 'Ostatni znany PID', lastKnownHttpPort: 'Ostatni znany port HTTP', startedAt: 'Uruchomiony o', @@ -814,8 +953,15 @@ export const pl: TranslationStructure = { lastSeen: 'Ostatnio widziana', never: 'Nigdy', metadataVersion: 'Wersja metadanych', + detectedClis: 'Wykryte CLI', + detectedCliNotDetected: 'Nie wykryto', + detectedCliUnknown: 'Nieznane', + detectedCliNotSupported: 'Nieobsługiwane (zaktualizuj happy-cli)', untitledSession: 'Sesja bez nazwy', back: 'Wstecz', + notFound: 'Nie znaleziono maszyny', + unknownMachine: 'nieznana maszyna', + unknownPath: 'nieznana ścieżka', }, message: { @@ -825,6 +971,10 @@ export const pl: TranslationStructure = { unknownTime: 'nieznany czas', }, + chatFooter: { + permissionsTerminalOnly: 'Uprawnienia są widoczne tylko w terminalu. Zresetuj lub wyślij wiadomość, aby sterować z aplikacji.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -851,6 +1001,7 @@ export const pl: TranslationStructure = { textCopied: 'Tekst skopiowany do schowka', failedToCopy: 'Nie udało się skopiować tekstu do schowka', noTextToCopy: 'Brak tekstu do skopiowania', + failedToOpen: 'Nie udało się otworzyć wyboru tekstu. Spróbuj ponownie.', }, markdown: { @@ -884,11 +1035,14 @@ export const pl: TranslationStructure = { edit: 'Edytuj artefakt', delete: 'Usuń', updateError: 'Nie udało się zaktualizować artefaktu. Spróbuj ponownie.', + deleteError: 'Nie udało się usunąć artefaktu. Spróbuj ponownie.', notFound: 'Artefakt nie został znaleziony', discardChanges: 'Odrzucić zmiany?', discardChangesDescription: 'Masz niezapisane zmiany. Czy na pewno chcesz je odrzucić?', deleteConfirm: 'Usunąć artefakt?', deleteConfirmDescription: 'Tej operacji nie można cofnąć', + noContent: 'Brak treści', + untitled: 'Bez tytułu', titleLabel: 'TYTUŁ', titlePlaceholder: 'Wprowadź tytuł dla swojego artefaktu', bodyLabel: 'TREŚĆ', @@ -974,6 +1128,45 @@ export const pl: TranslationStructure = { friendAcceptedGeneric: 'Zaproszenie do znajomych zaakceptowane', }, + apiKeys: { + addTitle: 'Nowy klucz API', + savedTitle: 'Zapisane klucze API', + badgeReady: 'Klucz API', + badgeRequired: 'Wymagany klucz API', + addSubtitle: 'Dodaj zapisany klucz API', + noneTitle: 'Brak', + noneSubtitle: 'Użyj środowiska maszyny lub wpisz klucz dla tej sesji', + emptyTitle: 'Brak zapisanych kluczy', + emptySubtitle: 'Dodaj jeden, aby używać profili z kluczem API bez ustawiania zmiennych środowiskowych na maszynie.', + savedHiddenSubtitle: 'Zapisany (wartość ukryta)', + defaultLabel: 'Domyślny', + fields: { + name: 'Nazwa', + value: 'Wartość', + }, + placeholders: { + nameExample: 'np. Work OpenAI', + }, + validation: { + nameRequired: 'Nazwa jest wymagana.', + valueRequired: 'Wartość jest wymagana.', + }, + actions: { + replace: 'Zastąp', + replaceValue: 'Zastąp wartość', + setDefault: 'Ustaw jako domyślny', + unsetDefault: 'Usuń domyślny', + }, + prompts: { + renameTitle: 'Zmień nazwę klucza API', + renameDescription: 'Zaktualizuj przyjazną nazwę dla tego klucza.', + replaceValueTitle: 'Zastąp wartość klucza API', + replaceValueDescription: 'Wklej nową wartość klucza API. Ta wartość nie będzie ponownie wyświetlana po zapisaniu.', + deleteTitle: 'Usuń klucz API', + deleteConfirm: ({ name }: { name: string }) => `Usunąć “${name}”? Tej czynności nie można cofnąć.`, + }, + }, + profiles: { // Profile management feature title: 'Profile', @@ -1001,7 +1194,7 @@ export const pl: TranslationStructure = { custom: 'Niestandardowe', builtInSaveAsHint: 'Zapisanie wbudowanego profilu tworzy nowy profil niestandardowy.', builtInNames: { - anthropic: 'Anthropic (Default)', + anthropic: 'Anthropic (Domyślny)', deepseek: 'DeepSeek (Reasoner)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', @@ -1026,6 +1219,92 @@ export const pl: TranslationStructure = { title: 'Instrukcje konfiguracji', viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji', }, + machineLogin: { + title: 'Wymagane logowanie na maszynie', + subtitle: 'Ten profil korzysta z pamięci podręcznej logowania CLI na wybranej maszynie.', + claudeCode: { + title: 'Claude Code', + instructions: 'Uruchom `claude`, a następnie wpisz `/login`, aby się zalogować.', + warning: 'Uwaga: ustawienie `ANTHROPIC_AUTH_TOKEN` zastępuje logowanie CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Uruchom `codex login`, aby się zalogować.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Uruchom `gemini auth`, aby się zalogować.', + }, + }, + requirements: { + apiKeyRequired: 'Klucz API', + configured: 'Skonfigurowano na maszynie', + notConfigured: 'Nie skonfigurowano', + checking: 'Sprawdzanie…', + modalTitle: 'Wymagany klucz API', + modalBody: 'Ten profil wymaga klucza API.\n\nDostępne opcje:\n• Użyj środowiska maszyny (zalecane)\n• Użyj zapisanego klucza z ustawień aplikacji\n• Wpisz klucz tylko dla tej sesji', + sectionTitle: 'Wymagania', + sectionSubtitle: 'Te pola służą do wstępnej weryfikacji i aby uniknąć niespodziewanych błędów.', + secretEnvVarPromptDescription: 'Wpisz nazwę wymaganej tajnej zmiennej środowiskowej (np. OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Ten profil wymaga ${env}. Wybierz jedną z opcji poniżej.`, + modalHelpGeneric: 'Ten profil wymaga klucza API. Wybierz jedną z opcji poniżej.', + modalRecommendation: 'Zalecane: ustaw klucz w środowisku daemona na komputerze (żeby nie wklejać go ponownie). Następnie uruchom ponownie daemona, aby wczytał nową zmienną środowiskową.', + chooseOptionTitle: 'Wybierz opcję', + machineEnvStatus: { + theMachine: 'maszynie', + checkFor: ({ env }: { env: string }) => `Sprawdź ${env}`, + checking: ({ env }: { env: string }) => `Sprawdzanie ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} znaleziono na ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} nie znaleziono na ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Sprawdzanie środowiska daemona…', + found: 'Znaleziono w środowisku daemona na maszynie.', + notFound: 'Ustaw w środowisku daemona na maszynie i uruchom ponownie daemona.', + }, + options: { + none: { + title: 'Brak', + subtitle: 'Nie wymaga klucza API ani logowania CLI.', + }, + apiKeyEnv: { + subtitle: 'Wymaga klucza API wstrzykiwanego przy starcie sesji.', + }, + machineLogin: { + subtitle: 'Wymaga zalogowania przez CLI na maszynie docelowej.', + longSubtitle: 'Wymaga zalogowania w CLI dla wybranego backendu AI na maszynie docelowej.', + }, + useMachineEnvironment: { + title: 'Użyj środowiska maszyny', + subtitleWithEnv: ({ env }: { env: string }) => `Użyj ${env} ze środowiska daemona.`, + subtitleGeneric: 'Użyj klucza ze środowiska daemona.', + }, + useSavedApiKey: { + title: 'Użyj zapisanego klucza API', + subtitle: 'Wybierz (lub dodaj) zapisany klucz w aplikacji.', + }, + enterOnce: { + title: 'Wpisz klucz', + subtitle: 'Wklej klucz tylko dla tej sesji (nie zostanie zapisany).', + }, + }, + apiKeyEnvVar: { + title: 'Zmienna środowiskowa klucza API', + subtitle: 'Wpisz nazwę zmiennej środowiskowej, której ten dostawca oczekuje dla klucza API (np. OPENAI_API_KEY).', + label: 'Nazwa zmiennej środowiskowej', + }, + sections: { + machineEnvironment: 'Środowisko maszyny', + useOnceTitle: 'Użyj raz', + useOnceFooter: 'Wklej klucz tylko dla tej sesji. Nie zostanie zapisany.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Rozpocznij z kluczem już obecnym na maszynie.', + }, + useOnceButton: 'Użyj raz (tylko sesja)', + }, + }, defaultSessionType: 'Domyślny typ sesji', defaultPermissionMode: { title: 'Domyślny tryb uprawnień', @@ -1111,7 +1390,7 @@ export const pl: TranslationStructure = { fixed: 'Stała', machine: 'Maszyna', checking: 'Sprawdzanie', - fallback: 'Fallback', + fallback: 'Wartość zapasowa', missing: 'Brak', }, }, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 55615a3d1..7c5c794c3 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -69,6 +69,7 @@ export const pt: TranslationStructure = { all: 'Todos', machine: 'máquina', clearSearch: 'Limpar pesquisa', + refresh: 'Atualizar', }, profile: { @@ -105,6 +106,15 @@ export const pt: TranslationStructure = { enterSecretKey: 'Por favor, insira uma chave secreta', invalidSecretKey: 'Chave secreta inválida. Verifique e tente novamente.', enterUrlManually: 'Inserir URL manualmente', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Abra o Happy no seu dispositivo móvel\n2. Vá em Configurações → Conta\n3. Toque em "Vincular novo dispositivo"\n4. Escaneie este código QR', + restoreWithSecretKeyInstead: 'Restaurar com chave secreta', + restoreWithSecretKeyDescription: 'Digite sua chave secreta para recuperar o acesso à sua conta.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Conectar ${name}`, + runCommandInTerminal: 'Execute o seguinte comando no terminal:', + }, }, settings: { @@ -145,6 +155,8 @@ export const pt: TranslationStructure = { usageSubtitle: 'Visualizar uso da API e custos', profiles: 'Perfis', profilesSubtitle: 'Gerenciar perfis de ambiente e variáveis', + apiKeys: 'Chaves de API', + apiKeysSubtitle: 'Gerencie as chaves de API salvas (não serão exibidas novamente após o envio)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Conta ${service} conectada`, @@ -182,6 +194,21 @@ export const pt: TranslationStructure = { wrapLinesInDiffsDescription: 'Quebrar linhas longas ao invés de rolagem horizontal nas visualizações de diffs', alwaysShowContextSize: 'Sempre mostrar tamanho do contexto', alwaysShowContextSizeDescription: 'Exibir uso do contexto mesmo quando não estiver próximo do limite', + agentInputActionBarLayout: 'Barra de ações do input', + agentInputActionBarLayoutDescription: 'Escolha como os chips de ação são exibidos acima do campo de entrada', + agentInputActionBarLayoutOptions: { + auto: 'Auto', + wrap: 'Quebrar linha', + scroll: 'Rolável', + collapsed: 'Recolhido', + }, + agentInputChipDensity: 'Densidade dos chips de ação', + agentInputChipDensityDescription: 'Escolha se os chips de ação exibem rótulos ou ícones', + agentInputChipDensityOptions: { + auto: 'Auto', + labels: 'Rótulos', + icons: 'Somente ícones', + }, avatarStyle: 'Estilo do avatar', avatarStyleDescription: 'Escolha a aparência do avatar da sessão', avatarOptions: { @@ -202,6 +229,22 @@ export const pt: TranslationStructure = { experimentalFeatures: 'Recursos experimentais', experimentalFeaturesEnabled: 'Recursos experimentais ativados', experimentalFeaturesDisabled: 'Usando apenas recursos estáveis', + experimentalOptions: 'Opções experimentais', + experimentalOptionsDescription: 'Escolha quais recursos experimentais estão ativados.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Recursos web', webFeaturesDescription: 'Recursos disponíveis apenas na versão web do aplicativo.', enterToSend: 'Enter para enviar', @@ -210,7 +253,7 @@ export const pt: TranslationStructure = { commandPalette: 'Paleta de comandos', commandPaletteEnabled: 'Pressione ⌘K para abrir', commandPaletteDisabled: 'Acesso rápido a comandos desativado', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Cópia de Markdown v2', markdownCopyV2Subtitle: 'Pressione e segure para abrir modal de cópia', hideInactiveSessions: 'Ocultar sessões inativas', hideInactiveSessionsSubtitle: 'Mostre apenas os chats ativos na sua lista', @@ -278,8 +321,26 @@ export const pt: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Iniciar nova sessão', + selectAiProfileTitle: 'Selecionar perfil de IA', + selectAiProfileDescription: 'Selecione um perfil de IA para aplicar variáveis de ambiente e padrões à sua sessão.', + changeProfile: 'Trocar perfil', + aiBackendSelectedByProfile: 'O backend de IA é selecionado pelo seu perfil. Para alterar, selecione um perfil diferente.', + selectAiBackendTitle: 'Selecionar backend de IA', + aiBackendLimitedByProfileAndMachineClis: 'Limitado pelo perfil selecionado e pelos CLIs disponíveis nesta máquina.', + aiBackendSelectWhichAiRuns: 'Selecione qual IA roda sua sessão.', + aiBackendNotCompatibleWithSelectedProfile: 'Não compatível com o perfil selecionado.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado nesta máquina.`, selectMachineTitle: 'Selecionar máquina', + selectMachineDescription: 'Escolha onde esta sessão será executada.', selectPathTitle: 'Selecionar caminho', + selectWorkingDirectoryTitle: 'Selecionar diretório de trabalho', + selectWorkingDirectoryDescription: 'Escolha a pasta usada para comandos e contexto.', + selectPermissionModeTitle: 'Selecionar modo de permissões', + selectPermissionModeDescription: 'Controle o quão estritamente as ações exigem aprovação.', + selectModelTitle: 'Selecionar modelo de IA', + selectModelDescription: 'Escolha o modelo usado por esta sessão.', + selectSessionTypeTitle: 'Selecionar tipo de sessão', + selectSessionTypeDescription: 'Escolha uma sessão simples ou uma vinculada a um worktree do Git.', searchPathsPlaceholder: 'Pesquisar caminhos...', noMachinesFound: 'Nenhuma máquina encontrada. Inicie uma sessão Happy no seu computador primeiro.', allMachinesOffline: 'Todas as máquinas estão offline', @@ -319,9 +380,23 @@ export const pt: TranslationStructure = { sessionType: { title: 'Tipo de sessão', simple: 'Simples', - worktree: 'Worktree', + worktree: 'Árvore de trabalho', comingSoon: 'Em breve', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Requer ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado`, + dontShowFor: 'Não mostrar este aviso para', + thisMachine: 'esta máquina', + anyMachine: 'qualquer máquina', + installCommand: ({ command }: { command: string }) => `Instalar: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Instale o CLI do ${cli} se disponível •`, + viewInstallationGuide: 'Ver guia de instalação →', + viewGeminiDocs: 'Ver docs do Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Criando worktree '${name}'...`, notGitRepo: 'Worktrees requerem um repositório git', @@ -346,6 +421,19 @@ export const pt: TranslationStructure = { commandPalette: { placeholder: 'Digite um comando ou pesquise...', + noCommandsFound: 'Nenhum comando encontrado', + }, + + commandView: { + completedWithNoOutput: '[Comando concluído sem saída]', + }, + + voiceAssistant: { + connecting: 'Conectando...', + active: 'Assistente de voz ativo', + connectionError: 'Erro de conexão', + label: 'Assistente de voz', + tapToEnd: 'Toque para encerrar', }, server: { @@ -401,6 +489,9 @@ export const pt: TranslationStructure = { happyHome: 'Diretório Happy', copyMetadata: 'Copiar metadados', agentState: 'Estado do agente', + rawJsonDevMode: 'JSON bruto (modo dev)', + sessionStatus: 'Status da sessão', + fullSessionObject: 'Objeto completo da sessão', controlledByUser: 'Controlado pelo usuário', pendingRequests: 'Solicitações pendentes', activity: 'Atividade', @@ -428,6 +519,35 @@ export const pt: TranslationStructure = { runIt: 'Execute', scanQrCode: 'Escaneie o código QR', openCamera: 'Abrir câmera', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Nenhuma mensagem ainda', + created: ({ time }: { time: string }) => `Criado ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Nenhuma sessão ativa', + startNewSessionDescription: 'Inicie uma nova sessão em qualquer uma das suas máquinas conectadas.', + startNewSessionButton: 'Iniciar nova sessão', + openTerminalToStart: 'Abra um novo terminal no computador para iniciar uma sessão.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'O que precisa ser feito?', + }, + home: { + noTasksYet: 'Ainda não há tarefas. Toque em + para adicionar.', + }, + view: { + workOnTask: 'Trabalhar na tarefa', + clarify: 'Esclarecer', + delete: 'Excluir', + linkedSessions: 'Sessões vinculadas', + tapTaskTextToEdit: 'Toque no texto da tarefa para editar', }, }, @@ -461,22 +581,22 @@ export const pt: TranslationStructure = { codexPermissionMode: { title: 'MODO DE PERMISSÃO CODEX', default: 'Configurações do CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Modo somente leitura', + safeYolo: 'YOLO seguro', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: 'Somente leitura', + badgeSafeYolo: 'YOLO seguro', 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', + title: 'MODELO CODEX', + gpt5CodexLow: 'gpt-5-codex baixo', + gpt5CodexMedium: 'gpt-5-codex médio', + gpt5CodexHigh: 'gpt-5-codex alto', + gpt5Minimal: 'GPT-5 Mínimo', + gpt5Low: 'GPT-5 Baixo', + gpt5Medium: 'GPT-5 Médio', + gpt5High: 'GPT-5 Alto', }, geminiPermissionMode: { title: 'MODO DE PERMISSÃO GEMINI', @@ -510,6 +630,11 @@ export const pt: TranslationStructure = { fileLabel: 'ARQUIVO', folderLabel: 'PASTA', }, + actionMenu: { + title: 'AÇÕES', + files: 'Arquivos', + stop: 'Parar', + }, noMachinesAvailable: 'Sem máquinas', }, @@ -614,7 +739,7 @@ export const pt: TranslationStructure = { loadingFile: ({ fileName }: { fileName: string }) => `Carregando ${fileName}...`, binaryFile: 'Arquivo binário', cannotDisplayBinary: 'Não é possível exibir o conteúdo do arquivo binário', - diff: 'Diff', + diff: 'Diferenças', file: 'Arquivo', fileEmpty: 'Arquivo está vazio', noChanges: 'Nenhuma alteração para exibir', @@ -734,6 +859,11 @@ export const pt: TranslationStructure = { deviceLinkedSuccessfully: 'Dispositivo vinculado com sucesso', terminalConnectedSuccessfully: 'Terminal conectado com sucesso', invalidAuthUrl: 'URL de autenticação inválida', + microphoneAccessRequiredTitle: 'É necessário acesso ao microfone', + microphoneAccessRequiredRequestPermission: 'O Happy precisa de acesso ao seu microfone para o chat por voz. Conceda a permissão quando solicitado.', + microphoneAccessRequiredEnableInSettings: 'O Happy precisa de acesso ao seu microfone para o chat por voz. Ative o acesso ao microfone nas configurações do seu dispositivo.', + microphoneAccessRequiredBrowserInstructions: 'Permita o acesso ao microfone nas configurações do navegador. Talvez seja necessário clicar no ícone de cadeado na barra de endereços e habilitar a permissão do microfone para este site.', + openSettings: 'Abrir configurações', developerMode: 'Modo desenvolvedor', developerModeEnabled: 'Modo desenvolvedor ativado', developerModeDisabled: 'Modo desenvolvedor desativado', @@ -788,6 +918,15 @@ export const pt: TranslationStructure = { daemon: 'Daemon', status: 'Status', stopDaemon: 'Parar daemon', + stopDaemonConfirmTitle: 'Parar daemon?', + stopDaemonConfirmBody: 'Você não poderá iniciar novas sessões nesta máquina até reiniciar o daemon no seu computador. Suas sessões atuais continuarão ativas.', + daemonStoppedTitle: 'Daemon parado', + stopDaemonFailed: 'Falha ao parar o daemon. Talvez ele não esteja em execução.', + renameTitle: 'Renomear máquina', + renameDescription: 'Dê a esta máquina um nome personalizado. Deixe em branco para usar o hostname padrão.', + renamePlaceholder: 'Digite o nome da máquina', + renamedSuccess: 'Máquina renomeada com sucesso', + renameFailed: 'Falha ao renomear a máquina', lastKnownPid: 'Último PID conhecido', lastKnownHttpPort: 'Última porta HTTP conhecida', startedAt: 'Iniciado em', @@ -804,8 +943,15 @@ export const pt: TranslationStructure = { lastSeen: 'Visto pela última vez', never: 'Nunca', metadataVersion: 'Versão dos metadados', + detectedClis: 'CLIs detectados', + detectedCliNotDetected: 'Não detectado', + detectedCliUnknown: 'Desconhecido', + detectedCliNotSupported: 'Não suportado (atualize o happy-cli)', untitledSession: 'Sessão sem título', back: 'Voltar', + notFound: 'Máquina não encontrada', + unknownMachine: 'máquina desconhecida', + unknownPath: 'caminho desconhecido', }, message: { @@ -815,6 +961,10 @@ export const pt: TranslationStructure = { unknownTime: 'horário desconhecido', }, + chatFooter: { + permissionsTerminalOnly: 'As permissões são mostradas apenas no terminal. Redefina ou envie uma mensagem para controlar pelo app.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -841,6 +991,7 @@ export const pt: TranslationStructure = { textCopied: 'Texto copiado para a área de transferência', failedToCopy: 'Falha ao copiar o texto para a área de transferência', noTextToCopy: 'Nenhum texto disponível para copiar', + failedToOpen: 'Falha ao abrir a seleção de texto. Tente novamente.', }, markdown: { @@ -860,11 +1011,14 @@ export const pt: TranslationStructure = { edit: 'Editar artefato', delete: 'Excluir', updateError: 'Falha ao atualizar artefato. Por favor, tente novamente.', + deleteError: 'Falha ao excluir o artefato. Tente novamente.', notFound: 'Artefato não encontrado', discardChanges: 'Descartar alterações?', discardChangesDescription: 'Você tem alterações não salvas. Tem certeza de que deseja descartá-las?', deleteConfirm: 'Excluir artefato?', deleteConfirmDescription: 'Este artefato será excluído permanentemente.', + noContent: 'Sem conteúdo', + untitled: 'Sem título', titlePlaceholder: 'Título do artefato', bodyPlaceholder: 'Digite o conteúdo aqui...', save: 'Salvar', @@ -968,8 +1122,8 @@ export const pt: TranslationStructure = { custom: 'Personalizado', builtInSaveAsHint: 'Salvar um perfil integrado cria um novo perfil personalizado.', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic (Padrão)', + deepseek: 'DeepSeek (Raciocínio)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -993,6 +1147,92 @@ export const pt: TranslationStructure = { title: 'Instruções de configuração', viewOfficialGuide: 'Ver guia oficial de configuração', }, + machineLogin: { + title: 'Login necessário na máquina', + subtitle: 'Este perfil depende do cache de login do CLI na máquina selecionada.', + claudeCode: { + title: 'Claude Code', + instructions: 'Execute `claude` e depois digite `/login` para entrar.', + warning: 'Obs.: definir `ANTHROPIC_AUTH_TOKEN` substitui o login do CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Execute `codex login` para entrar.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Execute `gemini auth` para entrar.', + }, + }, + requirements: { + apiKeyRequired: 'Chave de API', + configured: 'Configurada na máquina', + notConfigured: 'Não configurada', + checking: 'Verificando…', + modalTitle: 'Chave de API necessária', + modalBody: 'Este perfil requer uma chave de API.\n\nOpções disponíveis:\n• Usar ambiente da máquina (recomendado)\n• Usar chave salva nas configurações do app\n• Inserir uma chave apenas para esta sessão', + sectionTitle: 'Requisitos', + sectionSubtitle: 'Estes campos são usados para checar a prontidão e evitar falhas inesperadas.', + secretEnvVarPromptDescription: 'Digite o nome da variável de ambiente secreta necessária (ex.: OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Este perfil precisa de ${env}. Escolha uma opção abaixo.`, + modalHelpGeneric: 'Este perfil precisa de uma chave de API. Escolha uma opção abaixo.', + modalRecommendation: 'Recomendado: defina a chave no ambiente do daemon no seu computador (para não precisar colar novamente). Depois reinicie o daemon para ele carregar a nova variável de ambiente.', + chooseOptionTitle: 'Escolha uma opção', + machineEnvStatus: { + theMachine: 'a máquina', + checkFor: ({ env }: { env: string }) => `Verificar ${env}`, + checking: ({ env }: { env: string }) => `Verificando ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} encontrado em ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} não encontrado em ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Verificando ambiente do daemon…', + found: 'Encontrado no ambiente do daemon na máquina.', + notFound: 'Defina no ambiente do daemon na máquina e reinicie o daemon.', + }, + options: { + none: { + title: 'Nenhum', + subtitle: 'Não requer chave de API nem login via CLI.', + }, + apiKeyEnv: { + subtitle: 'Requer uma chave de API para ser injetada no início da sessão.', + }, + machineLogin: { + subtitle: 'Requer estar logado via um CLI na máquina de destino.', + longSubtitle: 'Requer estar logado via o CLI do backend de IA escolhido na máquina de destino.', + }, + useMachineEnvironment: { + title: 'Usar ambiente da máquina', + subtitleWithEnv: ({ env }: { env: string }) => `Usar ${env} do ambiente do daemon.`, + subtitleGeneric: 'Usar a chave do ambiente do daemon.', + }, + useSavedApiKey: { + title: 'Usar uma chave de API salva', + subtitle: 'Selecione (ou adicione) uma chave salva no app.', + }, + enterOnce: { + title: 'Inserir uma chave', + subtitle: 'Cole uma chave apenas para esta sessão (não será salva).', + }, + }, + apiKeyEnvVar: { + title: 'Variável de ambiente da chave de API', + subtitle: 'Digite o nome da variável de ambiente que este provedor espera para a chave de API (ex.: OPENAI_API_KEY).', + label: 'Nome da variável de ambiente', + }, + sections: { + machineEnvironment: 'Ambiente da máquina', + useOnceTitle: 'Usar uma vez', + useOnceFooter: 'Cole uma chave apenas para esta sessão. Ela não será salva.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Começar com a chave já presente na máquina.', + }, + useOnceButton: 'Usar uma vez (apenas sessão)', + }, + }, defaultSessionType: 'Tipo de sessão padrão', defaultPermissionMode: { title: 'Modo de permissão padrão', @@ -1078,7 +1318,7 @@ export const pt: TranslationStructure = { fixed: 'Fixo', machine: 'Máquina', checking: 'Verificando', - fallback: 'Fallback', + fallback: 'Alternativa', missing: 'Ausente', }, }, @@ -1091,6 +1331,45 @@ export const pt: TranslationStructure = { }, }, + apiKeys: { + addTitle: 'Nova chave de API', + savedTitle: 'Chaves de API salvas', + badgeReady: 'Chave de API', + badgeRequired: 'Chave de API necessária', + addSubtitle: 'Adicionar uma chave de API salva', + noneTitle: 'Nenhuma', + noneSubtitle: 'Use o ambiente da máquina ou insira uma chave para esta sessão', + emptyTitle: 'Nenhuma chave salva', + emptySubtitle: 'Adicione uma para usar perfis com chave de API sem configurar variáveis de ambiente na máquina.', + savedHiddenSubtitle: 'Salva (valor oculto)', + defaultLabel: 'Padrão', + fields: { + name: 'Nome', + value: 'Valor', + }, + placeholders: { + nameExample: 'ex.: Work OpenAI', + }, + validation: { + nameRequired: 'Nome é obrigatório.', + valueRequired: 'Valor é obrigatório.', + }, + actions: { + replace: 'Substituir', + replaceValue: 'Substituir valor', + setDefault: 'Definir como padrão', + unsetDefault: 'Remover padrão', + }, + prompts: { + renameTitle: 'Renomear chave de API', + renameDescription: 'Atualize o nome amigável desta chave.', + replaceValueTitle: 'Substituir valor da chave de API', + replaceValueDescription: 'Cole o novo valor da chave de API. Este valor não será mostrado novamente após salvar.', + deleteTitle: 'Excluir chave de API', + deleteConfirm: ({ name }: { name: string }) => `Excluir “${name}”? Esta ação não pode ser desfeita.`, + }, + }, + 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 9b500bdf4..0034f37dd 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -80,6 +80,7 @@ export const ru: TranslationStructure = { all: 'Все', machine: 'машина', clearSearch: 'Очистить поиск', + refresh: 'Обновить', }, connect: { @@ -87,6 +88,15 @@ export const ru: TranslationStructure = { enterSecretKey: 'Пожалуйста, введите секретный ключ', invalidSecretKey: 'Неверный секретный ключ. Проверьте и попробуйте снова.', enterUrlManually: 'Ввести URL вручную', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. Откройте Happy на мобильном устройстве\n2. Перейдите в Настройки → Аккаунт\n3. Нажмите «Подключить новое устройство»\n4. Отсканируйте этот QR-код', + restoreWithSecretKeyInstead: 'Восстановить по секретному ключу', + restoreWithSecretKeyDescription: 'Введите секретный ключ, чтобы восстановить доступ к аккаунту.', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `Подключить ${name}`, + runCommandInTerminal: 'Выполните следующую команду в терминале:', + }, }, settings: { @@ -127,11 +137,13 @@ export const ru: TranslationStructure = { usageSubtitle: 'Просмотр использования API и затрат', profiles: 'Профили', profilesSubtitle: 'Управление профилями переменных окружения для сессий', + apiKeys: 'API-ключи', + apiKeysSubtitle: 'Управление сохранёнными API-ключами (после ввода больше не показываются)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `Аккаунт ${service} подключен`, machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} ${status === 'online' ? 'online' : 'offline'}`, + `${name} ${status === 'online' ? 'в сети' : 'не в сети'}`, featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => `${feature} ${enabled ? 'включена' : 'отключена'}`, }, @@ -164,6 +176,21 @@ export const ru: TranslationStructure = { wrapLinesInDiffsDescription: 'Переносить длинные строки вместо горизонтальной прокрутки в представлениях различий', alwaysShowContextSize: 'Всегда показывать размер контекста', alwaysShowContextSizeDescription: 'Отображать использование контекста даже когда не близко к лимиту', + agentInputActionBarLayout: 'Панель действий ввода', + agentInputActionBarLayoutDescription: 'Выберите, как отображаются действия над полем ввода', + agentInputActionBarLayoutOptions: { + auto: 'Авто', + wrap: 'Перенос', + scroll: 'Прокрутка', + collapsed: 'Свернуто', + }, + agentInputChipDensity: 'Плотность чипов действий', + agentInputChipDensityDescription: 'Выберите, показывать ли чипы действий с подписями или только значками', + agentInputChipDensityOptions: { + auto: 'Авто', + labels: 'Подписи', + icons: 'Только значки', + }, avatarStyle: 'Стиль аватара', avatarStyleDescription: 'Выберите внешний вид аватара сессии', avatarOptions: { @@ -184,15 +211,31 @@ export const ru: TranslationStructure = { experimentalFeatures: 'Экспериментальные функции', experimentalFeaturesEnabled: 'Экспериментальные функции включены', experimentalFeaturesDisabled: 'Используются только стабильные функции', + experimentalOptions: 'Экспериментальные опции', + experimentalOptionsDescription: 'Выберите, какие экспериментальные функции включены.', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Веб-функции', webFeaturesDescription: 'Функции, доступные только в веб-версии приложения.', enterToSend: 'Enter для отправки', enterToSendEnabled: 'Нажмите Enter для отправки (Shift+Enter для новой строки)', enterToSendDisabled: 'Enter вставляет новую строку', - commandPalette: 'Command Palette', + commandPalette: 'Палитра команд', commandPaletteEnabled: 'Нажмите ⌘K для открытия', commandPaletteDisabled: 'Быстрый доступ к командам отключён', - markdownCopyV2: 'Markdown Copy v2', + markdownCopyV2: 'Копирование Markdown v2', markdownCopyV2Subtitle: 'Долгое нажатие открывает модальное окно копирования', hideInactiveSessions: 'Скрывать неактивные сессии', hideInactiveSessionsSubtitle: 'Показывать в списке только активные чаты', @@ -260,11 +303,29 @@ export const ru: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: 'Начать новую сессию', + selectAiProfileTitle: 'Выбрать профиль ИИ', + selectAiProfileDescription: 'Выберите профиль ИИ, чтобы применить переменные окружения и настройки по умолчанию к вашей сессии.', + changeProfile: 'Сменить профиль', + aiBackendSelectedByProfile: 'Бэкенд ИИ выбирается вашим профилем. Чтобы изменить его, выберите другой профиль.', + selectAiBackendTitle: 'Выбрать бэкенд ИИ', + aiBackendLimitedByProfileAndMachineClis: 'Ограничено выбранным профилем и доступными CLI на этой машине.', + aiBackendSelectWhichAiRuns: 'Выберите, какой ИИ будет работать в вашей сессии.', + aiBackendNotCompatibleWithSelectedProfile: 'Несовместимо с выбранным профилем.', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен на этой машине.`, selectMachineTitle: 'Выбрать машину', + selectMachineDescription: 'Выберите, где будет выполняться эта сессия.', selectPathTitle: 'Выбрать путь', + selectWorkingDirectoryTitle: 'Выбрать рабочую директорию', + selectWorkingDirectoryDescription: 'Выберите папку, используемую для команд и контекста.', + selectPermissionModeTitle: 'Выбрать режим разрешений', + selectPermissionModeDescription: 'Настройте, насколько строго действия требуют подтверждения.', + selectModelTitle: 'Выбрать модель ИИ', + selectModelDescription: 'Выберите модель, используемую этой сессией.', + selectSessionTypeTitle: 'Выбрать тип сессии', + selectSessionTypeDescription: 'Выберите простую сессию или сессию, привязанную к Git worktree.', searchPathsPlaceholder: 'Поиск путей...', noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.', - allMachinesOffline: 'Все машины находятся offline', + allMachinesOffline: 'Все машины не в сети', machineDetails: 'Посмотреть детали машины →', directoryDoesNotExist: 'Директория не найдена', createDirectoryConfirm: ({ directory }: { directory: string }) => `Директория ${directory} не существует. Хотите создать её?`, @@ -301,9 +362,23 @@ export const ru: TranslationStructure = { sessionType: { title: 'Тип сессии', simple: 'Простая', - worktree: 'Worktree', + worktree: 'Рабочее дерево', comingSoon: 'Скоро будет доступно', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `Требуется ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен`, + dontShowFor: 'Не показывать это предупреждение для', + thisMachine: 'этой машины', + anyMachine: 'любой машины', + installCommand: ({ command }: { command: string }) => `Установить: ${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `Установите ${cli} CLI, если доступно •`, + viewInstallationGuide: 'Открыть руководство по установке →', + viewGeminiDocs: 'Открыть документацию Gemini →', + }, worktree: { creating: ({ name }: { name: string }) => `Создание worktree '${name}'...`, notGitRepo: 'Worktree требует наличия git репозитория', @@ -375,6 +450,9 @@ export const ru: TranslationStructure = { happyHome: 'Домашний каталог Happy', copyMetadata: 'Копировать метаданные', agentState: 'Состояние агента', + rawJsonDevMode: 'Сырой JSON (режим разработчика)', + sessionStatus: 'Статус сессии', + fullSessionObject: 'Полный объект сессии', controlledByUser: 'Управляется пользователем', pendingRequests: 'Ожидающие запросы', activity: 'Активность', @@ -401,6 +479,35 @@ export const ru: TranslationStructure = { runIt: 'Запустите его', scanQrCode: 'Отсканируйте QR-код', openCamera: 'Открыть камеру', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: 'Сообщений пока нет', + created: ({ time }: { time: string }) => `Создано ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: 'Нет активных сессий', + startNewSessionDescription: 'Запустите новую сессию на любой из подключённых машин.', + startNewSessionButton: 'Новая сессия', + openTerminalToStart: 'Откройте новый терминал на компьютере, чтобы начать сессию.', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: 'Что нужно сделать?', + }, + home: { + noTasksYet: 'Пока нет задач. Нажмите +, чтобы добавить.', + }, + view: { + workOnTask: 'Работать над задачей', + clarify: 'Уточнить', + delete: 'Удалить', + linkedSessions: 'Связанные сессии', + tapTaskTextToEdit: 'Нажмите на текст задачи, чтобы отредактировать', }, }, @@ -419,8 +526,8 @@ export const ru: TranslationStructure = { connecting: 'подключение', disconnected: 'отключено', error: 'ошибка', - online: 'online', - offline: 'offline', + online: 'в сети', + offline: 'не в сети', lastSeen: ({ time }: { time: string }) => `в сети ${time}`, permissionRequired: 'требуется разрешение', activeNow: 'Активен сейчас', @@ -439,6 +546,19 @@ export const ru: TranslationStructure = { commandPalette: { placeholder: 'Введите команду или поиск...', + noCommandsFound: 'Команды не найдены', + }, + + commandView: { + completedWithNoOutput: '[Команда завершена без вывода]', + }, + + voiceAssistant: { + connecting: 'Подключение...', + active: 'Голосовой ассистент активен', + connectionError: 'Ошибка соединения', + label: 'Голосовой ассистент', + tapToEnd: 'Нажмите, чтобы завершить', }, agentInput: { @@ -471,22 +591,22 @@ export const ru: TranslationStructure = { codexPermissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ CODEX', default: 'Настройки CLI', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: 'Только чтение', + safeYolo: 'Безопасный YOLO', yolo: 'YOLO', badgeReadOnly: 'Только чтение', - badgeSafeYolo: 'Safe YOLO', + badgeSafeYolo: 'Безопасный 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', + 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: 'РЕЖИМ РАЗРЕШЕНИЙ', @@ -499,7 +619,7 @@ export const ru: TranslationStructure = { badgeYolo: 'YOLO', }, geminiModel: { - title: 'GEMINI MODEL', + title: 'МОДЕЛЬ GEMINI', gemini25Pro: { label: 'Gemini 2.5 Pro', description: 'Самая мощная', @@ -520,6 +640,11 @@ export const ru: TranslationStructure = { fileLabel: 'ФАЙЛ', folderLabel: 'ПАПКА', }, + actionMenu: { + title: 'ДЕЙСТВИЯ', + files: 'Файлы', + stop: 'Остановить', + }, noMachinesAvailable: 'Нет машин', }, @@ -732,6 +857,11 @@ export const ru: TranslationStructure = { deviceLinkedSuccessfully: 'Устройство успешно связано', terminalConnectedSuccessfully: 'Терминал успешно подключен', invalidAuthUrl: 'Неверный URL авторизации', + microphoneAccessRequiredTitle: 'Требуется доступ к микрофону', + microphoneAccessRequiredRequestPermission: 'Happy нужен доступ к микрофону для голосового чата. Разрешите доступ, когда появится запрос.', + microphoneAccessRequiredEnableInSettings: 'Happy нужен доступ к микрофону для голосового чата. Включите доступ к микрофону в настройках устройства.', + microphoneAccessRequiredBrowserInstructions: 'Разрешите доступ к микрофону в настройках браузера. Возможно, нужно нажать на значок замка в адресной строке и включить разрешение микрофона для этого сайта.', + openSettings: 'Открыть настройки', developerMode: 'Режим разработчика', developerModeEnabled: 'Режим разработчика включен', developerModeDisabled: 'Режим разработчика отключен', @@ -780,12 +910,21 @@ export const ru: TranslationStructure = { }, machine: { - offlineUnableToSpawn: 'Запуск отключен: машина offline', - offlineHelp: '• Убедитесь, что компьютер online\n• Выполните `happy daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g happy-coder@latest`', + offlineUnableToSpawn: 'Запуск отключён: машина офлайн', + offlineHelp: '• Убедитесь, что компьютер онлайн\n• Выполните `happy daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g happy-coder@latest`', launchNewSessionInDirectory: 'Запустить новую сессию в папке', - daemon: 'Daemon', + daemon: 'Демон', status: 'Статус', stopDaemon: 'Остановить daemon', + stopDaemonConfirmTitle: 'Остановить демон?', + stopDaemonConfirmBody: 'Вы не сможете создавать новые сессии на этой машине, пока не перезапустите демон на компьютере. Текущие сессии останутся активными.', + daemonStoppedTitle: 'Демон остановлен', + stopDaemonFailed: 'Не удалось остановить демон. Возможно, он не запущен.', + renameTitle: 'Переименовать машину', + renameDescription: 'Дайте этой машине имя. Оставьте пустым, чтобы использовать hostname по умолчанию.', + renamePlaceholder: 'Введите имя машины', + renamedSuccess: 'Машина успешно переименована', + renameFailed: 'Не удалось переименовать машину', lastKnownPid: 'Последний известный PID', lastKnownHttpPort: 'Последний известный HTTP порт', startedAt: 'Запущен в', @@ -802,8 +941,15 @@ export const ru: TranslationStructure = { lastSeen: 'Последняя активность', never: 'Никогда', metadataVersion: 'Версия метаданных', + detectedClis: 'Обнаруженные CLI', + detectedCliNotDetected: 'Не обнаружено', + detectedCliUnknown: 'Неизвестно', + detectedCliNotSupported: 'Не поддерживается (обновите happy-cli)', untitledSession: 'Безымянная сессия', back: 'Назад', + notFound: 'Машина не найдена', + unknownMachine: 'неизвестная машина', + unknownPath: 'неизвестный путь', }, message: { @@ -813,6 +959,10 @@ export const ru: TranslationStructure = { unknownTime: 'неизвестное время', }, + chatFooter: { + permissionsTerminalOnly: 'Разрешения отображаются только в терминале. Сбросьте их или отправьте сообщение, чтобы управлять из приложения.', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -851,6 +1001,7 @@ export const ru: TranslationStructure = { textCopied: 'Текст скопирован в буфер обмена', failedToCopy: 'Не удалось скопировать текст в буфер обмена', noTextToCopy: 'Нет текста для копирования', + failedToOpen: 'Не удалось открыть выбор текста. Пожалуйста, попробуйте снова.', }, markdown: { @@ -883,11 +1034,14 @@ export const ru: TranslationStructure = { edit: 'Редактировать артефакт', delete: 'Удалить', updateError: 'Не удалось обновить артефакт. Пожалуйста, попробуйте еще раз.', + deleteError: 'Не удалось удалить артефакт. Пожалуйста, попробуйте снова.', notFound: 'Артефакт не найден', discardChanges: 'Отменить изменения?', discardChangesDescription: 'У вас есть несохраненные изменения. Вы уверены, что хотите их отменить?', deleteConfirm: 'Удалить артефакт?', deleteConfirmDescription: 'Это действие нельзя отменить', + noContent: 'Нет содержимого', + untitled: 'Без названия', titleLabel: 'ЗАГОЛОВОК', titlePlaceholder: 'Введите заголовок для вашего артефакта', bodyLabel: 'СОДЕРЖИМОЕ', @@ -973,6 +1127,45 @@ export const ru: TranslationStructure = { friendAcceptedGeneric: 'Запрос в друзья принят', }, + apiKeys: { + addTitle: 'Новый API-ключ', + savedTitle: 'Сохранённые API-ключи', + badgeReady: 'API‑ключ', + badgeRequired: 'Требуется API‑ключ', + addSubtitle: 'Добавить сохранённый API-ключ', + noneTitle: 'Нет', + noneSubtitle: 'Используйте окружение машины или введите ключ для этой сессии', + emptyTitle: 'Нет сохранённых ключей', + emptySubtitle: 'Добавьте ключ, чтобы использовать профили с API-ключом без переменных окружения на машине.', + savedHiddenSubtitle: 'Сохранён (значение скрыто)', + defaultLabel: 'По умолчанию', + fields: { + name: 'Имя', + value: 'Значение', + }, + placeholders: { + nameExample: 'например, Work OpenAI', + }, + validation: { + nameRequired: 'Имя обязательно.', + valueRequired: 'Значение обязательно.', + }, + actions: { + replace: 'Заменить', + replaceValue: 'Заменить значение', + setDefault: 'Сделать по умолчанию', + unsetDefault: 'Убрать по умолчанию', + }, + prompts: { + renameTitle: 'Переименовать API-ключ', + renameDescription: 'Обновите понятное имя для этого ключа.', + replaceValueTitle: 'Заменить значение API-ключа', + replaceValueDescription: 'Вставьте новое значение API-ключа. После сохранения оно больше не будет показано.', + deleteTitle: 'Удалить API-ключ', + deleteConfirm: ({ name }: { name: string }) => `Удалить «${name}»? Это нельзя отменить.`, + }, + }, + profiles: { // Profile management feature title: 'Профили', @@ -1000,8 +1193,8 @@ export const ru: TranslationStructure = { custom: 'Пользовательский', builtInSaveAsHint: 'Сохранение встроенного профиля создаёт новый пользовательский профиль.', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic (по умолчанию)', + deepseek: 'DeepSeek (Рассуждение)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -1025,6 +1218,92 @@ export const ru: TranslationStructure = { title: 'Инструкции по настройке', viewOfficialGuide: 'Открыть официальное руководство', }, + machineLogin: { + title: 'Требуется вход на машине', + subtitle: 'Этот профиль использует кэш входа CLI на выбранной машине.', + claudeCode: { + title: 'Claude Code', + instructions: 'Запустите `claude`, затем введите `/login`, чтобы войти.', + warning: 'Примечание: установка `ANTHROPIC_AUTH_TOKEN` переопределяет вход через CLI.', + }, + codex: { + title: 'Codex', + instructions: 'Выполните `codex login`, чтобы войти.', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: 'Выполните `gemini auth`, чтобы войти.', + }, + }, + requirements: { + apiKeyRequired: 'API-ключ', + configured: 'Настроен на машине', + notConfigured: 'Не настроен', + checking: 'Проверка…', + modalTitle: 'Требуется API-ключ', + modalBody: 'Для этого профиля требуется API-ключ.\n\nДоступные варианты:\n• Использовать окружение машины (рекомендуется)\n• Использовать сохранённый ключ из настроек приложения\n• Ввести ключ только для этой сессии', + sectionTitle: 'Требования', + sectionSubtitle: 'Эти поля используются для предварительной проверки готовности и чтобы избежать неожиданных ошибок.', + secretEnvVarPromptDescription: 'Введите имя обязательной секретной переменной окружения (например, OPENAI_API_KEY).', + modalHelpWithEnv: ({ env }: { env: string }) => `Для этого профиля требуется ${env}. Выберите один вариант ниже.`, + modalHelpGeneric: 'Для этого профиля требуется API-ключ. Выберите один вариант ниже.', + modalRecommendation: 'Рекомендуется: задайте ключ в окружении демона на компьютере (чтобы не вставлять его снова). Затем перезапустите демон, чтобы он подхватил новую переменную окружения.', + chooseOptionTitle: 'Выберите вариант', + machineEnvStatus: { + theMachine: 'машине', + checkFor: ({ env }: { env: string }) => `Проверить ${env}`, + checking: ({ env }: { env: string }) => `Проверяем ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `${env} найден на ${machine}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `${env} не найден на ${machine}`, + }, + machineEnvSubtitle: { + checking: 'Проверяем окружение демона…', + found: 'Найдено в окружении демона на машине.', + notFound: 'Укажите значение в окружении демона на машине и перезапустите демон.', + }, + options: { + none: { + title: 'Нет', + subtitle: 'Не требует API-ключа или входа через CLI.', + }, + apiKeyEnv: { + subtitle: 'Требуется API-ключ, который будет передан при запуске сессии.', + }, + machineLogin: { + subtitle: 'Требуется вход через CLI на целевой машине.', + longSubtitle: 'Требуется быть авторизованным через CLI для выбранного бэкенда ИИ на целевой машине.', + }, + useMachineEnvironment: { + title: 'Использовать окружение машины', + subtitleWithEnv: ({ env }: { env: string }) => `Использовать ${env} из окружения демона.`, + subtitleGeneric: 'Использовать ключ из окружения демона.', + }, + useSavedApiKey: { + title: 'Использовать сохранённый API-ключ', + subtitle: 'Выберите (или добавьте) сохранённый ключ в приложении.', + }, + enterOnce: { + title: 'Ввести ключ', + subtitle: 'Вставьте ключ только для этой сессии (он не будет сохранён).', + }, + }, + apiKeyEnvVar: { + title: 'Переменная окружения для API-ключа', + subtitle: 'Введите имя переменной окружения, которую этот провайдер ожидает для API-ключа (например, OPENAI_API_KEY).', + label: 'Имя переменной окружения', + }, + sections: { + machineEnvironment: 'Окружение машины', + useOnceTitle: 'Использовать один раз', + useOnceFooter: 'Вставьте ключ только для этой сессии. Он не будет сохранён.', + }, + actions: { + useMachineEnvironment: { + subtitle: 'Использовать ключ, который уже есть на машине.', + }, + useOnceButton: 'Использовать один раз (только для сессии)', + }, + }, defaultSessionType: 'Тип сессии по умолчанию', defaultPermissionMode: { title: 'Режим разрешений по умолчанию', @@ -1038,8 +1317,8 @@ export const ru: TranslationStructure = { aiBackend: { title: 'Бекенд ИИ', selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.', - claudeSubtitle: 'Claude CLI', - codexSubtitle: 'Codex CLI', + claudeSubtitle: 'CLI Claude', + codexSubtitle: 'CLI Codex', geminiSubtitleExperimental: 'Gemini CLI (экспериментально)', }, tmux: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 91b6e694c..defbd99c4 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -68,9 +68,10 @@ export const zhHans: TranslationStructure = { delete: '删除', optional: '可选的', noMatches: '无匹配结果', - all: 'All', + all: '全部', machine: '机器', - clearSearch: 'Clear search', + clearSearch: '清除搜索', + refresh: '刷新', }, profile: { @@ -107,6 +108,15 @@ export const zhHans: TranslationStructure = { enterSecretKey: '请输入密钥', invalidSecretKey: '无效的密钥,请检查后重试。', enterUrlManually: '手动输入 URL', + terminalUrlPlaceholder: 'happy://terminal?...', + restoreQrInstructions: '1. 在你的手机上打开 Happy\n2. 前往 设置 → 账户\n3. 点击“链接新设备”\n4. 扫描此二维码', + restoreWithSecretKeyInstead: '改用密钥恢复', + restoreWithSecretKeyDescription: '输入你的密钥以恢复账户访问权限。', + secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...', + unsupported: { + connectTitle: ({ name }: { name: string }) => `连接 ${name}`, + runCommandInTerminal: '在终端中运行以下命令:', + }, }, settings: { @@ -147,6 +157,8 @@ export const zhHans: TranslationStructure = { usageSubtitle: '查看 API 使用情况和费用', profiles: '配置文件', profilesSubtitle: '管理环境配置文件和变量', + apiKeys: 'API 密钥', + apiKeysSubtitle: '管理已保存的 API 密钥(输入后将不再显示)', // Dynamic settings messages accountConnected: ({ service }: { service: string }) => `已连接 ${service} 账户`, @@ -184,6 +196,21 @@ export const zhHans: TranslationStructure = { wrapLinesInDiffsDescription: '在差异视图中换行显示长行而不是水平滚动', alwaysShowContextSize: '始终显示上下文大小', alwaysShowContextSizeDescription: '即使未接近限制时也显示上下文使用情况', + agentInputActionBarLayout: '输入操作栏', + agentInputActionBarLayoutDescription: '选择在输入框上方如何显示操作标签', + agentInputActionBarLayoutOptions: { + auto: '自动', + wrap: '换行', + scroll: '可滚动', + collapsed: '折叠', + }, + agentInputChipDensity: '操作标签密度', + agentInputChipDensityDescription: '选择操作标签显示文字还是图标', + agentInputChipDensityOptions: { + auto: '自动', + labels: '文字', + icons: '仅图标', + }, avatarStyle: '头像风格', avatarStyleDescription: '选择会话头像外观', avatarOptions: { @@ -204,6 +231,22 @@ export const zhHans: TranslationStructure = { experimentalFeatures: '实验功能', experimentalFeaturesEnabled: '实验功能已启用', experimentalFeaturesDisabled: '仅使用稳定功能', + experimentalOptions: '实验选项', + experimentalOptionsDescription: '选择启用哪些实验功能。', + expGemini: 'Gemini', + expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI', + expUsageReporting: 'Usage reporting', + expUsageReportingSubtitle: 'Enable usage and token reporting screens', + expFileViewer: 'File viewer', + expFileViewerSubtitle: 'Enable the session file viewer entrypoint', + expShowThinkingMessages: 'Show thinking messages', + expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat', + expSessionType: 'Session type selector', + expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)', + expZen: 'Zen', + expZenSubtitle: 'Enable the Zen navigation entry', + expVoiceAuthFlow: 'Voice auth flow', + expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)', webFeatures: 'Web 功能', webFeaturesDescription: '仅在应用的 Web 版本中可用的功能。', enterToSend: '回车发送', @@ -280,8 +323,26 @@ export const zhHans: TranslationStructure = { newSession: { // Used by new-session screen and launch flows title: '启动新会话', + selectAiProfileTitle: '选择 AI 配置', + selectAiProfileDescription: '选择一个 AI 配置,以将环境变量和默认值应用到会话。', + changeProfile: '更改配置', + aiBackendSelectedByProfile: 'AI 后端由所选配置决定。如需更改,请选择其他配置。', + selectAiBackendTitle: '选择 AI 后端', + aiBackendLimitedByProfileAndMachineClis: '受所选配置和此设备上可用的 CLI 限制。', + aiBackendSelectWhichAiRuns: '选择由哪个 AI 运行会话。', + aiBackendNotCompatibleWithSelectedProfile: '与所选配置不兼容。', + aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `此设备未检测到 ${cli} CLI。`, selectMachineTitle: '选择设备', + selectMachineDescription: '选择此会话运行的位置。', selectPathTitle: '选择路径', + selectWorkingDirectoryTitle: '选择工作目录', + selectWorkingDirectoryDescription: '选择用于命令和上下文的文件夹。', + selectPermissionModeTitle: '选择权限模式', + selectPermissionModeDescription: '控制操作需要批准的严格程度。', + selectModelTitle: '选择 AI 模型', + selectModelDescription: '选择此会话使用的模型。', + selectSessionTypeTitle: '选择会话类型', + selectSessionTypeDescription: '选择简单会话或与 Git worktree 关联的会话。', searchPathsPlaceholder: '搜索路径...', noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。', allMachinesOffline: '所有设备似乎都已离线', @@ -321,9 +382,23 @@ export const zhHans: TranslationStructure = { sessionType: { title: '会话类型', simple: '简单', - worktree: 'Worktree', + worktree: 'Worktree(Git)', comingSoon: '即将推出', }, + profileAvailability: { + requiresAgent: ({ agent }: { agent: string }) => `需要 ${agent}`, + cliNotDetected: ({ cli }: { cli: string }) => `未检测到 ${cli} CLI`, + }, + cliBanners: { + cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI 未检测到`, + dontShowFor: '不再显示此提示:', + thisMachine: '此设备', + anyMachine: '所有设备', + installCommand: ({ command }: { command: string }) => `安装:${command} •`, + installCliIfAvailable: ({ cli }: { cli: string }) => `如可用请安装 ${cli} CLI •`, + viewInstallationGuide: '查看安装指南 →', + viewGeminiDocs: '查看 Gemini 文档 →', + }, worktree: { creating: ({ name }: { name: string }) => `正在创建 worktree '${name}'...`, notGitRepo: 'Worktree 需要 git 仓库', @@ -348,6 +423,19 @@ export const zhHans: TranslationStructure = { commandPalette: { placeholder: '输入命令或搜索...', + noCommandsFound: '未找到命令', + }, + + commandView: { + completedWithNoOutput: '[命令完成且无输出]', + }, + + voiceAssistant: { + connecting: '连接中...', + active: '语音助手已启用', + connectionError: '连接错误', + label: '语音助手', + tapToEnd: '点击结束', }, server: { @@ -403,6 +491,9 @@ export const zhHans: TranslationStructure = { happyHome: 'Happy 主目录', copyMetadata: '复制元数据', agentState: 'Agent 状态', + rawJsonDevMode: '原始 JSON(开发者模式)', + sessionStatus: '会话状态', + fullSessionObject: '完整会话对象', controlledByUser: '用户控制', pendingRequests: '待处理请求', activity: '活动', @@ -430,6 +521,35 @@ export const zhHans: TranslationStructure = { runIt: '运行它', scanQrCode: '扫描二维码', openCamera: '打开相机', + installCommand: '$ npm i -g happy-coder', + runCommand: '$ happy', + }, + emptyMessages: { + noMessagesYet: '暂无消息', + created: ({ time }: { time: string }) => `创建于 ${time}`, + }, + emptySessionsTablet: { + noActiveSessions: '没有活动会话', + startNewSessionDescription: '在任意已连接设备上开始新的会话。', + startNewSessionButton: '开始新会话', + openTerminalToStart: '在电脑上打开新的终端以开始会话。', + }, + }, + + zen: { + title: 'Zen', + add: { + placeholder: '需要做什么?', + }, + home: { + noTasksYet: '还没有任务。点按 + 添加一个。', + }, + view: { + workOnTask: '处理任务', + clarify: '澄清', + delete: '删除', + linkedSessions: '已关联的会话', + tapTaskTextToEdit: '点击任务文本以编辑', }, }, @@ -463,22 +583,22 @@ export const zhHans: TranslationStructure = { codexPermissionMode: { title: 'CODEX 权限模式', default: 'CLI 设置', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', + readOnly: '只读模式', + safeYolo: '安全 YOLO', yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', + badgeReadOnly: '只读', + badgeSafeYolo: '安全 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', + 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 权限模式', @@ -512,6 +632,11 @@ export const zhHans: TranslationStructure = { fileLabel: '文件', folderLabel: '文件夹', }, + actionMenu: { + title: '操作', + files: '文件', + stop: '停止', + }, noMachinesAvailable: '无设备', }, @@ -736,6 +861,11 @@ export const zhHans: TranslationStructure = { deviceLinkedSuccessfully: '设备链接成功', terminalConnectedSuccessfully: '终端连接成功', invalidAuthUrl: '无效的认证 URL', + microphoneAccessRequiredTitle: '需要麦克风权限', + microphoneAccessRequiredRequestPermission: 'Happy 需要访问你的麦克风用于语音聊天。出现提示时请授予权限。', + microphoneAccessRequiredEnableInSettings: 'Happy 需要访问你的麦克风用于语音聊天。请在设备设置中启用麦克风权限。', + microphoneAccessRequiredBrowserInstructions: '请在浏览器设置中允许麦克风访问。你可能需要点击地址栏中的锁形图标,并为此网站启用麦克风权限。', + openSettings: '打开设置', developerMode: '开发者模式', developerModeEnabled: '开发者模式已启用', developerModeDisabled: '开发者模式已禁用', @@ -790,6 +920,15 @@ export const zhHans: TranslationStructure = { daemon: '守护进程', status: '状态', stopDaemon: '停止守护进程', + stopDaemonConfirmTitle: '停止守护进程?', + stopDaemonConfirmBody: '在您重新启动电脑上的守护进程之前,您将无法在此设备上创建新会话。当前会话将保持运行。', + daemonStoppedTitle: '守护进程已停止', + stopDaemonFailed: '停止守护进程失败。它可能未在运行。', + renameTitle: '重命名设备', + renameDescription: '为此设备设置自定义名称。留空则使用默认主机名。', + renamePlaceholder: '输入设备名称', + renamedSuccess: '设备重命名成功', + renameFailed: '设备重命名失败', lastKnownPid: '最后已知 PID', lastKnownHttpPort: '最后已知 HTTP 端口', startedAt: '启动时间', @@ -806,8 +945,15 @@ export const zhHans: TranslationStructure = { lastSeen: '最后活跃', never: '从未', metadataVersion: '元数据版本', + detectedClis: '已检测到的 CLI', + detectedCliNotDetected: '未检测到', + detectedCliUnknown: '未知', + detectedCliNotSupported: '不支持(请更新 happy-cli)', untitledSession: '无标题会话', back: '返回', + notFound: '未找到设备', + unknownMachine: '未知设备', + unknownPath: '未知路径', }, message: { @@ -817,6 +963,10 @@ export const zhHans: TranslationStructure = { unknownTime: '未知时间', }, + chatFooter: { + permissionsTerminalOnly: '权限仅在终端中显示。重置或发送消息即可从应用中控制。', + }, + codex: { // Codex permission dialog buttons permissions: { @@ -843,6 +993,7 @@ export const zhHans: TranslationStructure = { textCopied: '文本已复制到剪贴板', failedToCopy: '复制文本到剪贴板失败', noTextToCopy: '没有可复制的文本', + failedToOpen: '无法打开文本选择。请重试。', }, markdown: { @@ -862,11 +1013,14 @@ export const zhHans: TranslationStructure = { edit: '编辑工件', delete: '删除', updateError: '更新工件失败。请重试。', + deleteError: '删除工件失败。请重试。', notFound: '未找到工件', discardChanges: '放弃更改?', discardChangesDescription: '您有未保存的更改。确定要放弃它们吗?', deleteConfirm: '删除工件?', deleteConfirmDescription: '此工件将被永久删除。', + noContent: '无内容', + untitled: '未命名', titlePlaceholder: '工件标题', bodyPlaceholder: '在此输入内容...', save: '保存', @@ -970,8 +1124,8 @@ export const zhHans: TranslationStructure = { custom: '自定义', builtInSaveAsHint: '保存内置配置文件会创建一个新的自定义配置文件。', builtInNames: { - anthropic: 'Anthropic (Default)', - deepseek: 'DeepSeek (Reasoner)', + anthropic: 'Anthropic(默认)', + deepseek: 'DeepSeek(推理)', zai: 'Z.AI (GLM-4.6)', openai: 'OpenAI (GPT-5)', azureOpenai: 'Azure OpenAI', @@ -989,12 +1143,98 @@ export const zhHans: TranslationStructure = { duplicateProfile: '复制配置文件', deleteProfile: '删除配置文件', }, - copySuffix: '(Copy)', + copySuffix: '(副本)', duplicateName: '已存在同名配置文件', setupInstructions: { title: '设置说明', viewOfficialGuide: '查看官方设置指南', }, + machineLogin: { + title: '需要在设备上登录', + subtitle: '此配置文件依赖所选设备上的 CLI 登录缓存。', + claudeCode: { + title: 'Claude Code', + instructions: '运行 `claude`,然后输入 `/login` 登录。', + warning: '注意:设置 `ANTHROPIC_AUTH_TOKEN` 会覆盖 CLI 登录。', + }, + codex: { + title: 'Codex', + instructions: '运行 `codex login` 登录。', + }, + geminiCli: { + title: 'Gemini CLI', + instructions: '运行 `gemini auth` 登录。', + }, + }, + requirements: { + apiKeyRequired: 'API 密钥', + configured: '已在设备上配置', + notConfigured: '未配置', + checking: '检查中…', + modalTitle: '需要 API 密钥', + modalBody: '此配置需要 API 密钥。\n\n支持的选项:\n• 使用设备环境(推荐)\n• 使用应用设置中保存的密钥\n• 仅为本次会话输入密钥', + sectionTitle: '要求', + sectionSubtitle: '这些字段用于预检查就绪状态,避免意外失败。', + secretEnvVarPromptDescription: '输入所需的秘密环境变量名称(例如 OPENAI_API_KEY)。', + modalHelpWithEnv: ({ env }: { env: string }) => `此配置需要 ${env}。请选择下面的一个选项。`, + modalHelpGeneric: '此配置需要 API 密钥。请选择下面的一个选项。', + modalRecommendation: '推荐:在电脑上的守护进程环境中设置密钥(这样就无需再次粘贴)。然后重启守护进程以读取新的环境变量。', + chooseOptionTitle: '选择一个选项', + machineEnvStatus: { + theMachine: '设备', + checkFor: ({ env }: { env: string }) => `检查 ${env}`, + checking: ({ env }: { env: string }) => `正在检查 ${env}…`, + found: ({ env, machine }: { env: string; machine: string }) => `在${machine}上找到 ${env}`, + notFound: ({ env, machine }: { env: string; machine: string }) => `在${machine}上未找到 ${env}`, + }, + machineEnvSubtitle: { + checking: '正在检查守护进程环境…', + found: '已在设备的守护进程环境中找到。', + notFound: '请在设备的守护进程环境中设置它并重启守护进程。', + }, + options: { + none: { + title: '无', + subtitle: '不需要 API 密钥或 CLI 登录。', + }, + apiKeyEnv: { + subtitle: '需要在会话启动时注入 API 密钥。', + }, + machineLogin: { + subtitle: '需要在目标设备上通过 CLI 登录。', + longSubtitle: '需要在目标设备上登录到所选 AI 后端的 CLI。', + }, + useMachineEnvironment: { + title: '使用设备环境', + subtitleWithEnv: ({ env }: { env: string }) => `从守护进程环境中使用 ${env}。`, + subtitleGeneric: '从守护进程环境中使用密钥。', + }, + useSavedApiKey: { + title: '使用已保存的 API 密钥', + subtitle: '在应用中选择(或添加)一个已保存的密钥。', + }, + enterOnce: { + title: '输入密钥', + subtitle: '仅为本次会话粘贴密钥(不会保存)。', + }, + }, + apiKeyEnvVar: { + title: 'API 密钥环境变量', + subtitle: '输入此提供方期望的 API 密钥环境变量名(例如 OPENAI_API_KEY)。', + label: '环境变量名', + }, + sections: { + machineEnvironment: '设备环境', + useOnceTitle: '仅使用一次', + useOnceFooter: '仅为本次会话粘贴密钥。不会保存。', + }, + actions: { + useMachineEnvironment: { + subtitle: '使用设备上已存在的密钥开始。', + }, + useOnceButton: '仅使用一次(仅本次会话)', + }, + }, defaultSessionType: '默认会话类型', defaultPermissionMode: { title: '默认权限模式', @@ -1008,9 +1248,9 @@ export const zhHans: TranslationStructure = { aiBackend: { title: 'AI 后端', selectAtLeastOneError: '至少选择一个 AI 后端。', - claudeSubtitle: 'Claude CLI', - codexSubtitle: 'Codex CLI', - geminiSubtitleExperimental: 'Gemini CLI(实验)', + claudeSubtitle: 'Claude 命令行', + codexSubtitle: 'Codex 命令行', + geminiSubtitleExperimental: 'Gemini 命令行(实验)', }, tmux: { title: 'tmux', @@ -1093,6 +1333,45 @@ export const zhHans: TranslationStructure = { }, }, + apiKeys: { + addTitle: '新的 API 密钥', + savedTitle: '已保存的 API 密钥', + badgeReady: 'API 密钥', + badgeRequired: '需要 API 密钥', + addSubtitle: '添加已保存的 API 密钥', + noneTitle: '无', + noneSubtitle: '使用设备环境,或为本次会话输入密钥', + emptyTitle: '没有已保存的密钥', + emptySubtitle: '添加一个,以在不设置设备环境变量的情况下使用 API 密钥配置。', + savedHiddenSubtitle: '已保存(值已隐藏)', + defaultLabel: '默认', + fields: { + name: '名称', + value: '值', + }, + placeholders: { + nameExample: '例如:Work OpenAI', + }, + validation: { + nameRequired: '名称为必填项。', + valueRequired: '值为必填项。', + }, + actions: { + replace: '替换', + replaceValue: '替换值', + setDefault: '设为默认', + unsetDefault: '取消默认', + }, + prompts: { + renameTitle: '重命名 API 密钥', + renameDescription: '更新此密钥的友好名称。', + replaceValueTitle: '替换 API 密钥值', + replaceValueDescription: '粘贴新的 API 密钥值。保存后将不会再次显示。', + deleteTitle: '删除 API 密钥', + deleteConfirm: ({ name }: { name: string }) => `删除“${name}”?此操作无法撤销。`, + }, + }, + feed: { // Feed notifications for friend requests and acceptances friendRequestFrom: ({ name }: { name: string }) => `${name} 向您发送了好友请求`, From b71b9752a54edd0977bb1a39a4888c5886f26353 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:32:49 +0100 Subject: [PATCH 29/38] fix(autocomplete): remove debug suggestion logs --- sources/components/autocomplete/useActiveSuggestions.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/sources/components/autocomplete/useActiveSuggestions.ts b/sources/components/autocomplete/useActiveSuggestions.ts index 2215b1884..1bae8e4f5 100644 --- a/sources/components/autocomplete/useActiveSuggestions.ts +++ b/sources/components/autocomplete/useActiveSuggestions.ts @@ -72,16 +72,10 @@ export function useActiveSuggestions( // Sync query to suggestions const sync = React.useMemo(() => { return new ValueSync(async (query) => { - console.log('🎯 useActiveSuggestions: Processing query:', JSON.stringify(query)); if (!query) { - console.log('🎯 useActiveSuggestions: No query, skipping'); return; } const suggestions = await handler(query); - console.log('🎯 useActiveSuggestions: Got suggestions:', JSON.stringify(suggestions, (key, value) => { - if (key === 'component') return '[Function]'; - return value; - }, 2)); setState((prev) => { if (clampSelection) { // Simply clamp the selection to valid range From 1bd2422150dc653008b2e9646ac72359333276ec Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:33:22 +0100 Subject: [PATCH 30/38] feat(settings): expose per-experiment toggles --- sources/app/(app)/settings/features.tsx | 97 ++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index 589e3e99b..6e31094dc 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -10,6 +10,13 @@ import { t } from '@/text'; export default React.memo(function FeaturesSettingsScreen() { const [experiments, setExperiments] = useSettingMutable('experiments'); + const [expGemini, setExpGemini] = useSettingMutable('expGemini'); + const [expUsageReporting, setExpUsageReporting] = useSettingMutable('expUsageReporting'); + const [expFileViewer, setExpFileViewer] = useSettingMutable('expFileViewer'); + const [expShowThinkingMessages, setExpShowThinkingMessages] = useSettingMutable('expShowThinkingMessages'); + const [expSessionType, setExpSessionType] = useSettingMutable('expSessionType'); + const [expZen, setExpZen] = useSettingMutable('expZen'); + const [expVoiceAuthFlow, setExpVoiceAuthFlow] = useSettingMutable('expVoiceAuthFlow'); const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend'); const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled'); @@ -19,10 +26,28 @@ export default React.memo(function FeaturesSettingsScreen() { const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch'); + const setAllExperimentToggles = React.useCallback((enabled: boolean) => { + setExpGemini(enabled); + setExpUsageReporting(enabled); + setExpFileViewer(enabled); + setExpShowThinkingMessages(enabled); + setExpSessionType(enabled); + setExpZen(enabled); + setExpVoiceAuthFlow(enabled); + }, [ + setExpFileViewer, + setExpGemini, + setExpSessionType, + setExpShowThinkingMessages, + setExpUsageReporting, + setExpVoiceAuthFlow, + setExpZen, + ]); + return ( - {/* Experimental Features */} - @@ -33,11 +58,77 @@ export default React.memo(function FeaturesSettingsScreen() { rightElement={ { + setExperiments(next); + // Requirement: toggling the master switch enables/disables all experiments by default. + setAllExperimentToggles(next); + }} /> } showChevron={false} /> + + + {/* Per-experiment toggles (only shown when master experiments is enabled) */} + {experiments && ( + + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + + )} + + {/* Other feature toggles (not gated by experiments master switch) */} + Date: Sun, 18 Jan 2026 22:34:31 +0100 Subject: [PATCH 31/38] feat(agent-input): add configurable action bar layout --- sources/app/(app)/settings/appearance.tsx | 45 +- sources/components/AgentInput.tsx | 549 +++++++++++++++++----- 2 files changed, 477 insertions(+), 117 deletions(-) diff --git a/sources/app/(app)/settings/appearance.tsx b/sources/app/(app)/settings/appearance.tsx index fb6bb4505..3c3119e5c 100644 --- a/sources/app/(app)/settings/appearance.tsx +++ b/sources/app/(app)/settings/appearance.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; @@ -19,7 +20,7 @@ const isKnownAvatarStyle = (style: string): style is KnownAvatarStyle => { return style === 'pixelated' || style === 'gradient' || style === 'brutalist'; }; -export default function AppearanceSettingsScreen() { +export default React.memo(function AppearanceSettingsScreen() { const { theme } = useUnistyles(); const router = useRouter(); const [viewInline, setViewInline] = useSettingMutable('viewInline'); @@ -28,6 +29,8 @@ export default function AppearanceSettingsScreen() { const [showLineNumbersInToolViews, setShowLineNumbersInToolViews] = useSettingMutable('showLineNumbersInToolViews'); const [wrapLinesInDiffs, setWrapLinesInDiffs] = useSettingMutable('wrapLinesInDiffs'); const [alwaysShowContextSize, setAlwaysShowContextSize] = useSettingMutable('alwaysShowContextSize'); + const [agentInputActionBarLayout, setAgentInputActionBarLayout] = useSettingMutable('agentInputActionBarLayout'); + const [agentInputChipDensity, setAgentInputChipDensity] = useSettingMutable('agentInputChipDensity'); const [avatarStyle, setAvatarStyle] = useSettingMutable('avatarStyle'); const [showFlavorIcons, setShowFlavorIcons] = useSettingMutable('showFlavorIcons'); const [compactSessionView, setCompactSessionView] = useSettingMutable('compactSessionView'); @@ -198,6 +201,44 @@ export default function AppearanceSettingsScreen() { /> } /> + } + detail={ + agentInputActionBarLayout === 'auto' + ? t('settingsAppearance.agentInputActionBarLayoutOptions.auto') + : agentInputActionBarLayout === 'wrap' + ? t('settingsAppearance.agentInputActionBarLayoutOptions.wrap') + : agentInputActionBarLayout === 'scroll' + ? t('settingsAppearance.agentInputActionBarLayoutOptions.scroll') + : t('settingsAppearance.agentInputActionBarLayoutOptions.collapsed') + } + onPress={() => { + const order: Array = ['auto', 'wrap', 'scroll', 'collapsed']; + const idx = Math.max(0, order.indexOf(agentInputActionBarLayout)); + const next = order[(idx + 1) % order.length]!; + setAgentInputActionBarLayout(next); + }} + /> + } + detail={ + agentInputChipDensity === 'auto' + ? t('settingsAppearance.agentInputChipDensityOptions.auto') + : agentInputChipDensity === 'labels' + ? t('settingsAppearance.agentInputChipDensityOptions.labels') + : t('settingsAppearance.agentInputChipDensityOptions.icons') + } + onPress={() => { + const order: Array = ['auto', 'labels', 'icons']; + const idx = Math.max(0, order.indexOf(agentInputChipDensity)); + const next = order[(idx + 1) % order.length]!; + setAgentInputChipDensity(next); + }} + /> */} ); -} \ No newline at end of file +}); diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 3b0c16d30..54bc0a16f 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -1,7 +1,9 @@ import { Ionicons, Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Image as RNImage, Pressable } from 'react-native'; +import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Pressable, ScrollView } from 'react-native'; import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import Color from 'color'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; @@ -47,11 +49,6 @@ 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 }[]>; @@ -269,6 +266,32 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ flexWrap: 'wrap', overflow: 'visible', }, + actionButtonsLeftScroll: { + flex: 1, + overflow: 'visible', + }, + actionButtonsLeftScrollContent: { + flexDirection: 'row', + alignItems: 'center', + columnGap: 6, + paddingRight: 6, + }, + actionButtonsFadeLeft: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 18, + zIndex: 2, + }, + actionButtonsFadeRight: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: 18, + zIndex: 2, + }, actionButtonsLeftNarrow: { columnGap: 4, }, @@ -285,6 +308,10 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ height: 32, gap: 6, }, + actionChipIconOnly: { + paddingHorizontal: 8, + gap: 0, + }, actionChipPressed: { opacity: 0.7, }, @@ -463,25 +490,26 @@ export const AgentInput = React.memo(React.forwardRef { - const cliStatus = props.connectionStatus?.cliStatus; - if (!cliStatus) return null; - - const format = (name: string, value: boolean | null | undefined) => { - if (value === true) return `${name}✓`; - if (value === false) return `${name}✗`; - return `${name}?`; - }; + const effectiveChipDensity = React.useMemo<'labels' | 'icons'>(() => { + if (agentInputChipDensity === 'labels' || agentInputChipDensity === 'icons') { + return agentInputChipDensity; + } + // auto + return screenWidth < 420 ? 'icons' : 'labels'; + }, [agentInputChipDensity, screenWidth]); - const parts = [ - format('claude', cliStatus.claude), - format('codex', cliStatus.codex), - ...(Object.prototype.hasOwnProperty.call(cliStatus, 'gemini') ? [format('gemini', cliStatus.gemini)] : []), - ]; + const effectiveActionBarLayout = React.useMemo<'wrap' | 'scroll' | 'collapsed'>(() => { + if (agentInputActionBarLayout === 'wrap' || agentInputActionBarLayout === 'scroll' || agentInputActionBarLayout === 'collapsed') { + return agentInputActionBarLayout; + } + // auto + return screenWidth < 420 ? 'scroll' : 'wrap'; + }, [agentInputActionBarLayout, screenWidth]); - return ` · CLI: ${parts.join(' ')}`; - }, [props.connectionStatus?.cliStatus]); + const showChipLabels = effectiveChipDensity === 'labels'; // Abort button state @@ -537,6 +565,11 @@ export const AgentInput = React.memo(React.forwardRef { return normalizePermissionModeForAgentFlavor( props.permissionMode ?? 'default', @@ -590,6 +623,35 @@ export const AgentInput = React.memo(React.forwardRef actionBarViewportWidth + 8; + const showActionBarFadeLeft = canActionBarScroll && actionBarScrollX > 2; + const showActionBarFadeRight = canActionBarScroll && (actionBarScrollX + actionBarViewportWidth) < (actionBarContentWidth - 2); + + const actionBarFadeColor = React.useMemo(() => { + return theme.colors.input.background; + }, [theme.colors.input.background]); + + const actionBarFadeTransparent = React.useMemo(() => { + try { + return Color(actionBarFadeColor).alpha(0).rgb().string(); + } catch { + return 'transparent'; + } + }, [actionBarFadeColor]); // Handle settings selection const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { @@ -723,6 +785,194 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 0 : 8 } ]}> + {/* Action shortcuts (collapsed layout) */} + {actionBarIsCollapsed && hasAnyActions && ( + + + {t('agentInput.actionMenu.title')} + + + {props.onProfileClick ? ( + { + hapticsLight(); + setShowSettings(false); + props.onProfileClick?.(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {profileLabel ?? t('profiles.noProfile')} + + + ) : null} + + {props.onEnvVarsClick ? ( + { + hapticsLight(); + setShowSettings(false); + props.onEnvVarsClick?.(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} + + + ) : null} + + {props.agentType && props.onAgentClick ? ( + { + hapticsLight(); + setShowSettings(false); + props.onAgentClick?.(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {props.agentType === 'claude' + ? t('agentInput.agent.claude') + : props.agentType === 'codex' + ? t('agentInput.agent.codex') + : t('agentInput.agent.gemini')} + + + ) : null} + + {(props.machineName !== undefined) && props.onMachineClick ? ( + { + hapticsLight(); + setShowSettings(false); + props.onMachineClick?.(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : props.machineName} + + + ) : null} + + {(props.currentPath && props.onPathClick) ? ( + { + hapticsLight(); + setShowSettings(false); + props.onPathClick?.(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {props.currentPath} + + + ) : null} + + {(props.sessionId && props.onFileViewerPress) ? ( + { + hapticsLight(); + setShowSettings(false); + props.onFileViewerPress?.(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {t('agentInput.actionMenu.files')} + + + ) : null} + + {props.onAbort ? ( + { + setShowSettings(false); + handleAbortPress(); + }} + style={({ pressed }) => [ + styles.overlayOptionRow, + pressed ? styles.overlayOptionRowPressed : null, + ]} + > + + + {t('agentInput.actionMenu.stop')} + + + ) : null} + + )} + + {actionBarIsCollapsed && hasAnyActions ? ( + + ) : null} + {/* Permission Mode Section */} @@ -865,11 +1115,6 @@ export const AgentInput = React.memo(React.forwardRef {props.connectionStatus.text} - {cliStatusText && ( - - {cliStatusText} - - )} )} {contextWarning && ( @@ -887,7 +1132,7 @@ export const AgentInput = React.memo(React.forwardRef - {props.permissionMode && ( + {permissionChipLabel && ( - {isCodex ? ( - normalizedPermissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - normalizedPermissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - normalizedPermissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - normalizedPermissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' - ) : isGemini ? ( - normalizedPermissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - normalizedPermissionMode === 'read-only' ? t('agentInput.geminiPermissionMode.badgeReadOnly') : - normalizedPermissionMode === 'safe-yolo' ? t('agentInput.geminiPermissionMode.badgeSafeYolo') : - normalizedPermissionMode === 'yolo' ? t('agentInput.geminiPermissionMode.badgeYolo') : '' - ) : ( - normalizedPermissionMode === 'default' ? t('agentInput.permissionMode.default') : - normalizedPermissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - normalizedPermissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - normalizedPermissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' - )} + {permissionChipLabel} )} @@ -946,144 +1176,165 @@ export const AgentInput = React.memo(React.forwardRef{[ // Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status - - {/* Permission chip (popover in standard flow, scroll in wizard) */} - {showPermissionChip && ( + {(() => { + const chipStyle = (pressed: boolean) => ([ + styles.actionChip, + !showChipLabels ? styles.actionChipIconOnly : null, + pressed ? styles.actionChipPressed : null, + ]); + + const permissionOrControlsChip = (showPermissionChip || actionBarIsCollapsed) ? ( { hapticsLight(); - if (props.onPermissionClick) { + if (!actionBarIsCollapsed && props.onPermissionClick) { props.onPermissionClick(); return; } handleSettingsPress(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => [ - styles.actionChip, - p.pressed ? styles.actionChipPressed : null, - ]} + style={(p) => chipStyle(p.pressed)} > - - {permissionChipLabel ? ( + {showChipLabels && permissionChipLabel ? ( {permissionChipLabel} ) : null} - )} + ) : null; - {/* Profile selector button - FIRST */} - {props.onProfileClick && ( + const profileChip = props.onProfileClick ? ( { hapticsLight(); props.onProfileClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => [ - styles.actionChip, - p.pressed ? styles.actionChipPressed : null, - ]} + style={(p) => chipStyle(p.pressed)} > - - {profileLabel ?? t('profiles.noProfile')} - + {showChipLabels ? ( + + {profileLabel ?? t('profiles.noProfile')} + + ) : null} - )} + ) : null; - {/* Env vars preview (standard flow) */} - {props.onEnvVarsClick && ( + const envVarsChip = props.onEnvVarsClick ? ( { hapticsLight(); props.onEnvVarsClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => [ - styles.actionChip, - p.pressed ? styles.actionChipPressed : null, - ]} + style={(p) => chipStyle(p.pressed)} > - - {props.envVarsCount === undefined - ? t('agentInput.envVars.title') - : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} - + {showChipLabels ? ( + + {props.envVarsCount === undefined + ? t('agentInput.envVars.title') + : t('agentInput.envVars.titleWithCount', { count: props.envVarsCount })} + + ) : null} - )} + ) : null; - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( + const agentChip = (props.agentType && props.onAgentClick) ? ( { hapticsLight(); props.onAgentClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => [ - styles.actionChip, - p.pressed ? styles.actionChipPressed : null, - ]} + style={(p) => chipStyle(p.pressed)} > - - {props.agentType === 'claude' - ? t('agentInput.agent.claude') - : props.agentType === 'codex' - ? t('agentInput.agent.codex') - : t('agentInput.agent.gemini')} - + {showChipLabels ? ( + + {props.agentType === 'claude' + ? t('agentInput.agent.claude') + : props.agentType === 'codex' + ? t('agentInput.agent.codex') + : t('agentInput.agent.gemini')} + + ) : null} - )} + ) : null; - {/* Machine selector button */} - {(props.machineName !== undefined) && props.onMachineClick && ( + const machineChip = ((props.machineName !== undefined) && props.onMachineClick) ? ( { hapticsLight(); props.onMachineClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => [ - styles.actionChip, - p.pressed ? styles.actionChipPressed : null, - ]} + style={(p) => chipStyle(p.pressed)} > - - {props.machineName === null - ? t('agentInput.noMachinesAvailable') - : truncateWithEllipsis(props.machineName, 12)} - + {showChipLabels ? ( + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + + ) : null} - )} + ) : null; + + const pathChip = (props.currentPath && props.onPathClick) ? ( + { + hapticsLight(); + props.onPathClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => chipStyle(p.pressed)} + > + + {showChipLabels ? ( + + {props.currentPath} + + ) : null} + + ) : null; - {/* Abort button */} - {props.onAbort && ( - + const abortButton = props.onAbort && !actionBarIsCollapsed ? ( + [ styles.actionButton, @@ -1107,11 +1358,79 @@ export const AgentInput = React.memo(React.forwardRef - )} + ) : null; + + const gitStatusChip = !actionBarIsCollapsed ? ( + + ) : null; + + const chips = actionBarIsCollapsed + ? [permissionOrControlsChip].filter(Boolean) + : [ + permissionOrControlsChip, + profileChip, + envVarsChip, + agentChip, + machineChip, + ...(actionBarShouldScroll ? [pathChip] : []), + abortButton, + gitStatusChip, + ].filter(Boolean); + + // IMPORTANT: We must always render the ScrollView in "scroll layout" mode, + // otherwise we never measure content/viewport widths and can't know whether + // scrolling is needed (deadlock). + if (actionBarShouldScroll) { + return ( + + setActionBarViewportWidth(e.nativeEvent.layout.width)} + onContentSizeChange={(w) => setActionBarContentWidth(w)} + onScroll={(e) => setActionBarScrollX(e.nativeEvent.contentOffset.x)} + scrollEventThrottle={16} + > + {chips as any} + + {showActionBarFadeLeft ? ( + + ) : null} + {showActionBarFadeRight ? ( + + ) : null} + + ); + } - {/* Git Status Badge */} - - + return ( + + {chips as any} + + ); + })()} {/* Send/Voice button - aligned with first row */} , - // Row 2: Path selector (separate line to match pre-PR272 layout) - props.currentPath && props.onPathClick ? ( + // Row 2: Path selector (separate line to match pre-PR272 layout; hidden when action bar scrolls/collapses) + (!actionBarShouldScroll && !actionBarIsCollapsed && props.currentPath && props.onPathClick) ? ( void }) { +function GitStatusButton({ sessionId, onPress, compact }: { sessionId?: string, onPress?: () => void, compact?: boolean }) { const hasMeaningfulGitStatus = useHasMeaningfulGitStatus(sessionId || ''); const styles = stylesheet; const { theme } = useUnistyles(); @@ -1229,7 +1548,7 @@ function GitStatusButton({ sessionId, onPress }: { sessionId?: string, onPress?: paddingVertical: 6, height: 32, opacity: p.pressed ? 0.7 : 1, - flex: 1, + flex: compact ? 0 : 1, overflow: 'hidden', })} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} From d3b5b51e3136207c74c19590520fc6f365012d0f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:34:44 +0100 Subject: [PATCH 32/38] fix(modal): prevent stacked modal touch-blocking on iOS --- sources/app/(app)/_layout.tsx | 25 ++++++++- sources/modal/ModalManager.ts | 2 + sources/modal/components/BaseModal.tsx | 67 ++++++++++++++++++++++-- sources/modal/components/CustomModal.tsx | 20 ++++++- sources/modal/types.ts | 5 ++ 5 files changed, 111 insertions(+), 8 deletions(-) diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 64367c054..5bab01330 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -1,9 +1,10 @@ -import { Stack } from 'expo-router'; +import { Stack, router } from 'expo-router'; import 'react-native-reanimated'; import * as React from 'react'; import { Typography } from '@/constants/Typography'; import { createHeader } from '@/components/navigation/Header'; import { Platform, TouchableOpacity, Text } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { isRunningOnMac } from '@/utils/platform'; import { useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; @@ -337,7 +338,27 @@ export default function RootLayout() { headerTitle: t('newSession.title'), headerShown: true, headerBackTitle: t('common.cancel'), - presentation: 'modal', + // On iOS, presenting this as a native "modal" can cause React Native + // (used by our in-app modal system) to appear behind it and block touches. + // `containedModal` keeps presentation within the stack so overlays work reliably. + presentation: Platform.OS === 'ios' ? 'containedModal' : 'modal', + gestureEnabled: true, + fullScreenGestureEnabled: true, + // `containedModal` is reliable for stacking in-app modals above this screen on iOS, + // but swipe-to-dismiss is not consistently available. Always provide a close button. + headerBackVisible: false, + headerLeft: () => null, + headerRight: () => ( + router.back()} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + style={{ paddingHorizontal: 12, paddingVertical: 6 }} + accessibilityRole="button" + accessibilityLabel={t('common.cancel')} + > + + + ), }} /> (config: { component: CustomModalConfig

['component']; props?: CustomModalConfig

['props']; + closeOnBackdrop?: boolean; }): string { if (!this.showModalFn) { console.error('ModalManager not initialized. Make sure ModalProvider is mounted.'); @@ -108,6 +109,7 @@ class ModalManagerClass implements IModal { type: 'custom', component: config.component as unknown as CustomModalConfig['component'], props: config.props as unknown as CustomModalConfig['props'], + closeOnBackdrop: config.closeOnBackdrop, }; return this.showModalFn(modalConfig); diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx index 3d5702f5a..3452fc00a 100644 --- a/sources/modal/components/BaseModal.tsx +++ b/sources/modal/components/BaseModal.tsx @@ -57,6 +57,57 @@ export function BaseModal({ } }; + // IMPORTANT: + // On iOS, stacking native modals (expo-router / react-navigation modal screens + RN ) + // can lead to the RN modal rendering behind the navigation modal, while still blocking touches. + // To avoid this, we render "portal style" overlays on native (no RN ) and keep RN + // for web where we need to escape expo-router's body pointer-events behavior. + if (Platform.OS !== 'web') { + if (!visible) return null; + return ( + + + + + + + + + {children} + + + + + ); + } + return ( - - + - {children} + {/* See comment above: keep web interactive */} + + {children} + @@ -106,6 +160,11 @@ export function BaseModal({ } const styles = StyleSheet.create({ + portalRoot: { + ...StyleSheet.absoluteFillObject, + zIndex: 100000, + elevation: 100000, + }, container: { flex: 1, justifyContent: 'center', diff --git a/sources/modal/components/CustomModal.tsx b/sources/modal/components/CustomModal.tsx index d577a7fb5..e834ac2b9 100644 --- a/sources/modal/components/CustomModal.tsx +++ b/sources/modal/components/CustomModal.tsx @@ -18,10 +18,26 @@ export function CustomModal({ config, onClose }: CustomModalProps) { if (Component === CommandPalette) { return ; } + + const handleClose = React.useCallback(() => { + // Allow custom modals to run cleanup/cancel logic when the modal is dismissed + // (e.g. tapping the backdrop). + // NOTE: props are user-defined; we intentionally check this dynamically. + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const maybe = (config.props as any)?.onRequestClose; + if (typeof maybe === 'function') { + maybe(); + } + } catch { + // ignore + } + onClose(); + }, [config.props, onClose]); return ( - - + + ); } diff --git a/sources/modal/types.ts b/sources/modal/types.ts index e169c3658..de5042bc5 100644 --- a/sources/modal/types.ts +++ b/sources/modal/types.ts @@ -48,6 +48,11 @@ export interface CustomModalConfig

ext type: 'custom'; component: ComponentType

; props?: Omit; + /** + * Whether tapping the backdrop should close the modal. + * Defaults to true. + */ + closeOnBackdrop?: boolean; } export type ModalConfig = AlertModalConfig | ConfirmModalConfig | PromptModalConfig | CustomModalConfig; From 738530190406cc88bf5533062488a318cf7eb3c2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:35:14 +0100 Subject: [PATCH 33/38] feat(new-session): add api key selection and wizard extraction --- sources/app/(app)/new/NewSessionWizard.tsx | 878 ++++++++++ sources/app/(app)/new/index.tsx | 1543 ++++++++--------- sources/app/(app)/new/pick/profile.tsx | 410 ++--- .../newSession/ProfileCompatibilityIcon.tsx | 4 +- 4 files changed, 1719 insertions(+), 1116 deletions(-) create mode 100644 sources/app/(app)/new/NewSessionWizard.tsx diff --git a/sources/app/(app)/new/NewSessionWizard.tsx b/sources/app/(app)/new/NewSessionWizard.tsx new file mode 100644 index 000000000..f5d3df60e --- /dev/null +++ b/sources/app/(app)/new/NewSessionWizard.tsx @@ -0,0 +1,878 @@ +import { Ionicons } from '@expo/vector-icons'; +import * as React from 'react'; +import { Platform, Pressable, ScrollView, Text, View } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import { LinearGradient } from 'expo-linear-gradient'; +import Color from 'color'; +import { Typography } from '@/constants/Typography'; +import { AgentInput } from '@/components/AgentInput'; +import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ItemGroup'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { PathSelector } from '@/components/newSession/PathSelector'; +import { ProfilesList } from '@/components/profiles/ProfilesList'; +import { SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; +import { layout } from '@/components/layout'; +import { Modal } from '@/modal'; +import { t } from '@/text'; +import { getBuiltInProfile } from '@/sync/profileUtils'; +import { getProfileEnvironmentVariables, type AIBackendProfile } from '@/sync/settings'; +import { useSetting } from '@/sync/storage'; +import type { Machine } from '@/sync/storageTypes'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; + +type CLIAvailability = { + claude: boolean | null; + codex: boolean | null; + gemini: boolean | null; + login: { claude: boolean | null; codex: boolean | null; gemini: boolean | null }; + isDetecting: boolean; + timestamp: number; + error?: string; +}; + +export interface NewSessionWizardLayoutProps { + theme: any; + styles: any; + safeAreaBottom: number; + headerHeight: number; + newSessionSidePadding: number; + newSessionBottomPadding: number; + scrollViewRef: any; + profileSectionRef: any; + modelSectionRef: any; + machineSectionRef: any; + pathSectionRef: any; + permissionSectionRef: any; + registerWizardSectionOffset: ( + section: 'profile' | 'agent' | 'model' | 'machine' | 'path' | 'permission' | 'sessionType' + ) => (evt: any) => void; +} + +export interface NewSessionWizardProfilesProps { + useProfiles: boolean; + profiles: AIBackendProfile[]; + favoriteProfileIds: string[]; + setFavoriteProfileIds: (ids: string[]) => void; + experimentsEnabled: boolean; + selectedProfileId: string | null; + onPressDefaultEnvironment: () => void; + onPressProfile: (profile: AIBackendProfile) => void; + selectedMachineId: string | null; + getProfileDisabled: (profile: AIBackendProfile) => boolean; + getProfileSubtitleExtra: (profile: AIBackendProfile) => string | null; + handleAddProfile: () => void; + openProfileEdit: (params: { profileId: string }) => void; + handleDuplicateProfile: (profile: AIBackendProfile) => void; + handleDeleteProfile: (profile: AIBackendProfile) => void; + openProfileEnvVarsPreview: (profile: AIBackendProfile) => void; + suppressNextApiKeyAutoPromptKeyRef: React.MutableRefObject; + sessionOnlyApiKeyValue: string | null; + selectedSavedApiKeyValue: string | null | undefined; + apiKeyPreflightIsReady: boolean; + openApiKeyRequirementModal: (profile: AIBackendProfile, opts: { revertOnCancel: boolean }) => void; + profilesGroupTitles: { favorites: string; custom: string; builtIn: string }; +} + +export interface NewSessionWizardAgentProps { + cliAvailability: CLIAvailability; + allowGemini: boolean; + isWarningDismissed: (cli: 'claude' | 'codex' | 'gemini') => boolean; + hiddenBanners: { claude: boolean; codex: boolean; gemini: boolean }; + handleCLIBannerDismiss: (cli: 'claude' | 'codex' | 'gemini', scope: 'machine' | 'global' | 'temporary') => void; + agentType: 'claude' | 'codex' | 'gemini'; + setAgentType: (agent: 'claude' | 'codex' | 'gemini') => void; + modelOptions: ReadonlyArray<{ value: ModelMode; label: string; description: string }>; + modelMode: ModelMode | undefined; + setModelMode: (mode: ModelMode) => void; + selectedIndicatorColor: string; + profileMap: Map; + handleAgentInputProfileClick: () => void; + permissionMode: PermissionMode; + handlePermissionModeChange: (mode: PermissionMode) => void; + sessionType: 'simple' | 'worktree'; + setSessionType: (t: 'simple' | 'worktree') => void; +} + +export interface NewSessionWizardMachineProps { + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines: Machine[]; + favoriteMachineItems: Machine[]; + useMachinePickerSearch: boolean; + setSelectedMachineId: (id: string) => void; + getBestPathForMachine: (id: string) => string; + setSelectedPath: (path: string) => void; + favoriteMachines: string[]; + setFavoriteMachines: (ids: string[]) => void; + selectedPath: string; + recentPaths: string[]; + usePathPickerSearch: boolean; + favoriteDirectories: string[]; + setFavoriteDirectories: (dirs: string[]) => void; +} + +export interface NewSessionWizardFooterProps { + sessionPrompt: string; + setSessionPrompt: (v: string) => void; + handleCreateSession: () => void; + canCreate: boolean; + isCreating: boolean; + emptyAutocompletePrefixes: React.ComponentProps['autocompletePrefixes']; + emptyAutocompleteSuggestions: React.ComponentProps['autocompleteSuggestions']; + handleAgentInputAgentClick: () => void; + handleAgentInputPermissionClick: () => void; + connectionStatus?: React.ComponentProps['connectionStatus']; + handleAgentInputMachineClick: () => void; + handleAgentInputPathClick: () => void; + handleAgentInputProfileClick: () => void; + selectedProfileEnvVarsCount: number; + handleEnvVarsClick: () => void; +} + +export interface NewSessionWizardProps { + layout: NewSessionWizardLayoutProps; + profiles: NewSessionWizardProfilesProps; + agent: NewSessionWizardAgentProps; + machine: NewSessionWizardMachineProps; + footer: NewSessionWizardFooterProps; +} + +export const NewSessionWizard = React.memo(function NewSessionWizard(props: NewSessionWizardProps) { + const { + theme, + styles, + safeAreaBottom, + headerHeight, + newSessionSidePadding, + newSessionBottomPadding, + scrollViewRef, + profileSectionRef, + modelSectionRef, + machineSectionRef, + pathSectionRef, + permissionSectionRef, + registerWizardSectionOffset, + } = props.layout; + + const { + useProfiles, + profiles, + favoriteProfileIds, + setFavoriteProfileIds, + experimentsEnabled, + selectedProfileId, + onPressDefaultEnvironment, + onPressProfile, + selectedMachineId, + getProfileDisabled, + getProfileSubtitleExtra, + handleAddProfile, + openProfileEdit, + handleDuplicateProfile, + handleDeleteProfile, + openProfileEnvVarsPreview, + suppressNextApiKeyAutoPromptKeyRef, + sessionOnlyApiKeyValue, + selectedSavedApiKeyValue, + apiKeyPreflightIsReady, + openApiKeyRequirementModal, + profilesGroupTitles, + } = props.profiles; + + const expSessionType = useSetting('expSessionType'); + const showSessionTypeSelector = experimentsEnabled && expSessionType; + + const { + cliAvailability, + allowGemini, + isWarningDismissed, + hiddenBanners, + handleCLIBannerDismiss, + agentType, + setAgentType, + modelOptions, + modelMode, + setModelMode, + selectedIndicatorColor, + profileMap, + handleAgentInputProfileClick, + permissionMode, + handlePermissionModeChange, + sessionType, + setSessionType, + } = props.agent; + + const { + machines, + selectedMachine, + recentMachines, + favoriteMachineItems, + useMachinePickerSearch, + setSelectedMachineId, + getBestPathForMachine, + setSelectedPath, + favoriteMachines, + setFavoriteMachines, + selectedPath, + recentPaths, + usePathPickerSearch, + favoriteDirectories, + setFavoriteDirectories, + } = props.machine; + + const { + sessionPrompt, + setSessionPrompt, + handleCreateSession, + canCreate, + isCreating, + emptyAutocompletePrefixes, + emptyAutocompleteSuggestions, + handleAgentInputAgentClick, + handleAgentInputPermissionClick, + connectionStatus, + handleAgentInputMachineClick, + handleAgentInputPathClick, + handleAgentInputProfileClick: handleFooterProfileClick, + selectedProfileEnvVarsCount, + handleEnvVarsClick, + } = props.footer; + + return ( + + + + + + + {useProfiles && ( + <> + + + + {t('newSession.selectAiProfileTitle')} + + + + {t('newSession.selectAiProfileDescription')} + + openProfileEdit({ profileId: profile.id })} + onDuplicateProfile={handleDuplicateProfile} + onDeleteProfile={handleDeleteProfile} + getHasEnvironmentVariables={(profile) => Object.keys(getProfileEnvironmentVariables(profile)).length > 0} + onViewEnvironmentVariables={openProfileEnvVarsPreview} + onApiKeyBadgePress={(profile) => { + if (selectedMachineId) { + suppressNextApiKeyAutoPromptKeyRef.current = `${selectedMachineId}:${profile.id}`; + } + const hasInjected = Boolean(sessionOnlyApiKeyValue || selectedSavedApiKeyValue); + const hasMachineEnv = apiKeyPreflightIsReady; + const isMissingForSelectedProfile = + profile.id === selectedProfileId && !hasInjected && !hasMachineEnv; + openApiKeyRequirementModal(profile, { revertOnCancel: isMissingForSelectedProfile }); + }} + groupTitles={profilesGroupTitles} + /> + + + + )} + + {/* Section: AI Backend */} + + + + + {t('newSession.selectAiBackendTitle')} + + + + + {useProfiles && selectedProfileId + ? t('newSession.aiBackendLimitedByProfileAndMachineClis') + : t('newSession.aiBackendSelectWhichAiRuns')} + + + {/* Missing CLI Installation Banners */} + {selectedMachineId && cliAvailability.claude === false && !isWarningDismissed('claude') && !hiddenBanners.claude && ( + + + + + + {t('newSession.cliBanners.cliNotDetectedTitle', { cli: t('agentInput.agent.claude') })} + + + + {t('newSession.cliBanners.dontShowFor')} + + handleCLIBannerDismiss('claude', 'machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + {t('newSession.cliBanners.thisMachine')} + + + handleCLIBannerDismiss('claude', 'global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + {t('newSession.cliBanners.anyMachine')} + + + + handleCLIBannerDismiss('claude', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + {t('newSession.cliBanners.installCommand', { command: 'npm install -g @anthropic-ai/claude-code' })} + + { + if (Platform.OS === 'web') { + window.open('https://docs.anthropic.com/en/docs/claude-code/installation', '_blank'); + } + }}> + + {t('newSession.cliBanners.viewInstallationGuide')} + + + + + )} + + {selectedMachineId && cliAvailability.codex === false && !isWarningDismissed('codex') && !hiddenBanners.codex && ( + + + + + + {t('newSession.cliBanners.cliNotDetectedTitle', { cli: t('agentInput.agent.codex') })} + + + + {t('newSession.cliBanners.dontShowFor')} + + handleCLIBannerDismiss('codex', 'machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + {t('newSession.cliBanners.thisMachine')} + + + handleCLIBannerDismiss('codex', 'global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + {t('newSession.cliBanners.anyMachine')} + + + + handleCLIBannerDismiss('codex', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + {t('newSession.cliBanners.installCommand', { command: 'npm install -g codex-cli' })} + + { + if (Platform.OS === 'web') { + window.open('https://github.com/openai/openai-codex', '_blank'); + } + }}> + + {t('newSession.cliBanners.viewInstallationGuide')} + + + + + )} + + {selectedMachineId && cliAvailability.gemini === false && allowGemini && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( + + + + + + {t('newSession.cliBanners.cliNotDetectedTitle', { cli: t('agentInput.agent.gemini') })} + + + + {t('newSession.cliBanners.dontShowFor')} + + handleCLIBannerDismiss('gemini', 'machine')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + {t('newSession.cliBanners.thisMachine')} + + + handleCLIBannerDismiss('gemini', 'global')} + style={{ + borderRadius: 4, + borderWidth: 1, + borderColor: theme.colors.textSecondary, + paddingHorizontal: 8, + paddingVertical: 3, + }} + > + + {t('newSession.cliBanners.anyMachine')} + + + + handleCLIBannerDismiss('gemini', 'temporary')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + {t('newSession.cliBanners.installCliIfAvailable', { cli: t('agentInput.agent.gemini') })} + + { + if (Platform.OS === 'web') { + window.open('https://ai.google.dev/gemini-api/docs/get-started', '_blank'); + } + }}> + + {t('newSession.cliBanners.viewGeminiDocs')} + + + + + )} + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + {(() => { + const selectedProfile = useProfiles && selectedProfileId + ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) + : null; + + const options: Array<{ + key: 'claude' | 'codex' | 'gemini'; + title: string; + subtitle: string; + icon: React.ComponentProps['name']; + }> = [ + { key: 'claude', title: t('agentInput.agent.claude'), subtitle: t('profiles.aiBackend.claudeSubtitle'), icon: 'sparkles-outline' }, + { key: 'codex', title: t('agentInput.agent.codex'), subtitle: t('profiles.aiBackend.codexSubtitle'), icon: 'terminal-outline' }, + ...(allowGemini ? [{ key: 'gemini' as const, title: t('agentInput.agent.gemini'), subtitle: t('profiles.aiBackend.geminiSubtitleExperimental'), icon: 'planet-outline' as const }] : []), + ]; + + return options.map((option, index) => { + const compatible = !selectedProfile || !!selectedProfile.compatibility?.[option.key]; + const cliOk = cliAvailability[option.key] !== false; + const disabledReason = !compatible + ? t('newSession.aiBackendNotCompatibleWithSelectedProfile') + : !cliOk + ? t('newSession.aiBackendCliNotDetectedOnMachine', { cli: option.title }) + : null; + + const isSelected = agentType === option.key; + + return ( + } + selected={isSelected} + disabled={!!disabledReason} + onPress={() => { + if (disabledReason) { + Modal.alert( + t('profiles.aiBackend.title'), + disabledReason, + compatible + ? [{ text: t('common.ok'), style: 'cancel' }] + : [ + { text: t('common.ok'), style: 'cancel' }, + ...(useProfiles && selectedProfileId ? [{ text: t('newSession.changeProfile'), onPress: handleAgentInputProfileClick }] : []), + ], + ); + return; + } + setAgentType(option.key); + }} + rightElement={( + + + + )} + showChevron={false} + showDivider={index < options.length - 1} + /> + ); + }); + })()} + + + {modelOptions.length > 0 && ( + + + + + {t('newSession.selectModelTitle')} + + + + {t('newSession.selectModelDescription')} + + + {modelOptions.map((option, index, options) => { + const isSelected = modelMode === option.value; + return ( + } + showChevron={false} + selected={isSelected} + onPress={() => setModelMode(option.value)} + rightElement={( + + + + )} + showDivider={index < options.length - 1} + /> + ); + })} + + + )} + + + + {/* Section 2: Machine Selection */} + + + + {t('newSession.selectMachineTitle')} + + + + {t('newSession.selectMachineDescription')} + + + + { + setSelectedMachineId(machine.id); + const bestPath = getBestPathForMachine(machine.id); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); + } + }} + /> + + + {/* API key selection is now handled inline from the profile list (via the requirements badge). */} + + {/* Section 3: Working Directory */} + + + + {t('newSession.selectWorkingDirectoryTitle')} + + + + {t('newSession.selectWorkingDirectoryDescription')} + + + + + + + {/* Section 4: Permission Mode */} + + + + {t('newSession.selectPermissionModeTitle')} + + + + {t('newSession.selectPermissionModeDescription')} + + + {(agentType === 'codex' || agentType === 'gemini' + ? [ + { value: 'default' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.default' : 'agentInput.geminiPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.readOnly' : 'agentInput.geminiPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.safeYolo' : 'agentInput.geminiPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.yolo' : 'agentInput.geminiPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: t('agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits' as PermissionMode, label: t('agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: t('agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: t('agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( + + } + rightElement={permissionMode === option.value ? ( + + ) : null} + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> + ))} + + + + + {/* Section 5: Session Type */} + {showSessionTypeSelector && ( + <> + + + + {t('newSession.selectSessionTypeTitle')} + + + + {t('newSession.selectSessionTypeDescription')} + + + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + + + + + )} + + + + + + {/* AgentInput - Sticky at bottom */} + + {/* Always-on top divider gradient (wizard only). + Matches web: boxShadow 0 -10px 30px rgba(0,0,0,0.08) and fades into true transparency above. */} + {Platform.OS !== 'web' ? ( + { + try { + return Color(theme.colors.shadow.color).alpha(0.08).rgb().string(); + } catch { + return 'rgba(0,0,0,0.08)'; + } + })(), + 'transparent', + ]} + start={{ x: 0.5, y: 1 }} + end={{ x: 0.5, y: 0 }} + style={{ + position: 'absolute', + top: -30, + left: -1000, + right: -1000, + height: 30, + zIndex: 10, + }} + /> + ) : null} + + + 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + + + + + + ); +}); + diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 74fed3a4c..d94423d1a 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView } from 'react-native'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; +import { useAllMachines, storage, useSetting, useSettingMutable } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; @@ -26,6 +26,8 @@ import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/syn import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; import { useCLIDetection } from '@/hooks/useCLIDetection'; +import { useProfileEnvRequirements } from '@/hooks/useProfileEnvRequirements'; +import { getRequiredSecretEnvVarName } from '@/sync/profileSecrets'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; @@ -37,11 +39,18 @@ import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompati import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; import { ItemRowActions } from '@/components/ItemRowActions'; +import { ProfileRequirementsBadge } from '@/components/ProfileRequirementsBadge'; import { buildProfileActions } from '@/components/profileActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; -import { consumeProfileIdParam } from '@/profileRouteParams'; +import { consumeApiKeyIdParam, consumeProfileIdParam } from '@/profileRouteParams'; import { getModelOptionsForAgentType } from '@/sync/modelOptions'; import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; +import { ApiKeyRequirementModal, type ApiKeyRequirementModalResult } from '@/components/ApiKeyRequirementModal'; +import { useFocusEffect } from '@react-navigation/native'; +import { getRecentPathsForMachine } from '@/utils/recentPaths'; +import { InteractionManager } from 'react-native'; +import { NewSessionWizard } from './NewSessionWizard'; +import { prefetchMachineDetectCliIfStale } from '@/hooks/useMachineDetectCliCache'; // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { @@ -59,34 +68,6 @@ const transformProfileToEnvironmentVars = (profile: AIBackendProfile) => { 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): string => { - if (!machineId) return ''; - - const machine = storage.getState().machines[machineId]; - 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 }> = []; - - sessions.forEach(session => { - if (session.metadata?.machineId === machineId && session.metadata?.path) { - pathsWithTimestamps.push({ - path: session.metadata.path, - timestamp: session.createdAt // Use createdAt, not updatedAt - }); - } - }); - - // 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; -}; - // Configuration constants const RECENT_PATHS_DEFAULT_VISIBLE = 5; const styles = StyleSheet.create((theme, rt) => ({ @@ -247,7 +228,7 @@ const styles = StyleSheet.create((theme, rt) => ({ }, })); -function NewSessionWizard() { +function NewSessionScreen() { const { theme, rt } = useUnistyles(); const router = useRouter(); const navigation = useNavigation(); @@ -256,14 +237,23 @@ function NewSessionWizard() { const { width: screenWidth } = useWindowDimensions(); const selectedIndicatorColor = rt.themeName === 'dark' ? theme.colors.text : theme.colors.button.primary.background; + const openApiKeys = React.useCallback(() => { + router.push({ + pathname: '/new/pick/api-key', + params: { selectedId: '' }, + }); + }, [router]); + const newSessionSidePadding = 16; const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); - const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam, apiKeyId: apiKeyIdParam, apiKeySessionOnlyId } = useLocalSearchParams<{ prompt?: string; dataId?: string; machineId?: string; path?: string; profileId?: string; + apiKeyId?: string; + apiKeySessionOnlyId?: string; }>(); // Try to get data from temporary store first @@ -286,8 +276,12 @@ function NewSessionWizard() { // Variant B (true): Enhanced profile-first wizard with sections const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); const useProfiles = useSetting('useProfiles'); + const [apiKeys, setApiKeys] = useSettingMutable('apiKeys'); + const [defaultApiKeyByProfileId, setDefaultApiKeyByProfileId] = useSettingMutable('defaultApiKeyByProfileId'); const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const experimentsEnabled = useSetting('experiments'); + const expGemini = useSetting('expGemini'); + const expSessionType = useSetting('expSessionType'); const useMachinePickerSearch = useSetting('useMachinePickerSearch'); const usePathPickerSearch = useSetting('usePathPickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); @@ -297,6 +291,19 @@ function NewSessionWizard() { const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); + useFocusEffect( + React.useCallback(() => { + // Ensure newly-registered machines show up without requiring an app restart. + // Throttled to avoid spamming the server when navigating back/forth. + // Defer until after interactions so the screen feels instant on iOS. + InteractionManager.runAfterInteractions(() => { + void sync.refreshMachinesThrottled({ staleMs: 15_000 }); + }); + }, []) + ); + + // (prefetch effect moved below, after machines/recent/favorites are defined) + // Combined profiles (built-in + custom) const allProfiles = React.useMemo(() => { const builtInProfiles = DEFAULT_PROFILES.map(bp => getBuiltInProfile(bp.id)!); @@ -320,7 +327,6 @@ function NewSessionWizard() { setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); }, [favoriteProfileIds, setFavoriteProfileIds]); const machines = useAllMachines(); - const sessions = useSessions(); // Wizard state const [selectedProfileId, setSelectedProfileId] = React.useState(() => { @@ -338,13 +344,30 @@ function NewSessionWizard() { return null; }); + const [selectedApiKeyId, setSelectedApiKeyId] = React.useState(() => { + return persistedDraft?.selectedApiKeyId ?? null; + }); + + // Session-only secret (NOT persisted). Highest-precedence override for this session. + const [sessionOnlyApiKeyValue, setSessionOnlyApiKeyValue] = React.useState(null); + + const prevProfileIdBeforeApiKeyPromptRef = React.useRef(null); + const lastApiKeyPromptKeyRef = React.useRef(null); + const suppressNextApiKeyAutoPromptKeyRef = React.useRef(null); + React.useEffect(() => { if (!useProfiles && selectedProfileId !== null) { setSelectedProfileId(null); } }, [useProfiles, selectedProfileId]); - const allowGemini = experimentsEnabled; + const allowGemini = experimentsEnabled && expGemini; + + // AgentInput autocomplete is unused on this screen today, but passing a new + // function/array each render forces autocomplete hooks to re-sync. + // Keep these stable to avoid unnecessary work during taps/selection changes. + const emptyAutocompletePrefixes = React.useMemo(() => [], []); + const emptyAutocompleteSuggestions = React.useCallback(async () => [], []); const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { // Check if agent type was provided in temp data @@ -435,6 +458,77 @@ function NewSessionWizard() { return null; }); + const getBestPathForMachine = React.useCallback((machineId: string | null): string => { + if (!machineId) return ''; + const recent = getRecentPathsForMachine({ + machineId, + recentMachinePaths, + sessions: null, + }); + if (recent.length > 0) return recent[0]!; + const machine = machines.find((m) => m.id === machineId); + return machine?.metadata?.homeDir ?? ''; + }, [machines, recentMachinePaths]); + + const openApiKeyRequirementModal = React.useCallback((profile: AIBackendProfile, options: { revertOnCancel: boolean }) => { + const handleResolve = (result: ApiKeyRequirementModalResult) => { + if (result.action === 'cancel') { + // Always allow future prompts for this profile. + lastApiKeyPromptKeyRef.current = null; + suppressNextApiKeyAutoPromptKeyRef.current = null; + if (options.revertOnCancel) { + const prev = prevProfileIdBeforeApiKeyPromptRef.current; + setSelectedProfileId(prev); + } + return; + } + + if (result.action === 'useMachine') { + // Explicit choice: do not auto-apply default key. + setSelectedApiKeyId(''); + setSessionOnlyApiKeyValue(null); + return; + } + + if (result.action === 'enterOnce') { + // Explicit choice: do not auto-apply default key. + setSelectedApiKeyId(''); + setSessionOnlyApiKeyValue(result.value); + return; + } + + if (result.action === 'selectSaved') { + setSessionOnlyApiKeyValue(null); + setSelectedApiKeyId(result.apiKeyId); + if (result.setDefault) { + setDefaultApiKeyByProfileId({ + ...defaultApiKeyByProfileId, + [profile.id]: result.apiKeyId, + }); + } + } + }; + + Modal.show({ + component: ApiKeyRequirementModal, + props: { + profile, + machineId: selectedMachineId ?? null, + apiKeys, + defaultApiKeyId: defaultApiKeyByProfileId[profile.id] ?? null, + onChangeApiKeys: setApiKeys, + allowSessionOnly: true, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' }), + }, + }); + }, [ + apiKeys, + defaultApiKeyByProfileId, + selectedMachineId, + setDefaultApiKeyByProfileId, + ]); + const hasUserSelectedPermissionModeRef = React.useRef(false); const permissionModeRef = React.useRef(permissionMode); React.useEffect(() => { @@ -458,7 +552,7 @@ function NewSessionWizard() { // const [selectedPath, setSelectedPath] = React.useState(() => { - return getRecentPathForMachine(selectedMachineId); + return getBestPathForMachine(selectedMachineId); }); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; @@ -475,7 +569,7 @@ function NewSessionWizard() { } if (machineIdParam !== selectedMachineId) { setSelectedMachineId(machineIdParam); - const bestPath = getRecentPathForMachine(machineIdParam); + const bestPath = getBestPathForMachine(machineIdParam); setSelectedPath(bestPath); } }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); @@ -503,7 +597,7 @@ function NewSessionWizard() { } setSelectedMachineId(machineIdToUse); - setSelectedPath(getRecentPathForMachine(machineIdToUse)); + setSelectedPath(getBestPathForMachine(machineIdToUse)); }, [machines, recentMachinePaths, selectedMachineId]); // Handle path route param from picker screens (main's navigation pattern) @@ -528,7 +622,7 @@ function NewSessionWizard() { const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine - const cliAvailability = useCLIDetection(selectedMachineId); + const cliAvailability = useCLIDetection(selectedMachineId, { autoDetect: false }); // Auto-correct invalid agent selection after CLI detection completes // This handles the case where lastUsedAgent was 'codex' but codex is not installed @@ -633,6 +727,14 @@ function NewSessionWizard() { return { available: true }; }, [cliAvailability, experimentsEnabled]); + const profileAvailabilityById = React.useMemo(() => { + const map = new Map(); + for (const profile of allProfiles) { + map.set(profile.id, isProfileAvailable(profile)); + } + return map; + }, [allProfiles, isProfileAvailable]); + // Computed values const compatibleProfiles = React.useMemo(() => { return allProfiles.filter(profile => validateProfileForAgent(profile, agentType)); @@ -650,24 +752,66 @@ function NewSessionWizard() { return getBuiltInProfile(selectedProfileId); }, [selectedProfileId, profileMap]); + React.useEffect(() => { + // Session-only secrets are only for the current launch attempt; clear when profile changes. + setSessionOnlyApiKeyValue(null); + }, [selectedProfileId]); + const selectedMachine = React.useMemo(() => { if (!selectedMachineId) return null; return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); + const requiredSecretEnvVarName = React.useMemo(() => { + return getRequiredSecretEnvVarName(selectedProfile); + }, [selectedProfile]); + + const shouldShowApiKeySection = Boolean( + selectedProfile && + selectedProfile.authMode === 'apiKeyEnv' && + requiredSecretEnvVarName, + ); + + const apiKeyPreflight = useProfileEnvRequirements( + shouldShowApiKeySection ? selectedMachineId : null, + shouldShowApiKeySection ? selectedProfile : null, + ); + + const selectedSavedApiKey = React.useMemo(() => { + if (!selectedApiKeyId) return null; + return apiKeys.find((k) => k.id === selectedApiKeyId) ?? null; + }, [apiKeys, selectedApiKeyId]); + + React.useEffect(() => { + if (!selectedProfileId) return; + if (selectedApiKeyId !== null) return; + const nextDefault = defaultApiKeyByProfileId[selectedProfileId]; + if (typeof nextDefault === 'string' && nextDefault.length > 0) { + setSelectedApiKeyId(nextDefault); + } + }, [defaultApiKeyByProfileId, selectedApiKeyId, selectedProfileId]); + + const activeApiKeySource = sessionOnlyApiKeyValue + ? 'sessionOnly' + : selectedApiKeyId + ? 'saved' + : 'machineEnv'; + const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => { - // Persist wizard state before navigating so selection doesn't reset on return. - saveNewSessionDraft({ + // Persisting can block the JS thread on iOS (MMKV). Navigation should be instant, + // so we persist after the navigation transition. + const draft = { input: sessionPrompt, selectedMachineId, selectedPath, selectedProfileId: useProfiles ? selectedProfileId : null, + selectedApiKeyId, agentType, permissionMode, modelMode, sessionType, updatedAt: Date.now(), - }); + }; router.push({ pathname: '/new/pick/profile-edit', @@ -676,6 +820,10 @@ function NewSessionWizard() { ...(selectedMachineId ? { machineId: selectedMachineId } : {}), }, } as any); + + InteractionManager.runAfterInteractions(() => { + saveNewSessionDraft(draft); + }); }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); const handleAddProfile = React.useCallback(() => { @@ -708,89 +856,126 @@ function NewSessionWizard() { }, [profiles, selectedProfileId, setProfiles]); // Get recent paths for the selected machine - // Recent machines computed from sessions (for inline machine selection) + // Recent machines computed from recentMachinePaths (lightweight; avoids subscribing to sessions updates) 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 []; + if (!recentMachinePaths || recentMachinePaths.length === 0) return []; + + const byId = new Map(machines.map((m) => [m.id, m] as const)); + const seen = new Set(); + const result: typeof machines = []; + for (const entry of recentMachinePaths) { + if (seen.has(entry.machineId)) continue; + const m = byId.get(entry.machineId); + if (!m) continue; + seen.add(entry.machineId); + result.push(m); + } + return result; + }, [machines, recentMachinePaths]); const favoriteMachineItems = React.useMemo(() => { return machines.filter(m => favoriteMachines.includes(m.id)); }, [machines, favoriteMachines]); - const recentPaths = React.useMemo(() => { - if (!selectedMachineId) return []; + // Background refresh on open: pick up newly-installed CLIs without fetching on taps. + // Keep this fairly conservative to avoid impacting iOS responsiveness. + const CLI_DETECT_REVALIDATE_STALE_MS = 2 * 60 * 1000; // 2 minutes - const paths: string[] = []; - const pathSet = new Set(); + // One-time prefetch of detect-cli results for the wizard machine list. + // This keeps machine glyphs responsive (cache-only in the list) without + // triggering per-row auto-detect work during taps. + const didPrefetchWizardMachineGlyphsRef = React.useRef(false); + React.useEffect(() => { + if (!useEnhancedSessionWizard) return; + if (didPrefetchWizardMachineGlyphsRef.current) return; + didPrefetchWizardMachineGlyphsRef.current = true; + + InteractionManager.runAfterInteractions(() => { + try { + const candidates: string[] = []; + for (const m of favoriteMachineItems) candidates.push(m.id); + for (const m of recentMachines) candidates.push(m.id); + for (const m of machines.slice(0, 8)) candidates.push(m.id); + + const seen = new Set(); + const unique = candidates.filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); - // 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); + // Limit to avoid a thundering herd on iOS. + const toPrefetch = unique.slice(0, 12); + for (const machineId of toPrefetch) { + const machine = machines.find((m) => m.id === machineId); + if (!machine) continue; + if (!isMachineOnline(machine)) continue; + void prefetchMachineDetectCliIfStale({ machineId, staleMs: CLI_DETECT_REVALIDATE_STALE_MS }); + } + } catch { + // best-effort prefetch only } }); + }, [favoriteMachineItems, machines, recentMachines, useEnhancedSessionWizard]); - // 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 - }); - } - } - }); + // Cache-first + background refresh: for the actively selected machine, prefetch detect-cli + // if missing or stale. This updates the banners/agent availability on screen open, but avoids + // any fetches on tap handlers. + React.useEffect(() => { + if (!selectedMachineId) return; + const machine = machines.find((m) => m.id === selectedMachineId); + if (!machine) return; + if (!isMachineOnline(machine)) return; - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } + InteractionManager.runAfterInteractions(() => { + void prefetchMachineDetectCliIfStale({ + machineId: selectedMachineId, + staleMs: CLI_DETECT_REVALIDATE_STALE_MS, + }); + }); + }, [machines, selectedMachineId]); - return paths; - }, [sessions, selectedMachineId, recentMachinePaths]); + const recentPaths = React.useMemo(() => { + if (!selectedMachineId) return []; + return getRecentPathsForMachine({ + machineId: selectedMachineId, + recentMachinePaths, + sessions: null, + }); + }, [recentMachinePaths, selectedMachineId]); // Validation const canCreate = React.useMemo(() => { return selectedMachineId !== null && selectedPath.trim() !== ''; }, [selectedMachineId, selectedPath]); + // On iOS, keep tap handlers extremely light so selection state can commit instantly. + // We defer any follow-up adjustments (agent/session-type/permission defaults) until after interactions. + const pendingProfileSelectionRef = React.useRef<{ profileId: string; prevProfileId: string | null } | null>(null); + const selectProfile = React.useCallback((profileId: string) => { const prevSelectedProfileId = selectedProfileId; + prevProfileIdBeforeApiKeyPromptRef.current = prevSelectedProfileId; + // Ensure selecting a profile can re-prompt if needed. + lastApiKeyPromptKeyRef.current = null; + pendingProfileSelectionRef.current = { profileId, prevProfileId: prevSelectedProfileId }; setSelectedProfileId(profileId); - // Check both custom profiles and built-in profiles - const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); - if (profile) { + }, [selectedProfileId]); + + React.useEffect(() => { + if (!selectedProfileId) return; + const pending = pendingProfileSelectionRef.current; + if (!pending || pending.profileId !== selectedProfileId) return; + pendingProfileSelectionRef.current = null; + + InteractionManager.runAfterInteractions(() => { + // Ensure nothing changed while we waited. + if (selectedProfileId !== pending.profileId) return; + + const profile = profileMap.get(pending.profileId) || getBuiltInProfile(pending.profileId); + if (!profile) return; + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) .filter(([, supported]) => supported) .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') @@ -800,24 +985,118 @@ function NewSessionWizard() { setAgentType(supportedAgents[0] ?? 'claude'); } - // Set session type from profile's default if (profile.defaultSessionType) { setSessionType(profile.defaultSessionType); } - // Apply permission defaults only on first selection (or if the user hasn't explicitly chosen one). - // Switching between profiles should not reset permissions when the backend stays the same. if (!hasUserSelectedPermissionModeRef.current && profile.defaultPermissionMode) { const nextMode = profile.defaultPermissionMode as PermissionMode; - // If the user is switching profiles (not initial selection), keep their current permissionMode. - const isInitialProfileSelection = prevSelectedProfileId === null; + const isInitialProfileSelection = pending.prevProfileId === null; if (isInitialProfileSelection) { applyPermissionMode(nextMode, 'auto'); } } - } + }); }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]); + // Keep ProfilesList props stable to avoid rerendering the whole list on + // unrelated state updates (iOS perf). + const profilesGroupTitles = React.useMemo(() => { + return { + favorites: t('profiles.groups.favorites'), + custom: t('profiles.groups.custom'), + builtIn: t('profiles.groups.builtIn'), + }; + }, []); + + const getProfileDisabled = React.useCallback((profile: { id: string }) => { + return !(profileAvailabilityById.get(profile.id) ?? { available: true }).available; + }, [profileAvailabilityById]); + + const getProfileSubtitleExtra = React.useCallback((profile: { id: string }) => { + const availability = profileAvailabilityById.get(profile.id) ?? { available: true }; + if (availability.available || !availability.reason) return null; + if (availability.reason.startsWith('requires-agent:')) { + const required = availability.reason.split(':')[1]; + const agentLabel = required === 'claude' + ? t('agentInput.agent.claude') + : required === 'codex' + ? t('agentInput.agent.codex') + : required === 'gemini' + ? t('agentInput.agent.gemini') + : required; + return t('newSession.profileAvailability.requiresAgent', { agent: agentLabel }); + } + if (availability.reason.startsWith('cli-not-detected:')) { + const cli = availability.reason.split(':')[1]; + const cliLabel = cli === 'claude' + ? t('agentInput.agent.claude') + : cli === 'codex' + ? t('agentInput.agent.codex') + : cli === 'gemini' + ? t('agentInput.agent.gemini') + : cli; + return t('newSession.profileAvailability.cliNotDetected', { cli: cliLabel }); + } + return availability.reason; + }, [profileAvailabilityById]); + + const onPressProfile = React.useCallback((profile: { id: string }) => { + const availability = profileAvailabilityById.get(profile.id) ?? { available: true }; + if (!availability.available) return; + selectProfile(profile.id); + }, [profileAvailabilityById, selectProfile]); + + const onPressDefaultEnvironment = React.useCallback(() => { + setSelectedProfileId(null); + }, []); + + // If a selected profile requires an API key and the key isn't available on the selected machine, + // prompt immediately and revert selection on cancel (so the profile isn't "selected" without a key). + React.useEffect(() => { + if (!useProfiles) return; + if (!selectedMachineId) return; + if (!shouldShowApiKeySection) return; + if (!selectedProfileId) return; + + const hasInjected = Boolean(sessionOnlyApiKeyValue || selectedSavedApiKey?.value); + const hasMachineEnv = apiKeyPreflight.isReady; + if (hasInjected || hasMachineEnv) { + // Reset prompt key when requirements are satisfied so future selections can prompt again if needed. + lastApiKeyPromptKeyRef.current = null; + return; + } + + const promptKey = `${selectedMachineId}:${selectedProfileId}`; + if (suppressNextApiKeyAutoPromptKeyRef.current === promptKey) { + // One-shot suppression (used when the user explicitly opened the modal via the badge). + suppressNextApiKeyAutoPromptKeyRef.current = null; + return; + } + if (lastApiKeyPromptKeyRef.current === promptKey) { + return; + } + lastApiKeyPromptKeyRef.current = promptKey; + if (!selectedProfile) { + return; + } + openApiKeyRequirementModal(selectedProfile, { revertOnCancel: true }); + }, [ + apiKeyPreflight.isReady, + defaultApiKeyByProfileId, + openApiKeyRequirementModal, + requiredSecretEnvVarName, + selectedApiKeyId, + selectedMachineId, + selectedProfileId, + selectedProfile, + selectedSavedApiKey?.value, + sessionOnlyApiKeyValue, + shouldShowApiKeySection, + suppressNextApiKeyAutoPromptKeyRef, + useProfiles, + ]); + // Handle profile route param from picker screens React.useEffect(() => { if (!useProfiles) { @@ -850,6 +1129,58 @@ function NewSessionWizard() { } }, [navigation, profileIdParam, selectedProfileId, selectProfile, useProfiles]); + // Handle apiKey route param from picker screens + React.useEffect(() => { + const { nextSelectedApiKeyId, shouldClearParam } = consumeApiKeyIdParam({ + apiKeyIdParam, + selectedApiKeyId, + }); + + if (nextSelectedApiKeyId === null) { + if (selectedApiKeyId !== null) { + setSelectedApiKeyId(null); + } + } else if (typeof nextSelectedApiKeyId === 'string') { + setSelectedApiKeyId(nextSelectedApiKeyId); + } + + if (shouldClearParam) { + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ apiKeyId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { apiKeyId: undefined } }, + } as never); + } + } + }, [apiKeyIdParam, navigation, selectedApiKeyId]); + + // Handle session-only API key temp id from picker screens (value is stored in-memory only). + React.useEffect(() => { + if (typeof apiKeySessionOnlyId !== 'string' || apiKeySessionOnlyId.length === 0) { + return; + } + + const entry = getTempData<{ apiKey?: string }>(apiKeySessionOnlyId); + const value = entry?.apiKey; + if (typeof value === 'string' && value.length > 0) { + setSessionOnlyApiKeyValue(value); + setSelectedApiKeyId(null); + } + + const setParams = (navigation as any)?.setParams; + if (typeof setParams === 'function') { + setParams({ apiKeySessionOnlyId: undefined }); + } else { + navigation.dispatch({ + type: 'SET_PARAMS', + payload: { params: { apiKeySessionOnlyId: undefined } }, + } as never); + } + }, [apiKeySessionOnlyId, navigation]); + // Keep agentType compatible with the currently selected profile. React.useEffect(() => { if (!useProfiles || selectedProfileId === null) { @@ -1028,6 +1359,11 @@ function NewSessionWizard() { return ( + 0; + + if (needsSecret) { + const hasMachineEnv = apiKeyPreflight.isReady; + const hasInjected = typeof injectedSecretValue === 'string' && injectedSecretValue.length > 0; + + if (!hasInjected && !hasMachineEnv) { + Modal.alert( + t('common.error'), + `Missing API key (${requiredSecretName}). Configure it on the machine or select/enter a key.`, + ); + setIsCreating(false); + return; + } + + if (hasInjected) { + environmentVariables = { + ...environmentVariables, + [requiredSecretName]: injectedSecretValue!, + }; + } + } } } @@ -1282,7 +1653,26 @@ function NewSessionWizard() { Modal.alert(t('common.error'), errorMessage); setIsCreating(false); } - }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); + }, [ + agentType, + apiKeyPreflight.isReady, + experimentsEnabled, + modelMode, + permissionMode, + profileMap, + recentMachinePaths, + requiredSecretEnvVarName, + router, + selectedMachineId, + selectedPath, + selectedProfileId, + selectedSavedApiKey?.value, + sessionOnlyApiKeyValue, + sessionPrompt, + sessionType, + useEnhancedSessionWizard, + useProfiles, + ]); const handleCloseModal = React.useCallback(() => { // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. @@ -1304,22 +1694,13 @@ function NewSessionWizard() { if (!selectedMachine) return undefined; const isOnline = isMachineOnline(selectedMachine); - // Always include CLI status when a machine is selected. - // Values may be `null` while detection is still in flight / failed; the UI renders them as informational. - const includeCLI = Boolean(selectedMachineId); - 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]); + }, [selectedMachine, theme]); const persistDraftNow = React.useCallback(() => { saveNewSessionDraft({ @@ -1327,13 +1708,14 @@ function NewSessionWizard() { selectedMachineId, selectedPath, selectedProfileId: useProfiles ? selectedProfileId : null, + selectedApiKeyId, agentType, permissionMode, modelMode, sessionType, updatedAt: Date.now(), }); - }, [agentType, modelMode, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + }, [agentType, modelMode, permissionMode, selectedApiKeyId, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); // Persist the current wizard state so it survives remounts and screen navigation // Uses debouncing to avoid excessive writes @@ -1342,9 +1724,18 @@ function NewSessionWizard() { if (draftSaveTimerRef.current) { clearTimeout(draftSaveTimerRef.current); } + const delayMs = Platform.OS === 'web' ? 250 : 900; draftSaveTimerRef.current = setTimeout(() => { - persistDraftNow(); - }, 250); + // Persisting uses synchronous storage under the hood (MMKV), which can block the JS thread on iOS. + // Run after interactions so taps/animations stay responsive. + if (Platform.OS === 'web') { + persistDraftNow(); + } else { + InteractionManager.runAfterInteractions(() => { + persistDraftNow(); + }); + } + }, delayMs); return () => { if (draftSaveTimerRef.current) { clearTimeout(draftSaveTimerRef.current); @@ -1384,8 +1775,8 @@ function NewSessionWizard() { paddingTop: safeArea.top, paddingBottom: safeArea.bottom, }}> - {/* Session type selector only if experiments enabled */} - {experimentsEnabled && ( + {/* Session type selector only if enabled via experiments */} + {experimentsEnabled && expSessionType && ( @@ -1411,8 +1802,8 @@ function NewSessionWizard() { isSendDisabled={!canCreate} isSending={isCreating} placeholder={t('session.inputPlaceholder')} - autocompletePrefixes={[]} - autocompleteSuggestions={async () => []} + autocompletePrefixes={emptyAutocompletePrefixes} + autocompleteSuggestions={emptyAutocompleteSuggestions} agentType={agentType} onAgentClick={handleAgentClick} permissionMode={permissionMode} @@ -1446,712 +1837,196 @@ function NewSessionWizard() { // VARIANT B: Enhanced profile-first wizard (flag ON) // Full wizard with numbered sections, profile management, CLI detection // ======================================================================== - return ( - - - - - - - {useProfiles && ( - <> - - - - Select AI Profile - - - - Select an AI profile to apply environment variables and defaults to your session. - - - {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( - - {isDefaultEnvironmentFavorite && ( - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => { - if (ignoreProfileRowPressRef.current) { - ignoreProfileRowPressRef.current = false; - return; - } - setSelectedProfileId(null); - }} - rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} - showDivider={favoriteProfileItems.length > 0} - /> - )} - {favoriteProfileItems.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === favoriteProfileItems.length - 1; - return ( - { - if (!availability.available) return; - if (ignoreProfileRowPressRef.current) { - ignoreProfileRowPressRef.current = false; - return; - } - selectProfile(profile.id); - }} - rightElement={renderProfileRightElement(profile, isSelected, true)} - showDivider={!isLast} - /> - ); - })} - - )} - - {nonFavoriteCustomProfiles.length > 0 && ( - - {nonFavoriteCustomProfiles.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === nonFavoriteCustomProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { - if (!availability.available) return; - if (ignoreProfileRowPressRef.current) { - ignoreProfileRowPressRef.current = false; - return; - } - selectProfile(profile.id); - }} - rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - )} - - - {!isDefaultEnvironmentFavorite && ( - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => { - if (ignoreProfileRowPressRef.current) { - ignoreProfileRowPressRef.current = false; - return; - } - setSelectedProfileId(null); - }} - rightElement={renderDefaultEnvironmentRightElement(!selectedProfileId)} - showDivider={nonFavoriteBuiltInProfiles.length > 0} - /> - )} - {nonFavoriteBuiltInProfiles.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === nonFavoriteBuiltInProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { - if (!availability.available) return; - if (ignoreProfileRowPressRef.current) { - ignoreProfileRowPressRef.current = false; - return; - } - selectProfile(profile.id); - }} - rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - - } - onPress={handleAddProfile} - showChevron={false} - showDivider={false} - /> - - - - - )} - - {/* Section: AI Backend */} - - - - - Select AI Backend - - - - - {useProfiles && selectedProfileId - ? 'Limited by your selected profile and available CLIs on this machine.' - : 'Select which AI runs your session.'} - - - {/* 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 && allowGemini && !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 → - - - - - )} - - } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> - {(() => { - const selectedProfile = useProfiles && selectedProfileId - ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) - : null; - - const options: Array<{ - key: 'claude' | 'codex' | 'gemini'; - title: string; - subtitle: string; - icon: React.ComponentProps['name']; - }> = [ - { key: 'claude', title: 'Claude', subtitle: 'Claude CLI', icon: 'sparkles-outline' }, - { key: 'codex', title: 'Codex', subtitle: 'Codex CLI', icon: 'terminal-outline' }, - ...(allowGemini ? [{ key: 'gemini' as const, title: 'Gemini', subtitle: 'Gemini CLI', icon: 'planet-outline' as const }] : []), - ]; - - return options.map((option, index) => { - const compatible = !selectedProfile || !!selectedProfile.compatibility?.[option.key]; - const cliOk = cliAvailability[option.key] !== false; - const disabledReason = !compatible - ? 'Not compatible with the selected profile.' - : !cliOk - ? `${option.title} CLI not detected on this machine.` - : null; - - const isSelected = agentType === option.key; - - return ( - } - selected={isSelected} - disabled={!!disabledReason} - onPress={() => { - if (disabledReason) { - Modal.alert( - 'AI Backend', - disabledReason, - compatible - ? [{ text: t('common.ok'), style: 'cancel' }] - : [ - { text: t('common.ok'), style: 'cancel' }, - ...(useProfiles && selectedProfileId ? [{ text: 'Change Profile', onPress: handleAgentInputProfileClick }] : []), - ], - ); - return; - } - setAgentType(option.key); - }} - rightElement={( - - - - )} - showChevron={false} - showDivider={index < options.length - 1} - /> - ); - }); - })()} - - {modelOptions.length > 0 && ( - - - - - Select AI Model - - - - Choose the model used by this session. - - - {modelOptions.map((option, index, options) => { - const isSelected = modelMode === option.value; - return ( - } - showChevron={false} - selected={isSelected} - onPress={() => setModelMode(option.value)} - rightElement={( - - - - )} - showDivider={index < options.length - 1} - /> - ); - })} - - - )} - - - - {/* Section 2: Machine Selection */} - - - - Select Machine - - - - Choose where this session runs. - - - - { - setSelectedMachineId(machine.id); - const bestPath = getRecentPathForMachine(machine.id); - setSelectedPath(bestPath); - }} - onToggleFavorite={(machine) => { - const isInFavorites = favoriteMachines.includes(machine.id); - if (isInFavorites) { - setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); - } else { - setFavoriteMachines([...favoriteMachines, machine.id]); - } - }} - /> - - - {/* Section 3: Working Directory */} - - - - Select Working Directory - - - - Pick the folder used for commands and context. - - - - - - - {/* Section 4: Permission Mode */} - - - - Select Permission Mode - - - - Control how strictly actions require approval. - - - {(agentType === 'codex' || agentType === 'gemini' - ? [ - { value: 'default' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.default' : 'agentInput.geminiPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.readOnly' : 'agentInput.geminiPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.safeYolo' : 'agentInput.geminiPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.yolo' : 'agentInput.geminiPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, - ] - : [ - { value: 'default' as PermissionMode, label: t('agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: t('agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: t('agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: t('agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, - ] - ).map((option, index, array) => ( - - } - rightElement={permissionMode === option.value ? ( - - ) : null} - onPress={() => handlePermissionModeChange(option.value)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - - - {/* Section 5: Session Type */} - - - - Select Session Type - - - - Choose a simple session or one tied to a Git worktree. - - - - } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> - - - - - - - - - {/* AgentInput - Sticky at bottom */} - - - - []} - agentType={agentType} - onAgentClick={handleAgentInputAgentClick} - permissionMode={permissionMode} - onPermissionClick={handleAgentInputPermissionClick} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleAgentInputMachineClick} - currentPath={selectedPath} - onPathClick={handleAgentInputPathClick} - contentPaddingHorizontal={0} - {...(useProfiles ? { - profileId: selectedProfileId, - onProfileClick: handleAgentInputProfileClick, - envVarsCount: selectedProfileEnvVarsCount || undefined, - onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, - } : {})} - /> - - - - - + const wizardLayoutProps = React.useMemo(() => { + return { + theme, + styles, + safeAreaBottom: safeArea.bottom, + headerHeight, + newSessionSidePadding, + newSessionBottomPadding, + scrollViewRef, + profileSectionRef, + modelSectionRef, + machineSectionRef, + pathSectionRef, + permissionSectionRef, + registerWizardSectionOffset, + }; + }, [headerHeight, newSessionBottomPadding, newSessionSidePadding, registerWizardSectionOffset, safeArea.bottom, theme]); + + const wizardProfilesProps = React.useMemo(() => { + return { + useProfiles, + profiles, + favoriteProfileIds, + setFavoriteProfileIds, + experimentsEnabled, + selectedProfileId, + onPressDefaultEnvironment, + onPressProfile, + selectedMachineId, + getProfileDisabled, + getProfileSubtitleExtra, + handleAddProfile, + openProfileEdit, + handleDuplicateProfile, + handleDeleteProfile, + openProfileEnvVarsPreview, + suppressNextApiKeyAutoPromptKeyRef, + sessionOnlyApiKeyValue, + selectedSavedApiKeyValue: selectedSavedApiKey?.value, + apiKeyPreflightIsReady: apiKeyPreflight.isReady, + openApiKeyRequirementModal, + profilesGroupTitles, + }; + }, [ + apiKeyPreflight.isReady, + experimentsEnabled, + favoriteProfileIds, + getProfileDisabled, + getProfileSubtitleExtra, + handleAddProfile, + handleDeleteProfile, + handleDuplicateProfile, + onPressDefaultEnvironment, + onPressProfile, + openApiKeyRequirementModal, + openProfileEdit, + openProfileEnvVarsPreview, + profiles, + profilesGroupTitles, + selectedMachineId, + selectedProfileId, + selectedSavedApiKey?.value, + sessionOnlyApiKeyValue, + setFavoriteProfileIds, + suppressNextApiKeyAutoPromptKeyRef, + useProfiles, + ]); + + const wizardAgentProps = React.useMemo(() => { + return { + cliAvailability, + allowGemini, + isWarningDismissed, + hiddenBanners, + handleCLIBannerDismiss, + agentType, + setAgentType, + modelOptions, + modelMode, + setModelMode, + selectedIndicatorColor, + profileMap, + handleAgentInputProfileClick, + permissionMode, + handlePermissionModeChange, + sessionType, + setSessionType, + }; + }, [ + agentType, + allowGemini, + cliAvailability, + handleAgentInputProfileClick, + handleCLIBannerDismiss, + hiddenBanners, + isWarningDismissed, + modelMode, + modelOptions, + permissionMode, + profileMap, + selectedIndicatorColor, + sessionType, + setAgentType, + setModelMode, + setSessionType, + handlePermissionModeChange, + ]); + + const wizardMachineProps = React.useMemo(() => { + return { + machines, + selectedMachine: selectedMachine || null, + recentMachines, + favoriteMachineItems, + useMachinePickerSearch, + setSelectedMachineId, + getBestPathForMachine, + setSelectedPath, + favoriteMachines, + setFavoriteMachines, + selectedPath, + recentPaths, + usePathPickerSearch, + favoriteDirectories, + setFavoriteDirectories, + }; + }, [ + favoriteDirectories, + favoriteMachineItems, + favoriteMachines, + getBestPathForMachine, + machines, + recentMachines, + recentPaths, + selectedMachine, + selectedPath, + setFavoriteDirectories, + setFavoriteMachines, + setSelectedMachineId, + setSelectedPath, + useMachinePickerSearch, + usePathPickerSearch, + ]); + + const wizardFooterProps = React.useMemo(() => { + return { + sessionPrompt, + setSessionPrompt, + handleCreateSession, + canCreate, + isCreating, + emptyAutocompletePrefixes, + emptyAutocompleteSuggestions, + handleAgentInputAgentClick, + handleAgentInputPermissionClick, + connectionStatus, + handleAgentInputMachineClick, + handleAgentInputPathClick, + handleAgentInputProfileClick: handleAgentInputProfileClick, + selectedProfileEnvVarsCount, + handleEnvVarsClick, + }; + }, [ + canCreate, + connectionStatus, + emptyAutocompletePrefixes, + emptyAutocompleteSuggestions, + handleAgentInputAgentClick, + handleAgentInputMachineClick, + handleAgentInputPathClick, + handleAgentInputPermissionClick, + handleCreateSession, + handleEnvVarsClick, + isCreating, + selectedProfileEnvVarsCount, + sessionPrompt, + setSessionPrompt, + handleAgentInputProfileClick, + ]); + + return ( + ); } -export default React.memo(NewSessionWizard); +export default React.memo(NewSessionScreen); diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index ddc76b732..b10a1a22c 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -1,86 +1,136 @@ import React from 'react'; import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; -import { View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; -import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { AIBackendProfile } from '@/sync/settings'; import { Modal } from '@/modal'; -import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; -import { buildProfileGroups, toggleFavoriteProfileId } from '@/sync/profileGrouping'; -import { ItemRowActions } from '@/components/ItemRowActions'; -import { buildProfileActions } from '@/components/profileActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; -import { ignoreNextRowPress } from '@/utils/ignoreNextRowPress'; +import { machinePreviewEnv } from '@/sync/ops'; +import { getProfileEnvironmentVariables } from '@/sync/settings'; +import { getRequiredSecretEnvVarName } from '@/sync/profileSecrets'; +import { storeTempData } from '@/utils/tempDataStore'; +import { ProfilesList } from '@/components/profiles/ProfilesList'; +import { ApiKeyRequirementModal, type ApiKeyRequirementModalResult } from '@/components/ApiKeyRequirementModal'; export default React.memo(function ProfilePickerScreen() { const { theme } = useUnistyles(); - const styles = stylesheet; const router = useRouter(); const navigation = useNavigation(); const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); const useProfiles = useSetting('useProfiles'); const experimentsEnabled = useSetting('experiments'); + const [apiKeys, setApiKeys] = useSettingMutable('apiKeys'); + const [defaultApiKeyByProfileId, setDefaultApiKeyByProfileId] = useSettingMutable('defaultApiKeyByProfileId'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; - const ignoreProfileRowPressRef = React.useRef(false); - - const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { - return ; - }, []); - - const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { - const parts: string[] = []; - if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); - if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); - if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); - return parts.length > 0 ? parts.join(' • ') : ''; - }, [experimentsEnabled]); - - const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { - const backend = getProfileBackendSubtitle(profile); - if (profile.isBuiltIn) { - const builtInLabel = t('profiles.builtIn'); - return backend ? `${builtInLabel} · ${backend}` : builtInLabel; - } - const customLabel = t('profiles.custom'); - return backend ? `${customLabel} · ${backend}` : customLabel; - }, [getProfileBackendSubtitle]); - - const setProfileParamAndClose = React.useCallback((profileId: string) => { + const setParamsOnPreviousAndClose = React.useCallback((next: { profileId: string; apiKeyId?: string; apiKeySessionOnlyId?: string }) => { const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; if (state && state.index > 0 && previousRoute) { navigation.dispatch({ type: 'SET_PARAMS', - payload: { params: { profileId } }, + payload: { params: next }, source: previousRoute.key, } as never); } router.back(); }, [navigation, router]); - const handleProfileRowPress = React.useCallback((profileId: string) => { - if (ignoreProfileRowPressRef.current) { - ignoreProfileRowPressRef.current = false; - return; + const openApiKeyModal = React.useCallback((profile: AIBackendProfile) => { + const handleResolve = (result: ApiKeyRequirementModalResult) => { + if (result.action === 'cancel') return; + + if (result.action === 'useMachine') { + // Explicit choice: prefer machine key (do not auto-apply defaults in parent). + setParamsOnPreviousAndClose({ profileId: profile.id, apiKeyId: '' }); + return; + } + + if (result.action === 'enterOnce') { + const tempId = storeTempData({ apiKey: result.value }); + setParamsOnPreviousAndClose({ profileId: profile.id, apiKeySessionOnlyId: tempId }); + return; + } + + if (result.action === 'selectSaved') { + if (result.setDefault) { + setDefaultApiKeyByProfileId({ + ...defaultApiKeyByProfileId, + [profile.id]: result.apiKeyId, + }); + } + setParamsOnPreviousAndClose({ profileId: profile.id, apiKeyId: result.apiKeyId }); + } + }; + + Modal.show({ + component: ApiKeyRequirementModal, + props: { + profile, + machineId: machineId ?? null, + apiKeys, + defaultApiKeyId: defaultApiKeyByProfileId[profile.id] ?? null, + onChangeApiKeys: setApiKeys, + allowSessionOnly: true, + onResolve: handleResolve, + onRequestClose: () => handleResolve({ action: 'cancel' }), + }, + }); + }, [apiKeys, defaultApiKeyByProfileId, machineId, setDefaultApiKeyByProfileId, setParamsOnPreviousAndClose]); + + const handleProfilePress = React.useCallback(async (profile: AIBackendProfile) => { + const profileId = profile.id; + // Gate API-key profiles: require machine env OR a selected/saved key before selecting. + const requiredSecret = getRequiredSecretEnvVarName(profile); + + if (machineId && profile && profile.authMode === 'apiKeyEnv' && requiredSecret) { + const defaultKeyId = defaultApiKeyByProfileId[profileId] ?? ''; + const defaultKey = defaultKeyId ? (apiKeys.find((k) => k.id === defaultKeyId) ?? null) : null; + + // Check machine env for required secret (best-effort; if unsupported treat as "not detected"). + const preview = await machinePreviewEnv(machineId, { + keys: [requiredSecret], + extraEnv: getProfileEnvironmentVariables(profile), + sensitiveKeys: [requiredSecret], + }); + const machineHasKey = preview.supported + ? Boolean(preview.response.values[requiredSecret]?.isSet) + : false; + + if (!machineHasKey && !defaultKey) { + openApiKeyModal(profile); + return; + } + + // Auto-apply default key if available (still overrideable later). + if (defaultKey) { + setParamsOnPreviousAndClose({ profileId, apiKeyId: defaultKey.id }); + return; + } } - setProfileParamAndClose(profileId); - }, [setProfileParamAndClose]); + + const defaultKeyId = defaultApiKeyByProfileId[profileId] ?? ''; + const defaultKey = defaultKeyId ? (apiKeys.find((k) => k.id === defaultKeyId) ?? null) : null; + setParamsOnPreviousAndClose(defaultKey ? { profileId, apiKeyId: defaultKey.id } : { profileId }); + }, [apiKeys, defaultApiKeyByProfileId, machineId, router, setParamsOnPreviousAndClose]); + + const handleDefaultEnvironmentPress = React.useCallback(() => { + setParamsOnPreviousAndClose({ profileId: '' }); + }, [setParamsOnPreviousAndClose]); React.useEffect(() => { if (typeof profileId === 'string' && profileId.length > 0) { - setProfileParamAndClose(profileId); + setParamsOnPreviousAndClose({ profileId }); } - }, [profileId, setProfileParamAndClose]); + }, [profileId, setParamsOnPreviousAndClose]); const openProfileCreate = React.useCallback(() => { router.push({ @@ -103,21 +153,6 @@ export default React.memo(function ProfilePickerScreen() { }); }, [machineId, router]); - const { - favoriteProfiles: favoriteProfileItems, - customProfiles: nonFavoriteCustomProfiles, - builtInProfiles: nonFavoriteBuiltInProfiles, - favoriteIds: favoriteProfileIdSet, - } = React.useMemo(() => { - return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); - }, [favoriteProfileIds, profiles]); - - const isDefaultEnvironmentFavorite = favoriteProfileIdSet.has(''); - - const toggleFavoriteProfile = React.useCallback((profileId: string) => { - setFavoriteProfileIds(toggleFavoriteProfileId(favoriteProfileIds, profileId)); - }, [favoriteProfileIds, setFavoriteProfileIds]); - const handleAddProfile = React.useCallback(() => { openProfileCreate(); }, [openProfileCreate]); @@ -135,94 +170,12 @@ export default React.memo(function ProfilePickerScreen() { // Only custom profiles live in `profiles` setting. const updatedProfiles = profiles.filter(p => p.id !== profile.id); setProfiles(updatedProfiles); - if (selectedId === profile.id) { - setProfileParamAndClose(''); - } + if (selectedId === profile.id) setParamsOnPreviousAndClose({ profileId: '' }); }, }, ], ); - }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); - - const renderProfileRowRightElement = React.useCallback( - (profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { - const actions = buildProfileActions({ - profile, - isFavorite, - favoriteActionColor: theme.colors.text, - nonFavoriteActionColor: theme.colors.textSecondary, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onEdit: () => openProfileEdit(profile.id), - onDuplicate: () => openProfileDuplicate(profile.id), - onDelete: () => handleDeleteProfile(profile), - }); - - return ( - - - - - { - ignoreNextRowPress(ignoreProfileRowPressRef); - }} - /> - - ); - }, - [ - handleDeleteProfile, - openProfileEdit, - openProfileDuplicate, - theme.colors.text, - theme.colors.textSecondary, - toggleFavoriteProfile, - ], - ); - - const renderDefaultEnvironmentRowRightElement = React.useCallback((isSelected: boolean) => { - const isFavorite = isDefaultEnvironmentFavorite; - const actions: ItemAction[] = [ - { - id: 'favorite', - title: isFavorite ? t('profiles.actions.removeFromFavorites') : t('profiles.actions.addToFavorites'), - icon: isFavorite ? 'star' : 'star-outline', - onPress: () => toggleFavoriteProfile(''), - color: isFavorite ? theme.colors.text : theme.colors.textSecondary, - }, - ]; - - return ( - - - - - { - ignoreNextRowPress(ignoreProfileRowPressRef); - }} - /> - - ); - }, [isDefaultEnvironmentFavorite, theme.colors.text, theme.colors.textSecondary, toggleFavoriteProfile]); + }, [profiles, selectedId, setParamsOnPreviousAndClose, setProfiles]); return ( <> @@ -234,147 +187,42 @@ export default React.memo(function ProfilePickerScreen() { }} /> - - {!useProfiles ? ( - - } - showChevron={false} - /> - } - onPress={() => router.push('/settings/features')} - /> - - ) : ( - <> - {(isDefaultEnvironmentFavorite || favoriteProfileItems.length > 0) && ( - - {isDefaultEnvironmentFavorite && ( - } - onPress={() => handleProfileRowPress('')} - showChevron={false} - selected={selectedId === ''} - rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} - showDivider={favoriteProfileItems.length > 0} - /> - )} - {favoriteProfileItems.map((profile, index) => { - const isSelected = selectedId === profile.id; - const isLast = index === favoriteProfileItems.length - 1; - return ( - handleProfileRowPress(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={renderProfileRowRightElement(profile, isSelected, true)} - showDivider={!isLast} - /> - ); - })} - - )} - - {nonFavoriteCustomProfiles.length > 0 && ( - - {nonFavoriteCustomProfiles.map((profile, index) => { - const isSelected = selectedId === profile.id; - const isLast = index === nonFavoriteCustomProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - handleProfileRowPress(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - )} - - - {!isDefaultEnvironmentFavorite && ( - } - onPress={() => handleProfileRowPress('')} - showChevron={false} - selected={selectedId === ''} - rightElement={renderDefaultEnvironmentRowRightElement(selectedId === '')} - showDivider={nonFavoriteBuiltInProfiles.length > 0} - /> - )} - {nonFavoriteBuiltInProfiles.map((profile, index) => { - const isSelected = selectedId === profile.id; - const isLast = index === nonFavoriteBuiltInProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - handleProfileRowPress(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - - - } - onPress={handleAddProfile} - showChevron={false} - /> - - - )} - + {!useProfiles ? ( + + } + showChevron={false} + /> + } + onPress={() => router.push('/settings/features')} + /> + + ) : ( + openProfileEdit(p.id)} + onDuplicateProfile={(p) => openProfileDuplicate(p.id)} + onDeleteProfile={handleDeleteProfile} + onApiKeyBadgePress={(profile) => openApiKeyModal(profile)} + /> + )} ); }); -const stylesheet = StyleSheet.create(() => ({ - itemList: { - paddingTop: 0, - }, - rowRightElement: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - }, - indicatorSlot: { - width: 24, - alignItems: 'center', - justifyContent: 'center', - }, - selectedIndicatorVisible: { - opacity: 1, - }, - selectedIndicatorHidden: { - opacity: 0, - }, -})); +const stylesheet = StyleSheet.create(() => ({})); diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index f7b21d243..5daedd345 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -32,13 +32,15 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { useUnistyles(); // Subscribe to theme changes for re-render const styles = stylesheet; const experimentsEnabled = useSetting('experiments'); + const expGemini = useSetting('expGemini'); + const allowGemini = experimentsEnabled && expGemini; // iOS can render some dingbat glyphs as emoji; force text presentation (U+FE0E). const CLAUDE_GLYPH = '\u2733\uFE0E'; const GEMINI_GLYPH = '\u2726\uFE0E'; const hasClaude = !!profile.compatibility?.claude; const hasCodex = !!profile.compatibility?.codex; - const hasGemini = experimentsEnabled && !!profile.compatibility?.gemini; + const hasGemini = allowGemini && !!profile.compatibility?.gemini; const glyphs = React.useMemo(() => { const items: Array<{ key: string; glyph: string; factor: number }> = []; From b99d74643cc960091c9c1499c17e205af00e0192 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:35:39 +0100 Subject: [PATCH 34/38] perf(sync): debounce pending settings writes --- sources/sync/sync.ts | 73 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 6234a2a1d..ff3098fd5 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -12,7 +12,7 @@ import { ActivityUpdateAccumulator } from './reducer/activityUpdateAccumulator'; import { randomUUID } from 'expo-crypto'; import * as Notifications from 'expo-notifications'; import { registerPushToken } from './apiPush'; -import { Platform, AppState } from 'react-native'; +import { Platform, AppState, InteractionManager } from 'react-native'; import { isRunningOnMac } from '@/utils/platform'; import { NormalizedMessage, normalizeRawMessage, RawRecord } from './typesRaw'; import { applySettings, Settings, settingsDefaults, settingsParse, SUPPORTED_SCHEMA_VERSION } from './settings'; @@ -69,11 +69,15 @@ class Sync { private todosSync: InvalidateSync; private activityAccumulator: ActivityUpdateAccumulator; private pendingSettings: Partial = loadPendingSettings(); + private pendingSettingsFlushTimer: ReturnType | null = null; + private pendingSettingsDirty = false; revenueCatInitialized = false; // Generic locking mechanism private recalculationLockCount = 0; private lastRecalculationTime = 0; + private machinesRefreshInFlight: Promise | null = null; + private lastMachinesRefreshAt = 0; constructor() { this.sessionsSync = new InvalidateSync(this.fetchSessions); @@ -115,10 +119,48 @@ class Sync { this.todosSync.invalidate(); } else { log.log(`📱 App state changed to: ${nextAppState}`); + // Reliability: ensure we persist any pending settings immediately when backgrounding. + // This avoids losing last-second settings changes if the OS suspends the app. + try { + if (this.pendingSettingsFlushTimer) { + clearTimeout(this.pendingSettingsFlushTimer); + this.pendingSettingsFlushTimer = null; + } + savePendingSettings(this.pendingSettings); + } catch { + // ignore + } } }); } + private schedulePendingSettingsFlush = () => { + if (this.pendingSettingsFlushTimer) { + clearTimeout(this.pendingSettingsFlushTimer); + } + this.pendingSettingsDirty = true; + // Debounce disk write + network sync to keep UI interactions snappy. + // IMPORTANT: JSON.stringify + MMKV.set are synchronous and can stall taps on iOS if run too often. + this.pendingSettingsFlushTimer = setTimeout(() => { + if (!this.pendingSettingsDirty) { + return; + } + this.pendingSettingsDirty = false; + + const flush = () => { + // Persist pending settings for crash/restart safety. + savePendingSettings(this.pendingSettings); + // Trigger server sync (can be retried later). + this.settingsSync.invalidate(); + }; + if (Platform.OS === 'web') { + flush(); + } else { + InteractionManager.runAfterInteractions(flush); + } + }, 900); + }; + async create(credentials: AuthCredentials, encryption: Encryption) { this.credentials = credentials; this.encryption = encryption; @@ -297,7 +339,6 @@ class Sync { // Save pending settings this.pendingSettings = { ...this.pendingSettings, ...delta }; - savePendingSettings(this.pendingSettings); // Sync PostHog opt-out state if it was changed if (tracking && 'analyticsOptOut' in delta) { @@ -309,8 +350,7 @@ class Sync { } } - // Invalidate settings sync - this.settingsSync.invalidate(); + this.schedulePendingSettingsFlush(); } refreshPurchases = () => { @@ -545,6 +585,31 @@ class Sync { return this.fetchMachines(); } + public refreshMachinesThrottled = async (params?: { staleMs?: number; force?: boolean }) => { + if (!this.credentials) return; + const staleMs = params?.staleMs ?? 30_000; + const force = params?.force ?? false; + const now = Date.now(); + + if (!force && (now - this.lastMachinesRefreshAt) < staleMs) { + return; + } + + if (this.machinesRefreshInFlight) { + return this.machinesRefreshInFlight; + } + + this.machinesRefreshInFlight = this.fetchMachines() + .then(() => { + this.lastMachinesRefreshAt = Date.now(); + }) + .finally(() => { + this.machinesRefreshInFlight = null; + }); + + return this.machinesRefreshInFlight; + } + public refreshSessions = async () => { return this.sessionsSync.invalidateAndAwait(); } From 4ac60fcdcc7eec028209d1c59d10197aadf59607 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:35:50 +0100 Subject: [PATCH 35/38] fix(ui): localize error alerts --- sources/hooks/useHappyAction.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sources/hooks/useHappyAction.ts b/sources/hooks/useHappyAction.ts index 926767b6f..ba6a8b4e5 100644 --- a/sources/hooks/useHappyAction.ts +++ b/sources/hooks/useHappyAction.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { Modal } from '@/modal'; +import { t } from '@/text'; import { HappyError } from '@/utils/errors'; export function useHappyAction(action: () => Promise) { @@ -27,10 +28,10 @@ export function useHappyAction(action: () => Promise) { // await alert('Error', e.message, [{ text: 'OK', style: 'cancel' }]); // break; // } - Modal.alert('Error', e.message, [{ text: 'OK', style: 'cancel' }]); + Modal.alert(t('common.error'), e.message, [{ text: t('common.ok'), style: 'cancel' }]); break; } else { - Modal.alert('Error', 'Unknown error', [{ text: 'OK', style: 'cancel' }]); + Modal.alert(t('common.error'), t('errors.unknownError'), [{ text: t('common.ok'), style: 'cancel' }]); break; } } @@ -42,4 +43,4 @@ export function useHappyAction(action: () => Promise) { })(); }, [action]); return [loading, doAction] as const; -} \ No newline at end of file +} From 3a2d4399fbc226ef1c9c7e0a19f45358f6a3070a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:39:15 +0100 Subject: [PATCH 36/38] refactor(i18n): replace remaining UI literals --- sources/app/(app)/artifacts/[id].tsx | 10 +++++----- sources/app/(app)/restore/index.tsx | 7 ++----- sources/app/(app)/restore/manual.tsx | 4 ++-- sources/app/(app)/session/[id]/info.tsx | 10 +++++----- sources/app/(app)/settings/connect/claude.tsx | 4 ++-- sources/components/ChatFooter.tsx | 5 +++-- .../CommandPalette/CommandPaletteResults.tsx | 5 +++-- sources/components/CommandView.tsx | 4 ++-- sources/components/ConnectButton.tsx | 2 +- sources/components/EmptyMainScreen.tsx | 6 +++--- sources/components/EmptyMessages.tsx | 6 +++--- sources/components/EmptySessionsTablet.tsx | 11 ++++++----- sources/components/MessageView.tsx | 4 +++- sources/components/OAuthView.tsx | 6 +++--- sources/components/VoiceAssistantStatusBar.tsx | 13 +++++++------ sources/components/markdown/MarkdownView.tsx | 4 ++-- sources/components/markdown/MermaidRenderer.tsx | 2 +- sources/components/usage/UsageChart.tsx | 5 +++-- sources/utils/microphonePermissions.ts | 15 ++++++++------- 19 files changed, 64 insertions(+), 59 deletions(-) diff --git a/sources/app/(app)/artifacts/[id].tsx b/sources/app/(app)/artifacts/[id].tsx index 93d6e2b9f..83d0850e6 100644 --- a/sources/app/(app)/artifacts/[id].tsx +++ b/sources/app/(app)/artifacts/[id].tsx @@ -153,7 +153,7 @@ export default function ArtifactDetailScreen() { console.error('Failed to delete artifact:', err); Modal.alert( t('common.error'), - 'Failed to delete artifact' + t('artifacts.deleteError') ); } finally { setIsDeleting(false); @@ -216,7 +216,7 @@ export default function ArtifactDetailScreen() { ( - {artifact.title || 'Untitled'} + {artifact.title || t('artifacts.untitled')} {formattedDate} @@ -268,7 +268,7 @@ export default function ArtifactDetailScreen() { ) : ( - No content + {t('artifacts.noContent')} )} @@ -276,4 +276,4 @@ export default function ArtifactDetailScreen() { ); -} \ No newline at end of file +} diff --git a/sources/app/(app)/restore/index.tsx b/sources/app/(app)/restore/index.tsx index a0ae06f39..554925762 100644 --- a/sources/app/(app)/restore/index.tsx +++ b/sources/app/(app)/restore/index.tsx @@ -137,10 +137,7 @@ export default function Restore() { - 1. Open Happy on your mobile device{'\n'} - 2. Go to Settings → Account{'\n'} - 3. Tap "Link New Device"{'\n'} - 4. Scan this QR code + {t('connect.restoreQrInstructions')} {!authReady && ( @@ -157,7 +154,7 @@ export default function Restore() { /> )} - { + { router.push('/restore/manual'); }} /> diff --git a/sources/app/(app)/restore/manual.tsx b/sources/app/(app)/restore/manual.tsx index 2c36ed29f..8df9ac7c6 100644 --- a/sources/app/(app)/restore/manual.tsx +++ b/sources/app/(app)/restore/manual.tsx @@ -112,12 +112,12 @@ export default function Restore() { - Enter your secret key to restore access to your account. + {t('connect.restoreWithSecretKeyDescription')} + {session.agentState && ( <> } showChevron={false} /> @@ -428,7 +428,7 @@ function SessionInfoContent({ session }: { session: Session }) { {session.metadata && ( <> } showChevron={false} /> @@ -443,7 +443,7 @@ function SessionInfoContent({ session }: { session: Session }) { {sessionStatus && ( <> } showChevron={false} /> @@ -463,7 +463,7 @@ function SessionInfoContent({ session }: { session: Session }) { )} {/* Full Session Object */} } showChevron={false} /> diff --git a/sources/app/(app)/settings/connect/claude.tsx b/sources/app/(app)/settings/connect/claude.tsx index 0693dd796..8008dca48 100644 --- a/sources/app/(app)/settings/connect/claude.tsx +++ b/sources/app/(app)/settings/connect/claude.tsx @@ -72,9 +72,9 @@ const OAuthViewUnsupported = React.memo((props: { return ( - Connect {props.name} + {t('connect.unsupported.connectTitle', { name: props.name })} - Run the following command in your terminal: + {t('connect.unsupported.runCommandInTerminal')} diff --git a/sources/components/ChatFooter.tsx b/sources/components/ChatFooter.tsx index 111c42bca..f6dc16878 100644 --- a/sources/components/ChatFooter.tsx +++ b/sources/components/ChatFooter.tsx @@ -3,6 +3,7 @@ import { View, Text, ViewStyle, TextStyle } from 'react-native'; import { Typography } from '@/constants/Typography'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; interface ChatFooterProps { controlledByUser?: boolean; @@ -41,10 +42,10 @@ export const ChatFooter = React.memo((props: ChatFooterProps) => { color={theme.colors.box.warning.text} /> - Permissions shown in terminal only. Reset or send a message to control from app. + {t('chatFooter.permissionsTerminalOnly')} )} ); -}); \ No newline at end of file +}); diff --git a/sources/components/CommandPalette/CommandPaletteResults.tsx b/sources/components/CommandPalette/CommandPaletteResults.tsx index ab7ec1bc0..8a85a5697 100644 --- a/sources/components/CommandPalette/CommandPaletteResults.tsx +++ b/sources/components/CommandPalette/CommandPaletteResults.tsx @@ -3,6 +3,7 @@ import { View, ScrollView, Text, StyleSheet, Platform } from 'react-native'; import { Command, CommandCategory } from './types'; import { CommandPaletteItem } from './CommandPaletteItem'; import { Typography } from '@/constants/Typography'; +import { t } from '@/text'; interface CommandPaletteResultsProps { categories: CommandCategory[]; @@ -43,7 +44,7 @@ export function CommandPaletteResults({ return ( - No commands found + {t('commandPalette.noCommandsFound')} ); @@ -126,4 +127,4 @@ const styles = StyleSheet.create({ letterSpacing: 0.8, fontWeight: '600', }, -}); \ No newline at end of file +}); diff --git a/sources/components/CommandView.tsx b/sources/components/CommandView.tsx index 5bbd22a16..459935e85 100644 --- a/sources/components/CommandView.tsx +++ b/sources/components/CommandView.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Text, View, StyleSheet, Platform } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; +import { t } from '@/text'; interface CommandViewProps { command: string; @@ -120,7 +121,7 @@ export const CommandView = React.memo(({ {/* Empty output indicator */} {!stdout && !stderr && !error && !hideEmptyOutput && ( - [Command completed with no output] + {t('commandView.completedWithNoOutput')} )} ) : ( @@ -132,4 +133,3 @@ export const CommandView = React.memo(({ ); }); - diff --git a/sources/components/ConnectButton.tsx b/sources/components/ConnectButton.tsx index 9b313a3f7..e686d98aa 100644 --- a/sources/components/ConnectButton.tsx +++ b/sources/components/ConnectButton.tsx @@ -88,7 +88,7 @@ export const ConnectButton = React.memo(() => { }} value={manualUrl} onChangeText={setManualUrl} - placeholder="happy://terminal?..." + placeholder={t('connect.terminalUrlPlaceholder')} placeholderTextColor="#999" autoCapitalize="none" autoCorrect={false} diff --git a/sources/components/EmptyMainScreen.tsx b/sources/components/EmptyMainScreen.tsx index 5ca26942c..d63e9a3c8 100644 --- a/sources/components/EmptyMainScreen.tsx +++ b/sources/components/EmptyMainScreen.tsx @@ -96,10 +96,10 @@ export function EmptyMainScreen() { {t('components.emptyMainScreen.readyToCode')} - $ npm i -g happy-coder + {t('components.emptyMainScreen.installCommand')} - $ happy + {t('components.emptyMainScreen.runCommand')} @@ -151,7 +151,7 @@ export function EmptyMainScreen() { t('modals.authenticateTerminal'), t('modals.pasteUrlFromTerminal'), { - placeholder: 'happy://terminal?...', + placeholder: t('connect.terminalUrlPlaceholder'), cancelText: t('common.cancel'), confirmText: t('common.authenticate') } diff --git a/sources/components/EmptyMessages.tsx b/sources/components/EmptyMessages.tsx index 26ad3928a..41200ace4 100644 --- a/sources/components/EmptyMessages.tsx +++ b/sources/components/EmptyMessages.tsx @@ -112,12 +112,12 @@ export function EmptyMessages({ session }: EmptyMessagesProps) { )} - No messages yet + {t('components.emptyMessages.noMessagesYet')} - Created {startedTime} + {t('components.emptyMessages.created', { time: startedTime })} ); -} \ No newline at end of file +} diff --git a/sources/components/EmptySessionsTablet.tsx b/sources/components/EmptySessionsTablet.tsx index e9812c6ca..9e7b9a049 100644 --- a/sources/components/EmptySessionsTablet.tsx +++ b/sources/components/EmptySessionsTablet.tsx @@ -6,6 +6,7 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { useAllMachines } from '@/sync/storage'; import { isMachineOnline } from '@/utils/machineUtils'; import { useRouter } from 'expo-router'; +import { t } from '@/text'; const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -78,13 +79,13 @@ export function EmptySessionsTablet() { /> - No active sessions + {t('components.emptySessionsTablet.noActiveSessions')} {hasOnlineMachines ? ( <> - Start a new session on any of your connected machines. + {t('components.emptySessionsTablet.startNewSessionDescription')} - Start New Session + {t('components.emptySessionsTablet.startNewSessionButton')} ) : ( - Open a new terminal on your computer to start session. + {t('components.emptySessionsTablet.openTerminalToStart')} )} ); -} \ No newline at end of file +} diff --git a/sources/components/MessageView.tsx b/sources/components/MessageView.tsx index 9ddabe01c..ee695164f 100644 --- a/sources/components/MessageView.tsx +++ b/sources/components/MessageView.tsx @@ -90,12 +90,14 @@ function AgentTextBlock(props: { sessionId: string; }) { const experiments = useSetting('experiments'); + const expShowThinkingMessages = useSetting('expShowThinkingMessages'); + const showThinkingMessages = experiments && expShowThinkingMessages; 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) { + if (props.message.isThinking && !showThinkingMessages) { return null; } diff --git a/sources/components/OAuthView.tsx b/sources/components/OAuthView.tsx index 5a28793d7..d8201f29f 100644 --- a/sources/components/OAuthView.tsx +++ b/sources/components/OAuthView.tsx @@ -359,9 +359,9 @@ export const OAuthViewUnsupported = React.memo((props: { return ( - Connect {props.name} + {t('connect.unsupported.connectTitle', { name: props.name })} - Run the following command in your terminal: + {t('connect.unsupported.runCommandInTerminal')} @@ -371,4 +371,4 @@ export const OAuthViewUnsupported = React.memo((props: { ); -}); \ No newline at end of file +}); diff --git a/sources/components/VoiceAssistantStatusBar.tsx b/sources/components/VoiceAssistantStatusBar.tsx index d6dc8b09c..f554bd92a 100644 --- a/sources/components/VoiceAssistantStatusBar.tsx +++ b/sources/components/VoiceAssistantStatusBar.tsx @@ -8,6 +8,7 @@ import { Ionicons } from '@expo/vector-icons'; import { stopRealtimeSession } from '@/realtime/RealtimeSession'; import { useUnistyles } from 'react-native-unistyles'; import { VoiceBars } from './VoiceBars'; +import { t } from '@/text'; interface VoiceAssistantStatusBarProps { variant?: 'full' | 'sidebar'; @@ -34,7 +35,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.connecting, backgroundColor: theme.colors.surfaceHighest, isPulsing: true, - text: 'Connecting...', + text: t('voiceAssistant.connecting'), textColor: theme.colors.text }; case 'connected': @@ -42,7 +43,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.connected, backgroundColor: theme.colors.surfaceHighest, isPulsing: false, - text: 'Voice Assistant Active', + text: t('voiceAssistant.active'), textColor: theme.colors.text }; case 'error': @@ -50,7 +51,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.error, backgroundColor: theme.colors.surfaceHighest, isPulsing: false, - text: 'Connection Error', + text: t('voiceAssistant.connectionError'), textColor: theme.colors.text }; default: @@ -58,7 +59,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: color: theme.colors.status.default, backgroundColor: theme.colors.surfaceHighest, isPulsing: false, - text: 'Voice Assistant', + text: t('voiceAssistant.label'), textColor: theme.colors.text }; } @@ -128,7 +129,7 @@ export const VoiceAssistantStatusBar = React.memo(({ variant = 'full', style }: /> )} - Tap to end + {t('voiceAssistant.tapToEnd')} @@ -257,4 +258,4 @@ const styles = StyleSheet.create({ opacity: 0.8, ...Typography.default(), }, -}); \ No newline at end of file +}); diff --git a/sources/components/markdown/MarkdownView.tsx b/sources/components/markdown/MarkdownView.tsx index d1bfded82..64c0b0f94 100644 --- a/sources/components/markdown/MarkdownView.tsx +++ b/sources/components/markdown/MarkdownView.tsx @@ -41,7 +41,7 @@ export const MarkdownView = React.memo((props: { router.push(`/text-selection?textId=${textId}`); } catch (error) { console.error('Error storing text for selection:', error); - Modal.alert('Error', 'Failed to open text selection. Please try again.'); + Modal.alert(t('common.error'), t('textSelection.failedToOpen')); } }, [props.markdown, router]); const renderContent = () => { @@ -537,4 +537,4 @@ const style = StyleSheet.create((theme) => ({ // Web-only CSS styles _____web_global_styles: {} } : {}), -})); \ No newline at end of file +})); diff --git a/sources/components/markdown/MermaidRenderer.tsx b/sources/components/markdown/MermaidRenderer.tsx index 290d48854..4cc6ca601 100644 --- a/sources/components/markdown/MermaidRenderer.tsx +++ b/sources/components/markdown/MermaidRenderer.tsx @@ -75,7 +75,7 @@ export const MermaidRenderer = React.memo((props: { return ( - Mermaid diagram syntax error + {t('markdown.mermaidRenderFailed')} {props.content} diff --git a/sources/components/usage/UsageChart.tsx b/sources/components/usage/UsageChart.tsx index ac2c4713e..b63727e58 100644 --- a/sources/components/usage/UsageChart.tsx +++ b/sources/components/usage/UsageChart.tsx @@ -3,6 +3,7 @@ import { View, ScrollView, Pressable } from 'react-native'; import { Text } from '@/components/StyledText'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { UsageDataPoint } from '@/sync/apiUsage'; +import { t } from '@/text'; interface UsageChartProps { data: UsageDataPoint[]; @@ -68,7 +69,7 @@ export const UsageChart: React.FC = ({ if (!data || data.length === 0) { return ( - No usage data available + {t('usage.noData')} ); } @@ -161,4 +162,4 @@ export const UsageChart: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/sources/utils/microphonePermissions.ts b/sources/utils/microphonePermissions.ts index 13a1f7004..d42e8b393 100644 --- a/sources/utils/microphonePermissions.ts +++ b/sources/utils/microphonePermissions.ts @@ -1,6 +1,7 @@ import { Platform, Linking } from 'react-native'; import { Modal } from '@/modal'; import { AudioModule } from 'expo-audio'; +import { t } from '@/text'; export interface MicrophonePermissionResult { granted: boolean; @@ -82,23 +83,23 @@ export async function checkMicrophonePermission(): Promise { // Opens app settings on iOS/Android Linking.openSettings(); From fff72b4f54f287ed3dc24a01271f339e80467ddb Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:39:30 +0100 Subject: [PATCH 37/38] refactor(ui): improve item rendering and action menu timing --- sources/components/Item.tsx | 18 +++++++++++++++--- sources/components/ItemActionsMenuModal.tsx | 8 ++++++-- sources/components/ItemGroup.tsx | 13 +++++++++++-- sources/components/ItemRowActions.tsx | 2 +- sources/components/SessionsList.tsx | 1 - 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/sources/components/Item.tsx b/sources/components/Item.tsx index 9869a768b..d01928585 100644 --- a/sources/components/Item.tsx +++ b/sources/components/Item.tsx @@ -19,7 +19,7 @@ import { ItemGroupSelectionContext } from '@/components/ItemGroup'; export interface ItemProps { title: string; - subtitle?: string; + subtitle?: React.ReactNode; subtitleLines?: number; // set 0 or undefined for auto/multiline detail?: string; icon?: React.ReactNode; @@ -152,6 +152,7 @@ export const Item = React.memo((props) => { if (!copy || isWeb) return; let textToCopy: string; + const subtitleText = typeof subtitle === 'string' ? subtitle : null; if (typeof copy === 'string') { // If copy is a string, use it directly @@ -159,7 +160,7 @@ export const Item = React.memo((props) => { } else { // If copy is true, try to figure out what to copy // Priority: detail > subtitle > title - textToCopy = detail || subtitle || title; + textToCopy = detail || subtitleText || title; } try { @@ -226,10 +227,21 @@ export const Item = React.memo((props) => { {title} {subtitle && (() => { + // If subtitle is a ReactNode (not string), render as-is. + // This enables richer subtitle layouts (e.g. inline glyphs). + if (typeof subtitle !== 'string') { + return ( + + {subtitle} + + ); + } + // Allow multiline when requested or when content contains line breaks const effectiveLines = subtitleLines !== undefined ? (subtitleLines <= 0 ? undefined : subtitleLines) - : (typeof subtitle === 'string' && subtitle.indexOf('\n') !== -1 ? undefined : 1); + : (subtitle.indexOf('\n') !== -1 ? undefined : 1); + return ( void) => { props.onClose(); - setTimeout(() => fn(), 0); + // On iOS, navigation actions fired immediately after closing an overlay modal + // can be dropped or feel flaky. Run after interactions/animations settle. + InteractionManager.runAfterInteractions(() => { + fn(); + }); }, [props.onClose]); return ( diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index f71199e89..07fde7b8b 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -27,6 +27,11 @@ export interface ItemGroupProps { titleStyle?: StyleProp; footerTextStyle?: StyleProp; containerStyle?: StyleProp; + /** + * Performance: when you already know how many selectable rows are inside the group, + * pass this to avoid walking the full React children tree on every render. + */ + selectableItemCountOverride?: number; } const stylesheet = StyleSheet.create((theme, runtime) => ({ @@ -93,12 +98,16 @@ export const ItemGroup = React.memo((props) => { footerStyle, titleStyle, footerTextStyle, - containerStyle + containerStyle, + selectableItemCountOverride } = props; const selectableItemCount = React.useMemo(() => { + if (typeof selectableItemCountOverride === 'number') { + return selectableItemCountOverride; + } return countSelectableItems(children); - }, [children]); + }, [children, selectableItemCountOverride]); const selectionContextValue = React.useMemo(() => { return { selectableItemCount }; diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx index c039618bc..11aa1d90a 100644 --- a/sources/components/ItemRowActions.tsx +++ b/sources/components/ItemRowActions.tsx @@ -19,7 +19,7 @@ export function ItemRowActions(props: ItemRowActionsProps) { const { theme } = useUnistyles(); const styles = stylesheet; const { width } = useWindowDimensions(); - const compact = width < (props.compactThreshold ?? 420); + const compact = width < (props.compactThreshold ?? 450); const compactIds = React.useMemo(() => new Set(props.compactActionIds ?? []), [props.compactActionIds]); const inlineActions = React.useMemo(() => { diff --git a/sources/components/SessionsList.tsx b/sources/components/SessionsList.tsx index a3999ed91..ec2dbab10 100644 --- a/sources/components/SessionsList.tsx +++ b/sources/components/SessionsList.tsx @@ -204,7 +204,6 @@ export function SessionsList() { const compactSessionView = useSetting('compactSessionView'); const router = useRouter(); const selectable = isTablet; - const experiments = useSetting('experiments'); const dataWithSelected = selectable ? React.useMemo(() => { return data?.map(item => ({ ...item, From cc6c0af3f3d453535bbce1dc4c178a87f5726d36 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 18 Jan 2026 22:41:34 +0100 Subject: [PATCH 38/38] feat(experiments): gate Zen, file viewer, and voice auth flow --- sources/-session/SessionView.tsx | 3 ++- sources/-zen/ZenAdd.tsx | 5 +++-- sources/-zen/ZenHome.tsx | 5 +++-- sources/-zen/ZenView.tsx | 13 +++++++------ sources/-zen/components/ZenHeader.tsx | 6 +++--- sources/components/SidebarView.tsx | 14 ++++++++------ sources/realtime/RealtimeSession.ts | 4 +++- 7 files changed, 29 insertions(+), 21 deletions(-) diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index 530c928dd..45e232208 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -176,6 +176,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: const sessionUsage = useSessionUsage(sessionId); const alwaysShowContextSize = useSetting('alwaysShowContextSize'); const experiments = useSetting('experiments'); + const expFileViewer = useSetting('expFileViewer'); // Use draft hook for auto-saving message drafts const { clearDraft } = useDraft(sessionId, message, setMessage); @@ -316,7 +317,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: isMicActive={micButtonState.isMicActive} onAbort={() => sessionAbort(sessionId)} showAbortButton={sessionStatus.state === 'thinking' || sessionStatus.state === 'waiting'} - onFileViewerPress={experiments ? () => router.push(`/session/${sessionId}/files`) : undefined} + onFileViewerPress={(experiments && expFileViewer) ? () => router.push(`/session/${sessionId}/files`) : undefined} // Autocomplete configuration autocompletePrefixes={['@', '/']} autocompleteSuggestions={(query) => getSuggestions(sessionId, query)} diff --git a/sources/-zen/ZenAdd.tsx b/sources/-zen/ZenAdd.tsx index 14e4da50c..4c9ab8654 100644 --- a/sources/-zen/ZenAdd.tsx +++ b/sources/-zen/ZenAdd.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Typography } from '@/constants/Typography'; import { addTodo } from '@/-zen/model/ops'; import { useAuth } from '@/auth/AuthContext'; +import { t } from '@/text'; export const ZenAdd = React.memo(() => { const router = useRouter(); @@ -38,7 +39,7 @@ export const ZenAdd = React.memo(() => { borderBottomColor: theme.colors.divider, } ]} - placeholder="What needs to be done?" + placeholder={t('zen.add.placeholder')} placeholderTextColor={theme.colors.textSecondary} value={text} onChangeText={setText} @@ -71,4 +72,4 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: 4, ...Typography.default(), }, -})); \ No newline at end of file +})); diff --git a/sources/-zen/ZenHome.tsx b/sources/-zen/ZenHome.tsx index fdfd6e924..991a6d149 100644 --- a/sources/-zen/ZenHome.tsx +++ b/sources/-zen/ZenHome.tsx @@ -11,6 +11,7 @@ import { toggleTodo as toggleTodoSync, reorderTodos as reorderTodosSync } from ' import { useAuth } from '@/auth/AuthContext'; import { useShallow } from 'zustand/react/shallow'; import { VoiceAssistantStatusBar } from '@/components/VoiceAssistantStatusBar'; +import { t } from '@/text'; export const ZenHome = () => { const insets = useSafeAreaInsets(); @@ -103,7 +104,7 @@ export const ZenHome = () => { {undoneTodos.length === 0 ? ( - No tasks yet. Tap + to add one. + {t('zen.home.noTasksYet')} ) : ( @@ -114,4 +115,4 @@ export const ZenHome = () => { ); -}; \ No newline at end of file +}; diff --git a/sources/-zen/ZenView.tsx b/sources/-zen/ZenView.tsx index d04e51d11..4c3209afa 100644 --- a/sources/-zen/ZenView.tsx +++ b/sources/-zen/ZenView.tsx @@ -14,6 +14,7 @@ import { clarifyPrompt } from '@/-zen/model/prompts'; import { storeTempData, type NewSessionData } from '@/utils/tempDataStore'; import { toCamelCase } from '@/utils/stringUtils'; import { removeTaskLinks, getSessionsForTask } from '@/-zen/model/taskSessionLink'; +import { t } from '@/text'; export const ZenView = React.memo(() => { const router = useRouter(); @@ -217,7 +218,7 @@ export const ZenView = React.memo(() => { style={[styles.actionButton, { backgroundColor: theme.colors.button.primary.background }]} > - Work on task + {t('zen.view.workOnTask')} { style={[styles.actionButton, { backgroundColor: theme.colors.surfaceHighest }]} > - Clarify + {t('zen.view.clarify')} { style={[styles.actionButton, { backgroundColor: theme.colors.textDestructive }]} > - Delete + {t('zen.view.delete')} @@ -241,7 +242,7 @@ export const ZenView = React.memo(() => { {linkedSessions.length > 0 && ( - Linked Sessions + {t('zen.view.linkedSessions')} {linkedSessions.map((link, index) => ( { {/* Helper Text */} - Tap the task text to edit + {t('zen.view.tapTaskTextToEdit')} @@ -365,4 +366,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: 14, ...Typography.default(), }, -})); \ No newline at end of file +})); diff --git a/sources/-zen/components/ZenHeader.tsx b/sources/-zen/components/ZenHeader.tsx index 75620da4f..75baf657c 100644 --- a/sources/-zen/components/ZenHeader.tsx +++ b/sources/-zen/components/ZenHeader.tsx @@ -33,7 +33,7 @@ function HeaderTitleTablet() { fontWeight: '600', ...Typography.default('semiBold'), }}> - Zen + {t('zen.title')} ); } @@ -93,7 +93,7 @@ function HeaderTitle() { fontWeight: '600', ...Typography.default('semiBold'), }}> - Zen + {t('zen.title')} {connectionStatus.text && ( ); -} \ No newline at end of file +} diff --git a/sources/components/SidebarView.tsx b/sources/components/SidebarView.tsx index 6da485880..308c45601 100644 --- a/sources/components/SidebarView.tsx +++ b/sources/components/SidebarView.tsx @@ -1,4 +1,4 @@ -import { useSocketStatus, useFriendRequests, useSettings } from '@/sync/storage'; +import { useSocketStatus, useFriendRequests, useSetting } from '@/sync/storage'; import * as React from 'react'; import { Text, View, Pressable, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -139,7 +139,8 @@ export const SidebarView = React.memo(() => { const realtimeStatus = useRealtimeStatus(); const friendRequests = useFriendRequests(); const inboxHasContent = useInboxHasContent(); - const settings = useSettings(); + const experimentsEnabled = useSetting('experiments'); + const expZen = useSetting('expZen'); // Compute connection status once per render (theme-reactive, no stale memoization) const connectionStatus = (() => { @@ -187,9 +188,10 @@ export const SidebarView = React.memo(() => { // 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 showZen = experimentsEnabled && expZen; + // With Zen enabled: 4 icons (148px total), threshold 408px > max 360px → always left-justify + // Without Zen: 3 icons (108px total), threshold 328px → left-justify below ~340px + const shouldLeftJustify = showZen || sidebarWidth < 340; const handleNewSession = React.useCallback(() => { router.push('/new'); @@ -237,7 +239,7 @@ export const SidebarView = React.memo(() => { {/* Navigation icons */} - {settings.experiments && ( + {showZen && ( router.push('/(app)/zen')} hitSlop={15} diff --git a/sources/realtime/RealtimeSession.ts b/sources/realtime/RealtimeSession.ts index 93ab97318..374c81e0b 100644 --- a/sources/realtime/RealtimeSession.ts +++ b/sources/realtime/RealtimeSession.ts @@ -27,6 +27,8 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s } const experimentsEnabled = storage.getState().settings.experiments; + const expVoiceAuthFlow = storage.getState().settings.expVoiceAuthFlow; + const useAuthFlow = experimentsEnabled && expVoiceAuthFlow; const agentId = __DEV__ ? config.elevenLabsAgentIdDev : config.elevenLabsAgentIdProd; if (!agentId) { @@ -36,7 +38,7 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s try { // Simple path: No experiments = no auth needed - if (!experimentsEnabled) { + if (!useAuthFlow) { currentSessionId = sessionId; voiceSessionStarted = true; await voiceSession.startSession({