From ca6a0562ef2d61c2020380a7f7e87da84aecf7ce Mon Sep 17 00:00:00 2001 From: Scoteezy Date: Tue, 13 Jan 2026 13:39:09 +0300 Subject: [PATCH 01/72] feat: add ACP integration for Gemini with model selection and improved UX ACP Message Support: - Add 'acp' message type schema with provider field (gemini, codex, claude, opencode) - Implement normalization for all ACP message types in typesRaw.ts - Handle task_started, task_complete, turn_aborted for thinking state sync Gemini Model Selection: - Add Gemini models to ModelMode type (gemini-2.5-pro, flash, flash-lite) - Implement model selector UI in AgentInput for Gemini sessions - Pass selected model in meta.model to CLI - Default to gemini-2.5-pro for new Gemini sessions Permission System: - Update Gemini permission modes to match Codex-style (default, read-only, safe-yolo, yolo) - Replace 'Claude' references with agent-agnostic text in all translations Tool Display: - Add lowercase 'read' alias to knownTools for Gemini compatibility - Add 'think' tool definition for Gemini reasoning display - Both marked as minimal for collapsed display Session Management: - Add modelMode and onModelModeChange props to AgentInput - Update SessionView to pass model mode to AgentInput - Fix isGemini detection to work on new session page via agentType prop Translations: - Update all language files (en, ru, zh-Hans, pt, pl, ja, it, es, ca) - Replace Claude-specific permission text with generic agent text --- sources/-session/SessionView.tsx | 10 + sources/app/(app)/new/index.tsx | 54 +++- sources/components/AgentInput.tsx | 109 ++++++-- sources/components/PermissionModeSelector.tsx | 2 +- sources/components/tools/knownTools.tsx | 207 ++++++++++++++- sources/sync/storage.ts | 4 +- sources/sync/storageTypes.ts | 2 +- sources/sync/sync.ts | 43 +++- sources/sync/typesRaw.ts | 236 ++++++++++++++++++ sources/text/_default.ts | 14 +- sources/text/translations/ca.ts | 2 +- sources/text/translations/en.ts | 14 +- sources/text/translations/es.ts | 2 +- sources/text/translations/it.ts | 2 +- sources/text/translations/ja.ts | 2 +- sources/text/translations/pl.ts | 2 +- sources/text/translations/pt.ts | 2 +- sources/text/translations/ru.ts | 14 +- sources/text/translations/zh-Hans.ts | 2 +- 19 files changed, 653 insertions(+), 70 deletions(-) diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index e93fdd4eb..457419294 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -168,6 +168,9 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: const shouldShowCliWarning = isCliOutdated && !isAcknowledged; // Get permission mode from session object, default to 'default' const permissionMode = session.permissionMode || 'default'; + // Get model mode from session object - for Gemini sessions use explicit model, default to gemini-2.5-pro + const isGeminiSession = session.metadata?.flavor === 'gemini'; + const modelMode = session.modelMode || (isGeminiSession ? 'gemini-2.5-pro' : 'default'); const sessionStatus = useSessionStatus(session); const sessionUsage = useSessionUsage(sessionId); const alwaysShowContextSize = useSetting('alwaysShowContextSize'); @@ -193,6 +196,11 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); + // Function to update model mode (for Gemini sessions) + const updateModelMode = React.useCallback((mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => { + storage.getState().updateSessionModelMode(sessionId, mode); + }, [sessionId]); + // Memoize header-dependent styles to prevent re-renders const headerDependentStyles = React.useMemo(() => ({ contentContainer: { @@ -272,6 +280,8 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: sessionId={sessionId} permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} + modelMode={modelMode as any} + onModelModeChange={updateModelMode as any} metadata={session.metadata} connectionStatus={{ text: sessionStatus.statusText, diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c8d76009f..783dc2a19 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -349,13 +349,13 @@ function NewSessionWizard() { const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>('simple'); const [permissionMode, setPermissionMode] = React.useState(() => { // Initialize with last used permission mode if valid, otherwise default to 'default' - const validClaudeGeminiModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; if (lastUsedPermissionMode) { - if (agentType === 'codex' && validCodexModes.includes(lastUsedPermissionMode as PermissionMode)) { + if ((agentType === 'codex' || agentType === 'gemini') && validCodexGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) { return lastUsedPermissionMode as PermissionMode; - } else if ((agentType === 'claude' || agentType === 'gemini') && validClaudeGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) { + } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedPermissionMode as PermissionMode)) { return lastUsedPermissionMode as PermissionMode; } } @@ -368,8 +368,9 @@ function NewSessionWizard() { const [modelMode, setModelMode] = React.useState(() => { const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; - const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'default', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; - const validGeminiModes: ModelMode[] = ['default']; + 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)) { @@ -380,7 +381,7 @@ function NewSessionWizard() { return lastUsedModelMode as ModelMode; } } - return agentType === 'codex' ? 'gpt-5-codex-high' : 'default'; + return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default'; }); // Session details state @@ -706,10 +707,10 @@ function NewSessionWizard() { // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent React.useEffect(() => { const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - const isValidForCurrentAgent = agentType === 'codex' - ? validCodexModes.includes(permissionMode) + const isValidForCurrentAgent = (agentType === 'codex' || agentType === 'gemini') + ? validCodexGeminiModes.includes(permissionMode) : validClaudeModes.includes(permissionMode); if (!isValidForCurrentAgent) { @@ -717,6 +718,34 @@ function NewSessionWizard() { } }, [agentType, permissionMode]); + // Reset model mode when agent type changes to appropriate default + React.useEffect(() => { + const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus']; + const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; + // Note: 'default' is NOT valid for Gemini - we want explicit model selection + const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; + + let isValidForCurrentAgent = false; + if (agentType === 'codex') { + isValidForCurrentAgent = validCodexModes.includes(modelMode); + } else if (agentType === 'gemini') { + isValidForCurrentAgent = validGeminiModes.includes(modelMode); + } else { + isValidForCurrentAgent = validClaudeModes.includes(modelMode); + } + + if (!isValidForCurrentAgent) { + // Set appropriate default for each agent type + if (agentType === 'codex') { + setModelMode('gpt-5-codex-high'); + } else if (agentType === 'gemini') { + setModelMode('gemini-2.5-pro'); + } else { + setModelMode('default'); + } + } + }, [agentType, modelMode]); + // Scroll to section helpers - for AgentInput button clicks const scrollToSection = React.useCallback((ref: React.RefObject) => { if (!ref.current || !scrollViewRef.current) return; @@ -1032,8 +1061,11 @@ function NewSessionWizard() { await sync.refreshSessions(); - // Set permission mode on the session + // Set permission mode and model mode on the session storage.getState().updateSessionPermissionMode(result.sessionId, permissionMode); + if (agentType === 'gemini' && modelMode && modelMode !== 'default') { + storage.getState().updateSessionModelMode(result.sessionId, modelMode as 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'); + } // Send initial message if provided if (sessionPrompt.trim()) { diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index e406b8725..a2481e38a 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -300,8 +300,9 @@ export const AgentInput = React.memo(React.forwardRef 0; // Check if this is a Codex or Gemini session - const isCodex = props.metadata?.flavor === 'codex'; - const isGemini = props.metadata?.flavor === 'gemini'; + // 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'; // Profile data const profiles = useSetting('profiles'); @@ -527,15 +528,15 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 0 : 8 } ]}> - + {/* Permission Mode Section */} {isCodex ? t('agentInput.codexPermissionMode.title') : isGemini ? t('agentInput.geminiPermissionMode.title') : t('agentInput.permissionMode.title')} - {(isCodex + {((isCodex || isGemini) ? (['default', 'read-only', 'safe-yolo', 'yolo'] as const) - : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const) // Claude and Gemini share same modes + : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const) ).map((mode) => { const modeConfig = isCodex ? { 'default': { label: t('agentInput.codexPermissionMode.default') }, @@ -543,10 +544,10 @@ export const AgentInput = React.memo(React.forwardRef {t('agentInput.model.title')} - - {t('agentInput.model.configureInCli')} - + {isGemini ? ( + // Gemini model selector + (['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'] as const).map((model) => { + const modelConfig = { + 'gemini-2.5-pro': { label: 'Gemini 2.5 Pro', description: 'Most capable' }, + 'gemini-2.5-flash': { label: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, + 'gemini-2.5-flash-lite': { label: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, + }; + const config = modelConfig[model]; + const isSelected = props.modelMode === model; + + return ( + { + hapticsLight(); + props.onModelModeChange?.(model); + }} + style={({ pressed }) => ({ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent' + })} + > + + {isSelected && ( + + )} + + + + {config.label} + + + {config.description} + + + + ); + }) + ) : ( + + {t('agentInput.model.configureInCli')} + + )} @@ -765,9 +832,9 @@ export const AgentInput = React.memo(React.forwardRef { + // Gemini uses 'locations' array with 'path' field + if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) { + const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata); + return path; + } + if (typeof opts.tool.input.file_path === 'string') { + const path = resolvePath(opts.tool.input.file_path, opts.metadata); + return path; + } + return t('tools.names.readFile'); + }, + minimal: true, + icon: ICON_READ, + input: z.object({ + items: z.array(z.any()).optional(), + locations: z.array(z.object({ path: z.string() }).loose()).optional(), + file_path: z.string().optional() + }).partial().loose() + }, 'Edit': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { if (typeof opts.tool.input.file_path === 'string') { @@ -510,6 +540,67 @@ export const knownTools = { return t('tools.names.reasoning'); } }, + 'GeminiReasoning': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().describe('The title of the reasoning') + }).partial().loose(), + result: z.object({ + content: z.string().describe('The reasoning content'), + status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status of the reasoning') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, + 'think': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Use the title from input if provided + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + }, + icon: ICON_REASONING, + minimal: true, + input: z.object({ + title: z.string().optional().describe('The title of the thinking'), + items: z.array(z.any()).optional().describe('Items to think about'), + locations: z.array(z.any()).optional().describe('Locations to consider') + }).partial().loose(), + result: z.object({ + content: z.string().optional().describe('The reasoning content'), + text: z.string().optional().describe('The reasoning text'), + status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status') + }).partial().loose(), + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') { + return opts.tool.input.title; + } + return t('tools.names.reasoning'); + } + }, + 'change_title': { + title: 'Change Title', + icon: ICON_EDIT, + minimal: true, + noStatus: true, + input: z.object({ + title: z.string().optional().describe('New session title') + }).partial().loose(), + result: z.object({}).partial().loose() + }, 'CodexPatch': { title: t('tools.names.applyChanges'), icon: ICON_EDIT, @@ -564,6 +655,83 @@ export const knownTools = { return t('tools.names.applyChanges'); } }, + 'GeminiBash': { + title: t('tools.names.terminal'), + icon: ICON_TERMINAL, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + command: z.array(z.string()).describe('The command array to execute'), + cwd: z.string().optional().describe('Current working directory') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) { + let cmdArray = opts.tool.input.command; + // Remove shell wrapper prefix if present (bash/zsh with -lc flag) + if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') { + return cmdArray[2]; + } + return cmdArray.join(' '); + } + return null; + } + }, + 'GeminiPatch': { + title: t('tools.names.applyChanges'), + icon: ICON_EDIT, + minimal: true, + hideDefaultError: true, + isMutable: true, + input: z.object({ + auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'), + changes: z.record(z.string(), z.object({ + add: z.object({ + content: z.string() + }).optional(), + modify: z.object({ + old_content: z.string(), + new_content: z.string() + }).optional(), + delete: z.object({ + content: z.string() + }).optional() + }).loose()).describe('File changes to apply') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the first file being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + if (files.length > 0) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + if (files.length > 1) { + return t('tools.desc.modifyingMultipleFiles', { + file: fileName, + count: files.length - 1 + }); + } + return fileName; + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Show the number of files being modified + if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') { + const files = Object.keys(opts.tool.input.changes); + const fileCount = files.length; + if (fileCount === 1) { + const path = resolvePath(files[0], opts.metadata); + const fileName = path.split('/').pop() || path; + return t('tools.desc.modifyingFile', { file: fileName }); + } else if (fileCount > 1) { + return t('tools.desc.modifyingFiles', { count: fileCount }); + } + } + return t('tools.names.applyChanges'); + } + }, 'CodexDiff': { title: t('tools.names.viewDiff'), icon: ICON_EDIT, @@ -594,6 +762,43 @@ export const knownTools = { return t('tools.desc.showingDiff'); } }, + 'GeminiDiff': { + title: t('tools.names.viewDiff'), + icon: ICON_EDIT, + minimal: false, // Show full diff view + hideDefaultError: true, + noStatus: true, // Always successful, stateless like Task + input: z.object({ + unified_diff: z.string().optional().describe('Unified diff content'), + filePath: z.string().optional().describe('File path'), + description: z.string().optional().describe('Edit description') + }).partial().loose(), + result: z.object({ + status: z.literal('completed').describe('Always completed') + }).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Try to extract filename from filePath first + if (opts.tool.input?.filePath && typeof opts.tool.input.filePath === 'string') { + const basename = opts.tool.input.filePath.split('/').pop() || opts.tool.input.filePath; + return basename; + } + // Fall back to extracting from unified diff + if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') { + const diffLines = opts.tool.input.unified_diff.split('\n'); + for (const line of diffLines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + const fileName = line.replace(/^\+\+\+ (b\/)?/, ''); + const basename = fileName.split('/').pop() || fileName; + return basename; + } + } + } + return null; + }, + extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + return t('tools.desc.showingDiff'); + } + }, 'AskUserQuestion': { title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { // Use first question header as title if available diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index f1fe413f6..48e7ab771 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -117,7 +117,7 @@ interface StorageState { getActiveSessions: () => Session[]; updateSessionDraft: (sessionId: string, draft: string | null) => void; updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => void; - updateSessionModelMode: (sessionId: string, mode: 'default') => void; + updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => void; // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => void; addArtifact: (artifact: DecryptedArtifact) => void; @@ -808,7 +808,7 @@ export const storage = create()((set, get) => { sessions: updatedSessions }; }), - updateSessionModelMode: (sessionId: string, mode: 'default') => set((state) => { + updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => set((state) => { const session = state.sessions[sessionId]; if (!session) return state; diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bff187d04..82fedb5c1 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -70,7 +70,7 @@ export interface Session { }>; draft?: string | null; // Local draft message, not synced to server permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server - modelMode?: 'default' | null; // Local model mode, not synced to server (models configured in CLI) + modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. // We store it directly on Session to ensure it's available immediately on load. // Do NOT store reducerState itself on Session - it's mutable and should only exist in SessionMessages. diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index fde7d5b02..c1b38acf2 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -223,9 +223,13 @@ class Sync { return; } - // Read permission mode and model mode from session state + // Read permission mode from session state const permissionMode = session.permissionMode || 'default'; - const modelMode = session.modelMode || 'default'; + + // Read model mode - for Gemini, default to gemini-2.5-pro if not set + const flavor = session.metadata?.flavor; + const isGemini = flavor === 'gemini'; + const modelMode = session.modelMode || (isGemini ? 'gemini-2.5-pro' : 'default'); // Generate local ID const localId = randomUUID(); @@ -247,8 +251,12 @@ class Sync { sentFrom = 'web'; // fallback } - // Model settings - models are configured in CLI settings - const model: string | null = null; + // 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; // Create user message content with metadata @@ -1534,13 +1542,38 @@ class Sync { if (decrypted) { lastMessage = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content); + // Check for task lifecycle events to update thinking state + // This ensures UI updates even if volatile activity updates are lost + const rawContent = decrypted.content as { role?: string; content?: { type?: string; data?: { type?: string } } }; + const contentType = rawContent.content?.type; + const dataType = rawContent.content?.data?.type; + + // Debug logging to trace lifecycle events + if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { + console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`); + } + + const isTaskComplete = + ((contentType === 'acp' || contentType === 'codex') && + (dataType === 'task_complete' || dataType === 'turn_aborted')); + + const isTaskStarted = + ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started'); + + if (isTaskComplete || isTaskStarted) { + console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`); + } + // Update session const session = storage.getState().sessions[updateData.body.sid]; if (session) { this.applySessions([{ ...session, updatedAt: updateData.createdAt, - seq: updateData.seq + seq: updateData.seq, + // Update thinking state based on task lifecycle events + ...(isTaskComplete ? { thinking: false } : {}), + ...(isTaskStarted ? { thinking: true } : {}) }]) } else { // Fetch sessions again if we don't have this session diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 4dde5e855..aa7b2ed82 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -206,6 +206,68 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({ id: z.string() }) ]) +}), z.object({ + // ACP (Agent Communication Protocol) - unified format for all agent providers + type: z.literal('acp'), + provider: z.enum(['gemini', 'codex', 'claude', 'opencode']), + data: z.discriminatedUnion('type', [ + // Core message types + z.object({ type: z.literal('reasoning'), message: z.string() }), + z.object({ type: z.literal('message'), message: z.string() }), + z.object({ type: z.literal('thinking'), text: z.string() }), + // Tool interactions + z.object({ + type: z.literal('tool-call'), + callId: z.string(), + input: z.any(), + name: z.string(), + id: z.string() + }), + z.object({ + type: z.literal('tool-result'), + callId: z.string(), + output: z.any(), + id: z.string(), + isError: z.boolean().optional() + }), + // Hyphenated tool-call-result (for backwards compatibility with CLI) + z.object({ + type: z.literal('tool-call-result'), + callId: z.string(), + output: z.any(), + id: z.string() + }), + // File operations + z.object({ + type: z.literal('file-edit'), + description: z.string(), + filePath: z.string(), + diff: z.string().optional(), + oldContent: z.string().optional(), + newContent: z.string().optional(), + id: z.string() + }), + // Terminal/command output + z.object({ + type: z.literal('terminal-output'), + data: z.string(), + callId: z.string() + }), + // Task lifecycle events + z.object({ type: z.literal('task_started'), id: z.string() }), + z.object({ type: z.literal('task_complete'), id: z.string() }), + z.object({ type: z.literal('turn_aborted'), id: z.string() }), + // Permissions + z.object({ + type: z.literal('permission-request'), + permissionId: z.string(), + toolName: z.string(), + description: z.string(), + options: z.any().optional() + }), + // Usage/metrics + z.object({ type: z.literal('token_count') }).passthrough() + ]) })]); /** @@ -577,6 +639,180 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } satisfies NormalizedMessage; } } + // ACP (Agent Communication Protocol) - unified format for all agent providers + if (raw.content.type === 'acp') { + if (raw.content.data.type === 'message') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'reasoning') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text: raw.content.data.message, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-call') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.callId, + name: raw.content.data.name || 'unknown', + input: raw.content.data.input, + description: null, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'tool-result') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: raw.content.data.output, + is_error: raw.content.data.isError ?? false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + // Handle hyphenated tool-call-result (backwards compatibility) + if (raw.content.data.type === 'tool-call-result') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: raw.content.data.output, + is_error: false, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'thinking') { + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'thinking', + thinking: raw.content.data.text, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'file-edit') { + // Map file-edit to tool-call for UI rendering + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.id, + name: 'file-edit', + input: { + filePath: raw.content.data.filePath, + description: raw.content.data.description, + diff: raw.content.data.diff, + oldContent: raw.content.data.oldContent, + newContent: raw.content.data.newContent + }, + description: raw.content.data.description, + uuid: raw.content.data.id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'terminal-output') { + // Map terminal-output to tool-result + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: raw.content.data.callId, + content: raw.content.data.data, + is_error: false, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + if (raw.content.data.type === 'permission-request') { + // Map permission-request to tool-call for UI to show permission dialog + return { + id, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: raw.content.data.permissionId, + name: raw.content.data.toolName, + input: raw.content.data.options ?? {}, + description: raw.content.data.description, + uuid: id, + parentUUID: null + }], + meta: raw.meta + } satisfies NormalizedMessage; + } + // Task lifecycle events (task_started, task_complete, turn_aborted) and token_count + // are status/metrics - skip normalization, they don't need UI rendering + } } return null; } \ No newline at end of file diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 66ed2cbee..0a94f0590 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -431,12 +431,12 @@ export const en = { geminiPermissionMode: { title: 'GEMINI PERMISSION MODE', default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', + readOnly: 'Read Only', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, @@ -759,7 +759,7 @@ export const en = { permissions: { yesAllowAllEdits: 'Yes, allow all edits during this session', yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and tell Claude what to do differently', + noTellClaude: 'No, and provide feedback', } }, diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e27bdba63..46f9d4f9c 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -760,7 +760,7 @@ export const ca: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sí, permet totes les edicions durant aquesta sessió', yesForTool: 'Sí, no tornis a preguntar per aquesta eina', - noTellClaude: 'No, i digues a Claude què fer diferent', + noTellClaude: 'No, proporciona comentaris', } }, diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 201e9c0ec..7bddc729b 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -447,12 +447,12 @@ export const en: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI PERMISSION MODE', default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', + readOnly: 'Read Only', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% left`, @@ -775,7 +775,7 @@ export const en: TranslationStructure = { permissions: { yesAllowAllEdits: 'Yes, allow all edits during this session', yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and tell Claude what to do differently', + noTellClaude: 'No, and provide feedback', } }, diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 387d726cc..a79953775 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -760,7 +760,7 @@ export const es: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sí, permitir todas las ediciones durante esta sesión', yesForTool: 'Sí, no volver a preguntar para esta herramienta', - noTellClaude: 'No, y decirle a Claude qué hacer diferente', + noTellClaude: 'No, proporcionar comentarios', } }, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 4743fdd6e..bfa52467a 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -789,7 +789,7 @@ export const it: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sì, consenti tutte le modifiche durante questa sessione', yesForTool: 'Sì, non chiedere più per questo strumento', - noTellClaude: 'No, e di a Claude cosa fare diversamente', + noTellClaude: 'No, fornisci feedback', } }, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index c8af39724..fe1007884 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -792,7 +792,7 @@ export const ja: TranslationStructure = { permissions: { yesAllowAllEdits: 'はい、このセッション中のすべての編集を許可', yesForTool: "はい、このツールについては確認しない", - noTellClaude: 'いいえ、Claudeに別の方法を伝える', + noTellClaude: 'いいえ、フィードバックを提供', } }, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4d24cd147..1c8e2f087 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -770,7 +770,7 @@ export const pl: TranslationStructure = { permissions: { yesAllowAllEdits: 'Tak, zezwól na wszystkie edycje podczas tej sesji', yesForTool: 'Tak, nie pytaj ponownie dla tego narzędzia', - noTellClaude: 'Nie, i powiedz Claude co zrobić inaczej', + noTellClaude: 'Nie, przekaż opinię', } }, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 7a8508f9b..859a7ae8b 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -760,7 +760,7 @@ export const pt: TranslationStructure = { permissions: { yesAllowAllEdits: 'Sim, permitir todas as edições durante esta sessão', yesForTool: 'Sim, não perguntar novamente para esta ferramenta', - noTellClaude: 'Não, e dizer ao Claude o que fazer diferente', + noTellClaude: 'Não, fornecer feedback', } }, diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 238ce60be..aa533ea82 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -442,12 +442,12 @@ export const ru: TranslationStructure = { geminiPermissionMode: { title: 'РЕЖИМ РАЗРЕШЕНИЙ', default: 'По умолчанию', - acceptEdits: 'Принимать правки', - plan: 'Режим планирования', - bypassPermissions: 'YOLO режим', - badgeAcceptAllEdits: 'Принимать все правки', - badgeBypassAllPermissions: 'Обход всех разрешений', - badgePlanMode: 'Режим планирования', + readOnly: 'Только чтение', + safeYolo: 'Безопасный YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Только чтение', + badgeSafeYolo: 'Безопасный YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`, @@ -758,7 +758,7 @@ export const ru: TranslationStructure = { permissions: { yesAllowAllEdits: 'Да, разрешить все правки в этой сессии', yesForTool: 'Да, больше не спрашивать для этого инструмента', - noTellClaude: 'Нет, и сказать Claude что делать по-другому', + noTellClaude: 'Нет, дать обратную связь', } }, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 630414316..b77851fde 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -762,7 +762,7 @@ export const zhHans: TranslationStructure = { permissions: { yesAllowAllEdits: '是,允许本次会话的所有编辑', yesForTool: '是,不再询问此工具', - noTellClaude: '否,并告诉 Claude 该如何不同地操作', + noTellClaude: '否,提供反馈', } }, From 308c3bf83f6937ecaa5e935490a7c83f2309194a Mon Sep 17 00:00:00 2001 From: Scoteezy Date: Tue, 13 Jan 2026 14:35:03 +0300 Subject: [PATCH 02/72] fix(tools): hide unknown Gemini tools automatically - Add search, edit, shell tools to knownTools with minimal: true - Auto-hide unknown tools for Gemini sessions in ToolView - Prevents showing raw INPUT/OUTPUT for internal Gemini tools --- sources/components/tools/ToolView.tsx | 8 ++++++++ sources/components/tools/knownTools.tsx | 24 ++++++++++++++++++++++++ sources/sync/sync.ts | 6 +++--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/sources/components/tools/ToolView.tsx b/sources/components/tools/ToolView.tsx index 98e42d879..15b8c8567 100644 --- a/sources/components/tools/ToolView.tsx +++ b/sources/components/tools/ToolView.tsx @@ -50,6 +50,14 @@ export const ToolView = React.memo((props) => { let icon = ; let noStatus = false; let hideDefaultError = false; + + // For Gemini: unknown tools should be rendered as minimal (hidden) + // This prevents showing raw INPUT/OUTPUT for internal Gemini tools + // that we haven't explicitly added to knownTools + const isGemini = props.metadata?.flavor === 'gemini'; + if (!knownTool && isGemini) { + minimal = true; + } // Extract status first to potentially use as title if (knownTool && typeof knownTool.extractStatus === 'function') { diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 086e8ec0d..7b0e12a25 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -601,6 +601,30 @@ export const knownTools = { }).partial().loose(), result: z.object({}).partial().loose() }, + // Gemini internal tools - should be hidden (minimal) + 'search': { + title: t('tools.names.search'), + icon: ICON_SEARCH, + minimal: true, + input: z.object({ + items: z.array(z.any()).optional(), + locations: z.array(z.any()).optional() + }).partial().loose() + }, + 'edit': { + title: t('tools.names.editFile'), + icon: ICON_EDIT, + minimal: true, + isMutable: true, + input: z.object({}).partial().loose() + }, + 'shell': { + title: t('tools.names.terminal'), + icon: ICON_TERMINAL, + minimal: true, + isMutable: true, + input: z.object({}).partial().loose() + }, 'CodexPatch': { title: t('tools.names.applyChanges'), icon: ICON_EDIT, diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index c1b38acf2..5393a3651 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1544,9 +1544,9 @@ class Sync { // Check for task lifecycle events to update thinking state // This ensures UI updates even if volatile activity updates are lost - const rawContent = decrypted.content as { role?: string; content?: { type?: string; data?: { type?: string } } }; - const contentType = rawContent.content?.type; - const dataType = rawContent.content?.data?.type; + const rawContent = decrypted.content as { role?: string; content?: { type?: string; data?: { type?: string } } } | null; + const contentType = rawContent?.content?.type; + const dataType = rawContent?.content?.data?.type; // Debug logging to trace lifecycle events if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') { From 2bfe284a19bf5009f1fe17654b724499f6f6f4ef Mon Sep 17 00:00:00 2001 From: Scoteezy Date: Tue, 13 Jan 2026 15:47:57 +0300 Subject: [PATCH 03/72] feat(gemini): improve tool display for edit and execute actions - Add GeminiEditView for proper diff display (handles oldText/newText fields) - Add GeminiExecuteView for shell commands with command/cwd/description - Update knownTools with proper definitions for Gemini tools - Extract titles and content from Gemini's nested toolCall structure - Show meaningful UI instead of raw JSON for Gemini tool calls --- sources/components/tools/knownTools.tsx | 65 ++++++++++++- .../components/tools/views/GeminiEditView.tsx | 75 +++++++++++++++ .../tools/views/GeminiExecuteView.tsx | 92 +++++++++++++++++++ sources/components/tools/views/_all.tsx | 9 +- 4 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 sources/components/tools/views/GeminiEditView.tsx create mode 100644 sources/components/tools/views/GeminiExecuteView.tsx diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx index 7b0e12a25..696f8315e 100644 --- a/sources/components/tools/knownTools.tsx +++ b/sources/components/tools/knownTools.tsx @@ -612,11 +612,40 @@ export const knownTools = { }).partial().loose() }, 'edit': { - title: t('tools.names.editFile'), + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Gemini sends data in nested structure, try multiple locations + let filePath: string | undefined; + + // 1. Check toolCall.content[0].path + if (opts.tool.input?.toolCall?.content?.[0]?.path) { + filePath = opts.tool.input.toolCall.content[0].path; + } + // 2. Check toolCall.title (has nice "Writing to ..." format) + else if (opts.tool.input?.toolCall?.title) { + return opts.tool.input.toolCall.title; + } + // 3. Check input[0].path (array format) + else if (Array.isArray(opts.tool.input?.input) && opts.tool.input.input[0]?.path) { + filePath = opts.tool.input.input[0].path; + } + // 4. Check direct path field + else if (typeof opts.tool.input?.path === 'string') { + filePath = opts.tool.input.path; + } + + if (filePath) { + return resolvePath(filePath, opts.metadata); + } + return t('tools.names.editFile'); + }, icon: ICON_EDIT, - minimal: true, isMutable: true, - input: z.object({}).partial().loose() + input: z.object({ + path: z.string().describe('The file path to edit'), + oldText: z.string().describe('The text to replace'), + newText: z.string().describe('The new text'), + type: z.string().optional().describe('Type of edit (diff)') + }).partial().loose() }, 'shell': { title: t('tools.names.terminal'), @@ -625,6 +654,36 @@ export const knownTools = { isMutable: true, input: z.object({}).partial().loose() }, + 'execute': { + title: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Gemini sends nice title in toolCall.title + if (opts.tool.input?.toolCall?.title) { + // Title is like "rm file.txt [cwd /path] (description)" + // Extract just the command part before [ + const fullTitle = opts.tool.input.toolCall.title; + const bracketIdx = fullTitle.indexOf(' ['); + if (bracketIdx > 0) { + return fullTitle.substring(0, bracketIdx); + } + return fullTitle; + } + return t('tools.names.terminal'); + }, + icon: ICON_TERMINAL, + isMutable: true, + input: z.object({}).partial().loose(), + extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => { + // Extract description from parentheses at the end + if (opts.tool.input?.toolCall?.title) { + const title = opts.tool.input.toolCall.title; + const parenMatch = title.match(/\(([^)]+)\)$/); + if (parenMatch) { + return parenMatch[1]; + } + } + return null; + } + }, 'CodexPatch': { title: t('tools.names.applyChanges'), icon: ICON_EDIT, diff --git a/sources/components/tools/views/GeminiEditView.tsx b/sources/components/tools/views/GeminiEditView.tsx new file mode 100644 index 000000000..9a4255634 --- /dev/null +++ b/sources/components/tools/views/GeminiEditView.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { ToolSectionView } from '../../tools/ToolSectionView'; +import { ToolViewProps } from './_all'; +import { ToolDiffView } from '@/components/tools/ToolDiffView'; +import { trimIdent } from '@/utils/trimIdent'; +import { useSetting } from '@/sync/storage'; + +/** + * Extract edit content from Gemini's nested input format. + * + * Gemini sends data in nested structure: + * - tool.input.toolCall.content[0] + * - tool.input.input[0] + * - tool.input (direct fields) + */ +function extractEditContent(input: any): { oldText: string; newText: string; path: string } { + // Try various locations where Gemini might put the edit data + + // 1. Check tool.input.toolCall.content[0] + if (input?.toolCall?.content?.[0]) { + const content = input.toolCall.content[0]; + return { + oldText: content.oldText || '', + newText: content.newText || '', + path: content.path || '' + }; + } + + // 2. Check tool.input.input[0] (array format) + if (Array.isArray(input?.input) && input.input[0]) { + const content = input.input[0]; + return { + oldText: content.oldText || '', + newText: content.newText || '', + path: content.path || '' + }; + } + + // 3. Check direct fields (simple format) + return { + oldText: input?.oldText || input?.old_string || '', + newText: input?.newText || input?.new_string || '', + path: input?.path || input?.file_path || '' + }; +} + +/** + * Gemini Edit View + * + * Handles Gemini's edit tool format which uses: + * - oldText (instead of old_string) + * - newText (instead of new_string) + * - path (instead of file_path) + */ +export const GeminiEditView = React.memo(({ tool }) => { + const showLineNumbersInToolViews = useSetting('showLineNumbersInToolViews'); + + const { oldText, newText } = extractEditContent(tool.input); + const oldString = trimIdent(oldText); + const newString = trimIdent(newText); + + return ( + <> + + + + + ); +}); + diff --git a/sources/components/tools/views/GeminiExecuteView.tsx b/sources/components/tools/views/GeminiExecuteView.tsx new file mode 100644 index 000000000..86fe20e84 --- /dev/null +++ b/sources/components/tools/views/GeminiExecuteView.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { ToolSectionView } from '../../tools/ToolSectionView'; +import { ToolViewProps } from './_all'; +import { CodeView } from '@/components/CodeView'; + +/** + * Extract execute command info from Gemini's nested input format. + */ +function extractExecuteInfo(input: any): { command: string; description: string; cwd: string } { + let command = ''; + let description = ''; + let cwd = ''; + + // Try to get title from toolCall.title + // Format: "rm file.txt [current working directory /path] (description)" + if (input?.toolCall?.title) { + const fullTitle = input.toolCall.title; + + // Extract command (before [) + const bracketIdx = fullTitle.indexOf(' ['); + if (bracketIdx > 0) { + command = fullTitle.substring(0, bracketIdx); + } else { + command = fullTitle; + } + + // Extract cwd from [current working directory /path] + const cwdMatch = fullTitle.match(/\[current working directory ([^\]]+)\]/); + if (cwdMatch) { + cwd = cwdMatch[1]; + } + + // Extract description from (...) + const descMatch = fullTitle.match(/\(([^)]+)\)$/); + if (descMatch) { + description = descMatch[1]; + } + } + + return { command, description, cwd }; +} + +/** + * Gemini Execute View + * + * Displays shell/terminal commands from Gemini's execute tool. + */ +export const GeminiExecuteView = React.memo(({ tool }) => { + const { command, description, cwd } = extractExecuteInfo(tool.input); + + if (!command) { + return null; + } + + return ( + <> + + + + {(description || cwd) && ( + + {cwd && ( + 📁 {cwd} + )} + {description && ( + {description} + )} + + )} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + infoContainer: { + paddingHorizontal: 12, + paddingBottom: 8, + }, + cwdText: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: 4, + }, + descriptionText: { + fontSize: 13, + color: theme.colors.textSecondary, + fontStyle: 'italic', + }, +})); + diff --git a/sources/components/tools/views/_all.tsx b/sources/components/tools/views/_all.tsx index 64190c0d1..fe087a377 100644 --- a/sources/components/tools/views/_all.tsx +++ b/sources/components/tools/views/_all.tsx @@ -15,6 +15,8 @@ import { CodexBashView } from './CodexBashView'; import { CodexPatchView } from './CodexPatchView'; import { CodexDiffView } from './CodexDiffView'; import { AskUserQuestionView } from './AskUserQuestionView'; +import { GeminiEditView } from './GeminiEditView'; +import { GeminiExecuteView } from './GeminiExecuteView'; export type ToolViewProps = { tool: ToolCall; @@ -39,7 +41,10 @@ export const toolViewRegistry: Record = { exit_plan_mode: ExitPlanToolView, MultiEdit: MultiEditView, Task: TaskView, - AskUserQuestion: AskUserQuestionView + AskUserQuestion: AskUserQuestionView, + // Gemini tools (lowercase) + edit: GeminiEditView, + execute: GeminiExecuteView, }; export const toolFullViewRegistry: Record = { @@ -71,3 +76,5 @@ export { ExitPlanToolView } from './ExitPlanToolView'; export { MultiEditView } from './MultiEditView'; export { TaskView } from './TaskView'; export { AskUserQuestionView } from './AskUserQuestionView'; +export { GeminiEditView } from './GeminiEditView'; +export { GeminiExecuteView } from './GeminiExecuteView'; From ec4298259ddd88c8e2653782506675056e745f31 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 16:13:56 +0100 Subject: [PATCH 04/72] fix: upstream sync regressions (wizard, i18n, profiles, routing) - Fix NewSessionWizard runtime issues (expo-crypto UUID, TDZ, no setState-in-render) - Fix built-in profile duplication: ensure isBuiltIn=false + reset timestamps - Fix i18n drift: profiles.deleteConfirm is a function across languages; prevent en.ts drift via re-export - Fix expo-router typed routes for profile edit + settings/profiles; harden profileData parsing - Misc: remove unnecessary any; docs: CONTRIBUTING uses yarn --- CONTRIBUTING.md | 70 +- sources/app/(app)/new/index.tsx | 17 +- sources/app/(app)/new/pick/machine.tsx | 9 +- sources/app/(app)/new/pick/profile-edit.tsx | 6 + sources/app/(app)/settings/profiles.tsx | 62 +- sources/components/NewSessionWizard.tsx | 42 +- sources/components/SettingsView.tsx | 12 +- sources/sync/settings.ts | 68 +- sources/text/_default.ts | 2 +- sources/text/translations/ca.ts | 2 +- sources/text/translations/en.ts | 936 +------------------- sources/text/translations/es.ts | 2 +- sources/text/translations/it.ts | 2 +- sources/text/translations/ja.ts | 2 +- sources/text/translations/pl.ts | 2 +- sources/text/translations/pt.ts | 2 +- sources/text/translations/ru.ts | 2 +- sources/text/translations/zh-Hans.ts | 2 +- 18 files changed, 158 insertions(+), 1082 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aa5635cc..a7ca4f9aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,42 +23,42 @@ This allows you to test production-like builds with real users before releasing ```bash # Development variant (default) -npm run ios:dev +yarn ios:dev # Preview variant -npm run ios:preview +yarn ios:preview # Production variant -npm run ios:production +yarn ios:production ``` ### Android Development ```bash # Development variant -npm run android:dev +yarn android:dev # Preview variant -npm run android:preview +yarn android:preview # Production variant -npm run android:production +yarn android:production ``` ### macOS Desktop (Tauri) ```bash # Development variant - run with hot reload -npm run tauri:dev +yarn tauri:dev # Build development variant -npm run tauri:build:dev +yarn tauri:build:dev # Build preview variant -npm run tauri:build:preview +yarn tauri:build:preview # Build production variant -npm run tauri:build:production +yarn tauri:build:production ``` **How Tauri Variants Work:** @@ -71,13 +71,13 @@ npm run tauri:build:production ```bash # Start dev server for development variant -npm run start:dev +yarn start:dev # Start dev server for preview variant -npm run start:preview +yarn start:preview # Start dev server for production variant -npm run start:production +yarn start:production ``` ## Visual Differences @@ -95,7 +95,7 @@ This makes it easy to distinguish which version you're testing! 1. **Build development variant:** ```bash - npm run ios:dev + yarn ios:dev ``` 2. **Make your changes** to the code @@ -104,19 +104,19 @@ This makes it easy to distinguish which version you're testing! 4. **Rebuild if needed** for native changes: ```bash - npm run ios:dev + yarn ios:dev ``` ### Testing Preview (Pre-Release) 1. **Build preview variant:** ```bash - npm run ios:preview + yarn ios:preview ``` 2. **Test OTA updates:** ```bash - npm run ota # Publishes to preview branch + yarn ota # Publishes to preview branch ``` 3. **Verify** the preview build works as expected @@ -125,17 +125,17 @@ This makes it easy to distinguish which version you're testing! 1. **Build production variant:** ```bash - npm run ios:production + yarn ios:production ``` 2. **Submit to App Store:** ```bash - npm run submit + yarn submit ``` 3. **Deploy OTA updates:** ```bash - npm run ota:production + yarn ota:production ``` ## All Variants Simultaneously @@ -144,9 +144,9 @@ You can install all three variants on the same device: ```bash # Build all three variants -npm run ios:dev -npm run ios:preview -npm run ios:production +yarn ios:dev +yarn ios:preview +yarn ios:production ``` All three apps appear on your device with different icons and names! @@ -195,12 +195,12 @@ You can connect different variants to different Happy CLI instances: ```bash # Development app → Dev CLI daemon -npm run android:dev -# Connect to CLI running: npm run dev:daemon:start +yarn android:dev +# Connect to CLI running: yarn dev:daemon:start # Production app → Stable CLI daemon -npm run android:production -# Connect to CLI running: npm run stable:daemon:start +yarn android:production +# Connect to CLI running: yarn stable:daemon:start ``` Each app maintains separate authentication and sessions! @@ -210,7 +210,7 @@ Each app maintains separate authentication and sessions! To test with a local Happy server: ```bash -npm run start:local-server +yarn start:local-server ``` This sets: @@ -227,8 +227,8 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. Check `app.config.js` - verify `bundleId` is set correctly for the variant 2. Clean build: ```bash - npm run prebuild - npm run ios:dev # or whichever variant + yarn prebuild + yarn ios:dev # or whichever variant ``` ### App not updating after changes @@ -236,12 +236,12 @@ This shouldn't happen - each variant has a unique bundle ID. If it does: 1. **For JS changes**: Hot reload should work automatically 2. **For native changes**: Rebuild the variant: ```bash - npm run ios:dev # Force rebuild + yarn ios:dev # Force rebuild ``` 3. **For config changes**: Clean and prebuild: ```bash - npm run prebuild - npm run ios:dev + yarn prebuild + yarn ios:dev ``` ### All three apps look the same @@ -258,7 +258,7 @@ If they're all the same name, the variant might not be set correctly. Verify: echo $APP_ENV # Or look at the build output -npm run ios:dev # Should show "Happy (dev)" as the name +yarn ios:dev # Should show "Happy (dev)" as the name ``` ### Connected device not found @@ -270,7 +270,7 @@ For iOS connected device testing: xcrun devicectl list devices # Run on specific connected device -npm run ios:connected-device +yarn ios:connected-device ``` ## Tips diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c8d76009f..fd115776d 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -770,14 +770,17 @@ function NewSessionWizard() { updatedAt: Date.now(), version: '1.0.0', }; - const profileData = encodeURIComponent(JSON.stringify(newProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); + const profileData = JSON.stringify(newProfile); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router]); const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { - const profileData = encodeURIComponent(JSON.stringify(profile)); - const machineId = selectedMachineId || ''; - router.push(`/new/pick/profile-edit?profileData=${profileData}&machineId=${machineId}`); + const profileData = JSON.stringify(profile); + if (selectedMachineId) { + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}&machineId=${encodeURIComponent(selectedMachineId)}` as any); + return; + } + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router, selectedMachineId]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { @@ -789,8 +792,8 @@ function NewSessionWizard() { createdAt: Date.now(), updatedAt: Date.now(), }; - const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile)); - router.push(`/new/pick/profile-edit?profileData=${profileData}`); + const profileData = JSON.stringify(duplicatedProfile); + router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); }, [router]); // Helper to get meaningful subtitle text for profiles diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c02580e8d..e5c35236d 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -65,14 +65,15 @@ export default function MachinePickerScreen() { sessions?.forEach(item => { if (typeof item === 'string') return; // Skip section headers - const session = item as any; - if (session.metadata?.machineId && !machineIds.has(session.metadata.machineId)) { - const machine = machines.find(m => m.id === session.metadata.machineId); + const session = item; + const machineId = session.metadata?.machineId; + if (machineId && !machineIds.has(machineId)) { + const machine = machines.find(m => m.id === machineId); if (machine) { machineIds.add(machine.id); machinesWithTimestamp.push({ machine, - timestamp: session.updatedAt || session.createdAt + timestamp: session.updatedAt || session.createdAt, }); } } diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 9bf311c82..b6cbb91ac 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -22,7 +22,13 @@ export default function ProfileEditScreen() { const profile: AIBackendProfile = React.useMemo(() => { if (params.profileData) { try { + // Params may arrive already decoded (native) or URL-encoded (web / manual encodeURIComponent). + // Try raw JSON first, then fall back to decodeURIComponent. + try { + return JSON.parse(params.profileData); + } catch { return JSON.parse(decodeURIComponent(params.profileData)); + } } catch (error) { console.error('Failed to parse profile data:', error); } diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fa4522023..d56a11839 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, Alert } from 'react-native'; +import { View, Text, Pressable, ScrollView } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSettingMutable } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; -import { Modal as HappyModal } from '@/modal/ModalManager'; +import { Modal } from '@/modal'; import { layout } from '@/components/layout'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useWindowDimensions } from 'react-native'; @@ -27,8 +27,7 @@ interface ProfileManagerProps { } // Profile utilities now imported from @/sync/profileUtils - -function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { +const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { const { theme } = useUnistyles(); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); @@ -57,37 +56,26 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr setShowAddForm(true); }; - const handleDeleteProfile = (profile: AIBackendProfile) => { - // Show confirmation dialog before deleting - Alert.alert( + const handleDeleteProfile = async (profile: AIBackendProfile) => { + const confirmed = await Modal.confirm( t('profiles.delete.title'), t('profiles.delete.message', { name: profile.name }), - [ - { - text: t('profiles.delete.cancel'), - style: 'cancel', - }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); + { cancelText: t('profiles.delete.cancel'), confirmText: t('profiles.delete.confirm'), destructive: true } + ); + if (!confirmed) return; - // Clear last used profile if it was deleted - if (lastUsedProfile === profile.id) { - setLastUsedProfile(null); - } + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); - // Notify parent if this was the selected profile - if (selectedProfileId === profile.id && onProfileSelect) { - onProfileSelect(null); - } - }, - }, - ], - { cancelable: true } - ); + // Clear last used profile if it was deleted + if (lastUsedProfile === profile.id) { + setLastUsedProfile(null); + } + + // Notify parent if this was the selected profile + if (selectedProfileId === profile.id && onProfileSelect) { + onProfileSelect(null); + } }; const handleSelectProfile = (profileId: string | null) => { @@ -124,6 +112,9 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr const newProfile: AIBackendProfile = { ...profile, id: randomUUID(), // Generate new UUID for custom profile + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), }; // Check for duplicate names (excluding the new profile) @@ -151,7 +142,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr if (existingIndex >= 0) { // Update existing profile updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profile; + updatedProfiles[existingIndex] = { + ...profile, + updatedAt: Date.now(), + }; } else { // Add new profile updatedProfiles = [...profiles, profile]; @@ -356,7 +350,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr handleDeleteProfile(profile)} + onPress={() => void handleDeleteProfile(profile)} style={{ marginLeft: 16 }} > @@ -410,7 +404,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr )} ); -} +}); // ProfileEditForm now imported from @/components/ProfileEditForm diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx index ea556c99f..a18e38157 100644 --- a/sources/components/NewSessionWizard.tsx +++ b/sources/components/NewSessionWizard.tsx @@ -14,6 +14,7 @@ import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariabl import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; import { profileSyncService } from '@/sync/profileSync'; +import { randomUUID } from 'expo-crypto'; const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -700,6 +701,16 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N const [profileApiKeys, setProfileApiKeys] = useState>>({}); const [profileConfigs, setProfileConfigs] = useState>>({}); + function profileNeedsConfiguration(profileId: string | null): boolean { + if (!profileId) return false; // Manual configuration doesn't need API keys + const profile = allProfiles.find(p => p.id === profileId); + if (!profile) return false; + + // Check if profile is one that requires API keys + const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; + return profilesNeedingKeys.includes(profile.id); + } + // Dynamic steps based on whether profile needs configuration const steps: WizardStep[] = React.useMemo(() => { const baseSteps: WizardStep[] = experimentsEnabled @@ -719,18 +730,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N } return baseSteps; - }, [experimentsEnabled, selectedProfileId]); - - // Helper function to check if profile needs API keys - const profileNeedsConfiguration = (profileId: string | null): boolean => { - if (!profileId) return false; // Manual configuration doesn't need API keys - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return false; - - // Check if profile is one that requires API keys - const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; - return profilesNeedingKeys.includes(profile.id); - }; + }, [experimentsEnabled, selectedProfileId, allProfiles]); // Get required fields for profile configuration const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { @@ -870,6 +870,17 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; + React.useEffect(() => { + // Guard: if the user changes profiles such that profileConfig is no longer required, + // advance to the next step (or reset to the first step if currentStep is invalid). + if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { + const nextStep = steps[currentStepIndex + 1] ?? steps[0] ?? 'profile'; + if (nextStep !== currentStep) { + setCurrentStep(nextStep); + } + } + }, [currentStep, currentStepIndex, selectedProfileId, steps]); + // Handler for "Use Profile As-Is" - quick session creation const handleUseProfileAsIs = (profile: AIBackendProfile) => { setSelectedProfileId(profile.id); @@ -932,7 +943,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N ).then((profileName) => { if (profileName && profileName.trim()) { const newProfile: AIBackendProfile = { - id: crypto.randomUUID(), + id: randomUUID(), name: profileName.trim(), description: 'Custom AI profile', anthropicConfig: {}, @@ -976,7 +987,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N if (newName && newName.trim()) { const duplicatedProfile: AIBackendProfile = { ...profile, - id: crypto.randomUUID(), + id: randomUUID(), name: newName.trim(), description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', isBuiltIn: false, @@ -1287,8 +1298,7 @@ export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: N case 'profileConfig': if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // Skip configuration if no profile selected or profile doesn't need configuration - setCurrentStep(steps[currentStepIndex + 1]); + // No profile configuration needed; navigation effect will auto-advance. return null; } diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 249345e97..37b1fc9e5 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -110,7 +110,7 @@ export const SettingsView = React.memo(function SettingsView() { // Anthropic connection const [connectingAnthropic, connectAnthropic] = useHappyAction(async () => { - router.push('/settings/connect/claude'); + router.push('/(app)/settings/connect/claude'); }); // Anthropic disconnection @@ -302,19 +302,19 @@ export const SettingsView = React.memo(function SettingsView() { title={t('settings.account')} subtitle={t('settings.accountSubtitle')} icon={} - onPress={() => router.push('/settings/account')} + onPress={() => router.push('/(app)/settings/account')} /> } - onPress={() => router.push('/settings/appearance')} + onPress={() => router.push('/(app)/settings/appearance')} /> } - onPress={() => router.push('/settings/voice')} + onPress={() => router.push('/(app)/settings/voice')} /> } - onPress={() => router.push('/dev')} + onPress={() => router.push('/(app)/dev')} /> )} @@ -357,7 +357,7 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => { trackWhatsNewClicked(); - router.push('/changelog'); + router.push('/(app)/changelog'); }} /> { - if (!val) return true; // Optional - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - // Otherwise validate as URL - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), + baseUrl: urlOrTemplateStringOptional(), authToken: z.string().optional(), model: z.string().optional(), }); const OpenAIConfigSchema = z.object({ apiKey: z.string().optional(), - baseUrl: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), + baseUrl: urlOrTemplateStringOptional(), model: z.string().optional(), }); const AzureOpenAIConfigSchema = z.object({ apiKey: z.string().optional(), - endpoint: z.string().refine( - (val) => { - if (!val) return true; - // Allow ${VAR} and ${VAR:-default} template strings - if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } - }, - { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' } - ).optional(), + endpoint: urlOrTemplateStringOptional(), apiVersion: z.string().optional(), deploymentName: z.string().optional(), }); @@ -158,7 +136,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * * 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session: * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching - * - Non-tmux mode: Node.js spawn with env: { ...process.env, ...profileEnvVars } (shell expansion in child) + * - Non-tmux mode: daemon must interpolate ${VAR} / ${VAR:-default} in env values before calling spawn() (Node does not expand placeholders) * * 5. SESSION RECEIVES actual expanded values: * ANTHROPIC_AUTH_TOKEN=sk-real-key (expanded from daemon's Z_AI_AUTH_TOKEN, not literal ${Z_AI_AUTH_TOKEN}) @@ -172,7 +150,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * - Each session uses its selected backend for its entire lifetime (no mid-session switching) * - Keep secrets in shell environment, not in GUI/profile storage * - * PRIORITY ORDER when spawning (daemon/run.ts): + * PRIORITY ORDER when spawning: * Final env = { ...daemon.process.env, ...expandedProfileVars, ...authVars } * authVars override profile, profile overrides daemon.process.env */ diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 66ed2cbee..4c69aa745 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -902,7 +902,7 @@ export const en = { enterTmuxTempDir: 'Enter temp directory path', tmuxUpdateEnvironment: 'Update environment automatically', nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`, editProfile: 'Edit Profile', addProfileTitle: 'Add New Profile', delete: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index e27bdba63..5138685c6 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -894,7 +894,7 @@ export const ca: TranslationStructure = { tmuxTempDir: 'Directori temporal tmux', enterTmuxTempDir: 'Introdueix el directori temporal tmux', tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux', - deleteConfirm: 'Segur que vols eliminar aquest perfil?', + deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`, nameRequired: 'El nom del perfil és obligatori', delete: { title: 'Eliminar Perfil', diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 201e9c0ec..b3afb1bea 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -1,933 +1,17 @@ -import type { TranslationStructure } from '../_default'; +import { en as defaultEn, type TranslationStructure } from '../_default'; /** - * English plural helper function - * English has 2 plural forms: singular, plural - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on English plural rules - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - -/** - * ENGLISH TRANSLATIONS - DEDICATED FILE - * - * This file represents the new translation architecture where each language - * has its own dedicated file instead of being embedded in _default.ts. + * English translations (temporary re-export). * - * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects - * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) - * - Benefit: Better maintainability, smaller files, easier language management + * `_default.ts` is currently the canonical source of truth for the English + * translation structure and is used at runtime by `sources/text/index.ts`. * - * This file contains the complete English translation structure and serves as - * the reference implementation for all other language files. + * This file exists for the “dedicated translations per language file” migration + * and for tooling/scripts that import `text/translations/en`. * - * ARCHITECTURE NOTES: - * - All translation keys must match across all language files - * - Type safety enforced by TranslationStructure interface - * - New translation keys must be added to ALL language files + * Re-exporting prevents drift and ensures this file always matches + * `TranslationStructure` without duplicating the full object. */ -export const en: TranslationStructure = { - tabs: { - // Tab navigation labels - inbox: 'Inbox', - sessions: 'Terminals', - settings: 'Settings', - }, - - inbox: { - // Inbox screen - emptyTitle: 'Empty Inbox', - emptyDescription: 'Connect with friends to start sharing sessions', - updates: 'Updates', - }, - - common: { - // Simple string constants - cancel: 'Cancel', - authenticate: 'Authenticate', - save: 'Save', - saveAs: 'Save As', - error: 'Error', - success: 'Success', - ok: 'OK', - continue: 'Continue', - back: 'Back', - create: 'Create', - rename: 'Rename', - reset: 'Reset', - logout: 'Logout', - yes: 'Yes', - no: 'No', - discard: 'Discard', - version: 'Version', - copy: 'Copy', - copied: 'Copied', - scanning: 'Scanning...', - urlPlaceholder: 'https://example.com', - home: 'Home', - message: 'Message', - files: 'Files', - fileViewer: 'File Viewer', - loading: 'Loading...', - retry: 'Retry', - delete: 'Delete', - optional: 'optional', - }, - - profile: { - userProfile: 'User Profile', - details: 'Details', - firstName: 'First Name', - lastName: 'Last Name', - username: 'Username', - status: 'Status', - }, - - - status: { - connected: 'connected', - connecting: 'connecting', - disconnected: 'disconnected', - error: 'error', - online: 'online', - offline: 'offline', - lastSeen: ({ time }: { time: string }) => `last seen ${time}`, - permissionRequired: 'permission required', - activeNow: 'Active now', - unknown: 'unknown', - }, - - time: { - justNow: 'just now', - minutesAgo: ({ count }: { count: number }) => `${count} minute${count !== 1 ? 's' : ''} ago`, - hoursAgo: ({ count }: { count: number }) => `${count} hour${count !== 1 ? 's' : ''} ago`, - }, - - connect: { - restoreAccount: 'Restore Account', - enterSecretKey: 'Please enter a secret key', - invalidSecretKey: 'Invalid secret key. Please check and try again.', - enterUrlManually: 'Enter URL manually', - }, - - settings: { - title: 'Settings', - connectedAccounts: 'Connected Accounts', - connectAccount: 'Connect account', - github: 'GitHub', - machines: 'Machines', - features: 'Features', - social: 'Social', - account: 'Account', - accountSubtitle: 'Manage your account details', - appearance: 'Appearance', - appearanceSubtitle: 'Customize how the app looks', - voiceAssistant: 'Voice Assistant', - voiceAssistantSubtitle: 'Configure voice interaction preferences', - featuresTitle: 'Features', - featuresSubtitle: 'Enable or disable app features', - developer: 'Developer', - developerTools: 'Developer Tools', - about: 'About', - aboutFooter: 'Happy Coder is a Codex and Claude Code mobile client. It\'s fully end-to-end encrypted and your account is stored only on your device. Not affiliated with Anthropic.', - whatsNew: 'What\'s New', - whatsNewSubtitle: 'See the latest updates and improvements', - reportIssue: 'Report an Issue', - privacyPolicy: 'Privacy Policy', - termsOfService: 'Terms of Service', - eula: 'EULA', - supportUs: 'Support us', - supportUsSubtitlePro: 'Thank you for your support!', - supportUsSubtitle: 'Support project development', - scanQrCodeToAuthenticate: 'Scan QR code to authenticate', - githubConnected: ({ login }: { login: string }) => `Connected as @${login}`, - connectGithubAccount: 'Connect your GitHub account', - claudeAuthSuccess: 'Successfully connected to Claude', - exchangingTokens: 'Exchanging tokens...', - usage: 'Usage', - usageSubtitle: 'View your API usage and costs', - profiles: 'Profiles', - profilesSubtitle: 'Manage environment variable profiles for sessions', - - // Dynamic settings messages - accountConnected: ({ service }: { service: string }) => `${service} account connected`, - machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} is ${status}`, - featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => - `${feature} ${enabled ? 'enabled' : 'disabled'}`, - }, - - settingsAppearance: { - // Appearance settings screen - theme: 'Theme', - themeDescription: 'Choose your preferred color scheme', - themeOptions: { - adaptive: 'Adaptive', - light: 'Light', - dark: 'Dark', - }, - themeDescriptions: { - adaptive: 'Match system settings', - light: 'Always use light theme', - dark: 'Always use dark theme', - }, - display: 'Display', - displayDescription: 'Control layout and spacing', - inlineToolCalls: 'Inline Tool Calls', - inlineToolCallsDescription: 'Display tool calls directly in chat messages', - expandTodoLists: 'Expand Todo Lists', - expandTodoListsDescription: 'Show all todos instead of just changes', - showLineNumbersInDiffs: 'Show Line Numbers in Diffs', - showLineNumbersInDiffsDescription: 'Display line numbers in code diffs', - showLineNumbersInToolViews: 'Show Line Numbers in Tool Views', - showLineNumbersInToolViewsDescription: 'Display line numbers in tool view diffs', - wrapLinesInDiffs: 'Wrap Lines in Diffs', - wrapLinesInDiffsDescription: 'Wrap long lines instead of horizontal scrolling in diff views', - alwaysShowContextSize: 'Always Show Context Size', - alwaysShowContextSizeDescription: 'Display context usage even when not near limit', - avatarStyle: 'Avatar Style', - avatarStyleDescription: 'Choose session avatar appearance', - avatarOptions: { - pixelated: 'Pixelated', - gradient: 'Gradient', - brutalist: 'Brutalist', - }, - showFlavorIcons: 'Show AI Provider Icons', - showFlavorIconsDescription: 'Display AI provider icons on session avatars', - compactSessionView: 'Compact Session View', - compactSessionViewDescription: 'Show active sessions in a more compact layout', - }, - - settingsFeatures: { - // Features settings screen - experiments: 'Experiments', - experimentsDescription: 'Enable experimental features that are still in development. These features may be unstable or change without notice.', - experimentalFeatures: 'Experimental Features', - experimentalFeaturesEnabled: 'Experimental features enabled', - experimentalFeaturesDisabled: 'Using stable features only', - webFeatures: 'Web Features', - webFeaturesDescription: 'Features available only in the web version of the app.', - enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send messages', - enterToSendDisabled: 'Press ⌘+Enter to send messages', - commandPalette: 'Command Palette', - commandPaletteEnabled: 'Press ⌘K to open', - commandPaletteDisabled: 'Quick command access disabled', - markdownCopyV2: 'Markdown Copy v2', - markdownCopyV2Subtitle: 'Long press opens copy modal', - hideInactiveSessions: 'Hide inactive sessions', - hideInactiveSessionsSubtitle: 'Show only active chats in your list', - enhancedSessionWizard: 'Enhanced Session Wizard', - enhancedSessionWizardEnabled: 'Profile-first session launcher active', - enhancedSessionWizardDisabled: 'Using standard session launcher', - }, - - errors: { - networkError: 'Network error occurred', - serverError: 'Server error occurred', - unknownError: 'An unknown error occurred', - connectionTimeout: 'Connection timed out', - authenticationFailed: 'Authentication failed', - permissionDenied: 'Permission denied', - fileNotFound: 'File not found', - invalidFormat: 'Invalid format', - operationFailed: 'Operation failed', - tryAgain: 'Please try again', - contactSupport: 'Contact support if the problem persists', - sessionNotFound: 'Session not found', - voiceSessionFailed: 'Failed to start voice session', - voiceServiceUnavailable: 'Voice service is temporarily unavailable', - oauthInitializationFailed: 'Failed to initialize OAuth flow', - tokenStorageFailed: 'Failed to store authentication tokens', - oauthStateMismatch: 'Security validation failed. Please try again', - tokenExchangeFailed: 'Failed to exchange authorization code', - oauthAuthorizationDenied: 'Authorization was denied', - webViewLoadFailed: 'Failed to load authentication page', - failedToLoadProfile: 'Failed to load user profile', - userNotFound: 'User not found', - sessionDeleted: 'Session has been deleted', - sessionDeletedDescription: 'This session has been permanently removed', - - // Error functions with context - fieldError: ({ field, reason }: { field: string; reason: string }) => - `${field}: ${reason}`, - validationError: ({ field, min, max }: { field: string; min: number; max: number }) => - `${field} must be between ${min} and ${max}`, - retryIn: ({ seconds }: { seconds: number }) => - `Retry in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`, - errorWithCode: ({ message, code }: { message: string; code: number | string }) => - `${message} (Error ${code})`, - disconnectServiceFailed: ({ service }: { service: string }) => - `Failed to disconnect ${service}`, - connectServiceFailed: ({ service }: { service: string }) => - `Failed to connect ${service}. Please try again.`, - failedToLoadFriends: 'Failed to load friends list', - failedToAcceptRequest: 'Failed to accept friend request', - failedToRejectRequest: 'Failed to reject friend request', - failedToRemoveFriend: 'Failed to remove friend', - searchFailed: 'Search failed. Please try again.', - failedToSendRequest: 'Failed to send friend request', - }, - - newSession: { - // Used by new-session screen and launch flows - title: 'Start New Session', - noMachinesFound: 'No machines found. Start a Happy session on your computer first.', - allMachinesOffline: 'All machines appear offline', - machineDetails: 'View machine details →', - directoryDoesNotExist: 'Directory Not Found', - createDirectoryConfirm: ({ directory }: { directory: string }) => `The directory ${directory} does not exist. Do you want to create it?`, - sessionStarted: 'Session Started', - sessionStartedMessage: 'The session has been started successfully.', - sessionSpawningFailed: 'Session spawning failed - no session ID returned.', - startingSession: 'Starting session...', - startNewSessionInFolder: 'New session here', - failedToStart: 'Failed to start session. Make sure the daemon is running on the target machine.', - sessionTimeout: 'Session startup timed out. The machine may be slow or the daemon may not be responding.', - notConnectedToServer: 'Not connected to server. Check your internet connection.', - noMachineSelected: 'Please select a machine to start the session', - noPathSelected: 'Please select a directory to start the session in', - sessionType: { - title: 'Session Type', - simple: 'Simple', - worktree: 'Worktree', - comingSoon: 'Coming soon', - }, - worktree: { - creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`, - notGitRepo: 'Worktrees require a git repository', - failed: ({ error }: { error: string }) => `Failed to create worktree: ${error}`, - success: 'Worktree created successfully', - } - }, - - sessionHistory: { - // Used by session history screen - title: 'Session History', - empty: 'No sessions found', - today: 'Today', - yesterday: 'Yesterday', - daysAgo: ({ count }: { count: number }) => `${count} ${count === 1 ? 'day' : 'days'} ago`, - viewAll: 'View all sessions', - }, - - session: { - inputPlaceholder: 'Type a message ...', - }, - - commandPalette: { - placeholder: 'Type a command or search...', - }, - - server: { - // Used by Server Configuration screen (app/(app)/server.tsx) - serverConfiguration: 'Server Configuration', - enterServerUrl: 'Please enter a server URL', - notValidHappyServer: 'Not a valid Happy Server', - changeServer: 'Change Server', - continueWithServer: 'Continue with this server?', - resetToDefault: 'Reset to Default', - resetServerDefault: 'Reset server to default?', - validating: 'Validating...', - validatingServer: 'Validating server...', - serverReturnedError: 'Server returned an error', - failedToConnectToServer: 'Failed to connect to server', - currentlyUsingCustomServer: 'Currently using custom server', - customServerUrlLabel: 'Custom Server URL', - advancedFeatureFooter: "This is an advanced feature. Only change the server if you know what you're doing. You will need to log out and log in again after changing servers." - }, - - sessionInfo: { - // Used by Session Info screen (app/(app)/session/[id]/info.tsx) - killSession: 'Kill Session', - killSessionConfirm: 'Are you sure you want to terminate this session?', - archiveSession: 'Archive Session', - archiveSessionConfirm: 'Are you sure you want to archive this session?', - happySessionIdCopied: 'Happy Session ID copied to clipboard', - failedToCopySessionId: 'Failed to copy Happy Session ID', - happySessionId: 'Happy Session ID', - claudeCodeSessionId: 'Claude Code Session ID', - claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', - aiProvider: 'AI Provider', - failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', - metadataCopied: 'Metadata copied to clipboard', - failedToCopyMetadata: 'Failed to copy metadata', - failedToKillSession: 'Failed to kill session', - failedToArchiveSession: 'Failed to archive session', - connectionStatus: 'Connection Status', - created: 'Created', - lastUpdated: 'Last Updated', - sequence: 'Sequence', - quickActions: 'Quick Actions', - viewMachine: 'View Machine', - viewMachineSubtitle: 'View machine details and sessions', - killSessionSubtitle: 'Immediately terminate the session', - archiveSessionSubtitle: 'Archive this session and stop it', - metadata: 'Metadata', - host: 'Host', - path: 'Path', - operatingSystem: 'Operating System', - processId: 'Process ID', - happyHome: 'Happy Home', - copyMetadata: 'Copy Metadata', - agentState: 'Agent State', - controlledByUser: 'Controlled by User', - pendingRequests: 'Pending Requests', - activity: 'Activity', - thinking: 'Thinking', - thinkingSince: 'Thinking Since', - cliVersion: 'CLI Version', - cliVersionOutdated: 'CLI Update Required', - cliVersionOutdatedMessage: ({ currentVersion, requiredVersion }: { currentVersion: string; requiredVersion: string }) => - `Version ${currentVersion} installed. Update to ${requiredVersion} or later`, - updateCliInstructions: 'Please run npm install -g happy-coder@latest', - deleteSession: 'Delete Session', - deleteSessionSubtitle: 'Permanently remove this session', - deleteSessionConfirm: 'Delete Session Permanently?', - deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', - failedToDeleteSession: 'Failed to delete session', - sessionDeleted: 'Session deleted successfully', - - }, - - components: { - emptyMainScreen: { - // Used by EmptyMainScreen component - readyToCode: 'Ready to code?', - installCli: 'Install the Happy CLI', - runIt: 'Run it', - scanQrCode: 'Scan the QR code', - openCamera: 'Open Camera', - }, - }, - - agentInput: { - permissionMode: { - title: 'PERMISSION MODE', - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - agent: { - claude: 'Claude', - codex: 'Codex', - gemini: 'Gemini', - }, - model: { - title: 'MODEL', - configureInCli: 'Configure models in CLI settings', - }, - codexPermissionMode: { - title: 'CODEX PERMISSION MODE', - default: 'CLI Settings', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', - }, - codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', - }, - geminiPermissionMode: { - title: 'GEMINI PERMISSION MODE', - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - context: { - remaining: ({ percent }: { percent: number }) => `${percent}% left`, - }, - suggestion: { - fileLabel: 'FILE', - folderLabel: 'FOLDER', - }, - noMachinesAvailable: 'No machines', - }, - - machineLauncher: { - showLess: 'Show less', - showAll: ({ count }: { count: number }) => `Show all (${count} paths)`, - enterCustomPath: 'Enter custom path', - offlineUnableToSpawn: 'Unable to spawn new session, offline', - }, - - sidebar: { - sessionsTitle: 'Happy', - }, - - toolView: { - input: 'Input', - output: 'Output', - }, - - tools: { - fullView: { - description: 'Description', - inputParams: 'Input Parameters', - output: 'Output', - error: 'Error', - completed: 'Tool completed successfully', - noOutput: 'No output was produced', - running: 'Tool is running...', - rawJsonDevMode: 'Raw JSON (Dev Mode)', - }, - taskView: { - initializing: 'Initializing agent...', - moreTools: ({ count }: { count: number }) => `+${count} more ${plural({ count, singular: 'tool', plural: 'tools' })}`, - }, - multiEdit: { - editNumber: ({ index, total }: { index: number; total: number }) => `Edit ${index} of ${total}`, - replaceAll: 'Replace All', - }, - names: { - task: 'Task', - terminal: 'Terminal', - searchFiles: 'Search Files', - search: 'Search', - searchContent: 'Search Content', - listFiles: 'List Files', - planProposal: 'Plan proposal', - readFile: 'Read File', - editFile: 'Edit File', - writeFile: 'Write File', - fetchUrl: 'Fetch URL', - readNotebook: 'Read Notebook', - editNotebook: 'Edit Notebook', - todoList: 'Todo List', - webSearch: 'Web Search', - reasoning: 'Reasoning', - applyChanges: 'Update file', - viewDiff: 'Current file changes', - question: 'Question', - }, - askUserQuestion: { - submit: 'Submit Answer', - multipleQuestions: ({ count }: { count: number }) => `${count} questions`, - }, - desc: { - terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, - searchPattern: ({ pattern }: { pattern: string }) => `Search(pattern: ${pattern})`, - searchPath: ({ basename }: { basename: string }) => `Search(path: ${basename})`, - fetchUrlHost: ({ host }: { host: string }) => `Fetch URL(url: ${host})`, - editNotebookMode: ({ path, mode }: { path: string; mode: string }) => `Edit Notebook(file: ${path}, mode: ${mode})`, - todoListCount: ({ count }: { count: number }) => `Todo List(count: ${count})`, - webSearchQuery: ({ query }: { query: string }) => `Web Search(query: ${query})`, - grepPattern: ({ pattern }: { pattern: string }) => `grep(pattern: ${pattern})`, - multiEditEdits: ({ path, count }: { path: string; count: number }) => `${path} (${count} edits)`, - readingFile: ({ file }: { file: string }) => `Reading ${file}`, - writingFile: ({ file }: { file: string }) => `Writing ${file}`, - modifyingFile: ({ file }: { file: string }) => `Modifying ${file}`, - modifyingFiles: ({ count }: { count: number }) => `Modifying ${count} files`, - modifyingMultipleFiles: ({ file, count }: { file: string; count: number }) => `${file} and ${count} more`, - showingDiff: 'Showing changes', - } - }, - - files: { - searchPlaceholder: 'Search files...', - detachedHead: 'detached HEAD', - summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `${staged} staged • ${unstaged} unstaged`, - notRepo: 'Not a git repository', - notUnderGit: 'This directory is not under git version control', - searching: 'Searching files...', - noFilesFound: 'No files found', - noFilesInProject: 'No files in project', - tryDifferentTerm: 'Try a different search term', - searchResults: ({ count }: { count: number }) => `Search Results (${count})`, - projectRoot: 'Project root', - stagedChanges: ({ count }: { count: number }) => `Staged Changes (${count})`, - unstagedChanges: ({ count }: { count: number }) => `Unstaged Changes (${count})`, - // File viewer strings - loadingFile: ({ fileName }: { fileName: string }) => `Loading ${fileName}...`, - binaryFile: 'Binary File', - cannotDisplayBinary: 'Cannot display binary file content', - diff: 'Diff', - file: 'File', - fileEmpty: 'File is empty', - noChanges: 'No changes to display', - }, - - settingsVoice: { - // Voice settings screen - languageTitle: 'Language', - languageDescription: 'Choose your preferred language for voice assistant interactions. This setting syncs across all your devices.', - preferredLanguage: 'Preferred Language', - preferredLanguageSubtitle: 'Language used for voice assistant responses', - language: { - searchPlaceholder: 'Search languages...', - title: 'Languages', - footer: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'language', plural: 'languages' })} available`, - autoDetect: 'Auto-detect', - } - }, - - settingsAccount: { - // Account settings screen - accountInformation: 'Account Information', - status: 'Status', - statusActive: 'Active', - statusNotAuthenticated: 'Not Authenticated', - anonymousId: 'Anonymous ID', - publicId: 'Public ID', - notAvailable: 'Not available', - linkNewDevice: 'Link New Device', - linkNewDeviceSubtitle: 'Scan QR code to link device', - profile: 'Profile', - name: 'Name', - github: 'GitHub', - tapToDisconnect: 'Tap to disconnect', - server: 'Server', - backup: 'Backup', - backupDescription: 'Your secret key is the only way to recover your account. Save it in a secure place like a password manager.', - secretKey: 'Secret Key', - tapToReveal: 'Tap to reveal', - tapToHide: 'Tap to hide', - secretKeyLabel: 'SECRET KEY (TAP TO COPY)', - secretKeyCopied: 'Secret key copied to clipboard. Store it in a safe place!', - secretKeyCopyFailed: 'Failed to copy secret key', - privacy: 'Privacy', - privacyDescription: 'Help improve the app by sharing anonymous usage data. No personal information is collected.', - analytics: 'Analytics', - analyticsDisabled: 'No data is shared', - analyticsEnabled: 'Anonymous usage data is shared', - dangerZone: 'Danger Zone', - logout: 'Logout', - logoutSubtitle: 'Sign out and clear local data', - logoutConfirm: 'Are you sure you want to logout? Make sure you have backed up your secret key!', - }, - - settingsLanguage: { - // Language settings screen - title: 'Language', - description: 'Choose your preferred language for the app interface. This will sync across all your devices.', - currentLanguage: 'Current Language', - automatic: 'Automatic', - automaticSubtitle: 'Detect from device settings', - needsRestart: 'Language Changed', - needsRestartMessage: 'The app needs to restart to apply the new language setting.', - restartNow: 'Restart Now', - }, - - connectButton: { - authenticate: 'Authenticate Terminal', - authenticateWithUrlPaste: 'Authenticate Terminal with URL paste', - pasteAuthUrl: 'Paste the auth URL from your terminal', - }, - - updateBanner: { - updateAvailable: 'Update available', - pressToApply: 'Press to apply the update', - whatsNew: "What's new", - seeLatest: 'See the latest updates and improvements', - nativeUpdateAvailable: 'App Update Available', - tapToUpdateAppStore: 'Tap to update in App Store', - tapToUpdatePlayStore: 'Tap to update in Play Store', - }, - - changelog: { - // Used by the changelog screen - version: ({ version }: { version: number }) => `Version ${version}`, - noEntriesAvailable: 'No changelog entries available.', - }, - - terminal: { - // Used by terminal connection screens - webBrowserRequired: 'Web Browser Required', - webBrowserRequiredDescription: 'Terminal connection links can only be opened in a web browser for security reasons. Please use the QR code scanner or open this link on a computer.', - processingConnection: 'Processing connection...', - invalidConnectionLink: 'Invalid Connection Link', - invalidConnectionLinkDescription: 'The connection link is missing or invalid. Please check the URL and try again.', - connectTerminal: 'Connect Terminal', - terminalRequestDescription: 'A terminal is requesting to connect to your Happy Coder account. This will allow the terminal to send and receive messages securely.', - connectionDetails: 'Connection Details', - publicKey: 'Public Key', - encryption: 'Encryption', - endToEndEncrypted: 'End-to-end encrypted', - acceptConnection: 'Accept Connection', - connecting: 'Connecting...', - reject: 'Reject', - security: 'Security', - securityFooter: 'This connection link was processed securely in your browser and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - securityFooterDevice: 'This connection was processed securely on your device and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - clientSideProcessing: 'Client-Side Processing', - linkProcessedLocally: 'Link processed locally in browser', - linkProcessedOnDevice: 'Link processed locally on device', - }, - - modals: { - // Used across connect flows and settings - authenticateTerminal: 'Authenticate Terminal', - pasteUrlFromTerminal: 'Paste the authentication URL from your terminal', - deviceLinkedSuccessfully: 'Device linked successfully', - terminalConnectedSuccessfully: 'Terminal connected successfully', - invalidAuthUrl: 'Invalid authentication URL', - developerMode: 'Developer Mode', - developerModeEnabled: 'Developer mode enabled', - developerModeDisabled: 'Developer mode disabled', - disconnectGithub: 'Disconnect GitHub', - disconnectGithubConfirm: 'Are you sure you want to disconnect your GitHub account?', - disconnectService: ({ service }: { service: string }) => - `Disconnect ${service}`, - disconnectServiceConfirm: ({ service }: { service: string }) => - `Are you sure you want to disconnect ${service} from your account?`, - disconnect: 'Disconnect', - failedToConnectTerminal: 'Failed to connect terminal', - cameraPermissionsRequiredToConnectTerminal: 'Camera permissions are required to connect terminal', - failedToLinkDevice: 'Failed to link device', - cameraPermissionsRequiredToScanQr: 'Camera permissions are required to scan QR codes' - }, - - navigation: { - // Navigation titles and screen headers - connectTerminal: 'Connect Terminal', - linkNewDevice: 'Link New Device', - restoreWithSecretKey: 'Restore with Secret Key', - whatsNew: "What's New", - friends: 'Friends', - }, - - welcome: { - // Main welcome screen for unauthenticated users - title: 'Codex and Claude Code mobile client', - subtitle: 'End-to-end encrypted and your account is stored only on your device.', - createAccount: 'Create account', - linkOrRestoreAccount: 'Link or restore account', - loginWithMobileApp: 'Login with mobile app', - }, - - review: { - // Used by utils/requestReview.ts - enjoyingApp: 'Enjoying the app?', - feedbackPrompt: "We'd love to hear your feedback!", - yesILoveIt: 'Yes, I love it!', - notReally: 'Not really' - }, - - items: { - // Used by Item component for copy toast - copiedToClipboard: ({ label }: { label: string }) => `${label} copied to clipboard` - }, - - machine: { - launchNewSessionInDirectory: 'Launch New Session in Directory', - offlineUnableToSpawn: 'Launcher disabled while machine is offline', - offlineHelp: '• Make sure your computer is online\n• Run `happy daemon status` to diagnose\n• Are you running the latest CLI version? Upgrade with `npm install -g happy-coder@latest`', - daemon: 'Daemon', - status: 'Status', - stopDaemon: 'Stop Daemon', - lastKnownPid: 'Last Known PID', - lastKnownHttpPort: 'Last Known HTTP Port', - startedAt: 'Started At', - cliVersion: 'CLI Version', - daemonStateVersion: 'Daemon State Version', - activeSessions: ({ count }: { count: number }) => `Active Sessions (${count})`, - machineGroup: 'Machine', - host: 'Host', - machineId: 'Machine ID', - username: 'Username', - homeDirectory: 'Home Directory', - platform: 'Platform', - architecture: 'Architecture', - lastSeen: 'Last Seen', - never: 'Never', - metadataVersion: 'Metadata Version', - untitledSession: 'Untitled Session', - back: 'Back', - }, - - message: { - switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, - unknownEvent: 'Unknown event', - usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, - unknownTime: 'unknown time', - }, - - codex: { - // Codex permission dialog buttons - permissions: { - yesForSession: "Yes, and don't ask for a session", - stopAndExplain: 'Stop, and explain what to do', - } - }, - - claude: { - // Claude permission dialog buttons - permissions: { - yesAllowAllEdits: 'Yes, allow all edits during this session', - yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and tell Claude what to do differently', - } - }, - - textSelection: { - // Text selection screen - selectText: 'Select text range', - title: 'Select Text', - noTextProvided: 'No text provided', - textNotFound: 'Text not found or expired', - textCopied: 'Text copied to clipboard', - failedToCopy: 'Failed to copy text to clipboard', - noTextToCopy: 'No text available to copy', - }, - - markdown: { - // Markdown copy functionality - codeCopied: 'Code copied', - copyFailed: 'Failed to copy', - mermaidRenderFailed: 'Failed to render mermaid diagram', - }, - - artifacts: { - // Artifacts feature - title: 'Artifacts', - countSingular: '1 artifact', - countPlural: ({ count }: { count: number }) => `${count} artifacts`, - empty: 'No artifacts yet', - emptyDescription: 'Create your first artifact to get started', - new: 'New Artifact', - edit: 'Edit Artifact', - delete: 'Delete', - updateError: 'Failed to update artifact. Please try again.', - notFound: 'Artifact not found', - discardChanges: 'Discard changes?', - discardChangesDescription: 'You have unsaved changes. Are you sure you want to discard them?', - deleteConfirm: 'Delete artifact?', - deleteConfirmDescription: 'This action cannot be undone', - titleLabel: 'TITLE', - titlePlaceholder: 'Enter a title for your artifact', - bodyLabel: 'CONTENT', - bodyPlaceholder: 'Write your content here...', - emptyFieldsError: 'Please enter a title or content', - createError: 'Failed to create artifact. Please try again.', - save: 'Save', - saving: 'Saving...', - loading: 'Loading artifacts...', - error: 'Failed to load artifact', - }, - - friends: { - // Friends feature - title: 'Friends', - manageFriends: 'Manage your friends and connections', - searchTitle: 'Find Friends', - pendingRequests: 'Friend Requests', - myFriends: 'My Friends', - noFriendsYet: "You don't have any friends yet", - findFriends: 'Find Friends', - remove: 'Remove', - pendingRequest: 'Pending', - sentOn: ({ date }: { date: string }) => `Sent on ${date}`, - accept: 'Accept', - reject: 'Reject', - addFriend: 'Add Friend', - alreadyFriends: 'Already Friends', - requestPending: 'Request Pending', - searchInstructions: 'Enter a username to search for friends', - searchPlaceholder: 'Enter username...', - searching: 'Searching...', - userNotFound: 'User not found', - noUserFound: 'No user found with that username', - checkUsername: 'Please check the username and try again', - howToFind: 'How to Find Friends', - findInstructions: 'Search for friends by their username. Both you and your friend need to have GitHub connected to send friend requests.', - requestSent: 'Friend request sent!', - requestAccepted: 'Friend request accepted!', - requestRejected: 'Friend request rejected', - friendRemoved: 'Friend removed', - confirmRemove: 'Remove Friend', - confirmRemoveMessage: 'Are you sure you want to remove this friend?', - cannotAddYourself: 'You cannot send a friend request to yourself', - bothMustHaveGithub: 'Both users must have GitHub connected to become friends', - status: { - none: 'Not connected', - requested: 'Request sent', - pending: 'Request pending', - friend: 'Friends', - rejected: 'Rejected', - }, - acceptRequest: 'Accept Request', - removeFriend: 'Remove Friend', - removeFriendConfirm: ({ name }: { name: string }) => `Are you sure you want to remove ${name} as a friend?`, - requestSentDescription: ({ name }: { name: string }) => `Your friend request has been sent to ${name}`, - requestFriendship: 'Request friendship', - cancelRequest: 'Cancel friendship request', - cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, - denyRequest: 'Deny friendship', - nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, - }, - - usage: { - // Usage panel strings - today: 'Today', - last7Days: 'Last 7 days', - last30Days: 'Last 30 days', - totalTokens: 'Total Tokens', - totalCost: 'Total Cost', - tokens: 'Tokens', - cost: 'Cost', - usageOverTime: 'Usage over time', - byModel: 'By Model', - noData: 'No usage data available', - }, - - feed: { - // Feed notifications for friend requests and acceptances - friendRequestFrom: ({ name }: { name: string }) => `${name} sent you a friend request`, - friendRequestGeneric: 'New friend request', - friendAccepted: ({ name }: { name: string }) => `You are now friends with ${name}`, - friendAcceptedGeneric: 'Friend request accepted', - }, - - profiles: { - // Profile management feature - title: 'Profiles', - subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', - noProfileDescription: 'Use default environment settings', - defaultModel: 'Default Model', - addProfile: 'Add Profile', - profileName: 'Profile Name', - enterName: 'Enter profile name', - baseURL: 'Base URL', - authToken: 'Auth Token', - enterToken: 'Enter auth token', - model: 'Model', - tmuxSession: 'Tmux Session', - enterTmuxSession: 'Enter tmux session name', - tmuxTempDir: 'Tmux Temp Directory', - enterTmuxTempDir: 'Enter temp directory path', - tmuxUpdateEnvironment: 'Update environment automatically', - nameRequired: 'Profile name is required', - deleteConfirm: 'Are you sure you want to delete the profile "{name}"?', - editProfile: 'Edit Profile', - addProfileTitle: 'Add New Profile', - delete: { - title: 'Delete Profile', - message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, - confirm: 'Delete', - cancel: 'Cancel', - }, - } -} as const; +export const en: TranslationStructure = defaultEn; -export type TranslationsEn = typeof en; \ No newline at end of file +export type TranslationsEn = typeof en; diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 387d726cc..477bae10f 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -903,7 +903,7 @@ export const es: TranslationStructure = { enterTmuxTempDir: 'Ingrese la ruta del directorio temporal', tmuxUpdateEnvironment: 'Actualizar entorno automáticamente', nameRequired: 'El nombre del perfil es requerido', - deleteConfirm: '¿Estás seguro de que quieres eliminar el perfil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar el perfil "${name}"?`, editProfile: 'Editar Perfil', addProfileTitle: 'Agregar Nuevo Perfil', delete: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 4743fdd6e..55224e47d 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -90,7 +90,7 @@ export const it: TranslationStructure = { enterTmuxTempDir: 'Inserisci percorso directory temporanea', tmuxUpdateEnvironment: 'Aggiorna ambiente automaticamente', nameRequired: 'Il nome del profilo è obbligatorio', - deleteConfirm: 'Sei sicuro di voler eliminare il profilo "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Sei sicuro di voler eliminare il profilo "${name}"?`, editProfile: 'Modifica profilo', addProfileTitle: 'Aggiungi nuovo profilo', delete: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index c8af39724..394090e9f 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -93,7 +93,7 @@ export const ja: TranslationStructure = { enterTmuxTempDir: '一時ディレクトリのパスを入力', tmuxUpdateEnvironment: '環境を自動更新', nameRequired: 'プロファイル名は必須です', - deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?', + deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`, editProfile: 'プロファイルを編集', addProfileTitle: '新しいプロファイルを追加', delete: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 4d24cd147..c4da73780 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -926,7 +926,7 @@ export const pl: TranslationStructure = { enterTmuxTempDir: 'Wprowadź ścieżkę do katalogu tymczasowego', tmuxUpdateEnvironment: 'Aktualizuj środowisko automatycznie', nameRequired: 'Nazwa profilu jest wymagana', - deleteConfirm: 'Czy na pewno chcesz usunąć profil "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć profil "${name}"?`, editProfile: 'Edytuj Profil', addProfileTitle: 'Dodaj Nowy Profil', delete: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 7a8508f9b..d0d5b9b7e 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -894,7 +894,7 @@ export const pt: TranslationStructure = { tmuxTempDir: 'Diretório temporário tmux', enterTmuxTempDir: 'Digite o diretório temporário tmux', tmuxUpdateEnvironment: 'Atualizar ambiente tmux', - deleteConfirm: 'Tem certeza de que deseja excluir este perfil?', + deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`, nameRequired: 'O nome do perfil é obrigatório', delete: { title: 'Excluir Perfil', diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 238ce60be..5ce577666 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -925,7 +925,7 @@ export const ru: TranslationStructure = { enterTmuxTempDir: 'Введите путь к временному каталогу', tmuxUpdateEnvironment: 'Обновлять окружение автоматически', nameRequired: 'Имя профиля обязательно', - deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?', + deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`, editProfile: 'Редактировать Профиль', addProfileTitle: 'Добавить Новый Профиль', delete: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 630414316..4737d8a74 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -896,7 +896,7 @@ export const zhHans: TranslationStructure = { tmuxTempDir: 'tmux 临时目录', enterTmuxTempDir: '输入 tmux 临时目录', tmuxUpdateEnvironment: '更新 tmux 环境', - deleteConfirm: '确定要删除此配置文件吗?', + deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`, nameRequired: '配置文件名称为必填项', delete: { title: '删除配置', From 2e6532246d288ddd72fd6827d89289753c6a120f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 07:49:41 +0100 Subject: [PATCH 05/72] feat(settings): add profiles feature flag --- sources/-session/SessionView.tsx | 11 + sources/app/(app)/new/index.tsx | 566 +++++--------- sources/app/(app)/new/pick/profile-edit.tsx | 2 +- sources/app/(app)/new/pick/profile.tsx | 224 ++++++ sources/app/(app)/settings/features.tsx | 15 + sources/app/(app)/settings/profiles.tsx | 353 ++++----- sources/components/AgentInput.tsx | 239 +++--- .../components/EnvironmentVariableCard.tsx | 136 ++-- .../components/EnvironmentVariablesList.tsx | 246 +++---- sources/components/ProfileEditForm.tsx | 693 +++++------------- sources/components/SettingsView.tsx | 16 +- sources/components/Switch.web.tsx | 63 ++ sources/sync/ops.ts | 7 +- sources/sync/settings.spec.ts | 6 + sources/sync/settings.ts | 5 +- sources/sync/storageTypes.ts | 1 + sources/text/_default.ts | 3 + sources/text/translations/ca.ts | 3 + sources/text/translations/es.ts | 3 + sources/text/translations/it.ts | 3 + sources/text/translations/ja.ts | 3 + sources/text/translations/pl.ts | 3 + sources/text/translations/pt.ts | 3 + sources/text/translations/ru.ts | 3 + sources/text/translations/zh-Hans.ts | 3 + 25 files changed, 1112 insertions(+), 1498 deletions(-) create mode 100644 sources/app/(app)/new/pick/profile.tsx create mode 100644 sources/components/Switch.web.tsx diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index e93fdd4eb..d7f032ccd 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -273,6 +273,17 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} metadata={session.metadata} + profileId={session.metadata?.profileId ?? undefined} + onProfileClick={session.metadata?.profileId !== undefined ? () => { + const profileId = session.metadata?.profileId; + const profileInfo = (profileId === null || (typeof profileId === 'string' && profileId.trim() === '')) + ? t('profiles.noProfile') + : (typeof profileId === 'string' ? profileId : t('status.unknown')); + Modal.alert( + t('profiles.title'), + `This session uses: ${profileInfo}\n\nProfiles are fixed per session. To use a different profile, start a new session.`, + ); + } : undefined} connectionStatus={{ text: sessionStatus.statusText, color: sessionStatus.statusColor, diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index fd115776d..3f1678f2a 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native'; -import Constants from 'expo-constants'; import { Typography } from '@/constants/Typography'; import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; @@ -33,7 +32,8 @@ import { resolveAbsolutePath } from '@/utils/pathUtils'; import { MultiTextInput } from '@/components/MultiTextInput'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; -import { SearchableListSelector, SelectorConfig } from '@/components/SearchableListSelector'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { DirectorySelector } from '@/components/newSession/DirectorySelector'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; // Simple temporary state for passing selections back from picker screens @@ -258,11 +258,13 @@ function NewSessionWizard() { const { theme, rt } = useUnistyles(); const router = useRouter(); const safeArea = useSafeAreaInsets(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{ + const headerHeight = useHeaderHeight(); + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; machineId?: string; path?: string; + profileId?: string; }>(); // Try to get data from temporary store first @@ -284,6 +286,7 @@ function NewSessionWizard() { // Control A (false): Simpler AgentInput-driven layout // Variant B (true): Enhanced profile-first wizard with sections const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); + const useProfiles = useSetting('useProfiles'); const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); @@ -305,11 +308,21 @@ function NewSessionWizard() { // Wizard state const [selectedProfileId, setSelectedProfileId] = React.useState(() => { + if (!useProfiles) { + return null; + } if (lastUsedProfile && profileMap.has(lastUsedProfile)) { return lastUsedProfile; } - return 'anthropic'; // Default to Anthropic + // Default to "no profile" so default session creation remains unchanged. + return null; }); + + React.useEffect(() => { + if (!useProfiles && selectedProfileId !== null) { + setSelectedProfileId(null); + } + }, [useProfiles, selectedProfileId]); const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { // Check if agent type was provided in temp data if (tempSessionData?.agentType) { @@ -661,12 +674,8 @@ function NewSessionWizard() { // Validation const canCreate = React.useMemo(() => { - return ( - selectedProfileId !== null && - selectedMachineId !== null && - selectedPath.trim() !== '' - ); - }, [selectedProfileId, selectedMachineId, selectedPath]); + return selectedMachineId !== null && selectedPath.trim() !== ''; + }, [selectedMachineId, selectedPath]); const selectProfile = React.useCallback((profileId: string) => { setSelectedProfileId(profileId); @@ -703,6 +712,25 @@ function NewSessionWizard() { } }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); + // Handle profile route param from picker screens + React.useEffect(() => { + if (!useProfiles) { + return; + } + if (typeof profileIdParam !== 'string') { + return; + } + if (profileIdParam === '') { + if (selectedProfileId !== null) { + setSelectedProfileId(null); + } + return; + } + if (profileIdParam !== selectedProfileId) { + selectProfile(profileIdParam); + } + }, [profileIdParam, selectedProfileId, selectProfile, useProfiles]); + // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent React.useEffect(() => { const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; @@ -738,8 +766,17 @@ function NewSessionWizard() { }, []); const handleAgentInputProfileClick = React.useCallback(() => { - scrollToSection(profileSectionRef); - }, [scrollToSection]); + if (!useProfiles) { + return; + } + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId, useProfiles]); const handleAgentInputMachineClick = React.useCallback(() => { scrollToSection(machineSectionRef); @@ -749,53 +786,10 @@ function NewSessionWizard() { scrollToSection(pathSectionRef); }, [scrollToSection]); - const handleAgentInputPermissionChange = React.useCallback((mode: PermissionMode) => { - setPermissionMode(mode); - scrollToSection(permissionSectionRef); - }, [scrollToSection]); - const handleAgentInputAgentClick = React.useCallback(() => { scrollToSection(profileSectionRef); // Agent tied to profile section }, [scrollToSection]); - const handleAddProfile = React.useCallback(() => { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - const profileData = JSON.stringify(newProfile); - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); - }, [router]); - - const handleEditProfile = React.useCallback((profile: AIBackendProfile) => { - const profileData = JSON.stringify(profile); - if (selectedMachineId) { - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}&machineId=${encodeURIComponent(selectedMachineId)}` as any); - return; - } - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); - }, [router, selectedMachineId]); - - const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - const profileData = JSON.stringify(duplicatedProfile); - router.push(`/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}` as any); - }, [router]); - // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { const parts: string[] = []; @@ -878,27 +872,6 @@ function NewSessionWizard() { return parts.join(', '); }, [agentType, isProfileAvailable, daemonEnv]); - const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { - Modal.alert( - t('profiles.delete.title'), - t('profiles.delete.message', { name: profile.name }), - [ - { text: t('profiles.delete.cancel'), style: 'cancel' }, - { - text: t('profiles.delete.confirm'), - style: 'destructive', - onPress: () => { - const updatedProfiles = profiles.filter(p => p.id !== profile.id); - setProfiles(updatedProfiles); // Use mutable setter for persistence - if (selectedProfileId === profile.id) { - setSelectedProfileId('anthropic'); // Default to Anthropic - } - } - } - ] - ); - }, [profiles, selectedProfileId, setProfiles]); - // Handle machine and path selection callbacks React.useEffect(() => { let handler = (machineId: string) => { @@ -954,8 +927,21 @@ function NewSessionWizard() { }, [profiles, setProfiles]); const handleMachineClick = React.useCallback(() => { - router.push('/new/pick/machine'); - }, [router]); + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); + + const handleProfileClick = React.useCallback(() => { + router.push({ + pathname: '/new/pick/profile', + params: { + ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + }); + }, [router, selectedMachineId, selectedProfileId]); const handlePathClick = React.useCallback(() => { if (selectedMachineId) { @@ -1004,17 +990,26 @@ function NewSessionWizard() { // Save settings const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10); - sync.applySettings({ + const profilesActive = useProfiles; + + // Keep prod session creation behavior unchanged: + // only persist/apply profiles & model when an explicit opt-in flag is enabled. + const settingsUpdate: Parameters[0] = { recentMachinePaths: updatedPaths, lastUsedAgent: agentType, - lastUsedProfile: selectedProfileId, lastUsedPermissionMode: permissionMode, - lastUsedModelMode: modelMode, - }); + }; + if (profilesActive) { + settingsUpdate.lastUsedProfile = selectedProfileId; + } + if (useEnhancedSessionWizard) { + settingsUpdate.lastUsedModelMode = modelMode; + } + sync.applySettings(settingsUpdate); // Get environment variables from selected profile let environmentVariables = undefined; - if (selectedProfileId) { + if (profilesActive && selectedProfileId) { const selectedProfile = profileMap.get(selectedProfileId); if (selectedProfile) { environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); @@ -1026,6 +1021,7 @@ function NewSessionWizard() { directory: actualPath, approvedNewDirectoryCreation: true, agent: agentType, + profileId: profilesActive ? (selectedProfileId ?? '') : undefined, environmentVariables }); @@ -1064,7 +1060,7 @@ function NewSessionWizard() { Modal.alert(t('common.error'), errorMessage); setIsCreating(false); } - }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]); + }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); const screenWidth = useWindowDimensions().width; @@ -1122,7 +1118,7 @@ function NewSessionWizard() { return ( @@ -1147,20 +1143,19 @@ function NewSessionWizard() { onSend={handleCreateSession} isSendDisabled={!canCreate} isSending={isCreating} - placeholder="What would you like to work on?" + placeholder={t('session.inputPlaceholder')} autocompletePrefixes={[]} autocompleteSuggestions={async () => []} agentType={agentType} onAgentClick={handleAgentClick} permissionMode={permissionMode} onPermissionModeChange={handlePermissionModeChange} - modelMode={modelMode} - onModelModeChange={setModelMode} connectionStatus={connectionStatus} machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} onMachineClick={handleMachineClick} currentPath={selectedPath} onPathClick={handlePathClick} + {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleProfileClick } : {})} /> @@ -1176,7 +1171,7 @@ function NewSessionWizard() { return ( @@ -1253,11 +1248,15 @@ function NewSessionWizard() { {/* Section 1: Profile Management */} 1. - - Choose AI Profile + + + {useProfiles ? 'Choose AI Profile' : 'Select AI'} + - Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs. + {useProfiles + ? 'Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' + : 'Choose which AI runs your session.'} {/* Missing CLI Installation Banners */} @@ -1477,157 +1476,52 @@ function NewSessionWizard() { )} - {/* Custom profiles - show first */} - {profiles.map((profile) => { - const availability = isProfileAvailable(profile); - - return ( - availability.available && selectProfile(profile.id)} - disabled={!availability.available} - > - - - {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' : - profile.compatibility.claude ? '✳' : '꩜'} - - - - {profile.name} - - {getProfileSubtitle(profile)} - - - - {selectedProfileId === profile.id && ( - - )} - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - - - ); - })} - - {/* Built-in profiles - show after custom */} - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; - - const availability = isProfileAvailable(profile); - - return ( - availability.available && selectProfile(profile.id)} - disabled={!availability.available} - > - - - {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' : - profile.compatibility.claude ? '✳' : '꩜'} - - - - {profile.name} - - {getProfileSubtitle(profile)} - - - - {selectedProfileId === profile.id && ( - - )} - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - - - ); - })} - - {/* Profile Action Buttons */} - - - - - Add - - - selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - > - - - Duplicate - - - selectedProfile && !selectedProfile.isBuiltIn && handleDeleteProfile(selectedProfile)} - disabled={!selectedProfile || selectedProfile.isBuiltIn} - > - - - Delete - - - + {useProfiles ? ( + + + } + onPress={handleProfileClick} + /> + + ) : ( + + } + selected={agentType === 'claude'} + onPress={() => setAgentType('claude')} + showChevron={false} + /> + } + selected={agentType === 'codex'} + onPress={() => setAgentType('codex')} + showChevron={false} + /> + {experimentsEnabled && ( + } + selected={agentType === 'gemini'} + onPress={() => setAgentType('gemini')} + showChevron={false} + showDivider={false} + /> + )} + + )} {/* Section 2: Machine Selection */} @@ -1639,61 +1533,13 @@ function NewSessionWizard() { - - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - - ), - getRecentItemIcon: (machine) => ( - - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: true, - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={machines.filter(m => favoriteMachines.includes(m.id))} - selectedItem={selectedMachine || null} - onSelect={(machine) => { + favoriteMachines.includes(m.id))} + showFavorites={true} + onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); setSelectedPath(bestPath); @@ -1719,92 +1565,33 @@ function NewSessionWizard() { - - config={{ - getItemId: (path) => path, - getItemTitle: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), - getItemSubtitle: undefined, - getItemIcon: (path) => ( - - ), - getRecentItemIcon: (path) => ( - - ), - getFavoriteItemIcon: (path) => ( - - ), - canRemoveFavorite: (path) => path !== selectedMachine?.metadata?.homeDir, - formatForDisplay: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir), - parseFromDisplay: (text) => { - if (selectedMachine?.metadata?.homeDir) { - return resolveAbsolutePath(text, selectedMachine.metadata.homeDir); + { + if (!selectedMachine?.metadata?.homeDir) return []; + const homeDir = selectedMachine.metadata.homeDir; + return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; + })()} + onSelect={(path) => setSelectedPath(path)} + onToggleFavorite={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return; + if (path === homeDir) return; + + const relativePath = formatPathRelativeToHome(path, homeDir); + const isInFavorites = favoriteDirectories.some(fav => + resolveAbsolutePath(fav, homeDir) === path + ); + if (isInFavorites) { + setFavoriteDirectories(favoriteDirectories.filter(fav => + resolveAbsolutePath(fav, homeDir) !== path + )); + } else { + setFavoriteDirectories([...favoriteDirectories, relativePath]); } - return null; - }, - filterItem: (path, searchText) => { - const displayPath = formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir); - return displayPath.toLowerCase().includes(searchText.toLowerCase()); - }, - searchPlaceholder: "Type to filter or enter custom directory...", - recentSectionTitle: "Recent Directories", - favoritesSectionTitle: "Favorite Directories", - noItemsMessage: "No recent directories", - showFavorites: true, - showRecent: true, - showSearch: true, - allowCustomInput: true, - compactItems: true, - }} - items={recentPaths} - recentItems={recentPaths} - favoriteItems={(() => { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - // Include home directory plus user favorites - return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })()} - selectedItem={selectedPath} - onSelect={(path) => { - setSelectedPath(path); - }} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - // Don't allow removing home directory (handled by canRemoveFavorite) - if (path === homeDir) return; - - // Convert to relative format for storage - const relativePath = formatPathRelativeToHome(path, homeDir); - - // Check if already in favorites - const isInFavorites = favoriteDirectories.some(fav => - resolveAbsolutePath(fav, homeDir) === path - ); - - if (isInFavorites) { - // Remove from favorites - setFavoriteDirectories(favoriteDirectories.filter(fav => - resolveAbsolutePath(fav, homeDir) !== path - )); - } else { - // Add to favorites - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} - context={{ homeDir: selectedMachine?.metadata?.homeDir }} + }} /> @@ -1835,14 +1622,14 @@ function NewSessionWizard() { } rightElement={permissionMode === option.value ? ( ) : null} onPress={() => setPermissionMode(option.value)} @@ -1851,7 +1638,7 @@ function NewSessionWizard() { showDivider={index < array.length - 1} style={permissionMode === option.value ? { borderWidth: 2, - borderColor: theme.colors.button.primary.tint, + borderColor: theme.colors.button.primary.background, borderRadius: Platform.select({ ios: 10, default: 16 }), } : undefined} /> @@ -1897,13 +1684,11 @@ function NewSessionWizard() { onSend={handleCreateSession} isSendDisabled={!canCreate} isSending={isCreating} - placeholder="What would you like to work on?" + placeholder={t('session.inputPlaceholder')} autocompletePrefixes={[]} autocompleteSuggestions={async () => []} agentType={agentType} onAgentClick={handleAgentInputAgentClick} - permissionMode={permissionMode} - onPermissionModeChange={handleAgentInputPermissionChange} modelMode={modelMode} onModelModeChange={setModelMode} connectionStatus={connectionStatus} @@ -1911,8 +1696,7 @@ function NewSessionWizard() { onMachineClick={handleAgentInputMachineClick} currentPath={selectedPath} onPathClick={handleAgentInputPathClick} - profileId={selectedProfileId} - onProfileClick={handleAgentInputProfileClick} + {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleAgentInputProfileClick } : {})} /> diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index b6cbb91ac..7710ab643 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -90,7 +90,7 @@ export default function ProfileEditScreen() { const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, - backgroundColor: theme.colors.surface, + backgroundColor: theme.colors.groupped.background, paddingTop: rt.insets.top, paddingBottom: rt.insets.bottom, }, diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx new file mode 100644 index 000000000..8c67d6651 --- /dev/null +++ b/sources/app/(app)/new/pick/profile.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Ionicons } from '@expo/vector-icons'; +import { Item } from '@/components/Item'; +import { ItemGroup } from '@/components/ItemGroup'; +import { ItemList } from '@/components/ItemList'; +import { useSetting, useSettingMutable } from '@/sync/storage'; +import { t } from '@/text'; +import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { useUnistyles } from 'react-native-unistyles'; +import { randomUUID } from 'expo-crypto'; +import { AIBackendProfile } from '@/sync/settings'; +import { Modal } from '@/modal'; + +export default function ProfilePickerScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const navigation = useNavigation(); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string }>(); + const useProfiles = useSetting('useProfiles'); + const [profiles, setProfiles] = useSettingMutable('profiles'); + + const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; + const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + + const setProfileParamAndClose = React.useCallback((profileId: string) => { + const state = navigation.getState(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + navigation.dispatch({ + ...CommonActions.setParams({ profileId }), + source: previousRoute.key, + } as never); + } + router.back(); + }, [navigation, router]); + + const allProfiles = React.useMemo(() => { + const builtIns = DEFAULT_PROFILES + .map(bp => getBuiltInProfile(bp.id)) + .filter(Boolean) as AIBackendProfile[]; + return [...builtIns, ...profiles]; + }, [profiles]); + + const selectedProfile = React.useMemo(() => { + if (!selectedId) return null; + return allProfiles.find(p => p.id === selectedId) || null; + }, [allProfiles, selectedId]); + + const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { + const profileData = JSON.stringify(profile); + const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; + router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + const handleAddProfile = React.useCallback(() => { + const newProfile: AIBackendProfile = { + id: randomUUID(), + name: '', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + openProfileEdit(newProfile); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + const duplicated: AIBackendProfile = { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + openProfileEdit(duplicated); + }, [openProfileEdit]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + // Only custom profiles live in `profiles` setting. + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedId === profile.id) { + setProfileParamAndClose(''); + } + }, + }, + ], + ); + }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); + + return ( + <> + + + + {!useProfiles ? ( + + } + showChevron={false} + /> + } + onPress={() => router.push('/settings/features')} + /> + + ) : ( + <> + + } + onPress={handleAddProfile} + /> + } + onPress={() => selectedProfile && openProfileEdit(selectedProfile)} + disabled={!selectedProfile} + /> + } + onPress={() => selectedProfile && handleDuplicateProfile(selectedProfile)} + disabled={!selectedProfile} + /> + } + onPress={() => selectedProfile && handleDeleteProfile(selectedProfile)} + destructive={true} + disabled={!selectedProfile || selectedProfile.isBuiltIn} + /> + + + + } + onPress={() => setProfileParamAndClose('')} + showChevron={false} + selected={selectedId === ''} + rightElement={selectedId === '' + ? + : null} + /> + + + + {DEFAULT_PROFILES.map((profileDisplay) => { + const profile = getBuiltInProfile(profileDisplay.id); + if (!profile) return null; + + const isSelected = selectedId === profile.id; + return ( + } + onPress={() => setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={isSelected + ? + : null} + /> + ); + })} + + {profiles.map((profile) => { + const isSelected = selectedId === profile.id; + return ( + } + onPress={() => setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={isSelected + ? + : null} + /> + ); + })} + + + )} + + + ); +} + diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index ac7261455..9d7e96411 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -9,6 +9,7 @@ import { t } from '@/text'; export default function FeaturesSettingsScreen() { const [experiments, setExperiments] = useSettingMutable('experiments'); + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend'); const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled'); const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); @@ -72,6 +73,20 @@ export default function FeaturesSettingsScreen() { } showChevron={false} /> + } + rightElement={ + + } + showChevron={false} + /> {/* Web-only Features */} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index d56a11839..75fbd1b1a 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -1,25 +1,19 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView } from 'react-native'; +import { View, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSettingMutable } from '@/sync/storage'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { Modal } from '@/modal'; -import { layout } from '@/components/layout'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useWindowDimensions } from 'react-native'; import { AIBackendProfile } from '@/sync/settings'; import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { ProfileEditForm } from '@/components/ProfileEditForm'; import { randomUUID } from 'expo-crypto'; - -interface ProfileDisplay { - id: string; - name: string; - isBuiltIn: boolean; -} +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { Switch } from '@/components/Switch'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -29,12 +23,35 @@ interface ProfileManagerProps { // Profile utilities now imported from @/sync/profileUtils const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) { const { theme } = useUnistyles(); + const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); - const safeArea = useSafeAreaInsets(); - const screenWidth = useWindowDimensions().width; + + if (!useProfiles) { + return ( + + + } + rightElement={ + + } + showChevron={false} + /> + + + ); + } const handleAddProfile = () => { setEditingProfile({ @@ -159,232 +176,111 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel }; return ( - - 700 ? 16 : 8, - paddingBottom: safeArea.bottom + 100, - }} - > - - - {t('profiles.title')} - - - {/* None option - no profile */} - + + + } onPress={() => handleSelectProfile(null)} - > - - - - - - {t('profiles.noProfile')} - - - {t('profiles.noProfileDescription')} - - - {selectedProfileId === null && ( - - )} - + showChevron={false} + selected={selectedProfileId === null} + rightElement={selectedProfileId === null + ? + : null} + /> + - {/* Built-in profiles */} + {DEFAULT_PROFILES.map((profileDisplay) => { const profile = getBuiltInProfile(profileDisplay.id); if (!profile) return null; + const isSelected = selectedProfileId === profile.id; return ( - } onPress={() => handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || 'Default model'} - {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - - + showChevron={false} + selected={isSelected} + rightElement={ + + {isSelected && ( + + )} + handleEditProfile(profile)} + > + + + + } + /> ); })} - {/* Custom profiles */} - {profiles.map((profile) => ( - handleSelectProfile(profile.id)} - > - - - - - - {profile.name} - - - {profile.anthropicConfig?.model || t('profiles.defaultModel')} - {profile.tmuxConfig?.sessionName && ` • tmux: ${profile.tmuxConfig.sessionName}`} - {profile.tmuxConfig?.tmpDir && ` • dir: ${profile.tmuxConfig.tmpDir}`} - - - - {selectedProfileId === profile.id && ( - - )} - handleEditProfile(profile)} - > - - - void handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} - > - - - - - ))} + {profiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const subtitleParts: string[] = [t('profiles.defaultModel')]; + if (profile.tmuxConfig?.sessionName) subtitleParts.push(`tmux: ${profile.tmuxConfig.sessionName}`); + if (profile.tmuxConfig?.tmpDir) subtitleParts.push(`dir: ${profile.tmuxConfig.tmpDir}`); - {/* Add profile button */} - } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={ + + {isSelected && ( + + )} + handleEditProfile(profile)} + > + + + void handleDeleteProfile(profile)} + style={{ marginLeft: 16 }} + > + + + + } + /> + ); + })} + + } onPress={handleAddProfile} - > - - - {t('profiles.addProfile')} - - - - + showChevron={false} + /> + + {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( @@ -422,9 +318,12 @@ const profileManagerStyles = StyleSheet.create((theme) => ({ }, modalContent: { width: '100%', - maxWidth: Math.min(layout.maxWidth, 600), + maxWidth: 600, maxHeight: '90%', + borderRadius: 16, + overflow: 'hidden', + backgroundColor: theme.colors.groupped.background, }, })); -export default ProfileManager; \ No newline at end of file +export default ProfileManager; diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index e406b8725..893f26d4c 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -306,7 +306,9 @@ export const AgentInput = React.memo(React.forwardRef { - if (!props.profileId) return null; + if (props.profileId === undefined || props.profileId === null || props.profileId.trim() === '') { + return null; + } // Check custom profiles first const customProfile = profiles.find(p => p.id === props.profileId); if (customProfile) return customProfile; @@ -314,6 +316,20 @@ export const AgentInput = React.memo(React.forwardRef { + if (props.profileId === undefined) { + return null; + } + if (props.profileId === null || props.profileId.trim() === '') { + return t('profiles.noProfile'); + } + if (currentProfile) { + return currentProfile.name; + } + const shortId = props.profileId.length > 8 ? `${props.profileId.slice(0, 8)}…` : props.profileId; + return `${t('status.unknown')} (${shortId})`; + }, [props.profileId, currentProfile]); + // Calculate context warning const contextWarning = props.usageData?.contextSize ? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme) @@ -644,14 +660,14 @@ export const AgentInput = React.memo(React.forwardRef - + {props.connectionStatus && ( <> - {props.connectionStatus.text} - - {/* CLI Status - only shown when provided (wizard only) */} - {props.connectionStatus.cliStatus && ( - <> - - - {props.connectionStatus.cliStatus.claude ? '✓' : '✗'} - - - claude - - - - - {props.connectionStatus.cliStatus.codex ? '✓' : '✗'} - - - codex - - - {props.connectionStatus.cliStatus.gemini !== undefined && ( - - - {props.connectionStatus.cliStatus.gemini ? '✓' : '✗'} - - - gemini - - - )} - - )} )} {contextWarning && ( @@ -780,56 +728,60 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Box 1: Context Information (Machine + Path) - Only show if either exists */} - {(props.machineName !== undefined || props.currentPath) && ( - - {/* Machine chip */} - {props.machineName !== undefined && props.onMachineClick && ( + {/* Box 2: Action Area (Input + Send) */} + + {/* Input field */} + + + + + {/* Action buttons below input */} + + + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} + + + + {/* Settings button */} + {props.onPermissionModeChange && ( { - hapticsLight(); - props.onMachineClick?.(); - }} + onPress={handleSettingsPress} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ flexDirection: 'row', alignItems: 'center', borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, + paddingHorizontal: 8, paddingVertical: 6, + justifyContent: 'center', height: 32, opacity: p.pressed ? 0.7 : 1, - gap: 6, })} > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - )} - {/* Path chip */} - {props.currentPath && props.onPathClick && ( + {/* Profile selector button - FIRST */} + {props.onProfileClick && ( { hapticsLight(); - props.onPathClick?.(); + props.onProfileClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -838,83 +790,70 @@ export const AgentInput = React.memo(React.forwardRef - {props.currentPath} + {profileLabel ?? t('profiles.noProfile')} )} - - )} - - {/* Box 2: Action Area (Input + Send) */} - - {/* Input field */} - - - - - {/* Action buttons below input */} - - - {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - - - {/* Settings button */} - {props.onPermissionModeChange && ( + {/* Agent selector button */} + {props.agentType && props.onAgentClick && ( { + hapticsLight(); + props.onAgentClick?.(); + }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ flexDirection: 'row', alignItems: 'center', borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 8, + paddingHorizontal: 10, paddingVertical: 6, justifyContent: 'center', height: 32, opacity: p.pressed ? 0.7 : 1, + gap: 6, })} > + + {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} + )} - {/* Profile selector button - FIRST */} - {props.profileId && props.onProfileClick && ( + {/* Machine selector button */} + {(props.machineName !== undefined) && props.onMachineClick && ( { hapticsLight(); - props.onProfileClick?.(); + props.onMachineClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -930,7 +869,7 @@ export const AgentInput = React.memo(React.forwardRef @@ -940,17 +879,17 @@ export const AgentInput = React.memo(React.forwardRef - {currentProfile?.name || 'Select Profile'} + {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} )} - {/* Agent selector button */} - {props.agentType && props.onAgentClick && ( + {/* Path selector button */} + {props.currentPath && props.onPathClick && ( { hapticsLight(); - props.onAgentClick?.(); + props.onPathClick?.(); }} hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} style={(p) => ({ @@ -965,8 +904,8 @@ export const AgentInput = React.memo(React.forwardRef - @@ -976,7 +915,7 @@ export const AgentInput = React.memo(React.forwardRef - {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')} + {props.currentPath} )} diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 2185e0b21..fc2bccdc3 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { View, Text, TextInput, Pressable } from 'react-native'; +import { View, Text, TextInput, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; +import { Switch } from '@/components/Switch'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; @@ -74,7 +75,7 @@ export function EnvironmentVariableCard({ const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); - // Query remote machine for variable value (only if checkbox enabled and not secret) + // Query remote machine for variable value (only if toggle enabled and not secret) const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; const { variables: remoteValues } = useEnvironmentVariables( machineId, @@ -100,16 +101,21 @@ export function EnvironmentVariableCard({ return ( {/* Header row with variable name and action buttons */} @@ -147,60 +153,44 @@ export function EnvironmentVariableCard({ )} - {/* Checkbox: First try copying variable from remote machine */} - setUseRemoteVariable(!useRemoteVariable)} - > - - {useRemoteVariable && ( - - )} - + {/* Toggle: Copy from remote machine */} + - First try copying variable from remote machine: + Copy from remote machine - + + - {/* Remote variable name input */} - + {/* Remote variable name input (only when enabled) */} + {useRemoteVariable && ( + + )} {/* Remote variable status */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( @@ -212,7 +202,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...Typography.default() }}> - ⏳ Checking remote machine... + Checking remote machine... ) : remoteValue === null ? ( - ✗ Value not found + Value not found ) : ( <> @@ -229,7 +219,7 @@ export function EnvironmentVariableCard({ color: theme.colors.success, ...Typography.default() }}> - ✓ Value found: {remoteValue} + Value found {showRemoteDiffersWarning && ( - ⚠️ Differs from documented value: {expectedValue} + Differs from documented value: {expectedValue} )} @@ -254,7 +244,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...Typography.default() }}> - ℹ️ Select a machine to check if variable exists + Select a machine to check if variable exists )} @@ -267,33 +257,35 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...Typography.default() }}> - 🔒 Secret value - not retrieved for security + Secret value - not retrieved for security )} - {/* Default value label */} + {/* Value label */} - Default value: + {useRemoteVariable ? 'Default value:' : 'Value:'} {/* Default value input */} - ⚠️ Overriding documented default: {expectedValue} + Overriding documented default: {expectedValue} )} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index e42e61415..fd66b9067 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -5,6 +5,9 @@ import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { EnvironmentVariableCard } from './EnvironmentVariableCard'; import type { ProfileDocumentation } from '@/sync/profileUtils'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { layout } from '@/components/layout'; export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string }>; @@ -101,158 +104,115 @@ export function EnvironmentVariablesList({ return ( - {/* Section header */} - - Environment Variables - + + + } + showChevron={false} + onPress={() => { + if (showAddForm) { + setShowAddForm(false); + setNewVarName(''); + setNewVarValue(''); + } else { + setShowAddForm(true); + } + }} + /> + + {showAddForm && ( + + + + + + + + - {/* Add Variable Button */} - setShowAddForm(true)} - > - - - Add Variable - - - - {/* Add variable inline form */} - {showAddForm && ( - - - - - { - setShowAddForm(false); - setNewVarName(''); - setNewVarValue(''); - }} - > - - Cancel - - ({ backgroundColor: theme.colors.button.primary.background, - borderRadius: 6, - padding: theme.margins.sm, + borderRadius: 10, + paddingVertical: 10, alignItems: 'center', - }} - onPress={handleAddVariable} + opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1, + })} > - + Add - - )} - - {/* Variable cards */} - {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); - - // Auto-detect secrets if not explicitly documented - const isSecret = docs.isSecret || /TOKEN|KEY|SECRET|AUTH/i.test(envVar.name) || /TOKEN|KEY|SECRET|AUTH/i.test(varNameFromValue || ''); - - return ( - handleUpdateVariable(index, newValue)} - onDelete={() => handleDeleteVariable(index)} - onDuplicate={() => handleDuplicateVariable(index)} - /> - ); - })} + )} + + + + {environmentVariables.map((envVar, index) => { + const varNameFromValue = extractVarNameFromValue(envVar.value); + const docs = getDocumentation(varNameFromValue || envVar.name); + + const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + const isSecret = + docs.isSecret || + SECRET_NAME_REGEX.test(envVar.name) || + SECRET_NAME_REGEX.test(varNameFromValue || ''); + + return ( + handleUpdateVariable(index, newValue)} + onDelete={() => handleDeleteVariable(index)} + onDuplicate={() => handleDuplicateVariable(index)} + /> + ); + })} + ); } diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 8a3864d44..3cc69d908 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,17 +1,18 @@ import React from 'react'; -import { View, Text, Pressable, ScrollView, TextInput, ViewStyle, Linking, Platform } from 'react-native'; +import { View, Text, TextInput, ViewStyle, Linking, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { AIBackendProfile } from '@/sync/settings'; -import { PermissionMode, ModelMode } from '@/components/PermissionModeSelector'; +import { PermissionMode } from '@/components/PermissionModeSelector'; import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; +import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; -import { useEnvironmentVariables, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; export interface ProfileEditFormProps { @@ -27,554 +28,240 @@ export function ProfileEditForm({ machineId, onSave, onCancel, - containerStyle + containerStyle, }: ProfileEditFormProps) { const { theme } = useUnistyles(); + const styles = stylesheet; - // Get documentation for built-in profiles const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; return getBuiltInProfileDocumentation(profile.id); - }, [profile.isBuiltIn, profile.id]); + }, [profile.id, profile.isBuiltIn]); - // Local state for environment variables (unified for all config) const [environmentVariables, setEnvironmentVariables] = React.useState>( - profile.environmentVariables || [] + profile.environmentVariables || [], ); - // Extract ${VAR} references from environmentVariables for querying daemon - const envVarNames = React.useMemo(() => { - return extractEnvVarReferences(environmentVariables); - }, [environmentVariables]); - - // Query daemon environment using hook - const { variables: actualEnvVars } = useEnvironmentVariables(machineId, envVarNames); - const [name, setName] = React.useState(profile.name || ''); const [useTmux, setUseTmux] = React.useState(profile.tmuxConfig?.sessionName !== undefined); const [tmuxSession, setTmuxSession] = React.useState(profile.tmuxConfig?.sessionName || ''); const [tmuxTmpDir, setTmuxTmpDir] = React.useState(profile.tmuxConfig?.tmpDir || ''); - const [useStartupScript, setUseStartupScript] = React.useState(!!profile.startupBashScript); - const [startupScript, setStartupScript] = React.useState(profile.startupBashScript || ''); - const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>(profile.defaultSessionType || 'simple'); - const [defaultPermissionMode, setDefaultPermissionMode] = React.useState((profile.defaultPermissionMode as PermissionMode) || 'default'); - const [agentType, setAgentType] = React.useState<'claude' | 'codex'>(() => { - if (profile.compatibility.claude && !profile.compatibility.codex) return 'claude'; - if (profile.compatibility.codex && !profile.compatibility.claude) return 'codex'; - return 'claude'; // Default to Claude if both or neither - }); + const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>( + profile.defaultSessionType || 'simple', + ); + const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( + (profile.defaultPermissionMode as PermissionMode) || 'default', + ); + + const openSetupGuide = React.useCallback(async () => { + const url = profileDocs?.setupGuideUrl; + if (!url) return; + try { + if (Platform.OS === 'web') { + window.open(url, '_blank'); + } else { + await Linking.openURL(url); + } + } catch (error) { + console.error('Failed to open URL:', error); + } + }, [profileDocs?.setupGuideUrl]); - const handleSave = () => { + const handleSave = React.useCallback(() => { if (!name.trim()) { - // Profile name validation - prevent saving empty profiles return; } onSave({ ...profile, name: name.trim(), - // Clear all config objects - ALL configuration now in environmentVariables anthropicConfig: {}, openaiConfig: {}, azureOpenAIConfig: {}, - // Use environment variables from state (managed by EnvironmentVariablesList) environmentVariables, - // Keep non-env-var configuration - tmuxConfig: useTmux ? { - sessionName: tmuxSession.trim() || '', // Empty string = use current/most recent tmux session - tmpDir: tmuxTmpDir.trim() || undefined, - updateEnvironment: undefined, // Preserve schema compatibility, not used by daemon - } : { - sessionName: undefined, - tmpDir: undefined, - updateEnvironment: undefined, - }, - startupBashScript: useStartupScript ? (startupScript.trim() || undefined) : undefined, - defaultSessionType: defaultSessionType, - defaultPermissionMode: defaultPermissionMode, + tmuxConfig: useTmux + ? { + sessionName: tmuxSession.trim() || '', + tmpDir: tmuxTmpDir.trim() || undefined, + updateEnvironment: undefined, + } + : { + sessionName: undefined, + tmpDir: undefined, + updateEnvironment: undefined, + }, + defaultSessionType, + defaultPermissionMode, updatedAt: Date.now(), }); - }; + }, [ + defaultPermissionMode, + defaultSessionType, + environmentVariables, + name, + onSave, + profile, + tmuxSession, + tmuxTmpDir, + useTmux, + ]); return ( - - - {/* Profile Name */} - - {t('profiles.profileName')} - - - - {/* Built-in Profile Documentation - Setup Instructions */} - {profile.isBuiltIn && profileDocs && ( - - - - - Setup Instructions - - - - - {profileDocs.description} - - - {profileDocs.setupGuideUrl && ( - { - try { - const url = profileDocs.setupGuideUrl!; - // On web/Tauri desktop, use window.open - if (Platform.OS === 'web') { - window.open(url, '_blank'); - } else { - // On native (iOS/Android), use Linking API - await Linking.openURL(url); - } - } catch (error) { - console.error('Failed to open URL:', error); - } - }} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.button.primary.background, - borderRadius: 8, - padding: 12, - marginBottom: 16, - }} - > - - - View Official Setup Guide - - - - )} - - )} - - {/* Session Type */} - - Default Session Type - - - + + + + + + - {/* Permission Mode */} - - Default Permission Mode - - - {[ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ].map((option, index, array) => ( - - } - rightElement={defaultPermissionMode === option.value ? ( - - ) : null} - onPress={() => setDefaultPermissionMode(option.value)} - showChevron={false} - selected={defaultPermissionMode === option.value} - showDivider={index < array.length - 1} - style={defaultPermissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: 8, - } : undefined} - /> - ))} - - + {profile.isBuiltIn && profileDocs?.setupGuideUrl && ( + + } + onPress={() => void openSetupGuide()} + /> + + )} - {/* Tmux Enable/Disable */} - - setUseTmux(!useTmux)} - > - - {useTmux && ( - - )} - - - - Spawn Sessions in Tmux - + + + + - - {useTmux ? 'Sessions spawn in new tmux windows. Configure session name and temp directory below.' : 'Sessions spawn in regular shell (no tmux integration)'} - - - {/* Tmux Session Name */} - - Tmux Session Name ({t('common.optional')}) - - - Leave empty to use first existing tmux session (or create "happy" if none exist). Specify name (e.g., "my-work") for specific session. - - + + - {/* Tmux Temp Directory */} - - Tmux Temp Directory ({t('common.optional')}) - - - Temporary directory for tmux session files. Leave empty for system default. - - + {[ + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, + ].map((option, index, array) => ( + + } + rightElement={ + defaultPermissionMode === option.value ? ( + + ) : null + } + onPress={() => setDefaultPermissionMode(option.value)} + showChevron={false} + selected={defaultPermissionMode === option.value} + showDivider={index < array.length - 1} /> + ))} + - {/* Startup Bash Script */} - - - setUseStartupScript(!useStartupScript)} - > - - {useStartupScript && ( - - )} - - - - Startup Bash Script - + + } + showChevron={false} + onPress={() => setUseTmux((v) => !v)} + /> + {useTmux && ( + + + Tmux Session Name ({t('common.optional')}) + - - {useStartupScript - ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.' - : 'No startup script - sessions spawn directly'} - - + + Tmux Temp Directory ({t('common.optional')}) - {useStartupScript && startupScript.trim() && ( - { - if (Platform.OS === 'web') { - navigator.clipboard.writeText(startupScript); - } - }} - > - - - )} - + + )} + - {/* Environment Variables Section - Unified configuration */} - + - {/* Action buttons */} - - - - {t('common.cancel')} - - - {profile.isBuiltIn ? ( - // For built-in profiles, show "Save As" button (creates custom copy) - - - {t('common.saveAs')} - - - ) : ( - // For custom profiles, show regular "Save" button - - - {t('common.save')} - - - )} - - - + + + + + ); } -const profileEditFormStyles = StyleSheet.create((theme, rt) => ({ - scrollView: { - flex: 1, +const stylesheet = StyleSheet.create((theme) => ({ + inputContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + selectorContainer: { + paddingHorizontal: 12, + paddingBottom: 4, + }, + fieldLabel: { + ...Typography.default('semiBold'), + fontSize: 13, + color: theme.colors.groupped.sectionTitle, + marginBottom: 8, }, - scrollContent: { - padding: 20, + textInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, }, - formContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, // Matches new session panel main container - padding: 20, - width: '100%', + multilineInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 14, + lineHeight: 20, + color: theme.colors.input.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + minHeight: 120, }, })); diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx index 37b1fc9e5..955b66f49 100644 --- a/sources/components/SettingsView.tsx +++ b/sources/components/SettingsView.tsx @@ -37,6 +37,8 @@ export const SettingsView = React.memo(function SettingsView() { const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled'); const isPro = __DEV__ || useEntitlement('pro'); const experiments = useSetting('experiments'); + const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); + const useProfiles = useSetting('useProfiles'); const isCustomServer = isUsingCustomServer(); const allMachines = useAllMachines(); const profile = useProfile(); @@ -322,12 +324,14 @@ export const SettingsView = React.memo(function SettingsView() { icon={} onPress={() => router.push('/settings/features')} /> - } - onPress={() => router.push('/settings/profiles')} - /> + {useProfiles && ( + } + onPress={() => router.push('/settings/profiles')} + /> + )} {experiments && ( ({ + track: { + width: TRACK_WIDTH, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + padding: PADDING, + justifyContent: 'center', + }, + thumb: { + width: THUMB_SIZE, + height: THUMB_SIZE, + borderRadius: THUMB_SIZE / 2, + }, +})); + +export const Switch = ({ value, disabled, onValueChange }: SwitchProps) => { + const { theme } = useUnistyles(); + const styles = stylesheet; + + const translateX = value ? TRACK_WIDTH - THUMB_SIZE - PADDING * 2 : 0; + + return ( + onValueChange?.(!value)} + style={({ pressed }) => ({ + opacity: disabled ? 0.6 : pressed ? 0.85 : 1, + })} + > + + + + + ); +}; + diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts index 07f70e694..510cfe104 100644 --- a/sources/sync/ops.ts +++ b/sources/sync/ops.ts @@ -139,6 +139,8 @@ export interface SpawnSessionOptions { approvedNewDirectoryCreation?: boolean; token?: string; agent?: 'codex' | 'claude' | 'gemini'; + // Session-scoped profile identity (non-secret). Empty string means "no profile". + profileId?: string; // Environment variables from AI backend profile // Accepts any environment variables - daemon will pass them to the agent process // Common variables include: @@ -159,7 +161,7 @@ export interface SpawnSessionOptions { */ export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise { - const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables } = options; + const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables, profileId } = options; try { const result = await apiSocket.machineRPC; }>( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, environmentVariables } + { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, profileId, environmentVariables } ); return result; } catch (error) { diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 4f36ce46f..f6942f5b0 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -103,6 +103,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -137,6 +138,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -171,6 +173,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -207,6 +210,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -248,6 +252,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, @@ -298,6 +303,7 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 57a563958..2e78ec19b 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -84,9 +84,6 @@ export const AIBackendProfileSchema = z.object({ // Tmux configuration tmuxConfig: TmuxConfigSchema.optional(), - // Startup bash script (executed before spawning session) - startupBashScript: z.string().optional(), - // Environment variables (validated) environmentVariables: z.array(EnvironmentVariableSchema).default([]), @@ -241,6 +238,7 @@ export const SettingsSchema = z.object({ wrapLinesInDiffs: z.boolean().describe('Whether to wrap long lines in diff views'), analyticsOptOut: z.boolean().describe('Whether to opt out of anonymous analytics'), experiments: z.boolean().describe('Whether to enable experimental features'), + useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), @@ -310,6 +308,7 @@ export const settingsDefaults: Settings = { wrapLinesInDiffs: false, analyticsOptOut: false, experiments: false, + useProfiles: false, useEnhancedSessionWizard: false, alwaysShowContextSize: false, agentInputEnterToSend: true, diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bff187d04..318ed9fb2 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -10,6 +10,7 @@ export const MetadataSchema = z.object({ version: z.string().optional(), name: z.string().optional(), os: z.string().optional(), + profileId: z.string().nullable().optional(), // Session-scoped profile identity (non-secret) summary: z.object({ text: z.string(), updatedAt: z.number() diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 4c69aa745..a09a8ba81 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -207,6 +207,9 @@ export const en = { enhancedSessionWizard: 'Enhanced Session Wizard', enhancedSessionWizardEnabled: 'Profile-first session launcher active', enhancedSessionWizardDisabled: 'Using standard session launcher', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 5138685c6..260dc0b0c 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -208,6 +208,9 @@ export const ca: TranslationStructure = { enhancedSessionWizard: 'Assistent de sessió millorat', enhancedSessionWizardEnabled: 'Llançador de sessió amb perfil actiu', enhancedSessionWizardDisabled: 'Usant el llançador de sessió estàndard', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 477bae10f..382c663a2 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -208,6 +208,9 @@ export const es: TranslationStructure = { enhancedSessionWizard: 'Asistente de sesión mejorado', enhancedSessionWizardEnabled: 'Lanzador de sesión con perfil activo', enhancedSessionWizardDisabled: 'Usando el lanzador de sesión estándar', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 55224e47d..25e1e4879 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -237,6 +237,9 @@ export const it: TranslationStructure = { enhancedSessionWizard: 'Wizard sessione avanzato', enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo', enhancedSessionWizardDisabled: 'Usando avvio sessioni standard', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 394090e9f..9657b3d37 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -240,6 +240,9 @@ export const ja: TranslationStructure = { enhancedSessionWizard: '拡張セッションウィザード', enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効', enhancedSessionWizardDisabled: '標準セッションランチャーを使用', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index c4da73780..dcc489b23 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -219,6 +219,9 @@ export const pl: TranslationStructure = { enhancedSessionWizard: 'Ulepszony kreator sesji', enhancedSessionWizardEnabled: 'Aktywny launcher z profilem', enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index d0d5b9b7e..4cacd94f7 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -208,6 +208,9 @@ export const pt: TranslationStructure = { enhancedSessionWizard: 'Assistente de sessão aprimorado', enhancedSessionWizardEnabled: 'Lançador de sessão com perfil ativo', enhancedSessionWizardDisabled: 'Usando o lançador de sessão padrão', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 5ce577666..940635c3a 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -190,6 +190,9 @@ export const ru: TranslationStructure = { enhancedSessionWizard: 'Улучшенный мастер сессий', enhancedSessionWizardEnabled: 'Лаунчер с профилем активен', enhancedSessionWizardDisabled: 'Используется стандартный лаунчер', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 4737d8a74..45147cb4b 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -210,6 +210,9 @@ export const zhHans: TranslationStructure = { enhancedSessionWizard: '增强会话向导', enhancedSessionWizardEnabled: '配置文件优先启动器已激活', enhancedSessionWizardDisabled: '使用标准会话启动器', + profiles: 'AI Profiles', + profilesEnabled: 'Profile selection enabled', + profilesDisabled: 'Profile selection disabled', }, errors: { From ba866b80b7a433e3cd6956b25118c8a2b63ce74a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Mon, 12 Jan 2026 23:11:56 +0100 Subject: [PATCH 06/72] fix(new-session): restore legacy session creation UI --- sources/app/(app)/_layout.tsx | 8 + sources/app/(app)/new/index.tsx | 189 +- sources/app/(app)/new/pick/machine.tsx | 64 +- sources/app/(app)/new/pick/path.tsx | 208 +- sources/app/(app)/new/pick/profile.tsx | 119 +- sources/components/AgentInput.tsx | 48 +- sources/components/NewSessionWizard.tsx | 1927 ----------------- .../newSession/DirectorySelector.tsx | 115 + .../components/newSession/MachineSelector.tsx | 103 + 9 files changed, 544 insertions(+), 2237 deletions(-) delete mode 100644 sources/components/NewSessionWizard.tsx create mode 100644 sources/components/newSession/DirectorySelector.tsx create mode 100644 sources/components/newSession/MachineSelector.tsx diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 408d7ad24..1c5edb274 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -311,6 +311,13 @@ export default function RootLayout() { headerBackTitle: t('common.back'), }} /> + ({ + const styles = StyleSheet.create((theme, rt) => ({ container: { flex: 1, justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', @@ -112,28 +112,33 @@ const styles = StyleSheet.create((theme, rt) => ({ paddingTop: rt.insets.top, paddingBottom: 16, }, - wizardContainer: { - backgroundColor: theme.colors.surface, - borderRadius: 16, - marginHorizontal: 16, - padding: 16, - marginBottom: 16, - }, - sectionHeader: { - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - marginTop: 12, - ...Typography.default('semiBold') - }, - sectionDescription: { - fontSize: 12, - color: theme.colors.textSecondary, - marginBottom: 12, - lineHeight: 18, - ...Typography.default() - }, + wizardContainer: { + marginBottom: 16, + }, + wizardSectionHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 8, + marginTop: 12, + paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + }, + sectionHeader: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + marginTop: 12, + ...Typography.default('semiBold') + }, + sectionDescription: { + fontSize: 12, + color: theme.colors.textSecondary, + marginBottom: 12, + lineHeight: 18, + paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + ...Typography.default() + }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -290,6 +295,10 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); + const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); + const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); @@ -323,20 +332,20 @@ function NewSessionWizard() { setSelectedProfileId(null); } }, [useProfiles, selectedProfileId]); + const allowGemini = experimentsEnabled; + const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => { // Check if agent type was provided in temp data if (tempSessionData?.agentType) { - // Only allow gemini if experiments are enabled - if (tempSessionData.agentType === 'gemini' && !experimentsEnabled) { + if (tempSessionData.agentType === 'gemini' && !allowGemini) { return 'claude'; } return tempSessionData.agentType; } - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - // Only allow gemini if experiments are enabled - if (lastUsedAgent === 'gemini' && experimentsEnabled) { + if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex' || lastUsedAgent === 'gemini') { + if (lastUsedAgent === 'gemini' && !allowGemini) { + return 'claude'; + } return lastUsedAgent; } return 'claude'; @@ -346,12 +355,12 @@ function NewSessionWizard() { // Note: Does NOT persist immediately - persistence is handled by useEffect below const handleAgentClick = React.useCallback(() => { setAgentType(prev => { - // Cycle: claude -> codex -> gemini (if experiments) -> claude + // Cycle: claude -> codex -> (gemini?) -> claude if (prev === 'claude') return 'codex'; - if (prev === 'codex') return experimentsEnabled ? 'gemini' : 'claude'; + if (prev === 'codex') return allowGemini ? 'gemini' : 'claude'; return 'claude'; }); - }, [experimentsEnabled]); + }, [allowGemini]); // Persist agent selection changes (separate from setState to avoid race condition) // This runs after agentType state is updated, ensuring the value is stable @@ -1010,7 +1019,7 @@ function NewSessionWizard() { // Get environment variables from selected profile let environmentVariables = undefined; if (profilesActive && selectedProfileId) { - const selectedProfile = profileMap.get(selectedProfileId); + const selectedProfile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); if (selectedProfile) { environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType); } @@ -1063,6 +1072,11 @@ function NewSessionWizard() { }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); const screenWidth = useWindowDimensions().width; + const showInlineClose = screenWidth < 520; + + const handleCloseModal = React.useCallback(() => { + router.back(); + }, [router]); // Machine online status for AgentInput (DRY - reused in info box too) const connectionStatus = React.useMemo(() => { @@ -1121,6 +1135,23 @@ function NewSessionWizard() { keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight + safeArea.bottom + 16 : 0} style={styles.container} > + {showInlineClose && ( + + + + )} {/* Session type selector only if experiments enabled */} {experimentsEnabled && ( @@ -1174,6 +1205,23 @@ function NewSessionWizard() { keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight + safeArea.bottom + 16 : 0} style={styles.container} > + {showInlineClose && ( + + + + )} - 700 ? 16 : 8 } - ]}> + @@ -1246,7 +1292,7 @@ function NewSessionWizard() { )} {/* Section 1: Profile Management */} - + 1. @@ -1483,8 +1529,8 @@ function NewSessionWizard() { subtitle={selectedProfile ? getProfileSubtitle(selectedProfile) : t('profiles.noProfileDescription')} leftElement={ } @@ -1502,14 +1548,14 @@ function NewSessionWizard() { showChevron={false} /> } - selected={agentType === 'codex'} - onPress={() => setAgentType('codex')} - showChevron={false} - /> - {experimentsEnabled && ( + title="Codex" + subtitle="Codex CLI" + leftElement={} + selected={agentType === 'codex'} + onPress={() => setAgentType('codex')} + showChevron={false} + /> + {allowGemini && ( - + 2. Select Machine @@ -1537,27 +1583,28 @@ function NewSessionWizard() { machines={machines} selectedMachine={selectedMachine || null} recentMachines={recentMachines} - favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} - showFavorites={true} + favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} + showFavorites={useMachinePickerFavorites} + showSearch={useMachinePickerSearch} onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); setSelectedPath(bestPath); }} - onToggleFavorite={(machine) => { + onToggleFavorite={useMachinePickerFavorites ? ((machine) => { const isInFavorites = favoriteMachines.includes(machine.id); if (isInFavorites) { setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); } else { setFavoriteMachines([...favoriteMachines, machine.id]); } - }} + }) : undefined} /> {/* Section 3: Working Directory */} - + 3. Select Working Directory @@ -1569,13 +1616,15 @@ function NewSessionWizard() { machineHomeDir={selectedMachine?.metadata?.homeDir} selectedPath={selectedPath} recentPaths={recentPaths} - favoritePaths={(() => { + favoritePaths={useDirectoryPickerFavorites ? (() => { if (!selectedMachine?.metadata?.homeDir) return []; const homeDir = selectedMachine.metadata.homeDir; return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })()} + })() : []} + showFavorites={useDirectoryPickerFavorites} + showSearch={useDirectoryPickerSearch} onSelect={(path) => setSelectedPath(path)} - onToggleFavorite={(path) => { + onToggleFavorite={useDirectoryPickerFavorites ? ((path) => { const homeDir = selectedMachine?.metadata?.homeDir; if (!homeDir) return; if (path === homeDir) return; @@ -1591,21 +1640,25 @@ function NewSessionWizard() { } else { setFavoriteDirectories([...favoriteDirectories, relativePath]); } - }} + }) : undefined} /> {/* Section 4: Permission Mode */} - 4. Permission Mode + + 4. + + Permission Mode + {(agentType === 'codex' ? [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: 'Read Only', description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: 'Safe YOLO', description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: 'YOLO', description: 'Full access, skip permissions', icon: 'flash-outline' }, + { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, ] : [ { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, @@ -1622,25 +1675,21 @@ function NewSessionWizard() { } rightElement={permissionMode === option.value ? ( ) : null} onPress={() => setPermissionMode(option.value)} showChevron={false} selected={permissionMode === option.value} + pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={index < array.length - 1} - style={permissionMode === option.value ? { - borderWidth: 2, - borderColor: theme.colors.button.primary.background, - borderRadius: Platform.select({ ios: 10, default: 16 }), - } : undefined} /> ))} diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index e5c35236d..f8d89d57e 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -4,12 +4,10 @@ import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; import { useAllMachines, useSessions } from '@/sync/storage'; -import { Ionicons } from '@expo/vector-icons'; -import { isMachineOnline } from '@/utils/machineUtils'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; -import { SearchableListSelector } from '@/components/SearchableListSelector'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -115,61 +113,13 @@ export default function MachinePickerScreen() { }} /> - - config={{ - getItemId: (machine) => machine.id, - getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - getItemSubtitle: undefined, - getItemIcon: (machine) => ( - - ), - getRecentItemIcon: (machine) => ( - - ), - getItemStatus: (machine) => { - const offline = !isMachineOnline(machine); - return { - text: offline ? 'offline' : 'online', - color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, - isPulsing: !offline, - }; - }, - formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, - parseFromDisplay: (text) => { - return machines.find(m => - m.metadata?.displayName === text || m.metadata?.host === text || m.id === text - ) || null; - }, - filterItem: (machine, searchText) => { - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const search = searchText.toLowerCase(); - return displayName.includes(search) || host.includes(search); - }, - searchPlaceholder: "Type to filter machines...", - recentSectionTitle: "Recent Machines", - favoritesSectionTitle: "Favorite Machines", - noItemsMessage: "No machines available", - showFavorites: false, // Simpler modal experience - no favorites in modal - showRecent: true, - showSearch: true, - allowCustomInput: false, - compactItems: true, - }} - items={machines} - recentItems={recentMachines} - favoriteItems={[]} - selectedItem={selectedMachine} + diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b0214d6c6..08bc93cca 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -1,32 +1,19 @@ import React, { useState, useMemo, useRef } from 'react'; -import { View, Text, ScrollView, Pressable } from 'react-native'; +import { View, Text, Pressable } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; import { CommonActions, useNavigation } from '@react-navigation/native'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; import { Typography } from '@/constants/Typography'; import { useAllMachines, useSessions, useSetting } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { layout } from '@/components/layout'; import { t } from '@/text'; +import { ItemList } from '@/components/ItemList'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { layout } from '@/components/layout'; import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; const stylesheet = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.groupped.background, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - alignItems: 'center', - }, - contentWrapper: { - width: '100%', - maxWidth: layout.maxWidth, - }, emptyContainer: { flex: 1, justifyContent: 'center', @@ -39,6 +26,11 @@ const stylesheet = StyleSheet.create((theme) => ({ textAlign: 'center', ...Typography.default(), }, + contentWrapper: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, pathInputContainer: { flexDirection: 'row', alignItems: 'center', @@ -66,8 +58,8 @@ export default function PathPickerScreen() { const params = useLocalSearchParams<{ machineId?: string; selectedPath?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const inputRef = useRef(null); const recentMachinePaths = useSetting('recentMachinePaths'); + const inputRef = useRef(null); const [customPath, setCustomPath] = useState(params.selectedPath || ''); @@ -135,6 +127,17 @@ export default function PathPickerScreen() { router.back(); }, [customPath, router, machine, navigation]); + const suggestedPaths = useMemo(() => { + if (!machine) return []; + const homeDir = machine.metadata?.homeDir || '/home'; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + }, [machine]); + if (!machine) { return ( <> @@ -162,13 +165,11 @@ export default function PathPickerScreen() { ) }} /> - + - - No machine selected - + No machine selected - + ); } @@ -198,104 +199,73 @@ export default function PathPickerScreen() { ) }} /> - - - - - - - - + + + + + + - + + - {recentPaths.length > 0 && ( - - {recentPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === recentPaths.length - 1; - - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={!isLast} - /> - ); - })} - - )} - - {recentPaths.length === 0 && ( - - {(() => { - const homeDir = machine.metadata?.homeDir || '/home'; - const suggestedPaths = [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop` - ]; - return suggestedPaths.map((path, index) => { - const isSelected = customPath.trim() === path; + {recentPaths.length > 0 && ( + + {recentPaths.map((path, index) => { + const isSelected = customPath.trim() === path; + const isLast = index === recentPaths.length - 1; + return ( + } + onPress={() => { + setCustomPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + showDivider={!isLast} + /> + ); + })} + + )} - return ( - - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < 3} - /> - ); - }); - })()} - - )} - - - + {recentPaths.length === 0 && suggestedPaths.length > 0 && ( + + {suggestedPaths.map((path, index) => { + const isSelected = customPath.trim() === path; + const isLast = index === suggestedPaths.length - 1; + return ( + } + onPress={() => { + setCustomPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + showDivider={!isLast} + /> + ); + })} + + )} + + ); } \ No newline at end of file diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 8c67d6651..0219f0ab9 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; import { CommonActions, useNavigation } from '@react-navigation/native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; @@ -36,18 +37,6 @@ export default function ProfilePickerScreen() { router.back(); }, [navigation, router]); - const allProfiles = React.useMemo(() => { - const builtIns = DEFAULT_PROFILES - .map(bp => getBuiltInProfile(bp.id)) - .filter(Boolean) as AIBackendProfile[]; - return [...builtIns, ...profiles]; - }, [profiles]); - - const selectedProfile = React.useMemo(() => { - if (!selectedId) return null; - return allProfiles.find(p => p.id === selectedId) || null; - }, [allProfiles, selectedId]); - const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { const profileData = JSON.stringify(profile); const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; @@ -136,28 +125,7 @@ export default function ProfilePickerScreen() { title={t('profiles.addProfile')} icon={} onPress={handleAddProfile} - /> - } - onPress={() => selectedProfile && openProfileEdit(selectedProfile)} - disabled={!selectedProfile} - /> - } - onPress={() => selectedProfile && handleDuplicateProfile(selectedProfile)} - disabled={!selectedProfile} - /> - } - onPress={() => selectedProfile && handleDeleteProfile(selectedProfile)} - destructive={true} - disabled={!selectedProfile || selectedProfile.isBuiltIn} + showChevron={false} /> @@ -165,12 +133,12 @@ export default function ProfilePickerScreen() { } + icon={} onPress={() => setProfileParamAndClose('')} showChevron={false} selected={selectedId === ''} rightElement={selectedId === '' - ? + ? : null} /> @@ -190,9 +158,37 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} - rightElement={isSelected - ? - : null} + rightElement={ + + {isSelected && ( + + )} + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + style={{ marginLeft: 16 }} + > + + + + } /> ); })} @@ -208,9 +204,47 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} - rightElement={isSelected - ? - : null} + rightElement={ + + {isSelected && ( + + )} + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + style={{ marginLeft: 16 }} + > + + + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + style={{ marginLeft: 16 }} + > + + + + } /> ); })} @@ -221,4 +255,3 @@ export default function ProfilePickerScreen() { ); } - diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 893f26d4c..6520a73b9 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -1,6 +1,6 @@ import { Ionicons, Octicons } from '@expo/vector-icons'; import * as React from 'react'; -import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, TouchableWithoutFeedback, Image as RNImage, Pressable } from 'react-native'; +import { View, Platform, useWindowDimensions, ViewStyle, Text, ActivityIndicator, Image as RNImage, Pressable } from 'react-native'; import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; @@ -223,7 +223,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ // Button styles actionButtonsContainer: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 0, }, @@ -231,7 +231,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ flexDirection: 'row', gap: 8, flex: 1, - overflow: 'hidden', + flexWrap: 'wrap', + overflow: 'visible', }, actionButton: { flexDirection: 'row', @@ -300,8 +301,9 @@ export const AgentInput = React.memo(React.forwardRef 0; // Check if this is a Codex or Gemini session - const isCodex = props.metadata?.flavor === 'codex'; - const isGemini = props.metadata?.flavor === 'gemini'; + const effectiveFlavor = props.metadata?.flavor ?? props.agentType; + const isCodex = effectiveFlavor === 'codex'; + const isGemini = effectiveFlavor === 'gemini'; // Profile data const profiles = useSetting('profiles'); @@ -316,19 +318,25 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === undefined) { - return null; - } - if (props.profileId === null || props.profileId.trim() === '') { - return t('profiles.noProfile'); - } + const profileLabel = React.useMemo(() => { + if (props.profileId === undefined) { + return null; + } + if (props.profileId === null || props.profileId.trim() === '') { + return t('profiles.noProfile'); + } if (currentProfile) { return currentProfile.name; } const shortId = props.profileId.length > 8 ? `${props.profileId.slice(0, 8)}…` : props.profileId; return `${t('status.unknown')} (${shortId})`; - }, [props.profileId, currentProfile]); + }, [props.profileId, currentProfile]); + + const profileIcon = React.useMemo(() => { + if (props.profileId === null) return 'radio-button-off-outline'; + if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; + return 'person-outline'; + }, [props.profileId]); // Calculate context warning const contextWarning = props.usageData?.contextSize @@ -536,9 +544,7 @@ export const AgentInput = React.memo(React.forwardRef - setShowSettings(false)}> - - + setShowSettings(false)} style={styles.overlayBackdrop} /> 700 ? 0 : 8 } @@ -796,11 +802,11 @@ export const AgentInput = React.memo(React.forwardRef - + ({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - stepIndicator: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 24, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - stepDot: { - width: 8, - height: 8, - borderRadius: 4, - marginHorizontal: 4, - }, - stepDotActive: { - backgroundColor: theme.colors.button.primary.background, - }, - stepDotInactive: { - backgroundColor: theme.colors.divider, - }, - stepContent: { - flex: 1, - paddingHorizontal: 24, - paddingTop: 24, - paddingBottom: 0, // No bottom padding since footer is separate - }, - stepTitle: { - fontSize: 20, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - ...Typography.default('semiBold'), - }, - stepDescription: { - fontSize: 16, - color: theme.colors.textSecondary, - marginBottom: 24, - ...Typography.default(), - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 24, - paddingVertical: 16, - borderTopWidth: 1, - borderTopColor: theme.colors.divider, - backgroundColor: theme.colors.surface, // Ensure footer has solid background - }, - button: { - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 8, - minWidth: 100, - alignItems: 'center', - justifyContent: 'center', - }, - buttonPrimary: { - backgroundColor: theme.colors.button.primary.background, - }, - buttonSecondary: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: theme.colors.divider, - }, - buttonText: { - fontSize: 16, - fontWeight: '600', - ...Typography.default('semiBold'), - }, - buttonTextPrimary: { - color: '#FFFFFF', - }, - buttonTextSecondary: { - color: theme.colors.text, - }, - textInput: { - backgroundColor: theme.colors.input.background, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, - color: theme.colors.text, - borderWidth: 1, - borderColor: theme.colors.divider, - ...Typography.default(), - }, - agentOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderRadius: 12, - borderWidth: 2, - marginBottom: 12, - }, - agentOptionSelected: { - borderColor: theme.colors.button.primary.background, - backgroundColor: theme.colors.input.background, - }, - agentOptionUnselected: { - borderColor: theme.colors.divider, - backgroundColor: theme.colors.input.background, - }, - agentIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: theme.colors.button.primary.background, - alignItems: 'center', - justifyContent: 'center', - marginRight: 16, - }, - agentInfo: { - flex: 1, - }, - agentName: { - fontSize: 16, - fontWeight: '600', - color: theme.colors.text, - ...Typography.default('semiBold'), - }, - agentDescription: { - fontSize: 14, - color: theme.colors.textSecondary, - marginTop: 4, - ...Typography.default(), - }, -})); - -type WizardStep = 'profile' | 'profileConfig' | 'sessionType' | 'agent' | 'options' | 'machine' | 'path' | 'prompt'; - -// Profile selection item component with management actions -interface ProfileSelectionItemProps { - profile: AIBackendProfile; - isSelected: boolean; - onSelect: () => void; - onUseAsIs: () => void; - onEdit: () => void; - onDuplicate?: () => void; - onDelete?: () => void; - showManagementActions?: boolean; -} - -function ProfileSelectionItem({ profile, isSelected, onSelect, onUseAsIs, onEdit, onDuplicate, onDelete, showManagementActions = false }: ProfileSelectionItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - - {/* Profile Header */} - - - - - - - - {profile.name} - - - {profile.description} - - {profile.isBuiltIn && ( - - Built-in profile - - )} - - {isSelected && ( - - )} - - - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - - {/* Primary Actions */} - - - - - Use As-Is - - - - - - - Edit - - - - - {/* Management Actions - Only show for custom profiles */} - {showManagementActions && !profile.isBuiltIn && ( - - - - - Duplicate - - - - - - - Delete - - - - )} - - )} - - ); -} - -// Manual configuration item component -interface ManualConfigurationItemProps { - isSelected: boolean; - onSelect: () => void; - onUseCliVars: () => void; - onConfigureManually: () => void; -} - -function ManualConfigurationItem({ isSelected, onSelect, onUseCliVars, onConfigureManually }: ManualConfigurationItemProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - - return ( - - {/* Profile Header */} - - - - - - - - Manual Configuration - - - Use CLI environment variables or configure manually - - - {isSelected && ( - - )} - - - - {/* Action Buttons - Only show when selected */} - {isSelected && ( - - - - - Use CLI Vars - - - - - - - Configure - - - - )} - - ); -} - -interface NewSessionWizardProps { - onComplete: (config: { - sessionType: 'simple' | 'worktree'; - profileId: string | null; - agentType: 'claude' | 'codex'; - permissionMode: PermissionMode; - modelMode: ModelMode; - machineId: string; - path: string; - prompt: string; - environmentVariables?: Record; - }) => void; - onCancel: () => void; - initialPrompt?: string; -} - -export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: NewSessionWizardProps) { - const { theme } = useUnistyles(); - const styles = stylesheet; - const router = useRouter(); - const machines = useAllMachines(); - const sessions = useSessions(); - const experimentsEnabled = useSetting('experiments'); - const recentMachinePaths = useSetting('recentMachinePaths'); - const lastUsedAgent = useSetting('lastUsedAgent'); - const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); - const profiles = useSetting('profiles'); - const lastUsedProfile = useSetting('lastUsedProfile'); - - // Wizard state - const [currentStep, setCurrentStep] = useState('profile'); - const [sessionType, setSessionType] = useState<'simple' | 'worktree'>('simple'); - const [agentType, setAgentType] = useState<'claude' | 'codex'>(() => { - if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') { - return lastUsedAgent; - } - return 'claude'; - }); - const [permissionMode, setPermissionMode] = useState('default'); - const [modelMode, setModelMode] = useState('default'); - const [selectedProfileId, setSelectedProfileId] = useState(() => { - return lastUsedProfile; - }); - - // Built-in profiles - const builtInProfiles: AIBackendProfile[] = useMemo(() => [ - { - id: 'anthropic', - name: 'Anthropic (Default)', - description: 'Default Claude configuration', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'deepseek', - name: 'DeepSeek (Reasoner)', - description: 'DeepSeek reasoning model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.deepseek.com/anthropic', - model: 'deepseek-reasoner', - }, - environmentVariables: [ - { name: 'API_TIMEOUT_MS', value: '600000' }, - { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'deepseek-chat' }, - { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' }, - ], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'openai', - name: 'OpenAI (GPT-4/Codex)', - description: 'OpenAI GPT-4 and Codex models', - openaiConfig: { - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai-codex', - name: 'Azure OpenAI (Codex)', - description: 'Microsoft Azure OpenAI for Codex agents', - azureOpenAIConfig: { - endpoint: 'https://your-resource.openai.azure.com/', - apiVersion: '2024-02-15-preview', - deploymentName: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'azure-openai', - name: 'Azure OpenAI', - description: 'Microsoft Azure OpenAI configuration', - azureOpenAIConfig: { - apiVersion: '2024-02-15-preview', - }, - environmentVariables: [ - { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, - ], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'zai', - name: 'Z.ai (GLM-4.6)', - description: 'Z.ai GLM-4.6 model with proxy to Anthropic API', - anthropicConfig: { - baseUrl: 'https://api.z.ai/api/anthropic', - model: 'glm-4.6', - }, - environmentVariables: [], - compatibility: { claude: true, codex: false, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Microsoft Azure AI services', - openaiConfig: { - baseUrl: 'https://api.openai.azure.com', - model: 'gpt-4-turbo', - }, - environmentVariables: [], - compatibility: { claude: false, codex: true, gemini: false }, - isBuiltIn: true, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }, - ], []); - - // Combined profiles - const allProfiles = useMemo(() => { - return [...builtInProfiles, ...profiles]; - }, [profiles, builtInProfiles]); - - const [selectedMachineId, setSelectedMachineId] = useState(() => { - if (machines.length > 0) { - // Check if we have a recently used machine that's currently available - if (recentMachinePaths.length > 0) { - for (const recent of recentMachinePaths) { - if (machines.find(m => m.id === recent.machineId)) { - return recent.machineId; - } - } - } - return machines[0].id; - } - return ''; - }); - const [selectedPath, setSelectedPath] = useState(() => { - if (machines.length > 0 && selectedMachineId) { - const machine = machines.find(m => m.id === selectedMachineId); - return machine?.metadata?.homeDir || '/home'; - } - return '/home'; - }); - const [prompt, setPrompt] = useState(initialPrompt); - const [customPath, setCustomPath] = useState(''); - const [showCustomPathInput, setShowCustomPathInput] = useState(false); - - // Profile configuration state - const [profileApiKeys, setProfileApiKeys] = useState>>({}); - const [profileConfigs, setProfileConfigs] = useState>>({}); - - function profileNeedsConfiguration(profileId: string | null): boolean { - if (!profileId) return false; // Manual configuration doesn't need API keys - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return false; - - // Check if profile is one that requires API keys - const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek']; - return profilesNeedingKeys.includes(profile.id); - } - - // Dynamic steps based on whether profile needs configuration - const steps: WizardStep[] = React.useMemo(() => { - const baseSteps: WizardStep[] = experimentsEnabled - ? ['profile', 'sessionType', 'agent', 'options', 'machine', 'path', 'prompt'] - : ['profile', 'agent', 'options', 'machine', 'path', 'prompt']; - - // Insert profileConfig step after profile if needed - if (profileNeedsConfiguration(selectedProfileId)) { - const profileIndex = baseSteps.indexOf('profile'); - const beforeProfile = baseSteps.slice(0, profileIndex + 1) as WizardStep[]; - const afterProfile = baseSteps.slice(profileIndex + 1) as WizardStep[]; - return [ - ...beforeProfile, - 'profileConfig', - ...afterProfile - ] as WizardStep[]; - } - - return baseSteps; - }, [experimentsEnabled, selectedProfileId, allProfiles]); - - // Get required fields for profile configuration - const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => { - if (!profileId) return []; - const profile = allProfiles.find(p => p.id === profileId); - if (!profile) return []; - - switch (profile.id) { - case 'deepseek': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'DeepSeek API Key', placeholder: 'DEEPSEEK_API_KEY', isPassword: true } - ]; - case 'openai': - return [ - { key: 'OPENAI_API_KEY', label: 'OpenAI API Key', placeholder: 'sk-...', isPassword: true } - ]; - case 'azure-openai': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'zai': - return [ - { key: 'ANTHROPIC_AUTH_TOKEN', label: 'Z.ai API Key', placeholder: 'Z_AI_API_KEY', isPassword: true } - ]; - case 'microsoft': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure API Key', placeholder: 'Enter your Azure API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - case 'azure-openai-codex': - return [ - { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true }, - { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' }, - { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' } - ]; - default: - return []; - } - }; - - // Auto-load profile settings and sync with CLI - React.useEffect(() => { - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Auto-select agent type based on profile compatibility - if (selectedProfile.compatibility.claude && !selectedProfile.compatibility.codex) { - setAgentType('claude'); - } else if (selectedProfile.compatibility.codex && !selectedProfile.compatibility.claude) { - setAgentType('codex'); - } - - // Sync active profile to CLI - profileSyncService.setActiveProfile(selectedProfileId).catch(error => { - console.error('[Wizard] Failed to sync active profile to CLI:', error); - }); - } - } - }, [selectedProfileId, allProfiles]); - - // Sync profiles with CLI on component mount and when profiles change - React.useEffect(() => { - const syncProfiles = async () => { - try { - await profileSyncService.bidirectionalSync(allProfiles); - } catch (error) { - console.error('[Wizard] Failed to sync profiles with CLI:', error); - // Continue without sync - profiles work locally - } - }; - - // Sync on mount - syncProfiles(); - - // Set up sync listener for profile changes - const handleSyncEvent = (event: any) => { - if (event.status === 'error') { - console.warn('[Wizard] Profile sync error:', event.error); - } - }; - - profileSyncService.addEventListener(handleSyncEvent); - - return () => { - profileSyncService.removeEventListener(handleSyncEvent); - }; - }, [allProfiles]); - - // Get recent paths for the selected machine - const recentPaths = useMemo(() => { - if (!selectedMachineId) return []; - - const paths: string[] = []; - const pathSet = new Set(); - - // First, add paths from recentMachinePaths (these are the most recent) - recentMachinePaths.forEach(entry => { - if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) { - paths.push(entry.path); - pathSet.add(entry.path); - } - }); - - // Then add paths from sessions if we need more - if (sessions) { - const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = []; - - sessions.forEach(item => { - if (typeof item === 'string') return; // Skip section headers - - const session = item as any; - if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) { - const path = session.metadata.path; - if (!pathSet.has(path)) { - pathSet.add(path); - pathsWithTimestamps.push({ - path, - timestamp: session.updatedAt || session.createdAt - }); - } - } - }); - - // Sort session paths by most recent first and add them - pathsWithTimestamps - .sort((a, b) => b.timestamp - a.timestamp) - .forEach(item => paths.push(item.path)); - } - - return paths; - }, [sessions, selectedMachineId, recentMachinePaths]); - - const currentStepIndex = steps.indexOf(currentStep); - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; - - React.useEffect(() => { - // Guard: if the user changes profiles such that profileConfig is no longer required, - // advance to the next step (or reset to the first step if currentStep is invalid). - if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { - const nextStep = steps[currentStepIndex + 1] ?? steps[0] ?? 'profile'; - if (nextStep !== currentStep) { - setCurrentStep(nextStep); - } - } - }, [currentStep, currentStepIndex, selectedProfileId, steps]); - - // Handler for "Use Profile As-Is" - quick session creation - const handleUseProfileAsIs = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // Get environment variables from profile (no user configuration) - const environmentVariables = getProfileEnvironmentVariables(profile); - - // Complete wizard immediately with profile settings - onComplete({ - sessionType, - profileId: profile.id, - agentType: agentType || (profile.compatibility.claude ? 'claude' : 'codex'), - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - }; - - // Handler for "Edit Profile" - load profile and go to configuration step - const handleEditProfile = (profile: AIBackendProfile) => { - setSelectedProfileId(profile.id); - - // Auto-select agent type based on profile compatibility - if (profile.compatibility.claude && !profile.compatibility.codex) { - setAgentType('claude'); - } else if (profile.compatibility.codex && !profile.compatibility.claude) { - setAgentType('codex'); - } - - // If profile needs configuration, go to profileConfig step - if (profileNeedsConfiguration(profile.id)) { - setCurrentStep('profileConfig'); - } else { - // If no configuration needed, proceed to next step in the normal flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - } - }; - - // Handler for "Create New Profile" - const handleCreateProfile = () => { - Modal.prompt( - 'Create New Profile', - 'Enter a name for your new profile:', - { - defaultValue: 'My Custom Profile', - confirmText: 'Create', - cancelText: 'Cancel' - } - ).then((profileName) => { - if (profileName && profileName.trim()) { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: profileName.trim(), - description: 'Custom AI profile', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, newProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync new profile with CLI:', error); - }); - - // Auto-select the newly created profile - setSelectedProfileId(newProfile.id); - } - }); - }; - - // Handler for "Duplicate Profile" - const handleDuplicateProfile = (profile: AIBackendProfile) => { - Modal.prompt( - 'Duplicate Profile', - `Enter a name for the duplicate of "${profile.name}":`, - { - defaultValue: `${profile.name} (Copy)`, - confirmText: 'Duplicate', - cancelText: 'Cancel' - } - ).then((newName) => { - if (newName && newName.trim()) { - const duplicatedProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: newName.trim(), - description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile', - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = [...currentProfiles, duplicatedProfile]; - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync duplicated profile with CLI:', error); - }); - } - }); - }; - - // Handler for "Delete Profile" - const handleDeleteProfile = (profile: AIBackendProfile) => { - Modal.confirm( - 'Delete Profile', - `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`, - { - confirmText: 'Delete', - destructive: true - } - ).then((confirmed) => { - if (confirmed) { - // Get current profiles from settings - const currentProfiles = storage.getState().settings.profiles || []; - const updatedProfiles = currentProfiles.filter(p => p.id !== profile.id); - - // Persist through settings system - sync.applySettings({ profiles: updatedProfiles }); - - // Sync with CLI - profileSyncService.syncGuiToCli(updatedProfiles).catch(error => { - console.error('[Wizard] Failed to sync profile deletion with CLI:', error); - }); - - // Clear selection if deleted profile was selected - if (selectedProfileId === profile.id) { - setSelectedProfileId(null); - } - } - }); - }; - - // Handler for "Use CLI Environment Variables" - quick session creation with CLI vars - const handleUseCliEnvironmentVariables = () => { - setSelectedProfileId(null); - - // Complete wizard immediately with no profile (rely on CLI environment variables) - onComplete({ - sessionType, - profileId: null, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables: undefined, // Let CLI handle environment variables - }); - }; - - // Handler for "Manual Configuration" - go through normal wizard flow - const handleManualConfiguration = () => { - setSelectedProfileId(null); - - // Proceed to next step in normal wizard flow - const profileIndex = steps.indexOf('profile'); - setCurrentStep(steps[profileIndex + 1]); - }; - - const handleNext = () => { - // Special handling for profileConfig step - skip if profile doesn't need configuration - if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) { - setCurrentStep(steps[currentStepIndex + 1]); - return; - } - - if (isLastStep) { - // Get environment variables from selected profile with proper precedence handling - let environmentVariables: Record | undefined; - if (selectedProfileId) { - const selectedProfile = allProfiles.find(p => p.id === selectedProfileId); - if (selectedProfile) { - // Start with profile environment variables (base configuration) - environmentVariables = getProfileEnvironmentVariables(selectedProfile); - - // Only add user-provided API keys if they're non-empty - // This preserves CLI environment variable precedence when wizard fields are empty - const userApiKeys = profileApiKeys[selectedProfileId]; - if (userApiKeys) { - Object.entries(userApiKeys).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - - // Only add user configurations if they're non-empty - const userConfigs = profileConfigs[selectedProfileId]; - if (userConfigs) { - Object.entries(userConfigs).forEach(([key, value]) => { - // Only override if user provided a non-empty value - if (value && value.trim().length > 0) { - environmentVariables![key] = value; - } - }); - } - } - } - - onComplete({ - sessionType, - profileId: selectedProfileId, - agentType, - permissionMode, - modelMode, - machineId: selectedMachineId, - path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath, - prompt, - environmentVariables, - }); - } else { - setCurrentStep(steps[currentStepIndex + 1]); - } - }; - - const handleBack = () => { - if (isFirstStep) { - onCancel(); - } else { - setCurrentStep(steps[currentStepIndex - 1]); - } - }; - - const canProceed = useMemo(() => { - switch (currentStep) { - case 'profile': - return true; // Always valid (profile can be null for manual config) - case 'profileConfig': - if (!selectedProfileId) return false; - const requiredFields = getProfileRequiredFields(selectedProfileId); - // Profile configuration step is always shown when needed - // Users can leave fields empty to preserve CLI environment variables - return true; - case 'sessionType': - return true; // Always valid - case 'agent': - return true; // Always valid - case 'options': - return true; // Always valid - case 'machine': - return selectedMachineId.length > 0; - case 'path': - return (selectedPath.trim().length > 0) || (showCustomPathInput && customPath.trim().length > 0); - case 'prompt': - return prompt.trim().length > 0; - default: - return false; - } - }, [currentStep, selectedMachineId, selectedPath, prompt, showCustomPathInput, customPath, selectedProfileId, profileApiKeys, profileConfigs, getProfileRequiredFields]); - - const renderStepContent = () => { - switch (currentStep) { - case 'profile': - return ( - - Choose AI Profile - - Select a pre-configured AI profile or set up manually - - - - {builtInProfiles.map((profile) => ( - setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - /> - ))} - - - {profiles.length > 0 && ( - - {profiles.map((profile) => ( - setSelectedProfileId(profile.id)} - onUseAsIs={() => handleUseProfileAsIs(profile)} - onEdit={() => handleEditProfile(profile)} - onDuplicate={() => handleDuplicateProfile(profile)} - onDelete={() => handleDeleteProfile(profile)} - showManagementActions={true} - /> - ))} - - )} - - {/* Create New Profile Button */} - - - - - - - - Create New Profile - - - Set up a custom AI backend configuration - - - - - - - setSelectedProfileId(null)} - onUseCliVars={() => handleUseCliEnvironmentVariables()} - onConfigureManually={() => handleManualConfiguration()} - /> - - - - - 💡 **Profile Selection Options:** - - - • **Use As-Is**: Quick session creation with current profile settings - - - • **Edit**: Configure API keys and settings before session creation - - - • **Manual**: Use CLI environment variables without profile configuration - - - - ); - - case 'profileConfig': - if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) { - // No profile configuration needed; navigation effect will auto-advance. - return null; - } - - return ( - - Configure {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Profile'} - - Enter your API keys and configuration details - - - - {getProfileRequiredFields(selectedProfileId).map((field) => ( - - - {field.label} - - { - if (field.isPassword) { - // API key - setProfileApiKeys(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record || {}), - [field.key]: text - } - })); - } else { - // Configuration field - setProfileConfigs(prev => ({ - ...prev, - [selectedProfileId!]: { - ...(prev[selectedProfileId!] as Record || {}), - [field.key]: text - } - })); - } - }} - secureTextEntry={field.isPassword} - autoCapitalize="none" - autoCorrect={false} - returnKeyType="next" - /> - - ))} - - - - - 💡 Tip: Your API keys are only used for this session and are not stored permanently - - - 📝 Note: Leave fields empty to use CLI environment variables if they're already set - - - - ); - - case 'sessionType': - return ( - - Choose AI Backend & Session Type - - Select your AI provider and how you want to work with your code - - - - {[ - { - id: 'anthropic', - name: 'Anthropic Claude', - description: 'Advanced reasoning and coding assistant', - icon: 'cube-outline', - agentType: 'claude' as const - }, - { - id: 'openai', - name: 'OpenAI GPT-5', - description: 'Specialized coding assistant', - icon: 'code-outline', - agentType: 'codex' as const - }, - { - id: 'deepseek', - name: 'DeepSeek Reasoner', - description: 'Advanced reasoning model', - icon: 'analytics-outline', - agentType: 'claude' as const - }, - { - id: 'zai', - name: 'Z.ai', - description: 'AI assistant for development', - icon: 'flash-outline', - agentType: 'claude' as const - }, - { - id: 'microsoft', - name: 'Microsoft Azure', - description: 'Enterprise AI services', - icon: 'cloud-outline', - agentType: 'codex' as const - }, - ].map((backend) => ( - - } - rightElement={agentType === backend.agentType ? ( - - ) : null} - onPress={() => setAgentType(backend.agentType)} - showChevron={false} - selected={agentType === backend.agentType} - showDivider={true} - /> - ))} - - - - - ); - - case 'agent': - return ( - - Choose AI Agent - - Select which AI assistant you want to use - - - {selectedProfileId && ( - - - Profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - - - {allProfiles.find(p => p.id === selectedProfileId)?.description} - - - )} - - p.id === selectedProfileId)?.compatibility.claude && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude) { - setAgentType('claude'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude)} - > - - C - - - Claude - - Anthropic's AI assistant, great for coding and analysis - - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && ( - - Not compatible with selected profile - - )} - - {agentType === 'claude' && ( - - )} - - - p.id === selectedProfileId)?.compatibility.codex && { - opacity: 0.5, - backgroundColor: theme.colors.surface - } - ]} - onPress={() => { - if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex) { - setAgentType('codex'); - } - }} - disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex)} - > - - X - - - Codex - - OpenAI's specialized coding assistant - - {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && ( - - Not compatible with selected profile - - )} - - {agentType === 'codex' && ( - - )} - - - ); - - case 'options': - return ( - - Agent Options - - Configure how the AI agent should behave - - - {selectedProfileId && ( - - - Using profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'} - - - Environment variables will be applied automatically - - - )} - - {([ - { value: 'default', label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits', label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan', label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions', label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] as const).map((option, index, array) => ( - - } - rightElement={permissionMode === option.value ? ( - - ) : null} - onPress={() => setPermissionMode(option.value as PermissionMode)} - showChevron={false} - selected={permissionMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - - {(agentType === 'claude' ? [ - { value: 'default', label: 'Default', description: 'Balanced performance', icon: 'cube-outline' }, - { value: 'adaptiveUsage', label: 'Adaptive Usage', description: 'Automatically choose model', icon: 'analytics-outline' }, - { value: 'sonnet', label: 'Sonnet', description: 'Fast and efficient', icon: 'speedometer-outline' }, - { value: 'opus', label: 'Opus', description: 'Most capable model', icon: 'diamond-outline' }, - ] as const : [ - { value: 'gpt-5-codex-high', label: 'GPT-5 Codex High', description: 'Best for complex coding', icon: 'diamond-outline' }, - { value: 'gpt-5-codex-medium', label: 'GPT-5 Codex Medium', description: 'Balanced coding assistance', icon: 'cube-outline' }, - { value: 'gpt-5-codex-low', label: 'GPT-5 Codex Low', description: 'Fast coding help', icon: 'speedometer-outline' }, - ] as const).map((option, index, array) => ( - - } - rightElement={modelMode === option.value ? ( - - ) : null} - onPress={() => setModelMode(option.value as ModelMode)} - showChevron={false} - selected={modelMode === option.value} - showDivider={index < array.length - 1} - /> - ))} - - - ); - - case 'machine': - return ( - - Select Machine - - Choose which machine to run your session on - - - - {machines.map((machine, index) => ( - - } - rightElement={selectedMachineId === machine.id ? ( - - ) : null} - onPress={() => { - setSelectedMachineId(machine.id); - // Update path when machine changes - const homeDir = machine.metadata?.homeDir || '/home'; - setSelectedPath(homeDir); - }} - showChevron={false} - selected={selectedMachineId === machine.id} - showDivider={index < machines.length - 1} - /> - ))} - - - ); - - case 'path': - return ( - - Working Directory - - Choose the directory to work in - - - {/* Recent Paths */} - {recentPaths.length > 0 && ( - - {recentPaths.map((path, index) => ( - - } - rightElement={selectedPath === path && !showCustomPathInput ? ( - - ) : null} - onPress={() => { - setSelectedPath(path); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === path && !showCustomPathInput} - showDivider={index < recentPaths.length - 1} - /> - ))} - - )} - - {/* Common Directories */} - - {(() => { - const machine = machines.find(m => m.id === selectedMachineId); - const homeDir = machine?.metadata?.homeDir || '/home'; - const pathOptions = [ - { value: homeDir, label: homeDir, description: 'Home directory' }, - { value: `${homeDir}/projects`, label: `${homeDir}/projects`, description: 'Projects folder' }, - { value: `${homeDir}/Documents`, label: `${homeDir}/Documents`, description: 'Documents folder' }, - { value: `${homeDir}/Desktop`, label: `${homeDir}/Desktop`, description: 'Desktop folder' }, - ]; - return pathOptions.map((option, index) => ( - - } - rightElement={selectedPath === option.value && !showCustomPathInput ? ( - - ) : null} - onPress={() => { - setSelectedPath(option.value); - setShowCustomPathInput(false); - }} - showChevron={false} - selected={selectedPath === option.value && !showCustomPathInput} - showDivider={index < pathOptions.length - 1} - /> - )); - })()} - - - {/* Custom Path Option */} - - - } - rightElement={showCustomPathInput ? ( - - ) : null} - onPress={() => setShowCustomPathInput(true)} - showChevron={false} - selected={showCustomPathInput} - showDivider={false} - /> - {showCustomPathInput && ( - - - - )} - - - ); - - case 'prompt': - return ( - - Initial Message - - Write your first message to the AI agent - - - - - ); - - default: - return null; - } - }; - - return ( - - - New Session - - - - - - - {steps.map((step, index) => ( - - ))} - - - - {renderStepContent()} - - - - - - {isFirstStep ? 'Cancel' : 'Back'} - - - - - - {isLastStep ? 'Create Session' : 'Next'} - - - - - ); -} \ No newline at end of file diff --git a/sources/components/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx new file mode 100644 index 000000000..9f5cd1bd0 --- /dev/null +++ b/sources/components/newSession/DirectorySelector.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; + +export interface DirectorySelectorProps { + machineHomeDir?: string | null; + selectedPath: string; + recentPaths: string[]; + favoritePaths?: string[]; + onSelect: (path: string) => void; + onToggleFavorite?: (path: string) => void; + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + allSectionTitle?: string; + noItemsMessage?: string; +} + +export function DirectorySelector({ + machineHomeDir, + selectedPath, + recentPaths, + favoritePaths = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + searchPlaceholder = 'Type to filter directories...', + recentSectionTitle = 'Recent Directories', + favoritesSectionTitle = 'Favorite Directories', + allSectionTitle = 'All Directories', + noItemsMessage = 'No recent directories', +}: DirectorySelectorProps) { + const { theme } = useUnistyles(); + const homeDir = machineHomeDir || undefined; + + const allPaths = React.useMemo(() => { + const seen = new Set(); + const ordered: string[] = []; + for (const p of [...favoritePaths, ...recentPaths]) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoritePaths, recentPaths]); + + return ( + + config={{ + getItemId: (path) => path, + getItemTitle: (path) => formatPathRelativeToHome(path, homeDir), + getItemSubtitle: undefined, + getItemIcon: () => ( + + ), + getRecentItemIcon: () => ( + + ), + getFavoriteItemIcon: (path) => ( + + ), + canRemoveFavorite: (path) => path !== homeDir, + formatForDisplay: (path) => formatPathRelativeToHome(path, homeDir), + parseFromDisplay: (text) => { + const trimmed = text.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('/')) return trimmed; + if (homeDir) return resolveAbsolutePath(trimmed, homeDir); + return null; + }, + filterItem: (path, searchText) => { + const displayPath = formatPathRelativeToHome(path, homeDir); + return displayPath.toLowerCase().includes(searchText.toLowerCase()); + }, + searchPlaceholder, + recentSectionTitle, + favoritesSectionTitle, + allSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + showAll: favoritePaths.length > 0, + allowCustomInput: true, + }} + items={allPaths} + recentItems={recentPaths} + favoriteItems={favoritePaths} + selectedItem={selectedPath || null} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + /> + ); +} diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx new file mode 100644 index 000000000..7d5286345 --- /dev/null +++ b/sources/components/newSession/MachineSelector.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { SearchableListSelector } from '@/components/SearchableListSelector'; +import type { Machine } from '@/sync/storageTypes'; +import { isMachineOnline } from '@/utils/machineUtils'; + +export interface MachineSelectorProps { + machines: Machine[]; + selectedMachine: Machine | null; + recentMachines?: Machine[]; + favoriteMachines?: Machine[]; + onSelect: (machine: Machine) => void; + onToggleFavorite?: (machine: Machine) => void; + showFavorites?: boolean; + showRecent?: boolean; + showSearch?: boolean; + compactItems?: boolean; + searchPlaceholder?: string; + recentSectionTitle?: string; + favoritesSectionTitle?: string; + noItemsMessage?: string; +} + +export function MachineSelector({ + machines, + selectedMachine, + recentMachines = [], + favoriteMachines = [], + onSelect, + onToggleFavorite, + showFavorites = true, + showRecent = true, + showSearch = true, + compactItems = true, + searchPlaceholder = 'Type to filter machines...', + recentSectionTitle = 'Recent Machines', + favoritesSectionTitle = 'Favorite Machines', + noItemsMessage = 'No machines available', +}: MachineSelectorProps) { + const { theme } = useUnistyles(); + + return ( + + config={{ + getItemId: (machine) => machine.id, + getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + getItemSubtitle: undefined, + getItemIcon: () => ( + + ), + getRecentItemIcon: () => ( + + ), + getItemStatus: (machine) => { + const offline = !isMachineOnline(machine); + return { + text: offline ? 'offline' : 'online', + color: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected, + isPulsing: !offline, + }; + }, + formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id, + parseFromDisplay: (text) => { + return machines.find(m => + m.metadata?.displayName === text || m.metadata?.host === text || m.id === text + ) || null; + }, + filterItem: (machine, searchText) => { + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const search = searchText.toLowerCase(); + return displayName.includes(search) || host.includes(search); + }, + searchPlaceholder, + recentSectionTitle, + favoritesSectionTitle, + noItemsMessage, + showFavorites, + showRecent, + showSearch, + allowCustomInput: false, + compactItems, + }} + items={machines} + recentItems={recentMachines} + favoriteItems={favoriteMachines} + selectedItem={selectedMachine} + onSelect={onSelect} + onToggleFavorite={onToggleFavorite} + /> + ); +} + From 8b37d10f1002a4e088d49da96e7fcb5f8d811aac Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 12:08:42 +0100 Subject: [PATCH 07/72] refactor(pickers): unify selector layout and selection UI --- sources/app/(app)/_layout.tsx | 3 +- sources/app/(app)/new/pick/machine.tsx | 19 +- sources/app/(app)/new/pick/path.tsx | 161 ++++- sources/app/(app)/settings/features.tsx | 32 + sources/app/(app)/settings/voice/language.tsx | 56 +- sources/components/SearchHeader.tsx | 90 +++ sources/components/SearchableListSelector.tsx | 614 ++++-------------- .../components/newSession/MachineSelector.tsx | 11 +- sources/sync/settings.spec.ts | 31 +- sources/sync/settings.ts | 8 + sources/text/_default.ts | 8 + sources/text/translations/ca.ts | 8 + sources/text/translations/es.ts | 8 + sources/text/translations/it.ts | 8 + sources/text/translations/ja.ts | 8 + sources/text/translations/pl.ts | 8 + sources/text/translations/pt.ts | 8 + sources/text/translations/ru.ts | 8 + sources/text/translations/zh-Hans.ts | 8 + 19 files changed, 538 insertions(+), 559 deletions(-) create mode 100644 sources/components/SearchHeader.tsx diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 1c5edb274..97735e214 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -329,7 +329,8 @@ export default function RootLayout() { name="new/index" options={{ headerTitle: t('newSession.title'), - headerBackTitle: t('common.back'), + headerShown: true, + headerBackTitle: t('common.cancel'), presentation: 'modal', }} /> diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index f8d89d57e..c327ed3c2 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -3,7 +3,7 @@ import { View, Text } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions } from '@/sync/storage'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; @@ -36,6 +36,9 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -117,11 +120,19 @@ export default function MachinePickerScreen() { machines={machines} selectedMachine={selectedMachine} recentMachines={recentMachines} - favoriteMachines={[]} + favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} onSelect={handleSelectMachine} - showFavorites={false} + showFavorites={useMachinePickerFavorites} + showSearch={useMachinePickerSearch} + onToggleFavorite={useMachinePickerFavorites ? ((machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + setFavoriteMachines(isInFavorites + ? favoriteMachines.filter(id => id !== machine.id) + : [...favoriteMachines, machine.id] + ); + }) : undefined} /> ); -} \ No newline at end of file +} diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index 08bc93cca..5db8062ff 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -3,7 +3,7 @@ import { View, Text, Pressable } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { Typography } from '@/constants/Typography'; -import { useAllMachines, useSessions, useSetting } from '@/sync/storage'; +import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; @@ -12,6 +12,9 @@ import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { layout } from '@/components/layout'; import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; +import { SearchHeader } from '@/components/SearchHeader'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; const stylesheet = StyleSheet.create((theme) => ({ emptyContainer: { @@ -59,9 +62,13 @@ export default function PathPickerScreen() { const machines = useAllMachines(); const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); + const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); + const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); + const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const inputRef = useRef(null); const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [searchQuery, setSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -138,6 +145,61 @@ export default function PathPickerScreen() { ]; }, [machine]); + const favoritePaths = useMemo(() => { + if (!useDirectoryPickerFavorites || !machine) return []; + const homeDir = machine.metadata?.homeDir || '/home'; + const paths = [homeDir, ...favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir))]; + const seen = new Set(); + const ordered: string[] = []; + for (const p of paths) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoriteDirectories, machine, useDirectoryPickerFavorites]); + + const filteredRecentPaths = useMemo(() => { + const base = useDirectoryPickerFavorites + ? recentPaths.filter((p) => !favoritePaths.includes(p)) + : recentPaths; + if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, recentPaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + + const filteredSuggestedPaths = useMemo(() => { + const base = useDirectoryPickerFavorites + ? suggestedPaths.filter((p) => !favoritePaths.includes(p)) + : suggestedPaths; + if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, suggestedPaths, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + + const filteredFavoritePaths = useMemo(() => { + if (!useDirectoryPickerFavorites) return []; + if (!useDirectoryPickerSearch || !searchQuery.trim()) return favoritePaths; + const query = searchQuery.toLowerCase(); + return favoritePaths.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + + const toggleFavorite = React.useCallback((absolutePath: string) => { + if (!machine) return; + const homeDir = machine.metadata?.homeDir || '/home'; + if (absolutePath === homeDir) return; + + const relativePath = formatPathRelativeToHome(absolutePath, homeDir); + const resolved = resolveAbsolutePath(relativePath, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); + + setFavoriteDirectories(isInFavorites + ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) + : [...favoriteDirectories, relativePath] + ); + }, [favoriteDirectories, machine, setFavoriteDirectories]); + if (!machine) { return ( <> @@ -199,7 +261,14 @@ export default function PathPickerScreen() { ) }} /> - + + {useDirectoryPickerSearch && ( + + )} @@ -217,11 +286,54 @@ export default function PathPickerScreen() { - {recentPaths.length > 0 && ( + {useDirectoryPickerFavorites && filteredFavoritePaths.length > 0 && ( + + {filteredFavoritePaths.map((path, index) => { + const isSelected = customPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + const isHome = machine?.metadata?.homeDir ? path === machine.metadata.homeDir : false; + const favoriteIconName = isHome ? 'home-outline' : 'star'; + return ( + } + onPress={() => { + setCustomPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={useDirectoryPickerFavorites ? ( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + ) : null} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length > 0 && ( - {recentPaths.map((path, index) => { + {filteredRecentPaths.map((path, index) => { const isSelected = customPath.trim() === path; - const isLast = index === recentPaths.length - 1; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); return ( { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + ) : null} showDivider={!isLast} /> ); @@ -241,11 +368,12 @@ export default function PathPickerScreen() { )} - {recentPaths.length === 0 && suggestedPaths.length > 0 && ( + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( - {suggestedPaths.map((path, index) => { + {filteredSuggestedPaths.map((path, index) => { const isSelected = customPath.trim() === path; - const isLast = index === suggestedPaths.length - 1; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); return ( { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + ) : null} showDivider={!isLast} /> ); @@ -268,4 +411,4 @@ export default function PathPickerScreen() { ); -} \ No newline at end of file +} diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index 9d7e96411..b01619e6c 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -15,6 +15,10 @@ export default function FeaturesSettingsScreen() { const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [useMachinePickerFavorites, setUseMachinePickerFavorites] = useSettingMutable('useMachinePickerFavorites'); + const [useDirectoryPickerSearch, setUseDirectoryPickerSearch] = useSettingMutable('useDirectoryPickerSearch'); + const [useDirectoryPickerFavorites, setUseDirectoryPickerFavorites] = useSettingMutable('useDirectoryPickerFavorites'); return ( @@ -73,6 +77,34 @@ export default function FeaturesSettingsScreen() { } showChevron={false} /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> + } + rightElement={} + showChevron={false} + /> - {/* Search Header */} - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')} - style={{ marginLeft: 8 }} - /> - )} - - + {/* Language List */} void; + placeholder: string; + containerStyle?: StyleProp; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + autoCorrect?: boolean; +} + +const INPUT_BORDER_RADIUS = 10; + +const stylesheet = StyleSheet.create((theme) => ({ + container: { + backgroundColor: theme.colors.surface, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.input.background, + borderRadius: INPUT_BORDER_RADIUS, + paddingHorizontal: 12, + paddingVertical: 8, + }, + textInput: { + flex: 1, + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + paddingVertical: 0, + }, + clearIcon: { + marginLeft: 8, + }, +})); + +export function SearchHeader({ + value, + onChangeText, + placeholder, + containerStyle, + autoCapitalize = 'none', + autoCorrect = false, +}: SearchHeaderProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + + return ( + + + + + {value.trim().length > 0 && ( + onChangeText('')} + style={styles.clearIcon} + /> + )} + + + ); +} + diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index c81ba79e2..ca4f03d83 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -5,10 +5,9 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; -import { MultiTextInput } from '@/components/MultiTextInput'; -import { Modal } from '@/modal'; import { t } from '@/text'; import { StatusDot } from '@/components/StatusDot'; +import { SearchHeader } from '@/components/SearchHeader'; /** * Configuration object for customizing the SearchableListSelector component. @@ -40,12 +39,14 @@ export interface SelectorConfig { searchPlaceholder: string; recentSectionTitle: string; favoritesSectionTitle: string; + allSectionTitle?: string; noItemsMessage: string; // Optional features showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + showAll?: boolean; allowCustomInput?: boolean; // Item subtitle override (for recent items, e.g., "Recently used") @@ -59,9 +60,6 @@ export interface SelectorConfig { // Check if a favorite item can be removed (e.g., home directory can't be removed) canRemoveFavorite?: (item: T) => boolean; - - // Visual customization - compactItems?: boolean; // Use reduced padding for more compact lists (default: false) } /** @@ -75,140 +73,25 @@ export interface SearchableListSelectorProps { selectedItem: T | null; onSelect: (item: T) => void; onToggleFavorite?: (item: T) => void; - context?: any; // Additional context (e.g., homeDir for paths) + context?: any; // Additional context (e.g., homeDir for paths) // Optional overrides showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; - - // Controlled collapse states (optional - defaults to uncontrolled internal state) - collapsedSections?: { - recent?: boolean; - favorites?: boolean; - all?: boolean; - }; - onCollapsedSectionsChange?: (collapsed: { recent?: boolean; favorites?: boolean; all?: boolean }) => void; } const RECENT_ITEMS_DEFAULT_VISIBLE = 5; -// Spacing constants (match existing codebase patterns) -const STATUS_DOT_TEXT_GAP = 4; // Gap between StatusDot and text (used throughout app for status indicators) -const ITEM_SPACING_GAP = 4; // Gap between elements and spacing between items (compact) -const COMPACT_ITEM_PADDING = 4; // Vertical padding for compact lists -// Border radius constants (consistent rounding) -const INPUT_BORDER_RADIUS = 10; // Input field and containers -const BUTTON_BORDER_RADIUS = 8; // Buttons and actionable elements -// ITEM_BORDER_RADIUS must match ItemGroup's contentContainer borderRadius to prevent clipping -// ItemGroup uses Platform.select({ ios: 10, default: 16 }) -const ITEM_BORDER_RADIUS = Platform.select({ ios: 10, default: 16 }); // Match ItemGroup container radius +const STATUS_DOT_TEXT_GAP = 4; +const ITEM_SPACING_GAP = 4; const stylesheet = StyleSheet.create((theme) => ({ - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 16, - paddingBottom: 8, - }, - inputWrapper: { - flex: 1, - backgroundColor: theme.colors.input.background, - borderRadius: INPUT_BORDER_RADIUS, - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, - inputInner: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - }, - inputField: { - flex: 1, - }, - clearButton: { - width: 20, - height: 20, - borderRadius: INPUT_BORDER_RADIUS, - backgroundColor: theme.colors.textSecondary, - justifyContent: 'center', - alignItems: 'center', - marginLeft: 8, - }, - favoriteButton: { - borderRadius: BUTTON_BORDER_RADIUS, - padding: 8, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 10, - }, - sectionHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.text, - ...Typography.default(), - }, - selectedItemStyle: { - borderWidth: 2, - borderColor: theme.colors.button.primary.tint, - borderRadius: ITEM_BORDER_RADIUS, - }, - compactItemStyle: { - paddingVertical: COMPACT_ITEM_PADDING, - minHeight: 0, // Override Item's default minHeight (44-56px) for compact mode - }, - itemBackground: { - backgroundColor: theme.colors.input.background, - borderRadius: ITEM_BORDER_RADIUS, - marginBottom: ITEM_SPACING_GAP, - }, showMoreTitle: { textAlign: 'center', - color: theme.colors.button.primary.tint, + color: theme.colors.textLink, }, })); -/** - * Generic searchable list selector component with recent items, favorites, and filtering. - * - * Pattern extracted from Working Directory section in new session wizard. - * Supports any data type through TypeScript generics and configuration object. - * - * Features: - * - Search/filter with smart skip (doesn't filter when input matches selection) - * - Recent items with "Show More" toggle - * - Favorites with add/remove - * - Collapsible sections - * - Custom input support (optional) - * - * @example - * // For machines: - * - * config={machineConfig} - * items={machines} - * recentItems={recentMachines} - * favoriteItems={favoriteMachines} - * selectedItem={selectedMachine} - * onSelect={(machine) => setSelectedMachine(machine)} - * onToggleFavorite={(machine) => toggleFavorite(machine.id)} - * /> - * - * // For paths: - * - * config={pathConfig} - * items={allPaths} - * recentItems={recentPaths} - * favoriteItems={favoritePaths} - * selectedItem={selectedPath} - * onSelect={(path) => setSelectedPath(path)} - * onToggleFavorite={(path) => toggleFavorite(path)} - * context={{ homeDir }} - * /> - */ export function SearchableListSelector(props: SearchableListSelectorProps) { const { theme } = useUnistyles(); const styles = stylesheet; @@ -224,167 +107,43 @@ export function SearchableListSelector(props: SearchableListSelectorProps) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, - collapsedSections, - onCollapsedSectionsChange, } = props; + const showAll = config.showAll !== false; - // Use controlled state if provided, otherwise use internal state - const isControlled = collapsedSections !== undefined && onCollapsedSectionsChange !== undefined; - - // State management (matches Working Directory pattern) - const [inputText, setInputText] = React.useState(() => { - if (selectedItem) { - return config.formatForDisplay(selectedItem, context); - } - return ''; - }); + // Search query is intentionally decoupled from the selected value so pickers don't start pre-filtered. + const [inputText, setInputText] = React.useState(''); const [showAllRecent, setShowAllRecent] = React.useState(false); - // Internal uncontrolled state (used when not controlled from parent) - const [internalShowRecentSection, setInternalShowRecentSection] = React.useState(false); - const [internalShowFavoritesSection, setInternalShowFavoritesSection] = React.useState(false); - const [internalShowAllItemsSection, setInternalShowAllItemsSection] = React.useState(true); - - // Use controlled or uncontrolled state - const showRecentSection = isControlled ? !collapsedSections?.recent : internalShowRecentSection; - const showFavoritesSection = isControlled ? !collapsedSections?.favorites : internalShowFavoritesSection; - const showAllItemsSection = isControlled ? !collapsedSections?.all : internalShowAllItemsSection; - - // Toggle handlers that work for both controlled and uncontrolled - const toggleRecentSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, recent: !collapsedSections?.recent }); - } else { - setInternalShowRecentSection(!internalShowRecentSection); - } - }; - - const toggleFavoritesSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, favorites: !collapsedSections?.favorites }); - } else { - setInternalShowFavoritesSection(!internalShowFavoritesSection); - } - }; - - const toggleAllItemsSection = () => { - if (isControlled) { - onCollapsedSectionsChange?.({ ...collapsedSections, all: !collapsedSections?.all }); - } else { - setInternalShowAllItemsSection(!internalShowAllItemsSection); - } - }; - - // Track if user is actively typing (vs clicking from list) to control expansion behavior - const isUserTyping = React.useRef(false); - - // Update input text when selected item changes externally - React.useEffect(() => { - if (selectedItem && !isUserTyping.current) { - setInputText(config.formatForDisplay(selectedItem, context)); - } - }, [selectedItem, config, context]); - - // Filtering logic with smart skip (matches Working Directory pattern) - const filteredRecentItems = React.useMemo(() => { - if (!inputText.trim()) return recentItems; - - // Don't filter if text matches the currently selected item (user clicked from list) - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return recentItems; // Show all items, don't filter - } - - // User is typing - filter the list - return recentItems.filter(item => config.filterItem(item, inputText, context)); - }, [recentItems, inputText, selectedItem, config, context]); + const favoriteIds = React.useMemo(() => { + return new Set(favoriteItems.map((item) => config.getItemId(item))); + }, [favoriteItems, config]); const filteredFavoriteItems = React.useMemo(() => { if (!inputText.trim()) return favoriteItems; + return favoriteItems.filter((item) => config.filterItem(item, inputText, context)); + }, [favoriteItems, inputText, config, context]); - const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null; - if (selectedDisplayText && inputText === selectedDisplayText) { - return favoriteItems; // Show all favorites, don't filter - } - - // Don't filter if text matches a favorite (user clicked from list) - if (favoriteItems.some(item => config.formatForDisplay(item, context) === inputText)) { - return favoriteItems; // Show all favorites, don't filter - } - - return favoriteItems.filter(item => config.filterItem(item, inputText, context)); - }, [favoriteItems, inputText, selectedItem, config, context]); - - // Check if current input can be added to favorites - const canAddToFavorites = React.useMemo(() => { - if (!onToggleFavorite || !inputText.trim()) return false; - - // Parse input to see if it's a valid item - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (!parsedItem) return false; + const filteredRecentItems = React.useMemo(() => { + const base = recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + if (!inputText.trim()) return base; + return base.filter((item) => config.filterItem(item, inputText, context)); + }, [recentItems, favoriteIds, inputText, config, context]); - // Check if already in favorites - const parsedId = config.getItemId(parsedItem); - return !favoriteItems.some(fav => config.getItemId(fav) === parsedId); - }, [inputText, favoriteItems, config, context, onToggleFavorite]); + const filteredItems = React.useMemo(() => { + const base = items.filter((item) => !favoriteIds.has(config.getItemId(item))); + if (!inputText.trim()) return base; + return base.filter((item) => config.filterItem(item, inputText, context)); + }, [items, favoriteIds, inputText, config, context]); - // Handle input text change const handleInputChange = (text: string) => { - isUserTyping.current = true; // User is actively typing setInputText(text); - // If allowCustomInput, try to parse and select if (config.allowCustomInput && text.trim()) { const parsedItem = config.parseFromDisplay(text.trim(), context); - if (parsedItem) { - onSelect(parsedItem); - } + if (parsedItem) onSelect(parsedItem); } }; - // Handle item selection from list - const handleSelectItem = (item: T) => { - isUserTyping.current = false; // User clicked from list - setInputText(config.formatForDisplay(item, context)); - onSelect(item); - }; - - // Handle clear button - const handleClear = () => { - isUserTyping.current = false; - setInputText(''); - // Don't clear selection - just clear input - }; - - // Handle add to favorites - const handleAddToFavorites = () => { - if (!canAddToFavorites || !onToggleFavorite) return; - - const parsedItem = config.parseFromDisplay(inputText.trim(), context); - if (parsedItem) { - onToggleFavorite(parsedItem); - } - }; - - // Handle remove from favorites - const handleRemoveFavorite = (item: T) => { - if (!onToggleFavorite) return; - - Modal.alert( - 'Remove Favorite', - `Remove "${config.getItemTitle(item)}" from ${config.favoritesSectionTitle.toLowerCase()}?`, - [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: 'Remove', - style: 'destructive', - onPress: () => onToggleFavorite(item) - } - ] - ); - }; - - // Render status with StatusDot (DRY helper - matches Item.tsx detail style) const renderStatus = (status: { text: string; color: string; dotColor: string; isPulsing?: boolean } | null | undefined) => { if (!status) return null; return ( @@ -394,22 +153,49 @@ export function SearchableListSelector(props: SearchableListSelectorProps) isPulsing={status.isPulsing} size={6} /> - + {status.text} ); }; - // Render individual item (for recent items) - const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false) => { + const renderFavoriteToggle = (item: T, isFavorite: boolean) => { + if (!showFavorites || !onToggleFavorite) return null; + + const canRemove = config.canRemoveFavorite?.(item) ?? true; + const disabled = isFavorite && !canRemove; + const color = isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary; + + return ( + { + e.stopPropagation(); + if (disabled) return; + onToggleFavorite(item); + }} + > + + + ); + }; + + const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false, forFavorite = false) => { const itemId = config.getItemId(item); const title = config.getItemTitle(item); const subtitle = forRecent && config.getRecentItemSubtitle @@ -417,8 +203,11 @@ export function SearchableListSelector(props: SearchableListSelectorProps) : config.getItemSubtitle?.(item); const icon = forRecent && config.getRecentItemIcon ? config.getRecentItemIcon(item) - : config.getItemIcon(item); + : forFavorite && config.getFavoriteItemIcon + ? config.getFavoriteItemIcon(item) + : config.getItemIcon(item); const status = config.getItemStatus?.(item, theme); + const isFavorite = favoriteIds.has(itemId) || forFavorite; return ( (props: SearchableListSelectorProps) subtitle={subtitle} subtitleLines={0} leftElement={icon} - rightElement={ + rightElement={( {renderStatus(status)} + {renderFavoriteToggle(item, isFavorite)} {isSelected && ( )} - } - onPress={() => handleSelectItem(item)} + )} + onPress={() => onSelect(item)} showChevron={false} selected={isSelected} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} /> ); }; - // "Show More" logic (matches Working Directory pattern) - const itemsToShow = (inputText.trim() && isUserTyping.current) || showAllRecent + const showAllRecentItems = showAllRecent || inputText.trim().length > 0; + const recentItemsToShow = showAllRecentItems ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); return ( <> - {/* Search Input */} {showSearch && ( - - - - - - - {inputText.trim() && ( - ([ - styles.clearButton, - { opacity: pressed ? 0.6 : 0.8 } - ])} - > - - - )} - - - {showFavorites && onToggleFavorite && ( - ([ - styles.favoriteButton, - { - backgroundColor: canAddToFavorites - ? theme.colors.button.primary.background - : theme.colors.divider, - opacity: pressed ? 0.7 : 1, - } - ])} - > - - - )} - + )} - {/* Recent Items Section */} {showRecent && filteredRecentItems.length > 0 && ( - <> - - {config.recentSectionTitle} - + {recentItemsToShow.map((item, index, arr) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === arr.length - 1; + + const showDivider = !isLast || + (!inputText.trim() && + !showAllRecent && + filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); + + return renderItem(item, isSelected, isLast, showDivider, true, false); + })} + + {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && ( + setShowAllRecent(!showAllRecent)} + showChevron={false} + showDivider={false} + titleStyle={styles.showMoreTitle} /> - - - {showRecentSection && ( - - {itemsToShow.map((item, index, arr) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === arr.length - 1; - - // Override divider logic for "Show More" button - const showDivider = !isLast || - (!(inputText.trim() && isUserTyping.current) && - !showAllRecent && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); - - return renderItem(item, isSelected, isLast, showDivider, true); - })} - - {/* Show More Button */} - {!(inputText.trim() && isUserTyping.current) && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && ( - setShowAllRecent(!showAllRecent)} - showChevron={false} - showDivider={false} - titleStyle={styles.showMoreTitle} - /> - )} - )} - + )} - {/* Favorites Section */} {showFavorites && filteredFavoriteItems.length > 0 && ( - <> - - {config.favoritesSectionTitle} - - - - {showFavoritesSection && ( - - {filteredFavoriteItems.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === filteredFavoriteItems.length - 1; - - const title = config.getItemTitle(item); - const subtitle = config.getItemSubtitle?.(item); - const icon = config.getFavoriteItemIcon?.(item) || config.getItemIcon(item); - const status = config.getItemStatus?.(item, theme); - const canRemove = config.canRemoveFavorite?.(item) ?? true; - - return ( - - {renderStatus(status)} - {isSelected && ( - - )} - {onToggleFavorite && canRemove && ( - { - e.stopPropagation(); - handleRemoveFavorite(item); - }} - > - - - )} - - } - onPress={() => handleSelectItem(item)} - showChevron={false} - selected={isSelected} - showDivider={!isLast} - style={[ - styles.itemBackground, - config.compactItems ? styles.compactItemStyle : undefined, - isSelected ? styles.selectedItemStyle : undefined - ]} - /> - ); - })} - - )} - + + {filteredFavoriteItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredFavoriteItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, true); + })} + )} - {/* All Items Section - always shown when items provided */} - {items.length > 0 && ( - <> - - - {config.recentSectionTitle.replace('Recent ', 'All ')} - - - - - {showAllItemsSection && ( - - {items.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === items.length - 1; - - return renderItem(item, isSelected, isLast, !isLast, false); - })} - - )} - + {showAll && filteredItems.length > 0 && ( + + {filteredItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, false); + })} + )} ); diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 7d5286345..710816564 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -15,10 +15,10 @@ export interface MachineSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; - compactItems?: boolean; searchPlaceholder?: string; recentSectionTitle?: string; favoritesSectionTitle?: string; + allSectionTitle?: string; noItemsMessage?: string; } @@ -32,10 +32,10 @@ export function MachineSelector({ showFavorites = true, showRecent = true, showSearch = true, - compactItems = true, searchPlaceholder = 'Type to filter machines...', recentSectionTitle = 'Recent Machines', favoritesSectionTitle = 'Favorite Machines', + allSectionTitle = 'All Machines', noItemsMessage = 'No machines available', }: MachineSelectorProps) { const { theme } = useUnistyles(); @@ -49,14 +49,14 @@ export function MachineSelector({ getItemIcon: () => ( ), getRecentItemIcon: () => ( ), @@ -84,12 +84,12 @@ export function MachineSelector({ searchPlaceholder, recentSectionTitle, favoritesSectionTitle, + allSectionTitle, noItemsMessage, showFavorites, showRecent, showSearch, allowCustomInput: false, - compactItems, }} items={machines} recentItems={recentMachines} @@ -100,4 +100,3 @@ export function MachineSelector({ /> ); } - diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index f6942f5b0..d29db73e9 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -105,6 +105,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -140,6 +144,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -175,6 +183,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -212,6 +224,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -254,6 +270,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -305,6 +325,10 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -366,7 +390,13 @@ describe('settings', () => { analyticsOptOut: false, inferenceOpenAIKey: null, experiments: false, + useProfiles: false, alwaysShowContextSize: false, + useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, @@ -385,7 +415,6 @@ describe('settings', () => { favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], favoriteMachines: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, - useEnhancedSessionWizard: false, }); }); diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 2e78ec19b..9b0ad6234 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -240,6 +240,10 @@ export const SettingsSchema = z.object({ experiments: z.boolean().describe('Whether to enable experimental features'), useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), + useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), + useMachinePickerFavorites: z.boolean().describe('Whether to show favorites in machine picker UIs'), + useDirectoryPickerSearch: z.boolean().describe('Whether to show search in directory/path picker UIs'), + useDirectoryPickerFavorites: z.boolean().describe('Whether to show favorites in directory/path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), avatarStyle: z.string().describe('Avatar display style'), @@ -310,6 +314,10 @@ export const settingsDefaults: Settings = { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, + useMachinePickerSearch: false, + useMachinePickerFavorites: false, + useDirectoryPickerSearch: false, + useDirectoryPickerFavorites: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', diff --git a/sources/text/_default.ts b/sources/text/_default.ts index a09a8ba81..0de923d71 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -210,6 +210,14 @@ export const en = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 260dc0b0c..383862944 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -211,6 +211,14 @@ export const ca: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 382c663a2..439aaf22e 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -211,6 +211,14 @@ export const es: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 25e1e4879..662309434 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -240,6 +240,14 @@ export const it: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 9657b3d37..a8df99f08 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -243,6 +243,14 @@ export const ja: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index dcc489b23..abee98f60 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -222,6 +222,14 @@ export const pl: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 4cacd94f7..bfd1baec8 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -211,6 +211,14 @@ export const pt: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 940635c3a..b1ba00f45 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -193,6 +193,14 @@ export const ru: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 45147cb4b..f4b3905a4 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -213,6 +213,14 @@ export const zhHans: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', + machinePickerSearch: 'Machine Picker Search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + machinePickerFavorites: 'Machine Picker Favorites', + machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', + directoryPickerSearch: 'Path Picker Search', + directoryPickerSearchSubtitle: 'Show a search field in path pickers', + directoryPickerFavorites: 'Path Picker Favorites', + directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', }, errors: { From 306cd717d46a2d548074683f38b155a264170466 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 13:15:28 +0100 Subject: [PATCH 08/72] fix(sync): prevent settings version-mismatch retry loop --- sources/sync/persistence.ts | 5 +-- sources/sync/settings.ts | 64 +++++++++++++++++++++++++++---------- sources/sync/storage.ts | 11 ++++++- sources/sync/sync.ts | 4 +-- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 2f9367523..36db3482b 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -26,7 +26,8 @@ export function loadSettings(): { settings: Settings, version: number | null } { if (settings) { try { const parsed = JSON.parse(settings); - return { settings: settingsParse(parsed.settings), version: parsed.version }; + const version = typeof parsed.version === 'number' ? parsed.version : null; + return { settings: settingsParse(parsed.settings), version }; } catch (e) { console.error('Failed to parse settings', e); return { settings: { ...settingsDefaults }, version: null }; @@ -225,4 +226,4 @@ export function retrieveTempText(id: string): string | null { export function clearPersistence() { mmkv.clearAll(); -} \ No newline at end of file +} diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 9b0ad6234..992d61136 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -354,28 +354,58 @@ export function settingsParse(settings: unknown): Settings { return { ...settingsDefaults }; } - const parsed = SettingsSchemaPartial.safeParse(settings); - if (!parsed.success) { - // For invalid settings, preserve unknown fields but use defaults for known fields - const unknownFields = { ...(settings as any) }; - // Remove all known schema fields from unknownFields - const knownFields = Object.keys(SettingsSchema.shape); - knownFields.forEach(key => delete unknownFields[key]); - return { ...settingsDefaults, ...unknownFields }; - } + // IMPORTANT: be tolerant of partially-invalid settings objects. + // A single invalid field (e.g. one malformed profile) must not reset all other known settings to defaults. + const input = settings as Record; + const result: any = { ...settingsDefaults }; + + // Parse known fields individually to avoid whole-object failure. + (Object.keys(SettingsSchema.shape) as Array).forEach((key) => { + if (!(key in input)) return; + + // Special-case profiles: validate per profile entry, keep valid ones. + if (key === 'profiles') { + const profilesValue = input[key]; + if (Array.isArray(profilesValue)) { + const parsedProfiles: AIBackendProfile[] = []; + for (const rawProfile of profilesValue) { + const parsedProfile = AIBackendProfileSchema.safeParse(rawProfile); + if (parsedProfile.success) { + parsedProfiles.push(parsedProfile.data); + } else if (__DEV__) { + console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues); + } + } + result.profiles = parsedProfiles; + } + return; + } + + const schema = SettingsSchema.shape[key]; + const parsedField = schema.safeParse(input[key]); + if (parsedField.success) { + result[key] = parsedField.data; + } else if (__DEV__) { + console.warn(`[settingsParse] Invalid settings field "${String(key)}" - using default`, parsedField.error.issues); + } + }); // Migration: Convert old 'zh' language code to 'zh-Hans' - if (parsed.data.preferredLanguage === 'zh') { - console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); - parsed.data.preferredLanguage = 'zh-Hans'; + if (result.preferredLanguage === 'zh') { + if (__DEV__) { + console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); + } + result.preferredLanguage = 'zh-Hans'; } - // Merge defaults, parsed settings, and preserve unknown fields - const unknownFields = { ...(settings as any) }; - // Remove known fields from unknownFields to preserve only the unknown ones - Object.keys(parsed.data).forEach(key => delete unknownFields[key]); + // Preserve unknown fields (forward compatibility). + for (const [key, value] of Object.entries(input)) { + if (!(key in SettingsSchema.shape)) { + result[key] = value; + } + } - return { ...settingsDefaults, ...parsed.data, ...unknownFields }; + return result as Settings; } // diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index f1fe413f6..d95e186d2 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -102,6 +102,7 @@ interface StorageState { applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[], hasReadyEvent: boolean }; applyMessagesLoaded: (sessionId: string) => void; applySettings: (settings: Settings, version: number) => void; + replaceSettings: (settings: Settings, version: number) => void; applySettingsLocal: (settings: Partial) => void; applyLocalSettings: (settings: Partial) => void; applyPurchases: (customerInfo: CustomerInfo) => void; @@ -629,7 +630,7 @@ export const storage = create()((set, get) => { }; }), applySettings: (settings: Settings, version: number) => set((state) => { - if (state.settingsVersion === null || state.settingsVersion < version) { + if (state.settingsVersion == null || state.settingsVersion < version) { saveSettings(settings, version); return { ...state, @@ -640,6 +641,14 @@ export const storage = create()((set, get) => { return state; } }), + replaceSettings: (settings: Settings, version: number) => set((state) => { + saveSettings(settings, version); + return { + ...state, + settings, + settingsVersion: version + }; + }), applyLocalSettings: (delta: Partial) => set((state) => { const updatedLocalSettings = applyLocalSettings(state.localSettings, delta); saveLocalSettings(updatedLocalSettings); diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index fde7d5b02..18a238f03 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1173,7 +1173,7 @@ class Sync { const mergedSettings = applySettings(serverSettings, this.pendingSettings); // Update local storage with merged result at server's version - storage.getState().applySettings(mergedSettings, data.currentVersion); + storage.getState().replaceSettings(mergedSettings, data.currentVersion); // Sync tracking state with merged settings if (tracking) { @@ -1229,7 +1229,7 @@ class Sync { })); // Apply settings to storage - storage.getState().applySettings(parsedSettings, data.settingsVersion); + storage.getState().replaceSettings(parsedSettings, data.settingsVersion); // Sync PostHog opt-out state with settings if (tracking) { From 7b937d8b9aceca4c904aa62c09d5ac68076d92d7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 13:28:22 +0100 Subject: [PATCH 09/72] fix(security): avoid logging sensitive sync/message data --- sources/hooks/envVarUtils.ts | 13 +- sources/hooks/useCLIDetection.ts | 20 +- sources/hooks/useEnvironmentVariables.ts | 68 +++- sources/sync/profileSync.ts | 453 ----------------------- sources/sync/sync.ts | 30 +- sources/sync/typesRaw.ts | 50 ++- 6 files changed, 133 insertions(+), 501 deletions(-) delete mode 100644 sources/sync/profileSync.ts diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index 325404655..d3bc26823 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -32,14 +32,13 @@ export function resolveEnvVarSubstitution( value: string, daemonEnv: EnvironmentVariables ): string | null { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset). // Group 1: Variable name (required) - // Group 2: Default value (optional) - includes the :- or := prefix - // Group 3: The actual default value without prefix (optional) - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-(.*))?(:=(.*))?}$/); + // Group 2: Default value (optional) + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::-(.*))?\}$/); if (match) { const varName = match[1]; - const defaultValue = match[3] ?? match[5]; // :- default or := default + const defaultValue = match[2]; // :- default const daemonValue = daemonEnv[varName]; if (daemonValue !== undefined && daemonValue !== null) { @@ -76,9 +75,9 @@ export function extractEnvVarReferences( const refs = new Set(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion) + // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset) // Only capture the variable name, not the default value - const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*|:=.*)?\}$/); + const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*)?\}$/); if (match) { // Variable name is already validated by regex pattern [A-Z_][A-Z0-9_]* refs.add(match[1]); diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts index bda5c547b..97f8f1cb0 100644 --- a/sources/hooks/useCLIDetection.ts +++ b/sources/hooks/useCLIDetection.ts @@ -52,7 +52,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { const detectCLIs = async () => { // Set detecting flag (non-blocking - UI stays responsive) setAvailability(prev => ({ ...prev, isDetecting: true })); - console.log('[useCLIDetection] Starting detection for machineId:', machineId); + if (__DEV__) { + console.log('[useCLIDetection] Starting detection for machineId:', machineId); + } try { // Use single bash command to check both CLIs efficiently @@ -66,7 +68,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { ); if (cancelled) return; - console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); + if (__DEV__) { + console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }); + } if (result.success && result.exitCode === 0) { // Parse output: "claude:true\ncodex:false\ngemini:false" @@ -80,7 +84,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { } }); - console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + if (__DEV__) { + console.log('[useCLIDetection] Parsed CLI status:', cliStatus); + } setAvailability({ claude: cliStatus.claude ?? null, codex: cliStatus.codex ?? null, @@ -90,7 +96,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { }); } else { // Detection command failed - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); + if (__DEV__) { + console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result); + } setAvailability({ claude: null, codex: null, @@ -104,7 +112,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability { if (cancelled) return; // Network/RPC error - CONSERVATIVE fallback (don't assume availability) - console.log('[useCLIDetection] Network/RPC error:', error); + if (__DEV__) { + console.log('[useCLIDetection] Network/RPC error:', error); + } setAvailability({ claude: null, codex: null, diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 568bb0583..5dcd0b3af 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -69,12 +69,33 @@ export function useEnvironmentVariables( return; } - // Build batched command: query all variables in single bash invocation - // Format: echo "VAR1=$VAR1" && echo "VAR2=$VAR2" && ... - // Using echo with variable expansion ensures we get daemon's environment - const command = validVarNames - .map(name => `echo "${name}=$${name}"`) - .join(' && '); + // Query variables in a single machineBash() call. + // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty. + // Fallback to bash-only output if node isn't available. + const nodeScript = [ + // node -e sets argv[1] to "-e", so args start at argv[2] + "const keys = process.argv.slice(2);", + "const out = {};", + "for (const k of keys) {", + " out[k] = Object.prototype.hasOwnProperty.call(process.env, k) ? process.env[k] : null;", + "}", + "process.stdout.write(JSON.stringify(out));", + ].join(""); + const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${validVarNames.join(' ')}`; + // Bash fallback uses indirect expansion to avoid eval and to distinguish unset vs empty. + // IMPORTANT: avoid embedding literal `${...}` inside this TypeScript template string (it would be parsed as JS interpolation). + const bashIsSetExpr = '\\$' + '{!name+x}'; + const bashValueExpr = '\\$' + '{!name}'; + const bashFallback = [ + `for name in ${validVarNames.join(' ')}; do`, + `if [ -n "${bashIsSetExpr}" ]; then`, + `printf "%s=%s\\n" "$name" "${bashValueExpr}";`, + `else`, + `printf "%s=__HAPPY_UNSET__\\n" "$name";`, + `fi;`, + `done`, + ].join(' '); + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${bashFallback}; fi`; try { const result = await machineBash(machineId, command, '/'); @@ -82,16 +103,33 @@ export function useEnvironmentVariables( if (cancelled) return; if (result.success && result.exitCode === 0) { - // Parse output: "VAR1=value1\nVAR2=value2\nVAR3=" - const lines = result.stdout.trim().split('\n'); - lines.forEach(line => { - const equalsIndex = line.indexOf('='); - if (equalsIndex !== -1) { - const name = line.substring(0, equalsIndex); - const value = line.substring(equalsIndex + 1); - results[name] = value || null; // Empty string → null (not set) + const stdout = result.stdout; + + // JSON protocol: {"VAR":"value","MISSING":null} + if (stdout.trim().startsWith('{')) { + try { + const parsed = JSON.parse(stdout) as Record; + validVarNames.forEach((name) => { + results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; + }); + } catch { + // Fall through to line parser if JSON is malformed. } - }); + } + + // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__" + if (Object.keys(results).length === 0) { + // Do not trim: it can corrupt values with meaningful whitespace. + const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0); + lines.forEach(line => { + const equalsIndex = line.indexOf('='); + if (equalsIndex !== -1) { + const name = line.substring(0, equalsIndex); + const value = line.substring(equalsIndex + 1); + results[name] = value === '__HAPPY_UNSET__' ? null : value; + } + }); + } // Ensure all requested variables have entries (even if missing from output) validVarNames.forEach(name => { diff --git a/sources/sync/profileSync.ts b/sources/sync/profileSync.ts deleted file mode 100644 index 694ea1410..000000000 --- a/sources/sync/profileSync.ts +++ /dev/null @@ -1,453 +0,0 @@ -/** - * Profile Synchronization Service - * - * Handles bidirectional synchronization of profiles between GUI and CLI storage. - * Ensures consistent profile data across both systems with proper conflict resolution. - */ - -import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from './settings'; -import { sync } from './sync'; -import { storage } from './storage'; -import { apiSocket } from './apiSocket'; -import { Modal } from '@/modal'; - -// Profile sync status types -export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'; -export type SyncDirection = 'gui-to-cli' | 'cli-to-gui' | 'bidirectional'; - -// Profile sync conflict resolution strategies -export type ConflictResolution = 'gui-wins' | 'cli-wins' | 'most-recent' | 'merge'; - -// Profile sync event data -export interface ProfileSyncEvent { - direction: SyncDirection; - status: SyncStatus; - profilesSynced?: number; - error?: string; - timestamp: number; - message?: string; - warning?: string; -} - -// Profile sync configuration -export interface ProfileSyncConfig { - autoSync: boolean; - conflictResolution: ConflictResolution; - syncOnProfileChange: boolean; - syncOnAppStart: boolean; -} - -// Default sync configuration -const DEFAULT_SYNC_CONFIG: ProfileSyncConfig = { - autoSync: true, - conflictResolution: 'most-recent', - syncOnProfileChange: true, - syncOnAppStart: true, -}; - -class ProfileSyncService { - private static instance: ProfileSyncService; - private syncStatus: SyncStatus = 'idle'; - private lastSyncTime: number = 0; - private config: ProfileSyncConfig = DEFAULT_SYNC_CONFIG; - private eventListeners: Array<(event: ProfileSyncEvent) => void> = []; - - private constructor() { - // Private constructor for singleton - } - - public static getInstance(): ProfileSyncService { - if (!ProfileSyncService.instance) { - ProfileSyncService.instance = new ProfileSyncService(); - } - return ProfileSyncService.instance; - } - - /** - * Add event listener for sync events - */ - public addEventListener(listener: (event: ProfileSyncEvent) => void): void { - this.eventListeners.push(listener); - } - - /** - * Remove event listener - */ - public removeEventListener(listener: (event: ProfileSyncEvent) => void): void { - const index = this.eventListeners.indexOf(listener); - if (index > -1) { - this.eventListeners.splice(index, 1); - } - } - - /** - * Emit sync event to all listeners - */ - private emitEvent(event: ProfileSyncEvent): void { - this.eventListeners.forEach(listener => { - try { - listener(event); - } catch (error) { - console.error('[ProfileSync] Event listener error:', error); - } - }); - } - - /** - * Update sync configuration - */ - public updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - /** - * Get current sync configuration - */ - public getConfig(): ProfileSyncConfig { - return { ...this.config }; - } - - /** - * Get current sync status - */ - public getSyncStatus(): SyncStatus { - return this.syncStatus; - } - - /** - * Get last sync time - */ - public getLastSyncTime(): number { - return this.lastSyncTime; - } - - /** - * Sync profiles from GUI to CLI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncGuiToCli(profiles: AIBackendProfile[]): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'gui-to-cli', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Profiles are stored in GUI settings and available through existing Happy sync system - // CLI daemon reads profiles from GUI settings via existing channels - // TODO: Implement machine RPC endpoints for profile management in CLI daemon - console.log(`[ProfileSync] GUI profiles stored in Happy settings. CLI access via existing infrastructure.`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'success', - profilesSynced: profiles.length, - timestamp: Date.now(), - message: 'Profiles available through Happy settings system' - }); - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'gui-to-cli', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Sync profiles from CLI to GUI using proper Happy infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure - */ - public async syncCliToGui(): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'cli-to-gui', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // CLI profiles are accessed through Happy settings system, not direct file access - // Return profiles from current GUI settings - const currentProfiles = storage.getState().settings.profiles || []; - - console.log(`[ProfileSync] Retrieved ${currentProfiles.length} profiles from Happy settings`); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'success', - profilesSynced: currentProfiles.length, - timestamp: Date.now(), - message: 'Profiles retrieved from Happy settings system' - }); - - return currentProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'cli-to-gui', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Perform bidirectional sync with conflict resolution - */ - public async bidirectionalSync(guiProfiles: AIBackendProfile[]): Promise { - if (this.syncStatus === 'syncing') { - throw new Error('Sync already in progress'); - } - - this.syncStatus = 'syncing'; - this.emitEvent({ - direction: 'bidirectional', - status: 'syncing', - timestamp: Date.now(), - }); - - try { - // Get CLI profiles - const cliProfiles = await this.syncCliToGui(); - - // Resolve conflicts based on configuration - const resolvedProfiles = await this.resolveConflicts(guiProfiles, cliProfiles); - - // Update CLI with resolved profiles - await this.syncGuiToCli(resolvedProfiles); - - this.lastSyncTime = Date.now(); - this.syncStatus = 'success'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'success', - profilesSynced: resolvedProfiles.length, - timestamp: Date.now(), - }); - - return resolvedProfiles; - } catch (error) { - this.syncStatus = 'error'; - const errorMessage = error instanceof Error ? error.message : 'Unknown sync error'; - - this.emitEvent({ - direction: 'bidirectional', - status: 'error', - error: errorMessage, - timestamp: Date.now(), - }); - - throw error; - } - } - - /** - * Resolve conflicts between GUI and CLI profiles - */ - private async resolveConflicts( - guiProfiles: AIBackendProfile[], - cliProfiles: AIBackendProfile[] - ): Promise { - const { conflictResolution } = this.config; - const resolvedProfiles: AIBackendProfile[] = []; - const processedIds = new Set(); - - // Process profiles that exist in both GUI and CLI - for (const guiProfile of guiProfiles) { - const cliProfile = cliProfiles.find(p => p.id === guiProfile.id); - - if (cliProfile) { - let resolvedProfile: AIBackendProfile; - - switch (conflictResolution) { - case 'gui-wins': - resolvedProfile = { ...guiProfile, updatedAt: Date.now() }; - break; - case 'cli-wins': - resolvedProfile = { ...cliProfile, updatedAt: Date.now() }; - break; - case 'most-recent': - resolvedProfile = guiProfile.updatedAt! >= cliProfile.updatedAt! - ? { ...guiProfile } - : { ...cliProfile }; - break; - case 'merge': - resolvedProfile = await this.mergeProfiles(guiProfile, cliProfile); - break; - default: - resolvedProfile = { ...guiProfile }; - } - - resolvedProfiles.push(resolvedProfile); - processedIds.add(guiProfile.id); - } else { - // Profile exists only in GUI - resolvedProfiles.push({ ...guiProfile, updatedAt: Date.now() }); - processedIds.add(guiProfile.id); - } - } - - // Add profiles that exist only in CLI - for (const cliProfile of cliProfiles) { - if (!processedIds.has(cliProfile.id)) { - resolvedProfiles.push({ ...cliProfile, updatedAt: Date.now() }); - } - } - - return resolvedProfiles; - } - - /** - * Merge two profiles, preferring non-null values from both - */ - private async mergeProfiles( - guiProfile: AIBackendProfile, - cliProfile: AIBackendProfile - ): Promise { - const merged: AIBackendProfile = { - id: guiProfile.id, - name: guiProfile.name || cliProfile.name, - description: guiProfile.description || cliProfile.description, - anthropicConfig: { ...cliProfile.anthropicConfig, ...guiProfile.anthropicConfig }, - openaiConfig: { ...cliProfile.openaiConfig, ...guiProfile.openaiConfig }, - azureOpenAIConfig: { ...cliProfile.azureOpenAIConfig, ...guiProfile.azureOpenAIConfig }, - togetherAIConfig: { ...cliProfile.togetherAIConfig, ...guiProfile.togetherAIConfig }, - tmuxConfig: { ...cliProfile.tmuxConfig, ...guiProfile.tmuxConfig }, - environmentVariables: this.mergeEnvironmentVariables( - cliProfile.environmentVariables || [], - guiProfile.environmentVariables || [] - ), - compatibility: { ...cliProfile.compatibility, ...guiProfile.compatibility }, - isBuiltIn: guiProfile.isBuiltIn || cliProfile.isBuiltIn, - createdAt: Math.min(guiProfile.createdAt || 0, cliProfile.createdAt || 0), - updatedAt: Math.max(guiProfile.updatedAt || 0, cliProfile.updatedAt || 0), - version: guiProfile.version || cliProfile.version || '1.0.0', - }; - - return merged; - } - - /** - * Merge environment variables from two profiles - */ - private mergeEnvironmentVariables( - cliVars: Array<{ name: string; value: string }>, - guiVars: Array<{ name: string; value: string }> - ): Array<{ name: string; value: string }> { - const mergedVars = new Map(); - - // Add CLI variables first - cliVars.forEach(v => mergedVars.set(v.name, v.value)); - - // Override with GUI variables - guiVars.forEach(v => mergedVars.set(v.name, v.value)); - - return Array.from(mergedVars.entries()).map(([name, value]) => ({ name, value })); - } - - /** - * Set active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async setActiveProfile(profileId: string): Promise { - try { - // Store in GUI settings using Happy's settings system - sync.applySettings({ lastUsedProfile: profileId }); - - console.log(`[ProfileSync] Set active profile ${profileId} in Happy settings`); - - // Note: CLI daemon accesses active profile through Happy settings system - // TODO: Implement machine RPC endpoint for setting active profile in CLI daemon - } catch (error) { - console.error('[ProfileSync] Failed to set active profile:', error); - throw error; - } - } - - /** - * Get active profile using Happy settings infrastructure - * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system - */ - public async getActiveProfile(): Promise { - try { - // Get active profile from Happy settings system - const lastUsedProfileId = storage.getState().settings.lastUsedProfile; - - if (!lastUsedProfileId) { - return null; - } - - const profiles = storage.getState().settings.profiles || []; - const activeProfile = profiles.find((p: AIBackendProfile) => p.id === lastUsedProfileId); - - if (activeProfile) { - console.log(`[ProfileSync] Retrieved active profile ${activeProfile.name} from Happy settings`); - return activeProfile; - } - - return null; - } catch (error) { - console.error('[ProfileSync] Failed to get active profile:', error); - return null; - } - } - - /** - * Auto-sync if enabled and conditions are met - */ - public async autoSyncIfNeeded(guiProfiles: AIBackendProfile[]): Promise { - if (!this.config.autoSync) { - return; - } - - const timeSinceLastSync = Date.now() - this.lastSyncTime; - const AUTO_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes - - if (timeSinceLastSync > AUTO_SYNC_INTERVAL) { - try { - await this.bidirectionalSync(guiProfiles); - } catch (error) { - console.error('[ProfileSync] Auto-sync failed:', error); - // Don't throw for auto-sync failures - } - } - } -} - -// Export singleton instance -export const profileSyncService = ProfileSyncService.getInstance(); - -// Export convenience functions -export const syncGuiToCli = (profiles: AIBackendProfile[]) => profileSyncService.syncGuiToCli(profiles); -export const syncCliToGui = () => profileSyncService.syncCliToGui(); -export const bidirectionalSync = (guiProfiles: AIBackendProfile[]) => profileSyncService.bidirectionalSync(guiProfiles); -export const setActiveProfile = (profileId: string) => profileSyncService.setActiveProfile(profileId); -export const getActiveProfile = () => profileSyncService.getActiveProfile(); \ No newline at end of file diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 18a238f03..2b136f1a6 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -1222,11 +1222,13 @@ class Sync { parsedSettings = { ...settingsDefaults }; } - // Log - console.log('settings', JSON.stringify({ - settings: parsedSettings, - version: data.settingsVersion - })); + // Avoid logging full settings in production (may contain secrets like API keys / profile env vars). + if (__DEV__) { + console.log('settings', { + version: data.settingsVersion, + schemaVersion: parsedSettings.schemaVersion, + }); + } // Apply settings to storage storage.getState().replaceSettings(parsedSettings, data.settingsVersion); @@ -1259,15 +1261,15 @@ class Sync { const data = await response.json(); const parsedProfile = profileParse(data); - // Log profile data for debugging - console.log('profile', JSON.stringify({ - id: parsedProfile.id, - timestamp: parsedProfile.timestamp, - firstName: parsedProfile.firstName, - lastName: parsedProfile.lastName, - hasAvatar: !!parsedProfile.avatar, - hasGitHub: !!parsedProfile.github - })); + // Keep debug logs dev-only (avoid leaking PII/noise in prod logs). + if (__DEV__) { + console.log('profile', { + id: parsedProfile.id, + timestamp: parsedProfile.timestamp, + hasAvatar: !!parsedProfile.avatar, + hasGitHub: !!parsedProfile.github, + }); + } // Apply profile to storage storage.getState().applyProfile(parsedProfile); diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 4dde5e855..904ea0412 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -47,7 +47,9 @@ export type RawToolUseContent = z.infer; const rawToolResultContentSchema = z.object({ type: z.literal('tool_result'), tool_use_id: z.string(), - content: z.union([z.array(z.object({ type: z.literal('text'), text: z.string() })), z.string()]), + // Tool results can be strings, Claude-style arrays of text blocks, or structured JSON (Codex/Gemini). + // We accept any here and normalize later for display. + content: z.any(), is_error: z.boolean().optional(), permissions: z.object({ date: z.number(), @@ -340,13 +342,46 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA // Zod transform handles normalization during validation let parsed = rawRecordSchema.safeParse(raw); if (!parsed.success) { - console.error('=== VALIDATION ERROR ==='); - console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); - console.error('Raw message:', JSON.stringify(raw, null, 2)); - console.error('=== END ERROR ==='); + // Never log full raw messages in production: tool outputs and user text may contain secrets. + // Keep enough context for debugging in dev builds only. + console.error(`[typesRaw] Message validation failed (id=${id})`); + if (__DEV__) { + console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2)); + console.error('Raw summary:', { + role: raw?.role, + contentType: (raw as any)?.content?.type, + }); + } return null; } raw = parsed.data; + + const toolResultContentToText = (content: unknown): string => { + if (content === null || content === undefined) return ''; + if (typeof content === 'string') return content; + + // Claude sometimes sends tool_result.content as [{ type: 'text', text: '...' }] + if (Array.isArray(content)) { + const maybeTextBlocks = content as Array<{ type?: unknown; text?: unknown }>; + const isTextBlocks = maybeTextBlocks.every((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string'); + if (isTextBlocks) { + return maybeTextBlocks.map((b) => b.text as string).join(''); + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + } + + try { + return JSON.stringify(content); + } catch { + return String(content); + } + }; + if (raw.role === 'user') { return { id, @@ -463,10 +498,11 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA } else { for (let c of raw.content.data.message.content) { if (c.type === 'tool_result') { + const rawResultContent = raw.content.data.toolUseResult ?? c.content; content.push({ ...c, // WOLOG: Preserve all fields including unknown ones type: 'tool-result', - content: raw.content.data.toolUseResult ? raw.content.data.toolUseResult : (typeof c.content === 'string' ? c.content : c.content[0].text), + content: toolResultContentToText(rawResultContent), is_error: c.is_error || false, uuid: raw.content.data.uuid, parentUUID: raw.content.data.parentUuid ?? null, @@ -568,7 +604,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA content: [{ type: 'tool-result', tool_use_id: raw.content.data.callId, - content: raw.content.data.output, + content: toolResultContentToText(raw.content.data.output), is_error: false, uuid: raw.content.data.id, parentUUID: null From e25e11baec67a243bb5262c86e2981eb705a850c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:38:49 +0100 Subject: [PATCH 10/72] refactor(pickers): consolidate search toggle and remove default favorites --- sources/app/(app)/new/index.tsx | 30 +++++----- sources/app/(app)/new/pick/machine.tsx | 13 ++--- sources/app/(app)/new/pick/path.tsx | 58 ++++++++----------- sources/app/(app)/settings/features.tsx | 32 ++-------- .../newSession/DirectorySelector.tsx | 8 --- sources/sync/settings.spec.ts | 35 +++-------- sources/sync/settings.ts | 14 ++--- sources/text/_default.ts | 10 +--- sources/text/translations/ca.ts | 10 +--- sources/text/translations/es.ts | 10 +--- sources/text/translations/it.ts | 10 +--- sources/text/translations/ja.ts | 10 +--- sources/text/translations/pl.ts | 10 +--- sources/text/translations/pt.ts | 10 +--- sources/text/translations/ru.ts | 10 +--- sources/text/translations/zh-Hans.ts | 10 +--- 16 files changed, 76 insertions(+), 204 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 38e07f02a..11aa808f8 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -295,10 +295,7 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); - const useMachinePickerSearch = useSetting('useMachinePickerSearch'); - const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); - const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); - const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); + const usePickerSearch = useSetting('usePickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); @@ -1583,22 +1580,22 @@ function NewSessionWizard() { machines={machines} selectedMachine={selectedMachine || null} recentMachines={recentMachines} - favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} - showFavorites={useMachinePickerFavorites} - showSearch={useMachinePickerSearch} + favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} + showFavorites={true} + showSearch={usePickerSearch} onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); setSelectedPath(bestPath); }} - onToggleFavorite={useMachinePickerFavorites ? ((machine) => { + onToggleFavorite={(machine) => { const isInFavorites = favoriteMachines.includes(machine.id); if (isInFavorites) { setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); } else { setFavoriteMachines([...favoriteMachines, machine.id]); } - }) : undefined} + }} /> @@ -1616,18 +1613,17 @@ function NewSessionWizard() { machineHomeDir={selectedMachine?.metadata?.homeDir} selectedPath={selectedPath} recentPaths={recentPaths} - favoritePaths={useDirectoryPickerFavorites ? (() => { + favoritePaths={(() => { if (!selectedMachine?.metadata?.homeDir) return []; const homeDir = selectedMachine.metadata.homeDir; - return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))]; - })() : []} - showFavorites={useDirectoryPickerFavorites} - showSearch={useDirectoryPickerSearch} + return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + })()} + showFavorites={true} + showSearch={usePickerSearch} onSelect={(path) => setSelectedPath(path)} - onToggleFavorite={useDirectoryPickerFavorites ? ((path) => { + onToggleFavorite={(path) => { const homeDir = selectedMachine?.metadata?.homeDir; if (!homeDir) return; - if (path === homeDir) return; const relativePath = formatPathRelativeToHome(path, homeDir); const isInFavorites = favoriteDirectories.some(fav => @@ -1640,7 +1636,7 @@ function NewSessionWizard() { } else { setFavoriteDirectories([...favoriteDirectories, relativePath]); } - }) : undefined} + }} /> diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index c327ed3c2..8cff81e78 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -36,8 +36,7 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const useMachinePickerSearch = useSetting('useMachinePickerSearch'); - const useMachinePickerFavorites = useSetting('useMachinePickerFavorites'); + const usePickerSearch = useSetting('usePickerSearch'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -120,17 +119,17 @@ export default function MachinePickerScreen() { machines={machines} selectedMachine={selectedMachine} recentMachines={recentMachines} - favoriteMachines={useMachinePickerFavorites ? machines.filter(m => favoriteMachines.includes(m.id)) : []} + favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} - showFavorites={useMachinePickerFavorites} - showSearch={useMachinePickerSearch} - onToggleFavorite={useMachinePickerFavorites ? ((machine) => { + showFavorites={true} + showSearch={usePickerSearch} + onToggleFavorite={(machine) => { const isInFavorites = favoriteMachines.includes(machine.id); setFavoriteMachines(isInFavorites ? favoriteMachines.filter(id => id !== machine.id) : [...favoriteMachines, machine.id] ); - }) : undefined} + }} /> diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index 5db8062ff..b1fb5238c 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -62,8 +62,7 @@ export default function PathPickerScreen() { const machines = useAllMachines(); const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); - const useDirectoryPickerSearch = useSetting('useDirectoryPickerSearch'); - const useDirectoryPickerFavorites = useSetting('useDirectoryPickerFavorites'); + const usePickerSearch = useSetting('usePickerSearch'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const inputRef = useRef(null); @@ -146,9 +145,9 @@ export default function PathPickerScreen() { }, [machine]); const favoritePaths = useMemo(() => { - if (!useDirectoryPickerFavorites || !machine) return []; + if (!machine) return []; const homeDir = machine.metadata?.homeDir || '/home'; - const paths = [homeDir, ...favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir))]; + const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); const seen = new Set(); const ordered: string[] = []; for (const p of paths) { @@ -158,37 +157,31 @@ export default function PathPickerScreen() { ordered.push(p); } return ordered; - }, [favoriteDirectories, machine, useDirectoryPickerFavorites]); + }, [favoriteDirectories, machine]); const filteredRecentPaths = useMemo(() => { - const base = useDirectoryPickerFavorites - ? recentPaths.filter((p) => !favoritePaths.includes(p)) - : recentPaths; - if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + const base = recentPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; const query = searchQuery.toLowerCase(); return base.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, recentPaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); const filteredSuggestedPaths = useMemo(() => { - const base = useDirectoryPickerFavorites - ? suggestedPaths.filter((p) => !favoritePaths.includes(p)) - : suggestedPaths; - if (!useDirectoryPickerSearch || !searchQuery.trim()) return base; + const base = suggestedPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; const query = searchQuery.toLowerCase(); return base.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, searchQuery, suggestedPaths, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); const filteredFavoritePaths = useMemo(() => { - if (!useDirectoryPickerFavorites) return []; - if (!useDirectoryPickerSearch || !searchQuery.trim()) return favoritePaths; + if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; const query = searchQuery.toLowerCase(); return favoritePaths.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, searchQuery, useDirectoryPickerFavorites, useDirectoryPickerSearch]); + }, [favoritePaths, searchQuery, usePickerSearch]); const toggleFavorite = React.useCallback((absolutePath: string) => { if (!machine) return; const homeDir = machine.metadata?.homeDir || '/home'; - if (absolutePath === homeDir) return; const relativePath = formatPathRelativeToHome(absolutePath, homeDir); const resolved = resolveAbsolutePath(relativePath, homeDir); @@ -262,7 +255,7 @@ export default function PathPickerScreen() { }} /> - {useDirectoryPickerSearch && ( + {usePickerSearch && ( - {useDirectoryPickerFavorites && filteredFavoritePaths.length > 0 && ( + {filteredFavoritePaths.length > 0 && ( {filteredFavoritePaths.map((path, index) => { const isSelected = customPath.trim() === path; const isLast = index === filteredFavoritePaths.length - 1; - const isHome = machine?.metadata?.homeDir ? path === machine.metadata.homeDir : false; - const favoriteIconName = isHome ? 'home-outline' : 'star'; return ( { e.stopPropagation(); toggleFavorite(path); }} > - ) : null} + )} showDivider={!isLast} /> ); @@ -333,7 +323,7 @@ export default function PathPickerScreen() { {filteredRecentPaths.map((path, index) => { const isSelected = customPath.trim() === path; const isLast = index === filteredRecentPaths.length - 1; - const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); + const isFavorite = favoritePaths.includes(path); return ( { @@ -360,7 +350,7 @@ export default function PathPickerScreen() { color={isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary} /> - ) : null} + )} showDivider={!isLast} /> ); @@ -373,7 +363,7 @@ export default function PathPickerScreen() { {filteredSuggestedPaths.map((path, index) => { const isSelected = customPath.trim() === path; const isLast = index === filteredSuggestedPaths.length - 1; - const isFavorite = useDirectoryPickerFavorites && favoritePaths.includes(path); + const isFavorite = favoritePaths.includes(path); return ( { @@ -400,7 +390,7 @@ export default function PathPickerScreen() { color={isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary} /> - ) : null} + )} showDivider={!isLast} /> ); diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index b01619e6c..19ecdef28 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -15,10 +15,7 @@ export default function FeaturesSettingsScreen() { const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); - const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); - const [useMachinePickerFavorites, setUseMachinePickerFavorites] = useSettingMutable('useMachinePickerFavorites'); - const [useDirectoryPickerSearch, setUseDirectoryPickerSearch] = useSettingMutable('useDirectoryPickerSearch'); - const [useDirectoryPickerFavorites, setUseDirectoryPickerFavorites] = useSettingMutable('useDirectoryPickerFavorites'); + const [usePickerSearch, setUsePickerSearch] = useSettingMutable('usePickerSearch'); return ( @@ -78,31 +75,10 @@ export default function FeaturesSettingsScreen() { showChevron={false} /> } - rightElement={} - showChevron={false} - /> - } - rightElement={} - showChevron={false} - /> - } - rightElement={} - showChevron={false} - /> - } - rightElement={} + rightElement={} showChevron={false} /> ), - getFavoriteItemIcon: (path) => ( - - ), - canRemoveFavorite: (path) => path !== homeDir, formatForDisplay: (path) => formatPathRelativeToHome(path, homeDir), parseFromDisplay: (text) => { const trimmed = text.trim(); diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index d29db73e9..0f8d49fe7 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -105,10 +105,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -144,10 +141,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -183,10 +177,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -224,10 +215,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -270,10 +258,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -325,10 +310,7 @@ describe('settings', () => { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -393,10 +375,7 @@ describe('settings', () => { useProfiles: false, alwaysShowContextSize: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 992d61136..45ac8bf12 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -240,10 +240,7 @@ export const SettingsSchema = z.object({ experiments: z.boolean().describe('Whether to enable experimental features'), useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), - useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), - useMachinePickerFavorites: z.boolean().describe('Whether to show favorites in machine picker UIs'), - useDirectoryPickerSearch: z.boolean().describe('Whether to show search in directory/path picker UIs'), - useDirectoryPickerFavorites: z.boolean().describe('Whether to show favorites in directory/path picker UIs'), + usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), avatarStyle: z.string().describe('Avatar display style'), @@ -314,10 +311,7 @@ export const settingsDefaults: Settings = { experiments: false, useProfiles: false, useEnhancedSessionWizard: false, - useMachinePickerSearch: false, - useMachinePickerFavorites: false, - useDirectoryPickerSearch: false, - useDirectoryPickerFavorites: false, + usePickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -335,8 +329,8 @@ export const settingsDefaults: Settings = { // Profile management defaults profiles: [], lastUsedProfile: null, - // Default favorite directories (real common directories on Unix-like systems) - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + // Favorite directories (empty by default) + favoriteDirectories: [], // Favorite machines (empty by default) favoriteMachines: [], // Dismissed CLI warnings (empty by default) diff --git a/sources/text/_default.ts b/sources/text/_default.ts index 0de923d71..d6b14fa86 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -210,14 +210,8 @@ export const en = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 383862944..b440c2b72 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -211,14 +211,8 @@ export const ca: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 439aaf22e..7a899354c 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -211,14 +211,8 @@ export const es: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 662309434..60922056d 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -240,14 +240,8 @@ export const it: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index a8df99f08..041064dfc 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -243,14 +243,8 @@ export const ja: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index abee98f60..48e0e9282 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -222,14 +222,8 @@ export const pl: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index bfd1baec8..d4fa5a2fa 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -211,14 +211,8 @@ export const pt: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index b1ba00f45..2983f2ac2 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -193,14 +193,8 @@ export const ru: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index f4b3905a4..b60d797f2 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -213,14 +213,8 @@ export const zhHans: TranslationStructure = { profiles: 'AI Profiles', profilesEnabled: 'Profile selection enabled', profilesDisabled: 'Profile selection disabled', - machinePickerSearch: 'Machine Picker Search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - machinePickerFavorites: 'Machine Picker Favorites', - machinePickerFavoritesSubtitle: 'Show a favorites section in machine pickers', - directoryPickerSearch: 'Path Picker Search', - directoryPickerSearchSubtitle: 'Show a search field in path pickers', - directoryPickerFavorites: 'Path Picker Favorites', - directoryPickerFavoritesSubtitle: 'Show a favorites section in path pickers', + pickerSearch: 'Picker Search', + pickerSearchSubtitle: 'Show a search field in machine and path pickers', }, errors: { From 0cfafbcfdf45fcad0bc8add3df6aa0d0c1f26544 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:46:36 +0100 Subject: [PATCH 11/72] fix(new-session): restore chip layout and mobile close --- sources/app/(app)/new/index.tsx | 22 +++-- sources/components/AgentInput.tsx | 150 +++++++++++++++--------------- 2 files changed, 87 insertions(+), 85 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 11aa808f8..55eb53ac5 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1139,14 +1139,15 @@ function NewSessionWizard() { style={{ position: 'absolute', top: safeArea.top + 8, - left: 8, + right: 8, zIndex: 1000, - padding: 8, - borderRadius: 16, - backgroundColor: theme.colors.surface, + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as any) : null), }} > - + )} @@ -1209,14 +1210,15 @@ function NewSessionWizard() { style={{ position: 'absolute', top: safeArea.top + 8, - left: 8, + right: 8, zIndex: 1000, - padding: 8, - borderRadius: 16, - backgroundColor: theme.colors.surface, + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as any) : null), }} > - + )} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 6520a73b9..2fbc2918b 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -414,6 +414,29 @@ export const AgentInput = React.memo(React.forwardRef { + const mode = props.permissionMode ?? 'default'; + + if (isCodex) { + return mode === 'default' ? t('agentInput.codexPermissionMode.default') : + mode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : + mode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : + mode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : ''; + } + + if (isGemini) { + return mode === 'default' ? t('agentInput.geminiPermissionMode.default') : + mode === 'acceptEdits' ? t('agentInput.geminiPermissionMode.badgeAcceptAllEdits') : + mode === 'bypassPermissions' ? t('agentInput.geminiPermissionMode.badgeBypassAllPermissions') : + mode === 'plan' ? t('agentInput.geminiPermissionMode.badgePlanMode') : ''; + } + + return mode === 'default' ? t('agentInput.permissionMode.default') : + mode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : + mode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : + mode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : ''; + }, [isCodex, isGemini, props.permissionMode]); + // Handle settings button press const handleSettingsPress = React.useCallback(() => { hapticsLight(); @@ -657,11 +680,11 @@ export const AgentInput = React.memo(React.forwardRef )} - - {props.permissionMode && ( - - {isCodex ? ( - props.permissionMode === 'default' ? t('agentInput.codexPermissionMode.default') : - props.permissionMode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - props.permissionMode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - props.permissionMode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : '' - ) : isGemini ? ( - props.permissionMode === 'default' ? t('agentInput.geminiPermissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.geminiPermissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.geminiPermissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.geminiPermissionMode.badgePlanMode') : '' - ) : ( - props.permissionMode === 'default' ? t('agentInput.permissionMode.default') : - props.permissionMode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - props.permissionMode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - props.permissionMode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : '' - )} - - )} - )} @@ -767,11 +754,12 @@ export const AgentInput = React.memo(React.forwardRef + + {permissionBadgeLabel} + )} @@ -890,42 +886,6 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Path selector button */} - {props.currentPath && props.onPathClick && ( - { - hapticsLight(); - props.onPathClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - justifyContent: 'center', - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.currentPath} - - - )} - {/* Abort button */} {props.onAbort && ( @@ -1030,6 +990,46 @@ export const AgentInput = React.memo(React.forwardRef + + {/* Row 2: Path selector (separate line to match pre-PR272 layout) */} + {props.currentPath && props.onPathClick && ( + + + { + hapticsLight(); + props.onPathClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => ({ + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + opacity: p.pressed ? 0.7 : 1, + gap: 6, + })} + > + + + {props.currentPath} + + + + + )} From ec01fadf2962c44a01f38a92c206e8142c0b67ca Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:49:44 +0100 Subject: [PATCH 12/72] fix(profiles): improve tmux and env var spacing --- sources/components/EnvironmentVariablesList.tsx | 2 +- sources/components/ProfileEditForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index fd66b9067..bbebb2a4a 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -187,7 +187,7 @@ export function EnvironmentVariablesList({ )} - + {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 3cc69d908..fa8e7d99a 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -191,7 +191,7 @@ export function ProfileEditForm({ onChangeText={setTmuxSession} /> - + Tmux Temp Directory ({t('common.optional')}) Date: Tue, 13 Jan 2026 23:52:10 +0100 Subject: [PATCH 13/72] fix(ui): align SearchHeader with maxWidth layout --- sources/components/SearchHeader.tsx | 51 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 6122613e2..7b9b9976f 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -3,6 +3,7 @@ import { View, TextInput, Platform, StyleProp, ViewStyle } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; +import { layout } from '@/components/layout'; export interface SearchHeaderProps { value: string; @@ -23,6 +24,11 @@ const stylesheet = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.divider, }, + content: { + width: '100%', + maxWidth: layout.maxWidth, + alignSelf: 'center', + }, inputWrapper: { flexDirection: 'row', alignItems: 'center', @@ -58,33 +64,34 @@ export function SearchHeader({ return ( - - - - {value.trim().length > 0 && ( + + onChangeText('')} - style={styles.clearIcon} + style={{ marginRight: 8 }} + /> + - )} + {value.trim().length > 0 && ( + onChangeText('')} + style={styles.clearIcon} + /> + )} + ); } - From 5d4f1f9bdcb2b32e0e35280d6aa03201973fdc78 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 23:59:04 +0100 Subject: [PATCH 14/72] refactor(wizard): use machine and path picker modals --- sources/app/(app)/new/index.tsx | 112 +++++++++----------------------- 1 file changed, 31 insertions(+), 81 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 55eb53ac5..8b4211cae 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -32,8 +32,6 @@ import { resolveAbsolutePath } from '@/utils/pathUtils'; import { MultiTextInput } from '@/components/MultiTextInput'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; -import { MachineSelector } from '@/components/newSession/MachineSelector'; -import { DirectorySelector } from '@/components/newSession/DirectorySelector'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; // Simple temporary state for passing selections back from picker screens @@ -295,11 +293,8 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); - const usePickerSearch = useSetting('usePickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); - const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); - const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -467,8 +462,6 @@ function NewSessionWizard() { // Refs for scrolling to sections const scrollViewRef = React.useRef(null); const profileSectionRef = React.useRef(null); - const machineSectionRef = React.useRef(null); - const pathSectionRef = React.useRef(null); const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine @@ -785,12 +778,24 @@ function NewSessionWizard() { }, [router, selectedMachineId, selectedProfileId, useProfiles]); const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); + router.push({ + pathname: '/new/pick/machine', + params: selectedMachineId ? { selectedId: selectedMachineId } : {}, + }); + }, [router, selectedMachineId]); const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); + if (!selectedMachineId) { + return; + } + router.push({ + pathname: '/new/pick/path', + params: { + machineId: selectedMachineId, + selectedPath, + }, + }); + }, [router, selectedMachineId, selectedPath]); const handleAgentInputAgentClick = React.useCallback(() => { scrollToSection(profileSectionRef); // Agent tied to profile section @@ -1568,79 +1573,24 @@ function NewSessionWizard() { )} - {/* Section 2: Machine Selection */} - - - 2. - - Select Machine - - - - - favoriteMachines.includes(m.id))} - showFavorites={true} - showSearch={usePickerSearch} - onSelect={(machine) => { - setSelectedMachineId(machine.id); - const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); - setSelectedPath(bestPath); - }} - onToggleFavorite={(machine) => { - const isInFavorites = favoriteMachines.includes(machine.id); - if (isInFavorites) { - setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); - } else { - setFavoriteMachines([...favoriteMachines, machine.id]); - } - }} + + } + onPress={handleMachineClick} /> - - - {/* Section 3: Working Directory */} - - - 3. - - Select Working Directory - - + - - { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); - })()} - showFavorites={true} - showSearch={usePickerSearch} - onSelect={(path) => setSelectedPath(path)} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - const relativePath = formatPathRelativeToHome(path, homeDir); - const isInFavorites = favoriteDirectories.some(fav => - resolveAbsolutePath(fav, homeDir) === path - ); - if (isInFavorites) { - setFavoriteDirectories(favoriteDirectories.filter(fav => - resolveAbsolutePath(fav, homeDir) !== path - )); - } else { - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} + + } + onPress={handlePathClick} + disabled={!selectedMachineId} /> - + {/* Section 4: Permission Mode */} From 98db44faa0a595ee9405f072b11cf286e0f3f208 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 07:54:46 +0100 Subject: [PATCH 15/72] refactor(wizard): restore inline machine/path selectors --- sources/app/(app)/new/index.tsx | 122 +++++++++++++----- .../newSession/DirectorySelector.tsx | 12 +- 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 8b4211cae..d013472a3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -33,6 +33,8 @@ import { MultiTextInput } from '@/components/MultiTextInput'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import { DirectorySelector } from '@/components/newSession/DirectorySelector'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -293,8 +295,11 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); + const usePickerSearch = useSetting('usePickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); + const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -462,6 +467,8 @@ function NewSessionWizard() { // Refs for scrolling to sections const scrollViewRef = React.useRef(null); const profileSectionRef = React.useRef(null); + const machineSectionRef = React.useRef(null); + const pathSectionRef = React.useRef(null); const permissionSectionRef = React.useRef(null); // CLI Detection - automatic, non-blocking detection of installed CLIs on selected machine @@ -778,24 +785,12 @@ function NewSessionWizard() { }, [router, selectedMachineId, selectedProfileId, useProfiles]); const handleAgentInputMachineClick = React.useCallback(() => { - router.push({ - pathname: '/new/pick/machine', - params: selectedMachineId ? { selectedId: selectedMachineId } : {}, - }); - }, [router, selectedMachineId]); + scrollToSection(machineSectionRef); + }, [scrollToSection]); const handleAgentInputPathClick = React.useCallback(() => { - if (!selectedMachineId) { - return; - } - router.push({ - pathname: '/new/pick/path', - params: { - machineId: selectedMachineId, - selectedPath, - }, - }); - }, [router, selectedMachineId, selectedPath]); + scrollToSection(pathSectionRef); + }, [scrollToSection]); const handleAgentInputAgentClick = React.useCallback(() => { scrollToSection(profileSectionRef); // Agent tied to profile section @@ -1573,24 +1568,89 @@ function NewSessionWizard() { )} - - } - onPress={handleMachineClick} + {/* Section 2: Machine Selection */} + + + 2. + + Select Machine + + + + + favoriteMachines.includes(m.id))} + showFavorites={true} + showSearch={usePickerSearch} + onSelect={(machine) => { + setSelectedMachineId(machine.id); + const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); + setSelectedPath(bestPath); + }} + onToggleFavorite={(machine) => { + const isInFavorites = favoriteMachines.includes(machine.id); + if (isInFavorites) { + setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id)); + } else { + setFavoriteMachines([...favoriteMachines, machine.id]); + } + }} /> - + + + {/* Section 3: Working Directory */} + + + 3. + + Select Working Directory + + - - } - onPress={handlePathClick} - disabled={!selectedMachineId} + + { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return []; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + })()} + favoritePaths={(() => { + if (!selectedMachine?.metadata?.homeDir) return []; + const homeDir = selectedMachine.metadata.homeDir; + return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + })()} + showFavorites={true} + showSearch={usePickerSearch} + onSelect={(path) => setSelectedPath(path)} + onToggleFavorite={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + if (!homeDir) return; + + const relativePath = formatPathRelativeToHome(path, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => + resolveAbsolutePath(fav, homeDir) === path + ); + if (isInFavorites) { + setFavoriteDirectories( + favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== path) + ); + } else { + setFavoriteDirectories([...favoriteDirectories, relativePath]); + } + }} /> - + {/* Section 4: Permission Mode */} diff --git a/sources/components/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx index 977eff0cc..75b43d220 100644 --- a/sources/components/newSession/DirectorySelector.tsx +++ b/sources/components/newSession/DirectorySelector.tsx @@ -9,6 +9,7 @@ export interface DirectorySelectorProps { machineHomeDir?: string | null; selectedPath: string; recentPaths: string[]; + suggestedPaths?: string[]; favoritePaths?: string[]; onSelect: (path: string) => void; onToggleFavorite?: (path: string) => void; @@ -26,6 +27,7 @@ export function DirectorySelector({ machineHomeDir, selectedPath, recentPaths, + suggestedPaths = [], favoritePaths = [], onSelect, onToggleFavorite, @@ -40,18 +42,20 @@ export function DirectorySelector({ }: DirectorySelectorProps) { const { theme } = useUnistyles(); const homeDir = machineHomeDir || undefined; + const recentOrSuggestedPaths = recentPaths.length > 0 ? recentPaths : suggestedPaths; + const recentTitle = recentPaths.length > 0 ? recentSectionTitle : 'Suggested Directories'; const allPaths = React.useMemo(() => { const seen = new Set(); const ordered: string[] = []; - for (const p of [...favoritePaths, ...recentPaths]) { + for (const p of [...favoritePaths, ...recentOrSuggestedPaths]) { if (!p) continue; if (seen.has(p)) continue; seen.add(p); ordered.push(p); } return ordered; - }, [favoritePaths, recentPaths]); + }, [favoritePaths, recentOrSuggestedPaths]); return ( @@ -86,7 +90,7 @@ export function DirectorySelector({ return displayPath.toLowerCase().includes(searchText.toLowerCase()); }, searchPlaceholder, - recentSectionTitle, + recentSectionTitle: recentTitle, favoritesSectionTitle, allSectionTitle, noItemsMessage, @@ -97,7 +101,7 @@ export function DirectorySelector({ allowCustomInput: true, }} items={allPaths} - recentItems={recentPaths} + recentItems={recentOrSuggestedPaths} favoriteItems={favoritePaths} selectedItem={selectedPath || null} onSelect={onSelect} From 33f305a549d23d0ddd546a8089ffc95353b7db62 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 08:08:08 +0100 Subject: [PATCH 16/72] fix(wizard): always show working directory input --- sources/app/(app)/new/index.tsx | 72 ++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d013472a3..d0b3b478e 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -139,6 +139,23 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 paddingHorizontal: Platform.select({ ios: 32, default: 28 }), ...Typography.default() }, + wizardInputWrapper: { + paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + marginBottom: 12, + }, + wizardTextInput: { + ...Typography.default('regular'), + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: Platform.select({ ios: 10, default: 12 }), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -430,6 +447,10 @@ function NewSessionWizard() { const [selectedPath, setSelectedPath] = React.useState(() => { return getRecentPathForMachine(selectedMachineId, recentMachinePaths); }); + const [workingDirInput, setWorkingDirInput] = React.useState(() => { + return getRecentPathForMachine(selectedMachineId, recentMachinePaths); + }); + const [isEditingWorkingDir, setIsEditingWorkingDir] = React.useState(false); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); @@ -609,6 +630,14 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); + React.useEffect(() => { + if (isEditingWorkingDir) { + return; + } + const homeDir = selectedMachine?.metadata?.homeDir; + setWorkingDirInput(selectedPath ? formatPathRelativeToHome(selectedPath, homeDir) : ''); + }, [isEditingWorkingDir, selectedMachine?.metadata?.homeDir, selectedPath]); + // Get recent paths for the selected machine // Recent machines computed from sessions (for inline machine selection) const recentMachines = React.useMemo(() => { @@ -1611,6 +1640,40 @@ function NewSessionWizard() { + + setIsEditingWorkingDir(true)} + onBlur={() => { + setIsEditingWorkingDir(false); + const trimmed = workingDirInput.trim(); + if (!trimmed) { + setSelectedPath(''); + return; + } + const homeDir = selectedMachine?.metadata?.homeDir; + const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; + setSelectedPath(resolved); + }} + onChangeText={(text) => { + setWorkingDirInput(text); + const trimmed = text.trim(); + if (!trimmed) { + setSelectedPath(''); + return; + } + const homeDir = selectedMachine?.metadata?.homeDir; + const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; + setSelectedPath(resolved); + }} + placeholder="Enter directory (e.g. ~/src or /Users/you/project)" + placeholderTextColor={theme.colors.input.placeholder} + autoCapitalize="none" + autoCorrect={false} + style={styles.wizardTextInput} + /> + + resolveAbsolutePath(fav, homeDir)); })()} showFavorites={true} - showSearch={usePickerSearch} - onSelect={(path) => setSelectedPath(path)} + showSearch={false} + onSelect={(path) => { + const homeDir = selectedMachine?.metadata?.homeDir; + setIsEditingWorkingDir(false); + setSelectedPath(path); + setWorkingDirInput(formatPathRelativeToHome(path, homeDir)); + }} onToggleFavorite={(path) => { const homeDir = selectedMachine?.metadata?.homeDir; if (!homeDir) return; From c276449dde78637110edc7a7868bd02edf32f84a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 08:25:45 +0100 Subject: [PATCH 17/72] fix(wizard): embed profile list and stabilize picker row icons --- sources/app/(app)/new/index.tsx | 243 +++++++++--------- sources/components/SearchableListSelector.tsx | 9 +- 2 files changed, 126 insertions(+), 126 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d0b3b478e..1494a1fbf 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -121,7 +121,7 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 gap: 8, marginBottom: 8, marginTop: 12, - paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + paddingHorizontal: 16, }, sectionHeader: { fontSize: 14, @@ -136,11 +136,11 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 color: theme.colors.textSecondary, marginBottom: 12, lineHeight: 18, - paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + paddingHorizontal: 16, ...Typography.default() }, wizardInputWrapper: { - paddingHorizontal: Platform.select({ ios: 32, default: 28 }), + paddingHorizontal: 16, marginBottom: 12, }, wizardTextInput: { @@ -830,82 +830,30 @@ function NewSessionWizard() { const parts: string[] = []; const availability = isProfileAvailable(profile); - // Add "Built-in" indicator first for built-in profiles if (profile.isBuiltIn) { parts.push('Built-in'); } - // Add CLI type second (before warnings/availability) if (profile.compatibility.claude && profile.compatibility.codex) { - parts.push('Claude & Codex CLI'); + parts.push('Claude & Codex'); } else if (profile.compatibility.claude) { - parts.push('Claude CLI'); + parts.push('Claude'); } else if (profile.compatibility.codex) { - parts.push('Codex CLI'); + parts.push('Codex'); } - // Add availability warning if unavailable if (!availability.available && availability.reason) { if (availability.reason.startsWith('requires-agent:')) { const required = availability.reason.split(':')[1]; - parts.push(`⚠️ This profile uses ${required} CLI only`); + parts.push(`Requires ${required}`); } else if (availability.reason.startsWith('cli-not-detected:')) { const cli = availability.reason.split(':')[1]; - const cliName = cli === 'claude' ? 'Claude' : 'Codex'; - parts.push(`⚠️ ${cliName} CLI not detected (this profile needs it)`); + parts.push(`${cli} CLI not detected`); } } - // Get model name - check both anthropicConfig and environmentVariables - let modelName: string | undefined; - if (profile.anthropicConfig?.model) { - // User set in GUI - literal value, no evaluation needed - modelName = profile.anthropicConfig.model; - } else if (profile.openaiConfig?.model) { - modelName = profile.openaiConfig.model; - } else { - // Check environmentVariables - may need ${VAR} evaluation - const modelEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_MODEL'); - if (modelEnvVar) { - const resolved = resolveEnvVarSubstitution(modelEnvVar.value, daemonEnv); - if (resolved) { - // Show as "VARIABLE: value" when evaluated from ${VAR} - const varName = modelEnvVar.value.match(/^\$\{(.+)\}$/)?.[1]; - modelName = varName ? `${varName}: ${resolved}` : resolved; - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - modelName = modelEnvVar.value; - } - } - } - - if (modelName) { - parts.push(modelName); - } - - // Add base URL if exists in environmentVariables - const baseUrlEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_BASE_URL'); - if (baseUrlEnvVar) { - const resolved = resolveEnvVarSubstitution(baseUrlEnvVar.value, daemonEnv); - if (resolved) { - // Extract hostname and show with variable name - const varName = baseUrlEnvVar.value.match(/^\$\{([A-Z_][A-Z0-9_]*)/)?.[1]; - try { - const url = new URL(resolved); - const display = varName ? `${varName}: ${url.hostname}` : url.hostname; - parts.push(display); - } catch { - // Not a valid URL, show as-is with variable name - parts.push(varName ? `${varName}: ${resolved}` : resolved); - } - } else { - // Show raw ${VAR} if not resolved (machine not selected or var not set) - parts.push(baseUrlEnvVar.value); - } - } - - return parts.join(', '); - }, [agentType, isProfileAvailable, daemonEnv]); + return parts.join(' · '); + }, [isProfileAvailable]); // Handle machine and path selection callbacks React.useEffect(() => { @@ -1264,57 +1212,59 @@ function NewSessionWizard() { {/* CLI Detection Status Banner - shows after detection completes */} {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - - - - - {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - - - - {connectionStatus.text} + + + + + + {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}: - - - - {cliAvailability.claude ? '✓' : '✗'} - - - claude - - - - - {cliAvailability.codex ? '✓' : '✗'} - - - codex - - - {experimentsEnabled && ( - - {cliAvailability.gemini ? '✓' : '✗'} + + + {connectionStatus.text} - - gemini + + + + {cliAvailability.claude ? '✓' : '✗'} + + + claude + + + + + {cliAvailability.codex ? '✓' : '✗'} + + + codex - )} + {experimentsEnabled && ( + + + {cliAvailability.gemini ? '✓' : '✗'} + + + gemini + + + )} + )} @@ -1551,20 +1501,69 @@ function NewSessionWizard() { )} {useProfiles ? ( - - - } - onPress={handleProfileClick} - /> - + <> + + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + /> + + + {DEFAULT_PROFILES.map((profileDisplay, index) => { + const profile = getBuiltInProfile(profileDisplay.id); + if (!profile) return null; + + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === DEFAULT_PROFILES.length - 1 && profiles.length === 0; + + return ( + } + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => selectProfile(profile.id)} + showDivider={!isLast} + /> + ); + })} + {profiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === profiles.length - 1; + + return ( + } + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => selectProfile(profile.id)} + showDivider={!isLast} + /> + ); + })} + + + } + onPress={handleProfileClick} + /> + + ) : ( { const RECENT_ITEMS_DEFAULT_VISIBLE = 5; const STATUS_DOT_TEXT_GAP = 4; -const ITEM_SPACING_GAP = 4; +const ITEM_SPACING_GAP = 8; const stylesheet = StyleSheet.create((theme) => ({ showMoreTitle: { @@ -219,14 +219,15 @@ export function SearchableListSelector(props: SearchableListSelectorProps) rightElement={( {renderStatus(status)} - {renderFavoriteToggle(item, isFavorite)} - {isSelected && ( + - )} + + {renderFavoriteToggle(item, isFavorite)} )} onPress={() => onSelect(item)} From 4f49ef4eb8943319632977c7c92c4d147c8d3929 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 08:49:52 +0100 Subject: [PATCH 18/72] feat(wizard): embed profiles and use searchable directory picker --- sources/app/(app)/new/index.tsx | 207 ++++++++++++++++++++------------ 1 file changed, 132 insertions(+), 75 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 1494a1fbf..087fda848 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native'; +import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView } from 'react-native'; import { Typography } from '@/constants/Typography'; import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage'; import { Ionicons, Octicons } from '@expo/vector-icons'; @@ -139,23 +139,6 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 paddingHorizontal: 16, ...Typography.default() }, - wizardInputWrapper: { - paddingHorizontal: 16, - marginBottom: 12, - }, - wizardTextInput: { - ...Typography.default('regular'), - backgroundColor: theme.colors.input.background, - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: Platform.select({ ios: 10, default: 12 }), - fontSize: Platform.select({ ios: 17, default: 16 }), - lineHeight: Platform.select({ ios: 22, default: 24 }), - letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), - color: theme.colors.input.text, - borderWidth: 0.5, - borderColor: theme.colors.divider, - }, profileListItem: { backgroundColor: theme.colors.input.background, borderRadius: 12, @@ -447,10 +430,6 @@ function NewSessionWizard() { const [selectedPath, setSelectedPath] = React.useState(() => { return getRecentPathForMachine(selectedMachineId, recentMachinePaths); }); - const [workingDirInput, setWorkingDirInput] = React.useState(() => { - return getRecentPathForMachine(selectedMachineId, recentMachinePaths); - }); - const [isEditingWorkingDir, setIsEditingWorkingDir] = React.useState(false); const [sessionPrompt, setSessionPrompt] = React.useState(() => { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); @@ -630,13 +609,59 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); - React.useEffect(() => { - if (isEditingWorkingDir) { - return; - } - const homeDir = selectedMachine?.metadata?.homeDir; - setWorkingDirInput(selectedPath ? formatPathRelativeToHome(selectedPath, homeDir) : ''); - }, [isEditingWorkingDir, selectedMachine?.metadata?.homeDir, selectedPath]); + const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { + const profileData = JSON.stringify(profile); + const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; + router.push(selectedMachineId ? `${base}&machineId=${encodeURIComponent(selectedMachineId)}` as any : base as any); + }, [router, selectedMachineId]); + + const handleAddProfile = React.useCallback(() => { + const newProfile: AIBackendProfile = { + id: randomUUID(), + name: '', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; + openProfileEdit(newProfile); + }, [openProfileEdit]); + + const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { + const duplicated: AIBackendProfile = { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + openProfileEdit(duplicated); + }, [openProfileEdit]); + + const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { + Modal.alert( + t('profiles.delete.title'), + t('profiles.delete.message', { name: profile.name }), + [ + { text: t('profiles.delete.cancel'), style: 'cancel' }, + { + text: t('profiles.delete.confirm'), + style: 'destructive', + onPress: () => { + const updatedProfiles = profiles.filter(p => p.id !== profile.id); + setProfiles(updatedProfiles); + if (selectedProfileId === profile.id) { + setSelectedProfileId(null); + } + }, + }, + ], + ); + }, [profiles, selectedProfileId, setProfiles]); // Get recent paths for the selected machine // Recent machines computed from sessions (for inline machine selection) @@ -1503,6 +1528,13 @@ function NewSessionWizard() { {useProfiles ? ( <> + } + onPress={handleAddProfile} + showChevron={false} + /> selectProfile(profile.id)} + rightElement={ + + + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + + } showDivider={!isLast} /> ); @@ -1550,19 +1612,50 @@ function NewSessionWizard() { selected={isSelected} disabled={!availability.available} onPress={() => selectProfile(profile.id)} + rightElement={ + + + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + > + + + + } showDivider={!isLast} /> ); })} - - } - onPress={handleProfileClick} - /> - ) : ( @@ -1639,40 +1732,6 @@ function NewSessionWizard() { - - setIsEditingWorkingDir(true)} - onBlur={() => { - setIsEditingWorkingDir(false); - const trimmed = workingDirInput.trim(); - if (!trimmed) { - setSelectedPath(''); - return; - } - const homeDir = selectedMachine?.metadata?.homeDir; - const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; - setSelectedPath(resolved); - }} - onChangeText={(text) => { - setWorkingDirInput(text); - const trimmed = text.trim(); - if (!trimmed) { - setSelectedPath(''); - return; - } - const homeDir = selectedMachine?.metadata?.homeDir; - const resolved = trimmed.startsWith('~') ? resolveAbsolutePath(trimmed, homeDir) : trimmed; - setSelectedPath(resolved); - }} - placeholder="Enter directory (e.g. ~/src or /Users/you/project)" - placeholderTextColor={theme.colors.input.placeholder} - autoCapitalize="none" - autoCorrect={false} - style={styles.wizardTextInput} - /> - - resolveAbsolutePath(fav, homeDir)); })()} showFavorites={true} - showSearch={false} + showSearch={true} + searchPlaceholder="Type to filter or enter custom directory..." onSelect={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - setIsEditingWorkingDir(false); setSelectedPath(path); - setWorkingDirInput(formatPathRelativeToHome(path, homeDir)); }} onToggleFavorite={(path) => { const homeDir = selectedMachine?.metadata?.homeDir; From 01281fb27955d419b32e3a900f693192012f1524 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 09:24:09 +0100 Subject: [PATCH 19/72] refactor(wizard): reuse path picker UI and fix profile availability --- sources/app/(app)/new/index.tsx | 182 +++++-------- sources/app/(app)/new/pick/path.tsx | 219 +-------------- .../components/newSession/PathSelector.tsx | 255 ++++++++++++++++++ 3 files changed, 337 insertions(+), 319 deletions(-) create mode 100644 sources/components/newSession/PathSelector.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 087fda848..c4b18ebe7 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -27,14 +27,12 @@ import { StyleSheet } from 'react-native-unistyles'; import { randomUUID } from 'expo-crypto'; import { useCLIDetection } from '@/hooks/useCLIDetection'; import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; -import { formatPathRelativeToHome } from '@/utils/sessionUtils'; -import { resolveAbsolutePath } from '@/utils/pathUtils'; -import { MultiTextInput } from '@/components/MultiTextInput'; + import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; import { MachineSelector } from '@/components/newSession/MachineSelector'; -import { DirectorySelector } from '@/components/newSession/DirectorySelector'; +import { PathSelector } from '@/components/newSession/PathSelector'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -207,18 +205,6 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 flex: 1, ...Typography.default() }, - advancedHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 12, - }, - advancedHeaderText: { - fontSize: 13, - fontWeight: '500', - color: theme.colors.textSecondary, - ...Typography.default(), - }, permissionGrid: { flexDirection: 'row', flexWrap: 'wrap', @@ -434,7 +420,6 @@ function NewSessionWizard() { return tempSessionData?.prompt || prompt || persistedDraft?.input || ''; }); const [isCreating, setIsCreating] = React.useState(false); - const [showAdvanced, setShowAdvanced] = React.useState(false); // Handle machineId route param from picker screens (main's navigation pattern) React.useEffect(() => { @@ -451,6 +436,32 @@ function NewSessionWizard() { } }, [machineIdParam, machines, recentMachinePaths, selectedMachineId]); + // Ensure a machine is pre-selected once machines have loaded (wizard expects this). + React.useEffect(() => { + if (selectedMachineId !== null) { + return; + } + if (machines.length === 0) { + return; + } + + let machineIdToUse: string | null = null; + if (recentMachinePaths.length > 0) { + for (const recent of recentMachinePaths) { + if (machines.find(m => m.id === recent.machineId)) { + machineIdToUse = recent.machineId; + break; + } + } + } + if (!machineIdToUse) { + machineIdToUse = machines[0].id; + } + + setSelectedMachineId(machineIdToUse); + setSelectedPath(getRecentPathForMachine(machineIdToUse, recentMachinePaths)); + }, [machines, recentMachinePaths, selectedMachineId]); + // Handle path route param from picker screens (main's navigation pattern) React.useEffect(() => { if (typeof pathParam !== 'string') { @@ -552,40 +563,43 @@ function NewSessionWizard() { } }, [selectedMachineId, dismissedCLIWarnings, setDismissedCLIWarnings]); - // Helper to check if profile is available (compatible + CLI detected) + // Helper to check if profile is available (CLI detected + experiments gating) const isProfileAvailable = React.useCallback((profile: AIBackendProfile): { available: boolean; reason?: string } => { - // Check profile compatibility with selected agent type - if (!validateProfileForAgent(profile, agentType)) { - // Build list of agents this profile supports (excluding current) - // Uses Object.entries to iterate over compatibility flags - scales automatically with new agents - const supportedAgents = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([agent, supported]) => supported && agent !== agentType) - .map(([agent]) => agent.charAt(0).toUpperCase() + agent.slice(1)); // 'claude' -> 'Claude' - const required = supportedAgents.join(' or ') || 'another agent'; + const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini'); + + const allowedCLIs = supportedCLIs.filter((cli) => cli !== 'gemini' || experimentsEnabled); + + if (allowedCLIs.length === 0) { return { available: false, - reason: `requires-agent:${required}`, + reason: 'no-supported-cli', }; } - // Check if required CLI is detected on machine (only if detection completed) - // Determine required CLI: if profile supports exactly one CLI, that CLI is required - // Uses Object.entries to iterate - scales automatically when new agents are added - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) - .filter(([, supported]) => supported) - .map(([agent]) => agent); - const requiredCLI = supportedCLIs.length === 1 ? supportedCLIs[0] as 'claude' | 'codex' | 'gemini' : null; + // If a profile requires exactly one CLI, enforce that one. + if (allowedCLIs.length === 1) { + const requiredCLI = allowedCLIs[0]; + if (cliAvailability[requiredCLI] === false) { + return { + available: false, + reason: `cli-not-detected:${requiredCLI}`, + }; + } + return { available: true }; + } - if (requiredCLI && cliAvailability[requiredCLI] === false) { + // Multi-CLI profiles: available if *any* supported CLI is available (or detection not finished). + const anyAvailable = allowedCLIs.some((cli) => cliAvailability[cli] !== false); + if (!anyAvailable) { return { available: false, - reason: `cli-not-detected:${requiredCLI}`, + reason: 'cli-not-detected:any', }; } - - // Optimistic: If detection hasn't completed (null) or profile supports both, assume available return { available: true }; - }, [agentType, cliAvailability]); + }, [cliAvailability, experimentsEnabled]); // Computed values const compatibleProfiles = React.useMemo(() => { @@ -1528,13 +1542,6 @@ function NewSessionWizard() { {useProfiles ? ( <> - } - onPress={handleAddProfile} - showChevron={false} - /> + + } + onPress={handleAddProfile} + showChevron={false} + showDivider={false} + /> + ) : ( @@ -1732,47 +1749,14 @@ function NewSessionWizard() { - { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return []; - return [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop`, - ]; - })()} - favoritePaths={(() => { - if (!selectedMachine?.metadata?.homeDir) return []; - const homeDir = selectedMachine.metadata.homeDir; - return favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); - })()} - showFavorites={true} - showSearch={true} - searchPlaceholder="Type to filter or enter custom directory..." - onSelect={(path) => { - setSelectedPath(path); - }} - onToggleFavorite={(path) => { - const homeDir = selectedMachine?.metadata?.homeDir; - if (!homeDir) return; - - const relativePath = formatPathRelativeToHome(path, homeDir); - const isInFavorites = favoriteDirectories.some((fav) => - resolveAbsolutePath(fav, homeDir) === path - ); - if (isInFavorites) { - setFavoriteDirectories( - favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== path) - ); - } else { - setFavoriteDirectories([...favoriteDirectories, relativePath]); - } - }} + usePickerSearch={usePickerSearch} + favoriteDirectories={favoriteDirectories} + onChangeFavoriteDirectories={setFavoriteDirectories} /> @@ -1826,31 +1810,9 @@ function NewSessionWizard() { ))} - {/* Section 5: Advanced Options (Collapsible) */} - {experimentsEnabled && ( - <> - setShowAdvanced(!showAdvanced)} - > - Advanced Options - - - - {showAdvanced && ( - - - - )} - - )} + + + diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index b1fb5238c..f5fe4e8ca 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -8,13 +8,8 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; import { layout } from '@/components/layout'; -import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; -import { SearchHeader } from '@/components/SearchHeader'; -import { formatPathRelativeToHome } from '@/utils/sessionUtils'; -import { resolveAbsolutePath } from '@/utils/pathUtils'; +import { PathSelector } from '@/components/newSession/PathSelector'; const stylesheet = StyleSheet.create((theme) => ({ emptyContainer: { @@ -64,10 +59,8 @@ export default function PathPickerScreen() { const recentMachinePaths = useSetting('recentMachinePaths'); const usePickerSearch = useSetting('usePickerSearch'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); - const inputRef = useRef(null); const [customPath, setCustomPath] = useState(params.selectedPath || ''); - const [searchQuery, setSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -133,66 +126,6 @@ export default function PathPickerScreen() { router.back(); }, [customPath, router, machine, navigation]); - const suggestedPaths = useMemo(() => { - if (!machine) return []; - const homeDir = machine.metadata?.homeDir || '/home'; - return [ - homeDir, - `${homeDir}/projects`, - `${homeDir}/Documents`, - `${homeDir}/Desktop`, - ]; - }, [machine]); - - const favoritePaths = useMemo(() => { - if (!machine) return []; - const homeDir = machine.metadata?.homeDir || '/home'; - const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); - const seen = new Set(); - const ordered: string[] = []; - for (const p of paths) { - if (!p) continue; - if (seen.has(p)) continue; - seen.add(p); - ordered.push(p); - } - return ordered; - }, [favoriteDirectories, machine]); - - const filteredRecentPaths = useMemo(() => { - const base = recentPaths.filter((p) => !favoritePaths.includes(p)); - if (!usePickerSearch || !searchQuery.trim()) return base; - const query = searchQuery.toLowerCase(); - return base.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); - - const filteredSuggestedPaths = useMemo(() => { - const base = suggestedPaths.filter((p) => !favoritePaths.includes(p)); - if (!usePickerSearch || !searchQuery.trim()) return base; - const query = searchQuery.toLowerCase(); - return base.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); - - const filteredFavoritePaths = useMemo(() => { - if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; - const query = searchQuery.toLowerCase(); - return favoritePaths.filter((path) => path.toLowerCase().includes(query)); - }, [favoritePaths, searchQuery, usePickerSearch]); - - const toggleFavorite = React.useCallback((absolutePath: string) => { - if (!machine) return; - const homeDir = machine.metadata?.homeDir || '/home'; - - const relativePath = formatPathRelativeToHome(absolutePath, homeDir); - const resolved = resolveAbsolutePath(relativePath, homeDir); - const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); - - setFavoriteDirectories(isInFavorites - ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) - : [...favoriteDirectories, relativePath] - ); - }, [favoriteDirectories, machine, setFavoriteDirectories]); - if (!machine) { return ( <> @@ -255,148 +188,16 @@ export default function PathPickerScreen() { }} /> - {usePickerSearch && ( - - )} - - - - - - - - - {filteredFavoritePaths.length > 0 && ( - - {filteredFavoritePaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === filteredFavoritePaths.length - 1; - return ( - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} - showDivider={!isLast} - /> - ); - })} - - )} - - {filteredRecentPaths.length > 0 && ( - - {filteredRecentPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === filteredRecentPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} - showDivider={!isLast} - /> - ); - })} - - )} - - {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( - - {filteredSuggestedPaths.map((path, index) => { - const isSelected = customPath.trim() === path; - const isLast = index === filteredSuggestedPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( - } - onPress={() => { - setCustomPath(path); - setTimeout(() => inputRef.current?.focus(), 50); - }} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} - showDivider={!isLast} - /> - ); - })} - - )} + diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx new file mode 100644 index 000000000..0f2c6e4d9 --- /dev/null +++ b/sources/components/newSession/PathSelector.tsx @@ -0,0 +1,255 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { View, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; +import { SearchHeader } from '@/components/SearchHeader'; +import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput'; +import { formatPathRelativeToHome } from '@/utils/sessionUtils'; +import { resolveAbsolutePath } from '@/utils/pathUtils'; + +export interface PathSelectorProps { + machineHomeDir: string; + selectedPath: string; + onChangeSelectedPath: (path: string) => void; + recentPaths: string[]; + usePickerSearch: boolean; + favoriteDirectories: string[]; + onChangeFavoriteDirectories: (dirs: string[]) => void; +} + +const stylesheet = StyleSheet.create((theme) => ({ + pathInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 16, + }, + pathInput: { + flex: 1, + backgroundColor: theme.colors.input.background, + borderRadius: 10, + paddingHorizontal: 12, + minHeight: 36, + position: 'relative', + borderWidth: 0.5, + borderColor: theme.colors.divider, + }, +})); + +export function PathSelector({ + machineHomeDir, + selectedPath, + onChangeSelectedPath, + recentPaths, + usePickerSearch, + favoriteDirectories, + onChangeFavoriteDirectories, +}: PathSelectorProps) { + const { theme } = useUnistyles(); + const styles = stylesheet; + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + + const suggestedPaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + return [ + homeDir, + `${homeDir}/projects`, + `${homeDir}/Documents`, + `${homeDir}/Desktop`, + ]; + }, [machineHomeDir]); + + const favoritePaths = useMemo(() => { + const homeDir = machineHomeDir || '/home'; + const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir)); + const seen = new Set(); + const ordered: string[] = []; + for (const p of paths) { + if (!p) continue; + if (seen.has(p)) continue; + seen.add(p); + ordered.push(p); + } + return ordered; + }, [favoriteDirectories, machineHomeDir]); + + const filteredFavoritePaths = useMemo(() => { + if (!usePickerSearch || !searchQuery.trim()) return favoritePaths; + const query = searchQuery.toLowerCase(); + return favoritePaths.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, usePickerSearch]); + + const filteredRecentPaths = useMemo(() => { + const base = recentPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]); + + const filteredSuggestedPaths = useMemo(() => { + const base = suggestedPaths.filter((p) => !favoritePaths.includes(p)); + if (!usePickerSearch || !searchQuery.trim()) return base; + const query = searchQuery.toLowerCase(); + return base.filter((path) => path.toLowerCase().includes(query)); + }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); + + const toggleFavorite = React.useCallback((absolutePath: string) => { + const homeDir = machineHomeDir || '/home'; + + const relativePath = formatPathRelativeToHome(absolutePath, homeDir); + const resolved = resolveAbsolutePath(relativePath, homeDir); + const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved); + + onChangeFavoriteDirectories(isInFavorites + ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved) + : [...favoriteDirectories, relativePath] + ); + }, [favoriteDirectories, machineHomeDir, onChangeFavoriteDirectories]); + + const setPathAndFocus = React.useCallback((path: string) => { + onChangeSelectedPath(path); + setTimeout(() => inputRef.current?.focus(), 50); + }, [onChangeSelectedPath]); + + return ( + <> + {usePickerSearch && ( + + )} + + + + + + + + + + {filteredFavoritePaths.length > 0 && ( + + {filteredFavoritePaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + )} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length > 0 && ( + + {filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + )} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( + + {filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + { + e.stopPropagation(); + toggleFavorite(path); + }} + > + + + )} + showDivider={!isLast} + /> + ); + })} + + )} + + ); +} From c6f3441f9529ab147bab179cf0ccf30102db1dcd Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 10:07:18 +0100 Subject: [PATCH 20/72] refactor(wizard): unify bottom chips and profile icons - Add permission chip + scroll-to-section behavior in wizard\n- Unify profile icons across wizard, chip bar, and picker\n- Align session type selector with ItemGroup styling --- sources/app/(app)/new/index.tsx | 144 +++++++++++-------- sources/app/(app)/new/pick/profile.tsx | 30 +++- sources/components/AgentInput.tsx | 53 ++++--- sources/components/ProfileEditForm.tsx | 6 +- sources/components/SessionTypeSelector.tsx | 159 +++++++-------------- sources/sync/profileUtils.ts | 13 ++ 6 files changed, 209 insertions(+), 196 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c4b18ebe7..0c1eff5b6 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -21,7 +21,7 @@ import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; import { randomUUID } from 'expo-crypto'; @@ -820,49 +820,65 @@ function NewSessionWizard() { }, [agentType, permissionMode]); // Scroll to section helpers - for AgentInput button clicks - const scrollToSection = React.useCallback((ref: React.RefObject) => { - if (!ref.current || !scrollViewRef.current) return; - - // Use requestAnimationFrame to ensure layout is painted before measuring - requestAnimationFrame(() => { - if (ref.current && scrollViewRef.current) { - ref.current.measureLayout( - scrollViewRef.current as any, - (x, y) => { - scrollViewRef.current?.scrollTo({ y: y - 20, animated: true }); - }, - () => { - console.warn('measureLayout failed'); - } - ); - } - }); + const wizardSectionOffsets = React.useRef<{ profile?: number; machine?: number; path?: number; permission?: number }>({}); + const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + return (e: any) => { + wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; + }; + }, []); + const scrollToWizardSection = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { + const y = wizardSectionOffsets.current[key]; + if (typeof y !== 'number' || !scrollViewRef.current) return; + scrollViewRef.current.scrollTo({ y: Math.max(0, y - 20), animated: true }); }, []); const handleAgentInputProfileClick = React.useCallback(() => { - if (!useProfiles) { - return; - } - router.push({ - pathname: '/new/pick/profile', - params: { - ...(selectedProfileId ? { selectedId: selectedProfileId } : {}), - ...(selectedMachineId ? { machineId: selectedMachineId } : {}), - }, - }); - }, [router, selectedMachineId, selectedProfileId, useProfiles]); + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); const handleAgentInputMachineClick = React.useCallback(() => { - scrollToSection(machineSectionRef); - }, [scrollToSection]); + scrollToWizardSection('machine'); + }, [scrollToWizardSection]); const handleAgentInputPathClick = React.useCallback(() => { - scrollToSection(pathSectionRef); - }, [scrollToSection]); + scrollToWizardSection('path'); + }, [scrollToWizardSection]); + + const handleAgentInputPermissionClick = React.useCallback(() => { + scrollToWizardSection('permission'); + }, [scrollToWizardSection]); const handleAgentInputAgentClick = React.useCallback(() => { - scrollToSection(profileSectionRef); // Agent tied to profile section - }, [scrollToSection]); + scrollToWizardSection('profile'); + }, [scrollToWizardSection]); + + const profileIconContainerStyle = React.useMemo(() => ({ + width: 29, + height: 29, + borderRadius: 14.5, + backgroundColor: theme.colors.surfacePressed, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }), [theme.colors.surfacePressed]); + + const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { + const primary = getProfilePrimaryCli(profile); + const iconName = + primary === 'claude' ? 'cloud-outline' : + primary === 'codex' ? 'terminal-outline' : + primary === 'gemini' ? 'planet-outline' : + primary === 'multi' ? 'sparkles-outline' : + 'person-outline'; + return ( + + + + ); + }, [profileIconContainerStyle, theme.colors.textSecondary]); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { @@ -1248,7 +1264,7 @@ function NewSessionWizard() { - + {/* CLI Detection Status Banner - shows after detection completes */} {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( @@ -1565,7 +1581,7 @@ function NewSessionWizard() { key={profile.id} title={profile.name} subtitle={getProfileSubtitle(profile)} - leftElement={} + leftElement={renderProfileLeftElement(profile)} showChevron={false} selected={isSelected} disabled={!availability.available} @@ -1614,7 +1630,7 @@ function NewSessionWizard() { key={profile.id} title={profile.name} subtitle={getProfileSubtitle(profile)} - leftElement={} + leftElement={renderProfileLeftElement(profile)} showChevron={false} selected={isSelected} disabled={!availability.available} @@ -1706,8 +1722,10 @@ function NewSessionWizard() { )} + + {/* Section 2: Machine Selection */} - + 2. @@ -1740,7 +1758,7 @@ function NewSessionWizard() { {/* Section 3: Working Directory */} - + 3. @@ -1761,7 +1779,7 @@ function NewSessionWizard() { {/* Section 4: Permission Mode */} - + 4. @@ -1810,7 +1828,9 @@ function NewSessionWizard() { ))} - + + + @@ -1821,26 +1841,28 @@ function NewSessionWizard() { {/* Section 5: AgentInput - Sticky at bottom */} 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - []} - agentType={agentType} - onAgentClick={handleAgentInputAgentClick} - modelMode={modelMode} - onModelModeChange={setModelMode} - connectionStatus={connectionStatus} - machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} - onMachineClick={handleAgentInputMachineClick} - currentPath={selectedPath} - onPathClick={handleAgentInputPathClick} + []} + agentType={agentType} + onAgentClick={handleAgentInputAgentClick} + permissionMode={permissionMode} + onPermissionClick={handleAgentInputPermissionClick} + modelMode={modelMode} + onModelModeChange={setModelMode} + connectionStatus={connectionStatus} + machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host} + onMachineClick={handleAgentInputMachineClick} + currentPath={selectedPath} + onPathClick={handleAgentInputPathClick} {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleAgentInputProfileClick } : {})} - /> + /> diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 0219f0ab9..5dc51e4be 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -8,7 +8,7 @@ import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { useUnistyles } from 'react-native-unistyles'; import { randomUUID } from 'expo-crypto'; import { AIBackendProfile } from '@/sync/settings'; @@ -25,6 +25,30 @@ export default function ProfilePickerScreen() { const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + const profileIconContainerStyle = React.useMemo(() => ({ + width: 29, + height: 29, + borderRadius: 14.5, + backgroundColor: theme.colors.surfacePressed, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }), [theme.colors.surfacePressed]); + + const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { + const primary = getProfilePrimaryCli(profile); + const iconName = + primary === 'claude' ? 'cloud-outline' : + primary === 'codex' ? 'terminal-outline' : + primary === 'gemini' ? 'planet-outline' : + primary === 'multi' ? 'sparkles-outline' : + 'person-outline'; + return ( + + + + ); + }, [profileIconContainerStyle, theme.colors.textSecondary]); + const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; @@ -154,7 +178,7 @@ export default function ProfilePickerScreen() { key={profile.id} title={profile.name} subtitle={t('profiles.defaultModel')} - icon={} + icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} @@ -200,7 +224,7 @@ export default function ProfilePickerScreen() { key={profile.id} title={profile.name} subtitle={t('profiles.defaultModel')} - icon={} + icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 2fbc2918b..c11500d48 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -22,7 +22,7 @@ import { Theme } from '@/theme'; import { t } from '@/text'; import { Metadata } from '@/sync/storageTypes'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile } from '@/sync/profileUtils'; +import { getBuiltInProfile, getProfilePrimaryCli } from '@/sync/profileUtils'; interface AgentInputProps { value: string; @@ -35,6 +35,7 @@ interface AgentInputProps { isMicActive?: boolean; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; + onPermissionClick?: () => void; modelMode?: ModelMode; onModelModeChange?: (mode: ModelMode) => void; metadata?: Metadata | null; @@ -332,11 +333,16 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === null) return 'radio-button-off-outline'; - if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; - return 'person-outline'; - }, [props.profileId]); + const profileIcon = React.useMemo(() => { + if (props.profileId === null) return 'radio-button-off-outline'; + if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; + const primary = getProfilePrimaryCli(currentProfile); + if (primary === 'claude') return 'cloud-outline'; + if (primary === 'codex') return 'terminal-outline'; + if (primary === 'gemini') return 'planet-outline'; + if (primary === 'multi') return 'sparkles-outline'; + return 'person-outline'; + }, [currentProfile, props.profileId]); // Calculate context warning const contextWarning = props.usageData?.contextSize @@ -443,6 +449,8 @@ export const AgentInput = React.memo(React.forwardRef !prev); }, []); + const showPermissionChip = Boolean(props.onPermissionModeChange || props.onPermissionClick); + // Handle settings selection const handleSettingsSelect = React.useCallback((mode: PermissionMode) => { hapticsLight(); @@ -745,14 +753,21 @@ export const AgentInput = React.memo(React.forwardRef - {/* Settings button */} - {props.onPermissionModeChange && ( - ({ - flexDirection: 'row', - alignItems: 'center', + {/* Permission chip (popover in standard flow, scroll in wizard) */} + {showPermissionChip && ( + { + hapticsLight(); + if (props.onPermissionClick) { + props.onPermissionClick(); + return; + } + handleSettingsPress(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => ({ + flexDirection: 'row', + alignItems: 'center', borderRadius: Platform.select({ default: 16, android: 20 }), paddingHorizontal: 10, paddingVertical: 6, @@ -760,13 +775,13 @@ export const AgentInput = React.memo(React.forwardRef + })} + > + /> {permissionBadgeLabel} - - )} + + )} {/* Profile selector button - FIRST */} {props.onProfileClick && ( diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index fa8e7d99a..b43bc1297 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -133,11 +133,7 @@ export function ProfileEditForm({ )} - - - - - + diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index 33aefd357..b4420235a 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -1,142 +1,85 @@ import React from 'react'; -import { View, Text, Pressable, Platform } from 'react-native'; +import { View } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; import { t } from '@/text'; -interface SessionTypeSelectorProps { +export interface SessionTypeSelectorProps { value: 'simple' | 'worktree'; onChange: (value: 'simple' | 'worktree') => void; + title?: string | null; } const stylesheet = StyleSheet.create((theme) => ({ - container: { - backgroundColor: theme.colors.surface, - borderRadius: Platform.select({ default: 12, android: 16 }), - marginBottom: 12, - overflow: 'hidden', - }, - title: { - fontSize: 13, - color: theme.colors.textSecondary, - marginBottom: 8, - marginLeft: 16, - marginTop: 12, - ...Typography.default('semiBold'), - }, - optionContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - minHeight: 44, - }, - optionPressed: { - backgroundColor: theme.colors.surfacePressed, - }, - radioButton: { + radioOuter: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center', - marginRight: 12, }, - radioButtonActive: { + radioActive: { borderColor: theme.colors.radio.active, }, - radioButtonInactive: { + radioInactive: { borderColor: theme.colors.radio.inactive, }, - radioButtonDot: { + radioDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.colors.radio.dot, }, - optionContent: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - optionLabel: { - fontSize: 16, - ...Typography.default('regular'), - }, - optionLabelActive: { - color: theme.colors.text, - }, - optionLabelInactive: { - color: theme.colors.text, - }, - divider: { - height: Platform.select({ ios: 0.33, default: 0.5 }), - backgroundColor: theme.colors.divider, - marginLeft: 48, - }, })); -export const SessionTypeSelector: React.FC = ({ value, onChange }) => { +export function SessionTypeSelectorRows({ value, onChange }: Pick) { const { theme } = useUnistyles(); const styles = stylesheet; - const handlePress = (type: 'simple' | 'worktree') => { - onChange(type); - }; - return ( - - {t('newSession.sessionType.title')} - - handlePress('simple')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'simple' && } - - - - {t('newSession.sessionType.simple')} - - - + <> + + {value === 'simple' && } + + )} + selected={value === 'simple'} + pressableStyle={value === 'simple' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + onPress={() => onChange('simple')} + showChevron={false} + showDivider={true} + /> - + + {value === 'worktree' && } + + )} + selected={value === 'worktree'} + pressableStyle={value === 'worktree' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + onPress={() => onChange('worktree')} + showChevron={false} + showDivider={false} + /> + + ); +} - handlePress('worktree')} - style={({ pressed }) => [ - styles.optionContainer, - pressed && styles.optionPressed, - ]} - > - - {value === 'worktree' && } - - - - {t('newSession.sessionType.worktree')} - - - - +export function SessionTypeSelector({ value, onChange, title = t('newSession.sessionType.title') }: SessionTypeSelectorProps) { + if (title === null) { + return ; + } + + return ( + + + ); -}; \ No newline at end of file +} + diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts index d90a98a93..fc0668a74 100644 --- a/sources/sync/profileUtils.ts +++ b/sources/sync/profileUtils.ts @@ -1,5 +1,18 @@ import { AIBackendProfile } from './settings'; +export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none'; + +export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli { + if (!profile) return 'none'; + const supported = Object.entries(profile.compatibility ?? {}) + .filter(([, isSupported]) => isSupported) + .map(([cli]) => cli as 'claude' | 'codex' | 'gemini'); + + if (supported.length === 0) return 'none'; + if (supported.length === 1) return supported[0]; + return 'multi'; +} + /** * Documentation and expected values for built-in profiles. * These help users understand what environment variables to set and their expected values. From da8a1ef74aaf906db7b56d077fa4cd395f16052b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 10:33:16 +0100 Subject: [PATCH 21/72] fix(new-session): improve close and picker affordances - Add accessible close button with web fallback navigation\n- Align picker right-side spacing for status/check/favorite\n- Add consistent selected indicator for path rows --- sources/app/(app)/new/index.tsx | 29 +++++-- sources/components/SearchableListSelector.tsx | 2 +- .../components/newSession/PathSelector.tsx | 78 ++++++++----------- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 0c1eff5b6..9de54f2f1 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1104,6 +1104,17 @@ function NewSessionWizard() { const showInlineClose = screenWidth < 520; const handleCloseModal = React.useCallback(() => { + // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. + // Fall back to home so the user always has an exit. + if (Platform.OS === 'web') { + if (typeof window !== 'undefined' && window.history.length > 1) { + router.back(); + } else { + router.replace('/'); + } + return; + } + router.back(); }, [router]); @@ -1166,6 +1177,8 @@ function NewSessionWizard() { > {showInlineClose && ( - {showInlineClose && ( - { const RECENT_ITEMS_DEFAULT_VISIBLE = 5; const STATUS_DOT_TEXT_GAP = 4; -const ITEM_SPACING_GAP = 8; +const ITEM_SPACING_GAP = 16; const stylesheet = StyleSheet.create((theme) => ({ showMoreTitle: { diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 0f2c6e4d9..393f02268 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -39,6 +39,8 @@ const stylesheet = StyleSheet.create((theme) => ({ }, })); +const ITEM_RIGHT_GAP = 16; + export function PathSelector({ machineHomeDir, selectedPath, @@ -115,6 +117,34 @@ export function PathSelector({ setTimeout(() => inputRef.current?.focus(), 50); }, [onChangeSelectedPath]); + const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavorite(absolutePath); + }} + > + + + + ); + }, [theme.colors.button.primary.background, theme.colors.textSecondary, toggleFavorite]); + return ( <> {usePickerSearch && ( @@ -155,21 +185,7 @@ export function PathSelector({ selected={isSelected} showChevron={false} pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} + rightElement={renderRightElement(path, isSelected, true)} showDivider={!isLast} /> ); @@ -192,21 +208,7 @@ export function PathSelector({ selected={isSelected} showChevron={false} pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} + rightElement={renderRightElement(path, isSelected, isFavorite)} showDivider={!isLast} /> ); @@ -229,21 +231,7 @@ export function PathSelector({ selected={isSelected} showChevron={false} pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - { - e.stopPropagation(); - toggleFavorite(path); - }} - > - - - )} + rightElement={renderRightElement(path, isSelected, isFavorite)} showDivider={!isLast} /> ); From cf835048e9b2a360fc3d20f7fb131cfb5e72a9ab Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 11:21:32 +0100 Subject: [PATCH 22/72] refactor(pickers): split search toggles and unify wizard inputs - Move wizard machine search into a list-style group and filter inline\n- Render path picker search bar full-width (match machine picker)\n- Show selection indicator for No Profile row\n- Add separate settings toggles for machine/path picker search\n- Align env var card width with ItemGroup --- sources/app/(app)/new/index.tsx | 98 ++++++++++++++++--- sources/app/(app)/new/pick/machine.tsx | 4 +- sources/app/(app)/new/pick/path.tsx | 16 ++- sources/app/(app)/settings/features.tsx | 16 ++- .../components/EnvironmentVariableCard.tsx | 2 +- .../components/newSession/PathSelector.tsx | 31 +++++- sources/sync/settings.spec.ts | 20 +++- sources/sync/settings.ts | 19 +++- sources/text/_default.ts | 4 + sources/text/translations/ca.ts | 4 + sources/text/translations/es.ts | 4 + sources/text/translations/it.ts | 4 + sources/text/translations/ja.ts | 4 + sources/text/translations/pl.ts | 4 + sources/text/translations/pt.ts | 4 + sources/text/translations/ru.ts | 4 + sources/text/translations/zh-Hans.ts | 4 + 17 files changed, 211 insertions(+), 31 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 9de54f2f1..75f3e1c76 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -33,6 +33,7 @@ import { StatusDot } from '@/components/StatusDot'; import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence'; import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -281,7 +282,8 @@ function NewSessionWizard() { const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); - const usePickerSearch = useSetting('usePickerSearch'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); const [profiles, setProfiles] = useSettingMutable('profiles'); const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); @@ -703,6 +705,40 @@ function NewSessionWizard() { .map(item => item.machine); }, [sessions, machines]); + const [wizardMachineSearchQuery, setWizardMachineSearchQuery] = React.useState(''); + const normalizedWizardMachineSearchQuery = React.useMemo(() => wizardMachineSearchQuery.trim().toLowerCase(), [wizardMachineSearchQuery]); + + const favoriteMachineItems = React.useMemo(() => { + return machines.filter(m => favoriteMachines.includes(m.id)); + }, [machines, favoriteMachines]); + + const wizardMachineMatchesSearch = React.useCallback((machine: (typeof machines)[number]) => { + if (!useMachinePickerSearch || !normalizedWizardMachineSearchQuery) return true; + const displayName = (machine.metadata?.displayName || '').toLowerCase(); + const host = (machine.metadata?.host || '').toLowerCase(); + const id = (machine.id || '').toLowerCase(); + const query = normalizedWizardMachineSearchQuery; + return displayName.includes(query) || host.includes(query) || id.includes(query); + }, [normalizedWizardMachineSearchQuery, useMachinePickerSearch]); + + const wizardMachines = React.useMemo(() => { + return useMachinePickerSearch && normalizedWizardMachineSearchQuery + ? machines.filter(wizardMachineMatchesSearch) + : machines; + }, [machines, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); + + const wizardRecentMachines = React.useMemo(() => { + return useMachinePickerSearch && normalizedWizardMachineSearchQuery + ? recentMachines.filter(wizardMachineMatchesSearch) + : recentMachines; + }, [normalizedWizardMachineSearchQuery, recentMachines, useMachinePickerSearch, wizardMachineMatchesSearch]); + + const wizardFavoriteMachineItems = React.useMemo(() => { + return useMachinePickerSearch && normalizedWizardMachineSearchQuery + ? favoriteMachineItems.filter(wizardMachineMatchesSearch) + : favoriteMachineItems; + }, [favoriteMachineItems, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); + const recentPaths = React.useMemo(() => { if (!selectedMachineId) return []; @@ -1580,9 +1616,17 @@ function NewSessionWizard() { showChevron={false} selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} + rightElement={ + + + + } /> - - {DEFAULT_PROFILES.map((profileDisplay, index) => { const profile = getBuiltInProfile(profileDisplay.id); if (!profile) return null; @@ -1749,13 +1793,34 @@ function NewSessionWizard() { + {useMachinePickerSearch && ( + <> + + + + + + + + )} + favoriteMachines.includes(m.id))} + recentMachines={wizardRecentMachines} + favoriteMachines={wizardFavoriteMachineItems} showFavorites={true} - showSearch={usePickerSearch} + showSearch={false} onSelect={(machine) => { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); @@ -1782,15 +1847,16 @@ function NewSessionWizard() { - + {/* Section 4: Permission Mode */} diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx index 8cff81e78..3568fc3d2 100644 --- a/sources/app/(app)/new/pick/machine.tsx +++ b/sources/app/(app)/new/pick/machine.tsx @@ -36,7 +36,7 @@ export default function MachinePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string }>(); const machines = useAllMachines(); const sessions = useSessions(); - const usePickerSearch = useSetting('usePickerSearch'); + const useMachinePickerSearch = useSetting('useMachinePickerSearch'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); const selectedMachine = machines.find(m => m.id === params.selectedId) || null; @@ -122,7 +122,7 @@ export default function MachinePickerScreen() { favoriteMachines={machines.filter(m => favoriteMachines.includes(m.id))} onSelect={handleSelectMachine} showFavorites={true} - showSearch={usePickerSearch} + showSearch={useMachinePickerSearch} onToggleFavorite={(machine) => { const isInFavorites = favoriteMachines.includes(machine.id); setFavoriteMachines(isInFavorites diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx index f5fe4e8ca..4762b9f28 100644 --- a/sources/app/(app)/new/pick/path.tsx +++ b/sources/app/(app)/new/pick/path.tsx @@ -10,6 +10,7 @@ import { t } from '@/text'; import { ItemList } from '@/components/ItemList'; import { layout } from '@/components/layout'; import { PathSelector } from '@/components/newSession/PathSelector'; +import { SearchHeader } from '@/components/SearchHeader'; const stylesheet = StyleSheet.create((theme) => ({ emptyContainer: { @@ -57,10 +58,11 @@ export default function PathPickerScreen() { const machines = useAllMachines(); const sessions = useSessions(); const recentMachinePaths = useSetting('recentMachinePaths'); - const usePickerSearch = useSetting('usePickerSearch'); + const usePathPickerSearch = useSetting('usePathPickerSearch'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const [customPath, setCustomPath] = useState(params.selectedPath || ''); + const [pathSearchQuery, setPathSearchQuery] = useState(''); // Get the selected machine const machine = useMemo(() => { @@ -188,13 +190,23 @@ export default function PathPickerScreen() { }} /> + {usePathPickerSearch && ( + + )} diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx index 19ecdef28..c701ef908 100644 --- a/sources/app/(app)/settings/features.tsx +++ b/sources/app/(app)/settings/features.tsx @@ -15,7 +15,8 @@ export default function FeaturesSettingsScreen() { const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2'); const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions'); const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard'); - const [usePickerSearch, setUsePickerSearch] = useSettingMutable('usePickerSearch'); + const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch'); + const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch'); return ( @@ -75,10 +76,17 @@ export default function FeaturesSettingsScreen() { showChevron={false} /> } - rightElement={} + rightElement={} + showChevron={false} + /> + } + rightElement={} showChevron={false} /> void; recentPaths: string[]; usePickerSearch: boolean; + searchVariant?: 'header' | 'group' | 'none'; + searchQuery?: string; + onChangeSearchQuery?: (text: string) => void; favoriteDirectories: string[]; onChangeFavoriteDirectories: (dirs: string[]) => void; } @@ -47,13 +50,19 @@ export function PathSelector({ onChangeSelectedPath, recentPaths, usePickerSearch, + searchVariant = 'header', + searchQuery: controlledSearchQuery, + onChangeSearchQuery: onChangeSearchQueryProp, favoriteDirectories, onChangeFavoriteDirectories, }: PathSelectorProps) { const { theme } = useUnistyles(); const styles = stylesheet; const inputRef = useRef(null); - const [searchQuery, setSearchQuery] = useState(''); + + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); + const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; + const setSearchQuery = onChangeSearchQueryProp ?? setUncontrolledSearchQuery; const suggestedPaths = useMemo(() => { const homeDir = machineHomeDir || '/home'; @@ -147,7 +156,7 @@ export function PathSelector({ return ( <> - {usePickerSearch && ( + {usePickerSearch && searchVariant === 'header' && ( + {usePickerSearch && searchVariant === 'group' && ( + + + + + + )} + {filteredFavoritePaths.length > 0 && ( {filteredFavoritePaths.map((path, index) => { diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 0f8d49fe7..a7ca52f12 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -106,6 +106,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -142,6 +144,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', // This should be preserved from currentSettings @@ -178,6 +182,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -216,6 +222,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -259,6 +267,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -311,6 +321,8 @@ describe('settings', () => { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'gradient', @@ -374,9 +386,11 @@ describe('settings', () => { experiments: false, useProfiles: false, alwaysShowContextSize: false, - useEnhancedSessionWizard: false, - usePickerSearch: false, - avatarStyle: 'brutalist', + useEnhancedSessionWizard: false, + usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, + avatarStyle: 'brutalist', showFlavorIcons: false, compactSessionView: false, agentInputEnterToSend: true, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 45ac8bf12..139bd2f76 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -240,7 +240,10 @@ export const SettingsSchema = z.object({ experiments: z.boolean().describe('Whether to enable experimental features'), useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'), useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'), - usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs'), + // Legacy combined toggle (kept for backward compatibility; see settingsParse migration) + usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs (legacy combined toggle)'), + useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'), + usePathPickerSearch: z.boolean().describe('Whether to show search in path picker UIs'), alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'), agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'), avatarStyle: z.string().describe('Avatar display style'), @@ -312,6 +315,8 @@ export const settingsDefaults: Settings = { useProfiles: false, useEnhancedSessionWizard: false, usePickerSearch: false, + useMachinePickerSearch: false, + usePathPickerSearch: false, alwaysShowContextSize: false, agentInputEnterToSend: true, avatarStyle: 'brutalist', @@ -392,6 +397,18 @@ export function settingsParse(settings: unknown): Settings { result.preferredLanguage = 'zh-Hans'; } + // Migration: Convert legacy combined picker-search toggle into per-picker toggles. + // Only apply if new fields were not present in persisted settings. + const hasMachineSearch = 'useMachinePickerSearch' in input; + const hasPathSearch = 'usePathPickerSearch' in input; + if (!hasMachineSearch && !hasPathSearch) { + const legacy = SettingsSchema.shape.usePickerSearch.safeParse(input.usePickerSearch); + if (legacy.success && legacy.data === true) { + result.useMachinePickerSearch = true; + result.usePathPickerSearch = true; + } + } + // Preserve unknown fields (forward compatibility). for (const [key, value] of Object.entries(input)) { if (!(key in SettingsSchema.shape)) { diff --git a/sources/text/_default.ts b/sources/text/_default.ts index d6b14fa86..dfacda70f 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -212,6 +212,10 @@ export const en = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index b440c2b72..4a55163eb 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -213,6 +213,10 @@ export const ca: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 7a899354c..75d5eaa44 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -213,6 +213,10 @@ export const es: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 60922056d..1387529c0 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -242,6 +242,10 @@ export const it: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 041064dfc..72f3aa402 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -245,6 +245,10 @@ export const ja: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 48e0e9282..d44114e02 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -224,6 +224,10 @@ export const pl: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index d4fa5a2fa..5e6e625be 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -213,6 +213,10 @@ export const pt: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 2983f2ac2..49d57231e 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -195,6 +195,10 @@ export const ru: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index b60d797f2..bdbb5f19e 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -215,6 +215,10 @@ export const zhHans: TranslationStructure = { profilesDisabled: 'Profile selection disabled', pickerSearch: 'Picker Search', pickerSearchSubtitle: 'Show a search field in machine and path pickers', + machinePickerSearch: 'Machine search', + machinePickerSearchSubtitle: 'Show a search field in machine pickers', + pathPickerSearch: 'Path search', + pathPickerSearchSubtitle: 'Show a search field in path pickers', }, errors: { From b4c2f260f9212b3c48692f29e7239a32c0f3c662 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 11:59:13 +0100 Subject: [PATCH 23/72] fix(ui): align wizard search and env var cards --- sources/app/(app)/new/index.tsx | 61 ++-------------- .../components/EnvironmentVariableCard.tsx | 4 +- .../components/EnvironmentVariablesList.tsx | 54 +++++++------- sources/components/SearchableListSelector.tsx | 39 +++++++--- .../components/newSession/MachineSelector.tsx | 3 + .../components/newSession/PathSelector.tsx | 72 ++++++++++++++----- 6 files changed, 123 insertions(+), 110 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 75f3e1c76..86f2114e9 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -705,40 +705,10 @@ function NewSessionWizard() { .map(item => item.machine); }, [sessions, machines]); - const [wizardMachineSearchQuery, setWizardMachineSearchQuery] = React.useState(''); - const normalizedWizardMachineSearchQuery = React.useMemo(() => wizardMachineSearchQuery.trim().toLowerCase(), [wizardMachineSearchQuery]); - const favoriteMachineItems = React.useMemo(() => { return machines.filter(m => favoriteMachines.includes(m.id)); }, [machines, favoriteMachines]); - const wizardMachineMatchesSearch = React.useCallback((machine: (typeof machines)[number]) => { - if (!useMachinePickerSearch || !normalizedWizardMachineSearchQuery) return true; - const displayName = (machine.metadata?.displayName || '').toLowerCase(); - const host = (machine.metadata?.host || '').toLowerCase(); - const id = (machine.id || '').toLowerCase(); - const query = normalizedWizardMachineSearchQuery; - return displayName.includes(query) || host.includes(query) || id.includes(query); - }, [normalizedWizardMachineSearchQuery, useMachinePickerSearch]); - - const wizardMachines = React.useMemo(() => { - return useMachinePickerSearch && normalizedWizardMachineSearchQuery - ? machines.filter(wizardMachineMatchesSearch) - : machines; - }, [machines, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); - - const wizardRecentMachines = React.useMemo(() => { - return useMachinePickerSearch && normalizedWizardMachineSearchQuery - ? recentMachines.filter(wizardMachineMatchesSearch) - : recentMachines; - }, [normalizedWizardMachineSearchQuery, recentMachines, useMachinePickerSearch, wizardMachineMatchesSearch]); - - const wizardFavoriteMachineItems = React.useMemo(() => { - return useMachinePickerSearch && normalizedWizardMachineSearchQuery - ? favoriteMachineItems.filter(wizardMachineMatchesSearch) - : favoriteMachineItems; - }, [favoriteMachineItems, normalizedWizardMachineSearchQuery, useMachinePickerSearch, wizardMachineMatchesSearch]); - const recentPaths = React.useMemo(() => { if (!selectedMachineId) return []; @@ -1793,34 +1763,15 @@ function NewSessionWizard() { - {useMachinePickerSearch && ( - <> - - - - - - - - )} - { setSelectedMachineId(machine.id); const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 692461848..689a290e6 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -104,7 +104,6 @@ export function EnvironmentVariableCard({ backgroundColor: theme.colors.surface, borderRadius: 16, padding: 16, - marginHorizontal: Platform.select({ ios: 16, default: 12 }), marginBottom: 12, shadowColor: theme.colors.shadow.color, shadowOffset: { width: 0, height: 0.33 }, @@ -156,7 +155,8 @@ export function EnvironmentVariableCard({ {/* Toggle: Copy from remote machine */} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index bbebb2a4a..f18e301a7 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Pressable, TextInput } from 'react-native'; +import { View, Text, Pressable, TextInput, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -187,31 +187,33 @@ export function EnvironmentVariablesList({ )} - - {environmentVariables.map((envVar, index) => { - const varNameFromValue = extractVarNameFromValue(envVar.value); - const docs = getDocumentation(varNameFromValue || envVar.name); - - const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; - const isSecret = - docs.isSecret || - SECRET_NAME_REGEX.test(envVar.name) || - SECRET_NAME_REGEX.test(varNameFromValue || ''); - - return ( - handleUpdateVariable(index, newValue)} - onDelete={() => handleDeleteVariable(index)} - onDuplicate={() => handleDuplicateVariable(index)} - /> - ); - })} + + + {environmentVariables.map((envVar, index) => { + const varNameFromValue = extractVarNameFromValue(envVar.value); + const docs = getDocumentation(varNameFromValue || envVar.name); + + const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; + const isSecret = + docs.isSecret || + SECRET_NAME_REGEX.test(envVar.name) || + SECRET_NAME_REGEX.test(varNameFromValue || ''); + + return ( + handleUpdateVariable(index, newValue)} + onDelete={() => handleDeleteVariable(index)} + onDuplicate={() => handleDuplicateVariable(index)} + /> + ); + })} + ); diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 75f916d91..27c595bf0 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -79,6 +79,7 @@ export interface SearchableListSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; } const RECENT_ITEMS_DEFAULT_VISIBLE = 5; @@ -107,6 +108,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) showFavorites = config.showFavorites !== false, showRecent = config.showRecent !== false, showSearch = config.showSearch !== false, + searchPlacement = 'header', } = props; const showAll = config.showAll !== false; @@ -243,18 +245,33 @@ export function SearchableListSelector(props: SearchableListSelectorProps) ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); + const hasRecentGroup = showRecent && filteredRecentItems.length > 0; + const hasFavoritesGroup = showFavorites && filteredFavoriteItems.length > 0; + const hasAllGroup = showAll && filteredItems.length > 0; + + const effectiveSearchPlacement = React.useMemo(() => { + if (!showSearch) return 'header' as const; + if (searchPlacement === 'recent' && !hasRecentGroup) return 'header' as const; + if (searchPlacement === 'favorites' && !hasFavoritesGroup) return 'header' as const; + if (searchPlacement === 'all' && !hasAllGroup) return 'header' as const; + return searchPlacement; + }, [hasAllGroup, hasFavoritesGroup, hasRecentGroup, searchPlacement, showSearch]); + + const searchNode = showSearch ? ( + + ) : null; + return ( <> - {showSearch && ( - - )} + {effectiveSearchPlacement === 'header' && searchNode} - {showRecent && filteredRecentItems.length > 0 && ( + {hasRecentGroup && ( + {effectiveSearchPlacement === 'recent' && searchNode} {recentItemsToShow.map((item, index, arr) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -284,8 +301,9 @@ export function SearchableListSelector(props: SearchableListSelectorProps) )} - {showFavorites && filteredFavoriteItems.length > 0 && ( + {hasFavoritesGroup && ( + {effectiveSearchPlacement === 'favorites' && searchNode} {filteredFavoriteItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -296,8 +314,9 @@ export function SearchableListSelector(props: SearchableListSelectorProps) )} - {showAll && filteredItems.length > 0 && ( + {hasAllGroup && ( + {effectiveSearchPlacement === 'all' && searchNode} {filteredItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 710816564..689bd146c 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -15,6 +15,7 @@ export interface MachineSelectorProps { showFavorites?: boolean; showRecent?: boolean; showSearch?: boolean; + searchPlacement?: 'header' | 'recent' | 'favorites' | 'all'; searchPlaceholder?: string; recentSectionTitle?: string; favoritesSectionTitle?: string; @@ -32,6 +33,7 @@ export function MachineSelector({ showFavorites = true, showRecent = true, showSearch = true, + searchPlacement = 'header', searchPlaceholder = 'Type to filter machines...', recentSectionTitle = 'Recent Machines', favoritesSectionTitle = 'Favorite Machines', @@ -97,6 +99,7 @@ export function MachineSelector({ selectedItem={selectedMachine} onSelect={onSelect} onToggleFavorite={onToggleFavorite} + searchPlacement={searchPlacement} /> ); } diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 32f3d61e1..2f506a37f 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -180,21 +180,31 @@ export function PathSelector({ - {usePickerSearch && searchVariant === 'group' && ( - - - - + {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length > 0 && ( + + + {filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} )} @@ -220,7 +230,7 @@ export function PathSelector({ )} - {filteredRecentPaths.length > 0 && ( + {filteredRecentPaths.length > 0 && searchVariant !== 'group' && ( {filteredRecentPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; @@ -243,7 +253,35 @@ export function PathSelector({ )} - {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( + {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( + + + {filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && searchVariant !== 'group' && ( {filteredSuggestedPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; From f0929d13536c7000598ecda90d20563c4a353329 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 12:27:32 +0100 Subject: [PATCH 24/72] fix(ui): unify picker selection and profile icons --- sources/app/(app)/new/index.tsx | 28 +++++-------- sources/app/(app)/new/pick/profile.tsx | 20 +++------- sources/components/AgentInput.tsx | 4 +- .../components/EnvironmentVariableCard.tsx | 40 +++++++++---------- sources/components/ProfileEditForm.tsx | 26 +++++++----- sources/components/SearchableListSelector.tsx | 23 ++++++++--- .../components/newSession/PathSelector.tsx | 8 ++++ sources/text/_default.ts | 2 +- 8 files changed, 78 insertions(+), 73 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 86f2114e9..8c7280f63 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -858,15 +858,6 @@ function NewSessionWizard() { scrollToWizardSection('profile'); }, [scrollToWizardSection]); - const profileIconContainerStyle = React.useMemo(() => ({ - width: 29, - height: 29, - borderRadius: 14.5, - backgroundColor: theme.colors.surfacePressed, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }), [theme.colors.surfacePressed]); - const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { const primary = getProfilePrimaryCli(profile); const iconName = @@ -876,15 +867,13 @@ function NewSessionWizard() { primary === 'multi' ? 'sparkles-outline' : 'person-outline'; return ( - - - + ); - }, [profileIconContainerStyle, theme.colors.textSecondary]); + }, [theme.colors.textSecondary]); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { @@ -1582,10 +1571,11 @@ function NewSessionWizard() { } + leftElement={} showChevron={false} selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} + pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={ selectProfile(profile.id)} rightElement={ @@ -1662,6 +1653,7 @@ function NewSessionWizard() { leftElement={renderProfileLeftElement(profile)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} disabled={!availability.available} onPress={() => selectProfile(profile.id)} rightElement={ diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 5dc51e4be..5c543b3b4 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -25,15 +25,6 @@ export default function ProfilePickerScreen() { const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; - const profileIconContainerStyle = React.useMemo(() => ({ - width: 29, - height: 29, - borderRadius: 14.5, - backgroundColor: theme.colors.surfacePressed, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }), [theme.colors.surfacePressed]); - const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { const primary = getProfilePrimaryCli(profile); const iconName = @@ -43,11 +34,9 @@ export default function ProfilePickerScreen() { primary === 'multi' ? 'sparkles-outline' : 'person-outline'; return ( - - - + ); - }, [profileIconContainerStyle, theme.colors.textSecondary]); + }, [theme.colors.textSecondary]); const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); @@ -157,10 +146,11 @@ export default function ProfilePickerScreen() { } + icon={} onPress={() => setProfileParamAndClose('')} showChevron={false} selected={selectedId === ''} + pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={selectedId === '' ? : null} @@ -182,6 +172,7 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={ {isSelected && ( @@ -228,6 +219,7 @@ export default function ProfilePickerScreen() { onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={ {isSelected && ( diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index c11500d48..124578271 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -334,8 +334,8 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === null) return 'radio-button-off-outline'; - if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'radio-button-off-outline'; + if (props.profileId === null) return 'settings-outline'; + if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'settings-outline'; const primary = getProfilePrimaryCli(currentProfile); if (primary === 'claude') return 'cloud-outline'; if (primary === 'codex') return 'terminal-outline'; diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 689a290e6..f06be84a5 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -69,6 +69,13 @@ export function EnvironmentVariableCard({ }: EnvironmentVariableCardProps) { const { theme } = useUnistyles(); + const secondaryTextStyle = React.useMemo(() => ({ + fontSize: Platform.select({ ios: 15, default: 14 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }), []); + // Parse current value const parsed = parseVariableValue(variable.value); const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); @@ -143,10 +150,9 @@ export function EnvironmentVariableCard({ {/* Description */} {description && ( {description} @@ -156,9 +162,8 @@ export function EnvironmentVariableCard({ Copy from remote machine @@ -197,36 +202,32 @@ export function EnvironmentVariableCard({ {remoteValue === undefined ? ( Checking remote machine... ) : remoteValue === null ? ( Value not found ) : ( <> Value found {showRemoteDiffersWarning && ( Differs from documented value: {expectedValue} @@ -238,11 +239,10 @@ export function EnvironmentVariableCard({ {useRemoteVariable && !isSecret && !machineId && ( Select a machine to check if variable exists @@ -251,11 +251,10 @@ export function EnvironmentVariableCard({ {/* Security message for secrets */} {isSecret && ( Secret value - not retrieved for security @@ -263,10 +262,9 @@ export function EnvironmentVariableCard({ {/* Value label */} {useRemoteVariable ? 'Default value:' : 'Value:'} @@ -297,10 +295,9 @@ export function EnvironmentVariableCard({ {/* Default override warning */} {showDefaultOverrideWarning && !isSecret && ( Overriding documented default: {expectedValue} @@ -308,10 +305,9 @@ export function EnvironmentVariableCard({ {/* Session preview */} Session will receive: {variable.name} = { isSecret diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index b43bc1297..803e4b99e 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -32,6 +32,7 @@ export function ProfileEditForm({ }: ProfileEditFormProps) { const { theme } = useUnistyles(); const styles = stylesheet; + const groupStyle = React.useMemo(() => ({ marginBottom: 8 }), []); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -108,7 +109,7 @@ export function ProfileEditForm({ return ( - + {profile.isBuiltIn && profileDocs?.setupGuideUrl && ( - + } @@ -132,11 +133,11 @@ export function ProfileEditForm({ )} - + - + {[ { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, @@ -162,12 +163,13 @@ export function ProfileEditForm({ onPress={() => setDefaultPermissionMode(option.value)} showChevron={false} selected={defaultPermissionMode === option.value} + pressableStyle={defaultPermissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={index < array.length - 1} /> ))} - + - + + + diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 27c595bf0..5aa021cfa 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -235,6 +235,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) onPress={() => onSelect(item)} showChevron={false} selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} /> ); @@ -257,7 +258,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) return searchPlacement; }, [hasAllGroup, hasFavoritesGroup, hasRecentGroup, searchPlacement, showSearch]); - const searchNode = showSearch ? ( + const searchNodeHeader = showSearch ? ( (props: SearchableListSelectorProps) /> ) : null; + const searchNodeEmbedded = showSearch ? ( + + ) : null; + return ( <> - {effectiveSearchPlacement === 'header' && searchNode} + {effectiveSearchPlacement === 'header' && searchNodeHeader} {hasRecentGroup && ( - {effectiveSearchPlacement === 'recent' && searchNode} + {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} {recentItemsToShow.map((item, index, arr) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -303,7 +316,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {hasFavoritesGroup && ( - {effectiveSearchPlacement === 'favorites' && searchNode} + {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} {filteredFavoriteItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; @@ -316,7 +329,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) {hasAllGroup && ( - {effectiveSearchPlacement === 'all' && searchNode} + {effectiveSearchPlacement === 'all' && searchNodeEmbedded} {filteredItems.map((item, index) => { const itemId = config.getItemId(item); const selectedId = selectedItem ? config.getItemId(selectedItem) : null; diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 2f506a37f..1fde87ad0 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -186,6 +186,10 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + containerStyle={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} /> {filteredRecentPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; @@ -259,6 +263,10 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + containerStyle={{ + backgroundColor: 'transparent', + borderBottomWidth: 0, + }} /> {filteredSuggestedPaths.map((path, index) => { const isSelected = selectedPath.trim() === path; diff --git a/sources/text/_default.ts b/sources/text/_default.ts index dfacda70f..024c7f6eb 100644 --- a/sources/text/_default.ts +++ b/sources/text/_default.ts @@ -895,7 +895,7 @@ export const en = { // Profile management feature title: 'Profiles', subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'No Profile', + noProfile: 'Default', noProfileDescription: 'Use default environment settings', defaultModel: 'Default Model', addProfile: 'Add Profile', From ecc62469175b7331756dfc32c0eb35d13434de90 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 13:34:50 +0100 Subject: [PATCH 25/72] fix(ui): use profile compatibility glyph icons --- sources/app/(app)/new/index.tsx | 20 ++------- sources/app/(app)/new/pick/profile.tsx | 16 ++----- .../newSession/ProfileCompatibilityIcon.tsx | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 sources/components/newSession/ProfileCompatibilityIcon.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 8c7280f63..e039f020b 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -34,6 +34,7 @@ import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from ' import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -859,21 +860,8 @@ function NewSessionWizard() { }, [scrollToWizardSection]); const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { - const primary = getProfilePrimaryCli(profile); - const iconName = - primary === 'claude' ? 'cloud-outline' : - primary === 'codex' ? 'terminal-outline' : - primary === 'gemini' ? 'planet-outline' : - primary === 'multi' ? 'sparkles-outline' : - 'person-outline'; - return ( - - ); - }, [theme.colors.textSecondary]); + return ; + }, []); // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { @@ -1339,7 +1327,7 @@ function NewSessionWizard() { 1. - {useProfiles ? 'Choose AI Profile' : 'Select AI'} + {useProfiles ? 'Choose AI Profile & Backend' : 'Select AI'} diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 5c543b3b4..65148168c 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -8,11 +8,12 @@ import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; -import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; +import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { useUnistyles } from 'react-native-unistyles'; import { randomUUID } from 'expo-crypto'; import { AIBackendProfile } from '@/sync/settings'; import { Modal } from '@/modal'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; export default function ProfilePickerScreen() { const { theme } = useUnistyles(); @@ -26,17 +27,8 @@ export default function ProfilePickerScreen() { const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { - const primary = getProfilePrimaryCli(profile); - const iconName = - primary === 'claude' ? 'cloud-outline' : - primary === 'codex' ? 'terminal-outline' : - primary === 'gemini' ? 'planet-outline' : - primary === 'multi' ? 'sparkles-outline' : - 'person-outline'; - return ( - - ); - }, [theme.colors.textSecondary]); + return ; + }, []); const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx new file mode 100644 index 000000000..08d536bf5 --- /dev/null +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Text, View, type ViewStyle } from 'react-native'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import type { AIBackendProfile } from '@/sync/settings'; + +type Props = { + profile: Pick; + size?: number; + style?: ViewStyle; +}; + +export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { + const { theme } = useUnistyles(); + + const glyph = + profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : + profile.compatibility?.claude ? '✳' : + profile.compatibility?.codex ? '꩜' : + '•'; + + return ( + + + {glyph} + + + ); +} + From 3debc6ca129a27329c438fb06f00cc150b849d41 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:32:19 +0100 Subject: [PATCH 26/72] fix(pickers): keep search visible with zero matches --- sources/components/SearchableListSelector.tsx | 137 ++++++++++++------ 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 5aa021cfa..2c4308c17 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -120,22 +120,29 @@ export function SearchableListSelector(props: SearchableListSelectorProps) return new Set(favoriteItems.map((item) => config.getItemId(item))); }, [favoriteItems, config]); + const baseRecentItems = React.useMemo(() => { + return recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); + }, [recentItems, favoriteIds, config]); + + const baseAllItems = React.useMemo(() => { + const recentIds = new Set(baseRecentItems.map((item) => config.getItemId(item))); + return items.filter((item) => !favoriteIds.has(config.getItemId(item)) && !recentIds.has(config.getItemId(item))); + }, [items, baseRecentItems, favoriteIds, config]); + const filteredFavoriteItems = React.useMemo(() => { if (!inputText.trim()) return favoriteItems; return favoriteItems.filter((item) => config.filterItem(item, inputText, context)); }, [favoriteItems, inputText, config, context]); const filteredRecentItems = React.useMemo(() => { - const base = recentItems.filter((item) => !favoriteIds.has(config.getItemId(item))); - if (!inputText.trim()) return base; - return base.filter((item) => config.filterItem(item, inputText, context)); - }, [recentItems, favoriteIds, inputText, config, context]); + if (!inputText.trim()) return baseRecentItems; + return baseRecentItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseRecentItems, inputText, config, context]); const filteredItems = React.useMemo(() => { - const base = items.filter((item) => !favoriteIds.has(config.getItemId(item))); - if (!inputText.trim()) return base; - return base.filter((item) => config.filterItem(item, inputText, context)); - }, [items, favoriteIds, inputText, config, context]); + if (!inputText.trim()) return baseAllItems; + return baseAllItems.filter((item) => config.filterItem(item, inputText, context)); + }, [baseAllItems, inputText, config, context]); const handleInputChange = (text: string) => { setInputText(text); @@ -246,17 +253,29 @@ export function SearchableListSelector(props: SearchableListSelectorProps) ? filteredRecentItems : filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE); - const hasRecentGroup = showRecent && filteredRecentItems.length > 0; - const hasFavoritesGroup = showFavorites && filteredFavoriteItems.length > 0; - const hasAllGroup = showAll && filteredItems.length > 0; + const hasRecentGroupBase = showRecent && baseRecentItems.length > 0; + const hasFavoritesGroupBase = showFavorites && favoriteItems.length > 0; + const hasAllGroupBase = showAll && baseAllItems.length > 0; const effectiveSearchPlacement = React.useMemo(() => { if (!showSearch) return 'header' as const; - if (searchPlacement === 'recent' && !hasRecentGroup) return 'header' as const; - if (searchPlacement === 'favorites' && !hasFavoritesGroup) return 'header' as const; - if (searchPlacement === 'all' && !hasAllGroup) return 'header' as const; - return searchPlacement; - }, [hasAllGroup, hasFavoritesGroup, hasRecentGroup, searchPlacement, showSearch]); + if (searchPlacement === 'header') return 'header' as const; + + if (searchPlacement === 'favorites' && hasFavoritesGroupBase) return 'favorites' as const; + if (searchPlacement === 'recent' && hasRecentGroupBase) return 'recent' as const; + if (searchPlacement === 'all' && hasAllGroupBase) return 'all' as const; + + // Fall back to the first visible group so the search never disappears. + if (hasFavoritesGroupBase) return 'favorites' as const; + if (hasRecentGroupBase) return 'recent' as const; + if (hasAllGroupBase) return 'all' as const; + return 'header' as const; + }, [hasAllGroupBase, hasFavoritesGroupBase, hasRecentGroupBase, searchPlacement, showSearch]); + + const showNoMatches = inputText.trim().length > 0; + const shouldRenderRecentGroup = showRecent && (filteredRecentItems.length > 0 || (effectiveSearchPlacement === 'recent' && showSearch && hasRecentGroupBase)); + const shouldRenderFavoritesGroup = showFavorites && (filteredFavoriteItems.length > 0 || (effectiveSearchPlacement === 'favorites' && showSearch && hasFavoritesGroupBase)); + const shouldRenderAllGroup = showAll && (filteredItems.length > 0 || (effectiveSearchPlacement === 'all' && showSearch && hasAllGroupBase)); const searchNodeHeader = showSearch ? ( (props: SearchableListSelectorProps) /> ) : null; + const renderEmptyRow = (title: string) => ( + + ); + return ( <> {effectiveSearchPlacement === 'header' && searchNodeHeader} - {hasRecentGroup && ( + {shouldRenderRecentGroup && ( {effectiveSearchPlacement === 'recent' && searchNodeEmbedded} - {recentItemsToShow.map((item, index, arr) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === arr.length - 1; - - const showDivider = !isLast || - (!inputText.trim() && - !showAllRecent && - filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); - - return renderItem(item, isSelected, isLast, showDivider, true, false); - })} - - {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && ( + {recentItemsToShow.length === 0 + ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage) + : recentItemsToShow.map((item, index, arr) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === arr.length - 1; + + const showDivider = !isLast || + (!inputText.trim() && + !showAllRecent && + filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE); + + return renderItem(item, isSelected, isLast, showDivider, true, false); + })} + + {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && recentItemsToShow.length > 0 && ( (props: SearchableListSelectorProps) )} - {hasFavoritesGroup && ( + {shouldRenderFavoritesGroup && ( {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded} - {filteredFavoriteItems.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === filteredFavoriteItems.length - 1; - return renderItem(item, isSelected, isLast, !isLast, false, true); - })} + {filteredFavoriteItems.length === 0 + ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage) + : filteredFavoriteItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredFavoriteItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, true); + })} )} - {hasAllGroup && ( + {shouldRenderAllGroup && ( {effectiveSearchPlacement === 'all' && searchNodeEmbedded} - {filteredItems.map((item, index) => { - const itemId = config.getItemId(item); - const selectedId = selectedItem ? config.getItemId(selectedItem) : null; - const isSelected = itemId === selectedId; - const isLast = index === filteredItems.length - 1; - return renderItem(item, isSelected, isLast, !isLast, false, false); - })} + {filteredItems.length === 0 + ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage) + : filteredItems.map((item, index) => { + const itemId = config.getItemId(item); + const selectedId = selectedItem ? config.getItemId(selectedItem) : null; + const isSelected = itemId === selectedId; + const isLast = index === filteredItems.length - 1; + return renderItem(item, isSelected, isLast, !isLast, false, false); + })} + + )} + + {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && ( + + {effectiveSearchPlacement !== 'header' && searchNodeEmbedded} + {renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)} )} From 3ca8e85ca9eb16f7f480bf06135c6573849b3bae Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:32:28 +0100 Subject: [PATCH 27/72] fix(ui): stabilize chip icons --- sources/components/AgentInput.tsx | 24 +++++++------------ .../newSession/ProfileCompatibilityIcon.tsx | 6 +---- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 124578271..71ff147f6 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -22,7 +22,7 @@ import { Theme } from '@/theme'; import { t } from '@/text'; import { Metadata } from '@/sync/storageTypes'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; -import { getBuiltInProfile, getProfilePrimaryCli } from '@/sync/profileUtils'; +import { getBuiltInProfile } from '@/sync/profileUtils'; interface AgentInputProps { value: string; @@ -334,15 +334,9 @@ export const AgentInput = React.memo(React.forwardRef { - if (props.profileId === null) return 'settings-outline'; - if (typeof props.profileId === 'string' && props.profileId.trim() === '') return 'settings-outline'; - const primary = getProfilePrimaryCli(currentProfile); - if (primary === 'claude') return 'cloud-outline'; - if (primary === 'codex') return 'terminal-outline'; - if (primary === 'gemini') return 'planet-outline'; - if (primary === 'multi') return 'sparkles-outline'; - return 'person-outline'; - }, [currentProfile, props.profileId]); + // Always show a stable "profile" icon so the chip reads as Profile selection (not "current provider"). + return 'person-circle-outline'; + }, []); // Calculate context warning const contextWarning = props.usageData?.contextSize @@ -777,11 +771,11 @@ export const AgentInput = React.memo(React.forwardRef - + - + {glyph} ); } - From f6ce61ce64a33f7319054118753af3953b52b865 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:33:08 +0100 Subject: [PATCH 28/72] feat(profiles): add favorites and group lists --- sources/app/(app)/new/index.tsx | 269 ++++++++++++----------- sources/app/(app)/new/pick/profile.tsx | 271 ++++++++++++----------- sources/app/(app)/settings/profiles.tsx | 274 ++++++++++++++++++------ sources/sync/profileGrouping.ts | 46 ++++ sources/sync/profileMutations.ts | 38 ++++ sources/sync/settings.spec.ts | 7 + sources/sync/settings.ts | 4 + 7 files changed, 597 insertions(+), 312 deletions(-) create mode 100644 sources/sync/profileGrouping.ts create mode 100644 sources/sync/profileMutations.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index e039f020b..8792f7bb2 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -24,7 +24,6 @@ import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAge import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; -import { randomUUID } from 'expo-crypto'; import { useCLIDetection } from '@/hooks/useCLIDetection'; import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; @@ -35,6 +34,8 @@ import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -289,6 +290,7 @@ function NewSessionWizard() { const lastUsedProfile = useSetting('lastUsedProfile'); const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories'); const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings'); // Combined profiles (built-in + custom) @@ -298,6 +300,23 @@ function NewSessionWizard() { }, [profiles]); const profileMap = useProfileMap(allProfiles); + + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + if (favoriteProfileIdSet.has(profileId)) { + setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); + } else { + setFavoriteProfileIds([profileId, ...favoriteProfileIds]); + } + }, [favoriteProfileIdSet, favoriteProfileIds, setFavoriteProfileIds]); const machines = useAllMachines(); const sessions = useSessions(); @@ -633,30 +652,11 @@ function NewSessionWizard() { }, [router, selectedMachineId]); const handleAddProfile = React.useCallback(() => { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - openProfileEdit(newProfile); + openProfileEdit(createEmptyCustomProfile()); }, [openProfileEdit]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicated: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - openProfileEdit(duplicated); + openProfileEdit(duplicateProfileForEdit(profile)); }, [openProfileEdit]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { @@ -863,6 +863,72 @@ function NewSessionWizard() { return ; }, []); + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + {!profile.isBuiltIn && ( + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + > + + + )} + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEdit, + theme.colors.button.primary.background, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, + ]); + // Helper to get meaningful subtitle text for profiles const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => { const parts: string[] = []; @@ -919,11 +985,7 @@ function NewSessionWizard() { // For built-in profiles, create a new custom profile instead of modifying the built-in if (isBuiltIn) { - profileToSave = { - ...savedProfile, - id: randomUUID(), // Generate new UUID for custom profile - isBuiltIn: false, - }; + profileToSave = convertBuiltInProfileToCustom(savedProfile); } const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); @@ -1555,7 +1617,7 @@ function NewSessionWizard() { {useProfiles ? ( <> - + } /> - {DEFAULT_PROFILES.map((profileDisplay, index) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === DEFAULT_PROFILES.length - 1 && profiles.length === 0; + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} - return ( - selectProfile(profile.id)} - rightElement={ - - - - - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - - } - showDivider={!isLast} - /> - ); - })} - {profiles.map((profile, index) => { + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + {nonFavoriteBuiltInProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; - const isLast = index === profiles.length - 1; - + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); return ( selectProfile(profile.id)} - rightElement={ - - - - - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - - } + onPress={() => availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} showDivider={!isLast} /> ); diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 65148168c..d3617b37b 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -8,12 +8,12 @@ import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { useSetting, useSettingMutable } from '@/sync/storage'; import { t } from '@/text'; -import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { useUnistyles } from 'react-native-unistyles'; -import { randomUUID } from 'expo-crypto'; import { AIBackendProfile } from '@/sync/settings'; import { Modal } from '@/modal'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; export default function ProfilePickerScreen() { const { theme } = useUnistyles(); @@ -22,6 +22,7 @@ export default function ProfilePickerScreen() { const params = useLocalSearchParams<{ selectedId?: string; machineId?: string }>(); const useProfiles = useSetting('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; @@ -48,31 +49,29 @@ export default function ProfilePickerScreen() { router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); }, [machineId, router]); + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const toggleFavoriteProfile = React.useCallback((profileId: string) => { + if (favoriteProfileIdSet.has(profileId)) { + setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); + } else { + setFavoriteProfileIds([profileId, ...favoriteProfileIds]); + } + }, [favoriteProfileIdSet, favoriteProfileIds, setFavoriteProfileIds]); + const handleAddProfile = React.useCallback(() => { - const newProfile: AIBackendProfile = { - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - openProfileEdit(newProfile); + openProfileEdit(createEmptyCustomProfile()); }, [openProfileEdit]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - const duplicated: AIBackendProfile = { - ...profile, - id: randomUUID(), - name: `${profile.name} (Copy)`, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - openProfileEdit(duplicated); + openProfileEdit(duplicateProfileForEdit(profile)); }, [openProfileEdit]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { @@ -97,6 +96,72 @@ export default function ProfilePickerScreen() { ); }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); + const renderProfileRowRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + return ( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + openProfileEdit(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + {!profile.isBuiltIn && ( + { + e.stopPropagation(); + handleDeleteProfile(profile); + }} + > + + + )} + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEdit, + theme.colors.button.primary.background, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, + ]); + return ( <> ) : ( <> - - } - onPress={handleAddProfile} - showChevron={false} - /> - - - - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} - const isSelected = selectedId === profile.id; - return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={ - - {isSelected && ( - - )} - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - style={{ marginLeft: 16 }} - > - - - - } - /> - ); - })} + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} - {profiles.map((profile) => { + + {nonFavoriteBuiltInProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - {isSelected && ( - - )} - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - style={{ marginLeft: 16 }} - > - - - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - style={{ marginLeft: 16 }} - > - - - - } + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} /> ); })} + + + } + onPress={handleAddProfile} + showChevron={false} + /> + )} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 75fbd1b1a..64460faba 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -9,11 +9,13 @@ import { Modal } from '@/modal'; import { AIBackendProfile } from '@/sync/settings'; import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils'; import { ProfileEditForm } from '@/components/ProfileEditForm'; -import { randomUUID } from 'expo-crypto'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { Switch } from '@/components/Switch'; +import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { buildProfileGroups } from '@/sync/profileGrouping'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -26,6 +28,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); @@ -54,17 +57,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } const handleAddProfile = () => { - setEditingProfile({ - id: randomUUID(), - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true, gemini: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }); + setEditingProfile(createEmptyCustomProfile()); setShowAddForm(true); }; @@ -73,6 +66,11 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setShowAddForm(true); }; + const handleDuplicateProfile = (profile: AIBackendProfile) => { + setEditingProfile(duplicateProfileForEdit(profile)); + setShowAddForm(true); + }; + const handleDeleteProfile = async (profile: AIBackendProfile) => { const confirmed = await Modal.confirm( t('profiles.delete.title'), @@ -115,6 +113,23 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setLastUsedProfile(profileId); }; + const { + favoriteProfiles: favoriteProfileItems, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds: favoriteProfileIdSet, + } = React.useMemo(() => { + return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds }); + }, [favoriteProfileIds, profiles]); + + const toggleFavoriteProfile = (profileId: string) => { + if (favoriteProfileIdSet.has(profileId)) { + setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId)); + } else { + setFavoriteProfileIds([profileId, ...favoriteProfileIds]); + } + }; + const handleSaveProfile = (profile: AIBackendProfile) => { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { @@ -126,13 +141,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel // For built-in profiles, create a new custom profile instead of modifying the built-in if (isBuiltIn) { - const newProfile: AIBackendProfile = { - ...profile, - id: randomUUID(), // Generate new UUID for custom profile - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - }; + const newProfile = convertBuiltInProfileToCustom(profile); // Check for duplicate names (excluding the new profile) const isDuplicate = profiles.some(p => @@ -186,93 +195,224 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel onPress={() => handleSelectProfile(null)} showChevron={false} selected={selectedProfileId === null} + pressableStyle={selectedProfileId === null ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={selectedProfileId === null ? : null} /> - - {DEFAULT_PROFILES.map((profileDisplay) => { - const profile = getBuiltInProfile(profileDisplay.id); - if (!profile) return null; + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + handleEditProfile(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + {!profile.isBuiltIn && ( + { + e.stopPropagation(); + void handleDeleteProfile(profile); + }} + > + + + )} + + )} + /> + ); + })} + + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile) => { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + } + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + + + + + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + + { + e.stopPropagation(); + handleEditProfile(profile); + }} + > + + + { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} + > + + + { + e.stopPropagation(); + void handleDeleteProfile(profile); + }} + > + + + + )} + /> + ); + })} + + )} + + {nonFavoriteBuiltInProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); return ( } + leftElement={} onPress={() => handleSelectProfile(profile.id)} showChevron={false} selected={isSelected} - rightElement={ - - {isSelected && ( + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={( + + - )} + handleEditProfile(profile)} + onPress={(e) => { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} > - - - - } - /> - ); - })} - - {profiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const subtitleParts: string[] = [t('profiles.defaultModel')]; - if (profile.tmuxConfig?.sessionName) subtitleParts.push(`tmux: ${profile.tmuxConfig.sessionName}`); - if (profile.tmuxConfig?.tmpDir) subtitleParts.push(`dir: ${profile.tmuxConfig.tmpDir}`); - - return ( - } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - rightElement={ - - {isSelected && ( - )} + handleEditProfile(profile)} + onPress={(e) => { + e.stopPropagation(); + handleEditProfile(profile); + }} > void handleDeleteProfile(profile)} - style={{ marginLeft: 16 }} + onPress={(e) => { + e.stopPropagation(); + handleDuplicateProfile(profile); + }} > - + - } + )} /> ); })} + + } diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts new file mode 100644 index 000000000..59b0eb551 --- /dev/null +++ b/sources/sync/profileGrouping.ts @@ -0,0 +1,46 @@ +import { AIBackendProfile } from '@/sync/settings'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; + +export interface ProfileGroups { + favoriteProfiles: AIBackendProfile[]; + customProfiles: AIBackendProfile[]; + builtInProfiles: AIBackendProfile[]; + favoriteIds: Set; + builtInIds: Set; +} + +function isProfile(profile: AIBackendProfile | null | undefined): profile is AIBackendProfile { + return Boolean(profile); +} + +export function buildProfileGroups({ + customProfiles, + favoriteProfileIds, +}: { + customProfiles: AIBackendProfile[]; + favoriteProfileIds: string[]; +}): ProfileGroups { + const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id)); + const favoriteIds = new Set(favoriteProfileIds); + + const customById = new Map(customProfiles.map((profile) => [profile.id, profile] as const)); + + const favoriteProfiles = favoriteProfileIds + .map((id) => customById.get(id) ?? getBuiltInProfile(id)) + .filter(isProfile); + + const nonFavoriteCustomProfiles = customProfiles.filter((profile) => !favoriteIds.has(profile.id)); + + const nonFavoriteBuiltInProfiles = DEFAULT_PROFILES + .map((profile) => getBuiltInProfile(profile.id)) + .filter(isProfile) + .filter((profile) => !favoriteIds.has(profile.id)); + + return { + favoriteProfiles, + customProfiles: nonFavoriteCustomProfiles, + builtInProfiles: nonFavoriteBuiltInProfiles, + favoriteIds, + builtInIds, + }; +} diff --git a/sources/sync/profileMutations.ts b/sources/sync/profileMutations.ts new file mode 100644 index 000000000..736a0dad3 --- /dev/null +++ b/sources/sync/profileMutations.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'expo-crypto'; +import { AIBackendProfile } from '@/sync/settings'; + +export function createEmptyCustomProfile(): AIBackendProfile { + return { + id: randomUUID(), + name: '', + anthropicConfig: {}, + environmentVariables: [], + compatibility: { claude: true, codex: true, gemini: true }, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0', + }; +} + +export function duplicateProfileForEdit(profile: AIBackendProfile): AIBackendProfile { + return { + ...profile, + id: randomUUID(), + name: `${profile.name} (Copy)`, + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +export function convertBuiltInProfileToCustom(profile: AIBackendProfile): AIBackendProfile { + return { + ...profile, + id: randomUUID(), + isBuiltIn: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index a7ca52f12..43c615afc 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -126,6 +126,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -164,6 +165,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); @@ -202,6 +204,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = {}; @@ -242,6 +245,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: Partial = { @@ -287,6 +291,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; expect(applySettings(currentSettings, {})).toEqual(currentSettings); @@ -341,6 +346,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: [], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }; const delta: any = { @@ -407,6 +413,7 @@ describe('settings', () => { lastUsedProfile: null, favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], favoriteMachines: [], + favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, }); }); diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 139bd2f76..66a9625c0 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -268,6 +268,8 @@ export const SettingsSchema = z.object({ favoriteDirectories: z.array(z.string()).describe('User-defined favorite directories for quick access in path selection'), // Favorite machines for quick machine selection favoriteMachines: z.array(z.string()).describe('User-defined favorite machines (machine IDs) for quick access in machine selection'), + // Favorite profiles for quick profile selection (built-in or custom profile IDs) + favoriteProfiles: z.array(z.string()).describe('User-defined favorite profiles (profile IDs) for quick access in profile selection'), // Dismissed CLI warning banners (supports both per-machine and global dismissal) dismissedCLIWarnings: z.object({ perMachine: z.record(z.string(), z.object({ @@ -338,6 +340,8 @@ export const settingsDefaults: Settings = { favoriteDirectories: [], // Favorite machines (empty by default) favoriteMachines: [], + // Favorite profiles (empty by default) + favoriteProfiles: [], // Dismissed CLI warnings (empty by default) dismissedCLIWarnings: { perMachine: {}, global: {} }, }; From 80c1abdd5e13572f7de0094379980afb4702ba58 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:49:45 +0100 Subject: [PATCH 29/72] fix(paths): keep search visible in path picker --- .../components/newSession/PathSelector.tsx | 247 +++++++++++++----- 1 file changed, 182 insertions(+), 65 deletions(-) diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 1fde87ad0..97ff61022 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -108,6 +108,61 @@ export function PathSelector({ return base.filter((path) => path.toLowerCase().includes(query)); }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]); + const baseRecentPaths = useMemo(() => { + return recentPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, recentPaths]); + + const baseSuggestedPaths = useMemo(() => { + return suggestedPaths.filter((p) => !favoritePaths.includes(p)); + }, [favoritePaths, suggestedPaths]); + + const effectiveGroupSearchPlacement = useMemo(() => { + if (!usePickerSearch || searchVariant !== 'group') return null as null | 'favorites' | 'recent' | 'suggested' | 'fallback'; + const preferred: 'suggested' | 'recent' | 'favorites' | 'fallback' = + baseSuggestedPaths.length > 0 ? 'suggested' + : baseRecentPaths.length > 0 ? 'recent' + : favoritePaths.length > 0 ? 'favorites' + : 'fallback'; + + if (preferred === 'suggested') { + if (filteredSuggestedPaths.length > 0) return 'suggested'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + return 'suggested'; + } + + if (preferred === 'recent') { + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'recent'; + } + + if (preferred === 'favorites') { + if (filteredFavoritePaths.length > 0) return 'favorites'; + if (filteredRecentPaths.length > 0) return 'recent'; + if (filteredSuggestedPaths.length > 0) return 'suggested'; + return 'favorites'; + } + + return 'fallback'; + }, [ + baseRecentPaths.length, + baseSuggestedPaths.length, + favoritePaths.length, + filteredFavoritePaths.length, + filteredRecentPaths.length, + filteredSuggestedPaths.length, + searchVariant, + usePickerSearch, + ]); + + const showNoMatchesRow = usePickerSearch && searchQuery.trim().length > 0; + const shouldRenderFavoritesGroup = filteredFavoritePaths.length > 0 || effectiveGroupSearchPlacement === 'favorites'; + const shouldRenderRecentGroup = filteredRecentPaths.length > 0 || effectiveGroupSearchPlacement === 'recent'; + const shouldRenderSuggestedGroup = filteredSuggestedPaths.length > 0 || effectiveGroupSearchPlacement === 'suggested'; + const shouldRenderFallbackGroup = effectiveGroupSearchPlacement === 'fallback'; + const toggleFavorite = React.useCallback((absolutePath: string) => { const homeDir = machineHomeDir || '/home'; @@ -180,57 +235,88 @@ export function PathSelector({ - {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length > 0 && ( + {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && ( - - {filteredRecentPaths.map((path, index) => { - const isSelected = selectedPath.trim() === path; - const isLast = index === filteredRecentPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( + {effectiveGroupSearchPlacement === 'recent' && ( + + )} + {filteredRecentPaths.length === 0 + ? ( } - onPress={() => setPathAndFocus(path)} - selected={isSelected} + title={showNoMatchesRow ? 'No matches' : 'No recent paths'} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} + showDivider={false} + disabled={true} /> - ); - })} + ) + : filteredRecentPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredRecentPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} )} - {filteredFavoritePaths.length > 0 && ( + {shouldRenderFavoritesGroup && ( - {filteredFavoritePaths.map((path, index) => { - const isSelected = selectedPath.trim() === path; - const isLast = index === filteredFavoritePaths.length - 1; - return ( + {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && ( + + )} + {filteredFavoritePaths.length === 0 + ? ( } - onPress={() => setPathAndFocus(path)} - selected={isSelected} + title={showNoMatchesRow ? 'No matches' : 'No favorite paths'} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, true)} - showDivider={!isLast} + showDivider={false} + disabled={true} /> - ); - })} + ) + : filteredFavoritePaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredFavoritePaths.length - 1; + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> + ); + })} )} @@ -257,35 +343,46 @@ export function PathSelector({ )} - {usePickerSearch && searchVariant === 'group' && filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && ( + {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && ( - - {filteredSuggestedPaths.map((path, index) => { - const isSelected = selectedPath.trim() === path; - const isLast = index === filteredSuggestedPaths.length - 1; - const isFavorite = favoritePaths.includes(path); - return ( + {effectiveGroupSearchPlacement === 'suggested' && ( + + )} + {filteredSuggestedPaths.length === 0 + ? ( } - onPress={() => setPathAndFocus(path)} - selected={isSelected} + title={showNoMatchesRow ? 'No matches' : 'No suggested paths'} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} + showDivider={false} + disabled={true} /> - ); - })} + ) + : filteredSuggestedPaths.map((path, index) => { + const isSelected = selectedPath.trim() === path; + const isLast = index === filteredSuggestedPaths.length - 1; + const isFavorite = favoritePaths.includes(path); + return ( + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} )} @@ -311,6 +408,26 @@ export function PathSelector({ })} )} + + {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && ( + + + + + )} ); } From 5c9702cb78638e6c988a1f93ce8747dbde0fb0d5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:49:53 +0100 Subject: [PATCH 30/72] fix(ui): adjust icons --- sources/components/AgentInput.tsx | 2 +- sources/components/newSession/ProfileCompatibilityIcon.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 71ff147f6..713286947 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -772,7 +772,7 @@ export const AgentInput = React.memo(React.forwardRef diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index fd6eb49aa..d8ba1bf08 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -12,6 +12,7 @@ type Props = { export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { const { theme } = useUnistyles(); + const glyphSize = Math.round(size * 0.75); const glyph = profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : @@ -31,7 +32,7 @@ export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { style, ]} > - + {glyph} From a9eb77cdaa3a17276f3a3a558aa2efab4e6fe7a8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:55:56 +0100 Subject: [PATCH 31/72] fix(paths): preserve search focus when moving --- sources/components/SearchHeader.tsx | 9 ++++++ .../components/newSession/PathSelector.tsx | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 7b9b9976f..5bd43bbb7 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -12,6 +12,9 @@ export interface SearchHeaderProps { containerStyle?: StyleProp; autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; autoCorrect?: boolean; + inputRef?: React.Ref; + onFocus?: () => void; + onBlur?: () => void; } const INPUT_BORDER_RADIUS = 10; @@ -58,6 +61,9 @@ export function SearchHeader({ containerStyle, autoCapitalize = 'none', autoCorrect = false, + inputRef, + onFocus, + onBlur, }: SearchHeaderProps) { const { theme } = useUnistyles(); const styles = stylesheet; @@ -73,12 +79,15 @@ export function SearchHeader({ style={{ marginRight: 8 }} /> {value.trim().length > 0 && ( diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 97ff61022..c22c8b98a 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, Pressable } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; @@ -59,6 +59,8 @@ export function PathSelector({ const { theme } = useUnistyles(); const styles = stylesheet; const inputRef = useRef(null); + const searchInputRef = useRef(null); + const searchWasFocusedRef = useRef(false); const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(''); const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; @@ -157,6 +159,20 @@ export function PathSelector({ usePickerSearch, ]); + useEffect(() => { + if (!usePickerSearch || searchVariant !== 'group') return; + if (!searchWasFocusedRef.current) return; + + const id = setTimeout(() => { + // Keep the search box usable while it moves between groups by restoring focus. + // (The underlying TextInput unmounts/remounts as placement changes.) + try { + searchInputRef.current?.focus?.(); + } catch { } + }, 0); + return () => clearTimeout(id); + }, [effectiveGroupSearchPlacement, searchVariant, usePickerSearch]); + const showNoMatchesRow = usePickerSearch && searchQuery.trim().length > 0; const shouldRenderFavoritesGroup = filteredFavoritePaths.length > 0 || effectiveGroupSearchPlacement === 'favorites'; const shouldRenderRecentGroup = filteredRecentPaths.length > 0 || effectiveGroupSearchPlacement === 'recent'; @@ -242,6 +258,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, @@ -285,6 +304,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, @@ -350,6 +372,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, @@ -415,6 +440,9 @@ export function PathSelector({ value={searchQuery} onChangeText={setSearchQuery} placeholder="Search paths..." + inputRef={searchInputRef} + onFocus={() => { searchWasFocusedRef.current = true; }} + onBlur={() => { searchWasFocusedRef.current = false; }} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 0, From 7c5b888215deb8d4d5e9ab73f441d93e372961f6 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 14:56:05 +0100 Subject: [PATCH 32/72] fix(ui): tweak profile glyph sizing --- sources/components/newSession/ProfileCompatibilityIcon.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index d8ba1bf08..3869bc6ff 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -12,7 +12,6 @@ type Props = { export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { const { theme } = useUnistyles(); - const glyphSize = Math.round(size * 0.75); const glyph = profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : @@ -20,6 +19,11 @@ export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { profile.compatibility?.codex ? '꩜' : '•'; + const glyphSize = + glyph === '✳' ? Math.round(size * 0.85) : + glyph === '✳꩜' ? Math.round(size * 0.7) : + Math.round(size * 0.75); + return ( Date: Wed, 14 Jan 2026 15:13:40 +0100 Subject: [PATCH 33/72] fix(ui): polish wizard labels and focus styles --- sources/app/(app)/new/index.tsx | 93 +++++++++++-------- sources/components/ProfileEditForm.tsx | 2 +- sources/components/SearchHeader.tsx | 9 ++ .../newSession/ProfileCompatibilityIcon.tsx | 2 +- 4 files changed, 63 insertions(+), 43 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 8792f7bb2..edda7b53f 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -15,7 +15,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { machineSpawnNewSession } from '@/sync/ops'; import { Modal } from '@/modal'; import { sync } from '@/sync/sync'; -import { SessionTypeSelector } from '@/components/SessionTypeSelector'; +import { SessionTypeSelector, SessionTypeSelectorRows } from '@/components/SessionTypeSelector'; import { createWorktree } from '@/utils/createWorktree'; import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; @@ -124,14 +124,14 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 marginTop: 12, paddingHorizontal: 16, }, - sectionHeader: { - fontSize: 14, - fontWeight: '600', - color: theme.colors.text, - marginBottom: 8, - marginTop: 12, - ...Typography.default('semiBold') - }, + sectionHeader: { + fontSize: 17, + fontWeight: '600', + color: theme.colors.text, + marginBottom: 8, + marginTop: 12, + ...Typography.default('semiBold') + }, sectionDescription: { fontSize: 12, color: theme.colors.textSecondary, @@ -827,7 +827,7 @@ function NewSessionWizard() { }, [agentType, permissionMode]); // Scroll to section helpers - for AgentInput button clicks - const wizardSectionOffsets = React.useRef<{ profile?: number; machine?: number; path?: number; permission?: number }>({}); + const wizardSectionOffsets = React.useRef<{ profile?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { return (e: any) => { wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; @@ -1389,13 +1389,13 @@ function NewSessionWizard() { 1. - {useProfiles ? 'Choose AI Profile & Backend' : 'Select AI'} + {useProfiles ? 'Select AI Profile & Backend' : 'Select AI'} {useProfiles - ? 'Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' - : 'Choose which AI runs your session.'} + ? 'Select which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' + : 'Select which AI runs your session.'} {/* Missing CLI Installation Banners */} @@ -1816,28 +1816,28 @@ function NewSessionWizard() { {/* Section 4: Permission Mode */} - - - 4. - - Permission Mode - - - - {(agentType === 'codex' - ? [ - { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, - ] - : [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] - ).map((option, index, array) => ( + + + 4. + + Select Permission Mode + + + + {(agentType === 'codex' + ? [ + { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-half-outline' }, + { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-half-outline' }, + { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( - + - - - - + {/* Section 5: Session Type */} + + + 5. + + Select Session Type + + + + + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + + + + - {/* Section 5: AgentInput - Sticky at bottom */} + {/* AgentInput - Sticky at bottom */} 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> {[ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-half-outline' }, { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx index 5bd43bbb7..0e3c2103f 100644 --- a/sources/components/SearchHeader.tsx +++ b/sources/components/SearchHeader.tsx @@ -48,6 +48,15 @@ const stylesheet = StyleSheet.create((theme) => ({ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, paddingVertical: 0, + ...(Platform.select({ + web: { + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + }, + default: {}, + }) as object), }, clearIcon: { marginLeft: 8, diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index 3869bc6ff..ce2fb9b86 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -20,7 +20,7 @@ export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { '•'; const glyphSize = - glyph === '✳' ? Math.round(size * 0.85) : + glyph === '✳' ? Math.round(size * 0.9) : glyph === '✳꩜' ? Math.round(size * 0.7) : Math.round(size * 0.75); From d30d5db7a25f20877c56405232dfd1f66d7ba4b7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 15:28:31 +0100 Subject: [PATCH 34/72] fix(ui): simplify wizard headers and list icons --- sources/app/(app)/new/index.tsx | 57 ++++++-------- sources/app/(app)/new/pick/profile.tsx | 30 ++++--- sources/app/(app)/settings/profiles.tsx | 78 +++++++++---------- sources/components/SearchableListSelector.tsx | 2 +- .../components/newSession/MachineSelector.tsx | 4 +- .../components/newSession/PathSelector.tsx | 2 +- 6 files changed, 82 insertions(+), 91 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index edda7b53f..b85d582e8 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -883,7 +883,7 @@ function NewSessionWizard() { > @@ -1386,7 +1386,6 @@ function NewSessionWizard() { {/* Section 1: Profile Management */} - 1. {useProfiles ? 'Select AI Profile & Backend' : 'Select AI'} @@ -1617,28 +1616,6 @@ function NewSessionWizard() { {useProfiles ? ( <> - - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={ - - - - } - /> - - {favoriteProfileItems.length > 0 && ( {favoriteProfileItems.map((profile, index) => { @@ -1691,6 +1668,26 @@ function NewSessionWizard() { )} + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={ + + + + } + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> {nonFavoriteBuiltInProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; @@ -1761,7 +1758,6 @@ function NewSessionWizard() { {/* Section 2: Machine Selection */} - 2. Select Machine @@ -1796,7 +1792,6 @@ function NewSessionWizard() { {/* Section 3: Working Directory */} - 3. Select Working Directory @@ -1818,11 +1813,10 @@ function NewSessionWizard() { {/* Section 4: Permission Mode */} - 4. - - Select Permission Mode - - + + Select Permission Mode + + {(agentType === 'codex' ? [ @@ -1870,7 +1864,6 @@ function NewSessionWizard() { {/* Section 5: Session Type */} - 5. Select Session Type diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index d3617b37b..1db728d78 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -116,7 +116,7 @@ export default function ProfilePickerScreen() { > @@ -190,21 +190,6 @@ export default function ProfilePickerScreen() { ) : ( <> - - } - onPress={() => setProfileParamAndClose('')} - showChevron={false} - selected={selectedId === ''} - pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={selectedId === '' - ? - : null} - /> - - {favoriteProfileItems.length > 0 && ( {favoriteProfileItems.map((profile, index) => { @@ -253,6 +238,19 @@ export default function ProfilePickerScreen() { )} + } + onPress={() => setProfileParamAndClose('')} + showChevron={false} + selected={selectedId === ''} + pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={selectedId === '' + ? + : null} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> {nonFavoriteBuiltInProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; const isLast = index === nonFavoriteBuiltInProfiles.length - 1; diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 64460faba..f3a5c136e 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -227,19 +227,19 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel style={{ opacity: isSelected ? 1 : 0 }} /> - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + { @@ -302,19 +302,19 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel style={{ opacity: isSelected ? 1 : 0 }} /> - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + { @@ -374,19 +374,19 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel style={{ opacity: isSelected ? 1 : 0 }} /> - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - + { + e.stopPropagation(); + toggleFavoriteProfile(profile.id); + }} + > + + { diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 2c4308c17..968e02b8d 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -197,7 +197,7 @@ export function SearchableListSelector(props: SearchableListSelectorProps) > diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx index 689bd146c..402637f3e 100644 --- a/sources/components/newSession/MachineSelector.tsx +++ b/sources/components/newSession/MachineSelector.tsx @@ -51,14 +51,14 @@ export function MachineSelector({ getItemIcon: () => ( ), getRecentItemIcon: () => ( ), diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index c22c8b98a..1cc8bcf48 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -217,7 +217,7 @@ export function PathSelector({ > From ba0c6c3d943bff0847fbc5a4f9d122b87d21f29c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 15:39:01 +0100 Subject: [PATCH 35/72] fix(ui): lock backend chip when profile selected --- sources/app/(app)/new/index.tsx | 22 +++++++++++++++++++--- sources/app/(app)/new/pick/profile.tsx | 2 +- sources/app/(app)/settings/profiles.tsx | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index b85d582e8..189f64c21 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -358,7 +358,7 @@ function NewSessionWizard() { // Agent cycling handler (for cycling through claude -> codex -> gemini) // Note: Does NOT persist immediately - persistence is handled by useEffect below - const handleAgentClick = React.useCallback(() => { + const handleAgentCycle = React.useCallback(() => { setAgentType(prev => { // Cycle: claude -> codex -> (gemini?) -> claude if (prev === 'claude') return 'codex'; @@ -1026,6 +1026,22 @@ function NewSessionWizard() { }); }, [router, selectedMachineId, selectedProfileId]); + const handleAgentClick = React.useCallback(() => { + if (useProfiles && selectedProfileId !== null) { + Modal.alert( + 'AI Backend', + 'AI backend is selected by your profile. To change it, select a different profile.', + [ + { text: t('common.ok'), style: 'cancel' }, + { text: 'Change Profile', onPress: handleProfileClick }, + ], + ); + return; + } + + handleAgentCycle(); + }, [handleAgentCycle, handleProfileClick, selectedProfileId, useProfiles]); + const handlePathClick = React.useCallback(() => { if (selectedMachineId) { router.push({ @@ -1671,7 +1687,7 @@ function NewSessionWizard() { } + leftElement={} showChevron={false} selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} @@ -1682,7 +1698,7 @@ function NewSessionWizard() { name="checkmark-circle" size={24} color={theme.colors.button.primary.background} - style={{ opacity: selectedProfileId ? 1 : 0 }} + style={{ opacity: selectedProfileId ? 0 : 1 }} /> } diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 1db728d78..69666fc53 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -241,7 +241,7 @@ export default function ProfilePickerScreen() { } + icon={} onPress={() => setProfileParamAndClose('')} showChevron={false} selected={selectedId === ''} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index f3a5c136e..a6c4cb546 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -197,7 +197,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel selected={selectedProfileId === null} pressableStyle={selectedProfileId === null ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={selectedProfileId === null - ? + ? : null} /> From e8e6c4bbdc03ff9e2e563395889cd2ea829afe8b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 15:44:44 +0100 Subject: [PATCH 36/72] fix(profiles): gate backend changes by profile compatibility --- sources/app/(app)/new/index.tsx | 54 ++++++++++++++++--------- sources/app/(app)/settings/profiles.tsx | 17 +------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 189f64c21..167911fd3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1028,19 +1028,34 @@ function NewSessionWizard() { const handleAgentClick = React.useCallback(() => { if (useProfiles && selectedProfileId !== null) { - Modal.alert( - 'AI Backend', - 'AI backend is selected by your profile. To change it, select a different profile.', - [ - { text: t('common.ok'), style: 'cancel' }, - { text: 'Change Profile', onPress: handleProfileClick }, - ], - ); + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + const supportedAgents = profile + ? (Object.entries(profile.compatibility) as Array<[string, boolean]>) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini) + : []; + + if (supportedAgents.length <= 1) { + Modal.alert( + 'AI Backend', + 'AI backend is selected by your profile. To change it, select a different profile.', + [ + { text: t('common.ok'), style: 'cancel' }, + { text: 'Change Profile', onPress: handleProfileClick }, + ], + ); + return; + } + + const currentIndex = supportedAgents.indexOf(agentType); + const nextIndex = (currentIndex + 1) % supportedAgents.length; + setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? 'claude'); return; } handleAgentCycle(); - }, [handleAgentCycle, handleProfileClick, selectedProfileId, useProfiles]); + }, [agentType, allowGemini, handleAgentCycle, handleProfileClick, profileMap, selectedProfileId, setAgentType, useProfiles]); const handlePathClick = React.useCallback(() => { if (selectedMachineId) { @@ -1692,16 +1707,17 @@ function NewSessionWizard() { selected={!selectedProfileId} onPress={() => setSelectedProfileId(null)} pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={ - - - - } + rightElement={!selectedProfileId + ? ( + + + + ) + : null} showDivider={nonFavoriteBuiltInProfiles.length > 0} /> {nonFavoriteBuiltInProfiles.map((profile, index) => { diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index a6c4cb546..1a847a9f5 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -187,21 +187,6 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel return ( - - } - onPress={() => handleSelectProfile(null)} - showChevron={false} - selected={selectedProfileId === null} - pressableStyle={selectedProfileId === null ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={selectedProfileId === null - ? - : null} - /> - - {favoriteProfileItems.length > 0 && ( {favoriteProfileItems.map((profile) => { @@ -350,7 +335,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel )} - + {nonFavoriteBuiltInProfiles.map((profile) => { const isSelected = selectedProfileId === profile.id; const isFavorite = favoriteProfileIdSet.has(profile.id); From f4b1ffea5984baf50b9a1c2c348c826203f24cd5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 16:31:53 +0100 Subject: [PATCH 37/72] feat(profiles): add backend compatibility and wizard AI step --- sources/app/(app)/new/index.tsx | 384 ++++++++++-------- sources/components/ProfileEditForm.tsx | 50 +++ sources/components/SearchHeader.tsx | 39 +- .../newSession/ProfileCompatibilityIcon.tsx | 22 +- 4 files changed, 310 insertions(+), 185 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 167911fd3..4197fc325 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -763,24 +763,14 @@ function NewSessionWizard() { // Check both custom profiles and built-in profiles const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); if (profile) { - // Auto-select agent based on profile's EXCLUSIVE compatibility - // Only switch if profile supports exactly one CLI - scales automatically with new agents - const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][]) + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) .filter(([, supported]) => supported) - .map(([agent]) => agent); + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); - if (supportedCLIs.length === 1) { - const requiredAgent = supportedCLIs[0] as 'claude' | 'codex' | 'gemini'; - // Check if this agent is available and allowed - const isAvailable = cliAvailability[requiredAgent] !== false; - const isAllowed = requiredAgent !== 'gemini' || experimentsEnabled; - - if (isAvailable && isAllowed) { - setAgentType(requiredAgent); - } - // If the required CLI is unavailable or not allowed, keep current agent (profile will show as unavailable) + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? 'claude'); } - // If supportedCLIs.length > 1, profile supports multiple CLIs - don't force agent switch // Set session type from profile's default if (profile.defaultSessionType) { @@ -791,7 +781,7 @@ function NewSessionWizard() { setPermissionMode(profile.defaultPermissionMode as PermissionMode); } } - }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]); + }, [agentType, allowGemini, profileMap]); // Handle profile route param from picker screens React.useEffect(() => { @@ -812,6 +802,27 @@ function NewSessionWizard() { } }, [profileIdParam, selectedProfileId, selectProfile, useProfiles]); + // Keep agentType compatible with the currently selected profile. + React.useEffect(() => { + if (!useProfiles || selectedProfileId === null) { + return; + } + + const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId); + if (!profile) { + return; + } + + const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>) + .filter(([, supported]) => supported) + .map(([agent]) => agent as 'claude' | 'codex' | 'gemini') + .filter((agent) => agent !== 'gemini' || allowGemini); + + if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) { + setAgentType(supportedAgents[0] ?? 'claude'); + } + }, [agentType, allowGemini, profileMap, selectedProfileId, useProfiles]); + // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent React.useEffect(() => { const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; @@ -827,7 +838,7 @@ function NewSessionWizard() { }, [agentType, permissionMode]); // Scroll to section helpers - for AgentInput button clicks - const wizardSectionOffsets = React.useRef<{ profile?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); + const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => { return (e: any) => { wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0; @@ -856,7 +867,7 @@ function NewSessionWizard() { }, [scrollToWizardSection]); const handleAgentInputAgentClick = React.useCallback(() => { - scrollToWizardSection('profile'); + scrollToWizardSection('agent'); }, [scrollToWizardSection]); const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { @@ -1415,16 +1426,140 @@ function NewSessionWizard() { )} - {/* Section 1: Profile Management */} - - - - {useProfiles ? 'Select AI Profile & Backend' : 'Select AI'} - + {useProfiles && ( + <> + + + + Select AI Profile + + + + Select a profile to apply environment variables and defaults to your session. + + + {favoriteProfileItems.length > 0 && ( + + {favoriteProfileItems.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === favoriteProfileItems.length - 1; + return ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> + ); + })} + + )} + + {nonFavoriteCustomProfiles.length > 0 && ( + + {nonFavoriteCustomProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteCustomProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + )} + + + } + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={!selectedProfileId + ? ( + + + + ) + : null} + showDivider={nonFavoriteBuiltInProfiles.length > 0} + /> + {nonFavoriteBuiltInProfiles.map((profile, index) => { + const availability = isProfileAvailable(profile); + const isSelected = selectedProfileId === profile.id; + const isLast = index === nonFavoriteBuiltInProfiles.length - 1; + const isFavorite = favoriteProfileIdSet.has(profile.id); + return ( + availability.available && selectProfile(profile.id)} + rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> + ); + })} + + + } + onPress={handleAddProfile} + showChevron={false} + showDivider={false} + /> + + + + + )} + + {/* Section: AI Backend */} + + + + + Select AI Backend + + - {useProfiles - ? 'Select which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.' + {useProfiles && selectedProfileId + ? 'Limited by your selected profile and available CLIs on this machine.' : 'Select which AI runs your session.'} @@ -1573,7 +1708,7 @@ function NewSessionWizard() { )} - {selectedMachineId && cliAvailability.gemini === false && experimentsEnabled && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( + {selectedMachineId && cliAvailability.gemini === false && allowGemini && !isWarningDismissed('gemini') && !hiddenBanners.gemini && ( )} - {useProfiles ? ( - <> - {favoriteProfileItems.length > 0 && ( - - {favoriteProfileItems.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === favoriteProfileItems.length - 1; - return ( - availability.available && selectProfile(profile.id)} - rightElement={renderProfileRightElement(profile, isSelected, true)} - showDivider={!isLast} - /> - ); - })} - - )} - - {nonFavoriteCustomProfiles.length > 0 && ( - - {nonFavoriteCustomProfiles.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === nonFavoriteCustomProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - availability.available && selectProfile(profile.id)} - rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - )} - - - } - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={!selectedProfileId - ? ( + } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}> + {(() => { + const selectedProfile = useProfiles && selectedProfileId + ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId)) + : null; + + const options: Array<{ + key: 'claude' | 'codex' | 'gemini'; + title: string; + subtitle: string; + icon: React.ComponentProps['name']; + }> = [ + { key: 'claude', title: 'Claude', subtitle: 'Claude CLI', icon: 'sparkles-outline' }, + { key: 'codex', title: 'Codex', subtitle: 'Codex CLI', icon: 'terminal-outline' }, + ...(allowGemini ? [{ key: 'gemini' as const, title: 'Gemini', subtitle: 'Gemini CLI', icon: 'planet-outline' as const }] : []), + ]; + + return options.map((option, index) => { + const compatible = !selectedProfile || !!selectedProfile.compatibility?.[option.key]; + const cliOk = cliAvailability[option.key] !== false; + const disabledReason = !compatible + ? 'Not compatible with the selected profile.' + : !cliOk + ? `${option.title} CLI not detected on this machine.` + : null; + + const isSelected = agentType === option.key; + + return ( + } + selected={isSelected} + disabled={!!disabledReason} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + onPress={() => { + if (disabledReason) { + Modal.alert( + 'AI Backend', + disabledReason, + compatible + ? [{ text: t('common.ok'), style: 'cancel' }] + : [ + { text: t('common.ok'), style: 'cancel' }, + ...(useProfiles && selectedProfileId ? [{ text: 'Change Profile', onPress: handleAgentInputProfileClick }] : []), + ], + ); + return; + } + setAgentType(option.key); + }} + rightElement={( - ) - : null} - showDivider={nonFavoriteBuiltInProfiles.length > 0} - /> - {nonFavoriteBuiltInProfiles.map((profile, index) => { - const availability = isProfileAvailable(profile); - const isSelected = selectedProfileId === profile.id; - const isLast = index === nonFavoriteBuiltInProfiles.length - 1; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - availability.available && selectProfile(profile.id)} - rightElement={renderProfileRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> - ); - })} - - - } - onPress={handleAddProfile} - showChevron={false} - showDivider={false} - /> - - - ) : ( - - } - selected={agentType === 'claude'} - onPress={() => setAgentType('claude')} - showChevron={false} - /> - } - selected={agentType === 'codex'} - onPress={() => setAgentType('codex')} - showChevron={false} - /> - {allowGemini && ( - } - selected={agentType === 'gemini'} - onPress={() => setAgentType('gemini')} - showChevron={false} - showDivider={false} - /> - )} - - )} + )} + showChevron={false} + showDivider={index < options.length - 1} + /> + ); + }); + })()} + diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index b673c0b80..a0a4bf674 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -14,6 +14,8 @@ import { Item } from '@/components/Item'; import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; +import { useSetting } from '@/sync/storage'; +import { Modal } from '@/modal'; export interface ProfileEditFormProps { profile: AIBackendProfile; @@ -33,6 +35,7 @@ export function ProfileEditForm({ const { theme } = useUnistyles(); const styles = stylesheet; const groupStyle = React.useMemo(() => ({ marginBottom: 8 }), []); + const experimentsEnabled = useSetting('experiments'); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -53,6 +56,21 @@ export function ProfileEditForm({ const [defaultPermissionMode, setDefaultPermissionMode] = React.useState( (profile.defaultPermissionMode as PermissionMode) || 'default', ); + const [compatibility, setCompatibility] = React.useState>( + profile.compatibility || { claude: true, codex: true, gemini: true }, + ); + + const toggleCompatibility = React.useCallback((key: keyof AIBackendProfile['compatibility']) => { + setCompatibility((prev) => { + const next = { ...prev, [key]: !prev[key] }; + const enabledCount = Object.values(next).filter(Boolean).length; + if (enabledCount === 0) { + Modal.alert(t('common.error'), 'Select at least one AI backend.'); + return prev; + } + return next; + }); + }, []); const openSetupGuide = React.useCallback(async () => { const url = profileDocs?.setupGuideUrl; @@ -93,9 +111,11 @@ export function ProfileEditForm({ }, defaultSessionType, defaultPermissionMode, + compatibility, updatedAt: Date.now(), }); }, [ + compatibility, defaultPermissionMode, defaultSessionType, environmentVariables, @@ -169,6 +189,36 @@ export function ProfileEditForm({ ))} + + } + rightElement={ toggleCompatibility('claude')} />} + showChevron={false} + onPress={() => toggleCompatibility('claude')} + /> + } + rightElement={ toggleCompatibility('codex')} />} + showChevron={false} + onPress={() => toggleCompatibility('codex')} + /> + {experimentsEnabled && ( + } + rightElement={ toggleCompatibility('gemini')} />} + showChevron={false} + onPress={() => toggleCompatibility('gemini')} + showDivider={false} + /> + )} + + ({ paddingHorizontal: 12, paddingVertical: 8, }, - textInput: { - flex: 1, - ...Typography.default('regular'), - fontSize: Platform.select({ ios: 17, default: 16 }), - lineHeight: Platform.select({ ios: 22, default: 24 }), - letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), - color: theme.colors.input.text, - paddingVertical: 0, - ...(Platform.select({ - web: { - outlineStyle: 'none', - outlineWidth: 0, - outlineColor: 'transparent', - boxShadow: 'none', - }, - default: {}, - }) as object), - }, + textInput: { + flex: 1, + ...Typography.default('regular'), + fontSize: Platform.select({ ios: 17, default: 16 }), + lineHeight: Platform.select({ ios: 22, default: 24 }), + letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), + color: theme.colors.input.text, + paddingVertical: 0, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), + }, clearIcon: { marginLeft: 8, }, diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index ce2fb9b86..2555df500 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -10,19 +10,25 @@ type Props = { style?: ViewStyle; }; -export function ProfileCompatibilityIcon({ profile, size = 29, style }: Props) { +export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { const { theme } = useUnistyles(); + const hasClaude = !!profile.compatibility?.claude; + const hasCodex = !!profile.compatibility?.codex; + const hasGemini = !!profile.compatibility?.gemini; + const glyph = - profile.compatibility?.claude && profile.compatibility?.codex ? '✳꩜' : - profile.compatibility?.claude ? '✳' : - profile.compatibility?.codex ? '꩜' : - '•'; + hasClaude && hasCodex ? '✳꩜' : + hasClaude ? '✳' : + hasCodex ? '꩜' : + hasGemini ? '✦' : + '•'; const glyphSize = - glyph === '✳' ? Math.round(size * 0.9) : - glyph === '✳꩜' ? Math.round(size * 0.7) : - Math.round(size * 0.75); + glyph === '✳' ? Math.round(size * 1.0) : + glyph === '꩜' ? Math.round(size * 0.9) : + glyph === '✳꩜' ? Math.round(size * 0.8) : + Math.round(size * 0.85); return ( Date: Wed, 14 Jan 2026 17:19:01 +0100 Subject: [PATCH 38/72] fix(new-session): persist profile edits and preserve permissions --- sources/app/(app)/new/index.tsx | 143 ++++++++++++++------ sources/app/(app)/new/pick/profile-edit.tsx | 47 ++++++- 2 files changed, 149 insertions(+), 41 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 4197fc325..c19337132 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -425,12 +425,24 @@ function NewSessionWizard() { return null; }); - const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + const hasUserSelectedPermissionModeRef = React.useRef(false); + const permissionModeRef = React.useRef(permissionMode); + React.useEffect(() => { + permissionModeRef.current = permissionMode; + }, [permissionMode]); + + const applyPermissionMode = React.useCallback((mode: PermissionMode, source: 'user' | 'auto') => { setPermissionMode(mode); - // Save the new selection immediately sync.applySettings({ lastUsedPermissionMode: mode }); + if (source === 'user') { + hasUserSelectedPermissionModeRef.current = true; + } }, []); + const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => { + applyPermissionMode(mode, 'user'); + }, [applyPermissionMode]); + // // Path selection // @@ -759,6 +771,7 @@ function NewSessionWizard() { }, [selectedMachineId, selectedPath]); const selectProfile = React.useCallback((profileId: string) => { + const prevSelectedProfileId = selectedProfileId; setSelectedProfileId(profileId); // Check both custom profiles and built-in profiles const profile = profileMap.get(profileId) || getBuiltInProfile(profileId); @@ -776,12 +789,19 @@ function NewSessionWizard() { if (profile.defaultSessionType) { setSessionType(profile.defaultSessionType); } - // Set permission mode from profile's default - if (profile.defaultPermissionMode) { - setPermissionMode(profile.defaultPermissionMode as PermissionMode); + + // Apply permission defaults only on first selection (or if the user hasn't explicitly chosen one). + // Switching between profiles should not reset permissions when the backend stays the same. + if (!hasUserSelectedPermissionModeRef.current && profile.defaultPermissionMode) { + const nextMode = profile.defaultPermissionMode as PermissionMode; + // If the user is switching profiles (not initial selection), keep their current permissionMode. + const isInitialProfileSelection = prevSelectedProfileId === null; + if (isInitialProfileSelection) { + applyPermissionMode(nextMode, 'auto'); + } } } - }, [agentType, allowGemini, profileMap]); + }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]); // Handle profile route param from picker screens React.useEffect(() => { @@ -823,19 +843,66 @@ function NewSessionWizard() { } }, [agentType, allowGemini, profileMap, selectedProfileId, useProfiles]); - // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent + const prevAgentTypeRef = React.useRef(agentType); + + const mapPermissionModeAcrossAgents = React.useCallback((mode: PermissionMode, from: 'claude' | 'codex' | 'gemini', to: 'claude' | 'codex' | 'gemini'): PermissionMode => { + if (from === to) return mode; + + const toCodex = to === 'codex'; + if (toCodex) { + // Claude/Gemini -> Codex + switch (mode) { + case 'bypassPermissions': + return 'yolo'; + case 'plan': + return 'safe-yolo'; + case 'acceptEdits': + return 'safe-yolo'; + case 'default': + return 'default'; + default: + return 'default'; + } + } + + // Codex -> Claude/Gemini + switch (mode) { + case 'yolo': + return 'bypassPermissions'; + case 'safe-yolo': + return 'plan'; + case 'read-only': + return 'default'; + case 'default': + return 'default'; + default: + return 'default'; + } + }, []); + + // When agent type changes, keep the "permission level" consistent by mapping modes across backends. React.useEffect(() => { + const prev = prevAgentTypeRef.current; + if (prev === agentType) { + return; + } + prevAgentTypeRef.current = agentType; + + const current = permissionModeRef.current; const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - const isValidForCurrentAgent = agentType === 'codex' - ? validCodexModes.includes(permissionMode) - : validClaudeModes.includes(permissionMode); + const isValidForNewAgent = agentType === 'codex' + ? validCodexModes.includes(current) + : validClaudeModes.includes(current); - if (!isValidForCurrentAgent) { - setPermissionMode('default'); + if (isValidForNewAgent) { + return; } - }, [agentType, permissionMode]); + + const mapped = mapPermissionModeAcrossAgents(current, prev, agentType); + applyPermissionMode(mapped, 'auto'); + }, [agentType, applyPermissionMode, mapPermissionModeAcrossAgents]); // Scroll to section helpers - for AgentInput button clicks const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({}); @@ -1915,22 +1982,22 @@ function NewSessionWizard() { Select Permission Mode - - {(agentType === 'codex' - ? [ - { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-half-outline' }, - { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, - ] - : [ - { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-half-outline' }, - { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' }, - ] - ).map((option, index, array) => ( - + {(agentType === 'codex' + ? [ + { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-half-outline' }, + { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + ] + : [ + { value: 'default' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.default' : 'agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-half-outline' }, + { value: 'acceptEdits' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.acceptEdits' : 'agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.plan' : 'agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.bypassPermissions' : 'agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + ] + ).map((option, index, array) => ( + - ) : null} - onPress={() => setPermissionMode(option.value)} - showChevron={false} - selected={permissionMode === option.value} - pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < array.length - 1} - /> - ))} - + ) : null} + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + showDivider={index < array.length - 1} + /> + ))} + diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 7710ab643..d85d9f4bd 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -10,6 +10,10 @@ import { ProfileEditForm } from '@/components/ProfileEditForm'; import { AIBackendProfile } from '@/sync/settings'; import { layout } from '@/components/layout'; import { callbacks } from '../index'; +import { useSettingMutable } from '@/sync/storage'; +import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; +import { convertBuiltInProfileToCustom } from '@/sync/profileMutations'; +import { Modal } from '@/modal'; export default function ProfileEditScreen() { const { theme } = useUnistyles(); @@ -17,6 +21,8 @@ export default function ProfileEditScreen() { const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); + const [profiles, setProfiles] = useSettingMutable('profiles'); + const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { @@ -27,7 +33,7 @@ export default function ProfileEditScreen() { try { return JSON.parse(params.profileData); } catch { - return JSON.parse(decodeURIComponent(params.profileData)); + return JSON.parse(decodeURIComponent(params.profileData)); } } catch (error) { console.error('Failed to parse profile data:', error); @@ -48,8 +54,43 @@ export default function ProfileEditScreen() { }, [params.profileData]); const handleSave = (savedProfile: AIBackendProfile) => { - // Call the callback to notify wizard of saved profile - callbacks.onProfileSaved(savedProfile); + if (!savedProfile.name || savedProfile.name.trim() === '') { + Modal.alert(t('common.error'), 'Enter a profile name.'); + return; + } + + const isBuiltIn = + savedProfile.isBuiltIn === true || + DEFAULT_PROFILES.some((bp) => bp.id === savedProfile.id) || + !!getBuiltInProfile(savedProfile.id); + + let profileToSave = savedProfile; + if (isBuiltIn) { + profileToSave = convertBuiltInProfileToCustom(savedProfile); + } + + // Duplicate name guard (same behavior as settings/profiles) + const isDuplicateName = profiles.some((p) => { + if (isBuiltIn) { + return p.name.trim() === profileToSave.name.trim(); + } + return p.id !== profileToSave.id && p.name.trim() === profileToSave.name.trim(); + }); + if (isDuplicateName) { + Modal.alert(t('common.error'), 'A profile with that name already exists.'); + return; + } + + const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id); + const updatedProfiles = existingIndex >= 0 + ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) + : [...profiles, profileToSave]; + + setProfiles(updatedProfiles); + setLastUsedProfile(profileToSave.id); + + // Still notify the /new screen in case it is mounted and wants to update selection immediately. + callbacks.onProfileSaved(profileToSave); router.back(); }; From a57cbb59a88c7b3f759d1935246a061d3d535f37 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 17:19:20 +0100 Subject: [PATCH 39/72] fix(profiles): polish editor and picker UI --- sources/app/(app)/new/pick/profile.tsx | 71 ++++++++++++------- sources/app/(app)/settings/profiles.tsx | 33 +++++++-- .../components/EnvironmentVariableCard.tsx | 25 ++++++- .../components/EnvironmentVariablesList.tsx | 42 ++++++++--- sources/components/ProfileEditForm.tsx | 56 ++++++++++++--- .../newSession/DirectorySelector.tsx | 4 +- .../components/newSession/PathSelector.tsx | 22 +++--- .../newSession/ProfileCompatibilityIcon.tsx | 7 +- 8 files changed, 191 insertions(+), 69 deletions(-) diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 69666fc53..44bca012c 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -21,6 +21,7 @@ export default function ProfilePickerScreen() { const navigation = useNavigation(); const params = useLocalSearchParams<{ selectedId?: string; machineId?: string }>(); const useProfiles = useSetting('useProfiles'); + const experimentsEnabled = useSetting('experiments'); const [profiles, setProfiles] = useSettingMutable('profiles'); const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); @@ -31,6 +32,22 @@ export default function ProfilePickerScreen() { return ; }, []); + const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { + const parts: string[] = []; + if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); + if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); + if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); + return parts.length > 0 ? parts.join(' • ') : ''; + }, [experimentsEnabled]); + + const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => { + const backend = getProfileBackendSubtitle(profile); + if (profile.isBuiltIn) { + return backend ? `Built-in · ${backend}` : 'Built-in'; + } + return backend; + }, [getProfileBackendSubtitle]); + const setProfileParamAndClose = React.useCallback((profileId: string) => { const state = navigation.getState(); const previousRoute = state?.routes?.[state.index - 1]; @@ -196,15 +213,15 @@ export default function ProfilePickerScreen() { const isSelected = selectedId === profile.id; const isLast = index === favoriteProfileItems.length - 1; return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={renderProfileRowRightElement(profile, isSelected, true)} showDivider={!isLast} /> @@ -220,15 +237,15 @@ export default function ProfilePickerScreen() { const isLast = index === nonFavoriteCustomProfiles.length - 1; const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} showDivider={!isLast} /> @@ -256,15 +273,15 @@ export default function ProfilePickerScreen() { const isLast = index === nonFavoriteBuiltInProfiles.length - 1; const isFavorite = favoriteProfileIdSet.has(profile.id); return ( - setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + setProfileParamAndClose(profile.id)} + showChevron={false} + selected={isSelected} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} showDivider={!isLast} /> diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 1a847a9f5..00ff41fb1 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -16,6 +16,7 @@ import { Switch } from '@/components/Switch'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { useSetting } from '@/sync/storage'; interface ProfileManagerProps { onProfileSelect?: (profile: AIBackendProfile | null) => void; @@ -31,6 +32,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); + const experimentsEnabled = useSetting('experiments'); if (!useProfiles) { return ( @@ -130,9 +132,18 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } }; + const getProfileBackendSubtitle = React.useCallback((profile: Pick) => { + const parts: string[] = []; + if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude')); + if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex')); + if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini')); + return parts.length > 0 ? parts.join(' • ') : ''; + }, [experimentsEnabled]); + const handleSaveProfile = (profile: AIBackendProfile) => { // Profile validation - ensure name is not empty if (!profile.name || profile.name.trim() === '') { + Modal.alert(t('common.error'), 'Enter a profile name.'); return; } @@ -148,6 +159,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel p.name.trim() === newProfile.name.trim() ); if (isDuplicate) { + Modal.alert(t('common.error'), 'A profile with that name already exists.'); return; } @@ -159,6 +171,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel p.id !== profile.id && p.name.trim() === profile.name.trim() ); if (isDuplicate) { + Modal.alert(t('common.error'), 'A profile with that name already exists.'); return; } @@ -196,7 +209,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } onPress={() => handleSelectProfile(profile.id)} showChevron={false} @@ -271,7 +284,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } onPress={() => handleSelectProfile(profile.id)} showChevron={false} @@ -343,7 +356,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel } onPress={() => handleSelectProfile(profile.id)} showChevron={false} @@ -409,8 +422,14 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {/* Profile Add/Edit Modal */} {showAddForm && editingProfile && ( - - + { + setShowAddForm(false); + setEditingProfile(null); + }} + > + { }}> - - + + )} ); diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index f06be84a5..fac07ace9 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -69,6 +69,19 @@ export function EnvironmentVariableCard({ }: EnvironmentVariableCardProps) { const { theme } = useUnistyles(); + const webNoOutline = React.useMemo(() => (Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), []); + const secondaryTextStyle = React.useMemo(() => ({ fontSize: Platform.select({ ios: 15, default: 14 }), lineHeight: 20, @@ -76,6 +89,13 @@ export function EnvironmentVariableCard({ ...Typography.default(), }), []); + const remoteToggleLabelStyle = React.useMemo(() => ({ + fontSize: Platform.select({ ios: 16, default: 15 }), + lineHeight: 20, + letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), + ...Typography.default(), + }), []); + // Parse current value const parsed = parseVariableValue(variable.value); const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable); @@ -108,6 +128,7 @@ export function EnvironmentVariableCard({ return ( Copy from remote machine @@ -187,6 +208,7 @@ export function EnvironmentVariableCard({ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, marginBottom: 4, + ...webNoOutline, }} placeholder="Variable name (e.g., Z_AI_MODEL)" placeholderTextColor={theme.colors.input.placeholder} @@ -282,6 +304,7 @@ export function EnvironmentVariableCard({ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, marginBottom: 4, + ...webNoOutline, }} placeholder={expectedValue || (useRemoteVariable ? 'Default value' : 'Value')} placeholderTextColor={theme.colors.input.placeholder} diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index f18e301a7..f0d1908cf 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -8,6 +8,8 @@ import type { ProfileDocumentation } from '@/sync/profileUtils'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; import { layout } from '@/components/layout'; +import { Modal } from '@/modal'; +import { t } from '@/text'; export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string }>; @@ -33,6 +35,19 @@ export function EnvironmentVariablesList({ const [newVarName, setNewVarName] = React.useState(''); const [newVarValue, setNewVarValue] = React.useState(''); + const webNoOutline = React.useMemo(() => (Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), []); + // Helper to get expected value and description from documentation const getDocumentation = React.useCallback((varName: string) => { if (!profileDocs) return { expectedValue: undefined, description: undefined, isSecret: false }; @@ -79,20 +94,29 @@ export function EnvironmentVariablesList({ }, [environmentVariables, onChange]); const handleAddVariable = React.useCallback(() => { - if (!newVarName.trim()) return; + const normalizedName = newVarName.trim().toUpperCase(); + if (!normalizedName) { + Modal.alert(t('common.error'), 'Enter a variable name.'); + return; + } // Validate variable name format - if (!/^[A-Z_][A-Z0-9_]*$/.test(newVarName.trim())) { + if (!/^[A-Z_][A-Z0-9_]*$/.test(normalizedName)) { + Modal.alert( + t('common.error'), + 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.', + ); return; } // Check for duplicates - if (environmentVariables.some(v => v.name === newVarName.trim())) { + if (environmentVariables.some(v => v.name === normalizedName)) { + Modal.alert(t('common.error'), 'That variable already exists.'); return; } onChange([...environmentVariables, { - name: newVarName.trim(), + name: normalizedName, value: newVarValue.trim() || '' }]); @@ -100,7 +124,7 @@ export function EnvironmentVariablesList({ setNewVarName(''); setNewVarValue(''); setShowAddForm(false); - }, [newVarName, newVarValue, environmentVariables, onChange]); + }, [environmentVariables, newVarName, newVarValue, onChange]); return ( @@ -138,11 +162,11 @@ export function EnvironmentVariablesList({ marginBottom: 8, }}> setNewVarName(text.toUpperCase())} autoCapitalize="characters" autoCorrect={false} /> @@ -158,7 +182,7 @@ export function EnvironmentVariablesList({ marginBottom: 12, }}> - + {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index a0a4bf674..8a1f30dd2 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -16,6 +16,7 @@ import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; import { useSetting } from '@/sync/storage'; import { Modal } from '@/modal'; +import { RoundButton } from '@/components/RoundButton'; export interface ProfileEditFormProps { profile: AIBackendProfile; @@ -34,7 +35,7 @@ export function ProfileEditForm({ }: ProfileEditFormProps) { const { theme } = useUnistyles(); const styles = stylesheet; - const groupStyle = React.useMemo(() => ({ marginBottom: 8 }), []); + const groupStyle = React.useMemo(() => ({ marginBottom: 12 }), []); const experimentsEnabled = useSetting('experiments'); const profileDocs = React.useMemo(() => { @@ -88,6 +89,7 @@ export function ProfileEditForm({ const handleSave = React.useCallback(() => { if (!name.trim()) { + Modal.alert(t('common.error'), 'Enter a profile name.'); return; } @@ -264,14 +266,26 @@ export function ProfileEditForm({ /> - - - - + + + + + + + + + + ); } @@ -301,6 +315,18 @@ const stylesheet = StyleSheet.create((theme) => ({ lineHeight: Platform.select({ ios: 22, default: 24 }), letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }), color: theme.colors.input.text, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), }, multilineInput: { ...Typography.default('regular'), @@ -313,5 +339,17 @@ const stylesheet = StyleSheet.create((theme) => ({ color: theme.colors.input.text, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', minHeight: 120, + ...(Platform.select({ + web: { + outline: 'none', + outlineStyle: 'none', + outlineWidth: 0, + outlineColor: 'transparent', + boxShadow: 'none', + WebkitBoxShadow: 'none', + WebkitAppearance: 'none', + }, + default: {}, + }) as object), }, })); diff --git a/sources/components/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx index 75b43d220..6c66fabe4 100644 --- a/sources/components/newSession/DirectorySelector.tsx +++ b/sources/components/newSession/DirectorySelector.tsx @@ -66,14 +66,14 @@ export function DirectorySelector({ getItemIcon: () => ( ), getRecentItemIcon: () => ( ), diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index 1cc8bcf48..ef9e6b321 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -284,7 +284,7 @@ export function PathSelector({ } + leftElement={} onPress={() => setPathAndFocus(path)} selected={isSelected} showChevron={false} @@ -398,7 +398,7 @@ export function PathSelector({ } + leftElement={} onPress={() => setPathAndFocus(path)} selected={isSelected} showChevron={false} @@ -418,15 +418,15 @@ export function PathSelector({ const isLast = index === filteredSuggestedPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} + rightElement={renderRightElement(path, isSelected, isFavorite)} showDivider={!isLast} /> ); diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index 2555df500..3ad18e3dc 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -24,10 +24,11 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { hasGemini ? '✦' : '•'; + // Match visual size across glyphs (codex glyph runs larger; claude glyph runs smaller). const glyphSize = - glyph === '✳' ? Math.round(size * 1.0) : - glyph === '꩜' ? Math.round(size * 0.9) : - glyph === '✳꩜' ? Math.round(size * 0.8) : + glyph === '✳' ? Math.round(size * 1.08) : + glyph === '꩜' ? Math.round(size * 0.82) : + glyph === '✳꩜' ? Math.round(size * 0.78) : Math.round(size * 0.85); return ( From 64e84222649a56024f0abedba003a0eb8fd9ea2c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 18:33:53 +0100 Subject: [PATCH 40/72] fix(profiles): improve env var UX and editor safety --- sources/app/(app)/new/index.tsx | 66 +++--- sources/app/(app)/new/pick/profile-edit.tsx | 61 ++++- sources/app/(app)/settings/profiles.tsx | 44 +++- sources/components/AgentInput.tsx | 80 +++++-- .../components/EnvironmentVariableCard.tsx | 210 ++++++++++-------- sources/components/ProfileEditForm.tsx | 43 ++++ .../EnvironmentVariablesPreviewModal.tsx | 158 +++++++++++++ .../newSession/ProfileCompatibilityIcon.tsx | 44 ++-- 8 files changed, 531 insertions(+), 175 deletions(-) create mode 100644 sources/components/newSession/EnvironmentVariablesPreviewModal.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index c19337132..edd7064af 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -34,6 +34,7 @@ import { MachineSelector } from '@/components/newSession/MachineSelector'; import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; +import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; @@ -1055,37 +1056,18 @@ function NewSessionWizard() { React.useEffect(() => { let handler = (savedProfile: AIBackendProfile) => { - // Handle saved profile from profile-edit screen - - // Check if this is a built-in profile being edited - const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === savedProfile.id); - let profileToSave = savedProfile; - - // For built-in profiles, create a new custom profile instead of modifying the built-in - if (isBuiltIn) { - profileToSave = convertBuiltInProfileToCustom(savedProfile); - } - - const existingIndex = profiles.findIndex(p => p.id === profileToSave.id); - let updatedProfiles: AIBackendProfile[]; - - if (existingIndex >= 0) { - // Update existing profile - updatedProfiles = [...profiles]; - updatedProfiles[existingIndex] = profileToSave; - } else { - // Add new profile - updatedProfiles = [...profiles, profileToSave]; + // Only auto-select newly created profiles (Add / Duplicate / Save As). + // Edits to other profiles should not change the current selection. + const wasExisting = profiles.some(p => p.id === savedProfile.id); + if (!wasExisting) { + setSelectedProfileId(savedProfile.id); } - - setProfiles(updatedProfiles); // Use mutable setter for persistence - setSelectedProfileId(profileToSave.id); }; onProfileSaved = handler; return () => { onProfileSaved = () => { }; }; - }, [profiles, setProfiles]); + }, [profiles]); const handleMachineClick = React.useCallback(() => { router.push({ @@ -1147,6 +1129,33 @@ function NewSessionWizard() { } }, [selectedMachineId, selectedPath, router]); + const selectedProfileForEnvVars = React.useMemo(() => { + if (!useProfiles || !selectedProfileId) return null; + return profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId) || null; + }, [profileMap, selectedProfileId, useProfiles]); + + const selectedProfileEnvVars = React.useMemo(() => { + if (!selectedProfileForEnvVars) return {}; + return transformProfileToEnvironmentVars(selectedProfileForEnvVars, agentType) ?? {}; + }, [agentType, selectedProfileForEnvVars]); + + const selectedProfileEnvVarsCount = React.useMemo(() => { + return Object.keys(selectedProfileEnvVars).length; + }, [selectedProfileEnvVars]); + + const handleEnvVarsClick = React.useCallback(() => { + if (!selectedProfileForEnvVars) return; + Modal.show({ + component: EnvironmentVariablesPreviewModal, + props: { + environmentVariables: selectedProfileEnvVars, + machineId: selectedMachineId, + machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host, + profileName: selectedProfileForEnvVars.name, + }, + } as any); + }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]); + // Session creation const handleCreateSession = React.useCallback(async () => { if (!selectedMachineId) { @@ -1383,7 +1392,12 @@ function NewSessionWizard() { onMachineClick={handleMachineClick} currentPath={selectedPath} onPathClick={handlePathClick} - {...(useProfiles ? { profileId: selectedProfileId, onProfileClick: handleProfileClick } : {})} + {...(useProfiles ? { + profileId: selectedProfileId, + onProfileClick: handleProfileClick, + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} /> diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index d85d9f4bd..be7dc1c34 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { useNavigation } from '@react-navigation/native'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -18,11 +19,18 @@ import { Modal } from '@/modal'; export default function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); + const navigation = useNavigation(); const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); const [profiles, setProfiles] = useSettingMutable('profiles'); const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile'); + const [isDirty, setIsDirty] = React.useState(false); + const isDirtyRef = React.useRef(false); + + React.useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { @@ -53,6 +61,32 @@ export default function ProfileEditScreen() { }; }, [params.profileData]); + const confirmDiscard = React.useCallback(async () => { + return Modal.confirm( + 'Discard changes?', + 'You have unsaved changes. Discard them?', + { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' }, + ); + }, []); + + React.useEffect(() => { + const subscription = (navigation as any)?.addListener?.('beforeRemove', (e: any) => { + if (!isDirtyRef.current) return; + + e.preventDefault(); + + void (async () => { + const discard = await confirmDiscard(); + if (discard) { + isDirtyRef.current = false; + (navigation as any).dispatch(e.data.action); + } + })(); + }); + + return subscription; + }, [confirmDiscard, navigation]); + const handleSave = (savedProfile: AIBackendProfile) => { if (!savedProfile.name || savedProfile.name.trim() === '') { Modal.alert(t('common.error'), 'Enter a profile name.'); @@ -82,21 +116,33 @@ export default function ProfileEditScreen() { } const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id); + const isNewProfile = existingIndex < 0; const updatedProfiles = existingIndex >= 0 ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p) : [...profiles, profileToSave]; setProfiles(updatedProfiles); - setLastUsedProfile(profileToSave.id); - - // Still notify the /new screen in case it is mounted and wants to update selection immediately. - callbacks.onProfileSaved(profileToSave); + if (isNewProfile) { + setLastUsedProfile(profileToSave.id); + // Notify the /new screen only for newly created profiles (Add / Duplicate / Save As). + callbacks.onProfileSaved(profileToSave); + } router.back(); }; - const handleCancel = () => { - router.back(); - }; + const handleCancel = React.useCallback(() => { + void (async () => { + if (!isDirtyRef.current) { + router.back(); + return; + } + const discard = await confirmDiscard(); + if (discard) { + isDirtyRef.current = false; + router.back(); + } + })(); + }, [confirmDiscard, router]); return ( diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 00ff41fb1..0e78d2488 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -32,8 +32,14 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles'); const [editingProfile, setEditingProfile] = React.useState(null); const [showAddForm, setShowAddForm] = React.useState(false); + const [isEditingDirty, setIsEditingDirty] = React.useState(false); + const isEditingDirtyRef = React.useRef(false); const experimentsEnabled = useSetting('experiments'); + React.useEffect(() => { + isEditingDirtyRef.current = isEditingDirty; + }, [isEditingDirty]); + if (!useProfiles) { return ( @@ -73,6 +79,30 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setShowAddForm(true); }; + const closeEditor = React.useCallback(() => { + setShowAddForm(false); + setEditingProfile(null); + setIsEditingDirty(false); + }, []); + + const requestCloseEditor = React.useCallback(() => { + void (async () => { + if (!isEditingDirtyRef.current) { + closeEditor(); + return; + } + const discard = await Modal.confirm( + 'Discard changes?', + 'You have unsaved changes. Discard them?', + { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' }, + ); + if (discard) { + isEditingDirtyRef.current = false; + closeEditor(); + } + })(); + }, [closeEditor]); + const handleDeleteProfile = async (profile: AIBackendProfile) => { const confirmed = await Modal.confirm( t('profiles.delete.title'), @@ -193,8 +223,7 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel setProfiles(updatedProfiles); } - setShowAddForm(false); - setEditingProfile(null); + closeEditor(); }; return ( @@ -424,20 +453,15 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {showAddForm && editingProfile && ( { - setShowAddForm(false); - setEditingProfile(null); - }} + onPress={requestCloseEditor} > { }}> { - setShowAddForm(false); - setEditingProfile(null); - }} + onCancel={requestCloseEditor} + onDirtyChange={setIsEditingDirty} /> diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 713286947..89fe69077 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -74,6 +74,8 @@ interface AgentInputProps { minHeight?: number; profileId?: string | null; onProfileClick?: () => void; + envVarsCount?: number; + onEnvVarsClick?: () => void; } const MAX_CONTEXT_SIZE = 190000; @@ -807,11 +809,11 @@ export const AgentInput = React.memo(React.forwardRef - + {profileLabel ?? t('profiles.noProfile')} - + )} + {/* Env vars preview (standard flow) */} + {props.onEnvVarsClick && ( + { + hapticsLight(); + props.onEnvVarsClick?.(); + }} + hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} + style={(p) => ({ + flexDirection: 'row', + alignItems: 'center', + borderRadius: Platform.select({ default: 16, android: 20 }), + paddingHorizontal: 10, + paddingVertical: 6, + justifyContent: 'center', + height: 32, + opacity: p.pressed ? 0.7 : 1, + gap: 6, + })} + > + + + {props.envVarsCount ? `Env Vars (${props.envVarsCount})` : 'Env Vars'} + + + )} + {/* Agent selector button */} {props.agentType && props.onAgentClick && ( - + - + - + ({ - fontSize: Platform.select({ ios: 16, default: 15 }), + fontSize: Platform.select({ ios: 17, default: 16 }), lineHeight: 20, letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }), ...Typography.default(), @@ -126,6 +126,11 @@ export function EnvironmentVariableCard({ const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue; const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue; + const computedTemplateValue = + useRemoteVariable && remoteVariableName.trim() !== '' + ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` + : defaultValue; + return ( )} - {/* Toggle: Copy from remote machine */} - + {/* Value label */} + + {useRemoteVariable ? 'Fallback value:' : 'Value:'} + + + {/* Value input */} + + + {/* Security message for secrets */} + {isSecret && ( + + Secret value - not retrieved for security + + )} + + {/* Default override warning */} + {showDefaultOverrideWarning && !isSecret && ( + + Overriding documented default: {expectedValue} + + )} + + + + {/* Toggle: Use value from machine environment */} + - Copy from remote machine + Use value from machine environment - {/* Remote variable name input (only when enabled) */} + + Resolved when the session starts on the selected machine. + + + {/* Source variable name input (only when enabled) */} {useRemoteVariable && ( - + + ...secondaryTextStyle, + }}> + Source variable + + + + )} - {/* Remote variable status */} + {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( {remoteValue === undefined ? ( @@ -228,7 +313,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...secondaryTextStyle, }}> - Checking remote machine... + Checking machine environment... ) : remoteValue === null ? ( )} - {useRemoteVariable && !isSecret && !machineId && ( - - Select a machine to check if variable exists - - )} - - {/* Security message for secrets */} - {isSecret && ( - - Secret value - not retrieved for security - - )} - - {/* Value label */} - - {useRemoteVariable ? 'Default value:' : 'Value:'} - - - {/* Default value input */} - - - {/* Default override warning */} - {showDefaultOverrideWarning && !isSecret && ( - - Overriding documented default: {expectedValue} - - )} - {/* Session preview */} - + style={({ pressed }) => ({ + backgroundColor: theme.colors.button.primary.background, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + opacity: pressed ? 0.85 : 1, + })} + > + + {profile.isBuiltIn ? t('common.saveAs') : t('common.save')} + + diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index d835d0adb..3ee2c30c9 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, ScrollView, Pressable, Platform } from 'react-native'; +import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; @@ -33,6 +33,7 @@ function isSecretLike(name: string) { export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { const { theme } = useUnistyles(); + const { height: windowHeight } = useWindowDimensions(); const envVarEntries = React.useMemo(() => { return Object.entries(props.environmentVariables) @@ -54,17 +55,20 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev const { variables: machineEnv } = useEnvironmentVariables(props.machineId, refsToQuery); const title = props.profileName ? `Env Vars · ${props.profileName}` : 'Environment Variables'; + const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); return ( - + - These environment variables are sent when starting the session. - {props.machineName ? ` Values are resolved using the daemon on ${props.machineName}.` : ' Values are resolved using the daemon on the selected machine.'} + These environment variables are sent when starting the session. Values are resolved using the daemon on{' '} + {props.machineName ? ( + + {props.machineName} + + ) : ( + 'the selected machine' + )} + . diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx index 8f53f0a04..dfaac7f8a 100644 --- a/sources/components/newSession/ProfileCompatibilityIcon.tsx +++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx @@ -48,18 +48,22 @@ export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) { ) : ( - {glyphs.map((item) => ( - - {item.glyph} - - ))} + {glyphs.map((item) => { + const fontSize = Math.round(size * multiScale * item.factor); + return ( + + {item.glyph} + + ); + })} )} diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 36db3482b..90780e4b1 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -15,6 +15,7 @@ export interface NewSessionDraft { input: string; selectedMachineId: string | null; selectedPath: string | null; + selectedProfileId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; sessionType: NewSessionSessionType; @@ -140,6 +141,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const input = typeof parsed.input === 'string' ? parsed.input : ''; const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null; const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null; + const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null; const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' ? parsed.agentType : 'claude'; @@ -153,6 +155,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { input, selectedMachineId, selectedPath, + selectedProfileId, agentType, permissionMode, sessionType, From b79dfe5a9346f7e65ef94eceb840e17c964a1c14 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:08:24 +0100 Subject: [PATCH 45/72] fix(new-session): persist model mode in draft --- sources/app/(app)/new/index.tsx | 17 +++++++++++++++-- sources/sync/persistence.ts | 6 ++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 869bcf157..4e7d07afe 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -403,6 +403,17 @@ function NewSessionWizard() { const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'default', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high']; const validGeminiModes: ModelMode[] = ['default']; + if (persistedDraft?.modelMode) { + const draftMode = persistedDraft.modelMode as ModelMode; + if (agentType === 'codex' && validCodexModes.includes(draftMode)) { + return draftMode; + } else if (agentType === 'claude' && validClaudeModes.includes(draftMode)) { + return draftMode; + } else if (agentType === 'gemini' && validGeminiModes.includes(draftMode)) { + return draftMode; + } + } + if (lastUsedModelMode) { if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { return lastUsedModelMode as ModelMode; @@ -671,6 +682,7 @@ function NewSessionWizard() { selectedProfileId: useProfiles ? selectedProfileId : null, agentType, permissionMode, + modelMode, sessionType, updatedAt: Date.now(), }); @@ -678,7 +690,7 @@ function NewSessionWizard() { const profileData = JSON.stringify(profile); const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; router.push(selectedMachineId ? `${base}&machineId=${encodeURIComponent(selectedMachineId)}` as any : base as any); - }, [agentType, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); const handleAddProfile = React.useCallback(() => { openProfileEdit(createEmptyCustomProfile()); @@ -1370,10 +1382,11 @@ function NewSessionWizard() { selectedProfileId: useProfiles ? selectedProfileId : null, agentType, permissionMode, + modelMode, sessionType, updatedAt: Date.now(), }); - }, [agentType, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); + }, [agentType, modelMode, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); // Persist the current wizard state so it survives remounts and screen navigation // Uses debouncing to avoid excessive writes diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 90780e4b1..34ae1ad0c 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -4,6 +4,7 @@ import { LocalSettings, localSettingsDefaults, localSettingsParse } from './loca import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { ModelMode } from '@/components/PermissionModeSelector'; const mmkv = new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; @@ -18,6 +19,7 @@ export interface NewSessionDraft { selectedProfileId: string | null; agentType: NewSessionAgentType; permissionMode: PermissionMode; + modelMode: ModelMode; sessionType: NewSessionSessionType; updatedAt: number; } @@ -148,6 +150,9 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string' ? (parsed.permissionMode as PermissionMode) : 'default'; + const modelMode: ModelMode = typeof parsed.modelMode === 'string' + ? (parsed.modelMode as ModelMode) + : 'default'; const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple'; const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(); @@ -158,6 +163,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { selectedProfileId, agentType, permissionMode, + modelMode, sessionType, updatedAt, }; From 78d696bf971e27c7bfe12e5ce5747d98a33a9b8b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:17:30 +0100 Subject: [PATCH 46/72] refactor(new-session): stop using lastUsedModelMode --- sources/app/(app)/new/index.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 4e7d07afe..7a5f71432 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -283,7 +283,6 @@ function NewSessionWizard() { const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard'); const useProfiles = useSetting('useProfiles'); const lastUsedPermissionMode = useSetting('lastUsedPermissionMode'); - const lastUsedModelMode = useSetting('lastUsedModelMode'); const experimentsEnabled = useSetting('experiments'); const useMachinePickerSearch = useSetting('useMachinePickerSearch'); const usePathPickerSearch = useSetting('usePathPickerSearch'); @@ -413,16 +412,6 @@ function NewSessionWizard() { return draftMode; } } - - if (lastUsedModelMode) { - if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } else if (agentType === 'gemini' && validGeminiModes.includes(lastUsedModelMode as ModelMode)) { - return lastUsedModelMode as ModelMode; - } - } return agentType === 'codex' ? 'gpt-5-codex-high' : 'default'; }); @@ -1275,9 +1264,6 @@ function NewSessionWizard() { if (profilesActive) { settingsUpdate.lastUsedProfile = selectedProfileId; } - if (useEnhancedSessionWizard) { - settingsUpdate.lastUsedModelMode = modelMode; - } sync.applySettings(settingsUpdate); // Get environment variables from selected profile From 8307d1d0786a83a6b7861de0f94a3eb8952af6c7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:42:33 +0100 Subject: [PATCH 47/72] fix(new-session): compact mobile profile actions --- sources/app/(app)/new/index.tsx | 119 +++++++++++++----- sources/components/AgentInput.tsx | 29 ++--- .../newSession/ProfileActionsMenuModal.tsx | 115 +++++++++++++++++ 3 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 sources/components/newSession/ProfileActionsMenuModal.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 7a5f71432..d1b24f81a 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -35,6 +35,7 @@ import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; +import { ProfileActionsMenuModal } from '@/components/newSession/ProfileActionsMenuModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; @@ -251,13 +252,14 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); - const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ - prompt?: string; - dataId?: string; - machineId?: string; + const router = useRouter(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const screenWidth = useWindowDimensions().width; + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ + prompt?: string; + dataId?: string; + machineId?: string; path?: string; profileId?: string; }>(); @@ -971,16 +973,76 @@ function NewSessionWizard() { } as any); }, [selectedMachine, selectedMachineId]); - const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => { - return ; - }, []); - - const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { - const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; - return ( - - - { + return ; + }, []); + + const openProfileActionsMenu = React.useCallback((profile: AIBackendProfile, isFavorite: boolean) => { + Modal.show({ + component: ProfileActionsMenuModal, + props: { + profileName: profile.name, + isFavorite, + hasEnvVars: Object.keys(getProfileEnvironmentVariables(profile)).length > 0, + canDelete: !profile.isBuiltIn, + onToggleFavorite: () => toggleFavoriteProfile(profile.id), + onViewEnvVars: () => openProfileEnvVarsPreview(profile), + onEdit: () => openProfileEdit(profile), + onCopy: () => handleDuplicateProfile(profile), + onDelete: !profile.isBuiltIn ? () => handleDeleteProfile(profile) : undefined, + }, + } as any); + }, [handleDeleteProfile, handleDuplicateProfile, openProfileEdit, openProfileEnvVarsPreview, toggleFavoriteProfile]); + + const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; + const compact = screenWidth < 420; + + if (compact) { + return ( + + + + + {envVarCount > 0 && ( + { + ignoreProfileRowPressRef.current = true; + }} + onPress={(e) => { + e.stopPropagation(); + openProfileEnvVarsPreview(profile); + }} + > + + + )} + { + ignoreProfileRowPressRef.current = true; + }} + onPress={(e) => { + e.stopPropagation(); + openProfileActionsMenu(profile, isFavorite); + }} + > + + + + ); + } + + return ( + + + { @@ -1321,8 +1385,7 @@ function NewSessionWizard() { } }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]); - const screenWidth = useWindowDimensions().width; - const showInlineClose = screenWidth < 520; + const showInlineClose = screenWidth < 520; const handleCloseModal = React.useCallback(() => { // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry. diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 9ed377621..98093555d 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -230,13 +230,14 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ justifyContent: 'space-between', paddingHorizontal: 0, }, - actionButtonsLeft: { - flexDirection: 'row', - gap: 8, - flex: 1, - flexWrap: 'wrap', - overflow: 'visible', - }, + actionButtonsLeft: { + flexDirection: 'row', + columnGap: 8, + rowGap: 6, + flex: 1, + flexWrap: 'wrap', + overflow: 'visible', + }, actionButton: { flexDirection: 'row', alignItems: 'center', @@ -741,13 +742,13 @@ export const AgentInput = React.memo(React.forwardRef - - {/* Action buttons below input */} - - - {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - - + + {/* Action buttons below input */} + + + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} + + {/* Permission chip (popover in standard flow, scroll in wizard) */} {showPermissionChip && ( diff --git a/sources/components/newSession/ProfileActionsMenuModal.tsx b/sources/components/newSession/ProfileActionsMenuModal.tsx new file mode 100644 index 000000000..b658c33b1 --- /dev/null +++ b/sources/components/newSession/ProfileActionsMenuModal.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; + +export interface ProfileActionsMenuModalProps { + profileName: string; + isFavorite: boolean; + hasEnvVars: boolean; + canDelete: boolean; + onToggleFavorite: () => void; + onViewEnvVars?: () => void; + onEdit: () => void; + onCopy: () => void; + onDelete?: () => void; + onClose: () => void; +} + +export function ProfileActionsMenuModal(props: ProfileActionsMenuModalProps) { + const { theme } = useUnistyles(); + + const closeThen = React.useCallback((fn?: () => void) => { + props.onClose(); + if (!fn) return; + setTimeout(() => fn(), 0); + }, [props]); + + return ( + + + + {props.profileName} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + {props.hasEnvVars && props.onViewEnvVars && ( + } + onPress={() => closeThen(props.onViewEnvVars)} + showChevron={false} + /> + )} + + } + onPress={() => closeThen(props.onToggleFavorite)} + showChevron={false} + /> + } + onPress={() => closeThen(props.onEdit)} + showChevron={false} + /> + } + onPress={() => closeThen(props.onCopy)} + showChevron={false} + /> + {props.canDelete && props.onDelete && ( + } + onPress={() => closeThen(props.onDelete)} + showChevron={false} + /> + )} + + + + ); +} + From 4692b3b199b9723ae0aa75fbb7417139d7b12a42 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 21:57:52 +0100 Subject: [PATCH 48/72] fix(ui): suppress selected background for single-item groups --- sources/app/(app)/machine/[id].tsx | 1 - sources/app/(app)/new/index.tsx | 58 +++++----- sources/app/(app)/new/pick/profile.tsx | 40 +++---- sources/app/(app)/settings/profiles.tsx | 33 +++--- sources/components/Item.tsx | 35 +++--- sources/components/ItemGroup.tsx | 53 ++++++--- sources/components/ProfileEditForm.tsx | 1 - sources/components/SearchableListSelector.tsx | 1 - sources/components/SessionTypeSelector.tsx | 3 - .../components/newSession/PathSelector.tsx | 105 +++++++++--------- 10 files changed, 167 insertions(+), 163 deletions(-) diff --git a/sources/app/(app)/machine/[id].tsx b/sources/app/(app)/machine/[id].tsx index bb014796a..68438d54d 100644 --- a/sources/app/(app)/machine/[id].tsx +++ b/sources/app/(app)/machine/[id].tsx @@ -399,7 +399,6 @@ export default function MachineDetailScreen() { disabled={!isMachineOnline(machine)} selected={isSelected} showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={!hideDivider} /> ); diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d1b24f81a..884027594 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -1658,11 +1658,10 @@ function NewSessionWizard() { title={profile.name} subtitle={getProfileSubtitle(profile)} leftElement={renderProfileLeftElement(profile)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - disabled={!availability.available} - onPress={() => { + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => { if (!availability.available) return; if (ignoreProfileRowPressRef.current) { ignoreProfileRowPressRef.current = false; @@ -1691,11 +1690,10 @@ function NewSessionWizard() { title={profile.name} subtitle={getProfileSubtitle(profile)} leftElement={renderProfileLeftElement(profile)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - disabled={!availability.available} - onPress={() => { + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => { if (!availability.available) return; if (ignoreProfileRowPressRef.current) { ignoreProfileRowPressRef.current = false; @@ -1716,12 +1714,11 @@ function NewSessionWizard() { title={t('profiles.noProfile')} subtitle={t('profiles.noProfileDescription')} leftElement={} - showChevron={false} - selected={!selectedProfileId} - onPress={() => setSelectedProfileId(null)} - pressableStyle={!selectedProfileId ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={!selectedProfileId - ? ( + showChevron={false} + selected={!selectedProfileId} + onPress={() => setSelectedProfileId(null)} + rightElement={!selectedProfileId + ? ( { + showChevron={false} + selected={isSelected} + disabled={!availability.available} + onPress={() => { if (!availability.available) return; if (ignoreProfileRowPressRef.current) { ignoreProfileRowPressRef.current = false; @@ -2042,11 +2038,10 @@ function NewSessionWizard() { key={option.key} title={option.title} subtitle={disabledReason ?? option.subtitle} - leftElement={} - selected={isSelected} - disabled={!!disabledReason} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - onPress={() => { + leftElement={} + selected={isSelected} + disabled={!!disabledReason} + onPress={() => { if (disabledReason) { Modal.alert( 'AI Backend', @@ -2177,12 +2172,11 @@ function NewSessionWizard() { color={theme.colors.button.primary.background} /> ) : null} - onPress={() => handlePermissionModeChange(option.value)} - showChevron={false} - selected={permissionMode === option.value} - pressableStyle={permissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - showDivider={index < array.length - 1} - /> + onPress={() => handlePermissionModeChange(option.value)} + showChevron={false} + selected={permissionMode === option.value} + showDivider={index < array.length - 1} + /> ))} diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 44bca012c..233425f98 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -220,11 +220,10 @@ export default function ProfilePickerScreen() { icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderProfileRowRightElement(profile, isSelected, true)} - showDivider={!isLast} - /> + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, true)} + showDivider={!isLast} + /> ); })} @@ -244,11 +243,10 @@ export default function ProfilePickerScreen() { icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -260,12 +258,11 @@ export default function ProfilePickerScreen() { subtitle={t('profiles.noProfileDescription')} icon={} onPress={() => setProfileParamAndClose('')} - showChevron={false} - selected={selectedId === ''} - pressableStyle={selectedId === '' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={selectedId === '' - ? - : null} + showChevron={false} + selected={selectedId === ''} + rightElement={selectedId === '' + ? + : null} showDivider={nonFavoriteBuiltInProfiles.length > 0} /> {nonFavoriteBuiltInProfiles.map((profile, index) => { @@ -279,12 +276,11 @@ export default function ProfilePickerScreen() { subtitle={getProfileSubtitle(profile)} icon={renderProfileIcon(profile)} onPress={() => setProfileParamAndClose(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} - showDivider={!isLast} - /> + showChevron={false} + selected={isSelected} + rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index 0e78d2488..fd3aec8f8 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -240,12 +240,11 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel title={profile.name} subtitle={getProfileBackendSubtitle(profile)} leftElement={} - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + } - onPress={() => handleSelectProfile(profile.id)} - showChevron={false} - selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={( - + onPress={() => handleSelectProfile(profile.id)} + showChevron={false} + selected={isSelected} + rightElement={( + ({ export const Item = React.memo((props) => { const { theme } = useUnistyles(); const styles = stylesheet; - + const selectionContext = React.useContext(ItemGroupSelectionContext); + // Platform-specific measurements const isIOS = Platform.OS === 'ios'; const isAndroid = Platform.OS === 'android'; @@ -197,9 +199,10 @@ export const Item = React.memo((props) => { // The copy will be handled by long press instead const handlePress = onPress; - const isInteractive = handlePress || onLongPress || (copy && !isWeb); - const showAccessory = isInteractive && showChevron && !rightElement; - const chevronSize = (isIOS && !isWeb) ? 17 : 24; + const isInteractive = handlePress || onLongPress || (copy && !isWeb); + const showAccessory = isInteractive && showChevron && !rightElement; + const chevronSize = (isIOS && !isWeb) ? 17 : 24; + const showSelectedBackground = !!selected && ((selectionContext?.selectableItemCount ?? 2) > 1); const titleColor = destructive ? styles.titleDestructive : (selected ? styles.titleSelected : styles.titleNormal); const containerPadding = subtitle ? styles.containerWithSubtitle : styles.containerWithoutSubtitle; @@ -285,21 +288,23 @@ export const Item = React.memo((props) => { ); - if (isInteractive) { - return ( - [ - { - backgroundColor: pressed && isIOS && !isWeb ? theme.colors.surfacePressedOverlay : 'transparent', - opacity: disabled ? 0.5 : 1 - }, - pressableStyle - ]} + disabled={disabled || loading} + style={({ pressed }) => [ + { + backgroundColor: pressed && isIOS && !isWeb + ? theme.colors.surfacePressedOverlay + : (showSelectedBackground ? theme.colors.surfaceSelected : 'transparent'), + opacity: disabled ? 0.5 : 1 + }, + pressableStyle + ]} android_ripple={(isAndroid || isWeb) ? { color: theme.colors.surfaceRipple, borderless: false, diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx index 0e046fb86..006f534d3 100644 --- a/sources/components/ItemGroup.tsx +++ b/sources/components/ItemGroup.tsx @@ -11,6 +11,8 @@ import { Typography } from '@/constants/Typography'; import { layout } from './layout'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null); + interface ItemChildProps { showDivider?: boolean; [key: string]: any; @@ -95,6 +97,25 @@ export const ItemGroup = React.memo((props) => { containerStyle } = props; + const selectableItemCount = React.useMemo(() => { + const countSelectable = (node: React.ReactNode): number => { + return React.Children.toArray(node).reduce((count, child) => { + if (!React.isValidElement(child)) { + return count; + } + if (child.type === React.Fragment) { + return count + countSelectable(child.props.children); + } + const propsAny = child.props as any; + const hasTitle = typeof propsAny?.title === 'string'; + const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function'; + return count + (hasTitle && isSelectable ? 1 : 0); + }, 0); + }; + + return countSelectable(children); + }, [children]); + return ( @@ -116,21 +137,23 @@ export const ItemGroup = React.memo((props) => { {/* Content Container */} - {React.Children.map(children, (child, index) => { - if (React.isValidElement(child)) { - // Don't add props to React.Fragment - if (child.type === React.Fragment) { - return child; + + {React.Children.map(children, (child, index) => { + if (React.isValidElement(child)) { + // Don't add props to React.Fragment + if (child.type === React.Fragment) { + return child; + } + const isLast = index === React.Children.count(children) - 1; + const childProps = child.props as ItemChildProps; + return React.cloneElement(child, { + ...childProps, + showDivider: !isLast && childProps.showDivider !== false + }); } - const isLast = index === React.Children.count(children) - 1; - const childProps = child.props as ItemChildProps; - return React.cloneElement(child, { - ...childProps, - showDivider: !isLast && childProps.showDivider !== false - }); - } - return child; - })} + return child; + })} + {/* Footer */} @@ -144,4 +167,4 @@ export const ItemGroup = React.memo((props) => { ); -}); \ No newline at end of file +}); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 7940b1c86..a79bfd564 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -227,7 +227,6 @@ export function ProfileEditForm({ onPress={() => setDefaultPermissionMode(option.value)} showChevron={false} selected={defaultPermissionMode === option.value} - pressableStyle={defaultPermissionMode === option.value ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={index < array.length - 1} /> ))} diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx index 968e02b8d..5d93bb0eb 100644 --- a/sources/components/SearchableListSelector.tsx +++ b/sources/components/SearchableListSelector.tsx @@ -242,7 +242,6 @@ export function SearchableListSelector(props: SearchableListSelectorProps) onPress={() => onSelect(item)} showChevron={false} selected={isSelected} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast} /> ); diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx index b4420235a..d7489d4ec 100644 --- a/sources/components/SessionTypeSelector.tsx +++ b/sources/components/SessionTypeSelector.tsx @@ -48,7 +48,6 @@ export function SessionTypeSelectorRows({ value, onChange }: Pick )} selected={value === 'simple'} - pressableStyle={value === 'simple' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} onPress={() => onChange('simple')} showChevron={false} showDivider={true} @@ -62,7 +61,6 @@ export function SessionTypeSelectorRows({ value, onChange }: Pick )} selected={value === 'worktree'} - pressableStyle={value === 'worktree' ? { backgroundColor: theme.colors.surfaceSelected } : undefined} onPress={() => onChange('worktree')} showChevron={false} showDivider={false} @@ -82,4 +80,3 @@ export function SessionTypeSelector({ value, onChange, title = t('newSession.ses ); } - diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx index ef9e6b321..6ab44d019 100644 --- a/sources/components/newSession/PathSelector.tsx +++ b/sources/components/newSession/PathSelector.tsx @@ -281,17 +281,16 @@ export function PathSelector({ const isLast = index === filteredRecentPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -326,17 +325,16 @@ export function PathSelector({ const isSelected = selectedPath.trim() === path; const isLast = index === filteredFavoritePaths.length - 1; return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, true)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, true)} + showDivider={!isLast} + /> ); })} @@ -349,17 +347,16 @@ export function PathSelector({ const isLast = index === filteredRecentPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -395,17 +392,16 @@ export function PathSelector({ const isLast = index === filteredSuggestedPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} @@ -418,17 +414,16 @@ export function PathSelector({ const isLast = index === filteredSuggestedPaths.length - 1; const isFavorite = favoritePaths.includes(path); return ( - } - onPress={() => setPathAndFocus(path)} - selected={isSelected} - showChevron={false} - pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined} - rightElement={renderRightElement(path, isSelected, isFavorite)} - showDivider={!isLast} - /> + } + onPress={() => setPathAndFocus(path)} + selected={isSelected} + showChevron={false} + rightElement={renderRightElement(path, isSelected, isFavorite)} + showDivider={!isLast} + /> ); })} From f3815a1766265dedfe73181243e559883d29ddc7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:07:46 +0100 Subject: [PATCH 49/72] fix(ui): collapse row actions on mobile --- sources/app/(app)/settings/profiles.tsx | 286 ++++++++++---------- sources/components/ItemActionsMenuModal.tsx | 91 +++++++ sources/components/ItemRowActions.tsx | 85 ++++++ 3 files changed, 312 insertions(+), 150 deletions(-) create mode 100644 sources/components/ItemActionsMenuModal.tsx create mode 100644 sources/components/ItemRowActions.tsx diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx index fd3aec8f8..8b80f011b 100644 --- a/sources/app/(app)/settings/profiles.tsx +++ b/sources/app/(app)/settings/profiles.tsx @@ -12,6 +12,8 @@ import { ProfileEditForm } from '@/components/ProfileEditForm'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; import { Item } from '@/components/Item'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; import { Switch } from '@/components/Switch'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { buildProfileGroups } from '@/sync/profileGrouping'; @@ -231,20 +233,50 @@ const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, sel {favoriteProfileItems.length > 0 && ( - {favoriteProfileItems.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => handleEditProfile(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + ]; + if (!profile.isBuiltIn) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => { void handleDeleteProfile(profile); }, + }); + } + return ( + } onPress={() => handleSelectProfile(profile.id)} showChevron={false} - selected={isSelected} - rightElement={( - + selected={isSelected} + rightElement={( + - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - {!profile.isBuiltIn && ( - { - e.stopPropagation(); - void handleDeleteProfile(profile); - }} - > - - - )} - - )} - /> - ); - })} + + + )} + /> + ); + })} )} {nonFavoriteCustomProfiles.length > 0 && ( - {nonFavoriteCustomProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => handleEditProfile(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + { + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => { void handleDeleteProfile(profile); }, + }, + ]; + return ( + } onPress={() => handleSelectProfile(profile.id)} showChevron={false} - selected={isSelected} - rightElement={( - + selected={isSelected} + rightElement={( + - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - { - e.stopPropagation(); - void handleDeleteProfile(profile); - }} - > - - - - )} - /> + + + )} + /> ); })} )} - {nonFavoriteBuiltInProfiles.map((profile) => { - const isSelected = selectedProfileId === profile.id; - const isFavorite = favoriteProfileIdSet.has(profile.id); - return ( - { + const isSelected = selectedProfileId === profile.id; + const isFavorite = favoriteProfileIdSet.has(profile.id); + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => handleEditProfile(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + ]; + return ( + } onPress={() => handleSelectProfile(profile.id)} showChevron={false} - selected={isSelected} - rightElement={( - + selected={isSelected} + rightElement={( + - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - handleEditProfile(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - - )} - /> - ); - })} + + + )} + /> + ); + })} diff --git a/sources/components/ItemActionsMenuModal.tsx b/sources/components/ItemActionsMenuModal.tsx new file mode 100644 index 000000000..387a94b60 --- /dev/null +++ b/sources/components/ItemActionsMenuModal.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Typography } from '@/constants/Typography'; +import { ItemGroup } from '@/components/ItemGroup'; +import { Item } from '@/components/Item'; + +export type ItemAction = { + id: string; + title: string; + icon: React.ComponentProps['name']; + onPress: () => void; + destructive?: boolean; + color?: string; +}; + +export interface ItemActionsMenuModalProps { + title: string; + actions: ItemAction[]; + onClose: () => void; +} + +export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) { + const { theme } = useUnistyles(); + + const closeThen = React.useCallback((fn: () => void) => { + props.onClose(); + setTimeout(() => fn(), 0); + }, [props]); + + return ( + + + + {props.title} + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + + {props.actions.map((action, idx) => ( + + } + onPress={() => closeThen(action.onPress)} + showChevron={false} + showDivider={idx < props.actions.length - 1} + /> + ))} + + + + ); +} diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx new file mode 100644 index 000000000..9d2789411 --- /dev/null +++ b/sources/components/ItemRowActions.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { View, Pressable, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useUnistyles } from 'react-native-unistyles'; +import { Modal } from '@/modal'; +import { ItemActionsMenuModal, type ItemAction } from '@/components/ItemActionsMenuModal'; + +export interface ItemRowActionsProps { + title: string; + actions: ItemAction[]; + compactThreshold?: number; + compactActionIds?: string[]; + iconSize?: number; + gap?: number; + onActionPressIn?: () => void; +} + +export function ItemRowActions(props: ItemRowActionsProps) { + const { theme } = useUnistyles(); + const { width } = useWindowDimensions(); + const compact = width < (props.compactThreshold ?? 420); + + const compactIds = React.useMemo(() => new Set(props.compactActionIds ?? []), [props.compactActionIds]); + const inlineActions = React.useMemo(() => { + if (!compact) return props.actions; + return props.actions.filter((a) => compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + const overflowActions = React.useMemo(() => { + if (!compact) return []; + return props.actions.filter((a) => !compactIds.has(a.id)); + }, [compact, compactIds, props.actions]); + + const openMenu = React.useCallback(() => { + if (overflowActions.length === 0) return; + Modal.show({ + component: ItemActionsMenuModal, + props: { + title: props.title, + actions: overflowActions, + }, + } as any); + }, [overflowActions, props.title]); + + const iconSize = props.iconSize ?? 20; + const gap = props.gap ?? 16; + + return ( + + {inlineActions.map((action) => ( + props.onActionPressIn?.()} + onPress={(e: any) => { + e?.stopPropagation?.(); + action.onPress(); + }} + > + + + ))} + + {compact && overflowActions.length > 0 && ( + props.onActionPressIn?.()} + onPress={(e: any) => { + e?.stopPropagation?.(); + openMenu(); + }} + > + + + )} + + ); +} From 97d644e0a284e173cd3a85c04bd633ab0c143bf7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:21:41 +0100 Subject: [PATCH 50/72] refactor(new-session): reuse ItemRowActions for profile rows --- sources/app/(app)/new/index.tsx | 192 +++++------------- sources/app/(app)/new/pick/profile.tsx | 107 +++++----- .../newSession/ProfileActionsMenuModal.tsx | 115 ----------- 3 files changed, 108 insertions(+), 306 deletions(-) delete mode 100644 sources/components/newSession/ProfileActionsMenuModal.tsx diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 884027594..daded0ffe 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -35,9 +35,10 @@ import { PathSelector } from '@/components/newSession/PathSelector'; import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; -import { ProfileActionsMenuModal } from '@/components/newSession/ProfileActionsMenuModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; // Simple temporary state for passing selections back from picker screens let onMachineSelected: (machineId: string) => void = () => { }; @@ -977,66 +978,45 @@ function NewSessionWizard() { return ; }, []); - const openProfileActionsMenu = React.useCallback((profile: AIBackendProfile, isFavorite: boolean) => { - Modal.show({ - component: ProfileActionsMenuModal, - props: { - profileName: profile.name, - isFavorite, - hasEnvVars: Object.keys(getProfileEnvironmentVariables(profile)).length > 0, - canDelete: !profile.isBuiltIn, - onToggleFavorite: () => toggleFavoriteProfile(profile.id), - onViewEnvVars: () => openProfileEnvVarsPreview(profile), - onEdit: () => openProfileEdit(profile), - onCopy: () => handleDuplicateProfile(profile), - onDelete: !profile.isBuiltIn ? () => handleDeleteProfile(profile) : undefined, - }, - } as any); - }, [handleDeleteProfile, handleDuplicateProfile, openProfileEdit, openProfileEnvVarsPreview, toggleFavoriteProfile]); - const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length; - const compact = screenWidth < 420; - - if (compact) { - return ( - - - - - {envVarCount > 0 && ( - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileEnvVarsPreview(profile); - }} - > - - - )} - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileActionsMenu(profile, isFavorite); - }} - > - - - - ); + + const actions: ItemAction[] = []; + if (envVarCount > 0) { + actions.push({ + id: 'envVars', + title: 'View environment variables', + icon: 'list-outline', + onPress: () => openProfileEnvVarsPreview(profile), + }); + } + actions.push({ + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }); + actions.push({ + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit(profile), + }); + actions.push({ + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }); + if (!profile.isBuiltIn) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => handleDeleteProfile(profile), + }); } return ( @@ -1045,85 +1025,25 @@ function NewSessionWizard() { - - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - {envVarCount > 0 && ( - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileEnvVarsPreview(profile); - }} - > - - - )} - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - {!profile.isBuiltIn && ( - { - ignoreProfileRowPressRef.current = true; - }} - onPress={(e) => { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - )} - - ); - }, [ - handleDeleteProfile, + color={theme.colors.button.primary.background} + style={{ opacity: isSelected ? 1 : 0 }} + /> + + 0 ? ['envVars'] : []} + iconSize={20} + onActionPressIn={() => { + ignoreProfileRowPressRef.current = true; + }} + /> + + ); + }, [ + handleDeleteProfile, handleDuplicateProfile, openProfileEnvVarsPreview, - openProfileActionsMenu, openProfileEdit, screenWidth, theme.colors.button.primary.background, diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 233425f98..31b87bf5e 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -14,6 +14,8 @@ import { Modal } from '@/modal'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { buildProfileGroups } from '@/sync/profileGrouping'; import { createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; +import { ItemRowActions } from '@/components/ItemRowActions'; +import type { ItemAction } from '@/components/ItemActionsMenuModal'; export default function ProfilePickerScreen() { const { theme } = useUnistyles(); @@ -113,65 +115,60 @@ export default function ProfilePickerScreen() { ); }, [profiles, selectedId, setProfileParamAndClose, setProfiles]); - const renderProfileRowRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { - return ( - - + const renderProfileRowRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => { + const actions: ItemAction[] = [ + { + id: 'favorite', + title: isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: isFavorite ? 'star' : 'star-outline', + color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, + onPress: () => toggleFavoriteProfile(profile.id), + }, + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit(profile), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => handleDuplicateProfile(profile), + }, + ]; + if (!profile.isBuiltIn) { + actions.push({ + id: 'delete', + title: 'Delete profile', + icon: 'trash-outline', + destructive: true, + onPress: () => handleDeleteProfile(profile), + }); + } + + return ( + + - - { - e.stopPropagation(); - toggleFavoriteProfile(profile.id); - }} - > - - - { - e.stopPropagation(); - openProfileEdit(profile); - }} - > - - - { - e.stopPropagation(); - handleDuplicateProfile(profile); - }} - > - - - {!profile.isBuiltIn && ( - { - e.stopPropagation(); - handleDeleteProfile(profile); - }} - > - - - )} - - ); - }, [ - handleDeleteProfile, - handleDuplicateProfile, - openProfileEdit, + style={{ opacity: isSelected ? 1 : 0 }} + /> + + + + ); + }, [ + handleDeleteProfile, + handleDuplicateProfile, + openProfileEdit, theme.colors.button.primary.background, theme.colors.button.secondary.tint, theme.colors.deleteAction, diff --git a/sources/components/newSession/ProfileActionsMenuModal.tsx b/sources/components/newSession/ProfileActionsMenuModal.tsx deleted file mode 100644 index b658c33b1..000000000 --- a/sources/components/newSession/ProfileActionsMenuModal.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import { View, Text, ScrollView, Pressable } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { useUnistyles } from 'react-native-unistyles'; -import { Typography } from '@/constants/Typography'; -import { ItemGroup } from '@/components/ItemGroup'; -import { Item } from '@/components/Item'; - -export interface ProfileActionsMenuModalProps { - profileName: string; - isFavorite: boolean; - hasEnvVars: boolean; - canDelete: boolean; - onToggleFavorite: () => void; - onViewEnvVars?: () => void; - onEdit: () => void; - onCopy: () => void; - onDelete?: () => void; - onClose: () => void; -} - -export function ProfileActionsMenuModal(props: ProfileActionsMenuModalProps) { - const { theme } = useUnistyles(); - - const closeThen = React.useCallback((fn?: () => void) => { - props.onClose(); - if (!fn) return; - setTimeout(() => fn(), 0); - }, [props]); - - return ( - - - - {props.profileName} - - - ({ opacity: pressed ? 0.7 : 1 })} - > - - - - - - - {props.hasEnvVars && props.onViewEnvVars && ( - } - onPress={() => closeThen(props.onViewEnvVars)} - showChevron={false} - /> - )} - - } - onPress={() => closeThen(props.onToggleFavorite)} - showChevron={false} - /> - } - onPress={() => closeThen(props.onEdit)} - showChevron={false} - /> - } - onPress={() => closeThen(props.onCopy)} - showChevron={false} - /> - {props.canDelete && props.onDelete && ( - } - onPress={() => closeThen(props.onDelete)} - showChevron={false} - /> - )} - - - - ); -} - From 17c531ed6f4792b3ca6d0195a9ce62ff6fe7229d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:21:50 +0100 Subject: [PATCH 51/72] fix(ui): center modals and tighten chips --- sources/app/(app)/_layout.tsx | 6 ++++++ sources/components/AgentInput.tsx | 6 +++--- sources/modal/components/BaseModal.tsx | 8 ++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx index 97735e214..64367c054 100644 --- a/sources/app/(app)/_layout.tsx +++ b/sources/app/(app)/_layout.tsx @@ -117,6 +117,12 @@ export default function RootLayout() { headerTitle: t('settings.features'), }} /> + ({ actionButtonsLeft: { flexDirection: 'row', columnGap: 8, - rowGap: 6, + rowGap: 3, flex: 1, flexWrap: 'wrap', overflow: 'visible', @@ -745,10 +745,10 @@ export const AgentInput = React.memo(React.forwardRef - + {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - + {/* Permission chip (popover in standard flow, scroll in wizard) */} {showPermissionChip && ( diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx index 34010fd0c..8ff4ab56f 100644 --- a/sources/modal/components/BaseModal.tsx +++ b/sources/modal/components/BaseModal.tsx @@ -84,6 +84,7 @@ export function BaseModal({ Date: Wed, 14 Jan 2026 22:43:53 +0100 Subject: [PATCH 52/72] fix(ui): tighten new session chip spacing --- sources/components/AgentInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 95541784c..720b20ac3 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -232,7 +232,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ }, actionButtonsLeft: { flexDirection: 'row', - columnGap: 8, + columnGap: 6, rowGap: 3, flex: 1, flexWrap: 'wrap', @@ -748,7 +748,7 @@ export const AgentInput = React.memo(React.forwardRef {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */} - + {/* Permission chip (popover in standard flow, scroll in wizard) */} {showPermissionChip && ( From 71a0edfe2b540f1db1b3504dbf72fc84ad5581f4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 14 Jan 2026 22:56:23 +0100 Subject: [PATCH 53/72] fix(ui): align new session input padding --- sources/app/(app)/new/index.tsx | 55 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index daded0ffe..2d58ae7a1 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -253,14 +253,17 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); - const screenWidth = useWindowDimensions().width; - const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ - prompt?: string; - dataId?: string; - machineId?: string; + const router = useRouter(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const screenWidth = useWindowDimensions().width; + + const newSessionSidePadding = 16; + const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ + prompt?: string; + dataId?: string; + machineId?: string; path?: string; profileId?: string; }>(); @@ -1406,23 +1409,23 @@ function NewSessionWizard() { )} - {/* Session type selector only if experiments enabled */} - {experimentsEnabled && ( - 700 ? 16 : 8, marginBottom: 16 }}> - - + + )} - - {/* AgentInput with inline chips - sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - + + - - {/* AgentInput - Sticky at bottom */} - 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}> - - + + Date: Wed, 14 Jan 2026 23:18:31 +0100 Subject: [PATCH 54/72] fix(ui): simplify chips and move session profile info --- sources/-session/SessionView.tsx | 11 ----- sources/app/(app)/session/[id]/info.tsx | 57 +++++++++++++++++-------- sources/components/AgentInput.tsx | 53 +++++++++++++---------- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index d7f032ccd..e93fdd4eb 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -273,17 +273,6 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: permissionMode={permissionMode} onPermissionModeChange={updatePermissionMode} metadata={session.metadata} - profileId={session.metadata?.profileId ?? undefined} - onProfileClick={session.metadata?.profileId !== undefined ? () => { - const profileId = session.metadata?.profileId; - const profileInfo = (profileId === null || (typeof profileId === 'string' && profileId.trim() === '')) - ? t('profiles.noProfile') - : (typeof profileId === 'string' ? profileId : t('status.unknown')); - Modal.alert( - t('profiles.title'), - `This session uses: ${profileInfo}\n\nProfiles are fixed per session. To use a different profile, start a new session.`, - ); - } : undefined} connectionStatus={{ text: sessionStatus.statusText, color: sessionStatus.statusColor, diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 631df7f39..245deb278 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -7,7 +7,7 @@ import { Item } from '@/components/Item'; import { ItemGroup } from '@/components/ItemGroup'; import { ItemList } from '@/components/ItemList'; import { Avatar } from '@/components/Avatar'; -import { useSession, useIsDataReady } from '@/sync/storage'; +import { useSession, useIsDataReady, useSetting } from '@/sync/storage'; import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId } from '@/utils/sessionUtils'; import * as Clipboard from 'expo-clipboard'; import { Modal } from '@/modal'; @@ -20,6 +20,7 @@ import { CodeView } from '@/components/CodeView'; import { Session } from '@/sync/storageTypes'; import { useHappyAction } from '@/hooks/useHappyAction'; import { HappyError } from '@/utils/errors'; +import { getBuiltInProfile } from '@/sync/profileUtils'; // Animated status dot component function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) { @@ -66,10 +67,24 @@ function SessionInfoContent({ session }: { session: Session }) { const devModeEnabled = __DEV__; const sessionName = getSessionName(session); const sessionStatus = useSessionStatus(session); - + const useProfiles = useSetting('useProfiles'); + const profiles = useSetting('profiles'); + // Check if CLI version is outdated const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION); + const profileLabel = React.useMemo(() => { + const profileId = session.metadata?.profileId; + if (profileId === null || profileId === '') return t('profiles.noProfile'); + if (typeof profileId !== 'string') return t('status.unknown'); + + const builtIn = getBuiltInProfile(profileId); + if (builtIn) return builtIn.name; + + const custom = profiles.find(p => p.id === profileId); + return custom?.name ?? t('status.unknown'); + }, [profiles, session.metadata?.profileId]); + const handleCopySessionId = useCallback(async () => { if (!session) return; try { @@ -198,10 +213,10 @@ function SessionInfoContent({ session }: { session: Session }) { )} - {/* Session Details */} - - + } onPress={handleCopySessionId} @@ -221,17 +236,25 @@ function SessionInfoContent({ session }: { session: Session }) { }} /> )} - } - showChevron={false} - /> - } - showChevron={false} + } + showChevron={false} + /> + {useProfiles && session.metadata?.profileId !== undefined && ( + } + showChevron={false} + /> + )} + } + showChevron={false} /> ({ container: { alignItems: 'center', @@ -417,27 +422,27 @@ export const AgentInput = React.memo(React.forwardRef { + const permissionChipLabel = React.useMemo(() => { const mode = props.permissionMode ?? 'default'; if (isCodex) { - return mode === 'default' ? t('agentInput.codexPermissionMode.default') : - mode === 'read-only' ? t('agentInput.codexPermissionMode.badgeReadOnly') : - mode === 'safe-yolo' ? t('agentInput.codexPermissionMode.badgeSafeYolo') : - mode === 'yolo' ? t('agentInput.codexPermissionMode.badgeYolo') : ''; + return mode === 'default' ? 'CLI' : + mode === 'read-only' ? 'RO' : + mode === 'safe-yolo' ? 'Safe' : + mode === 'yolo' ? 'YOLO' : ''; } if (isGemini) { return mode === 'default' ? t('agentInput.geminiPermissionMode.default') : - mode === 'acceptEdits' ? t('agentInput.geminiPermissionMode.badgeAcceptAllEdits') : - mode === 'bypassPermissions' ? t('agentInput.geminiPermissionMode.badgeBypassAllPermissions') : - mode === 'plan' ? t('agentInput.geminiPermissionMode.badgePlanMode') : ''; + mode === 'acceptEdits' ? 'Accept' : + mode === 'bypassPermissions' ? 'YOLO' : + mode === 'plan' ? 'Plan' : ''; } return mode === 'default' ? t('agentInput.permissionMode.default') : - mode === 'acceptEdits' ? t('agentInput.permissionMode.badgeAcceptAllEdits') : - mode === 'bypassPermissions' ? t('agentInput.permissionMode.badgeBypassAllPermissions') : - mode === 'plan' ? t('agentInput.permissionMode.badgePlanMode') : ''; + mode === 'acceptEdits' ? 'Accept' : + mode === 'bypassPermissions' ? 'YOLO' : + mode === 'plan' ? 'Plan' : ''; }, [isCodex, isGemini, props.permissionMode]); // Handle settings button press @@ -775,7 +780,7 @@ export const AgentInput = React.memo(React.forwardRef @@ -785,7 +790,7 @@ export const AgentInput = React.memo(React.forwardRef - {permissionBadgeLabel} + {permissionChipLabel} )} @@ -923,16 +928,18 @@ export const AgentInput = React.memo(React.forwardRef - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} + + {props.machineName === null + ? t('agentInput.noMachinesAvailable') + : truncateWithEllipsis(props.machineName, 12)} + + + )} {/* Abort button */} {props.onAbort && ( From 05233ff72c0085737e76268bf56f3f13421f70bf Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 05:20:13 +0100 Subject: [PATCH 55/72] fix(session-info): show profile under metadata --- sources/app/(app)/session/[id]/info.tsx | 48 ++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx index 245deb278..e1cf603d2 100644 --- a/sources/app/(app)/session/[id]/info.tsx +++ b/sources/app/(app)/session/[id]/info.tsx @@ -242,14 +242,6 @@ function SessionInfoContent({ session }: { session: Session }) { icon={} showChevron={false} /> - {useProfiles && session.metadata?.profileId !== undefined && ( - } - showChevron={false} - /> - )} )} - { - const flavor = session.metadata.flavor || 'claude'; - if (flavor === 'claude') return 'Claude'; - if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; - if (flavor === 'gemini') return 'Gemini'; - return flavor; - })()} - icon={} - showChevron={false} - /> - {session.metadata.hostPid && ( - { + const flavor = session.metadata.flavor || 'claude'; + if (flavor === 'claude') return 'Claude'; + if (flavor === 'gpt' || flavor === 'openai') return 'Codex'; + if (flavor === 'gemini') return 'Gemini'; + return flavor; + })()} + icon={} + showChevron={false} + /> + {useProfiles && session.metadata?.profileId !== undefined && ( + } + showChevron={false} + /> + )} + {session.metadata.hostPid && ( + } showChevron={false} /> From 94f8b28a88b908771b3566b1559faaf67068f1f7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 05:20:22 +0100 Subject: [PATCH 56/72] fix(profiles): align edit form buttons --- sources/components/ProfileEditForm.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index a79bfd564..8c8f6d68a 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -307,15 +307,13 @@ export function ProfileEditForm({ /> - + ({ backgroundColor: theme.colors.surface, - borderColor: theme.colors.divider, - borderWidth: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center', From 7482c7ca804674b885971dcaa188f2a2264ec971 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 06:36:46 +0100 Subject: [PATCH 57/72] fix(env-vars): improve preview scroll and machine resolution --- .../components/EnvironmentVariableCard.tsx | 32 ++-- sources/components/ProfileEditForm.tsx | 147 +++++++++++++++++- .../EnvironmentVariablesPreviewModal.tsx | 104 +++++++++++-- 3 files changed, 251 insertions(+), 32 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 6118d29c5..3c1e98c0a 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -110,6 +110,7 @@ export function EnvironmentVariableCard({ ); const remoteValue = remoteValues[remoteVariableName]; + const hasFallback = defaultValue.trim() !== ''; // Update parent when local state changes React.useEffect(() => { @@ -131,6 +132,15 @@ export function EnvironmentVariableCard({ ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}` : defaultValue; + const resolvedSessionValue = + isSecret + ? (useRemoteVariable && remoteVariableName + ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` + : (defaultValue ? '***hidden***' : '(empty)')) + : (useRemoteVariable && machineId && remoteValue !== undefined + ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : '(empty)') : remoteValue) + : (computedTemplateValue || '(empty)')); + return ( @@ -315,12 +327,16 @@ export function EnvironmentVariableCard({ }}> Checking machine environment... - ) : remoteValue === null ? ( + ) : (remoteValue === null || remoteValue === '') ? ( - Value not found + {remoteValue === '' ? ( + hasFallback ? 'Empty on machine (using fallback)' : 'Empty on machine' + ) : ( + hasFallback ? 'Not found on machine (using fallback)' : 'Not found on machine' + )} ) : ( <> @@ -350,15 +366,7 @@ export function EnvironmentVariableCard({ marginTop: 4, ...secondaryTextStyle, }}> - Session will receive: {variable.name} = { - isSecret - ? (useRemoteVariable && remoteVariableName - ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) - : (useRemoteVariable && machineId && remoteValue !== undefined && remoteValue !== null - ? remoteValue - : (computedTemplateValue || '(empty)')) - } + Session will receive: {variable.name} = {resolvedSessionValue} ); diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 8c8f6d68a..adb20ce70 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable } from 'react-native'; +import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; @@ -14,8 +14,10 @@ import { Item } from '@/components/Item'; import { Switch } from '@/components/Switch'; import { getBuiltInProfileDocumentation } from '@/sync/profileUtils'; import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList'; -import { useSetting } from '@/sync/storage'; +import { useSetting, useAllMachines, useMachine, useSettingMutable } from '@/sync/storage'; import { Modal } from '@/modal'; +import { MachineSelector } from '@/components/newSession/MachineSelector'; +import type { Machine } from '@/sync/storageTypes'; export interface ProfileEditFormProps { profile: AIBackendProfile; @@ -26,6 +28,90 @@ export interface ProfileEditFormProps { containerStyle?: ViewStyle; } +interface MachinePreviewModalProps { + machines: Machine[]; + favoriteMachineIds: string[]; + selectedMachineId: string | null; + onSelect: (machineId: string) => void; + onToggleFavorite: (machineId: string) => void; + onClose: () => void; +} + +function MachinePreviewModal(props: MachinePreviewModalProps) { + const { theme } = useUnistyles(); + const { height: windowHeight } = useWindowDimensions(); + + const selectedMachine = React.useMemo(() => { + if (!props.selectedMachineId) return null; + return props.machines.find((m) => m.id === props.selectedMachineId) ?? null; + }, [props.machines, props.selectedMachineId]); + + const favoriteMachines = React.useMemo(() => { + const byId = new Map(props.machines.map((m) => [m.id, m] as const)); + return props.favoriteMachineIds.map((id) => byId.get(id)).filter(Boolean) as Machine[]; + }, [props.favoriteMachineIds, props.machines]); + + const maxHeight = Math.min(720, Math.max(420, Math.floor(windowHeight * 0.85))); + + return ( + + + + Preview Machine + + + ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + + 0} + showSearch + searchPlacement={favoriteMachines.length > 0 ? 'favorites' : 'all'} + onSelect={(machine) => { + props.onSelect(machine.id); + props.onClose(); + }} + onToggleFavorite={(machine) => props.onToggleFavorite(machine.id)} + /> + + + ); +} + export function ProfileEditForm({ profile, machineId, @@ -38,6 +124,38 @@ export function ProfileEditForm({ const styles = stylesheet; const groupStyle = React.useMemo(() => ({ marginBottom: 12 }), []); const experimentsEnabled = useSetting('experiments'); + const machines = useAllMachines(); + const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines'); + const routeMachine = machineId; + const [previewMachineId, setPreviewMachineId] = React.useState(routeMachine); + + React.useEffect(() => { + setPreviewMachineId(routeMachine); + }, [routeMachine]); + + const resolvedMachineId = routeMachine ?? previewMachineId; + const resolvedMachine = useMachine(resolvedMachineId ?? ''); + + const toggleFavoriteMachineId = React.useCallback((machineIdToToggle: string) => { + if (favoriteMachines.includes(machineIdToToggle)) { + setFavoriteMachines(favoriteMachines.filter((id) => id !== machineIdToToggle)); + } else { + setFavoriteMachines([machineIdToToggle, ...favoriteMachines]); + } + }, [favoriteMachines, setFavoriteMachines]); + + const showMachinePreviewPicker = React.useCallback(() => { + Modal.show({ + component: MachinePreviewModal, + props: { + machines, + favoriteMachineIds: favoriteMachines, + selectedMachineId: previewMachineId, + onSelect: setPreviewMachineId, + onToggleFavorite: toggleFavoriteMachineId, + }, + } as any); + }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]); const profileDocs = React.useMemo(() => { if (!profile.isBuiltIn) return null; @@ -298,10 +416,33 @@ export function ProfileEditForm({ )} + {!routeMachine && ( + + } + onPress={showMachinePreviewPicker} + /> + + )} + + {routeMachine && resolvedMachine && ( + + + Resolving against{' '} + + {resolvedMachine.metadata?.displayName || resolvedMachine.metadata?.host || resolvedMachine.id} + + . + + + )} + diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index 3ee2c30c9..853281d37 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -34,6 +34,26 @@ function isSecretLike(name: string) { export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) { const { theme } = useUnistyles(); const { height: windowHeight } = useWindowDimensions(); + const scrollRef = React.useRef(null); + const scrollYRef = React.useRef(0); + + const handleScroll = React.useCallback((e: any) => { + scrollYRef.current = e?.nativeEvent?.contentOffset?.y ?? 0; + }, []); + + // On web, RN ScrollView inside a modal doesn't reliably respond to mouse wheel / trackpad scroll. + // Manually translate wheel deltas into scrollTo. + const handleWheel = React.useCallback((e: any) => { + if (Platform.OS !== 'web') return; + const deltaY = e?.deltaY; + if (typeof deltaY !== 'number' || Number.isNaN(deltaY)) return; + + if (e?.cancelable) { + e?.preventDefault?.(); + } + e?.stopPropagation?.(); + scrollRef.current?.scrollTo({ y: Math.max(0, scrollYRef.current + deltaY), animated: false }); + }, []); const envVarEntries = React.useMemo(() => { return Object.entries(props.environmentVariables) @@ -58,18 +78,21 @@ export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPrev const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85))); return ( - + - + { + if (secret) return undefined; + if (!isMachineBased) return 'Fixed'; + if (!hasMachineContext) return 'Machine'; + if (resolvedValue === undefined) return 'Checking'; + if (resolvedValue === null || resolvedValue === '') return parsed?.fallback ? 'Fallback' : 'Missing'; + return 'Machine'; + })(); + + const detailColor = (() => { + if (!detailLabel) return theme.colors.textSecondary; + if (detailLabel === 'Machine') return theme.colors.status.connected; + if (detailLabel === 'Fallback' || detailLabel === 'Missing') return theme.colors.warning; + return theme.colors.textSecondary; + })(); + + const rightElement = (() => { + if (secret) return undefined; + if (!isMachineBased) return undefined; + if (!hasMachineContext || detailLabel === 'Checking') { + return ; + } + return ; + })(); + return ( ); })} From b00bbc08af187bb746850b3f9f7851b5d324a9dc Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 06:37:17 +0100 Subject: [PATCH 58/72] fix(new-session): refine wizard padding and bottom bar --- sources/app/(app)/new/index.tsx | 104 ++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 2d58ae7a1..6129391eb 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -110,12 +110,12 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 scrollContainer: { flex: 1, }, - contentContainer: { - width: '100%', - alignSelf: 'center', - paddingTop: rt.insets.top, - paddingBottom: 16, - }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: rt.insets.top + 16, + paddingBottom: 16, + }, wizardContainer: { marginBottom: 16, }, @@ -1421,13 +1421,27 @@ function NewSessionWizard() { )} - {/* AgentInput with inline chips - sticky at bottom */} - - - + 0 ? handleEnvVarsClick : undefined, - } : {})} - /> - - + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + ); @@ -1498,13 +1511,13 @@ function NewSessionWizard() { { maxWidth: layout.maxWidth, flex: 1, width: '100%', alignSelf: 'center' } ]}> - {/* CLI Detection Status Banner - shows after detection completes */} - {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && ( - - 0 && selectedMachine && connectionStatus && ( + + - {/* AgentInput - Sticky at bottom */} - - - + 0 ? handleEnvVarsClick : undefined, - } : {})} - /> - - + envVarsCount: selectedProfileEnvVarsCount || undefined, + onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined, + } : {})} + /> + ); From 3d1d6fb338928239536dee4f777c8f463defca6b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 07:13:45 +0100 Subject: [PATCH 59/72] fix(env-vars): clarify machine resolution and source var input --- .../components/EnvironmentVariableCard.tsx | 4 ++-- .../components/EnvironmentVariablesList.tsx | 20 +++++++++++++++++++ sources/components/ProfileEditForm.tsx | 13 +----------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 3c1e98c0a..267a5bc36 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -309,8 +309,8 @@ export function EnvironmentVariableCard({ placeholder="Source variable name (e.g., Z_AI_MODEL)" placeholderTextColor={theme.colors.input.placeholder} value={remoteVariableName} - onChangeText={setRemoteVariableName} - autoCapitalize="none" + onChangeText={(text) => setRemoteVariableName(text.toUpperCase())} + autoCapitalize="characters" autoCorrect={false} /> diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index fe845ed37..d7507ae3d 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -12,6 +12,7 @@ import { t } from '@/text'; export interface EnvironmentVariablesListProps { environmentVariables: Array<{ name: string; value: string }>; machineId: string | null; + machineName?: string | null; profileDocs?: ProfileDocumentation | null; onChange: (newVariables: Array<{ name: string; value: string }>) => void; } @@ -23,6 +24,7 @@ export interface EnvironmentVariablesListProps { export function EnvironmentVariablesList({ environmentVariables, machineId, + machineName, profileDocs, onChange, }: EnvironmentVariablesListProps) { @@ -130,6 +132,9 @@ export function EnvironmentVariablesList({ paddingTop: Platform.select({ ios: 35, default: 16 }), paddingBottom: Platform.select({ ios: 6, default: 8 }), paddingHorizontal: Platform.select({ ios: 32, default: 24 }), + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', }}> Environment Variables + + {machineId && machineName && ( + + + + {machineName} + + + )} {environmentVariables.length > 0 && ( diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index adb20ce70..69a33fdc0 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -427,22 +427,11 @@ export function ProfileEditForm({ )} - {routeMachine && resolvedMachine && ( - - - Resolving against{' '} - - {resolvedMachine.metadata?.displayName || resolvedMachine.metadata?.host || resolvedMachine.id} - - . - - - )} - From f0148f1c815a2986a55efdc1f33ca17cdd68b10e Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 07:14:03 +0100 Subject: [PATCH 60/72] fix(new-session): improve wizard sizing and AI profile labels --- sources/app/(app)/new/index.tsx | 87 +++++++++++++++----------- sources/app/(app)/new/pick/profile.tsx | 4 +- sources/components/AgentInput.tsx | 3 +- 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 6129391eb..55d5dbbf6 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -110,12 +110,12 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 scrollContainer: { flex: 1, }, - contentContainer: { - width: '100%', - alignSelf: 'center', - paddingTop: rt.insets.top + 16, - paddingBottom: 16, - }, + contentContainer: { + width: '100%', + alignSelf: 'center', + paddingTop: rt.insets.top + 24, + paddingBottom: 16, + }, wizardContainer: { marginBottom: 16, }, @@ -253,13 +253,14 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 function NewSessionWizard() { const { theme, rt } = useUnistyles(); - const router = useRouter(); - const safeArea = useSafeAreaInsets(); - const headerHeight = useHeaderHeight(); - const screenWidth = useWindowDimensions().width; - - const newSessionSidePadding = 16; - const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const router = useRouter(); + const safeArea = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + + const newSessionSidePadding = 16; + const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); + const webModalHeight = Platform.OS === 'web' ? Math.min(Math.max(520, screenHeight - 48), 860) : undefined; const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; @@ -1386,7 +1387,7 @@ function NewSessionWizard() { {showInlineClose && ( - + + 0 ? handleEnvVarsClick : undefined, } : {})} /> + + @@ -1477,7 +1483,7 @@ function NewSessionWizard() { {showInlineClose && ( Select AI Profile - - - Select a profile to apply environment variables and defaults to your session. - + + + Select an AI profile to apply environment variables and defaults to your session. + {favoriteProfileItems.length > 0 && ( @@ -1613,8 +1619,8 @@ function NewSessionWizard() { )} - {nonFavoriteCustomProfiles.length > 0 && ( - + {nonFavoriteCustomProfiles.length > 0 && ( + {nonFavoriteCustomProfiles.map((profile, index) => { const availability = isProfileAvailable(profile); const isSelected = selectedProfileId === profile.id; @@ -1645,7 +1651,7 @@ function NewSessionWizard() { )} - + + + 0 ? handleEnvVarsClick : undefined, } : {})} /> + + diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx index 31b87bf5e..d003dfcdd 100644 --- a/sources/app/(app)/new/pick/profile.tsx +++ b/sources/app/(app)/new/pick/profile.tsx @@ -227,7 +227,7 @@ export default function ProfilePickerScreen() { )} {nonFavoriteCustomProfiles.length > 0 && ( - + {nonFavoriteCustomProfiles.map((profile, index) => { const isSelected = selectedId === profile.id; const isLast = index === nonFavoriteCustomProfiles.length - 1; @@ -249,7 +249,7 @@ export default function ProfilePickerScreen() { )} - + void; envVarsCount?: number; onEnvVarsClick?: () => void; + contentPaddingHorizontal?: number; } const MAX_CONTEXT_SIZE = 190000; @@ -550,7 +551,7 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 16 : 8 } + { paddingHorizontal: props.contentPaddingHorizontal ?? (screenWidth > 700 ? 16 : 8) } ]}> Date: Thu, 15 Jan 2026 08:05:45 +0100 Subject: [PATCH 61/72] fix(new-session): avoid nested scroll and align composer padding --- sources/app/(app)/new/index.tsx | 15 +++++++++++---- sources/components/AgentInput.tsx | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 55d5dbbf6..739dde2de 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -106,9 +106,17 @@ const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 flex: 1, justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end', paddingTop: Platform.OS === 'web' ? 0 : 40, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), }, scrollContainer: { flex: 1, + ...(Platform.select({ + web: { minHeight: 0 }, + default: {}, + }) as any), }, contentContainer: { width: '100%', @@ -256,11 +264,10 @@ function NewSessionWizard() { const router = useRouter(); const safeArea = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); - const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const { width: screenWidth } = useWindowDimensions(); const newSessionSidePadding = 16; const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom); - const webModalHeight = Platform.OS === 'web' ? Math.min(Math.max(520, screenHeight - 48), 860) : undefined; const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{ prompt?: string; dataId?: string; @@ -1387,7 +1394,7 @@ function NewSessionWizard() { {showInlineClose && ( {showInlineClose && ( ({ alignItems: 'center', flexShrink: 0, marginLeft: 8, + marginRight: 8, }, sendButtonActive: { backgroundColor: theme.colors.button.primary.background, From a1bcc98b6947a5d8d9e873d05c2b08b12b959d69 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 08:06:07 +0100 Subject: [PATCH 62/72] fix(env-vars): resolve via login shell and clarify machine status --- .../components/EnvironmentVariableCard.tsx | 11 +++++--- .../components/EnvironmentVariablesList.tsx | 19 +------------- sources/hooks/useEnvironmentVariables.ts | 25 +++++++++++++------ 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 267a5bc36..9923b4561 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -9,6 +9,7 @@ import { Switch } from '@/components/Switch'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; machineId: string | null; + machineName?: string | null; expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) @@ -60,6 +61,7 @@ function parseVariableValue(value: string): { export function EnvironmentVariableCard({ variable, machineId, + machineName, expectedValue, description, isSecret = false, @@ -111,6 +113,7 @@ export function EnvironmentVariableCard({ const remoteValue = remoteValues[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; + const machineLabel = machineName?.trim() ? machineName.trim() : 'machine'; // Update parent when local state changes React.useEffect(() => { @@ -325,7 +328,7 @@ export function EnvironmentVariableCard({ fontStyle: 'italic', ...secondaryTextStyle, }}> - Checking machine environment... + Checking {machineLabel}... ) : (remoteValue === null || remoteValue === '') ? ( {remoteValue === '' ? ( - hasFallback ? 'Empty on machine (using fallback)' : 'Empty on machine' + hasFallback ? `Empty on ${machineLabel} (using fallback)` : `Empty on ${machineLabel}` ) : ( - hasFallback ? 'Not found on machine (using fallback)' : 'Not found on machine' + hasFallback ? `Not found on ${machineLabel} (using fallback)` : `Not found on ${machineLabel}` )} ) : ( @@ -344,7 +347,7 @@ export function EnvironmentVariableCard({ color: theme.colors.success, ...secondaryTextStyle, }}> - Value found + Value found on {machineLabel} {showRemoteDiffersWarning && ( Environment Variables - - {machineId && machineName && ( - - - - {machineName} - - - )} {environmentVariables.length > 0 && ( @@ -181,6 +163,7 @@ export function EnvironmentVariablesList({ key={index} variable={envVar} machineId={machineId} + machineName={machineName ?? null} expectedValue={docs.expectedValue} description={docs.description} isSecret={isSecret} diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 5dcd0b3af..3e08f5ced 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -70,6 +70,9 @@ export function useEnvironmentVariables( } // Query variables in a single machineBash() call. + // + // IMPORTANT: Run the query inside a login shell so we match the environment a session + // would typically start with (e.g. macOS users often configure PATH in zsh startup files). // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty. // Fallback to bash-only output if node isn't available. const nodeScript = [ @@ -82,20 +85,26 @@ export function useEnvironmentVariables( "process.stdout.write(JSON.stringify(out));", ].join(""); const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${validVarNames.join(' ')}`; - // Bash fallback uses indirect expansion to avoid eval and to distinguish unset vs empty. - // IMPORTANT: avoid embedding literal `${...}` inside this TypeScript template string (it would be parsed as JS interpolation). - const bashIsSetExpr = '\\$' + '{!name+x}'; - const bashValueExpr = '\\$' + '{!name}'; - const bashFallback = [ + // Shell fallback uses `printenv` to distinguish unset vs empty via exit code. + // Note: values containing newlines may not round-trip here; the node/JSON path preserves them. + const shellFallback = [ `for name in ${validVarNames.join(' ')}; do`, - `if [ -n "${bashIsSetExpr}" ]; then`, - `printf "%s=%s\\n" "$name" "${bashValueExpr}";`, + `if printenv "$name" >/dev/null 2>&1; then`, + `printf "%s=%s\\n" "$name" "$(printenv "$name")";`, `else`, `printf "%s=__HAPPY_UNSET__\\n" "$name";`, `fi;`, `done`, ].join(' '); - const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${bashFallback}; fi`; + + const inShell = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; + const escapedInShell = inShell.replace(/'/g, "'\\''"); + + const command = [ + `if command -v zsh >/dev/null 2>&1; then zsh -lc '${escapedInShell}';`, + `elif command -v bash >/dev/null 2>&1; then bash -lc '${escapedInShell}';`, + `else sh -lc '${escapedInShell}'; fi`, + ].join(' '); try { const result = await machineBash(machineId, command, '/'); From 2b8b6e712ca0307b47bc7c265d518ca8878fa1f7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 09:04:55 +0100 Subject: [PATCH 63/72] fix(web): increase modal height and align env preview with daemon --- sources/hooks/useEnvironmentVariables.ts | 30 +++++++++++++----------- sources/theme.css | 14 ++++++++++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts index 3e08f5ced..a012c46af 100644 --- a/sources/hooks/useEnvironmentVariables.ts +++ b/sources/hooks/useEnvironmentVariables.ts @@ -71,8 +71,10 @@ export function useEnvironmentVariables( // Query variables in a single machineBash() call. // - // IMPORTANT: Run the query inside a login shell so we match the environment a session - // would typically start with (e.g. macOS users often configure PATH in zsh startup files). + // IMPORTANT: This runs inside the daemon process environment on the machine, because the + // RPC handler executes commands using Node's `exec()` without overriding `env`. + // That means this matches what `${VAR}` expansion uses when spawning sessions on the daemon + // (see happy-cli: expandEnvironmentVariables(..., process.env)). // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty. // Fallback to bash-only output if node isn't available. const nodeScript = [ @@ -97,14 +99,7 @@ export function useEnvironmentVariables( `done`, ].join(' '); - const inShell = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; - const escapedInShell = inShell.replace(/'/g, "'\\''"); - - const command = [ - `if command -v zsh >/dev/null 2>&1; then zsh -lc '${escapedInShell}';`, - `elif command -v bash >/dev/null 2>&1; then bash -lc '${escapedInShell}';`, - `else sh -lc '${escapedInShell}'; fi`, - ].join(' '); + const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`; try { const result = await machineBash(machineId, command, '/'); @@ -115,9 +110,14 @@ export function useEnvironmentVariables( const stdout = result.stdout; // JSON protocol: {"VAR":"value","MISSING":null} - if (stdout.trim().startsWith('{')) { + // Be resilient to any stray output (log lines, warnings) by extracting the last JSON object. + const trimmed = stdout.trim(); + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1); try { - const parsed = JSON.parse(stdout) as Record; + const parsed = JSON.parse(jsonSlice) as Record; validVarNames.forEach((name) => { results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null; }); @@ -128,9 +128,11 @@ export function useEnvironmentVariables( // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__" if (Object.keys(results).length === 0) { - // Do not trim: it can corrupt values with meaningful whitespace. + // Do not trim each line: it can corrupt values with meaningful whitespace. const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0); - lines.forEach(line => { + lines.forEach((line) => { + // Ignore unrelated output (warnings, prompts, etc). + if (!/^[A-Z_][A-Z0-9_]*=/.test(line)) return; const equalsIndex = line.indexOf('='); if (equalsIndex !== -1) { const name = line.substring(0, equalsIndex); diff --git a/sources/theme.css b/sources/theme.css index 7e241b5ae..e10795b89 100644 --- a/sources/theme.css +++ b/sources/theme.css @@ -33,6 +33,18 @@ scrollbar-color: var(--colors-divider) var(--colors-surface-high); } +/* Expo Router (web) modal sizing + - Expo Router uses a Vaul/Radix drawer for `presentation: 'modal'` on web. + - Default sizing is a bit short on large screens; override via attribute selectors + so we don't rely on hashed classnames. */ +@media (min-width: 700px) { + [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] { + height: min(860px, calc(100vh - 48px)) !important; + max-height: min(860px, calc(100vh - 48px)) !important; + min-height: min(860px, calc(100vh - 48px)) !important; + } +} + /* Ensure scrollbars are visible on hover for macOS */ ::-webkit-scrollbar:horizontal { height: 12px; @@ -40,4 +52,4 @@ ::-webkit-scrollbar:vertical { width: 12px; -} \ No newline at end of file +} From 4b70ee6c98daf8e60cf0f3a03ac4b21c37baa885 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 09:55:29 +0100 Subject: [PATCH 64/72] fix(env-vars): batch resolve in editor and support := --- .../components/EnvironmentVariableCard.tsx | 22 +++++------- .../components/EnvironmentVariablesList.tsx | 36 ++++++++++++++----- .../EnvironmentVariablesPreviewModal.tsx | 2 +- sources/hooks/envVarUtils.ts | 6 ++-- sources/theme.css | 6 ++-- 5 files changed, 44 insertions(+), 28 deletions(-) diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx index 9923b4561..26647598e 100644 --- a/sources/components/EnvironmentVariableCard.tsx +++ b/sources/components/EnvironmentVariableCard.tsx @@ -3,13 +3,14 @@ import { View, Text, TextInput, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; -import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables'; import { Switch } from '@/components/Switch'; export interface EnvironmentVariableCardProps { variable: { name: string; value: string }; machineId: string | null; machineName?: string | null; + machineEnv?: Record; + isMachineEnvLoading?: boolean; expectedValue?: string; // From profile documentation description?: string; // Variable description isSecret?: boolean; // Whether this is a secret (never query remote) @@ -26,8 +27,8 @@ function parseVariableValue(value: string): { remoteVariableName: string; defaultValue: string; } { - // Match: ${VARIABLE_NAME:-default_value} - const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); + // Match: ${VARIABLE_NAME:-default_value} or ${VARIABLE_NAME:=default_value} + const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/); if (matchWithFallback) { return { useRemoteVariable: true, @@ -62,6 +63,8 @@ export function EnvironmentVariableCard({ variable, machineId, machineName, + machineEnv, + isMachineEnvLoading = false, expectedValue, description, isSecret = false, @@ -104,14 +107,7 @@ export function EnvironmentVariableCard({ const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName); const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue); - // Query remote machine for variable value (only if toggle enabled and not secret) - const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== ''; - const { variables: remoteValues } = useEnvironmentVariables( - machineId, - shouldQueryRemote ? [remoteVariableName] : [] - ); - - const remoteValue = remoteValues[remoteVariableName]; + const remoteValue = machineEnv?.[remoteVariableName]; const hasFallback = defaultValue.trim() !== ''; const machineLabel = machineName?.trim() ? machineName.trim() : 'machine'; @@ -139,7 +135,7 @@ export function EnvironmentVariableCard({ isSecret ? (useRemoteVariable && remoteVariableName ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security` - : (defaultValue ? '***hidden***' : '(empty)')) + : (defaultValue ? '***hidden***' : '(empty)')) : (useRemoteVariable && machineId && remoteValue !== undefined ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : '(empty)') : remoteValue) : (computedTemplateValue || '(empty)')); @@ -322,7 +318,7 @@ export function EnvironmentVariableCard({ {/* Machine environment status (only with machine context) */} {useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && ( - {remoteValue === undefined ? ( + {isMachineEnvLoading || remoteValue === undefined ? ( ; @@ -30,6 +31,31 @@ export function EnvironmentVariablesList({ }: EnvironmentVariablesListProps) { const { theme } = useUnistyles(); + // Extract variable name from a template value (for matching documentation / machine env lookup) + const extractVarNameFromValue = React.useCallback((value: string): string | null => { + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/); + return match ? match[1] : null; + }, []); + + const SECRET_NAME_REGEX = React.useMemo(() => /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i, []); + + const resolvedEnvVarRefs = React.useMemo(() => { + const refs = new Set(); + environmentVariables.forEach((envVar) => { + const ref = extractVarNameFromValue(envVar.value); + if (!ref) return; + // Don't query secret-like env vars from the machine. + if (SECRET_NAME_REGEX.test(ref) || SECRET_NAME_REGEX.test(envVar.name)) return; + refs.add(ref); + }); + return Array.from(refs); + }, [SECRET_NAME_REGEX, environmentVariables, extractVarNameFromValue]); + + const { variables: machineEnv, isLoading: isMachineEnvLoading } = useEnvironmentVariables( + machineId, + resolvedEnvVarRefs, + ); + // Add variable inline form state const [showAddForm, setShowAddForm] = React.useState(false); const [newVarName, setNewVarName] = React.useState(''); @@ -60,12 +86,6 @@ export function EnvironmentVariablesList({ }; }, [profileDocs]); - // Extract variable name from value (for matching documentation) - const extractVarNameFromValue = React.useCallback((value: string): string | null => { - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/); - return match ? match[1] : null; - }, []); - const handleUpdateVariable = React.useCallback((index: number, newValue: string) => { const updated = [...environmentVariables]; updated[index] = { ...updated[index], value: newValue }; @@ -151,8 +171,6 @@ export function EnvironmentVariablesList({ {environmentVariables.map((envVar, index) => { const varNameFromValue = extractVarNameFromValue(envVar.value); const docs = getDocumentation(varNameFromValue || envVar.name); - - const SECRET_NAME_REGEX = /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i; const isSecret = docs.isSecret || SECRET_NAME_REGEX.test(envVar.name) || @@ -164,6 +182,8 @@ export function EnvironmentVariablesList({ variable={envVar} machineId={machineId} machineName={machineName ?? null} + machineEnv={machineEnv} + isMachineEnvLoading={isMachineEnvLoading} expectedValue={docs.expectedValue} description={docs.description} isSecret={isSecret} diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx index 853281d37..3ad466286 100644 --- a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx +++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx @@ -16,7 +16,7 @@ export interface EnvironmentVariablesPreviewModalProps { } function parseTemplateValue(value: string): { sourceVar: string; fallback: string } | null { - const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/); + const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/); if (withFallback) { return { sourceVar: withFallback[1], fallback: withFallback[2] }; } diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index d3bc26823..83f5fa825 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -35,7 +35,7 @@ export function resolveEnvVarSubstitution( // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset). // Group 1: Variable name (required) // Group 2: Default value (optional) - const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::-(.*))?\}$/); + const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=](.*))?\}$/); if (match) { const varName = match[1]; const defaultValue = match[2]; // :- default @@ -75,9 +75,9 @@ export function extractEnvVarReferences( const refs = new Set(); environmentVariables.forEach(ev => { - // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset) + // Match ${VAR}, ${VAR:-default}, or ${VAR:=default} (bash parameter expansion subset). // Only capture the variable name, not the default value - const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*)?\}$/); + const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=].*)?\}$/); if (match) { // Variable name is already validated by regex pattern [A-Z_][A-Z0-9_]* refs.add(match[1]); diff --git a/sources/theme.css b/sources/theme.css index e10795b89..2c80b7222 100644 --- a/sources/theme.css +++ b/sources/theme.css @@ -39,9 +39,9 @@ so we don't rely on hashed classnames. */ @media (min-width: 700px) { [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] { - height: min(860px, calc(100vh - 48px)) !important; - max-height: min(860px, calc(100vh - 48px)) !important; - min-height: min(860px, calc(100vh - 48px)) !important; + height: min(820px, calc(100vh - 48px)) !important; + max-height: min(820px, calc(100vh - 48px)) !important; + min-height: min(820px, calc(100vh - 48px)) !important; } } From 21c79688f528de2ee1ffd1c44c91e75517dece4a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:07:36 +0100 Subject: [PATCH 65/72] refactor(i18n): move translation types to _types Use sources/text/translations/en.ts as the canonical English strings and remove sources/text/_default.ts as a second source of truth. --- sources/text/README.md | 11 +- sources/text/_default.ts | 946 --------------------------- sources/text/_types.ts | 24 + sources/text/index.ts | 14 +- sources/text/translations/ca.ts | 2 +- sources/text/translations/en.ts | 10 +- sources/text/translations/es.ts | 2 +- sources/text/translations/it.ts | 2 +- sources/text/translations/ja.ts | 2 +- sources/text/translations/pl.ts | 2 +- sources/text/translations/pt.ts | 2 +- sources/text/translations/ru.ts | 2 +- sources/text/translations/zh-Hans.ts | 2 +- 13 files changed, 45 insertions(+), 976 deletions(-) delete mode 100644 sources/text/_default.ts create mode 100644 sources/text/_types.ts diff --git a/sources/text/README.md b/sources/text/README.md index 09128f3ef..9c40002fc 100644 --- a/sources/text/README.md +++ b/sources/text/README.md @@ -82,8 +82,8 @@ t('invalid.key') // Error: Key doesn't exist ## Files Structure -### `_default.ts` -Contains the main translation object with mixed string/function values: +### `translations/en.ts` +Contains the canonical English translation object with mixed string/function values: ```typescript export const en = { @@ -97,6 +97,9 @@ export const en = { } as const; ``` +### `_types.ts` +Contains the TypeScript types derived from the English translation structure. + ### `index.ts` Main module with the `t` function and utilities: - `t()` - Main translation function with strict typing @@ -164,7 +167,7 @@ The API stays the same, but you get: ## Adding New Translations -1. **Add to `_default.ts`**: +1. **Add to `translations/en.ts`**: ```typescript // String constant newConstant: 'My New Text', @@ -220,4 +223,4 @@ To add more languages: 3. Add locale switching logic 4. All existing type safety is preserved -This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. \ No newline at end of file +This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience. diff --git a/sources/text/_default.ts b/sources/text/_default.ts deleted file mode 100644 index 024c7f6eb..000000000 --- a/sources/text/_default.ts +++ /dev/null @@ -1,946 +0,0 @@ -/** - * English translations for the Happy app - * Values can be: - * - String constants for static text - * - Functions with typed object parameters for dynamic text - */ - -/** - * English plural helper function - * @param options - Object containing count, singular, and plural forms - * @returns The appropriate form based on count - */ -function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string { - return count === 1 ? singular : plural; -} - -export const en = { - tabs: { - // Tab navigation labels - inbox: 'Inbox', - sessions: 'Terminals', - settings: 'Settings', - }, - - inbox: { - // Inbox screen - emptyTitle: 'Empty Inbox', - emptyDescription: 'Connect with friends to start sharing sessions', - updates: 'Updates', - }, - - common: { - // Simple string constants - cancel: 'Cancel', - authenticate: 'Authenticate', - save: 'Save', - saveAs: 'Save As', - error: 'Error', - success: 'Success', - ok: 'OK', - continue: 'Continue', - back: 'Back', - create: 'Create', - rename: 'Rename', - reset: 'Reset', - logout: 'Logout', - yes: 'Yes', - no: 'No', - discard: 'Discard', - version: 'Version', - copied: 'Copied', - copy: 'Copy', - scanning: 'Scanning...', - urlPlaceholder: 'https://example.com', - home: 'Home', - message: 'Message', - files: 'Files', - fileViewer: 'File Viewer', - loading: 'Loading...', - retry: 'Retry', - delete: 'Delete', - optional: 'optional', - }, - - profile: { - userProfile: 'User Profile', - details: 'Details', - firstName: 'First Name', - lastName: 'Last Name', - username: 'Username', - status: 'Status', - }, - - status: { - connected: 'connected', - connecting: 'connecting', - disconnected: 'disconnected', - error: 'error', - online: 'online', - offline: 'offline', - lastSeen: ({ time }: { time: string }) => `last seen ${time}`, - permissionRequired: 'permission required', - activeNow: 'Active now', - unknown: 'unknown', - }, - - time: { - justNow: 'just now', - minutesAgo: ({ count }: { count: number }) => `${count} minute${count !== 1 ? 's' : ''} ago`, - hoursAgo: ({ count }: { count: number }) => `${count} hour${count !== 1 ? 's' : ''} ago`, - }, - - connect: { - restoreAccount: 'Restore Account', - enterSecretKey: 'Please enter a secret key', - invalidSecretKey: 'Invalid secret key. Please check and try again.', - enterUrlManually: 'Enter URL manually', - }, - - settings: { - title: 'Settings', - connectedAccounts: 'Connected Accounts', - connectAccount: 'Connect account', - github: 'GitHub', - machines: 'Machines', - features: 'Features', - social: 'Social', - account: 'Account', - accountSubtitle: 'Manage your account details', - appearance: 'Appearance', - appearanceSubtitle: 'Customize how the app looks', - voiceAssistant: 'Voice Assistant', - voiceAssistantSubtitle: 'Configure voice interaction preferences', - featuresTitle: 'Features', - featuresSubtitle: 'Enable or disable app features', - developer: 'Developer', - developerTools: 'Developer Tools', - about: 'About', - aboutFooter: 'Happy Coder is a Codex and Claude Code mobile client. It\'s fully end-to-end encrypted and your account is stored only on your device. Not affiliated with Anthropic.', - whatsNew: 'What\'s New', - whatsNewSubtitle: 'See the latest updates and improvements', - reportIssue: 'Report an Issue', - privacyPolicy: 'Privacy Policy', - termsOfService: 'Terms of Service', - eula: 'EULA', - supportUs: 'Support us', - supportUsSubtitlePro: 'Thank you for your support!', - supportUsSubtitle: 'Support project development', - scanQrCodeToAuthenticate: 'Scan QR code to authenticate', - githubConnected: ({ login }: { login: string }) => `Connected as @${login}`, - connectGithubAccount: 'Connect your GitHub account', - claudeAuthSuccess: 'Successfully connected to Claude', - exchangingTokens: 'Exchanging tokens...', - usage: 'Usage', - usageSubtitle: 'View your API usage and costs', - profiles: 'Profiles', - profilesSubtitle: 'Manage environment variable profiles for sessions', - - // Dynamic settings messages - accountConnected: ({ service }: { service: string }) => `${service} account connected`, - machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) => - `${name} is ${status}`, - featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) => - `${feature} ${enabled ? 'enabled' : 'disabled'}`, - }, - - settingsAppearance: { - // Appearance settings screen - theme: 'Theme', - themeDescription: 'Choose your preferred color scheme', - themeOptions: { - adaptive: 'Adaptive', - light: 'Light', - dark: 'Dark', - }, - themeDescriptions: { - adaptive: 'Match system settings', - light: 'Always use light theme', - dark: 'Always use dark theme', - }, - display: 'Display', - displayDescription: 'Control layout and spacing', - inlineToolCalls: 'Inline Tool Calls', - inlineToolCallsDescription: 'Display tool calls directly in chat messages', - expandTodoLists: 'Expand Todo Lists', - expandTodoListsDescription: 'Show all todos instead of just changes', - showLineNumbersInDiffs: 'Show Line Numbers in Diffs', - showLineNumbersInDiffsDescription: 'Display line numbers in code diffs', - showLineNumbersInToolViews: 'Show Line Numbers in Tool Views', - showLineNumbersInToolViewsDescription: 'Display line numbers in tool view diffs', - wrapLinesInDiffs: 'Wrap Lines in Diffs', - wrapLinesInDiffsDescription: 'Wrap long lines instead of horizontal scrolling in diff views', - alwaysShowContextSize: 'Always Show Context Size', - alwaysShowContextSizeDescription: 'Display context usage even when not near limit', - avatarStyle: 'Avatar Style', - avatarStyleDescription: 'Choose session avatar appearance', - avatarOptions: { - pixelated: 'Pixelated', - gradient: 'Gradient', - brutalist: 'Brutalist', - }, - showFlavorIcons: 'Show AI Provider Icons', - showFlavorIconsDescription: 'Display AI provider icons on session avatars', - compactSessionView: 'Compact Session View', - compactSessionViewDescription: 'Show active sessions in a more compact layout', - }, - - settingsFeatures: { - // Features settings screen - experiments: 'Experiments', - experimentsDescription: 'Enable experimental features that are still in development. These features may be unstable or change without notice.', - experimentalFeatures: 'Experimental Features', - experimentalFeaturesEnabled: 'Experimental features enabled', - experimentalFeaturesDisabled: 'Using stable features only', - webFeatures: 'Web Features', - webFeaturesDescription: 'Features available only in the web version of the app.', - enterToSend: 'Enter to Send', - enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)', - enterToSendDisabled: 'Enter inserts a new line', - commandPalette: 'Command Palette', - commandPaletteEnabled: 'Press ⌘K to open', - commandPaletteDisabled: 'Quick command access disabled', - markdownCopyV2: 'Markdown Copy v2', - markdownCopyV2Subtitle: 'Long press opens copy modal', - hideInactiveSessions: 'Hide inactive sessions', - hideInactiveSessionsSubtitle: 'Show only active chats in your list', - enhancedSessionWizard: 'Enhanced Session Wizard', - enhancedSessionWizardEnabled: 'Profile-first session launcher active', - enhancedSessionWizardDisabled: 'Using standard session launcher', - profiles: 'AI Profiles', - profilesEnabled: 'Profile selection enabled', - profilesDisabled: 'Profile selection disabled', - pickerSearch: 'Picker Search', - pickerSearchSubtitle: 'Show a search field in machine and path pickers', - machinePickerSearch: 'Machine search', - machinePickerSearchSubtitle: 'Show a search field in machine pickers', - pathPickerSearch: 'Path search', - pathPickerSearchSubtitle: 'Show a search field in path pickers', - }, - - errors: { - networkError: 'Network error occurred', - serverError: 'Server error occurred', - unknownError: 'An unknown error occurred', - connectionTimeout: 'Connection timed out', - authenticationFailed: 'Authentication failed', - permissionDenied: 'Permission denied', - fileNotFound: 'File not found', - invalidFormat: 'Invalid format', - operationFailed: 'Operation failed', - tryAgain: 'Please try again', - contactSupport: 'Contact support if the problem persists', - sessionNotFound: 'Session not found', - voiceSessionFailed: 'Failed to start voice session', - voiceServiceUnavailable: 'Voice service is temporarily unavailable', - oauthInitializationFailed: 'Failed to initialize OAuth flow', - tokenStorageFailed: 'Failed to store authentication tokens', - oauthStateMismatch: 'Security validation failed. Please try again', - tokenExchangeFailed: 'Failed to exchange authorization code', - oauthAuthorizationDenied: 'Authorization was denied', - webViewLoadFailed: 'Failed to load authentication page', - failedToLoadProfile: 'Failed to load user profile', - userNotFound: 'User not found', - sessionDeleted: 'Session has been deleted', - sessionDeletedDescription: 'This session has been permanently removed', - - // Error functions with context - fieldError: ({ field, reason }: { field: string; reason: string }) => - `${field}: ${reason}`, - validationError: ({ field, min, max }: { field: string; min: number; max: number }) => - `${field} must be between ${min} and ${max}`, - retryIn: ({ seconds }: { seconds: number }) => - `Retry in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`, - errorWithCode: ({ message, code }: { message: string; code: number | string }) => - `${message} (Error ${code})`, - disconnectServiceFailed: ({ service }: { service: string }) => - `Failed to disconnect ${service}`, - connectServiceFailed: ({ service }: { service: string }) => - `Failed to connect ${service}. Please try again.`, - failedToLoadFriends: 'Failed to load friends list', - failedToAcceptRequest: 'Failed to accept friend request', - failedToRejectRequest: 'Failed to reject friend request', - failedToRemoveFriend: 'Failed to remove friend', - searchFailed: 'Search failed. Please try again.', - failedToSendRequest: 'Failed to send friend request', - }, - - newSession: { - // Used by new-session screen and launch flows - title: 'Start New Session', - noMachinesFound: 'No machines found. Start a Happy session on your computer first.', - allMachinesOffline: 'All machines appear offline', - machineDetails: 'View machine details →', - directoryDoesNotExist: 'Directory Not Found', - createDirectoryConfirm: ({ directory }: { directory: string }) => `The directory ${directory} does not exist. Do you want to create it?`, - sessionStarted: 'Session Started', - sessionStartedMessage: 'The session has been started successfully.', - sessionSpawningFailed: 'Session spawning failed - no session ID returned.', - startingSession: 'Starting session...', - startNewSessionInFolder: 'New session here', - failedToStart: 'Failed to start session. Make sure the daemon is running on the target machine.', - sessionTimeout: 'Session startup timed out. The machine may be slow or the daemon may not be responding.', - notConnectedToServer: 'Not connected to server. Check your internet connection.', - noMachineSelected: 'Please select a machine to start the session', - noPathSelected: 'Please select a directory to start the session in', - sessionType: { - title: 'Session Type', - simple: 'Simple', - worktree: 'Worktree', - comingSoon: 'Coming soon', - }, - worktree: { - creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`, - notGitRepo: 'Worktrees require a git repository', - failed: ({ error }: { error: string }) => `Failed to create worktree: ${error}`, - success: 'Worktree created successfully', - } - }, - - sessionHistory: { - // Used by session history screen - title: 'Session History', - empty: 'No sessions found', - today: 'Today', - yesterday: 'Yesterday', - daysAgo: ({ count }: { count: number }) => `${count} ${count === 1 ? 'day' : 'days'} ago`, - viewAll: 'View all sessions', - }, - - session: { - inputPlaceholder: 'Type a message ...', - }, - - commandPalette: { - placeholder: 'Type a command or search...', - }, - - server: { - // Used by Server Configuration screen (app/(app)/server.tsx) - serverConfiguration: 'Server Configuration', - enterServerUrl: 'Please enter a server URL', - notValidHappyServer: 'Not a valid Happy Server', - changeServer: 'Change Server', - continueWithServer: 'Continue with this server?', - resetToDefault: 'Reset to Default', - resetServerDefault: 'Reset server to default?', - validating: 'Validating...', - validatingServer: 'Validating server...', - serverReturnedError: 'Server returned an error', - failedToConnectToServer: 'Failed to connect to server', - currentlyUsingCustomServer: 'Currently using custom server', - customServerUrlLabel: 'Custom Server URL', - advancedFeatureFooter: "This is an advanced feature. Only change the server if you know what you're doing. You will need to log out and log in again after changing servers." - }, - - sessionInfo: { - // Used by Session Info screen (app/(app)/session/[id]/info.tsx) - killSession: 'Kill Session', - killSessionConfirm: 'Are you sure you want to terminate this session?', - archiveSession: 'Archive Session', - archiveSessionConfirm: 'Are you sure you want to archive this session?', - happySessionIdCopied: 'Happy Session ID copied to clipboard', - failedToCopySessionId: 'Failed to copy Happy Session ID', - happySessionId: 'Happy Session ID', - claudeCodeSessionId: 'Claude Code Session ID', - claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard', - aiProvider: 'AI Provider', - failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID', - metadataCopied: 'Metadata copied to clipboard', - failedToCopyMetadata: 'Failed to copy metadata', - failedToKillSession: 'Failed to kill session', - failedToArchiveSession: 'Failed to archive session', - connectionStatus: 'Connection Status', - created: 'Created', - lastUpdated: 'Last Updated', - sequence: 'Sequence', - quickActions: 'Quick Actions', - viewMachine: 'View Machine', - viewMachineSubtitle: 'View machine details and sessions', - killSessionSubtitle: 'Immediately terminate the session', - archiveSessionSubtitle: 'Archive this session and stop it', - metadata: 'Metadata', - host: 'Host', - path: 'Path', - operatingSystem: 'Operating System', - processId: 'Process ID', - happyHome: 'Happy Home', - copyMetadata: 'Copy Metadata', - agentState: 'Agent State', - controlledByUser: 'Controlled by User', - pendingRequests: 'Pending Requests', - activity: 'Activity', - thinking: 'Thinking', - thinkingSince: 'Thinking Since', - cliVersion: 'CLI Version', - cliVersionOutdated: 'CLI Update Required', - cliVersionOutdatedMessage: ({ currentVersion, requiredVersion }: { currentVersion: string; requiredVersion: string }) => - `Version ${currentVersion} installed. Update to ${requiredVersion} or later`, - updateCliInstructions: 'Please run npm install -g happy-coder@latest', - deleteSession: 'Delete Session', - deleteSessionSubtitle: 'Permanently remove this session', - deleteSessionConfirm: 'Delete Session Permanently?', - deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.', - failedToDeleteSession: 'Failed to delete session', - sessionDeleted: 'Session deleted successfully', - - }, - - components: { - emptyMainScreen: { - // Used by EmptyMainScreen component - readyToCode: 'Ready to code?', - installCli: 'Install the Happy CLI', - runIt: 'Run it', - scanQrCode: 'Scan the QR code', - openCamera: 'Open Camera', - }, - }, - - agentInput: { - permissionMode: { - title: 'PERMISSION MODE', - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - agent: { - claude: 'Claude', - codex: 'Codex', - gemini: 'Gemini', - }, - model: { - title: 'MODEL', - configureInCli: 'Configure models in CLI settings', - }, - codexPermissionMode: { - title: 'CODEX PERMISSION MODE', - default: 'CLI Settings', - readOnly: 'Read Only Mode', - safeYolo: 'Safe YOLO', - yolo: 'YOLO', - badgeReadOnly: 'Read Only Mode', - badgeSafeYolo: 'Safe YOLO', - badgeYolo: 'YOLO', - }, - codexModel: { - title: 'CODEX MODEL', - gpt5CodexLow: 'gpt-5-codex low', - gpt5CodexMedium: 'gpt-5-codex medium', - gpt5CodexHigh: 'gpt-5-codex high', - gpt5Minimal: 'GPT-5 Minimal', - gpt5Low: 'GPT-5 Low', - gpt5Medium: 'GPT-5 Medium', - gpt5High: 'GPT-5 High', - }, - geminiPermissionMode: { - title: 'GEMINI PERMISSION MODE', - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Yolo Mode', - badgeAcceptAllEdits: 'Accept All Edits', - badgeBypassAllPermissions: 'Bypass All Permissions', - badgePlanMode: 'Plan Mode', - }, - context: { - remaining: ({ percent }: { percent: number }) => `${percent}% left`, - }, - suggestion: { - fileLabel: 'FILE', - folderLabel: 'FOLDER', - }, - noMachinesAvailable: 'No machines', - }, - - machineLauncher: { - showLess: 'Show less', - showAll: ({ count }: { count: number }) => `Show all (${count} paths)`, - enterCustomPath: 'Enter custom path', - offlineUnableToSpawn: 'Unable to spawn new session, offline', - }, - - sidebar: { - sessionsTitle: 'Happy', - }, - - toolView: { - input: 'Input', - output: 'Output', - }, - - tools: { - fullView: { - description: 'Description', - inputParams: 'Input Parameters', - output: 'Output', - error: 'Error', - completed: 'Tool completed successfully', - noOutput: 'No output was produced', - running: 'Tool is running...', - rawJsonDevMode: 'Raw JSON (Dev Mode)', - }, - taskView: { - initializing: 'Initializing agent...', - moreTools: ({ count }: { count: number }) => `+${count} more ${plural({ count, singular: 'tool', plural: 'tools' })}`, - }, - multiEdit: { - editNumber: ({ index, total }: { index: number; total: number }) => `Edit ${index} of ${total}`, - replaceAll: 'Replace All', - }, - names: { - task: 'Task', - terminal: 'Terminal', - searchFiles: 'Search Files', - search: 'Search', - searchContent: 'Search Content', - listFiles: 'List Files', - planProposal: 'Plan proposal', - readFile: 'Read File', - editFile: 'Edit File', - writeFile: 'Write File', - fetchUrl: 'Fetch URL', - readNotebook: 'Read Notebook', - editNotebook: 'Edit Notebook', - todoList: 'Todo List', - webSearch: 'Web Search', - reasoning: 'Reasoning', - applyChanges: 'Update file', - viewDiff: 'Current file changes', - question: 'Question', - }, - askUserQuestion: { - submit: 'Submit Answer', - multipleQuestions: ({ count }: { count: number }) => `${count} questions`, - }, - desc: { - terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`, - searchPattern: ({ pattern }: { pattern: string }) => `Search(pattern: ${pattern})`, - searchPath: ({ basename }: { basename: string }) => `Search(path: ${basename})`, - fetchUrlHost: ({ host }: { host: string }) => `Fetch URL(url: ${host})`, - editNotebookMode: ({ path, mode }: { path: string; mode: string }) => `Edit Notebook(file: ${path}, mode: ${mode})`, - todoListCount: ({ count }: { count: number }) => `Todo List(count: ${count})`, - webSearchQuery: ({ query }: { query: string }) => `Web Search(query: ${query})`, - grepPattern: ({ pattern }: { pattern: string }) => `grep(pattern: ${pattern})`, - multiEditEdits: ({ path, count }: { path: string; count: number }) => `${path} (${count} edits)`, - readingFile: ({ file }: { file: string }) => `Reading ${file}`, - writingFile: ({ file }: { file: string }) => `Writing ${file}`, - modifyingFile: ({ file }: { file: string }) => `Modifying ${file}`, - modifyingFiles: ({ count }: { count: number }) => `Modifying ${count} files`, - modifyingMultipleFiles: ({ file, count }: { file: string; count: number }) => `${file} and ${count} more`, - showingDiff: 'Showing changes', - } - }, - - files: { - searchPlaceholder: 'Search files...', - detachedHead: 'detached HEAD', - summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `${staged} staged • ${unstaged} unstaged`, - notRepo: 'Not a git repository', - notUnderGit: 'This directory is not under git version control', - searching: 'Searching files...', - noFilesFound: 'No files found', - noFilesInProject: 'No files in project', - tryDifferentTerm: 'Try a different search term', - searchResults: ({ count }: { count: number }) => `Search Results (${count})`, - projectRoot: 'Project root', - stagedChanges: ({ count }: { count: number }) => `Staged Changes (${count})`, - unstagedChanges: ({ count }: { count: number }) => `Unstaged Changes (${count})`, - // File viewer strings - loadingFile: ({ fileName }: { fileName: string }) => `Loading ${fileName}...`, - binaryFile: 'Binary File', - cannotDisplayBinary: 'Cannot display binary file content', - diff: 'Diff', - file: 'File', - fileEmpty: 'File is empty', - noChanges: 'No changes to display', - }, - - settingsVoice: { - // Voice settings screen - languageTitle: 'Language', - languageDescription: 'Choose your preferred language for voice assistant interactions. This setting syncs across all your devices.', - preferredLanguage: 'Preferred Language', - preferredLanguageSubtitle: 'Language used for voice assistant responses', - language: { - searchPlaceholder: 'Search languages...', - title: 'Languages', - footer: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'language', plural: 'languages' })} available`, - autoDetect: 'Auto-detect', - } - }, - - settingsAccount: { - // Account settings screen - accountInformation: 'Account Information', - status: 'Status', - statusActive: 'Active', - statusNotAuthenticated: 'Not Authenticated', - anonymousId: 'Anonymous ID', - publicId: 'Public ID', - notAvailable: 'Not available', - linkNewDevice: 'Link New Device', - linkNewDeviceSubtitle: 'Scan QR code to link device', - profile: 'Profile', - name: 'Name', - github: 'GitHub', - tapToDisconnect: 'Tap to disconnect', - server: 'Server', - backup: 'Backup', - backupDescription: 'Your secret key is the only way to recover your account. Save it in a secure place like a password manager.', - secretKey: 'Secret Key', - tapToReveal: 'Tap to reveal', - tapToHide: 'Tap to hide', - secretKeyLabel: 'SECRET KEY (TAP TO COPY)', - secretKeyCopied: 'Secret key copied to clipboard. Store it in a safe place!', - secretKeyCopyFailed: 'Failed to copy secret key', - privacy: 'Privacy', - privacyDescription: 'Help improve the app by sharing anonymous usage data. No personal information is collected.', - analytics: 'Analytics', - analyticsDisabled: 'No data is shared', - analyticsEnabled: 'Anonymous usage data is shared', - dangerZone: 'Danger Zone', - logout: 'Logout', - logoutSubtitle: 'Sign out and clear local data', - logoutConfirm: 'Are you sure you want to logout? Make sure you have backed up your secret key!', - }, - - settingsLanguage: { - // Language settings screen - title: 'Language', - description: 'Choose your preferred language for the app interface. This will sync across all your devices.', - currentLanguage: 'Current Language', - automatic: 'Automatic', - automaticSubtitle: 'Detect from device settings', - needsRestart: 'Language Changed', - needsRestartMessage: 'The app needs to restart to apply the new language setting.', - restartNow: 'Restart Now', - }, - - connectButton: { - authenticate: 'Authenticate Terminal', - authenticateWithUrlPaste: 'Authenticate Terminal with URL paste', - pasteAuthUrl: 'Paste the auth URL from your terminal', - }, - - updateBanner: { - updateAvailable: 'Update available', - pressToApply: 'Press to apply the update', - whatsNew: "What's new", - seeLatest: 'See the latest updates and improvements', - nativeUpdateAvailable: 'App Update Available', - tapToUpdateAppStore: 'Tap to update in App Store', - tapToUpdatePlayStore: 'Tap to update in Play Store', - }, - - changelog: { - // Used by the changelog screen - version: ({ version }: { version: number }) => `Version ${version}`, - noEntriesAvailable: 'No changelog entries available.', - }, - - terminal: { - // Used by terminal connection screens - webBrowserRequired: 'Web Browser Required', - webBrowserRequiredDescription: 'Terminal connection links can only be opened in a web browser for security reasons. Please use the QR code scanner or open this link on a computer.', - processingConnection: 'Processing connection...', - invalidConnectionLink: 'Invalid Connection Link', - invalidConnectionLinkDescription: 'The connection link is missing or invalid. Please check the URL and try again.', - connectTerminal: 'Connect Terminal', - terminalRequestDescription: 'A terminal is requesting to connect to your Happy Coder account. This will allow the terminal to send and receive messages securely.', - connectionDetails: 'Connection Details', - publicKey: 'Public Key', - encryption: 'Encryption', - endToEndEncrypted: 'End-to-end encrypted', - acceptConnection: 'Accept Connection', - connecting: 'Connecting...', - reject: 'Reject', - security: 'Security', - securityFooter: 'This connection link was processed securely in your browser and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - securityFooterDevice: 'This connection was processed securely on your device and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.', - clientSideProcessing: 'Client-Side Processing', - linkProcessedLocally: 'Link processed locally in browser', - linkProcessedOnDevice: 'Link processed locally on device', - }, - - modals: { - // Used across connect flows and settings - authenticateTerminal: 'Authenticate Terminal', - pasteUrlFromTerminal: 'Paste the authentication URL from your terminal', - deviceLinkedSuccessfully: 'Device linked successfully', - terminalConnectedSuccessfully: 'Terminal connected successfully', - invalidAuthUrl: 'Invalid authentication URL', - developerMode: 'Developer Mode', - developerModeEnabled: 'Developer mode enabled', - developerModeDisabled: 'Developer mode disabled', - disconnectGithub: 'Disconnect GitHub', - disconnectGithubConfirm: 'Are you sure you want to disconnect your GitHub account?', - disconnectService: ({ service }: { service: string }) => - `Disconnect ${service}`, - disconnectServiceConfirm: ({ service }: { service: string }) => - `Are you sure you want to disconnect ${service} from your account?`, - disconnect: 'Disconnect', - failedToConnectTerminal: 'Failed to connect terminal', - cameraPermissionsRequiredToConnectTerminal: 'Camera permissions are required to connect terminal', - failedToLinkDevice: 'Failed to link device', - cameraPermissionsRequiredToScanQr: 'Camera permissions are required to scan QR codes' - }, - - navigation: { - // Navigation titles and screen headers - connectTerminal: 'Connect Terminal', - linkNewDevice: 'Link New Device', - restoreWithSecretKey: 'Restore with Secret Key', - whatsNew: "What's New", - friends: 'Friends', - }, - - welcome: { - // Main welcome screen for unauthenticated users - title: 'Codex and Claude Code mobile client', - subtitle: 'End-to-end encrypted and your account is stored only on your device.', - createAccount: 'Create account', - linkOrRestoreAccount: 'Link or restore account', - loginWithMobileApp: 'Login with mobile app', - }, - - review: { - // Used by utils/requestReview.ts - enjoyingApp: 'Enjoying the app?', - feedbackPrompt: "We'd love to hear your feedback!", - yesILoveIt: 'Yes, I love it!', - notReally: 'Not really' - }, - - items: { - // Used by Item component for copy toast - copiedToClipboard: ({ label }: { label: string }) => `${label} copied to clipboard` - }, - - machine: { - launchNewSessionInDirectory: 'Launch New Session in Directory', - offlineUnableToSpawn: 'Launcher disabled while machine is offline', - offlineHelp: '• Make sure your computer is online\n• Run `happy daemon status` to diagnose\n• Are you running the latest CLI version? Upgrade with `npm install -g happy-coder@latest`', - daemon: 'Daemon', - status: 'Status', - stopDaemon: 'Stop Daemon', - lastKnownPid: 'Last Known PID', - lastKnownHttpPort: 'Last Known HTTP Port', - startedAt: 'Started At', - cliVersion: 'CLI Version', - daemonStateVersion: 'Daemon State Version', - activeSessions: ({ count }: { count: number }) => `Active Sessions (${count})`, - machineGroup: 'Machine', - host: 'Host', - machineId: 'Machine ID', - username: 'Username', - homeDirectory: 'Home Directory', - platform: 'Platform', - architecture: 'Architecture', - lastSeen: 'Last Seen', - never: 'Never', - metadataVersion: 'Metadata Version', - untitledSession: 'Untitled Session', - back: 'Back', - }, - - message: { - switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, - unknownEvent: 'Unknown event', - usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, - unknownTime: 'unknown time', - }, - - codex: { - // Codex permission dialog buttons - permissions: { - yesForSession: "Yes, and don't ask for a session", - stopAndExplain: 'Stop, and explain what to do', - } - }, - - claude: { - // Claude permission dialog buttons - permissions: { - yesAllowAllEdits: 'Yes, allow all edits during this session', - yesForTool: "Yes, don't ask again for this tool", - noTellClaude: 'No, and tell Claude what to do differently', - } - }, - - textSelection: { - // Text selection screen - selectText: 'Select text range', - title: 'Select Text', - noTextProvided: 'No text provided', - textNotFound: 'Text not found or expired', - textCopied: 'Text copied to clipboard', - failedToCopy: 'Failed to copy text to clipboard', - noTextToCopy: 'No text available to copy', - }, - - markdown: { - // Markdown copy functionality - codeCopied: 'Code copied', - copyFailed: 'Copy failed', - mermaidRenderFailed: 'Failed to render mermaid diagram', - }, - - artifacts: { - // Artifacts feature - title: 'Artifacts', - countSingular: '1 artifact', - countPlural: ({ count }: { count: number }) => `${count} artifacts`, - empty: 'No artifacts yet', - emptyDescription: 'Create your first artifact to get started', - new: 'New Artifact', - edit: 'Edit Artifact', - delete: 'Delete', - updateError: 'Failed to update artifact. Please try again.', - notFound: 'Artifact not found', - discardChanges: 'Discard changes?', - discardChangesDescription: 'You have unsaved changes. Are you sure you want to discard them?', - deleteConfirm: 'Delete artifact?', - deleteConfirmDescription: 'This action cannot be undone', - titleLabel: 'TITLE', - titlePlaceholder: 'Enter a title for your artifact', - bodyLabel: 'CONTENT', - bodyPlaceholder: 'Write your content here...', - emptyFieldsError: 'Please enter a title or content', - createError: 'Failed to create artifact. Please try again.', - save: 'Save', - saving: 'Saving...', - loading: 'Loading artifacts...', - error: 'Failed to load artifact', - }, - - friends: { - // Friends feature - title: 'Friends', - manageFriends: 'Manage your friends and connections', - searchTitle: 'Find Friends', - pendingRequests: 'Friend Requests', - myFriends: 'My Friends', - noFriendsYet: "You don't have any friends yet", - findFriends: 'Find Friends', - remove: 'Remove', - pendingRequest: 'Pending', - sentOn: ({ date }: { date: string }) => `Sent on ${date}`, - accept: 'Accept', - reject: 'Reject', - addFriend: 'Add Friend', - alreadyFriends: 'Already Friends', - requestPending: 'Request Pending', - searchInstructions: 'Enter a username to search for friends', - searchPlaceholder: 'Enter username...', - searching: 'Searching...', - userNotFound: 'User not found', - noUserFound: 'No user found with that username', - checkUsername: 'Please check the username and try again', - howToFind: 'How to Find Friends', - findInstructions: 'Search for friends by their username. Both you and your friend need to have GitHub connected to send friend requests.', - requestSent: 'Friend request sent!', - requestAccepted: 'Friend request accepted!', - requestRejected: 'Friend request rejected', - friendRemoved: 'Friend removed', - confirmRemove: 'Remove Friend', - confirmRemoveMessage: 'Are you sure you want to remove this friend?', - cannotAddYourself: 'You cannot send a friend request to yourself', - bothMustHaveGithub: 'Both users must have GitHub connected to become friends', - status: { - none: 'Not connected', - requested: 'Request sent', - pending: 'Request pending', - friend: 'Friends', - rejected: 'Rejected', - }, - acceptRequest: 'Accept Request', - removeFriend: 'Remove Friend', - removeFriendConfirm: ({ name }: { name: string }) => `Are you sure you want to remove ${name} as a friend?`, - requestSentDescription: ({ name }: { name: string }) => `Your friend request has been sent to ${name}`, - requestFriendship: 'Request friendship', - cancelRequest: 'Cancel friendship request', - cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`, - denyRequest: 'Deny friendship', - nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`, - }, - - usage: { - // Usage panel strings - today: 'Today', - last7Days: 'Last 7 days', - last30Days: 'Last 30 days', - totalTokens: 'Total Tokens', - totalCost: 'Total Cost', - tokens: 'Tokens', - cost: 'Cost', - usageOverTime: 'Usage over time', - byModel: 'By Model', - noData: 'No usage data available', - }, - - feed: { - // Feed notifications for friend requests and acceptances - friendRequestFrom: ({ name }: { name: string }) => `${name} sent you a friend request`, - friendRequestGeneric: 'New friend request', - friendAccepted: ({ name }: { name: string }) => `You are now friends with ${name}`, - friendAcceptedGeneric: 'Friend request accepted', - }, - - profiles: { - // Profile management feature - title: 'Profiles', - subtitle: 'Manage environment variable profiles for sessions', - noProfile: 'Default', - noProfileDescription: 'Use default environment settings', - defaultModel: 'Default Model', - addProfile: 'Add Profile', - profileName: 'Profile Name', - enterName: 'Enter profile name', - baseURL: 'Base URL', - authToken: 'Auth Token', - enterToken: 'Enter auth token', - model: 'Model', - tmuxSession: 'Tmux Session', - enterTmuxSession: 'Enter tmux session name', - tmuxTempDir: 'Tmux Temp Directory', - enterTmuxTempDir: 'Enter temp directory path', - tmuxUpdateEnvironment: 'Update environment automatically', - nameRequired: 'Profile name is required', - deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`, - editProfile: 'Edit Profile', - addProfileTitle: 'Add New Profile', - delete: { - title: 'Delete Profile', - message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`, - confirm: 'Delete', - cancel: 'Cancel', - }, - } -} as const; - -export type Translations = typeof en; - -/** - * Generic translation type that matches the structure of Translations - * but allows different string values (for other languages) - */ -export type TranslationStructure = { - readonly [K in keyof Translations]: { - readonly [P in keyof Translations[K]]: Translations[K][P] extends string - ? string - : Translations[K][P] extends (...args: any[]) => string - ? Translations[K][P] - : Translations[K][P] extends object - ? { - readonly [Q in keyof Translations[K][P]]: Translations[K][P][Q] extends string - ? string - : Translations[K][P][Q] - } - : Translations[K][P] - } -}; diff --git a/sources/text/_types.ts b/sources/text/_types.ts new file mode 100644 index 000000000..4234daee3 --- /dev/null +++ b/sources/text/_types.ts @@ -0,0 +1,24 @@ +import { en } from './translations/en'; + +export type Translations = typeof en; + +/** + * Generic translation type that matches the structure of Translations + * but allows different string values (for other languages). + */ +export type TranslationStructure = { + readonly [K in keyof Translations]: { + readonly [P in keyof Translations[K]]: Translations[K][P] extends string + ? string + : Translations[K][P] extends (...args: any[]) => string + ? Translations[K][P] + : Translations[K][P] extends object + ? { + readonly [Q in keyof Translations[K][P]]: Translations[K][P][Q] extends string + ? string + : Translations[K][P][Q] + } + : Translations[K][P] + } +}; + diff --git a/sources/text/index.ts b/sources/text/index.ts index e627bb855..a05afb9d6 100644 --- a/sources/text/index.ts +++ b/sources/text/index.ts @@ -1,4 +1,5 @@ -import { en, type Translations, type TranslationStructure } from './_default'; +import { en } from './translations/en'; +import type { Translations, TranslationStructure } from './_types'; import { ru } from './translations/ru'; import { pl } from './translations/pl'; import { es } from './translations/es'; @@ -98,13 +99,11 @@ let found = false; if (settings.settings.preferredLanguage && settings.settings.preferredLanguage in translations) { currentLanguage = settings.settings.preferredLanguage as SupportedLanguage; found = true; - console.log(`[i18n] Using preferred language: ${currentLanguage}`); } // Read from device if (!found) { let locales = Localization.getLocales(); - console.log(`[i18n] Device locales:`, locales.map(l => l.languageCode)); for (let l of locales) { if (l.languageCode) { // Expo added special handling for Chinese variants using script code https://github.com/expo/expo/pull/34984 @@ -114,35 +113,26 @@ if (!found) { // We only have translations for simplified Chinese right now, but looking for help with traditional Chinese. if (l.languageScriptCode === 'Hans') { chineseVariant = 'zh-Hans'; - // } else if (l.languageScriptCode === 'Hant') { - // chineseVariant = 'zh-Hant'; } - console.log(`[i18n] Chinese script code: ${l.languageScriptCode} -> ${chineseVariant}`); - if (chineseVariant && chineseVariant in translations) { currentLanguage = chineseVariant as SupportedLanguage; - console.log(`[i18n] Using Chinese variant: ${currentLanguage}`); break; } currentLanguage = 'zh-Hans'; - console.log(`[i18n] Falling back to simplified Chinese: zh-Hans`); break; } // Direct match for non-Chinese languages if (l.languageCode in translations) { currentLanguage = l.languageCode as SupportedLanguage; - console.log(`[i18n] Using device locale: ${currentLanguage}`); break; } } } } -console.log(`[i18n] Final language: ${currentLanguage}`); - /** * Main translation function with strict typing * diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts index 4a55163eb..636312712 100644 --- a/sources/text/translations/ca.ts +++ b/sources/text/translations/ca.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Catalan plural helper function diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts index 985b75fcb..45ae3de1d 100644 --- a/sources/text/translations/en.ts +++ b/sources/text/translations/en.ts @@ -1,5 +1,3 @@ -import type { TranslationStructure } from '../_default'; - /** * English plural helper function * English has 2 plural forms: singular, plural @@ -14,10 +12,10 @@ function plural({ count, singular, plural }: { count: number; singular: string; * ENGLISH TRANSLATIONS - DEDICATED FILE * * This file represents the new translation architecture where each language - * has its own dedicated file instead of being embedded in _default.ts. + * has its own dedicated file instead of being embedded in _types.ts. * * STRUCTURE CHANGE: - * - Previously: All languages in _default.ts as objects + * - Previously: All languages in a single default file * - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.) * - Benefit: Better maintainability, smaller files, easier language management * @@ -29,7 +27,7 @@ function plural({ count, singular, plural }: { count: number; singular: string; * - Type safety enforced by TranslationStructure interface * - New translation keys must be added to ALL language files */ -export const en: TranslationStructure = { +export const en = { tabs: { // Tab navigation labels inbox: 'Inbox', @@ -939,4 +937,4 @@ export const en: TranslationStructure = { } } as const; -export type TranslationsEn = typeof en; \ No newline at end of file +export type TranslationsEn = typeof en; diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 75d5eaa44..09433d74d 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Spanish plural helper function diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 1387529c0..a9d572351 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Italian plural helper function diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index 72f3aa402..c0f4a21eb 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -5,7 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; +import type { TranslationStructure } from '../_types'; /** * Japanese plural helper function diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index d44114e02..d620dea97 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Polish plural helper function diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 5e6e625be..6b59d4c99 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Portuguese plural helper function diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts index 49d57231e..0d8ac93b8 100644 --- a/sources/text/translations/ru.ts +++ b/sources/text/translations/ru.ts @@ -1,4 +1,4 @@ -import type { TranslationStructure } from '../_default'; +import type { TranslationStructure } from '../_types'; /** * Russian plural helper function diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index bdbb5f19e..31e4aba22 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -5,7 +5,7 @@ * - Functions with typed object parameters for dynamic text */ -import { TranslationStructure } from "../_default"; +import type { TranslationStructure } from '../_types'; /** * Chinese plural helper function From 04628ed9d6884fac33ab71dbaef3a9747bba6546 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:05 +0100 Subject: [PATCH 66/72] refactor(profiles): remove provider config objects Profiles are env-var-based only: drop anthropic/openai/azure/together config objects from the schema and conversion, and migrate any legacy values into environmentVariables. Also extract PermissionMode/ModelMode into sources/sync/permissionTypes and remove the unused PermissionModeSelector component. --- sources/app/(app)/new/index.tsx | 91 ++-------- sources/components/AgentInput.tsx | 17 +- sources/components/PermissionModeSelector.tsx | 110 ------------ sources/components/ProfileEditForm.tsx | 15 +- sources/sync/permissionTypes.ts | 21 +++ sources/sync/persistence.ts | 3 +- sources/sync/profileMutations.ts | 2 - sources/sync/profileUtils.ts | 9 +- sources/sync/settings.spec.ts | 39 ++++- sources/sync/settings.ts | 162 ++++++++---------- sources/sync/storage.ts | 16 +- 11 files changed, 153 insertions(+), 332 deletions(-) delete mode 100644 sources/components/PermissionModeSelector.tsx create mode 100644 sources/sync/permissionTypes.ts diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index 739dde2de..37dcfafc3 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -19,13 +19,12 @@ import { SessionTypeSelector, SessionTypeSelectorRows } from '@/components/Sessi import { createWorktree } from '@/utils/createWorktree'; import { getTempData, type NewSessionData } from '@/utils/tempDataStore'; import { linkTaskToSession } from '@/-zen/model/taskSessionLink'; -import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings'; import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils'; import { AgentInput } from '@/components/AgentInput'; import { StyleSheet } from 'react-native-unistyles'; import { useCLIDetection } from '@/hooks/useCLIDetection'; -import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables'; import { isMachineOnline } from '@/utils/machineUtils'; import { StatusDot } from '@/components/StatusDot'; @@ -36,23 +35,9 @@ import { SearchHeader } from '@/components/SearchHeader'; import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon'; import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal'; import { buildProfileGroups } from '@/sync/profileGrouping'; -import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { ItemRowActions } from '@/components/ItemRowActions'; import type { ItemAction } from '@/components/ItemActionsMenuModal'; -// Simple temporary state for passing selections back from picker screens -let onMachineSelected: (machineId: string) => void = () => { }; -let onProfileSaved: (profile: AIBackendProfile) => void = () => { }; - -export const callbacks = { - onMachineSelected: (machineId: string) => { - onMachineSelected(machineId); - }, - onProfileSaved: (profile: AIBackendProfile) => { - onProfileSaved(profile); - } -} - // Optimized profile lookup utility const useProfileMap = (profiles: AIBackendProfile[]) => { return React.useMemo(() => @@ -65,7 +50,7 @@ const useProfileMap = (profiles: AIBackendProfile[]) => { // Returns ALL profile environment variables - daemon will use them as-is const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => { // getProfileEnvironmentVariables already returns ALL env vars from profile - // including custom environmentVariables array and provider-specific configs + // including custom environmentVariables array return getProfileEnvironmentVariables(profile); }; @@ -560,19 +545,6 @@ function NewSessionWizard() { } }, [cliAvailability.timestamp, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, agentType, experimentsEnabled]); - // Extract all ${VAR} references from profiles to query daemon environment - const envVarRefs = React.useMemo(() => { - const refs = new Set(); - allProfiles.forEach(profile => { - extractEnvVarReferences(profile.environmentVariables || []) - .forEach(ref => refs.add(ref)); - }); - return Array.from(refs); - }, [allProfiles]); - - // Query daemon environment for ${VAR} resolution - const { variables: daemonEnv } = useEnvironmentVariables(selectedMachineId, envVarRefs); - // Temporary banner dismissal (X button) - resets when component unmounts or machine changes const [hiddenBanners, setHiddenBanners] = React.useState<{ claude: boolean; codex: boolean; gemini: boolean }>({ claude: false, codex: false, gemini: false }); @@ -676,7 +648,7 @@ function NewSessionWizard() { return machines.find(m => m.id === selectedMachineId); }, [selectedMachineId, machines]); - const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { + const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => { // Persist wizard state before navigating so selection doesn't reset on return. saveNewSessionDraft({ input: sessionPrompt, @@ -690,17 +662,21 @@ function NewSessionWizard() { updatedAt: Date.now(), }); - const profileData = JSON.stringify(profile); - const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; - router.push(selectedMachineId ? `${base}&machineId=${encodeURIComponent(selectedMachineId)}` as any : base as any); + router.push({ + pathname: '/new/pick/profile-edit', + params: { + ...params, + ...(selectedMachineId ? { machineId: selectedMachineId } : {}), + }, + } as any); }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]); const handleAddProfile = React.useCallback(() => { - openProfileEdit(createEmptyCustomProfile()); + openProfileEdit({}); }, [openProfileEdit]); const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - openProfileEdit(duplicateProfileForEdit(profile)); + openProfileEdit({ cloneFromProfileId: profile.id }); }, [openProfileEdit]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { @@ -1008,12 +984,12 @@ function NewSessionWizard() { color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, onPress: () => toggleFavoriteProfile(profile.id), }); - actions.push({ - id: 'edit', - title: 'Edit profile', - icon: 'create-outline', - onPress: () => openProfileEdit(profile), - }); + actions.push({ + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit({ profileId: profile.id }), + }); actions.push({ id: 'copy', title: 'Duplicate profile', @@ -1094,37 +1070,6 @@ function NewSessionWizard() { return parts.join(' · '); }, [isProfileAvailable]); - // Handle machine and path selection callbacks - React.useEffect(() => { - let handler = (machineId: string) => { - let machine = storage.getState().machines[machineId]; - if (machine) { - setSelectedMachineId(machineId); - const bestPath = getRecentPathForMachine(machineId, recentMachinePaths); - setSelectedPath(bestPath); - } - }; - onMachineSelected = handler; - return () => { - onMachineSelected = () => { }; - }; - }, [recentMachinePaths]); - - React.useEffect(() => { - let handler = (savedProfile: AIBackendProfile) => { - // Only auto-select newly created profiles (Add / Duplicate / Save As). - // Edits to other profiles should not change the current selection. - const wasExisting = profiles.some(p => p.id === savedProfile.id); - if (!wasExisting) { - setSelectedProfileId(savedProfile.id); - } - }; - onProfileSaved = handler; - return () => { - onProfileSaved = () => { }; - }; - }, [profiles]); - const handleMachineClick = React.useCallback(() => { router.push({ pathname: '/new/pick/machine', diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 0d3719398..2f7ea6f87 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -5,7 +5,7 @@ import { Image } from 'expo-image'; import { layout } from './layout'; import { MultiTextInput, KeyPressEvent } from './MultiTextInput'; import { Typography } from '@/constants/Typography'; -import { PermissionMode, ModelMode } from './PermissionModeSelector'; +import type { PermissionMode, ModelMode } from '@/sync/permissionTypes'; import { hapticsLight, hapticsError } from './haptics'; import { Shaker, ShakeInstance } from './Shaker'; import { StatusDot } from './StatusDot'; @@ -372,7 +372,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('📝 Input state changed:', JSON.stringify(newState)); setInputState(newState); }, []); @@ -382,18 +381,6 @@ export const AgentInput = React.memo(React.forwardRef { - // console.log('🔍 Autocomplete Debug:', JSON.stringify({ - // value: props.value, - // inputState, - // activeWord, - // suggestionsCount: suggestions.length, - // selected, - // prefixes: props.autocompletePrefixes - // }, null, 2)); - // }, [props.value, inputState, activeWord, suggestions.length, selected]); - // Handle suggestion selection const handleSuggestionSelect = React.useCallback((index: number) => { if (!suggestions[index] || !inputRef.current) return; @@ -415,8 +402,6 @@ export const AgentInput = React.memo(React.forwardRef void; - disabled?: boolean; -} - -const modeConfig = { - default: { - label: 'Default', - icon: 'shield-checkmark' as const, - description: 'Ask for permissions' - }, - acceptEdits: { - label: 'Accept Edits', - icon: 'create' as const, - description: 'Auto-approve edits' - }, - plan: { - label: 'Plan', - icon: 'list' as const, - description: 'Plan before executing' - }, - bypassPermissions: { - label: 'Yolo', - icon: 'flash' as const, - description: 'Skip all permissions' - }, - // Codex modes (not displayed in this component, but needed for type compatibility) - 'read-only': { - label: 'Read-only', - icon: 'eye' as const, - description: 'Read-only mode' - }, - 'safe-yolo': { - label: 'Safe YOLO', - icon: 'shield' as const, - description: 'Safe YOLO mode' - }, - 'yolo': { - label: 'YOLO', - icon: 'rocket' as const, - description: 'YOLO mode' - }, -}; - -const modeOrder: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; - -export const PermissionModeSelector: React.FC = ({ - mode, - onModeChange, - disabled = false -}) => { - const currentConfig = modeConfig[mode]; - - const handleTap = () => { - hapticsLight(); - const currentIndex = modeOrder.indexOf(mode); - const nextIndex = (currentIndex + 1) % modeOrder.length; - onModeChange(modeOrder[nextIndex]); - }; - - return ( - - - {/* - {currentConfig.label} - */} - - ); -}; \ No newline at end of file diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx index 69a33fdc0..5f543fdf1 100644 --- a/sources/components/ProfileEditForm.tsx +++ b/sources/components/ProfileEditForm.tsx @@ -6,7 +6,7 @@ import { useUnistyles } from 'react-native-unistyles'; import { Typography } from '@/constants/Typography'; import { t } from '@/text'; import { AIBackendProfile } from '@/sync/settings'; -import { PermissionMode } from '@/components/PermissionModeSelector'; +import type { PermissionMode } from '@/sync/permissionTypes'; import { SessionTypeSelector } from '@/components/SessionTypeSelector'; import { ItemList } from '@/components/ItemList'; import { ItemGroup } from '@/components/ItemGroup'; @@ -256,21 +256,14 @@ export function ProfileEditForm({ onSave({ ...profile, name: name.trim(), - anthropicConfig: {}, - openaiConfig: {}, - azureOpenAIConfig: {}, environmentVariables, tmuxConfig: useTmux ? { + ...(profile.tmuxConfig ?? {}), sessionName: tmuxSession.trim() || '', tmpDir: tmuxTmpDir.trim() || undefined, - updateEnvironment: undefined, } - : { - sessionName: undefined, - tmpDir: undefined, - updateEnvironment: undefined, - }, + : undefined, defaultSessionType, defaultPermissionMode, compatibility, @@ -394,7 +387,7 @@ export function ProfileEditForm({ Tmux Session Name ({t('common.optional')}) { return { id: 'anthropic', name: 'Anthropic (Default)', - anthropicConfig: {}, environmentVariables: [], defaultPermissionMode: 'default', compatibility: { claude: true, codex: false, gemini: false }, @@ -269,11 +268,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Launch daemon with: DEEPSEEK_AUTH_TOKEN=sk-... DEEPSEEK_BASE_URL=https://api.deepseek.com/anthropic // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden (getProfileEnvironmentVariables priority) + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'deepseek', name: 'DeepSeek (Reasoner)', - anthropicConfig: {}, environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${DEEPSEEK_AUTH_TOKEN}' }, // Secret - no fallback @@ -295,11 +293,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { // Model mappings: Z_AI_OPUS_MODEL=GLM-4.6, Z_AI_SONNET_MODEL=GLM-4.6, Z_AI_HAIKU_MODEL=GLM-4.5-Air // Uses ${VAR:-default} format for fallback values (bash parameter expansion) // Secrets use ${VAR} without fallback for security - // NOTE: anthropicConfig left empty so environmentVariables aren't overridden + // NOTE: Profiles are env-var based; environmentVariables are the single source of truth. return { id: 'zai', name: 'Z.AI (GLM-4.6)', - anthropicConfig: {}, environmentVariables: [ { name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic}' }, { name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' }, // Secret - no fallback @@ -320,7 +317,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'openai', name: 'OpenAI (GPT-5)', - openaiConfig: {}, environmentVariables: [ { name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' }, @@ -339,7 +335,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => { return { id: 'azure-openai', name: 'Azure OpenAI', - azureOpenAIConfig: {}, environmentVariables: [ { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' }, { name: 'AZURE_OPENAI_DEPLOYMENT_NAME', value: 'gpt-5-codex' }, diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts index 43c615afc..1f38fef48 100644 --- a/sources/sync/settings.spec.ts +++ b/sources/sync/settings.spec.ts @@ -89,6 +89,37 @@ describe('settings', () => { } }); }); + + it('should migrate legacy provider config objects into environmentVariables', () => { + const settingsWithLegacyProfileConfig: any = { + profiles: [ + { + id: 'legacy-profile', + name: 'Legacy Profile', + isBuiltIn: false, + compatibility: { claude: true, codex: true, gemini: true }, + environmentVariables: [{ name: 'FOO', value: 'bar' }], + openaiConfig: { + apiKey: 'sk-test', + baseUrl: 'https://example.com', + model: 'gpt-test', + }, + }, + ], + }; + + const parsed = settingsParse(settingsWithLegacyProfileConfig); + expect(parsed.profiles).toHaveLength(1); + + const profile = parsed.profiles[0]!; + expect(profile.environmentVariables).toEqual(expect.arrayContaining([ + { name: 'FOO', value: 'bar' }, + { name: 'OPENAI_API_KEY', value: 'sk-test' }, + { name: 'OPENAI_BASE_URL', value: 'https://example.com' }, + { name: 'OPENAI_MODEL', value: 'gpt-test' }, + ])); + expect((profile as any).openaiConfig).toBeUndefined(); + }); }); describe('applySettings', () => { @@ -411,7 +442,7 @@ describe('settings', () => { lastUsedModelMode: null, profiles: [], lastUsedProfile: null, - favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'], + favoriteDirectories: [], favoriteMachines: [], favoriteProfiles: [], dismissedCLIWarnings: { perMachine: {}, global: {} }, @@ -595,7 +626,6 @@ describe('settings', () => { { id: 'server-profile', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -613,7 +643,6 @@ describe('settings', () => { { id: 'local-profile', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -715,7 +744,6 @@ describe('settings', () => { profiles: [{ id: 'test-profile', name: 'Test', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, @@ -748,7 +776,6 @@ describe('settings', () => { profiles: [{ id: 'device-b-profile', name: 'Device B Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -860,7 +887,6 @@ describe('settings', () => { profiles: [{ id: 'server-profile-1', name: 'Server Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true }, isBuiltIn: false, @@ -879,7 +905,6 @@ describe('settings', () => { profiles: [{ id: 'local-profile-1', name: 'Local Profile', - anthropicConfig: {}, environmentVariables: [], compatibility: { claude: true, codex: true, gemini: true }, isBuiltIn: false, diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 66a9625c0..ce7e3669e 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -4,50 +4,6 @@ import * as z from 'zod'; // Configuration Profile Schema (for environment variable profiles) // -// Environment variable schemas for different AI providers -// Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings -const URL_OR_TEMPLATE_REGEX = /^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/; -const URL_OR_TEMPLATE_ERROR = 'Must be a valid URL or ${VAR} or ${VAR:-default} template string'; - -function isUrlOrTemplateString(val: string): boolean { - if (!val) return true; // Optional or empty string - if (URL_OR_TEMPLATE_REGEX.test(val)) return true; - try { - new URL(val); - return true; - } catch { - return false; - } -} - -function urlOrTemplateStringOptional() { - return z.string().refine(isUrlOrTemplateString, { message: URL_OR_TEMPLATE_ERROR }).optional(); -} - -const AnthropicConfigSchema = z.object({ - baseUrl: urlOrTemplateStringOptional(), - authToken: z.string().optional(), - model: z.string().optional(), -}); - -const OpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - baseUrl: urlOrTemplateStringOptional(), - model: z.string().optional(), -}); - -const AzureOpenAIConfigSchema = z.object({ - apiKey: z.string().optional(), - endpoint: urlOrTemplateStringOptional(), - apiVersion: z.string().optional(), - deploymentName: z.string().optional(), -}); - -const TogetherAIConfigSchema = z.object({ - apiKey: z.string().optional(), - model: z.string().optional(), -}); - // Tmux configuration schema const TmuxConfigSchema = z.object({ sessionName: z.string().optional(), @@ -75,12 +31,6 @@ export const AIBackendProfileSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - // Agent-specific configurations - anthropicConfig: AnthropicConfigSchema.optional(), - openaiConfig: OpenAIConfigSchema.optional(), - azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), - togetherAIConfig: TogetherAIConfigSchema.optional(), - // Tmux configuration tmuxConfig: TmuxConfigSchema.optional(), @@ -115,6 +65,61 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud return profile.compatibility[agent]; } +function mergeEnvironmentVariables( + existing: unknown, + additions: Record +): Array<{ name: string; value: string }> { + const map = new Map(); + + if (Array.isArray(existing)) { + for (const entry of existing) { + if (!entry || typeof entry !== 'object') continue; + const name = (entry as any).name; + const value = (entry as any).value; + if (typeof name !== 'string' || typeof value !== 'string') continue; + map.set(name, value); + } + } + + for (const [name, value] of Object.entries(additions)) { + if (typeof value !== 'string') continue; + if (!map.has(name)) { + map.set(name, value); + } + } + + return Array.from(map.entries()).map(([name, value]) => ({ name, value })); +} + +function normalizeLegacyProfileConfig(profile: unknown): unknown { + if (!profile || typeof profile !== 'object') return profile; + + const raw = profile as Record; + const additions: Record = { + ANTHROPIC_BASE_URL: raw.anthropicConfig?.baseUrl, + ANTHROPIC_AUTH_TOKEN: raw.anthropicConfig?.authToken, + ANTHROPIC_MODEL: raw.anthropicConfig?.model, + OPENAI_API_KEY: raw.openaiConfig?.apiKey, + OPENAI_BASE_URL: raw.openaiConfig?.baseUrl, + OPENAI_MODEL: raw.openaiConfig?.model, + AZURE_OPENAI_API_KEY: raw.azureOpenAIConfig?.apiKey, + AZURE_OPENAI_ENDPOINT: raw.azureOpenAIConfig?.endpoint, + AZURE_OPENAI_API_VERSION: raw.azureOpenAIConfig?.apiVersion, + AZURE_OPENAI_DEPLOYMENT_NAME: raw.azureOpenAIConfig?.deploymentName, + TOGETHER_API_KEY: raw.togetherAIConfig?.apiKey, + TOGETHER_MODEL: raw.togetherAIConfig?.model, + }; + + const environmentVariables = mergeEnvironmentVariables(raw.environmentVariables, additions); + + // Remove legacy provider config objects. Any values are preserved via environmentVariables migration above. + const { anthropicConfig, openaiConfig, azureOpenAIConfig, togetherAIConfig, ...rest } = raw; + return { + ...rest, + environmentVariables, + }; +} + /** * Converts a profile into environment variables for session spawning. * @@ -132,8 +137,8 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud * Sent: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} (literal string with placeholder) * * 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session: - * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching - * - Non-tmux mode: daemon must interpolate ${VAR} / ${VAR:-default} in env values before calling spawn() (Node does not expand placeholders) + * - Tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before launching (shells do not expand placeholders inside env values automatically) + * - Non-tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before calling spawn() (Node does not expand placeholders) * * 5. SESSION RECEIVES actual expanded values: * ANTHROPIC_AUTH_TOKEN=sk-real-key (expanded from daemon's Z_AI_AUTH_TOKEN, not literal ${Z_AI_AUTH_TOKEN}) @@ -159,34 +164,6 @@ export function getProfileEnvironmentVariables(profile: AIBackendProfile): Recor envVars[envVar.name] = envVar.value; }); - // Add Anthropic config - if (profile.anthropicConfig) { - if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; - if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; - if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; - } - - // Add OpenAI config - if (profile.openaiConfig) { - if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; - if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; - if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; - } - - // Add Azure OpenAI config - if (profile.azureOpenAIConfig) { - if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; - if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; - if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; - if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; - } - - // Add Together AI config - if (profile.togetherAIConfig) { - if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; - if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; - } - // Add Tmux config if (profile.tmuxConfig) { // Empty string means "use current/most recent session", so include it @@ -224,6 +201,8 @@ export function isProfileVersionCompatible(profileVersion: string, requiredVersi // // Current schema version for backward compatibility +// NOTE: This schemaVersion is for the Happy app's settings blob (synced via the server). +// happy-cli maintains its own local settings schemaVersion separately. export const SUPPORTED_SCHEMA_VERSION = 2; export const SettingsSchema = z.object({ @@ -357,6 +336,8 @@ export function settingsParse(settings: unknown): Settings { return { ...settingsDefaults }; } + const isDev = typeof __DEV__ !== 'undefined' && __DEV__; + // IMPORTANT: be tolerant of partially-invalid settings objects. // A single invalid field (e.g. one malformed profile) must not reset all other known settings to defaults. const input = settings as Record; @@ -364,7 +345,7 @@ export function settingsParse(settings: unknown): Settings { // Parse known fields individually to avoid whole-object failure. (Object.keys(SettingsSchema.shape) as Array).forEach((key) => { - if (!(key in input)) return; + if (!Object.prototype.hasOwnProperty.call(input, key)) return; // Special-case profiles: validate per profile entry, keep valid ones. if (key === 'profiles') { @@ -372,10 +353,10 @@ export function settingsParse(settings: unknown): Settings { if (Array.isArray(profilesValue)) { const parsedProfiles: AIBackendProfile[] = []; for (const rawProfile of profilesValue) { - const parsedProfile = AIBackendProfileSchema.safeParse(rawProfile); + const parsedProfile = AIBackendProfileSchema.safeParse(normalizeLegacyProfileConfig(rawProfile)); if (parsedProfile.success) { parsedProfiles.push(parsedProfile.data); - } else if (__DEV__) { + } else if (isDev) { console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues); } } @@ -388,16 +369,13 @@ export function settingsParse(settings: unknown): Settings { const parsedField = schema.safeParse(input[key]); if (parsedField.success) { result[key] = parsedField.data; - } else if (__DEV__) { + } else if (isDev) { console.warn(`[settingsParse] Invalid settings field "${String(key)}" - using default`, parsedField.error.issues); } }); // Migration: Convert old 'zh' language code to 'zh-Hans' if (result.preferredLanguage === 'zh') { - if (__DEV__) { - console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"'); - } result.preferredLanguage = 'zh-Hans'; } @@ -415,8 +393,14 @@ export function settingsParse(settings: unknown): Settings { // Preserve unknown fields (forward compatibility). for (const [key, value] of Object.entries(input)) { - if (!(key in SettingsSchema.shape)) { - result[key] = value; + if (key === '__proto__') continue; + if (!Object.prototype.hasOwnProperty.call(SettingsSchema.shape, key)) { + Object.defineProperty(result, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); } } diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index d95e186d2..6a212b35d 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -12,7 +12,7 @@ import { TodoState } from "../-zen/model/ops"; import { Profile } from "./profile"; import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence"; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { PermissionMode } from '@/sync/permissionTypes'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; import { sync } from "./sync"; @@ -367,8 +367,6 @@ export const storage = create()((set, get) => { listData.push(...inactiveSessions); } - // console.log(`📊 Storage: applySessions called with ${sessions.length} sessions, active: ${activeSessions.length}, inactive: ${inactiveSessions.length}`); - // Process AgentState updates for sessions that already have messages loaded const updatedSessionMessages = { ...state.sessionMessages }; @@ -385,15 +383,6 @@ export const storage = create()((set, get) => { const currentRealtimeSessionId = getCurrentRealtimeSessionId(); const voiceSession = getVoiceSession(); - // console.log('[REALTIME DEBUG] Permission check:', { - // currentRealtimeSessionId, - // sessionId: session.id, - // match: currentRealtimeSessionId === session.id, - // hasVoiceSession: !!voiceSession, - // oldRequests: Object.keys(oldSession?.agentState?.requests || {}), - // newRequests: Object.keys(newSession.agentState?.requests || {}) - // }); - if (currentRealtimeSessionId === session.id && voiceSession) { const oldRequests = oldSession?.agentState?.requests || {}; const newRequests = newSession.agentState?.requests || {}; @@ -403,7 +392,6 @@ export const storage = create()((set, get) => { if (!oldRequests[requestId]) { // This is a NEW permission request const toolName = request.tool; - // console.log('[REALTIME DEBUG] Sending permission notification for:', toolName); voiceSession.sendTextMessage( `Claude is requesting permission to use the ${toolName} tool` ); @@ -880,12 +868,10 @@ export const storage = create()((set, get) => { }), // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => set((state) => { - console.log(`🗂️ Storage.applyArtifacts: Applying ${artifacts.length} artifacts`); const mergedArtifacts = { ...state.artifacts }; artifacts.forEach(artifact => { mergedArtifacts[artifact.id] = artifact; }); - console.log(`🗂️ Storage.applyArtifacts: Total artifacts after merge: ${Object.keys(mergedArtifacts).length}`); return { ...state, From 5571035ab69f0c24aaa8418cb12d53de7d6e9791 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:21 +0100 Subject: [PATCH 67/72] fix(new-session): switch profile picker to id-based navigation Avoid URL-encoding full profile JSON when editing/duplicating profiles and return selection via navigation params (unmount-safe). --- sources/app/(app)/new/pick/profile-edit.tsx | 68 ++++++++++------ sources/app/(app)/new/pick/profile.tsx | 77 +++++++++++-------- .../new/pick/profilePickerRouting.test.ts | 26 +++++++ 3 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 sources/app/(app)/new/pick/profilePickerRouting.test.ts diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx index 7dd6865e4..8fe47f83e 100644 --- a/sources/app/(app)/new/pick/profile-edit.tsx +++ b/sources/app/(app)/new/pick/profile-edit.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native'; import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; -import { useNavigation } from '@react-navigation/native'; +import { CommonActions, useNavigation } from '@react-navigation/native'; import { StyleSheet } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { useHeaderHeight } from '@react-navigation/elements'; @@ -10,17 +10,25 @@ import { t } from '@/text'; import { ProfileEditForm } from '@/components/ProfileEditForm'; import { AIBackendProfile } from '@/sync/settings'; import { layout } from '@/components/layout'; -import { callbacks } from '../index'; import { useSettingMutable } from '@/sync/storage'; import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils'; -import { convertBuiltInProfileToCustom } from '@/sync/profileMutations'; +import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations'; import { Modal } from '@/modal'; export default function ProfileEditScreen() { const { theme } = useUnistyles(); const router = useRouter(); const navigation = useNavigation(); - const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>(); + const params = useLocalSearchParams<{ + profileId?: string | string[]; + cloneFromProfileId?: string | string[]; + profileData?: string | string[]; + machineId?: string | string[]; + }>(); + const profileIdParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; + const cloneFromProfileIdParam = Array.isArray(params.cloneFromProfileId) ? params.cloneFromProfileId[0] : params.cloneFromProfileId; + const profileDataParam = Array.isArray(params.profileData) ? params.profileData[0] : params.profileData; + const machineIdParam = Array.isArray(params.machineId) ? params.machineId[0] : params.machineId; const screenWidth = useWindowDimensions().width; const headerHeight = useHeaderHeight(); const [profiles, setProfiles] = useSettingMutable('profiles'); @@ -34,32 +42,38 @@ export default function ProfileEditScreen() { // Deserialize profile from URL params const profile: AIBackendProfile = React.useMemo(() => { - if (params.profileData) { + if (profileDataParam) { try { // Params may arrive already decoded (native) or URL-encoded (web / manual encodeURIComponent). // Try raw JSON first, then fall back to decodeURIComponent. try { - return JSON.parse(params.profileData); + return JSON.parse(profileDataParam); } catch { - return JSON.parse(decodeURIComponent(params.profileData)); + return JSON.parse(decodeURIComponent(profileDataParam)); } } catch (error) { console.error('Failed to parse profile data:', error); } } + const resolveById = (id: string) => profiles.find((p) => p.id === id) ?? getBuiltInProfile(id) ?? null; + + if (cloneFromProfileIdParam) { + const base = resolveById(cloneFromProfileIdParam); + if (base) { + return duplicateProfileForEdit(base); + } + } + + if (profileIdParam) { + const existing = resolveById(profileIdParam); + if (existing) { + return existing; + } + } + // Return empty profile for new profile creation - return { - id: '', - name: '', - anthropicConfig: {}, - environmentVariables: [], - compatibility: { claude: true, codex: true }, - isBuiltIn: false, - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0', - }; - }, [params.profileData]); + return createEmptyCustomProfile(); + }, [cloneFromProfileIdParam, profileDataParam, profileIdParam, profiles]); const confirmDiscard = React.useCallback(async () => { return Modal.confirm( @@ -122,10 +136,20 @@ export default function ProfileEditScreen() { : [...profiles, profileToSave]; setProfiles(updatedProfiles); + + // Update last used profile for convenience in other screens. if (isNewProfile) { setLastUsedProfile(profileToSave.id); - // Notify the /new screen only for newly created profiles (Add / Duplicate / Save As). - callbacks.onProfileSaved(profileToSave); + } + + // Pass selection back to the /new screen via navigation params (unmount-safe). + const state = (navigation as any).getState?.(); + const previousRoute = state?.routes?.[state.index - 1]; + if (state && state.index > 0 && previousRoute) { + (navigation as any).dispatch({ + ...CommonActions.setParams({ profileId: profileToSave.id }), + source: previousRoute.key, + } as never); } // Prevent the unsaved-changes guard from triggering on successful save. isDirtyRef.current = false; @@ -167,7 +191,7 @@ export default function ProfileEditScreen() { ]}> (); + const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>(); const useProfiles = useSetting('useProfiles'); const experimentsEnabled = useSetting('experiments'); const [profiles, setProfiles] = useSettingMutable('profiles'); @@ -29,6 +28,7 @@ export default function ProfilePickerScreen() { const selectedId = typeof params.selectedId === 'string' ? params.selectedId : ''; const machineId = typeof params.machineId === 'string' ? params.machineId : undefined; + const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId; const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => { return ; @@ -62,9 +62,24 @@ export default function ProfilePickerScreen() { router.back(); }, [navigation, router]); - const openProfileEdit = React.useCallback((profile: AIBackendProfile) => { - const profileData = JSON.stringify(profile); - const base = `/new/pick/profile-edit?profileData=${encodeURIComponent(profileData)}`; + React.useEffect(() => { + if (typeof profileId === 'string' && profileId.length > 0) { + setProfileParamAndClose(profileId); + } + }, [profileId, setProfileParamAndClose]); + + const openProfileCreate = React.useCallback(() => { + const base = '/new/pick/profile-edit'; + router.push(machineId ? `${base}?machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + const openProfileEdit = React.useCallback((profileId: string) => { + const base = `/new/pick/profile-edit?profileId=${encodeURIComponent(profileId)}`; + router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); + }, [machineId, router]); + + const openProfileDuplicate = React.useCallback((cloneFromProfileId: string) => { + const base = `/new/pick/profile-edit?cloneFromProfileId=${encodeURIComponent(cloneFromProfileId)}`; router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any); }, [machineId, router]); @@ -86,12 +101,8 @@ export default function ProfilePickerScreen() { }, [favoriteProfileIdSet, favoriteProfileIds, setFavoriteProfileIds]); const handleAddProfile = React.useCallback(() => { - openProfileEdit(createEmptyCustomProfile()); - }, [openProfileEdit]); - - const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => { - openProfileEdit(duplicateProfileForEdit(profile)); - }, [openProfileEdit]); + openProfileCreate(); + }, [openProfileCreate]); const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => { Modal.alert( @@ -124,19 +135,19 @@ export default function ProfilePickerScreen() { color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary, onPress: () => toggleFavoriteProfile(profile.id), }, - { - id: 'edit', - title: 'Edit profile', - icon: 'create-outline', - onPress: () => openProfileEdit(profile), - }, - { - id: 'copy', - title: 'Duplicate profile', - icon: 'copy-outline', - onPress: () => handleDuplicateProfile(profile), - }, - ]; + { + id: 'edit', + title: 'Edit profile', + icon: 'create-outline', + onPress: () => openProfileEdit(profile.id), + }, + { + id: 'copy', + title: 'Duplicate profile', + icon: 'copy-outline', + onPress: () => openProfileDuplicate(profile.id), + }, + ]; if (!profile.isBuiltIn) { actions.push({ id: 'delete', @@ -165,15 +176,15 @@ export default function ProfilePickerScreen() { /> ); - }, [ - handleDeleteProfile, - handleDuplicateProfile, - openProfileEdit, - theme.colors.button.primary.background, - theme.colors.button.secondary.tint, - theme.colors.deleteAction, - theme.colors.textSecondary, - toggleFavoriteProfile, + }, [ + handleDeleteProfile, + openProfileEdit, + openProfileDuplicate, + theme.colors.button.primary.background, + theme.colors.button.secondary.tint, + theme.colors.deleteAction, + theme.colors.textSecondary, + toggleFavoriteProfile, ]); return ( diff --git a/sources/app/(app)/new/pick/profilePickerRouting.test.ts b/sources/app/(app)/new/pick/profilePickerRouting.test.ts new file mode 100644 index 000000000..f0a78b93e --- /dev/null +++ b/sources/app/(app)/new/pick/profilePickerRouting.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('ProfilePickerScreen routing', () => { + it('does not serialize full profile JSON into profile-edit URL params', () => { + const file = join(process.cwd(), 'sources/app/(app)/new/pick/profile.tsx'); + const content = readFileSync(file, 'utf8'); + + expect(content).not.toContain('profileData='); + expect(content).not.toContain('encodeURIComponent(profileData)'); + expect(content).not.toContain('JSON.stringify(profile)'); + }); + + it('consumes returned profileId param from profile-edit to auto-select and close', () => { + const file = join(process.cwd(), 'sources/app/(app)/new/pick/profile.tsx'); + const content = readFileSync(file, 'utf8'); + + // When profile-edit navigates back, it returns selection via navigation params. + // The picker must read that param and forward it back to /new. + expect(content).toMatch(/profileId\?:/); + expect(content).toContain('setProfileParamAndClose'); + expect(content).toContain('params.profileId'); + }); +}); + From 1a36c61acda56ba9595143834a768dfb03ef2939 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:36 +0100 Subject: [PATCH 68/72] fix(env): align template semantics and preview safety Support , default, and default (:= treated like :-) and apply bash-like empty-string fallback semantics. Prevent secret-like env vars from being queried into UI memory during env previews. --- .../EnvironmentVariablesList.keys.test.ts | 14 ++++++++++++++ sources/components/EnvironmentVariablesList.tsx | 2 +- .../EnvironmentVariablesPreviewModal.tsx | 2 ++ sources/hooks/envVarUtils.ts | 7 ++++++- sources/hooks/useEnvironmentVariables.test.ts | 4 ++++ 5 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 sources/components/EnvironmentVariablesList.keys.test.ts diff --git a/sources/components/EnvironmentVariablesList.keys.test.ts b/sources/components/EnvironmentVariablesList.keys.test.ts new file mode 100644 index 000000000..814e3e8db --- /dev/null +++ b/sources/components/EnvironmentVariablesList.keys.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('EnvironmentVariablesList item keys', () => { + it('does not key EnvironmentVariableCard by array index', () => { + const file = join(process.cwd(), 'sources/components/EnvironmentVariablesList.tsx'); + const content = readFileSync(file, 'utf8'); + + expect(content).not.toContain('key={index}'); + expect(content).toContain('key={envVar.name}'); + }); +}); + diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx index b210c1666..9b92e2198 100644 --- a/sources/components/EnvironmentVariablesList.tsx +++ b/sources/components/EnvironmentVariablesList.tsx @@ -178,7 +178,7 @@ export function EnvironmentVariablesList({ return ( { const parsed = parseTemplateValue(envVar.value); if (parsed?.sourceVar) { + // Never fetch secret-like values into UI memory. + if (isSecretLike(envVar.name) || isSecretLike(parsed.sourceVar)) return; refs.add(parsed.sourceVar); } }); diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts index 83f5fa825..e839a6b10 100644 --- a/sources/hooks/envVarUtils.ts +++ b/sources/hooks/envVarUtils.ts @@ -41,13 +41,18 @@ export function resolveEnvVarSubstitution( const defaultValue = match[2]; // :- default const daemonValue = daemonEnv[varName]; - if (daemonValue !== undefined && daemonValue !== null) { + // For ${VAR:-default} and ${VAR:=default}, treat empty string as "missing" (bash semantics). + // For plain ${VAR}, preserve empty string (it is an explicit value). + if (daemonValue !== undefined && daemonValue !== null && daemonValue !== '') { return daemonValue; } // Variable not set - use default if provided if (defaultValue !== undefined) { return defaultValue; } + if (daemonValue === '') { + return ''; + } return null; } // Not a substitution - return literal value diff --git a/sources/hooks/useEnvironmentVariables.test.ts b/sources/hooks/useEnvironmentVariables.test.ts index e1bae6d24..45a978263 100644 --- a/sources/hooks/useEnvironmentVariables.test.ts +++ b/sources/hooks/useEnvironmentVariables.test.ts @@ -89,6 +89,10 @@ describe('resolveEnvVarSubstitution', () => { expect(resolveEnvVarSubstitution('${VAR:-fallback}', envWithNull)).toBe('fallback'); }); + it('returns default when VAR is empty string in ${VAR:-default}', () => { + expect(resolveEnvVarSubstitution('${EMPTY:-fallback}', daemonEnv)).toBe('fallback'); + }); + it('returns literal for non-substitution values', () => { expect(resolveEnvVarSubstitution('literal-value', daemonEnv)).toBe('literal-value'); }); From 8adde3f337beab5a1b4ddb1be8a49037ce1e71a4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 15 Jan 2026 23:08:55 +0100 Subject: [PATCH 69/72] fix(sync): stop resetting model meta and gate logs Stop sending model:null/fallbackModel:null in outgoing message meta (keeps modelMode dormant without forcing per-message resets). Gate noisy realtime/sync debug logs behind __DEV__/remove console noise in tests. --- sources/realtime/RealtimeVoiceSession.tsx | 19 +++--- sources/realtime/RealtimeVoiceSession.web.tsx | 29 +++++---- sources/sync/messageMeta.test.ts | 18 ++++++ sources/sync/messageMeta.ts | 15 +++++ sources/sync/reducer/phase0-skipping.spec.ts | 8 +-- sources/sync/sync.ts | 60 +++---------------- 6 files changed, 70 insertions(+), 79 deletions(-) create mode 100644 sources/sync/messageMeta.test.ts create mode 100644 sources/sync/messageMeta.ts diff --git a/sources/realtime/RealtimeVoiceSession.tsx b/sources/realtime/RealtimeVoiceSession.tsx index da558e1ec..71445ca04 100644 --- a/sources/realtime/RealtimeVoiceSession.tsx +++ b/sources/realtime/RealtimeVoiceSession.tsx @@ -9,6 +9,11 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { @@ -93,18 +98,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: (data) => { - console.log('Realtime session connected:', data); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -116,10 +121,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -129,7 +134,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -157,4 +162,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/sources/realtime/RealtimeVoiceSession.web.tsx b/sources/realtime/RealtimeVoiceSession.web.tsx index 54edb4672..1aa82a06d 100644 --- a/sources/realtime/RealtimeVoiceSession.web.tsx +++ b/sources/realtime/RealtimeVoiceSession.web.tsx @@ -9,11 +9,16 @@ import type { VoiceSession, VoiceSessionConfig } from './types'; // Static reference to the conversation hook instance let conversationInstance: ReturnType | null = null; +function debugLog(...args: unknown[]) { + if (!__DEV__) return; + console.debug(...args); +} + // Global voice session implementation class RealtimeVoiceSessionImpl implements VoiceSession { async startSession(config: VoiceSessionConfig): Promise { - console.log('[RealtimeVoiceSessionImpl] conversationInstance:', conversationInstance); + debugLog('[RealtimeVoiceSessionImpl] startSession'); if (!conversationInstance) { console.warn('Realtime voice session not initialized - conversationInstance is null'); return; @@ -55,7 +60,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession { const conversationId = await conversationInstance.startSession(sessionConfig); - console.log('Started conversation with ID:', conversationId); + debugLog('Started conversation'); } catch (error) { console.error('Failed to start realtime session:', error); storage.getState().setRealtimeStatus('error'); @@ -98,18 +103,18 @@ export const RealtimeVoiceSession: React.FC = () => { const conversation = useConversation({ clientTools: realtimeClientTools, onConnect: () => { - console.log('Realtime session connected'); + debugLog('Realtime session connected'); storage.getState().setRealtimeStatus('connected'); storage.getState().setRealtimeMode('idle'); }, onDisconnect: () => { - console.log('Realtime session disconnected'); + debugLog('Realtime session disconnected'); storage.getState().setRealtimeStatus('disconnected'); storage.getState().setRealtimeMode('idle', true); // immediate mode change storage.getState().clearRealtimeModeDebounce(); }, onMessage: (data) => { - console.log('Realtime message:', data); + debugLog('Realtime message received'); }, onError: (error) => { // Log but don't block app - voice features will be unavailable @@ -121,10 +126,10 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode('idle', true); // immediate mode change }, onStatusChange: (data) => { - console.log('Realtime status change:', data); + debugLog('Realtime status change'); }, onModeChange: (data) => { - console.log('Realtime mode change:', data); + debugLog('Realtime mode change'); // Only animate when speaking const mode = data.mode as string; @@ -134,7 +139,7 @@ export const RealtimeVoiceSession: React.FC = () => { storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle'); }, onDebug: (message) => { - console.debug('Realtime debug:', message); + debugLog('Realtime debug:', message); } }); @@ -142,16 +147,16 @@ export const RealtimeVoiceSession: React.FC = () => { useEffect(() => { // Store the conversation instance globally - console.log('[RealtimeVoiceSession] Setting conversationInstance:', conversation); + debugLog('[RealtimeVoiceSession] Setting conversationInstance'); conversationInstance = conversation; // Register the voice session once if (!hasRegistered.current) { try { - console.log('[RealtimeVoiceSession] Registering voice session'); + debugLog('[RealtimeVoiceSession] Registering voice session'); registerVoiceSession(new RealtimeVoiceSessionImpl()); hasRegistered.current = true; - console.log('[RealtimeVoiceSession] Voice session registered successfully'); + debugLog('[RealtimeVoiceSession] Voice session registered successfully'); } catch (error) { console.error('Failed to register voice session:', error); } @@ -165,4 +170,4 @@ export const RealtimeVoiceSession: React.FC = () => { // This component doesn't render anything visible return null; -}; \ No newline at end of file +}; diff --git a/sources/sync/messageMeta.test.ts b/sources/sync/messageMeta.test.ts new file mode 100644 index 000000000..e87cf1431 --- /dev/null +++ b/sources/sync/messageMeta.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { buildOutgoingMessageMeta } from './messageMeta'; + +describe('buildOutgoingMessageMeta', () => { + it('does not include model fields by default', () => { + const meta = buildOutgoingMessageMeta({ + sentFrom: 'web', + permissionMode: 'default', + appendSystemPrompt: 'PROMPT', + }); + + expect(meta.sentFrom).toBe('web'); + expect(meta.permissionMode).toBe('default'); + expect(meta.appendSystemPrompt).toBe('PROMPT'); + expect('model' in meta).toBe(false); + expect('fallbackModel' in meta).toBe(false); + }); +}); diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts new file mode 100644 index 000000000..39b7bd67d --- /dev/null +++ b/sources/sync/messageMeta.ts @@ -0,0 +1,15 @@ +import type { MessageMeta } from './typesMessageMeta'; + +export function buildOutgoingMessageMeta(params: { + sentFrom: string; + permissionMode: NonNullable; + appendSystemPrompt: string; + displayText?: string; +}): MessageMeta { + return { + sentFrom: params.sentFrom, + permissionMode: params.permissionMode, + appendSystemPrompt: params.appendSystemPrompt, + ...(params.displayText ? { displayText: params.displayText } : {}), + }; +} diff --git a/sources/sync/reducer/phase0-skipping.spec.ts b/sources/sync/reducer/phase0-skipping.spec.ts index 5e005ab59..c1bb0e2ff 100644 --- a/sources/sync/reducer/phase0-skipping.spec.ts +++ b/sources/sync/reducer/phase0-skipping.spec.ts @@ -93,12 +93,6 @@ describe('Phase 0 permission skipping issue', () => { // Process messages and AgentState together (simulates opening chat) const result = reducer(state, toolMessages, agentState); - // Log what happened (for debugging) - console.log('Result messages:', result.messages.length); - console.log('Permission mappings:', { - toolIdToMessageId: Array.from(state.toolIdToMessageId.entries()) - }); - // Find the tool messages in the result const webFetchTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'WebFetch'); const writeTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'Write'); @@ -203,4 +197,4 @@ describe('Phase 0 permission skipping issue', () => { expect(toolAfterPermission?.tool?.permission?.id).toBe('tool1'); expect(toolAfterPermission?.tool?.permission?.status).toBe('approved'); }); -}); \ No newline at end of file +}); diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index 2b136f1a6..be559bea6 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -39,6 +39,7 @@ import { fetchFeed } from './apiFeed'; import { FeedItem } from './feedTypes'; import { UserProfile } from './friendTypes'; import { initializeTodoSync } from '../-zen/model/ops'; +import { buildOutgoingMessageMeta } from './messageMeta'; class Sync { // Spawned agents (especially in spawn mode) can take noticeable time to connect. @@ -225,7 +226,6 @@ class Sync { // Read permission mode and model mode from session state const permissionMode = session.permissionMode || 'default'; - const modelMode = session.modelMode || 'default'; // Generate local ID const localId = randomUUID(); @@ -247,10 +247,6 @@ class Sync { sentFrom = 'web'; // fallback } - // Model settings - models are configured in CLI settings - const model: string | null = null; - const fallbackModel: string | null = null; - // Create user message content with metadata const content: RawRecord = { role: 'user', @@ -258,14 +254,12 @@ class Sync { type: 'text', text }, - meta: { + meta: buildOutgoingMessageMeta({ sentFrom, permissionMode: permissionMode || 'default', - model, - fallbackModel, appendSystemPrompt: systemPrompt, - ...(displayText && { displayText }) // Add displayText if provided - } + displayText, + }) }; const encryptedRawRecord = await encryption.encryptRawRecord(content); @@ -835,7 +829,6 @@ class Sync { private fetchMachines = async () => { if (!this.credentials) return; - console.log('📊 Sync: Fetching machines...'); const API_ENDPOINT = getServerUrl(); const response = await fetch(`${API_ENDPOINT}/v1/machines`, { headers: { @@ -850,7 +843,6 @@ class Sync { } const data = await response.json(); - console.log(`📊 Sync: Fetched ${Array.isArray(data) ? data.length : 0} machines from server`); const machines = data as Array<{ id: string; metadata: string; @@ -1181,11 +1173,6 @@ class Sync { } // Log and retry - console.log('settings version-mismatch, retrying', { - serverVersion: data.currentVersion, - retry: retryCount + 1, - pendingKeys: Object.keys(this.pendingSettings) - }); retryCount++; continue; } else { @@ -1222,14 +1209,6 @@ class Sync { parsedSettings = { ...settingsDefaults }; } - // Avoid logging full settings in production (may contain secrets like API keys / profile env vars). - if (__DEV__) { - console.log('settings', { - version: data.settingsVersion, - schemaVersion: parsedSettings.schemaVersion, - }); - } - // Apply settings to storage storage.getState().replaceSettings(parsedSettings, data.settingsVersion); @@ -1261,16 +1240,6 @@ class Sync { const data = await response.json(); const parsedProfile = profileParse(data); - // Keep debug logs dev-only (avoid leaking PII/noise in prod logs). - if (__DEV__) { - console.log('profile', { - id: parsedProfile.id, - timestamp: parsedProfile.timestamp, - hasAvatar: !!parsedProfile.avatar, - hasGitHub: !!parsedProfile.github, - }); - } - // Apply profile to storage storage.getState().applyProfile(parsedProfile); } @@ -1308,12 +1277,11 @@ class Sync { }); if (!response.ok) { - console.log(`[fetchNativeUpdate] Request failed: ${response.status}`); + log.log(`[fetchNativeUpdate] Request failed: ${response.status}`); return; } const data = await response.json(); - console.log('[fetchNativeUpdate] Data:', data); // Apply update status to storage if (data.update_required && data.update_url) { @@ -1327,7 +1295,7 @@ class Sync { }); } } catch (error) { - console.log('[fetchNativeUpdate] Error:', error); + console.error('[fetchNativeUpdate] Error:', error); storage.getState().applyNativeUpdateStatus(null); } } @@ -1348,7 +1316,6 @@ class Sync { } if (!apiKey) { - console.log(`RevenueCat: No API key found for platform ${Platform.OS}`); return; } @@ -1365,7 +1332,6 @@ class Sync { }); this.revenueCatInitialized = true; - console.log('RevenueCat initialized successfully'); } // Sync purchases @@ -1432,9 +1398,6 @@ class Sync { } } } - console.log('Batch decrypted and normalized messages in', Date.now() - start, 'ms'); - console.log('normalizedMessages', JSON.stringify(normalizedMessages)); - // console.log('messages', JSON.stringify(normalizedMessages)); // Apply to storage this.applyMessages(sessionId, normalizedMessages); @@ -1461,7 +1424,7 @@ class Sync { log.log('finalStatus: ' + JSON.stringify(finalStatus)); if (finalStatus !== 'granted') { - console.log('Failed to get push token for push notification!'); + log.log('Failed to get push token for push notification!'); return; } @@ -1509,15 +1472,12 @@ class Sync { } private handleUpdate = async (update: unknown) => { - console.log('🔄 Sync: handleUpdate called with:', JSON.stringify(update).substring(0, 300)); const validatedUpdate = ApiUpdateContainerSchema.safeParse(update); if (!validatedUpdate.success) { - console.log('❌ Sync: Invalid update received:', validatedUpdate.error); console.error('❌ Sync: Invalid update data:', update); return; } const updateData = validatedUpdate.data; - console.log(`🔄 Sync: Validated update type: ${updateData.body.t}`); if (updateData.body.t === 'new-message') { @@ -1551,7 +1511,6 @@ class Sync { // Update messages if (lastMessage) { - console.log('🔄 Sync: Applying message:', JSON.stringify(lastMessage)); this.applyMessages(updateData.body.sid, [lastMessage]); let hasMutableTool = false; if (lastMessage.role === 'agent' && lastMessage.content[0] && lastMessage.content[0].type === 'tool-result') { @@ -1937,7 +1896,6 @@ class Sync { } if (sessions.length > 0) { - // console.log('flushing activity updates ' + sessions.length); this.applySessions(sessions); // log.log(`🔄 Activity updates flushed - updated ${sessions.length} sessions`); } @@ -1946,17 +1904,13 @@ class Sync { private handleEphemeralUpdate = (update: unknown) => { const validatedUpdate = ApiEphemeralUpdateSchema.safeParse(update); if (!validatedUpdate.success) { - console.log('Invalid ephemeral update received:', validatedUpdate.error); console.error('Invalid ephemeral update received:', update); return; - } else { - // console.log('Ephemeral update received:', update); } const updateData = validatedUpdate.data; // Process activity updates through smart debounce accumulator if (updateData.type === 'activity') { - // console.log('adding activity update ' + updateData.id); this.activityAccumulator.addUpdate(updateData); } From b870e1de97b50ce8ecdb565036113652012a505f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 00:06:51 +0100 Subject: [PATCH 70/72] fix(new): align Gemini permission and model modes --- sources/app/(app)/new/index.tsx | 26 +++++++++++++------------- sources/sync/permissionTypes.ts | 5 ++++- sources/text/translations/ca.ts | 14 +++++++------- sources/text/translations/es.ts | 14 +++++++------- sources/text/translations/it.ts | 12 ++++++------ sources/text/translations/ja.ts | 12 ++++++------ sources/text/translations/pl.ts | 14 +++++++------- sources/text/translations/pt.ts | 14 +++++++------- sources/text/translations/zh-Hans.ts | 14 +++++++------- 9 files changed, 64 insertions(+), 61 deletions(-) diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx index d4c6364c1..031c7af34 100644 --- a/sources/app/(app)/new/index.tsx +++ b/sources/app/(app)/new/index.tsx @@ -2066,24 +2066,24 @@ function NewSessionWizard() { - {(agentType === 'codex' + {(agentType === 'codex' || agentType === 'gemini' ? [ - { value: 'default' as PermissionMode, label: t('agentInput.codexPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, - { value: 'read-only' as PermissionMode, label: t('agentInput.codexPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, - { value: 'safe-yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, - { value: 'yolo' as PermissionMode, label: t('agentInput.codexPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, + { value: 'default' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.default' : 'agentInput.geminiPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' }, + { value: 'read-only' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.readOnly' : 'agentInput.geminiPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' }, + { value: 'safe-yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.safeYolo' : 'agentInput.geminiPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' }, + { value: 'yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.yolo' : 'agentInput.geminiPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' }, ] : [ - { value: 'default' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.default' : 'agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, - { value: 'acceptEdits' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.acceptEdits' : 'agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, - { value: 'plan' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.plan' : 'agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, - { value: 'bypassPermissions' as PermissionMode, label: t(agentType === 'gemini' ? 'agentInput.geminiPermissionMode.bypassPermissions' : 'agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, + { value: 'default' as PermissionMode, label: t('agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' }, + { value: 'acceptEdits' as PermissionMode, label: t('agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' }, + { value: 'plan' as PermissionMode, label: t('agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' }, + { value: 'bypassPermissions' as PermissionMode, label: t('agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' }, ] ).map((option, index, array) => ( - `${percent}% restant`, diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts index 099f954ad..3272ca282 100644 --- a/sources/text/translations/es.ts +++ b/sources/text/translations/es.ts @@ -439,14 +439,14 @@ export const es: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODO DE PERMISOS', + title: 'MODO DE PERMISOS GEMINI', default: 'Por defecto', - acceptEdits: 'Aceptar ediciones', - plan: 'Modo de planificación', - bypassPermissions: 'Modo Yolo', - badgeAcceptAllEdits: 'Aceptar todas las ediciones', - badgeBypassAllPermissions: 'Omitir todos los permisos', - badgePlanMode: 'Modo de planificación', + readOnly: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts index 241ca8d9e..c1c31981e 100644 --- a/sources/text/translations/it.ts +++ b/sources/text/translations/it.ts @@ -470,12 +470,12 @@ export const it: TranslationStructure = { geminiPermissionMode: { title: 'MODALITÀ PERMESSI GEMINI', default: 'Predefinito', - acceptEdits: 'Accetta modifiche', - plan: 'Modalità piano', - bypassPermissions: 'Modalità YOLO', - badgeAcceptAllEdits: 'Accetta tutte le modifiche', - badgeBypassAllPermissions: 'Bypassa tutti i permessi', - badgePlanMode: 'Modalità piano', + readOnly: 'Modalità sola lettura', + safeYolo: 'YOLO sicuro', + yolo: 'YOLO', + badgeReadOnly: 'Modalità sola lettura', + badgeSafeYolo: 'YOLO sicuro', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts index ea316bdaf..34d6d8047 100644 --- a/sources/text/translations/ja.ts +++ b/sources/text/translations/ja.ts @@ -473,12 +473,12 @@ export const ja: TranslationStructure = { geminiPermissionMode: { title: 'GEMINI権限モード', default: 'デフォルト', - acceptEdits: '編集を許可', - plan: 'プランモード', - bypassPermissions: 'Yoloモード', - badgeAcceptAllEdits: 'すべての編集を許可', - badgeBypassAllPermissions: 'すべての権限をバイパス', - badgePlanMode: 'プランモード', + readOnly: '読み取り専用モード', + safeYolo: 'セーフYOLO', + yolo: 'YOLO', + badgeReadOnly: '読み取り専用モード', + badgeSafeYolo: 'セーフYOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `残り ${percent}%`, diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts index 08c4485ca..436e9a4ee 100644 --- a/sources/text/translations/pl.ts +++ b/sources/text/translations/pl.ts @@ -449,14 +449,14 @@ export const pl: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'TRYB UPRAWNIEŃ', + title: 'TRYB UPRAWNIEŃ GEMINI', default: 'Domyślny', - acceptEdits: 'Akceptuj edycje', - plan: 'Tryb planowania', - bypassPermissions: 'Tryb YOLO', - badgeAcceptAllEdits: 'Akceptuj wszystkie edycje', - badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia', - badgePlanMode: 'Tryb planowania', + readOnly: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`, diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts index 9da339b47..080db2565 100644 --- a/sources/text/translations/pt.ts +++ b/sources/text/translations/pt.ts @@ -439,14 +439,14 @@ export const pt: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: 'MODO DE PERMISSÃO', + title: 'MODO DE PERMISSÃO GEMINI', default: 'Padrão', - acceptEdits: 'Aceitar edições', - plan: 'Modo de planejamento', - bypassPermissions: 'Modo Yolo', - badgeAcceptAllEdits: 'Aceitar todas as edições', - badgeBypassAllPermissions: 'Ignorar todas as permissões', - badgePlanMode: 'Modo de planejamento', + readOnly: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `${percent}% restante`, diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts index 00552934b..fc33c96f9 100644 --- a/sources/text/translations/zh-Hans.ts +++ b/sources/text/translations/zh-Hans.ts @@ -441,14 +441,14 @@ export const zhHans: TranslationStructure = { gpt5High: 'GPT-5 High', }, geminiPermissionMode: { - title: '权限模式', + title: 'GEMINI 权限模式', default: '默认', - acceptEdits: '接受编辑', - plan: '计划模式', - bypassPermissions: 'Yolo 模式', - badgeAcceptAllEdits: '接受所有编辑', - badgeBypassAllPermissions: '绕过所有权限', - badgePlanMode: '计划模式', + readOnly: 'Read Only Mode', + safeYolo: 'Safe YOLO', + yolo: 'YOLO', + badgeReadOnly: 'Read Only Mode', + badgeSafeYolo: 'Safe YOLO', + badgeYolo: 'YOLO', }, context: { remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`, From 47ae80af7802b92156b852557ebb89f39e978a95 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 00:29:10 +0100 Subject: [PATCH 71/72] fix(app): avoid bundling vitest test file --- sources/{app/(app)/new/pick => }/profilePickerRouting.test.ts | 1 - 1 file changed, 1 deletion(-) rename sources/{app/(app)/new/pick => }/profilePickerRouting.test.ts (99%) diff --git a/sources/app/(app)/new/pick/profilePickerRouting.test.ts b/sources/profilePickerRouting.test.ts similarity index 99% rename from sources/app/(app)/new/pick/profilePickerRouting.test.ts rename to sources/profilePickerRouting.test.ts index f0a78b93e..0bb994c9e 100644 --- a/sources/app/(app)/new/pick/profilePickerRouting.test.ts +++ b/sources/profilePickerRouting.test.ts @@ -23,4 +23,3 @@ describe('ProfilePickerScreen routing', () => { expect(content).toContain('params.profileId'); }); }); - From 667388160a03a75fec0e26eb01a55e0464986f28 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 16 Jan 2026 00:36:25 +0100 Subject: [PATCH 72/72] fix(agent-input): remove duplicate machine/path panel --- sources/components/AgentInput.tsx | 81 ------------------------------- 1 file changed, 81 deletions(-) diff --git a/sources/components/AgentInput.tsx b/sources/components/AgentInput.tsx index 78b98737d..0341c483b 100644 --- a/sources/components/AgentInput.tsx +++ b/sources/components/AgentInput.tsx @@ -820,87 +820,6 @@ export const AgentInput = React.memo(React.forwardRef )} - {/* Box 1: Context Information (Machine + Path) - Only show if either exists */} - {(props.machineName !== undefined || props.currentPath) && ( - - {/* Machine chip */} - {props.machineName !== undefined && props.onMachineClick && ( - { - hapticsLight(); - props.onMachineClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName} - - - )} - - {/* Path chip */} - {props.currentPath && props.onPathClick && ( - { - hapticsLight(); - props.onPathClick?.(); - }} - hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }} - style={(p) => ({ - flexDirection: 'row', - alignItems: 'center', - borderRadius: Platform.select({ default: 16, android: 20 }), - paddingHorizontal: 10, - paddingVertical: 6, - height: 32, - opacity: p.pressed ? 0.7 : 1, - gap: 6, - })} - > - - - {props.currentPath} - - - )} - - )} - {/* Box 2: Action Area (Input + Send) */} {/* Input field */}