diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx
index 27df1e9b4e..8eb4d3a9a7 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -1,15 +1,18 @@
import { Text } from "@components/text";
-import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes";
+import { getCloudReasoningConfigOptionId } from "@posthog/core/sessions/cloudSessionConfig";
+import { getDefaultExecutionModeForAdapter } from "@posthog/core/sessions/executionModes";
import {
countUserMessages,
getSessionActivityPhase,
} 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,
@@ -57,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,
@@ -167,11 +171,37 @@ export default function TaskDetailScreen() {
const [initialComposerMessage, setInitialComposerMessage] = useState<
string | undefined
>();
+ const composerAdapter: Adapter =
+ task?.latest_run?.runtime_adapter &&
+ !session?.terminalStatus &&
+ 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) ??
+ getDefaultExecutionModeForAdapter(composerAdapter);
+ 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);
@@ -311,18 +341,18 @@ export default function TaskDetailScreen() {
)
: text;
- const supportsReasoning =
- getReasoningEffortOptions("claude", composerModel) !== null;
const updatedTask = await getPostHogApiClient().runTaskInCloud(
taskId,
undefined,
{
resumeFromRunId: task.latest_run?.id,
pendingUserMessage,
- adapter: "claude",
- model: composerModel,
- reasoningLevel: supportsReasoning ? composerReasoning : undefined,
- initialPermissionMode: composerMode,
+ ...buildCloudTaskRunConfig({
+ adapter: composerAdapter,
+ mode: composerMode,
+ model: composerModel,
+ reasoning: composerReasoning,
+ }),
rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
},
);
@@ -346,6 +376,7 @@ export default function TaskDetailScreen() {
connectToTask,
updateTaskInCache,
composerMode,
+ composerAdapter,
composerModel,
composerReasoning,
],
@@ -500,6 +531,19 @@ export default function TaskDetailScreen() {
[taskId, setComposerConfig, setConfigOption],
);
+ const handleAdapterChange = useCallback(
+ (value: Adapter) => {
+ if (!taskId) return;
+ setComposerConfig(taskId, {
+ adapter: value,
+ mode: getDefaultExecutionModeForAdapter(value),
+ model: value === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL,
+ reasoning: DEFAULT_REASONING_EFFORT,
+ });
+ },
+ [taskId, setComposerConfig],
+ );
+
const handleModelChange = useCallback(
(value: string) => {
if (!taskId) return;
@@ -513,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(() => {
@@ -568,6 +616,12 @@ export default function TaskDetailScreen() {
undefined,
{
resumeFromRunId: task.latest_run?.id,
+ ...buildCloudTaskRunConfig({
+ adapter: composerAdapter,
+ mode: composerMode,
+ model: composerModel,
+ reasoning: composerReasoning,
+ }),
rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
},
);
@@ -584,7 +638,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 +842,9 @@ export default function TaskDetailScreen() {
/>
) : null}
;
- 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,
@@ -130,10 +103,10 @@ export default function NewTaskScreen() {
const { insets, bottom } = useScreenInsets();
const keyboard = useReanimatedKeyboardAnimation();
const restingBottom = bottom("compact");
+ const [adapter, setAdapter] = useState("claude");
const { configOptions, hasLiveConfig, isConfigReady } =
- useCloudTaskConfigOptions("claude");
+ useCloudTaskConfigOptions(adapter);
const modelConfigOption = getModelConfigOption(configOptions);
- const mobileModelOptions = getComposerModelOptions(modelConfigOption);
const {
error,
hasGithubIntegration,
@@ -197,7 +170,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,19 +192,16 @@ 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 [modeSheetOpen, setModeSheetOpen] = useState(false);
- const [modelSheetOpen, setModelSheetOpen] = useState(false);
- const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false);
const [attachments, setAttachments] = useState([]);
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
@@ -363,7 +335,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
@@ -372,15 +344,9 @@ export default function NewTaskScreen() {
)
: trimmedPrompt;
- const supportsReasoning =
- getReasoningEffortOptions("claude", model) !== null;
-
await client.runTaskInCloud(task.id, undefined, {
pendingUserMessage,
- adapter: "claude",
- model,
- reasoningLevel: supportsReasoning ? reasoning : undefined,
- initialPermissionMode: mode,
+ ...buildCloudTaskRunConfig({ adapter, mode, model, reasoning }),
autoPublish: usePreferencesStore.getState().autoPublishCloudRuns,
rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud,
...(signalReport
@@ -401,6 +367,7 @@ export default function NewTaskScreen() {
}
}, [
attachments,
+ adapter,
creating,
mode,
model,
@@ -419,7 +386,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 +396,7 @@ export default function NewTaskScreen() {
repository: selection.repository,
githubIntegrationId: selection.integrationId,
composerIsEmpty: !hasContent || !isConfigReady,
- runtimeAdapter: "claude",
+ runtimeAdapter: adapter,
model,
reasoningEffort: showReasoningPill ? reasoning : null,
});
@@ -500,320 +467,243 @@ 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}
+
+
+
-
-
+
+
+
+
+ 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={
- EXECUTION_MODES.find((option) => option.id === mode)
- ?.name ?? mode
+ : themeColors.gray[10]
}
- accent={mode === "plan"}
- onPress={() => setModeSheetOpen(true)}
+ weight={attachments.length > 0 ? "fill" : "regular"}
/>
-
- }
- label={
- getConfigOptionLabel(
- modelConfigOption.options,
- model,
- ) ?? model
- }
- onPress={() => setModelSheetOpen(true)}
+
+
+
+
+ {
+ setMode(next);
+ usePreferencesStore
+ .getState()
+ .setLastNewTaskMode(next);
+ }}
+ onModelChange={setModel}
+ onReasoningChange={(next) => {
+ setReasoning(next);
+ usePreferencesStore
+ .getState()
+ .setLastUsedReasoningEffort(next);
+ }}
+ />
+
+ {/* Right-edge fade hints that more pills exist when the row
+ overflows. Non-interactive so taps fall through. */}
+
-
- {showReasoningPill ? (
-
- }
- label={
- reasoningOptions.find(
- (option) => option.value === reasoning,
- )?.name ?? reasoning
+
+
+
+ {creating || isTranscribing ? (
+
+ ) : isRecording ? (
+
+ ) : hasContent ? (
+ setReasoningSheetOpen(true)}
+ weight="bold"
/>
- ) : null}
-
- {/* Right-edge fade hints that more pills exist when the row
- overflows. Non-interactive so taps fall through. */}
-
+ ) : (
+
+ )}
+
+
-
- {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 next = value as ExecutionMode;
- setMode(next);
- usePreferencesStore.getState().setLastNewTaskMode(next);
- }}
- onClose={() => setModeSheetOpen(false)}
- options={EXECUTION_MODES.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,
- ),
- }))}
- />
-
- {
- const next = resolveCloudComposerModelChange({
- adapter: "claude",
- 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: ,
- }))}
- />
-
- {
- 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/components/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx
index c12189fc1c..8c7909b6b5 100644
--- a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx
+++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx
@@ -1,11 +1,10 @@
import { Text } from "@components/text";
-import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { CaretLeft } from "phosphor-react-native";
import type { ReactNode } from "react";
import { Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { toRgba, useThemeColors } from "@/lib/theme";
+import { useThemeColors } from "@/lib/theme";
interface FloatingTaskHeaderProps {
title: string;
@@ -14,12 +13,7 @@ interface FloatingTaskHeaderProps {
rightSlot?: ReactNode;
}
-/**
- * Floating header for the task detail screen — back arrow on the left,
- * centered title + repo subtitle, optional right slot for actions. Sits over
- * the content with a top-to-bottom fade so the scroll list disappears
- * gracefully behind it rather than getting clipped by a hard edge.
- */
+/** Task detail toolbar with navigation, task identity, and run actions. */
export function FloatingTaskHeader({
title,
subtitle,
@@ -38,30 +32,14 @@ export function FloatingTaskHeader({
// on iOS and fall back to the real inset on Android.
const topInset = Platform.OS === "ios" ? 6 : insets.top;
- // Fade height extends well past the title row so content scrolling up
- // behind the header gets a long, gentle transition instead of crashing
- // into the subtitle. Header row content sits in roughly the first
- // (topInset + 44)pt; the rest is pure fade.
- const fadeHeight = topInset + 96;
+ const headerHeight = topInset + 52;
return (
-
-
-
+
{
+ 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/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..ea7dfbc060 100644
--- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
+++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
@@ -1,42 +1,26 @@
import { Text } from "@components/text";
-import {
- DEFAULT_CLAUDE_EXECUTION_MODE,
- getAvailableModes,
-} 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,
Lightning,
Microphone,
PaperclipIcon,
- PauseIcon,
PencilIcon,
- Robot,
- ShieldCheck,
- Sparkle,
Stack,
Stop,
} from "phosphor-react-native";
-import {
- type ReactNode,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react";
+import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
- Animated,
- Easing,
Keyboard,
Pressable,
ScrollView,
@@ -48,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 {
@@ -56,19 +41,10 @@ import {
pickPhotoFromLibrary,
} from "./attachments/pickers";
import type { PendingAttachment } from "./attachments/types";
-import { getMobileExecutionModes } from "./options";
-import {
- getComposerModelOptions,
- getConfigOptionLabel,
- 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 EXECUTION_MODES = getMobileExecutionModes(getAvailableModes());
-
interface TaskChatComposerProps {
onSend: (message: string, attachments: PendingAttachment[]) => void;
onStop?: () => void;
@@ -77,9 +53,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;
@@ -94,78 +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 ;
- }
-}
-
-function PulsingBorder({ active, color }: { active: boolean; color: string }) {
- const opacity = useRef(new Animated.Value(0)).current;
- const animRef = useRef(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,
@@ -173,9 +80,12 @@ export function TaskChatComposer({
placeholder = "Ask a question",
initialMessage,
isUserTurn = false,
+ adapter,
mode,
model,
reasoning,
+ onAdapterChange,
+ canChangeAdapter = true,
onModeChange,
onModelChange,
onReasoningChange,
@@ -187,9 +97,8 @@ export function TaskChatComposer({
onCancelEdit,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
- const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude");
+ const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions(adapter);
const modelConfigOption = getModelConfigOption(configOptions);
- const mobileModelOptions = getComposerModelOptions(modelConfigOption);
const [message, setMessage] = useState(() => initialMessage ?? "");
const [attachments, setAttachments] = useState([]);
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
@@ -208,7 +117,7 @@ export function TaskChatComposer({
useEffect(() => {
if (!hasLiveConfig) return;
const next = resolveCloudComposerModelChange({
- adapter: "claude",
+ adapter,
modelOption: modelConfigOption,
requestedModel: model,
reasoning,
@@ -216,6 +125,7 @@ export function TaskChatComposer({
if (next.model !== model) onModelChange(next.model);
if (next.reasoning !== reasoning) onReasoningChange(next.reasoning);
}, [
+ adapter,
hasLiveConfig,
model,
modelConfigOption,
@@ -234,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("claude", model) ?? [];
- const showReasoningPill = reasoningOptions.length > 0;
-
const hasContent = message.trim().length > 0 || attachments.length > 0;
const primaryAction = resolveComposerPrimaryAction({
hasContent,
@@ -310,10 +213,9 @@ export function TaskChatComposer({
return (
<>
-
-
-
-
+
+
+
{editing ? (
@@ -339,7 +241,7 @@ export function TaskChatComposer({
/>
+
+
-
- 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={EXECUTION_MODES.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,
- ),
- }))}
- />
-
- {
- const next = resolveCloudComposerModelChange({
- adapter: "claude",
- 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: ,
- }))}
- />
-
- 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/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,
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/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,
+ };
+}
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 b6a9e4f8f4..c340613ac4 100644
--- a/packages/core/src/sessions/cloudSessionConfig.ts
+++ b/packages/core/src/sessions/cloudSessionConfig.ts
@@ -1,11 +1,14 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { Adapter, StoredLogEntry } from "@posthog/shared";
import {
- DEFAULT_CLAUDE_EXECUTION_MODE,
- getAvailableCodexModes,
- getAvailableModes,
+ getAvailableModesForAdapter,
+ 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.
@@ -56,10 +59,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)
@@ -104,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,
diff --git a/packages/core/src/sessions/executionModes.test.ts b/packages/core/src/sessions/executionModes.test.ts
new file mode 100644
index 0000000000..9246e9ce67
--- /dev/null
+++ b/packages/core/src/sessions/executionModes.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import {
+ getAvailableModesForAdapter,
+ getDefaultExecutionModeForAdapter,
+} 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,
+ );
+ });
+});
+
+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 2ccbadf354..a415b2716c 100644
--- a/packages/core/src/sessions/executionModes.ts
+++ b/packages/core/src/sessions/executionModes.ts
@@ -46,3 +46,15 @@ export function getAvailableModes(): ModeInfo[] {
export function getAvailableCodexModes(): ModeInfo[] {
return [...CODEX_MODE_PRESETS];
}
+
+export function getAvailableModesForAdapter(
+ adapter: "claude" | "codex",
+): ModeInfo[] {
+ return adapter === "codex" ? getAvailableCodexModes() : getAvailableModes();
+}
+
+export function getDefaultExecutionModeForAdapter(
+ adapter: "claude" | "codex",
+): ExecutionMode {
+ return adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE;
+}