From 1aa43126d1b48e0738cdecc86ec6bc216facd046 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 21:50:18 +0300 Subject: [PATCH 1/7] feat(mobile): add Codex task controls Generated-By: PostHog Code Task-Id: 11daf8d3-7830-436a-ab8f-99123586070d --- apps/mobile/src/app/task/[id].tsx | 74 +++++++++++++++-- apps/mobile/src/app/task/index.tsx | 79 +++++++++++++++---- .../src/features/tasks/composer/Pill.tsx | 15 +++- .../tasks/composer/TaskChatComposer.tsx | 66 +++++++++++++--- .../src/features/tasks/stores/taskStore.ts | 7 +- .../core/src/sessions/executionModes.test.ts | 13 +++ packages/core/src/sessions/executionModes.ts | 7 ++ 7 files changed, 224 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/sessions/executionModes.test.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 27df1e9b4e..da8242e6a8 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -6,10 +6,13 @@ import { } from "@posthog/core/sessions/sessionActivity"; import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; import { + type Adapter, + DEFAULT_CODEX_MODEL, DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, getReasoningEffortOptions, + isSupportedReasoningEffort, type SupportedReasoningEffort, serializeCloudPrompt, type Task, @@ -167,11 +170,36 @@ export default function TaskDetailScreen() { const [initialComposerMessage, setInitialComposerMessage] = useState< string | undefined >(); + const composerAdapter: Adapter = + task?.latest_run?.runtime_adapter && + composerConfig?.adapter !== task.latest_run.runtime_adapter + ? task.latest_run.runtime_adapter + : (composerConfig?.adapter ?? + task?.latest_run?.runtime_adapter ?? + "claude"); + const composerConfigMatchesAdapter = + composerConfig?.adapter === undefined + ? composerAdapter === "claude" + : composerConfig.adapter === composerAdapter; const composerMode: ExecutionMode = - composerConfig?.mode ?? DEFAULT_CLAUDE_EXECUTION_MODE; - const composerModel = composerConfig?.model ?? DEFAULT_GATEWAY_MODEL; + (composerConfigMatchesAdapter ? composerConfig?.mode : undefined) ?? + DEFAULT_CLAUDE_EXECUTION_MODE; + const composerModel = + (composerConfigMatchesAdapter ? composerConfig?.model : undefined) ?? + task?.latest_run?.model ?? + (composerAdapter === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL); + const requestedComposerReasoning = composerConfigMatchesAdapter + ? composerConfig?.reasoning + : undefined; const composerReasoning: SupportedReasoningEffort = - composerConfig?.reasoning ?? DEFAULT_REASONING_EFFORT; + requestedComposerReasoning && + isSupportedReasoningEffort( + composerAdapter, + composerModel, + requestedComposerReasoning, + ) + ? requestedComposerReasoning + : DEFAULT_REASONING_EFFORT; const messagingMode = useMessagingMode(taskId); const queuedCount = useQueuedCount(taskId); @@ -312,14 +340,14 @@ export default function TaskDetailScreen() { : text; const supportsReasoning = - getReasoningEffortOptions("claude", composerModel) !== null; + getReasoningEffortOptions(composerAdapter, composerModel) !== null; const updatedTask = await getPostHogApiClient().runTaskInCloud( taskId, undefined, { resumeFromRunId: task.latest_run?.id, pendingUserMessage, - adapter: "claude", + adapter: composerAdapter, model: composerModel, reasoningLevel: supportsReasoning ? composerReasoning : undefined, initialPermissionMode: composerMode, @@ -346,6 +374,7 @@ export default function TaskDetailScreen() { connectToTask, updateTaskInCache, composerMode, + composerAdapter, composerModel, composerReasoning, ], @@ -500,6 +529,19 @@ export default function TaskDetailScreen() { [taskId, setComposerConfig, setConfigOption], ); + const handleAdapterChange = useCallback( + (value: Adapter) => { + if (!taskId) return; + setComposerConfig(taskId, { + adapter: value, + mode: DEFAULT_CLAUDE_EXECUTION_MODE, + model: value === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL, + reasoning: DEFAULT_REASONING_EFFORT, + }); + }, + [taskId, setComposerConfig], + ); + const handleModelChange = useCallback( (value: string) => { if (!taskId) return; @@ -568,6 +610,13 @@ export default function TaskDetailScreen() { undefined, { resumeFromRunId: task.latest_run?.id, + adapter: composerAdapter, + model: composerModel, + reasoningLevel: + getReasoningEffortOptions(composerAdapter, composerModel) !== null + ? composerReasoning + : undefined, + initialPermissionMode: composerMode, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, ); @@ -584,7 +633,17 @@ export default function TaskDetailScreen() { "Could not restart the task. Please try again.", ); } - }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); + }, [ + taskId, + task, + disconnectFromTask, + connectToTask, + updateTaskInCache, + composerAdapter, + composerModel, + composerReasoning, + composerMode, + ]); // Clear retrying once the agent finishes a turn or the run terminates. useEffect(() => { @@ -778,6 +837,8 @@ export default function TaskDetailScreen() { /> ) : null} ("claude"); const { configOptions, hasLiveConfig, isConfigReady } = - useCloudTaskConfigOptions("claude"); + useCloudTaskConfigOptions(adapter); + const executionModes = getMobileExecutionModes( + getAvailableModesForAdapter(adapter), + ); const modelConfigOption = getModelConfigOption(configOptions); const mobileModelOptions = getComposerModelOptions(modelConfigOption); const { @@ -197,7 +201,9 @@ export default function NewTaskScreen() { const prefs = usePreferencesStore.getState(); if (prefs.defaultInitialTaskMode === "last_used") { const last = prefs.lastNewTaskMode; - const isValidMode = EXECUTION_MODES.some((mode) => mode.id === last); + const isValidMode = getMobileExecutionModes( + getAvailableModesForAdapter("claude"), + ).some((mode) => mode.id === last); if (isValidMode) return last as ExecutionMode; } return DEFAULT_CLAUDE_EXECUTION_MODE; @@ -217,16 +223,17 @@ export default function NewTaskScreen() { useEffect(() => { if (!hasLiveConfig) return; const next = resolveCloudComposerModelChange({ - adapter: "claude", + adapter, modelOption: modelConfigOption, requestedModel: model, reasoning, }); if (next.model !== model) setModel(next.model); if (next.reasoning !== reasoning) setReasoning(next.reasoning); - }, [hasLiveConfig, model, modelConfigOption, reasoning]); + }, [adapter, hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); + const [adapterSheetOpen, setAdapterSheetOpen] = useState(false); const [modeSheetOpen, setModeSheetOpen] = useState(false); const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); @@ -363,7 +370,7 @@ export default function NewTaskScreen() { // user picked here, so the task detail screen reflects them and every // subsequent run (resume-after-terminal) reuses the selected mode rather // than falling back to the default plan mode. - setComposerConfig(task.id, { mode, model, reasoning }); + setComposerConfig(task.id, { adapter, mode, model, reasoning }); const pendingUserMessage = attachments.length > 0 @@ -373,11 +380,11 @@ export default function NewTaskScreen() { : trimmedPrompt; const supportsReasoning = - getReasoningEffortOptions("claude", model) !== null; + getReasoningEffortOptions(adapter, model) !== null; await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - adapter: "claude", + adapter, model, reasoningLevel: supportsReasoning ? reasoning : undefined, initialPermissionMode: mode, @@ -401,6 +408,7 @@ export default function NewTaskScreen() { } }, [ attachments, + adapter, creating, mode, model, @@ -419,7 +427,7 @@ export default function NewTaskScreen() { hasContent && isRepositorySelectionComplete(selection) && !creating; - const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; const showReasoningPill = reasoningOptions.length > 0; // Best-effort prewarm; failures are swallowed. `selection.integrationId` is @@ -429,7 +437,7 @@ export default function NewTaskScreen() { repository: selection.repository, githubIntegrationId: selection.integrationId, composerIsEmpty: !hasContent || !isConfigReady, - runtimeAdapter: "claude", + runtimeAdapter: adapter, model, reasoningEffort: showReasoningPill ? reasoning : null, }); @@ -636,6 +644,13 @@ export default function NewTaskScreen() { paddingRight: 16, }} > + } + label={adapter === "codex" ? "Codex" : "Claude"} + accent={adapter === "codex"} + onPress={() => setAdapterSheetOpen(true)} + /> + option.id === mode) + executionModes.find((option) => option.id === mode) ?.name ?? mode } accent={mode === "plan"} @@ -749,6 +764,38 @@ export default function NewTaskScreen() { + { + const nextAdapter = value as Adapter; + setAdapter(nextAdapter); + setMode(DEFAULT_CLAUDE_EXECUTION_MODE); + setModel( + nextAdapter === "codex" + ? DEFAULT_CODEX_MODEL + : DEFAULT_GATEWAY_MODEL, + ); + setReasoning(DEFAULT_REASONING_EFFORT); + }} + onClose={() => setAdapterSheetOpen(false)} + options={[ + { + value: "claude", + label: "Claude Code", + description: "Anthropic's coding agent", + icon: , + }, + { + value: "codex", + label: "Codex", + description: "OpenAI's coding agent", + icon: , + }, + ]} + /> + setModeSheetOpen(false)} - options={EXECUTION_MODES.map((executionMode) => ({ + options={executionModes.map((executionMode) => ({ value: executionMode.id, label: executionMode.name, description: executionMode.description, @@ -779,7 +826,7 @@ export default function NewTaskScreen() { value={model} onChange={(value) => { const next = resolveCloudComposerModelChange({ - adapter: "claude", + adapter, modelOption: modelConfigOption, requestedModel: value, reasoning, diff --git a/apps/mobile/src/features/tasks/composer/Pill.tsx b/apps/mobile/src/features/tasks/composer/Pill.tsx index 5860c05d0c..94446f3770 100644 --- a/apps/mobile/src/features/tasks/composer/Pill.tsx +++ b/apps/mobile/src/features/tasks/composer/Pill.tsx @@ -11,14 +11,23 @@ interface PillProps { placeholder?: boolean; /** Tone the label in accent (used for Plan Mode in the desktop). */ accent?: boolean; - onPress: () => void; + onPress?: () => void; + disabled?: boolean; } -export function Pill({ icon, label, placeholder, accent, onPress }: PillProps) { +export function Pill({ + icon, + label, + placeholder, + accent, + onPress, + disabled = false, +}: PillProps) { const themeColors = useThemeColors(); return ( {icon ? {icon} : null} @@ -34,7 +43,7 @@ export function Pill({ icon, label, placeholder, accent, onPress }: PillProps) { > {label} - + {disabled ? null : } ); } diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 433d785276..ee533a19c4 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -1,10 +1,11 @@ import { Text } from "@components/text"; import { DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableModes, + getAvailableModesForAdapter, } from "@posthog/core/sessions/executionModes"; import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; import { + type Adapter, DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, @@ -56,10 +57,10 @@ import { pickPhotoFromLibrary, } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; -import { getMobileExecutionModes } from "./options"; import { getComposerModelOptions, getConfigOptionLabel, + getMobileExecutionModes, getModelConfigOption, resolveComposerPrimaryAction, } from "./options"; @@ -67,8 +68,6 @@ import { Pill } from "./Pill"; import { SelectSheet } from "./SelectSheet"; const log = logger.scope("task-chat-composer"); -const EXECUTION_MODES = getMobileExecutionModes(getAvailableModes()); - interface TaskChatComposerProps { onSend: (message: string, attachments: PendingAttachment[]) => void; onStop?: () => void; @@ -77,9 +76,12 @@ interface TaskChatComposerProps { initialMessage?: string; isUserTurn?: boolean; /** Current pill values (persisted per-task by the caller). */ + adapter: Adapter; mode: ExecutionMode; model: string; reasoning: SupportedReasoningEffort; + onAdapterChange: (adapter: Adapter) => void; + canChangeAdapter?: boolean; onModeChange: (mode: ExecutionMode) => void; onModelChange: (model: string) => void; onReasoningChange: (reasoning: SupportedReasoningEffort) => void; @@ -173,9 +175,12 @@ export function TaskChatComposer({ placeholder = "Ask a question", initialMessage, isUserTurn = false, + adapter, mode, model, reasoning, + onAdapterChange, + canChangeAdapter = true, onModeChange, onModelChange, onReasoningChange, @@ -187,7 +192,10 @@ export function TaskChatComposer({ onCancelEdit, }: TaskChatComposerProps) { const themeColors = useThemeColors(); - const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions(adapter); + const executionModes = getMobileExecutionModes( + getAvailableModesForAdapter(adapter), + ); const modelConfigOption = getModelConfigOption(configOptions); const mobileModelOptions = getComposerModelOptions(modelConfigOption); const [message, setMessage] = useState(() => initialMessage ?? ""); @@ -208,7 +216,7 @@ export function TaskChatComposer({ useEffect(() => { if (!hasLiveConfig) return; const next = resolveCloudComposerModelChange({ - adapter: "claude", + adapter, modelOption: modelConfigOption, requestedModel: model, reasoning, @@ -216,6 +224,7 @@ export function TaskChatComposer({ if (next.model !== model) onModelChange(next.model); if (next.reasoning !== reasoning) onReasoningChange(next.reasoning); }, [ + adapter, hasLiveConfig, model, modelConfigOption, @@ -235,10 +244,11 @@ export function TaskChatComposer({ const isTranscribing = status === "transcribing"; const [modeSheetOpen, setModeSheetOpen] = useState(false); + const [adapterSheetOpen, setAdapterSheetOpen] = useState(false); const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); - const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; const showReasoningPill = reasoningOptions.length > 0; const hasContent = message.trim().length > 0 || attachments.length > 0; @@ -386,6 +396,18 @@ export function TaskChatComposer({ paddingRight: 4, }} > + } + label={adapter === "codex" ? "Codex" : "Claude"} + accent={adapter === "codex"} + onPress={ + canChangeAdapter + ? () => setAdapterSheetOpen(true) + : undefined + } + disabled={!canChangeAdapter} + /> + option.id === mode) - ?.name ?? mode + executionModes.find((option) => option.id === mode)?.name ?? + mode } accent={mode === "plan"} onPress={() => setModeSheetOpen(true)} @@ -476,13 +498,35 @@ export function TaskChatComposer({ + onAdapterChange(value as Adapter)} + onClose={() => setAdapterSheetOpen(false)} + options={[ + { + value: "claude", + label: "Claude Code", + description: "Anthropic's coding agent", + icon: , + }, + { + value: "codex", + label: "Codex", + description: "OpenAI's coding agent", + icon: , + }, + ]} + /> + onModeChange(v as ExecutionMode)} onClose={() => setModeSheetOpen(false)} - options={EXECUTION_MODES.map((m) => ({ + options={executionModes.map((m) => ({ value: m.id, label: m.name, description: m.description, @@ -500,7 +544,7 @@ export function TaskChatComposer({ value={model} onChange={(v) => { const next = resolveCloudComposerModelChange({ - adapter: "claude", + adapter, modelOption: modelConfigOption, requestedModel: v, reasoning, diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 39276a7eeb..f7589e0333 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,5 +1,9 @@ import type { TaskActivitySortMode } from "@posthog/core/tasks/taskActivity"; -import type { ExecutionMode, SupportedReasoningEffort } from "@posthog/shared"; +import type { + Adapter, + ExecutionMode, + SupportedReasoningEffort, +} from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -16,6 +20,7 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { /** Per-task chat composer pill values. Persisted so reopening a task keeps * the mode/model/reasoning the user last selected for it. */ export interface TaskComposerConfig { + adapter?: Adapter; mode?: ExecutionMode; model?: string; reasoning?: SupportedReasoningEffort; diff --git a/packages/core/src/sessions/executionModes.test.ts b/packages/core/src/sessions/executionModes.test.ts new file mode 100644 index 0000000000..88bbfe2b64 --- /dev/null +++ b/packages/core/src/sessions/executionModes.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { getAvailableModesForAdapter } from "./executionModes"; + +describe("getAvailableModesForAdapter", () => { + it.each([ + ["claude", ["default", "acceptEdits", "plan", "bypassPermissions", "auto"]], + ["codex", ["plan", "read-only", "auto", "full-access"]], + ] as const)("returns %s execution modes", (adapter, expected) => { + expect(getAvailableModesForAdapter(adapter).map((mode) => mode.id)).toEqual( + expected, + ); + }); +}); diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 2ccbadf354..8d4f7e920f 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -7,6 +7,7 @@ export interface ModeInfo { } export const DEFAULT_CLAUDE_EXECUTION_MODE: ExecutionMode = "plan"; +export const DEFAULT_CODEX_EXECUTION_MODE: ExecutionMode = "plan"; const availableModes: ModeInfo[] = [ { @@ -46,3 +47,9 @@ export function getAvailableModes(): ModeInfo[] { export function getAvailableCodexModes(): ModeInfo[] { return [...CODEX_MODE_PRESETS]; } + +export function getAvailableModesForAdapter( + adapter: "claude" | "codex", +): ModeInfo[] { + return adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); +} From b08731f460c9b933a5417a0ced31bb32006f6846 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 21 Jul 2026 21:59:49 +0300 Subject: [PATCH 2/7] feat(mobile): align task composer with desktop Generated-By: PostHog Code Task-Id: 11daf8d3-7830-436a-ab8f-99123586070d --- apps/mobile/src/app/task/index.tsx | 529 +++++++++--------- .../tasks/composer/TaskChatComposer.tsx | 86 +-- 2 files changed, 308 insertions(+), 307 deletions(-) diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index a24b14c235..a69fa24aa4 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -21,6 +21,7 @@ import { ArrowUp, BrainIcon, CaretDown, + Cpu, GithubLogo, MicrophoneIcon, PaperclipIcon, @@ -91,6 +92,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); +const SWITCH_ADAPTER_VALUE = "__switch_adapter__"; const SUGGESTIONS = [ "Create or update my CLAUDE.md file", "Search for a TODO comment and fix it", @@ -233,7 +235,6 @@ export default function NewTaskScreen() { }, [adapter, hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); - const [adapterSheetOpen, setAdapterSheetOpen] = useState(false); const [modeSheetOpen, setModeSheetOpen] = useState(false); const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); @@ -508,294 +509,267 @@ export default function NewTaskScreen() { - - {repoSheetOpen ? null : prompt.trim().length === 0 ? ( - - - Suggestions - - - {SUGGESTIONS.map((suggestion) => ( - setPrompt(suggestion)} - className="rounded-2xl border border-gray-6 bg-card px-4 py-3 active:bg-gray-2" - > - - {suggestion} - - - ))} - - - ) : null} - - {/* Inline repo picker: pops up directly above the pill when - open, replacing the suggestions area. Rendered inline (not - a Modal) so it feels like a dropdown anchored to the pill - rather than a slide-in sheet. */} - - - setSelection(toRepositorySelection(option)) - } - onClose={() => setRepoSheetOpen(false)} - /> - - - - - {repositoryWarning ? ( - - ) : null} - - - setRepoSheetOpen((prev) => !prev)} - className={`flex-row items-center gap-2 rounded-full border py-1.5 pr-2.5 pl-2 active:bg-gray-2 ${ - repoSheetOpen - ? "border-accent-7 bg-accent-3" - : "border-gray-6 bg-card" - }`} - > - + + + + setSelection(toRepositorySelection(option)) } - weight={selectedRepositoryOption ? "fill" : "regular"} + onClose={() => setRepoSheetOpen(false)} /> - - {repositoryLabel} - - + + {repositoryWarning ? ( + - - + ) : null} - - - - - + setAttachmentSheetOpen(true)} - accessibilityLabel="Add attachment" - accessibilityRole="button" - className="h-9 w-9 items-center justify-center active:opacity-60" + onPress={() => setRepoSheetOpen((prev) => !prev)} + className={`flex-row items-center gap-2 rounded-md border py-1.5 pr-2.5 pl-2 active:bg-gray-2 ${ + repoSheetOpen + ? "border-accent-7 bg-accent-3" + : "border-gray-6 bg-card" + }`} > - 0 - ? themeColors.accent[11] + selectedRepositoryOption + ? themeColors.gray[12] : themeColors.gray[10] } - weight={attachments.length > 0 ? "fill" : "regular"} + weight={selectedRepositoryOption ? "fill" : "regular"} + /> + + {repositoryLabel} + + + - - - } - label={adapter === "codex" ? "Codex" : "Claude"} - accent={adapter === "codex"} - onPress={() => setAdapterSheetOpen(true)} - /> + + + - + setAttachmentSheetOpen(true)} + accessibilityLabel="Add attachment" + accessibilityRole="button" + className="h-9 w-9 items-center justify-center active:opacity-60" + > + 0 ? themeColors.accent[11] - : themeColors.gray[11], - )} - label={ - executionModes.find((option) => option.id === mode) - ?.name ?? mode - } - accent={mode === "plan"} - onPress={() => setModeSheetOpen(true)} - /> - - } - label={ - getConfigOptionLabel( - modelConfigOption.options, - model, - ) ?? model + : themeColors.gray[10] } - onPress={() => setModelSheetOpen(true)} + weight={attachments.length > 0 ? "fill" : "regular"} /> + + + + + option.id === mode) + ?.name ?? mode + } + accent={mode === "plan"} + onPress={() => setModeSheetOpen(true)} + /> - {showReasoningPill ? ( + adapter === "codex" ? ( + + ) : ( + + ) } label={ - reasoningOptions.find( - (option) => option.value === reasoning, - )?.name ?? reasoning + getConfigOptionLabel( + modelConfigOption.options, + model, + ) ?? model } - onPress={() => setReasoningSheetOpen(true)} + onPress={() => setModelSheetOpen(true)} /> - ) : null} - - {/* Right-edge fade hints that more pills exist when the row + + {showReasoningPill ? ( + + } + label={ + reasoningOptions.find( + (option) => option.value === reasoning, + )?.name ?? reasoning + } + onPress={() => setReasoningSheetOpen(true)} + /> + ) : null} + + {/* Right-edge fade hints that more pills exist when the row overflows. Non-interactive so taps fall through. */} - + + + + + {creating || isTranscribing ? ( + + ) : isRecording ? ( + + ) : hasContent ? ( + + ) : ( + + )} + + - - {creating || isTranscribing ? ( - - ) : isRecording ? ( - - ) : hasContent ? ( - - ) : ( - - )} - - + + Suggestions + + + {SUGGESTIONS.map((suggestion) => ( + setPrompt(suggestion)} + className="rounded-lg border border-gray-5 bg-gray-2 px-3 py-2.5 active:bg-gray-3" + > + + {suggestion} + + + ))} + + + ) : null} - { - const nextAdapter = value as Adapter; - setAdapter(nextAdapter); - setMode(DEFAULT_CLAUDE_EXECUTION_MODE); - setModel( - nextAdapter === "codex" - ? DEFAULT_CODEX_MODEL - : DEFAULT_GATEWAY_MODEL, - ); - setReasoning(DEFAULT_REASONING_EFFORT); - }} - onClose={() => setAdapterSheetOpen(false)} - options={[ - { - value: "claude", - label: "Claude Code", - description: "Anthropic's coding agent", - icon: , - }, - { - value: "codex", - label: "Codex", - description: "OpenAI's coding agent", - icon: , - }, - ]} - /> - { + if (value === SWITCH_ADAPTER_VALUE) { + const nextAdapter: Adapter = + adapter === "claude" ? "codex" : "claude"; + setAdapter(nextAdapter); + setMode(DEFAULT_CLAUDE_EXECUTION_MODE); + setModel( + nextAdapter === "codex" + ? DEFAULT_CODEX_MODEL + : DEFAULT_GATEWAY_MODEL, + ); + setReasoning(DEFAULT_REASONING_EFFORT); + return; + } const next = resolveCloudComposerModelChange({ adapter, modelOption: modelConfigOption, @@ -835,13 +822,27 @@ export default function NewTaskScreen() { setReasoning(next.reasoning); }} onClose={() => setModelSheetOpen(false)} - options={mobileModelOptions.map((modelOption) => ({ - value: modelOption.value, - label: modelOption.label, - description: modelOption.description, - disabled: modelOption.disabled, - icon: , - }))} + options={[ + ...mobileModelOptions.map((modelOption) => ({ + value: modelOption.value, + label: modelOption.label, + description: modelOption.description, + disabled: modelOption.disabled, + icon: + adapter === "codex" ? ( + + ) : ( + + ), + })), + { + value: SWITCH_ADAPTER_VALUE, + label: `Switch to ${adapter === "claude" ? "Codex" : "Claude Code"}`, + description: "Change coding agent", + disabled: false, + icon: , + }, + ]} /> void; onStop?: () => void; @@ -244,7 +246,6 @@ export function TaskChatComposer({ const isTranscribing = status === "transcribing"; const [modeSheetOpen, setModeSheetOpen] = useState(false); - const [adapterSheetOpen, setAdapterSheetOpen] = useState(false); const [modelSheetOpen, setModelSheetOpen] = useState(false); const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); @@ -396,18 +397,6 @@ export function TaskChatComposer({ paddingRight: 4, }} > - } - label={adapter === "codex" ? "Codex" : "Claude"} - accent={adapter === "codex"} - onPress={ - canChangeAdapter - ? () => setAdapterSheetOpen(true) - : undefined - } - disabled={!canChangeAdapter} - /> - } + icon={ + adapter === "codex" ? ( + + ) : ( + + ) + } label={ getConfigOptionLabel(modelConfigOption.options, model) ?? model @@ -498,28 +493,6 @@ export function TaskChatComposer({ - onAdapterChange(value as Adapter)} - onClose={() => setAdapterSheetOpen(false)} - options={[ - { - value: "claude", - label: "Claude Code", - description: "Anthropic's coding agent", - icon: , - }, - { - value: "codex", - label: "Codex", - description: "OpenAI's coding agent", - icon: , - }, - ]} - /> - { + if (v === SWITCH_ADAPTER_VALUE) { + onAdapterChange(adapter === "claude" ? "codex" : "claude"); + return; + } const next = resolveCloudComposerModelChange({ adapter, modelOption: modelConfigOption, @@ -555,13 +532,36 @@ export function TaskChatComposer({ } }} onClose={() => setModelSheetOpen(false)} - options={mobileModelOptions.map((m) => ({ - value: m.value, - label: m.label, - description: m.description, - disabled: m.disabled, - icon: , - }))} + options={[ + ...mobileModelOptions.map((m) => ({ + value: m.value, + label: m.label, + description: m.description, + disabled: m.disabled, + icon: + adapter === "codex" ? ( + + ) : ( + + ), + })), + ...(canChangeAdapter + ? [ + { + value: SWITCH_ADAPTER_VALUE, + label: `Switch to ${adapter === "claude" ? "Codex" : "Claude Code"}`, + description: "Change coding agent", + disabled: false, + icon: + adapter === "claude" ? ( + + ) : ( + + ), + }, + ] + : []), + ]} /> Date: Tue, 21 Jul 2026 22:06:51 +0300 Subject: [PATCH 3/7] feat(mobile): align task detail with desktop Generated-By: PostHog Code Task-Id: 11daf8d3-7830-436a-ab8f-99123586070d --- apps/mobile/src/app/task/[id].tsx | 1 + .../tasks/components/FloatingTaskHeader.tsx | 34 +----- .../tasks/composer/TaskChatComposer.tsx | 107 ++++-------------- 3 files changed, 29 insertions(+), 113 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index da8242e6a8..323d6fa509 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -837,6 +837,7 @@ export default function TaskDetailScreen() { /> ) : null} - - - + (null); - - useEffect(() => { - if (active) { - opacity.setValue(0); - animRef.current = Animated.loop( - Animated.sequence([ - Animated.timing(opacity, { - toValue: 1, - duration: 1500, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - Animated.timing(opacity, { - toValue: 0, - duration: 1500, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - ]), - ); - animRef.current.start(); - } else { - animRef.current?.stop(); - animRef.current = null; - opacity.setValue(0); - } - return () => { - animRef.current?.stop(); - }; - }, [active, opacity]); - - if (!active) return null; - - return ( - - ); -} - export function TaskChatComposer({ onSend, onStop, @@ -321,10 +259,9 @@ export function TaskChatComposer({ return ( <> - - - - + + + {editing ? ( @@ -350,7 +287,7 @@ export function TaskChatComposer({ /> - - ) : ( - - ) - } - label={messagingModeLabel} - accent={isSteer} - onPress={handleToggleMessagingMode} - /> - setReasoningSheetOpen(true)} /> ) : null} + + + ) : ( + + ) + } + label={messagingModeLabel} + accent={isSteer} + onPress={handleToggleMessagingMode} + /> Date: Thu, 23 Jul 2026 16:24:01 +0300 Subject: [PATCH 4/7] fix(mobile): reuse desktop adapter defaults Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/app/task/[id].tsx | 8 ++++++-- apps/mobile/src/app/task/index.tsx | 3 ++- .../features/tasks/composer/TaskChatComposer.tsx | 5 ++++- packages/core/src/sessions/cloudSessionConfig.ts | 11 ++++------- packages/core/src/sessions/executionModes.test.ts | 14 +++++++++++++- packages/core/src/sessions/executionModes.ts | 7 ++++++- 6 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 323d6fa509..4219027922 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,5 +1,8 @@ import { Text } from "@components/text"; -import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getDefaultExecutionModeForAdapter, +} from "@posthog/core/sessions/executionModes"; import { countUserMessages, getSessionActivityPhase, @@ -172,6 +175,7 @@ export default function TaskDetailScreen() { >(); const composerAdapter: Adapter = task?.latest_run?.runtime_adapter && + !session?.terminalStatus && composerConfig?.adapter !== task.latest_run.runtime_adapter ? task.latest_run.runtime_adapter : (composerConfig?.adapter ?? @@ -534,7 +538,7 @@ export default function TaskDetailScreen() { if (!taskId) return; setComposerConfig(taskId, { adapter: value, - mode: DEFAULT_CLAUDE_EXECUTION_MODE, + mode: getDefaultExecutionModeForAdapter(value), model: value === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL, reasoning: DEFAULT_REASONING_EFFORT, }); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index a69fa24aa4..7257cf5240 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -2,6 +2,7 @@ import { Text } from "@components/text"; import { DEFAULT_CLAUDE_EXECUTION_MODE, getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, } from "@posthog/core/sessions/executionModes"; import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; import { @@ -803,7 +804,7 @@ export default function NewTaskScreen() { const nextAdapter: Adapter = adapter === "claude" ? "codex" : "claude"; setAdapter(nextAdapter); - setMode(DEFAULT_CLAUDE_EXECUTION_MODE); + setMode(getDefaultExecutionModeForAdapter(nextAdapter)); setModel( nextAdapter === "codex" ? DEFAULT_CODEX_MODEL diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 8c58071170..82da4eefe4 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -2,6 +2,7 @@ import { Text } from "@components/text"; import { DEFAULT_CLAUDE_EXECUTION_MODE, getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, } from "@posthog/core/sessions/executionModes"; import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; import { @@ -454,7 +455,9 @@ export function TaskChatComposer({ value={model} onChange={(v) => { if (v === SWITCH_ADAPTER_VALUE) { - onAdapterChange(adapter === "claude" ? "codex" : "claude"); + const nextAdapter = adapter === "claude" ? "codex" : "claude"; + onAdapterChange(nextAdapter); + onModeChange(getDefaultExecutionModeForAdapter(nextAdapter)); return; } const next = resolveCloudComposerModelChange({ diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index b6a9e4f8f4..1a7799b3dc 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -1,9 +1,8 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { Adapter, StoredLogEntry } from "@posthog/shared"; import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableCodexModes, - getAvailableModes, + getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, } from "./executionModes"; /** @@ -56,10 +55,8 @@ export function buildCloudDefaultConfigOptions( adapter: Adapter = "claude", extra: SessionConfigOption[] = [], ): SessionConfigOption[] { - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const fallbackMode = - adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; + const modes = getAvailableModesForAdapter(adapter); + const fallbackMode = getDefaultExecutionModeForAdapter(adapter); const currentMode = typeof initialMode === "string" && modes.some((mode) => mode.id === initialMode) diff --git a/packages/core/src/sessions/executionModes.test.ts b/packages/core/src/sessions/executionModes.test.ts index 88bbfe2b64..9246e9ce67 100644 --- a/packages/core/src/sessions/executionModes.test.ts +++ b/packages/core/src/sessions/executionModes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getAvailableModesForAdapter } from "./executionModes"; +import { + getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, +} from "./executionModes"; describe("getAvailableModesForAdapter", () => { it.each([ @@ -11,3 +14,12 @@ describe("getAvailableModesForAdapter", () => { ); }); }); + +describe("getDefaultExecutionModeForAdapter", () => { + it.each([ + ["claude", "plan"], + ["codex", "auto"], + ] as const)("returns the desktop default for %s", (adapter, expected) => { + expect(getDefaultExecutionModeForAdapter(adapter)).toBe(expected); + }); +}); diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 8d4f7e920f..a415b2716c 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -7,7 +7,6 @@ export interface ModeInfo { } export const DEFAULT_CLAUDE_EXECUTION_MODE: ExecutionMode = "plan"; -export const DEFAULT_CODEX_EXECUTION_MODE: ExecutionMode = "plan"; const availableModes: ModeInfo[] = [ { @@ -53,3 +52,9 @@ export function getAvailableModesForAdapter( ): ModeInfo[] { return adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); } + +export function getDefaultExecutionModeForAdapter( + adapter: "claude" | "codex", +): ExecutionMode { + return adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; +} From c4b8e2f662c8bec9a8d452247c572a4e4c7ba89f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Thu, 23 Jul 2026 16:45:59 +0300 Subject: [PATCH 5/7] fix(mobile): use canonical Codex config Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/app/task/[id].tsx | 16 +++++++++------- .../core/src/sessions/cloudSessionConfig.test.ts | 8 ++++++++ packages/core/src/sessions/cloudSessionConfig.ts | 6 +++++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 4219027922..a51307acd6 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,8 +1,6 @@ import { Text } from "@components/text"; -import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getDefaultExecutionModeForAdapter, -} from "@posthog/core/sessions/executionModes"; +import { getCloudReasoningConfigOptionId } from "@posthog/core/sessions/cloudSessionConfig"; +import { getDefaultExecutionModeForAdapter } from "@posthog/core/sessions/executionModes"; import { countUserMessages, getSessionActivityPhase, @@ -187,7 +185,7 @@ export default function TaskDetailScreen() { : composerConfig.adapter === composerAdapter; const composerMode: ExecutionMode = (composerConfigMatchesAdapter ? composerConfig?.mode : undefined) ?? - DEFAULT_CLAUDE_EXECUTION_MODE; + getDefaultExecutionModeForAdapter(composerAdapter); const composerModel = (composerConfigMatchesAdapter ? composerConfig?.model : undefined) ?? task?.latest_run?.model ?? @@ -559,10 +557,14 @@ export default function TaskDetailScreen() { (value: SupportedReasoningEffort) => { if (!taskId) return; setComposerConfig(taskId, { reasoning: value }); - setConfigOption(taskId, "effort", value).catch(() => {}); + setConfigOption( + taskId, + getCloudReasoningConfigOptionId(composerAdapter), + value, + ).catch(() => {}); usePreferencesStore.getState().setLastUsedReasoningEffort(value); }, - [taskId, setComposerConfig, setConfigOption], + [taskId, composerAdapter, setComposerConfig, setConfigOption], ); const handleStop = useCallback(() => { diff --git a/packages/core/src/sessions/cloudSessionConfig.test.ts b/packages/core/src/sessions/cloudSessionConfig.test.ts index 5a6499f5e4..0b97ebbfcb 100644 --- a/packages/core/src/sessions/cloudSessionConfig.test.ts +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -4,6 +4,7 @@ import { addMissingCloudRuntimeConfigOptions, buildCloudDefaultConfigOptions, extractLatestConfigOptionsFromEntries, + getCloudReasoningConfigOptionId, } from "./cloudSessionConfig"; function configUpdateEntry( @@ -19,6 +20,13 @@ function configUpdateEntry( } as unknown as StoredLogEntry; } +it.each([ + ["claude", "effort"], + ["codex", "reasoning_effort"], +] as const)("uses the %s reasoning config id", (adapter, expected) => { + expect(getCloudReasoningConfigOptionId(adapter)).toBe(expected); +}); + describe("extractLatestConfigOptionsFromEntries", () => { it("returns undefined when no config_option_update entries exist", () => { expect(extractLatestConfigOptionsFromEntries([])).toBeUndefined(); diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index 1a7799b3dc..c340613ac4 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -5,6 +5,10 @@ import { getDefaultExecutionModeForAdapter, } from "./executionModes"; +export function getCloudReasoningConfigOptionId(adapter: Adapter): string { + return adapter === "codex" ? "reasoning_effort" : "effort"; +} + /** * Pure derivations of cloud session config options. No store or host access — * just shaping the config-option list the mode switcher renders. @@ -101,7 +105,7 @@ export function addMissingCloudRuntimeConfigOptions( if (initialReasoningEffort && !categories.has("thought_level")) { extras.push({ - id: adapter === "codex" ? "reasoning_effort" : "effort", + id: getCloudReasoningConfigOptionId(adapter), name: adapter === "codex" ? "Reasoning" : "Effort", type: "select", currentValue: initialReasoningEffort, From b968bf5fb9f838d90b4eea7a095697d919e11191 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Thu, 23 Jul 2026 19:31:44 +0300 Subject: [PATCH 6/7] refactor(mobile): share agent configuration controls Keep task creation, in-task changes, and terminal retries on the same adapter configuration policy. Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/app/task/[id].tsx | 27 +-- apps/mobile/src/app/task/index.tsx | 205 ++-------------- .../composer/AgentConfigControls.test.tsx | 97 ++++++++ .../tasks/composer/AgentConfigControls.tsx | 225 ++++++++++++++++++ .../tasks/composer/TaskChatComposer.tsx | 192 ++------------- .../tasks/utils/cloudTaskRunConfig.test.ts | 31 +++ .../tasks/utils/cloudTaskRunConfig.ts | 28 +++ 7 files changed, 432 insertions(+), 373 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx create mode 100644 apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx create mode 100644 apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts create mode 100644 apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index a51307acd6..8eb4d3a9a7 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -12,7 +12,6 @@ import { DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, - getReasoningEffortOptions, isSupportedReasoningEffort, type SupportedReasoningEffort, serializeCloudPrompt, @@ -61,6 +60,7 @@ import { import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import { confirmStopRun } from "@/features/tasks/utils/archiveGuard"; +import { buildCloudTaskRunConfig } from "@/features/tasks/utils/cloudTaskRunConfig"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { ANALYTICS_EVENTS, @@ -341,18 +341,18 @@ export default function TaskDetailScreen() { ) : text; - const supportsReasoning = - getReasoningEffortOptions(composerAdapter, composerModel) !== null; const updatedTask = await getPostHogApiClient().runTaskInCloud( taskId, undefined, { resumeFromRunId: task.latest_run?.id, pendingUserMessage, - adapter: composerAdapter, - model: composerModel, - reasoningLevel: supportsReasoning ? composerReasoning : undefined, - initialPermissionMode: composerMode, + ...buildCloudTaskRunConfig({ + adapter: composerAdapter, + mode: composerMode, + model: composerModel, + reasoning: composerReasoning, + }), rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, ); @@ -616,13 +616,12 @@ export default function TaskDetailScreen() { undefined, { resumeFromRunId: task.latest_run?.id, - adapter: composerAdapter, - model: composerModel, - reasoningLevel: - getReasoningEffortOptions(composerAdapter, composerModel) !== null - ? composerReasoning - : undefined, - initialPermissionMode: composerMode, + ...buildCloudTaskRunConfig({ + adapter: composerAdapter, + mode: composerMode, + model: composerModel, + reasoning: composerReasoning, + }), rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, ); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 7257cf5240..647094c432 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -2,12 +2,10 @@ import { Text } from "@components/text"; import { DEFAULT_CLAUDE_EXECUTION_MODE, getAvailableModesForAdapter, - getDefaultExecutionModeForAdapter, } from "@posthog/core/sessions/executionModes"; import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; import { type Adapter, - DEFAULT_CODEX_MODEL, DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, @@ -20,17 +18,10 @@ import { LinearGradient } from "expo-linear-gradient"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { ArrowUp, - BrainIcon, CaretDown, - Cpu, GithubLogo, MicrophoneIcon, PaperclipIcon, - PauseIcon, - PencilIcon, - Robot, - ShieldCheck, - Sparkle, StopIcon, } from "phosphor-react-native"; import { useCallback, useEffect, useState } from "react"; @@ -50,6 +41,7 @@ import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; +import { AgentConfigControls } from "@/features/tasks/composer/AgentConfigControls"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; import { AttachmentsBar } from "@/features/tasks/composer/attachments/AttachmentsBar"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; @@ -61,14 +53,10 @@ import { import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { - getComposerModelOptions, - getConfigOptionLabel, getMobileExecutionModes, getModelConfigOption, } from "@/features/tasks/composer/options"; -import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline"; -import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskConfigOptions"; import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations"; import { useWarmTask } from "@/features/tasks/hooks/useWarmTask"; @@ -82,6 +70,7 @@ import type { CreateTaskOptions, RepositorySelection, } from "@/features/tasks/types"; +import { buildCloudTaskRunConfig } from "@/features/tasks/utils/cloudTaskRunConfig"; import { findRepositoryOption, isRepositorySelectionComplete, @@ -93,31 +82,12 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); -const SWITCH_ADAPTER_VALUE = "__switch_adapter__"; const SUGGESTIONS = [ "Create or update my CLAUDE.md file", "Search for a TODO comment and fix it", "Recommend areas to improve our tests", ] as const; -function modeIcon(mode: ExecutionMode, color: string, size = 14) { - switch (mode) { - case "plan": - return ; - case "default": - return ; - case "acceptEdits": - return ; - case "bypassPermissions": - case "full-access": - return ; - case "read-only": - return ; - case "auto": - return ; - } -} - export default function NewTaskScreen() { const { prompt: initialPrompt, @@ -136,11 +106,7 @@ export default function NewTaskScreen() { const [adapter, setAdapter] = useState("claude"); const { configOptions, hasLiveConfig, isConfigReady } = useCloudTaskConfigOptions(adapter); - const executionModes = getMobileExecutionModes( - getAvailableModesForAdapter(adapter), - ); const modelConfigOption = getModelConfigOption(configOptions); - const mobileModelOptions = getComposerModelOptions(modelConfigOption); const { error, hasGithubIntegration, @@ -236,9 +202,6 @@ export default function NewTaskScreen() { }, [adapter, hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); - const [modeSheetOpen, setModeSheetOpen] = useState(false); - const [modelSheetOpen, setModelSheetOpen] = useState(false); - const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); @@ -381,15 +344,9 @@ export default function NewTaskScreen() { ) : trimmedPrompt; - const supportsReasoning = - getReasoningEffortOptions(adapter, model) !== null; - await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - adapter, - model, - reasoningLevel: supportsReasoning ? reasoning : undefined, - initialPermissionMode: mode, + ...buildCloudTaskRunConfig({ adapter, mode, model, reasoning }), autoPublish: usePreferencesStore.getState().autoPublishCloudRuns, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, ...(signalReport @@ -618,51 +575,27 @@ export default function NewTaskScreen() { paddingRight: 16, }} > - option.id === mode) - ?.name ?? mode - } - accent={mode === "plan"} - onPress={() => setModeSheetOpen(true)} + { + setMode(next); + usePreferencesStore + .getState() + .setLastNewTaskMode(next); + }} + onModelChange={setModel} + onReasoningChange={(next) => { + setReasoning(next); + usePreferencesStore + .getState() + .setLastUsedReasoningEffort(next); + }} /> - - - ) : ( - - ) - } - label={ - getConfigOptionLabel( - modelConfigOption.options, - model, - ) ?? model - } - onPress={() => setModelSheetOpen(true)} - /> - - {showReasoningPill ? ( - - } - label={ - reasoningOptions.find( - (option) => option.value === reasoning, - )?.name ?? reasoning - } - onPress={() => setReasoningSheetOpen(true)} - /> - ) : null} {/* Right-edge fade hints that more pills exist when the row overflows. Non-interactive so taps fall through. */} @@ -771,98 +704,6 @@ export default function NewTaskScreen() { - { - const next = value as ExecutionMode; - setMode(next); - usePreferencesStore.getState().setLastNewTaskMode(next); - }} - onClose={() => setModeSheetOpen(false)} - options={executionModes.map((executionMode) => ({ - value: executionMode.id, - label: executionMode.name, - description: executionMode.description, - icon: modeIcon( - executionMode.id as ExecutionMode, - executionMode.id === "plan" - ? themeColors.accent[11] - : themeColors.gray[11], - 16, - ), - }))} - /> - - { - if (value === SWITCH_ADAPTER_VALUE) { - const nextAdapter: Adapter = - adapter === "claude" ? "codex" : "claude"; - setAdapter(nextAdapter); - setMode(getDefaultExecutionModeForAdapter(nextAdapter)); - setModel( - nextAdapter === "codex" - ? DEFAULT_CODEX_MODEL - : DEFAULT_GATEWAY_MODEL, - ); - setReasoning(DEFAULT_REASONING_EFFORT); - return; - } - const next = resolveCloudComposerModelChange({ - adapter, - modelOption: modelConfigOption, - requestedModel: value, - reasoning, - }); - setModel(next.model); - setReasoning(next.reasoning); - }} - onClose={() => setModelSheetOpen(false)} - options={[ - ...mobileModelOptions.map((modelOption) => ({ - value: modelOption.value, - label: modelOption.label, - description: modelOption.description, - disabled: modelOption.disabled, - icon: - adapter === "codex" ? ( - - ) : ( - - ), - })), - { - value: SWITCH_ADAPTER_VALUE, - label: `Switch to ${adapter === "claude" ? "Codex" : "Claude Code"}`, - description: "Change coding agent", - disabled: false, - icon: , - }, - ]} - /> - - { - const next = value as SupportedReasoningEffort; - setReasoning(next); - usePreferencesStore.getState().setLastUsedReasoningEffort(next); - }} - onClose={() => setReasoningSheetOpen(false)} - options={reasoningOptions.map((reasoningLevel) => ({ - value: reasoningLevel.value, - label: reasoningLevel.name, - icon: , - }))} - /> - setAttachmentSheetOpen(false)} diff --git a/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx b/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx new file mode 100644 index 0000000000..33b036c686 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx @@ -0,0 +1,97 @@ +import type { CloudTaskConfigOption } from "@posthog/shared"; +import { createElement, type ReactNode } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { AgentConfigControls } from "./AgentConfigControls"; + +vi.mock("phosphor-react-native", () => { + const icon = (name: string) => (props: Record) => + createElement(name, props); + return { + BrainIcon: icon("BrainIcon"), + CaretDown: icon("CaretDown"), + Check: icon("Check"), + Cpu: icon("Cpu"), + PauseIcon: icon("PauseIcon"), + PencilIcon: icon("PencilIcon"), + Robot: icon("Robot"), + ShieldCheck: icon("ShieldCheck"), + Sparkle: icon("Sparkle"), + }; +}); + +vi.mock("@/components/SheetContainer", () => ({ + SheetContainer: ({ + open, + children, + }: { + open: boolean; + children: ReactNode; + }) => (open ? createElement("SheetContainer", null, children) : null), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { 10: "#777", 11: "#555" }, + accent: { 9: "#f60", 11: "#f60" }, + }), +})); + +const configOptions: CloudTaskConfigOption[] = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-sonnet-4-6", + options: [{ value: "claude-sonnet-4-6", name: "Sonnet 4.6" }], + category: "model", + description: "Choose a model", + }, +]; + +function findPressableWithText( + renderer: ReturnType, + label: string, +) { + return renderer.root.find( + (node) => + typeof node.props.onPress === "function" && + node.findAll((child) => child.props.children === label).length > 0, + ); +} + +describe("AgentConfigControls", () => { + it("resets incompatible values when switching adapters", () => { + const onAdapterChange = vi.fn(); + const onModeChange = vi.fn(); + const onModelChange = vi.fn(); + const onReasoningChange = vi.fn(); + let renderer!: ReturnType; + + act(() => { + renderer = create( + createElement(AgentConfigControls, { + adapter: "claude", + mode: "plan", + model: "claude-sonnet-4-6", + reasoning: "high", + configOptions, + onAdapterChange, + onModeChange, + onModelChange, + onReasoningChange, + }), + ); + }); + + act(() => findPressableWithText(renderer, "Sonnet 4.6").props.onPress()); + act(() => + findPressableWithText(renderer, "Switch to Codex").props.onPress(), + ); + + expect(onAdapterChange).toHaveBeenCalledWith("codex"); + expect(onModeChange).toHaveBeenCalledWith("auto"); + expect(onModelChange).toHaveBeenCalledWith("gpt-5.5"); + expect(onReasoningChange).toHaveBeenCalledWith("high"); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx b/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx new file mode 100644 index 0000000000..5d21968dca --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx @@ -0,0 +1,225 @@ +import { + getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, +} from "@posthog/core/sessions/executionModes"; +import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, +} from "@posthog/shared"; +import { + BrainIcon, + Cpu, + PauseIcon, + PencilIcon, + Robot, + ShieldCheck, + Sparkle, +} from "phosphor-react-native"; +import { type ReactNode, useState } from "react"; +import { useThemeColors } from "@/lib/theme"; +import { + getComposerModelOptions, + getConfigOptionLabel, + getMobileExecutionModes, + getModelConfigOption, +} from "./options"; +import { Pill } from "./Pill"; +import { SelectSheet } from "./SelectSheet"; + +const SWITCH_ADAPTER_VALUE = "__switch_adapter__"; + +interface AgentConfigControlsProps { + adapter: Adapter; + mode: ExecutionMode; + model: string; + reasoning: SupportedReasoningEffort; + configOptions: readonly CloudTaskConfigOption[]; + onAdapterChange: (adapter: Adapter) => void; + onModeChange: (mode: ExecutionMode) => void; + onModelChange: (model: string) => void; + onReasoningChange: (reasoning: SupportedReasoningEffort) => void; + canChangeAdapter?: boolean; +} + +function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { + switch (mode) { + case "plan": + return ; + case "default": + return ; + case "acceptEdits": + return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; + case "auto": + return ; + } +} + +export function AgentConfigControls({ + adapter, + mode, + model, + reasoning, + configOptions, + onAdapterChange, + onModeChange, + onModelChange, + onReasoningChange, + canChangeAdapter = true, +}: AgentConfigControlsProps) { + const themeColors = useThemeColors(); + const [modeSheetOpen, setModeSheetOpen] = useState(false); + const [modelSheetOpen, setModelSheetOpen] = useState(false); + const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); + const executionModes = getMobileExecutionModes( + getAvailableModesForAdapter(adapter), + ); + const modelConfigOption = getModelConfigOption(configOptions); + const modelOptions = getComposerModelOptions(modelConfigOption); + const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; + + return ( + <> + option.id === mode)?.name ?? mode + } + accent={mode === "plan"} + onPress={() => setModeSheetOpen(true)} + /> + + + ) : ( + + ) + } + label={getConfigOptionLabel(modelConfigOption.options, model) ?? model} + onPress={() => setModelSheetOpen(true)} + /> + + {reasoningOptions.length > 0 ? ( + } + label={ + reasoningOptions.find((option) => option.value === reasoning) + ?.name ?? reasoning + } + onPress={() => setReasoningSheetOpen(true)} + /> + ) : null} + + onModeChange(value as ExecutionMode)} + onClose={() => setModeSheetOpen(false)} + options={executionModes.map((option) => ({ + value: option.id, + label: option.name, + description: option.description, + icon: modeIcon( + option.id as ExecutionMode, + option.id === "plan" + ? themeColors.accent[11] + : themeColors.gray[11], + 16, + ), + }))} + /> + + { + if (value === SWITCH_ADAPTER_VALUE) { + const nextAdapter: Adapter = + adapter === "claude" ? "codex" : "claude"; + onAdapterChange(nextAdapter); + onModeChange(getDefaultExecutionModeForAdapter(nextAdapter)); + onModelChange( + nextAdapter === "codex" + ? DEFAULT_CODEX_MODEL + : DEFAULT_GATEWAY_MODEL, + ); + onReasoningChange(DEFAULT_REASONING_EFFORT); + return; + } + const next = resolveCloudComposerModelChange({ + adapter, + modelOption: modelConfigOption, + requestedModel: value, + reasoning, + }); + onModelChange(next.model); + onReasoningChange(next.reasoning); + }} + onClose={() => setModelSheetOpen(false)} + options={[ + ...modelOptions.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + disabled: option.disabled, + icon: + adapter === "codex" ? ( + + ) : ( + + ), + })), + ...(canChangeAdapter + ? [ + { + value: SWITCH_ADAPTER_VALUE, + label: `Switch to ${adapter === "claude" ? "Codex" : "Claude Code"}`, + description: "Change coding agent", + disabled: false, + icon: + adapter === "claude" ? ( + + ) : ( + + ), + }, + ] + : []), + ]} + /> + + + onReasoningChange(value as SupportedReasoningEffort) + } + onClose={() => setReasoningSheetOpen(false)} + options={reasoningOptions.map((option) => ({ + value: option.value, + label: option.name, + icon: , + }))} + /> + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 82da4eefe4..ea7dfbc060 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -1,35 +1,24 @@ import { Text } from "@components/text"; -import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableModesForAdapter, - getDefaultExecutionModeForAdapter, -} from "@posthog/core/sessions/executionModes"; +import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; import { type Adapter, DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, - getReasoningEffortOptions, type SupportedReasoningEffort, } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowUp, - BrainIcon, - Cpu, Lightning, Microphone, PaperclipIcon, - PauseIcon, PencilIcon, - Robot, - ShieldCheck, - Sparkle, Stack, Stop, } from "phosphor-react-native"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Keyboard, @@ -43,6 +32,7 @@ import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskCo import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import type { MessagingMode } from "../stores/messagingModeStore"; +import { AgentConfigControls } from "./AgentConfigControls"; import { AttachmentSheet } from "./attachments/AttachmentSheet"; import { AttachmentsBar } from "./attachments/AttachmentsBar"; import { @@ -51,18 +41,10 @@ import { pickPhotoFromLibrary, } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; -import { - getComposerModelOptions, - getConfigOptionLabel, - getMobileExecutionModes, - getModelConfigOption, - resolveComposerPrimaryAction, -} from "./options"; +import { getModelConfigOption, resolveComposerPrimaryAction } from "./options"; import { Pill } from "./Pill"; -import { SelectSheet } from "./SelectSheet"; const log = logger.scope("task-chat-composer"); -const SWITCH_ADAPTER_VALUE = "__switch_adapter__"; interface TaskChatComposerProps { onSend: (message: string, attachments: PendingAttachment[]) => void; onStop?: () => void; @@ -91,24 +73,6 @@ interface TaskChatComposerProps { onCancelEdit?: () => void; } -function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { - switch (mode) { - case "plan": - return ; - case "default": - return ; - case "acceptEdits": - return ; - case "bypassPermissions": - case "full-access": - return ; - case "read-only": - return ; - case "auto": - return ; - } -} - export function TaskChatComposer({ onSend, onStop, @@ -134,11 +98,7 @@ export function TaskChatComposer({ }: TaskChatComposerProps) { const themeColors = useThemeColors(); const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions(adapter); - const executionModes = getMobileExecutionModes( - getAvailableModesForAdapter(adapter), - ); const modelConfigOption = getModelConfigOption(configOptions); - const mobileModelOptions = getComposerModelOptions(modelConfigOption); const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); @@ -184,13 +144,6 @@ export function TaskChatComposer({ const isRecording = status === "recording"; const isTranscribing = status === "transcribing"; - const [modeSheetOpen, setModeSheetOpen] = useState(false); - const [modelSheetOpen, setModelSheetOpen] = useState(false); - const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); - - const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; - const showReasoningPill = reasoningOptions.length > 0; - const hasContent = message.trim().length > 0 || attachments.length > 0; const primaryAction = resolveComposerPrimaryAction({ hasContent, @@ -335,48 +288,19 @@ export function TaskChatComposer({ paddingRight: 4, }} > - option.id === mode)?.name ?? - mode - } - accent={mode === "plan"} - onPress={() => setModeSheetOpen(true)} + - - ) : ( - - ) - } - label={ - getConfigOptionLabel(modelConfigOption.options, model) ?? - model - } - onPress={() => setModelSheetOpen(true)} - /> - - {showReasoningPill ? ( - } - label={ - reasoningOptions.find( - (option) => option.value === reasoning, - )?.name ?? reasoning - } - onPress={() => setReasoningSheetOpen(true)} - /> - ) : null} - - onModeChange(v as ExecutionMode)} - onClose={() => setModeSheetOpen(false)} - options={executionModes.map((m) => ({ - value: m.id, - label: m.name, - description: m.description, - icon: modeIcon( - m.id as ExecutionMode, - m.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], - 16, - ), - }))} - /> - - { - if (v === SWITCH_ADAPTER_VALUE) { - const nextAdapter = adapter === "claude" ? "codex" : "claude"; - onAdapterChange(nextAdapter); - onModeChange(getDefaultExecutionModeForAdapter(nextAdapter)); - return; - } - const next = resolveCloudComposerModelChange({ - adapter, - modelOption: modelConfigOption, - requestedModel: v, - reasoning, - }); - onModelChange(next.model); - if (next.reasoning !== reasoning) { - onReasoningChange(next.reasoning); - } - }} - onClose={() => setModelSheetOpen(false)} - options={[ - ...mobileModelOptions.map((m) => ({ - value: m.value, - label: m.label, - description: m.description, - disabled: m.disabled, - icon: - adapter === "codex" ? ( - - ) : ( - - ), - })), - ...(canChangeAdapter - ? [ - { - value: SWITCH_ADAPTER_VALUE, - label: `Switch to ${adapter === "claude" ? "Codex" : "Claude Code"}`, - description: "Change coding agent", - disabled: false, - icon: - adapter === "claude" ? ( - - ) : ( - - ), - }, - ] - : []), - ]} - /> - - onReasoningChange(v as SupportedReasoningEffort)} - onClose={() => setReasoningSheetOpen(false)} - options={reasoningOptions.map((r) => ({ - value: r.value, - label: r.name, - icon: , - }))} - /> - setAttachmentSheetOpen(false)} diff --git a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts new file mode 100644 index 0000000000..320a2f6a56 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildCloudTaskRunConfig } from "./cloudTaskRunConfig"; + +describe("buildCloudTaskRunConfig", () => { + it("forwards the selected Codex configuration to cloud task dispatch", () => { + expect( + buildCloudTaskRunConfig({ + adapter: "codex", + mode: "full-access", + model: "gpt-5.5", + reasoning: "high", + }), + ).toEqual({ + adapter: "codex", + initialPermissionMode: "full-access", + model: "gpt-5.5", + reasoningLevel: "high", + }); + }); + + it("omits reasoning when the selected model does not support it", () => { + expect( + buildCloudTaskRunConfig({ + adapter: "claude", + mode: "plan", + model: "claude-haiku-4-5", + reasoning: "high", + }).reasoningLevel, + ).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts new file mode 100644 index 0000000000..0ebddf8c4b --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts @@ -0,0 +1,28 @@ +import { + type Adapter, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export function buildCloudTaskRunConfig({ + adapter, + mode, + model, + reasoning, +}: { + adapter: Adapter; + mode: ExecutionMode; + model: string; + reasoning: SupportedReasoningEffort; +}) { + return { + adapter, + model, + reasoningLevel: + getReasoningEffortOptions(adapter, model) === null + ? undefined + : reasoning, + initialPermissionMode: mode, + }; +} From 5e2a0645cace6c522a655de2c557199e0929328e Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Thu, 23 Jul 2026 20:27:40 +0300 Subject: [PATCH 7/7] fix(mobile): validate resumed agent controls Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../tasks/stores/taskSessionStore.test.ts | 35 +++++++++++++++++-- .../features/tasks/stores/taskSessionStore.ts | 16 +++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index c38504a1c3..2aadd16883 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -263,6 +263,8 @@ describe("_resumeCloudRun", () => { mockGetTask.mockResolvedValue( previousTask({ branch: "feature", + runtime_adapter: "claude", + model: "claude-opus-4-8", reasoning_effort: "low", state: { initial_permission_mode: "acceptEdits" }, }), @@ -275,6 +277,7 @@ describe("_resumeCloudRun", () => { expect(mockRunTaskInCloud).toHaveBeenCalledWith("t1", { branch: "feature", runtimeAdapter: "claude", + model: "claude-opus-4-8", resumeFromRunId: "prev-run", pendingUserMessage: "hi", reasoningEffort: "low", @@ -299,7 +302,14 @@ describe("_resumeCloudRun", () => { it("prefers the composer's current selection over the previous run", async () => { useTaskStore.setState({ - composerConfigByTaskId: { t1: { mode: "plan", reasoning: "max" } }, + composerConfigByTaskId: { + t1: { + adapter: "codex", + mode: "plan", + model: "gpt-5.5", + reasoning: "high", + }, + }, }); mockGetTask.mockResolvedValue( previousTask({ @@ -316,11 +326,32 @@ describe("_resumeCloudRun", () => { expect(mockRunTaskInCloud).toHaveBeenCalledWith( "t1", expect.objectContaining({ - reasoningEffort: "max", + runtimeAdapter: "codex", + model: "gpt-5.5", + reasoningEffort: "high", initialPermissionMode: "plan", }), ); }); + + it("drops stored reasoning unsupported by the resumed model", async () => { + mockGetTask.mockResolvedValue( + previousTask({ + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: "max", + }), + ); + + await useTaskSessionStore + .getState() + ._resumeCloudRun("t1", "prev-run", "hi"); + + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "t1", + expect.objectContaining({ reasoningEffort: undefined }), + ); + }); }); describe("compaction tracking from the log stream", () => { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 0c57cc6d1a..539545dc4a 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,6 +1,8 @@ import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; import { + type Adapter, type CloudTaskUpdatePayload, + isSupportedReasoningEffort, isTerminalStatus, type StoredLogEntry, serializeCloudPrompt, @@ -1187,9 +1189,18 @@ export const useTaskSessionStore = create((set, get) => ({ const composerConfig = useTaskStore.getState().composerConfigByTaskId[taskId]; + const adapter: Adapter = + composerConfig?.adapter ?? previousRun?.runtime_adapter ?? "claude"; + const model = composerConfig?.model ?? previousRun?.model ?? undefined; const previousPermissionMode = previousRun?.state?.initial_permission_mode; - const reasoningEffort = + const requestedReasoning = composerConfig?.reasoning ?? previousRun?.reasoning_effort ?? undefined; + const reasoningEffort = + model && + requestedReasoning && + isSupportedReasoningEffort(adapter, model, requestedReasoning) + ? requestedReasoning + : undefined; const initialPermissionMode = composerConfig?.mode ?? (typeof previousPermissionMode === "string" @@ -1198,7 +1209,8 @@ export const useTaskSessionStore = create((set, get) => ({ const updatedTask = await runTaskInCloud(taskId, { branch: previousBranch, - runtimeAdapter: "claude", + runtimeAdapter: adapter, + model, resumeFromRunId: previousRunId, pendingUserMessage: prompt, reasoningEffort,