diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6084d8e15c68..3ceb21822e77 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -64,6 +64,7 @@ import { resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { resolveNewTaskInteractionMode } from "./new-task-interaction-mode"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -654,9 +655,9 @@ export function NewTaskDraftScreen(props: { draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; - const interactionMode = flow.planModeEnabled - ? (draft.interactionMode ?? flow.interactionMode) - : "default"; + const interactionMode = resolveNewTaskInteractionMode( + draft.interactionMode ?? flow.interactionMode, + ); const initialMessageText = draft.text.trim(); if ( @@ -701,10 +702,12 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - // Drop the workspace selection with the content: the next task should - // re-resolve mode/branch/origin from the server's configured defaults - // instead of resurrecting this task's picks. - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + // Drop one-task controls with the content: the next task should return + // to Build mode and re-resolve workspace settings from server defaults. + clearComposerDraftContent(draftKey, { + clearInteractionMode: true, + clearWorkspaceSelection: true, + }); } navigation.getParent()?.goBack(); return; @@ -768,7 +771,10 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey, { + clearInteractionMode: true, + clearWorkspaceSelection: true, + }); } navigation.dispatch( StackActions.replace("Thread", { diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 637b7b5ee188..97d49060b18c 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,5 +1,6 @@ import type { ModelSelection, + ProviderInteractionMode, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, @@ -51,12 +52,17 @@ import { NATIVE_SHEET_SURFACE_CONTENT_STYLE, } from "../../native/sheet-surface"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { interactionModeFromPlanToggle } from "./new-task-interaction-mode"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; -import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; +import { + buildThreadSettingsOptionItems, + RUNTIME_MODE_CHOICES, + selectableChoices, +} from "./thread-settings-options"; import { modelMatchesCatalogQuery, pendingModelAfterPress, @@ -291,6 +297,8 @@ type ThreadSettingsSessionProps = { readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; + readonly interactionMode?: ProviderInteractionMode; + readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; }; export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & { @@ -338,6 +346,8 @@ type ThreadSettingsSessionValue = { readonly providerGroups: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly onUpdateInteractionMode: ((mode: ProviderInteractionMode) => void) | undefined; readonly displayedDescriptors: ReadonlyArray; readonly providerExpansionOverrides: ReadonlySet; readonly hasLegacyModels: boolean; @@ -456,6 +466,8 @@ function ThreadSettingsSessionProvider( providerGroups: props.providerGroups, runtimeMode: props.runtimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, + interactionMode: props.interactionMode, + onUpdateInteractionMode: props.onUpdateInteractionMode, displayedDescriptors, providerExpansionOverrides, hasLegacyModels, @@ -485,6 +497,8 @@ function ThreadSettingsSessionProvider( pressModel, providerFilter, props.onUpdateRuntimeMode, + props.interactionMode, + props.onUpdateInteractionMode, props.providerGroups, props.runtimeMode, searchQuery, @@ -660,6 +674,10 @@ function ThreadSettingsOptionsItem(props: { }) { const insets = useSafeAreaInsets(); const session = useThreadSettingsSession(); + const optionItems = buildThreadSettingsOptionItems( + session.displayedDescriptors, + session.interactionMode !== undefined && session.onUpdateInteractionMode !== undefined, + ); const bottomToolbarInset = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET @@ -672,7 +690,28 @@ function ThreadSettingsOptionsItem(props: { className="mx-4 overflow-hidden rounded-2xl bg-card" layout={THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION} > - {session.displayedDescriptors.map((descriptor) => { + {optionItems.map((item) => { + if (item.kind === "interaction-mode") { + return ( + + + session.onUpdateInteractionMode?.(interactionModeFromPlanToggle(enabled)) + } + /> + + ); + } + const descriptor = item.descriptor; if (descriptor.type === "select") { return ( navigation.goBack()} /> diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts index e556318855ff..3d12998e6c43 100644 --- a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts +++ b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts @@ -1,57 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; -describe("resolvePendingTaskInteractionMode", () => { - it("preserves a queued plan task while the preference is still loading", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: false, - planModeEnabled: false, - draftInteractionMode: "plan", - queuedInteractionMode: "plan", - }), - ).toBe("plan"); - }); - - it("forces build mode once the disabled preference has loaded", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: true, - planModeEnabled: false, - draftInteractionMode: "plan", - queuedInteractionMode: "plan", - }), - ).toBe("default"); - }); - - it("keeps a fresh draft in build mode while the preference is loading", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: false, - planModeEnabled: false, - draftInteractionMode: "plan", - queuedInteractionMode: undefined, - }), - ).toBe("default"); - }); - - it("honors the draft's mode when the plan preference is enabled", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: true, - planModeEnabled: true, - draftInteractionMode: "plan", - queuedInteractionMode: undefined, - }), - ).toBe("plan"); - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: true, - planModeEnabled: true, - draftInteractionMode: undefined, - queuedInteractionMode: "plan", - }), - ).toBe("default"); +describe("resolveLegacyPlanModeEnabled", () => { + it("stays disabled until an enabled preference has loaded", () => { + expect(resolveLegacyPlanModeEnabled({ loaded: false, preference: true })).toBe(false); + expect(resolveLegacyPlanModeEnabled({ loaded: true, preference: false })).toBe(false); + expect(resolveLegacyPlanModeEnabled({ loaded: true, preference: true })).toBe(true); }); }); diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/features/threads/legacy-plan-mode.ts index e7122125fb58..7ce701d3c766 100644 --- a/apps/mobile/src/features/threads/legacy-plan-mode.ts +++ b/apps/mobile/src/features/threads/legacy-plan-mode.ts @@ -1,29 +1,6 @@ -import { - DEFAULT_PROVIDER_INTERACTION_MODE, - type ProviderInteractionMode, -} from "@t3tools/contracts"; - export function resolveLegacyPlanModeEnabled(input: { readonly loaded: boolean; readonly preference: boolean | undefined; }): boolean { return input.loaded && input.preference === true; } - -export function resolvePendingTaskInteractionMode(input: { - readonly preferenceLoaded: boolean; - readonly planModeEnabled: boolean; - readonly draftInteractionMode: ProviderInteractionMode | undefined; - readonly queuedInteractionMode: ProviderInteractionMode | undefined; -}): ProviderInteractionMode { - if (input.planModeEnabled) { - return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - } - if (!input.preferenceLoaded) { - // Only an existing queued task may retain its previous mode while the - // preference is unknown. A fresh draft still defaults to Build so a stale - // persisted Plan selection cannot bypass a disabled preference at launch. - return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - } - return DEFAULT_PROVIDER_INTERACTION_MODE; -} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..84ec1220f827 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -11,7 +11,6 @@ import type { } from "@t3tools/contracts"; import { CommandId, - DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, MessageId, T3_PROJECT_FILE_NAME, @@ -76,8 +75,8 @@ import { type HomeProjectScope, } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; -import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; +import { resolveNewTaskInteractionMode } from "./new-task-interaction-mode"; import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, @@ -194,7 +193,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); const groupingSettings = useMobileProjectGroupingSettings(); - const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState(); + const { enabled: planModeEnabled } = useLegacyPlanModeState(); const projectScopes = useMemo( () => sortHomeProjectScopes({ @@ -401,9 +400,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) - : DEFAULT_PROVIDER_INTERACTION_MODE; + const interactionMode = resolveNewTaskInteractionMode(selectedProjectDraft.interactionMode); // Stored selections only count while their provider is usable on the // server; otherwise the server's default model wins instead of silently @@ -866,12 +863,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { attachments: draft.attachments, modelSelection: draftModelSelection, runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: resolvePendingTaskInteractionMode({ - preferenceLoaded: planModePreferenceLoaded, - planModeEnabled, - draftInteractionMode: draft.interactionMode, - queuedInteractionMode: editingPendingTask?.interactionMode, - }), + interactionMode: resolveNewTaskInteractionMode( + draft.interactionMode ?? editingPendingTask?.interactionMode, + ), creation: { projectId: selectedProject.id, ...(projectTitle !== undefined ? { projectTitle } : {}), @@ -900,8 +894,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, - planModeEnabled, - planModePreferenceLoaded, startFromOrigin, workspaceMode, ], diff --git a/apps/mobile/src/features/threads/new-task-interaction-mode.test.ts b/apps/mobile/src/features/threads/new-task-interaction-mode.test.ts new file mode 100644 index 000000000000..5b3d5b87d959 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-interaction-mode.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + interactionModeFromPlanToggle, + resolveNewTaskInteractionMode, +} from "./new-task-interaction-mode"; + +describe("new task interaction mode", () => { + it("defaults new chats to Build mode", () => { + expect(resolveNewTaskInteractionMode(undefined)).toBe("default"); + expect(interactionModeFromPlanToggle(false)).toBe("default"); + }); + + it("keeps an explicit Plan mode selection", () => { + expect(resolveNewTaskInteractionMode("plan")).toBe("plan"); + expect(interactionModeFromPlanToggle(true)).toBe("plan"); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-interaction-mode.ts b/apps/mobile/src/features/threads/new-task-interaction-mode.ts new file mode 100644 index 000000000000..e131c7f84998 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-interaction-mode.ts @@ -0,0 +1,14 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type ProviderInteractionMode, +} from "@t3tools/contracts"; + +export function resolveNewTaskInteractionMode( + interactionMode: ProviderInteractionMode | undefined, +): ProviderInteractionMode { + return interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; +} + +export function interactionModeFromPlanToggle(enabled: boolean): ProviderInteractionMode { + return enabled ? "plan" : DEFAULT_PROVIDER_INTERACTION_MODE; +} diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts index 041f8b9de010..aa3f856d526e 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts @@ -1,7 +1,7 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { selectableChoices } from "./thread-settings-options"; +import { buildThreadSettingsOptionItems, selectableChoices } from "./thread-settings-options"; const effortDescriptor: Extract = { id: "effort", @@ -27,3 +27,42 @@ describe("selectableChoices", () => { ]); }); }); + +describe("buildThreadSettingsOptionItems", () => { + const descriptor = (id: string): ProviderOptionDescriptor => ({ + id, + label: id, + type: "boolean", + currentValue: false, + }); + + const itemIds = (items: ReturnType) => + items.map((item) => + item.kind === "interaction-mode" ? "interactionMode" : item.descriptor.id, + ); + + it("puts interaction mode immediately after Fast Mode", () => { + expect( + itemIds( + buildThreadSettingsOptionItems( + [descriptor("effort"), descriptor("fastMode"), descriptor("serviceTier")], + true, + ), + ), + ).toEqual(["effort", "fastMode", "interactionMode", "serviceTier"]); + }); + + it("still shows interaction mode when the provider has no Fast Mode option", () => { + expect( + itemIds( + buildThreadSettingsOptionItems([descriptor("effort"), descriptor("serviceTier")], true), + ), + ).toEqual(["effort", "serviceTier", "interactionMode"]); + }); + + it("does not add interaction mode to existing-thread settings", () => { + expect(itemIds(buildThreadSettingsOptionItems([descriptor("fastMode")], false))).toEqual([ + "fastMode", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts index b678154f83bb..0511d82b6ecd 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -1,5 +1,39 @@ import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +export type ThreadSettingsOptionItem = + | { + readonly kind: "descriptor"; + readonly descriptor: ProviderOptionDescriptor; + } + | { readonly kind: "interaction-mode" }; + +/** + * Plan mode belongs directly after Fast Mode when that provider option is + * available. Providers without Fast Mode still get the new-task control at + * the end of their provider-specific options. + */ +export function buildThreadSettingsOptionItems( + descriptors: ReadonlyArray, + includeInteractionMode: boolean, +): ReadonlyArray { + const items: Array = []; + let insertedInteractionMode = false; + + for (const descriptor of descriptors) { + items.push({ kind: "descriptor", descriptor }); + if (includeInteractionMode && descriptor.id === "fastMode") { + items.push({ kind: "interaction-mode" }); + insertedInteractionMode = true; + } + } + + if (includeInteractionMode && !insertedInteractionMode) { + items.push({ kind: "interaction-mode" }); + } + + return items; +} + /** * Desktop-oriented effort keywords that don't belong in the phone picker. * Prompt-injected values (ultrathink and friends) are filtered from the diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fece..66d4af921de8 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -182,7 +182,7 @@ describe("mobile composer drafts", () => { }); }); - it("drops the workspace selection when clearing a sent new-task draft", () => { + it("drops one-task settings when clearing a sent new-task draft", () => { const draftKey = "new-task:environment-1:project-1"; const draft: ComposerDraft = { text: "send this", @@ -191,6 +191,7 @@ describe("mobile composer drafts", () => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + interactionMode: "plan", workspaceSelection: { mode: "worktree", branch: "main", @@ -201,6 +202,7 @@ describe("mobile composer drafts", () => { expect( clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { + clearInteractionMode: true, clearWorkspaceSelection: true, }), ).toEqual({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596c7..b0e254793edb 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -394,15 +394,24 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearInteractionMode?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { + importedShareIds: _importedShareIds, + interactionMode, + workspaceSelection, + ...retained + } = existing; const draft = { ...retained, + ...(options?.clearInteractionMode || interactionMode === undefined ? {} : { interactionMode }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), @@ -599,7 +608,10 @@ export async function restoreComposerDraftSnapshot( export function clearComposerDraftContent( draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearInteractionMode?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): void { updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c79aea36a0e..8465aa5d4a76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5184,10 +5184,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==}