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; +}