From ed4b3d90d336a9a1197bc09fdd2382c746c6cfdc Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 30 Jul 2026 12:08:45 +0100 Subject: [PATCH 1/2] feat(mobile): merge model and reasoning into one agent config control (port #3821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the desktop "new agent harness selector" (#3821) to apps/mobile. Replaces the separate model and reasoning pills with a single merged pill that opens a bottom sheet: a Faster→Smarter preset scale (model + effort together), a fast-mode toggle (flag-gated), a 200k/1M context-window control, and an Advanced section for picking harness/model/reasoning individually with reset. Sends context_window and fast_mode on cloud task creation and sandbox warming, persists the selection, and bumps the preferences store version once so stale installs land on valid defaults. Generated-By: PostHog Code Task-Id: 2af7b99c-c87f-415b-81ef-95a656535821 --- apps/mobile/src/app/task/[id].tsx | 53 +++- apps/mobile/src/app/task/index.tsx | 59 +++- .../stores/preferencesStore.test.ts | 57 ++++ .../preferences/stores/preferencesStore.ts | 45 +++ apps/mobile/src/features/tasks/api.ts | 6 + .../composer/AgentConfigControls.test.tsx | 178 ++++++++---- .../tasks/composer/AgentConfigControls.tsx | 170 ++++++----- .../tasks/composer/AgentConfigSheet.tsx | 273 ++++++++++++++++++ .../tasks/composer/TaskChatComposer.tsx | 13 + .../features/tasks/composer/options.test.ts | 82 ++++++ .../src/features/tasks/composer/options.ts | 74 +++++ .../features/tasks/hooks/useWarmTask.test.tsx | 25 ++ .../src/features/tasks/hooks/useWarmTask.ts | 14 +- .../src/features/tasks/stores/taskStore.ts | 3 + .../tasks/utils/cloudTaskRunConfig.test.ts | 46 +++ .../tasks/utils/cloudTaskRunConfig.ts | 9 + packages/api-client/src/posthog-client.ts | 8 + packages/shared/src/index.ts | 4 + packages/shared/src/reasoning-effort.ts | 55 ++++ 19 files changed, 1013 insertions(+), 161 deletions(-) create mode 100644 apps/mobile/src/features/tasks/composer/AgentConfigSheet.tsx diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 17fff50faa..3612eca815 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -45,6 +45,10 @@ import { StopRunButton } from "@/features/tasks/components/StopRunButton"; import { TaskSessionView } from "@/features/tasks/components/TaskSessionView"; import { buildCloudPromptBlocks } from "@/features/tasks/composer/attachments/buildCloudPrompt"; import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; +import { + type ContextWindow, + DEFAULT_CONTEXT_WINDOW, +} from "@/features/tasks/composer/options"; import { QueuedMessagesDock } from "@/features/tasks/composer/QueuedMessagesDock"; import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; import { @@ -215,6 +219,13 @@ export default function TaskDetailScreen() { ) ? requestedComposerReasoning : DEFAULT_REASONING_EFFORT; + const composerContextWindow: ContextWindow = + (composerConfigMatchesAdapter + ? composerConfig?.contextWindow + : undefined) ?? DEFAULT_CONTEXT_WINDOW; + const composerFastMode: boolean = + (composerConfigMatchesAdapter ? composerConfig?.fastMode : undefined) ?? + false; const messagingMode = useMessagingMode(taskId); const queuedCount = useQueuedCount(taskId); @@ -368,6 +379,8 @@ export default function TaskDetailScreen() { mode: composerMode, model: composerModel, reasoning: composerReasoning, + contextWindow: composerContextWindow, + fastMode: composerFastMode, }), rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, @@ -397,6 +410,8 @@ export default function TaskDetailScreen() { composerAdapter, composerModel, composerReasoning, + composerContextWindow, + composerFastMode, ], ); @@ -554,10 +569,14 @@ export default function TaskDetailScreen() { const handleAdapterChange = useCallback( (selection: CloudComposerSelection) => { if (!taskId) return; - setComposerConfig(taskId, selection); - const preferences = usePreferencesStore.getState(); - preferences.setLastNewTaskMode(selection.mode); - preferences.setLastUsedReasoningEffort(selection.reasoning); + setComposerConfig(taskId, { + ...selection, + contextWindow: DEFAULT_CONTEXT_WINDOW, + fastMode: false, + }); + usePreferencesStore + .getState() + .resetLastUsedAgentConfig(selection.mode, selection.reasoning); }, [taskId, setComposerConfig], ); @@ -585,6 +604,24 @@ export default function TaskDetailScreen() { [taskId, composerAdapter, setComposerConfig, setConfigOption], ); + const handleContextWindowChange = useCallback( + (value: ContextWindow) => { + if (!taskId) return; + setComposerConfig(taskId, { contextWindow: value }); + usePreferencesStore.getState().setLastUsedContextWindow(value); + }, + [taskId, setComposerConfig], + ); + + const handleFastModeChange = useCallback( + (value: boolean) => { + if (!taskId) return; + setComposerConfig(taskId, { fastMode: value }); + usePreferencesStore.getState().setLastUsedFastMode(value); + }, + [taskId, setComposerConfig], + ); + const handleStop = useCallback(() => { if (!taskId) return; // cancelPrompt returns false on failure — no need to alert, @@ -639,6 +676,8 @@ export default function TaskDetailScreen() { mode: composerMode, model: composerModel, reasoning: composerReasoning, + contextWindow: composerContextWindow, + fastMode: composerFastMode, }), rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, @@ -666,6 +705,8 @@ export default function TaskDetailScreen() { composerModel, composerReasoning, composerMode, + composerContextWindow, + composerFastMode, ]); // Clear retrying once the agent finishes a turn or the run terminates. @@ -877,10 +918,14 @@ export default function TaskDetailScreen() { mode={composerMode} model={composerModel} reasoning={composerReasoning} + contextWindow={composerContextWindow} + fastMode={composerFastMode} onAdapterChange={handleAdapterChange} onModeChange={handleModeChange} onModelChange={handleModelChange} onReasoningChange={handleReasoningChange} + onContextWindowChange={handleContextWindowChange} + onFastModeChange={handleFastModeChange} messagingMode={messagingMode} queuedCount={queuedCount} onToggleMessagingMode={toggleMessagingMode} diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 04050437c7..555f6ba9a8 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -14,6 +14,8 @@ import { KIMI_MODEL_FLAG, type SupportedReasoningEffort, serializeCloudPrompt, + supports1MContext, + supportsFastMode, } from "@posthog/shared"; import { LinearGradient } from "expo-linear-gradient"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; @@ -55,6 +57,8 @@ import { import type { PendingAttachment } from "@/features/tasks/composer/attachments/types"; import { DotBackground } from "@/features/tasks/composer/DotBackground"; import { + type ContextWindow, + DEFAULT_CONTEXT_WINDOW, filterKimiModelConfigOptions, getMobileExecutionModes, getModelConfigOption, @@ -199,6 +203,12 @@ export default function NewTaskScreen() { ? desired : DEFAULT_REASONING_EFFORT; }); + const [contextWindow, setContextWindow] = useState( + () => usePreferencesStore.getState().lastUsedContextWindow, + ); + const [fastMode, setFastMode] = useState( + () => usePreferencesStore.getState().lastUsedFastMode, + ); useEffect(() => { if (!hasLiveConfig) return; @@ -346,7 +356,14 @@ 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, { adapter, mode, model, reasoning }); + setComposerConfig(task.id, { + adapter, + mode, + model, + reasoning, + contextWindow, + fastMode, + }); const pendingUserMessage = attachments.length > 0 @@ -357,7 +374,14 @@ export default function NewTaskScreen() { await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - ...buildCloudTaskRunConfig({ adapter, mode, model, reasoning }), + ...buildCloudTaskRunConfig({ + adapter, + mode, + model, + reasoning, + contextWindow, + fastMode, + }), autoPublish: usePreferencesStore.getState().autoPublishCloudRuns, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, ...(signalReport @@ -384,6 +408,8 @@ export default function NewTaskScreen() { model, prompt, reasoning, + contextWindow, + fastMode, router, selection, signalReport, @@ -410,6 +436,8 @@ export default function NewTaskScreen() { runtimeAdapter: adapter, model, reasoningEffort: showReasoningPill ? reasoning : null, + contextWindow: supports1MContext(model) ? contextWindow : null, + fastMode: supportsFastMode(model) ? fastMode : null, }); if (isLoading && hasGithubIntegration === null) { @@ -591,17 +619,22 @@ export default function NewTaskScreen() { mode={mode} model={model} reasoning={reasoning} + contextWindow={contextWindow} + fastMode={fastMode} configOptions={configOptions} onAdapterChange={(next) => { setAdapter(next.adapter); setMode(next.mode); setModel(next.model); setReasoning(next.reasoning); - const preferences = usePreferencesStore.getState(); - preferences.setLastNewTaskMode(next.mode); - preferences.setLastUsedReasoningEffort( - next.reasoning, - ); + setContextWindow(DEFAULT_CONTEXT_WINDOW); + setFastMode(false); + usePreferencesStore + .getState() + .resetLastUsedAgentConfig( + next.mode, + next.reasoning, + ); }} onModeChange={(next) => { setMode(next); @@ -616,6 +649,18 @@ export default function NewTaskScreen() { .getState() .setLastUsedReasoningEffort(next); }} + onContextWindowChange={(next) => { + setContextWindow(next); + usePreferencesStore + .getState() + .setLastUsedContextWindow(next); + }} + onFastModeChange={(next) => { + setFastMode(next); + usePreferencesStore + .getState() + .setLastUsedFastMode(next); + }} /> {/* Right-edge fade hints that more pills exist when the row diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts index 263daeb2c6..fc2718571a 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.test.ts @@ -55,6 +55,63 @@ describe("preferencesStore reasoning effort", () => { }); }); +describe("preferencesStore agent config", () => { + it("defaults context window to 1m and fast mode off", () => { + const state = usePreferencesStore.getState(); + expect(state.lastUsedContextWindow).toBe("1m"); + expect(state.lastUsedFastMode).toBe(false); + }); + + it.each(["200k", "1m"] as const)( + "updates lastUsedContextWindow to %s via setter", + (value) => { + usePreferencesStore.getState().setLastUsedContextWindow(value); + expect(usePreferencesStore.getState().lastUsedContextWindow).toBe(value); + }, + ); + + it.each([true, false])( + "updates lastUsedFastMode to %s via setter", + (enabled) => { + usePreferencesStore.getState().setLastUsedFastMode(enabled); + expect(usePreferencesStore.getState().lastUsedFastMode).toBe(enabled); + }, + ); + + it("resets the last-used agent config to a harness's mode and effort", () => { + usePreferencesStore.getState().setLastUsedContextWindow("200k"); + usePreferencesStore.getState().setLastUsedFastMode(true); + + usePreferencesStore.getState().resetLastUsedAgentConfig("auto", "medium"); + + const state = usePreferencesStore.getState(); + expect(state.lastNewTaskMode).toBe("auto"); + expect(state.lastUsedReasoningEffort).toBe("medium"); + expect(state.lastUsedContextWindow).toBe("1m"); + expect(state.lastUsedFastMode).toBe(false); + }); + + it("resets the stale agent selection when migrating a pre-versioned install", () => { + const migrate = usePreferencesStore.persist.getOptions().migrate; + const migrated = migrate?.( + { + theme: "dark", + lastUsedReasoningEffort: "ultracode", + defaultReasoningEffort: "low", + }, + 0, + ) as Record; + + expect(migrated).toMatchObject({ + theme: "dark", + defaultReasoningEffort: "low", + lastUsedReasoningEffort: "high", + lastUsedContextWindow: "1m", + lastUsedFastMode: false, + }); + }); +}); + describe("preferencesStore scale sound with task length", () => { it("defaults to false", () => { expect(usePreferencesStore.getState().scaleSoundWithTaskLength).toBe(false); diff --git a/apps/mobile/src/features/preferences/stores/preferencesStore.ts b/apps/mobile/src/features/preferences/stores/preferencesStore.ts index 27db9a559e..527d42cfa2 100644 --- a/apps/mobile/src/features/preferences/stores/preferencesStore.ts +++ b/apps/mobile/src/features/preferences/stores/preferencesStore.ts @@ -3,6 +3,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { Platform } from "react-native"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { + type ContextWindow, + DEFAULT_CONTEXT_WINDOW, +} from "@/features/tasks/composer/options"; export type ThemePreference = "light" | "dark" | "system"; @@ -76,6 +80,16 @@ interface PreferencesState { lastUsedReasoningEffort: string; setLastUsedReasoningEffort: (effort: string) => void; + /** Most recent context window + fast mode picked in the merged agent config + * control. Persisted so the next new-task composer restores them. */ + lastUsedContextWindow: ContextWindow; + setLastUsedContextWindow: (contextWindow: ContextWindow) => void; + lastUsedFastMode: boolean; + setLastUsedFastMode: (enabled: boolean) => void; + /** Resets the last-used agent selection to a new harness's mode/effort with + * default context window and fast mode off, in one write. */ + resetLastUsedAgentConfig: (mode: string, reasoning: string) => void; + autoPublishCloudRuns: boolean; setAutoPublishCloudRuns: (enabled: boolean) => void; @@ -122,6 +136,19 @@ export const usePreferencesStore = create()( setLastUsedReasoningEffort: (effort) => set({ lastUsedReasoningEffort: effort }), + lastUsedContextWindow: DEFAULT_CONTEXT_WINDOW, + setLastUsedContextWindow: (contextWindow) => + set({ lastUsedContextWindow: contextWindow }), + lastUsedFastMode: false, + setLastUsedFastMode: (enabled) => set({ lastUsedFastMode: enabled }), + resetLastUsedAgentConfig: (mode, reasoning) => + set({ + lastNewTaskMode: mode, + lastUsedReasoningEffort: reasoning, + lastUsedContextWindow: DEFAULT_CONTEXT_WINDOW, + lastUsedFastMode: false, + }), + autoPublishCloudRuns: true, setAutoPublishCloudRuns: (enabled) => set({ autoPublishCloudRuns: enabled }), @@ -132,6 +159,22 @@ export const usePreferencesStore = create()( { name: "posthog-preferences", storage: createJSONStorage(() => AsyncStorage), + // Bumped to 1 when the merged agent config control shipped: existing + // installs drop their stale last-used agent selection so they land on the + // new preset defaults instead of a model/effort pair off the new scale. + version: 1, + migrate: (persisted, fromVersion) => { + const state = (persisted ?? {}) as Partial; + if (fromVersion < 1) { + return { + ...state, + lastUsedReasoningEffort: "high", + lastUsedContextWindow: DEFAULT_CONTEXT_WINDOW, + lastUsedFastMode: false, + } as PreferencesState; + } + return state as PreferencesState; + }, partialize: (state) => ({ pingsEnabled: state.pingsEnabled, pushNotificationsEnabled: state.pushNotificationsEnabled, @@ -144,6 +187,8 @@ export const usePreferencesStore = create()( lastNewTaskMode: state.lastNewTaskMode, defaultReasoningEffort: state.defaultReasoningEffort, lastUsedReasoningEffort: state.lastUsedReasoningEffort, + lastUsedContextWindow: state.lastUsedContextWindow, + lastUsedFastMode: state.lastUsedFastMode, autoPublishCloudRuns: state.autoPublishCloudRuns, rtkEnabledCloud: state.rtkEnabledCloud, }), diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index f50a155246..9275533284 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -74,6 +74,10 @@ export interface RunTaskInCloudOptions { model?: string; /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ reasoningEffort?: string; + /** Context window size; only sent for models that support the 1M beta. */ + contextWindow?: "200k" | "1m"; + /** Fast mode; only sent for models that support it. */ + fastMode?: boolean; /** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */ initialPermissionMode?: string; /** Source that triggered this run. */ @@ -101,6 +105,8 @@ export async function runTaskInCloud( adapter: options.runtimeAdapter, model: options.model, reasoningLevel: options.reasoningEffort, + contextWindow: options.contextWindow, + fastMode: options.fastMode, initialPermissionMode: options.initialPermissionMode as | ExecutionMode | undefined, diff --git a/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx b/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx index 3516aa7c75..8338a5872a 100644 --- a/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx +++ b/apps/mobile/src/features/tasks/composer/AgentConfigControls.test.tsx @@ -4,14 +4,21 @@ import { act, create } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; import { AgentConfigControls } from "./AgentConfigControls"; +const flagState = { fastMode: false as boolean }; + +vi.mock("posthog-react-native", () => ({ + useFeatureFlag: () => flagState.fastMode, +})); + vi.mock("phosphor-react-native", () => { const icon = (name: string) => (props: Record) => createElement(name, props); return { - BrainIcon: icon("BrainIcon"), + ArrowCounterClockwise: icon("ArrowCounterClockwise"), CaretDown: icon("CaretDown"), Check: icon("Check"), Cpu: icon("Cpu"), + Lightning: icon("Lightning"), PauseIcon: icon("PauseIcon"), PencilIcon: icon("PencilIcon"), Robot: icon("Robot"), @@ -32,22 +39,46 @@ vi.mock("@/components/SheetContainer", () => ({ vi.mock("@/lib/theme", () => ({ useThemeColors: () => ({ - gray: { 10: "#777", 11: "#555" }, + gray: { 10: "#777", 11: "#555", 12: "#111" }, accent: { 9: "#f60", 11: "#f60" }, + status: { warning: "#d97706" }, + background: "#fff", }), })); -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", - }, -]; +const ladderModelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-sonnet-5", + options: [ + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + { value: "claude-fable-5", name: "Claude Fable 5" }, + ], + category: "model", + description: "Choose a model", +}; + +const configOptions: CloudTaskConfigOption[] = [ladderModelOption]; + +function baseProps() { + return { + adapter: "claude" as const, + mode: "plan" as const, + model: "claude-sonnet-5", + reasoning: "medium" as const, + contextWindow: "1m" as const, + fastMode: false, + configOptions, + onAdapterChange: vi.fn(), + onModeChange: vi.fn(), + onModelChange: vi.fn(), + onReasoningChange: vi.fn(), + onContextWindowChange: vi.fn(), + onFastModeChange: vi.fn(), + }; +} function findPressableWithText( renderer: ReturnType, @@ -60,68 +91,89 @@ function findPressableWithText( ); } +function render(props: ReturnType) { + let renderer!: ReturnType; + act(() => { + renderer = create(createElement(AgentConfigControls, props)); + }); + return renderer; +} + 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, - }), - ); - }); + it("summarizes model and reasoning in a single pill", () => { + const renderer = render(baseProps()); + expect(() => + findPressableWithText(renderer, "Claude Sonnet 5 · Medium"), + ).not.toThrow(); + }); + + it("applies model and effort together when a preset is picked", () => { + const props = baseProps(); + const renderer = render(props); - act(() => findPressableWithText(renderer, "Sonnet 4.6").props.onPress()); act(() => - findPressableWithText(renderer, "Switch to Codex").props.onPress(), + findPressableWithText( + renderer, + "Claude Sonnet 5 · Medium", + ).props.onPress(), + ); + act(() => + findPressableWithText( + renderer, + "Claude Opus 5 · Extra High", + ).props.onPress(), ); - expect(onAdapterChange).toHaveBeenCalledWith({ + expect(props.onModelChange).toHaveBeenCalledWith("claude-opus-5"); + expect(props.onReasoningChange).toHaveBeenCalledWith("xhigh"); + }); + + it("resets to the other harness's middle preset when switching harness", () => { + const props = baseProps(); + const renderer = render(props); + + act(() => + findPressableWithText( + renderer, + "Claude Sonnet 5 · Medium", + ).props.onPress(), + ); + act(() => findPressableWithText(renderer, "Advanced").props.onPress()); + act(() => findPressableWithText(renderer, "Codex").props.onPress()); + + expect(props.onAdapterChange).toHaveBeenCalledWith({ adapter: "codex", mode: "auto", - model: "gpt-5.5", - reasoning: "high", + model: "gpt-5.6-sol", + reasoning: "medium", }); - expect(onModeChange).not.toHaveBeenCalled(); - expect(onModelChange).not.toHaveBeenCalled(); - expect(onReasoningChange).not.toHaveBeenCalled(); }); it("hides adapter switching while the active run locks the adapter", () => { - let renderer!: ReturnType; - - act(() => { - renderer = create( - createElement(AgentConfigControls, { - adapter: "claude", - mode: "plan", - model: "claude-sonnet-4-6", - reasoning: "high", - configOptions, - canChangeAdapter: false, - onAdapterChange: vi.fn(), - onModeChange: vi.fn(), - onModelChange: vi.fn(), - onReasoningChange: vi.fn(), - }), - ); - }); + const props = { ...baseProps(), canChangeAdapter: false }; + const renderer = render(props); - act(() => findPressableWithText(renderer, "Sonnet 4.6").props.onPress()); + act(() => + findPressableWithText( + renderer, + "Claude Sonnet 5 · Medium", + ).props.onPress(), + ); + act(() => findPressableWithText(renderer, "Advanced").props.onPress()); + + expect(() => findPressableWithText(renderer, "Codex")).toThrow(); + }); + + it("only surfaces the fast mode toggle when the flag is on and the model supports it", () => { + flagState.fastMode = true; + const props = { ...baseProps(), model: "claude-opus-5" }; + const renderer = render(props); + + act(() => + findPressableWithText(renderer, "Claude Opus 5 · Medium").props.onPress(), + ); + expect(() => renderer.root.findByType("Lightning" as never)).not.toThrow(); - expect(() => findPressableWithText(renderer, "Switch to Codex")).toThrow(); + flagState.fastMode = false; }); }); diff --git a/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx b/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx index 8f5d1aa360..50f9f4ca9a 100644 --- a/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx +++ b/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx @@ -1,48 +1,57 @@ import { getAvailableModesForAdapter } from "@posthog/core/sessions/executionModes"; -import { - type CloudComposerSelection, - resolveCloudComposerAdapterChange, - resolveCloudComposerModelChange, -} from "@posthog/core/task-detail/composerModelPolicy"; +import type { CloudComposerSelection } from "@posthog/core/task-detail/composerModelPolicy"; import { type Adapter, type CloudTaskConfigOption, type ExecutionMode, + FAST_MODE_FLAG, getReasoningEffortOptions, type SupportedReasoningEffort, + supports1MContext, + supportsFastMode, } from "@posthog/shared"; import { - BrainIcon, Cpu, + Lightning, PauseIcon, PencilIcon, Robot, ShieldCheck, Sparkle, } from "phosphor-react-native"; +import { useFeatureFlag } from "posthog-react-native"; import { type ReactNode, useState } from "react"; import { useThemeColors } from "@/lib/theme"; +import { AgentConfigSheet } from "./AgentConfigSheet"; import { + type AgentPreset, + type ContextWindow, + DEFAULT_CONTEXT_WINDOW, + getAgentPresets, getComposerModelOptions, getConfigOptionLabel, + getMiddlePreset, getMobileExecutionModes, getModelConfigOption, + resolveHarnessSwitchSelection, } 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; + contextWindow: ContextWindow; + fastMode: boolean; configOptions: readonly CloudTaskConfigOption[]; onAdapterChange: (selection: CloudComposerSelection) => void; onModeChange: (mode: ExecutionMode) => void; onModelChange: (model: string) => void; onReasoningChange: (reasoning: SupportedReasoningEffort) => void; + onContextWindowChange: (contextWindow: ContextWindow) => void; + onFastModeChange: (enabled: boolean) => void; canChangeAdapter?: boolean; } @@ -69,23 +78,56 @@ export function AgentConfigControls({ mode, model, reasoning, + contextWindow, + fastMode, configOptions, onAdapterChange, onModeChange, onModelChange, onReasoningChange, + onContextWindowChange, + onFastModeChange, canChangeAdapter = true, }: AgentConfigControlsProps) { const themeColors = useThemeColors(); const [modeSheetOpen, setModeSheetOpen] = useState(false); - const [modelSheetOpen, setModelSheetOpen] = useState(false); - const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); + const [configSheetOpen, setConfigSheetOpen] = useState(false); + const fastModeFlagEnabled = !!useFeatureFlag(FAST_MODE_FLAG); + const executionModes = getMobileExecutionModes( getAvailableModesForAdapter(adapter), ); const modelConfigOption = getModelConfigOption(configOptions); const modelOptions = getComposerModelOptions(modelConfigOption); const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; + const presets = getAgentPresets(adapter, modelConfigOption); + + const modelLabel = + getConfigOptionLabel(modelConfigOption.options, model) ?? model; + const effortLabel = + reasoningOptions.find((option) => option.value === reasoning)?.name ?? + reasoning; + const configLabel = + reasoningOptions.length > 0 ? `${modelLabel} · ${effortLabel}` : modelLabel; + + const fastModeAvailable = + fastModeFlagEnabled && adapter === "claude" && supportsFastMode(model); + const fastActive = fastModeAvailable && fastMode; + const contextWindowAvailable = supports1MContext(model); + + const applyPreset = (preset: AgentPreset) => { + if (preset.model !== model) onModelChange(preset.model); + if (preset.effort !== reasoning) onReasoningChange(preset.effort); + }; + + const handleReset = () => { + const middle = getMiddlePreset(presets); + if (middle) applyPreset(middle); + if (contextWindow !== DEFAULT_CONTEXT_WINDOW) { + onContextWindowChange(DEFAULT_CONTEXT_WINDOW); + } + if (fastMode) onFastModeChange(false); + }; return ( <> @@ -103,27 +145,22 @@ export function AgentConfigControls({ + ) : adapter === "codex" ? ( ) : ( ) } - label={getConfigOptionLabel(modelConfigOption.options, model) ?? model} - onPress={() => setModelSheetOpen(true)} + label={configLabel} + onPress={() => setConfigSheetOpen(true)} /> - {reasoningOptions.length > 0 ? ( - } - label={ - reasoningOptions.find((option) => option.value === reasoning) - ?.name ?? reasoning - } - onPress={() => setReasoningSheetOpen(true)} - /> - ) : null} - - { - if (value === SWITCH_ADAPTER_VALUE) { - onAdapterChange(resolveCloudComposerAdapterChange(adapter)); - return; + setConfigSheetOpen(false)} + adapter={adapter} + model={model} + reasoning={reasoning} + contextWindow={contextWindow} + fastMode={fastActive} + presets={presets} + reasoningOptions={reasoningOptions} + modelOptions={modelOptions} + fastModeAvailable={fastModeAvailable} + contextWindowAvailable={contextWindowAvailable} + canChangeAdapter={canChangeAdapter} + onSelectPreset={applyPreset} + onModelChange={onModelChange} + onReasoningChange={onReasoningChange} + onAdapterSelect={(next) => { + if (next !== adapter) { + onAdapterChange(resolveHarnessSwitchSelection(adapter)); } - 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: , - }))} + onFastModeChange={onFastModeChange} + onContextWindowChange={onContextWindowChange} + onReset={handleReset} /> ); diff --git a/apps/mobile/src/features/tasks/composer/AgentConfigSheet.tsx b/apps/mobile/src/features/tasks/composer/AgentConfigSheet.tsx new file mode 100644 index 0000000000..b0b38cbbf8 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/AgentConfigSheet.tsx @@ -0,0 +1,273 @@ +import { Text } from "@components/text"; +import type { Adapter, SupportedReasoningEffort } from "@posthog/shared"; +import { + ArrowCounterClockwise, + CaretDown, + Check, + Lightning, +} from "phosphor-react-native"; +import { useState } from "react"; +import { Pressable, ScrollView, Switch, View } from "react-native"; +import { SheetContainer } from "@/components/SheetContainer"; +import { useThemeColors } from "@/lib/theme"; +import type { AgentPreset, ContextWindow, MobileModelOption } from "./options"; + +const ADAPTER_LABELS: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +interface AgentConfigSheetProps { + open: boolean; + onClose: () => void; + adapter: Adapter; + model: string; + reasoning: SupportedReasoningEffort; + contextWindow: ContextWindow; + fastMode: boolean; + presets: AgentPreset[]; + reasoningOptions: ReadonlyArray<{ value: string; name: string }>; + modelOptions: MobileModelOption[]; + fastModeAvailable: boolean; + contextWindowAvailable: boolean; + canChangeAdapter: boolean; + onSelectPreset: (preset: AgentPreset) => void; + onModelChange: (model: string) => void; + onReasoningChange: (value: SupportedReasoningEffort) => void; + onAdapterSelect: (adapter: Adapter) => void; + onFastModeChange: (enabled: boolean) => void; + onContextWindowChange: (value: ContextWindow) => void; + onReset: () => void; +} + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function Row({ + label, + description, + selected, + onPress, +}: { + label: string; + description?: string; + selected: boolean; + onPress: () => void; +}) { + const themeColors = useThemeColors(); + return ( + + + {label} + {description ? ( + {description} + ) : null} + + {selected ? ( + + ) : null} + + ); +} + +function Segmented({ + value, + options, + onChange, +}: { + value: T; + options: ReadonlyArray<{ value: T; label: string }>; + onChange: (value: T) => void; +}) { + return ( + + {options.map((option) => { + const active = option.value === value; + return ( + onChange(option.value)} + className={`flex-1 items-center rounded-md py-1.5 ${ + active ? "bg-background" : "active:opacity-60" + }`} + > + + {option.label} + + + ); + })} + + ); +} + +export function AgentConfigSheet({ + open, + onClose, + adapter, + model, + reasoning, + contextWindow, + fastMode, + presets, + reasoningOptions, + modelOptions, + fastModeAvailable, + contextWindowAvailable, + canChangeAdapter, + onSelectPreset, + onModelChange, + onReasoningChange, + onAdapterSelect, + onFastModeChange, + onContextWindowChange, + onReset, +}: AgentConfigSheetProps) { + const themeColors = useThemeColors(); + const [advancedOpen, setAdvancedOpen] = useState(false); + + return ( + + + + Model & reasoning + + {fastModeAvailable ? ( + + + Fast + + + ) : null} + + + + {presets.length > 0 ? ( + <> + + Faster + Smarter + + {presets.map((preset) => { + const selected = + preset.model === model && preset.effort === reasoning; + return ( + onSelectPreset(preset)} + /> + ); + })} + + ) : null} + + {contextWindowAvailable ? ( + <> + Context window + + + ) : null} + + setAdvancedOpen((prev) => !prev)} + className="mt-4 flex-row items-center gap-1.5 px-4 py-2 active:opacity-60" + > + + Advanced + + + {advancedOpen ? ( + <> + {canChangeAdapter ? ( + <> + Harness + + + ) : null} + + Model + {modelOptions.map((option) => ( + { + if (!option.disabled) onModelChange(option.value); + }} + /> + ))} + + {reasoningOptions.length > 0 ? ( + <> + Reasoning + {reasoningOptions.map((option) => ( + + onReasoningChange( + option.value as SupportedReasoningEffort, + ) + } + /> + ))} + + ) : null} + + + + Reset to default + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 8de9148624..65d9e5d83b 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -50,6 +50,7 @@ import { } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; import { + type ContextWindow, filterKimiModelConfigOptions, getModelConfigOption, resolveComposerPrimaryAction, @@ -77,11 +78,15 @@ interface TaskChatComposerProps { mode: ExecutionMode; model: string; reasoning: SupportedReasoningEffort; + contextWindow: ContextWindow; + fastMode: boolean; onAdapterChange: (selection: CloudComposerSelection) => void; canChangeAdapter?: boolean; onModeChange: (mode: ExecutionMode) => void; onModelChange: (model: string) => void; onReasoningChange: (reasoning: SupportedReasoningEffort) => void; + onContextWindowChange: (contextWindow: ContextWindow) => void; + onFastModeChange: (enabled: boolean) => void; /** Steer vs Queue behaviour for messages sent while a turn is running. */ messagingMode: MessagingMode; queuedCount: number; @@ -104,11 +109,15 @@ export function TaskChatComposer({ mode, model, reasoning, + contextWindow, + fastMode, onAdapterChange, canChangeAdapter = true, onModeChange, onModelChange, onReasoningChange, + onContextWindowChange, + onFastModeChange, messagingMode, queuedCount, onToggleMessagingMode, @@ -405,11 +414,15 @@ export function TaskChatComposer({ mode={mode} model={model} reasoning={reasoning} + contextWindow={contextWindow} + fastMode={fastMode} configOptions={configOptions} onAdapterChange={onAdapterChange} onModeChange={onModeChange} onModelChange={onModelChange} onReasoningChange={onReasoningChange} + onContextWindowChange={onContextWindowChange} + onFastModeChange={onFastModeChange} canChangeAdapter={canChangeAdapter} /> diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts index 47b77eb79f..9f5b1070fc 100644 --- a/apps/mobile/src/features/tasks/composer/options.test.ts +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest"; import { filterKimiModelConfigOptions, filterKimiModelOption, + getAgentPresets, getComposerModelOptions, + getMiddlePreset, getMobileExecutionModes, resolveComposerPrimaryAction, + resolveHarnessSwitchSelection, } from "./options"; const KIMI = "moonshotai/kimi-k3"; @@ -138,6 +141,85 @@ describe("mobile composer options", () => { }); }); + describe("agent presets", () => { + const ladderConfig: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-opus-5", + options: [ + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + // Restricted models drop out of the scale entirely. + { + value: "claude-fable-5", + name: "Claude Fable 5", + _meta: restrictedModelMeta(), + }, + ], + category: "model", + description: "Choose a model", + }; + + it("maps only the offered, unrestricted, effort-valid ladder notches", () => { + expect(getAgentPresets("claude", ladderConfig)).toEqual([ + { + model: "claude-sonnet-5", + effort: "medium", + modelLabel: "Claude Sonnet 5", + effortLabel: "Medium", + }, + { + model: "claude-sonnet-5", + effort: "high", + modelLabel: "Claude Sonnet 5", + effortLabel: "High", + }, + { + model: "claude-opus-5", + effort: "medium", + modelLabel: "Claude Opus 5", + effortLabel: "Medium", + }, + { + model: "claude-opus-5", + effort: "xhigh", + modelLabel: "Claude Opus 5", + effortLabel: "Extra High", + }, + ]); + }); + + it("returns the balanced middle notch", () => { + const presets = getAgentPresets("claude", ladderConfig); + expect(getMiddlePreset(presets)).toEqual({ + model: "claude-sonnet-5", + effort: "high", + modelLabel: "Claude Sonnet 5", + effortLabel: "High", + }); + expect(getMiddlePreset([])).toBeUndefined(); + }); + }); + + describe("resolveHarnessSwitchSelection", () => { + it("switches to the codex middle notch", () => { + expect(resolveHarnessSwitchSelection("claude")).toMatchObject({ + adapter: "codex", + model: "gpt-5.6-sol", + reasoning: "medium", + }); + }); + + it("switches to the claude middle notch", () => { + expect(resolveHarnessSwitchSelection("codex")).toMatchObject({ + adapter: "claude", + model: "claude-opus-5", + reasoning: "medium", + }); + }); + }); + it.each([ [{ hasContent: true }, "send"], [{ canStop: true }, "stop"], diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index eac5bac776..8a9da1f794 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,10 +1,28 @@ import type { ModeInfo } from "@posthog/core/sessions/executionModes"; import { + type CloudComposerSelection, + resolveCloudComposerAdapterChange, +} from "@posthog/core/task-detail/composerModelPolicy"; +import { + type Adapter, type CloudTaskConfigOption, + getCapabilityLadder, + getReasoningEffortOptions, isModalModelId, isRestrictedModelOption, + type SupportedReasoningEffort, } from "@posthog/shared"; +export type ContextWindow = "200k" | "1m"; +export const DEFAULT_CONTEXT_WINDOW: ContextWindow = "1m"; + +export interface AgentPreset { + model: string; + effort: SupportedReasoningEffort; + modelLabel: string; + effortLabel: string; +} + export interface MobileModelOption { value: string; label: string; @@ -85,6 +103,62 @@ export function getConfigOptionLabel( return options.find((option) => option.value === value)?.name ?? value; } +/** + * The Faster → Smarter preset scale for the merged model + reasoning control, + * derived from the shared capability ladder. A notch survives only when its + * model is present and unrestricted in the live config and its effort is + * supported for that model, so the scale never offers an unusable pairing. + */ +export function getAgentPresets( + adapter: Adapter, + modelOption: CloudTaskConfigOption, +): AgentPreset[] { + return getCapabilityLadder(adapter).flatMap((notch) => { + const entry = modelOption.options.find((o) => o.value === notch.model); + if (!entry || isRestrictedModelOption(entry._meta)) return []; + const effortOption = getReasoningEffortOptions(adapter, notch.model)?.find( + (option) => option.value === notch.effort, + ); + if (!effortOption) return []; + return [ + { + model: notch.model, + effort: notch.effort, + modelLabel: entry.name, + effortLabel: effortOption.name, + }, + ]; + }); +} + +/** Index of the balanced middle notch on a Faster→Smarter ordered scale. */ +function middleIndex(length: number): number { + return Math.floor((length - 1) / 2); +} + +/** The balanced middle notch used as the reset target and harness default. */ +export function getMiddlePreset( + presets: readonly AgentPreset[], +): AgentPreset | undefined { + if (presets.length === 0) return undefined; + return presets[middleIndex(presets.length)]; +} + +/** + * Switches to the other harness, landing on that harness's middle-notch model + * and effort rather than a bare default. The composer re-resolves the model + * against the live config once it loads if this pairing isn't offered yet. + */ +export function resolveHarnessSwitchSelection( + currentAdapter: Adapter, +): CloudComposerSelection { + const base = resolveCloudComposerAdapterChange(currentAdapter); + const ladder = getCapabilityLadder(base.adapter); + const middle = ladder[middleIndex(ladder.length)]; + if (!middle) return base; + return { ...base, model: middle.model, reasoning: middle.effort }; +} + export type ComposerPrimaryAction = | "send" | "stop" diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 4985ffeaee..83ffde108e 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -34,6 +34,8 @@ interface Props { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + contextWindow?: "200k" | "1m" | null; + fastMode?: boolean | null; sandboxEnvironmentId?: string | null; customImageId?: string | null; } @@ -202,6 +204,29 @@ describe("useWarmTask", () => { }, ); + it("forwards context window and fast mode", async () => { + render({ + ...composing, + runtimeAdapter: "claude", + model: "claude-opus-4-8", + reasoningEffort: "high", + contextWindow: "200k", + fastMode: true, + }); + await flushDebounce(); + + expect(mockWarmTask).toHaveBeenCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + runtime_adapter: "claude", + model: "claude-opus-4-8", + reasoning_effort: "high", + context_window: "200k", + fast_mode: true, + }); + }); + it("forwards the sandbox environment and custom image", async () => { render({ ...composing, diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index d3034f8638..c9396ffb46 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -16,6 +16,8 @@ interface UseWarmTaskOptions { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + contextWindow?: "200k" | "1m" | null; + fastMode?: boolean | null; sandboxEnvironmentId?: string | null; customImageId?: string | null; } @@ -28,6 +30,8 @@ export function useWarmTask({ runtimeAdapter, model, reasoningEffort, + contextWindow, + fastMode, sandboxEnvironmentId, customImageId, }: UseWarmTaskOptions): void { @@ -40,6 +44,8 @@ export function useWarmTask({ const normalizedRuntimeAdapter = runtimeAdapter ?? null; const normalizedModel = model ?? null; const normalizedReasoningEffort = reasoningEffort ?? null; + const normalizedContextWindow = contextWindow ?? null; + const normalizedFastMode = fastMode ?? null; const normalizedSandboxEnvironmentId = sandboxEnvironmentId ?? null; const normalizedCustomImageId = customImageId ?? null; const eligible = @@ -49,7 +55,7 @@ export function useWarmTask({ !composerIsEmpty; const key = repository && githubIntegrationId != null - ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}:${normalizedSandboxEnvironmentId ?? ""}:${normalizedCustomImageId ?? ""}` + ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}:${normalizedContextWindow ?? ""}:${normalizedFastMode ?? ""}:${normalizedSandboxEnvironmentId ?? ""}:${normalizedCustomImageId ?? ""}` : null; useEffect(() => { @@ -74,6 +80,8 @@ export function useWarmTask({ const warmRuntimeAdapter = normalizedRuntimeAdapter; const warmModel = normalizedModel; const warmReasoningEffort = normalizedReasoningEffort; + const warmContextWindow = normalizedContextWindow; + const warmFastMode = normalizedFastMode; const warmSandboxEnvironmentId = normalizedSandboxEnvironmentId; const warmCustomImageId = normalizedCustomImageId; debounceRef.current = setTimeout(() => { @@ -87,6 +95,8 @@ export function useWarmTask({ runtime_adapter: warmRuntimeAdapter, model: warmModel, reasoning_effort: warmReasoningEffort, + ...(warmContextWindow ? { context_window: warmContextWindow } : {}), + ...(warmFastMode != null ? { fast_mode: warmFastMode } : {}), ...(warmSandboxEnvironmentId ? { sandbox_environment_id: warmSandboxEnvironmentId } : {}), @@ -108,6 +118,8 @@ export function useWarmTask({ normalizedRuntimeAdapter, normalizedModel, normalizedReasoningEffort, + normalizedContextWindow, + normalizedFastMode, normalizedSandboxEnvironmentId, normalizedCustomImageId, ]); diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index f7589e0333..20ce1259c2 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -7,6 +7,7 @@ import type { import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import type { ContextWindow } from "../composer/options"; import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; @@ -24,6 +25,8 @@ export interface TaskComposerConfig { mode?: ExecutionMode; model?: string; reasoning?: SupportedReasoningEffort; + contextWindow?: ContextWindow; + fastMode?: boolean; } interface TaskUIState { diff --git a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts index 320a2f6a56..d313be7218 100644 --- a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts +++ b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts @@ -28,4 +28,50 @@ describe("buildCloudTaskRunConfig", () => { }).reasoningLevel, ).toBeUndefined(); }); + + it("sends context window and fast mode for a supporting model", () => { + expect( + buildCloudTaskRunConfig({ + adapter: "claude", + mode: "plan", + model: "claude-opus-4-8", + reasoning: "high", + contextWindow: "200k", + fastMode: true, + }), + ).toEqual({ + adapter: "claude", + model: "claude-opus-4-8", + reasoningLevel: "high", + initialPermissionMode: "plan", + contextWindow: "200k", + fastMode: true, + }); + }); + + it("omits fast mode for a model that supports 1M context but not fast mode", () => { + const config = buildCloudTaskRunConfig({ + adapter: "claude", + mode: "plan", + model: "claude-sonnet-5", + reasoning: "high", + contextWindow: "1m", + fastMode: true, + }); + expect(config).toMatchObject({ contextWindow: "1m" }); + expect(config).not.toHaveProperty("fastMode"); + }); + + it("omits context window for a model without the 1M beta", () => { + const config = buildCloudTaskRunConfig({ + adapter: "claude", + mode: "plan", + model: "claude-haiku-4-5", + reasoning: "high", + contextWindow: "1m", + fastMode: true, + }); + expect(config).not.toHaveProperty("contextWindow"); + expect(config).not.toHaveProperty("fastMode"); + }); }); diff --git a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts index 0ebddf8c4b..0a12b1d3e3 100644 --- a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts +++ b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts @@ -3,18 +3,25 @@ import { type ExecutionMode, getReasoningEffortOptions, type SupportedReasoningEffort, + supports1MContext, + supportsFastMode, } from "@posthog/shared"; +import type { ContextWindow } from "@/features/tasks/composer/options"; export function buildCloudTaskRunConfig({ adapter, mode, model, reasoning, + contextWindow, + fastMode, }: { adapter: Adapter; mode: ExecutionMode; model: string; reasoning: SupportedReasoningEffort; + contextWindow?: ContextWindow; + fastMode?: boolean; }) { return { adapter, @@ -24,5 +31,7 @@ export function buildCloudTaskRunConfig({ ? undefined : reasoning, initialPermissionMode: mode, + ...(contextWindow && supports1MContext(model) ? { contextWindow } : {}), + ...(supportsFastMode(model) ? { fastMode: !!fastMode } : {}), }; } diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 4af1b4c9bf..05fb47285a 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -3086,6 +3086,8 @@ export class PostHogAPIClient { runtime_adapter?: string | null; model?: string | null; reasoning_effort?: string | null; + context_window?: "200k" | "1m" | null; + fast_mode?: boolean | null; sandbox_environment_id?: string | null; custom_image_id?: string | null; }): Promise<{ task_id: string; run_id: string } | null> { @@ -3104,6 +3106,12 @@ export class PostHogAPIClient { runtime_adapter: options.runtime_adapter ?? null, model: options.model ?? null, reasoning_effort: options.reasoning_effort ?? null, + ...(options.context_window + ? { context_window: options.context_window } + : {}), + ...(options.fast_mode != null + ? { fast_mode: options.fast_mode } + : {}), ...(options.sandbox_environment_id ? { sandbox_environment_id: options.sandbox_environment_id } : {}), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 720d70e4ed..1648fb76d0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -267,11 +267,15 @@ export { isPrivateIpv6Literal, } from "./private-network"; export { + type CapabilityNotch, DEFAULT_REASONING_EFFORT, + getCapabilityLadder, getReasoningEffortOptions, isSupportedReasoningEffort, type ReasoningEffortOption, type SupportedReasoningEffort, + supports1MContext, + supportsFastMode, } from "./reasoning-effort"; export { type CloudRegion, diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts index a76cc96d62..c332d1f09b 100644 --- a/packages/shared/src/reasoning-effort.ts +++ b/packages/shared/src/reasoning-effort.ts @@ -79,3 +79,58 @@ export function isSupportedReasoningEffort( ) ?? false ); } + +/** One stop on the Faster/Smarter capability scale: a model plus effort pairing. */ +export interface CapabilityNotch { + model: string; + effort: SupportedReasoningEffort; +} + +// Mirrors the desktop ladder in @posthog/agent (unreachable from mobile); the +// two must stay in sync. +const CLAUDE_CAPABILITY_LADDER: readonly CapabilityNotch[] = [ + { model: "claude-sonnet-5", effort: "medium" }, + { model: "claude-sonnet-5", effort: "high" }, + { model: "claude-opus-5", effort: "medium" }, + { model: "claude-opus-5", effort: "xhigh" }, + { model: "claude-fable-5", effort: "max" }, +]; + +const CODEX_CAPABILITY_LADDER: readonly CapabilityNotch[] = [ + { model: "gpt-5.6-terra", effort: "low" }, + { model: "gpt-5.6-sol", effort: "low" }, + { model: "gpt-5.6-sol", effort: "medium" }, + { model: "gpt-5.6-sol", effort: "high" }, + { model: "gpt-5.6-sol", effort: "xhigh" }, +]; + +export function getCapabilityLadder( + adapter: Adapter, +): readonly CapabilityNotch[] { + return adapter === "codex" + ? CODEX_CAPABILITY_LADDER + : CLAUDE_CAPABILITY_LADDER; +} + +const MODELS_WITH_1M_CONTEXT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-fable-5", +]); + +export function supports1MContext(modelId: string): boolean { + return MODELS_WITH_1M_CONTEXT.has(modelId); +} + +const MODELS_WITH_FAST_MODE = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", +]); + +export function supportsFastMode(modelId: string): boolean { + return MODELS_WITH_FAST_MODE.has(modelId); +} From af3479bcb709b5d69c7176727b25201f0205274c Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 30 Jul 2026 12:22:08 +0100 Subject: [PATCH 2/2] feat(mobile): default messaging mode to steer (port #3983) Ports desktop PR #3983 to the React Native app. Steer mode is stable in cloud, and mobile only runs cloud sessions, so new sessions now default to steer instead of queue. Since `defaultMode` is persisted in AsyncStorage, existing installs would otherwise keep queue forever. Bumps the `messaging-mode-storage` persist version to 1 with a migrate that moves untouched installs to steer. Per-task overrides are preserved and still win over the default. An explicit "queue" default is indistinguishable from the old default in the single-key store, so it is reset to steer too (one-time, disclosed tradeoff). Generated-By: PostHog Code Task-Id: 33d606bb-a1b4-48d4-9fb9-e5d26fa4a9f0 --- .../tasks/stores/messagingModeStore.test.ts | 42 ++++++++++++++++--- .../tasks/stores/messagingModeStore.ts | 27 ++++++------ 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/tasks/stores/messagingModeStore.test.ts b/apps/mobile/src/features/tasks/stores/messagingModeStore.test.ts index fc85fdcb37..fa00422623 100644 --- a/apps/mobile/src/features/tasks/stores/messagingModeStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/messagingModeStore.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { type MessagingMode, + migrateMessagingModeState, useMessagingModeStore, } from "./messagingModeStore"; @@ -28,12 +29,15 @@ describe("messagingModeStore", () => { ); }); - it("falls back to the global default when a task has no override", () => { - useMessagingModeStore.getState().setDefaultMode("queue"); - expect(useMessagingModeStore.getState().getEffectiveMode("t1")).toBe( - "queue", - ); - }); + it.each(["queue", "steer"] as const)( + "falls back to the global default (%s) when a task has no override", + (mode) => { + useMessagingModeStore.getState().setDefaultMode(mode); + expect(useMessagingModeStore.getState().getEffectiveMode("t1")).toBe( + mode, + ); + }, + ); it("prefers a per-task override over the global default", () => { useMessagingModeStore.getState().setMode("t1", "queue"); @@ -53,6 +57,8 @@ describe("messagingModeStore", () => { ); }); + // Exercises the migration through the persisted store's wired `migrate` + // option, complementing the direct unit tests below. describe("migration", () => { it.each([ { @@ -91,3 +97,27 @@ describe("messagingModeStore", () => { }); }); }); + +describe("migrateMessagingModeState", () => { + it("moves v0 installs to steer while keeping per-task overrides", () => { + const migrated = migrateMessagingModeState( + { modesByTaskId: { t1: "queue" }, defaultMode: "queue" }, + 0, + ); + expect(migrated).toEqual({ + modesByTaskId: { t1: "queue" }, + defaultMode: "steer", + }); + }); + + it("leaves already-migrated state untouched", () => { + const state = { modesByTaskId: {}, defaultMode: "queue" as const }; + expect(migrateMessagingModeState(state, 1)).toEqual(state); + }); + + it("tolerates missing persisted state", () => { + expect(migrateMessagingModeState(undefined, 0)).toEqual({ + defaultMode: "steer", + }); + }); +}); diff --git a/apps/mobile/src/features/tasks/stores/messagingModeStore.ts b/apps/mobile/src/features/tasks/stores/messagingModeStore.ts index 8482b8db1f..154c4e0770 100644 --- a/apps/mobile/src/features/tasks/stores/messagingModeStore.ts +++ b/apps/mobile/src/features/tasks/stores/messagingModeStore.ts @@ -13,6 +13,20 @@ interface MessagingModeState { getEffectiveMode: (taskId: string | undefined) => MessagingMode; } +/** + * Mobile sessions are all cloud, where steer is stable, so the default moved to + * steer. Existing installs persisted "queue" under v0 and would keep it, so v1 + * rehydrates them to steer. Per-task overrides are untouched; an explicit + * "queue" default is indistinguishable from the old default, so it is reset too. + */ +export function migrateMessagingModeState( + persisted: unknown, + version: number, +): Partial { + const state = (persisted ?? {}) as Partial; + return version < 1 ? { ...state, defaultMode: "steer" } : state; +} + export const useMessagingModeStore = create()( persist( (set, get) => ({ @@ -35,18 +49,7 @@ export const useMessagingModeStore = create()( name: "messaging-mode-storage", storage: createJSONStorage(() => AsyncStorage), version: 1, - // Pre-v1 installs persisted the old "queue" default, so flip them to the - // new "steer" default once. Per-task overrides are left untouched. - migrate: (persisted, version) => { - const state = persisted as Pick< - MessagingModeState, - "modesByTaskId" | "defaultMode" - >; - if (version < 1 && state.defaultMode === "queue") { - return { ...state, defaultMode: "steer" }; - } - return state; - }, + migrate: migrateMessagingModeState, partialize: (state) => ({ modesByTaskId: state.modesByTaskId, defaultMode: state.defaultMode,