authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 06:51:20-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 03:51:20-07:00
log30164cb1ba8ea05fdd6be69215dba2cc2f0e2aa8
treeba711e4f663b6f32082f70e7eddd4298fb6197d7
parent06404107261cae24394d6abe39984fb445c5aff5
signature Signed by PGP key B5690EEEBB952194

feat(mobile): one sheet for model and thread settings (#5625)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

11 files changed, 1079 insertions(+), 469 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+56-117
......@@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri
2525import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
2626import { ProviderIcon } from "../../components/ProviderIcon";
2727import { ComposerSurface } from "./ThreadComposer";
28import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
29import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
2830
2931import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
3032import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
31import {
32 applyProviderOptionMenuEvent,
33 buildProviderOptionMenuActions,
34 providerOptionsConfigurationLabel,
35 resolveProviderOptionDescriptors,
36} from "../../lib/providerOptions";
33import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
3734import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3835import {
3936 clearComposerDraftContent,
......@@ -43,7 +40,7 @@ import {
4340 type ComposerDraft,
4441} from "../../state/use-composer-drafts";
4542import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
46import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
43import { resolveSelectableModelSelection } from "../../lib/modelOptions";
4744import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
4845import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
4946import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
......@@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: {
103100 const promptInputRef = useRef<ComposerEditorHandle>(null);
104101 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
105102 const [isComposerFocused, setIsComposerFocused] = useState(false);
103 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
104 editorRef: promptInputRef,
105 isEditorFocused: isComposerFocused,
106 });
106107 const [importingShareKey, setImportingShareKey] = useState<string | null>(null);
107108 const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
108109 const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
......@@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: {
521522
522523 let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null;
523524 const interaction = InteractionManager.runAfterInteractions(() => {
524 focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus());
525 focusFrame = requestAnimationFrame(() => {
526 // The delayed focus can land after the settings sheet opened, which
527 // would pop the keyboard underneath its modal.
528 if (!settingsSheetPresentation.isActiveRef.current) {
529 promptInputRef.current?.focus();
530 } else {
531 settingsSheetPresentation.restoreFocusAfterSave();
532 }
533 });
525534 });
526535
527536 return () => {
......@@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: {
530539 cancelAnimationFrame(focusFrame);
531540 }
532541 };
533 }, [selectedProject]);
542 }, [
543 selectedProject,
544 settingsSheetPresentation.isActiveRef,
545 settingsSheetPresentation.restoreFocusAfterSave,
546 ]);
534547
535548 const environmentMenuActions = useMemo(
536549 () =>
......@@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: {
544557 [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending],
545558 );
546559
547 const modelMenuActions = useMemo(
548 () => buildModelMenuActions(flow.providerGroups, flow.selectedModel),
549 [flow.providerGroups, flow.selectedModel],
550 );
551560 const providerOptionDescriptors = useMemo(
552561 () =>
553562 resolveProviderOptionDescriptors({
......@@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: {
557566 [flow.selectedModel?.options, flow.selectedModelOption?.capabilities],
558567 );
559568
560 const optionsMenuActions = useMemo(
561 () => [
562 ...buildProviderOptionMenuActions(providerOptionDescriptors),
563 {
564 id: "options-runtime",
565 title: "Runtime",
566 subtitle:
567 flow.runtimeMode === "approval-required"
568 ? "Approve actions"
569 : flow.runtimeMode === "auto-accept-edits"
570 ? "Auto-accept edits"
571 : flow.runtimeMode === "auto"
572 ? "Auto"
573 : "Full access",
574 subactions: [
575 { id: "options:runtime:approval-required", title: "Approve actions" },
576 { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },
577 { id: "options:runtime:auto", title: "Auto" },
578 { id: "options:runtime:full-access", title: "Full access" },
579 ].map((option) => {
580 const value = option.id.replace("options:runtime:", "");
581 return {
582 id: option.id,
583 title: option.title,
584 state: flow.runtimeMode === value ? ("on" as const) : undefined,
585 };
586 }),
587 },
588 {
589 id: "options-interaction",
590 title: "Interaction",
591 subtitle: flow.interactionMode === "plan" ? "Plan" : "Default",
592 subactions: [
593 { id: "options:interaction:default", title: "Default" },
594 { id: "options:interaction:plan", title: "Plan" },
595 ].map((option) => {
596 const value = option.id.replace("options:interaction:", "");
597 return {
598 id: option.id,
599 title: option.title,
600 state: flow.interactionMode === value ? ("on" as const) : undefined,
601 };
602 }),
603 },
604 ],
605 [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors],
606 );
607
608569 const workspaceMenuActions = useMemo(() => {
609570 const branchActions =
610571 flow.availableBranches.length === 0
......@@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: {
675636 flow.availableBranches.find((branch) => branch.current)?.name ??
676637 flow.availableBranches.find((branch) => branch.isDefault)?.name ??
677638 null;
678 const configurationLabel = useMemo(
679 () => providerOptionsConfigurationLabel(providerOptionDescriptors),
680 [providerOptionDescriptors],
681 );
639 const settingsSummaryLabel = threadSettingsSummaryLabel({
640 modelLabel: flow.selectedModelOption?.label ?? "Model",
641 optionDescriptors: providerOptionDescriptors,
642 runtimeMode: flow.runtimeMode,
643 interactionMode: flow.interactionMode,
644 });
682645 const workspaceLabel = useMemo(
683646 () =>
684647 formatWorkspaceLabel({
......@@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: {
688651 }),
689652 [currentBranchName, flow.selectedBranchName, flow.workspaceMode],
690653 );
691 function handleModelMenuAction(event: string) {
692 if (isIncomingShareTransferPending || !event.startsWith("model:")) {
693 return;
694 }
695 flow.setSelectedModelKey(event.slice("model:".length));
696 }
697
698654 function handleEnvironmentMenuAction(event: string) {
699655 if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
700656 return;
......@@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: {
702658 flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));
703659 }
704660
705 function handleOptionsMenuAction(event: string) {
706 if (isIncomingShareTransferPending) {
707 return;
708 }
709 const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event);
710 if (providerOptions) {
711 flow.setSelectedModelOptions(providerOptions);
712 return;
713 }
714 if (event.startsWith("options:runtime:")) {
715 flow.setRuntimeMode(
716 event.slice("options:runtime:".length) as Parameters<typeof flow.setRuntimeMode>[0],
717 );
718 return;
719 }
720 if (event.startsWith("options:interaction:")) {
721 flow.setInteractionMode(
722 event.slice("options:interaction:".length) as Parameters<typeof flow.setInteractionMode>[0],
723 );
724 }
725 }
726
727661 function handleWorkspaceMenuAction(event: string) {
728662 if (isIncomingShareTransferPending) {
729663 return;
......@@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: {
930864 const isDarkMode = colorScheme === "dark";
931865 // Android expansion follows native editor focus so relayout cannot race
932866 // the touch gesture that opens the keyboard.
933 const isExpanded = !isAndroid || isComposerFocused;
867 // The settings sheet dismisses the keyboard, so its flag keeps the Android
868 // draft composer expanded through the blur (mirrors ThreadComposer).
869 const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
934870 const canStart =
935871 Boolean(flow.selectedProject) &&
936872 Boolean(flow.selectedModel) &&
......@@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: {
984920 showChevron={false}
985921 disabled={isIncomingShareTransferPending}
986922 />
987 <ControlPillMenu
988 actions={modelMenuActions}
989 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
990 >
991 <ComposerToolbarTrigger
992 accessibilityLabel="Model"
993 disabled={isIncomingShareTransferPending}
994 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
995 label={flow.selectedModelOption?.label ?? "Model"}
996 />
997 </ControlPillMenu>
998 <ControlPillMenu
999 actions={optionsMenuActions}
1000 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
1001 >
1002 <ComposerToolbarTrigger
1003 accessibilityLabel="Configuration"
1004 disabled={isIncomingShareTransferPending}
1005 icon="slider.horizontal.3"
1006 label={configurationLabel}
1007 />
1008 </ControlPillMenu>
923 <ComposerToolbarTrigger
924 accessibilityLabel="Thread settings"
925 disabled={isIncomingShareTransferPending}
926 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
927 label={settingsSummaryLabel}
928 maxWidth={320}
929 onPress={settingsSheetPresentation.open}
930 />
1009931 <ControlPillMenu
1010932 actions={environmentMenuActions}
1011933 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
......@@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: {
1031953 </>
1032954 );
1033955
956 const settingsSheet = (
957 <ThreadSettingsSheet
958 visible={settingsSheetPresentation.isVisible}
959 onClose={settingsSheetPresentation.close}
960 onDismissed={settingsSheetPresentation.onDismissed}
961 providerGroups={flow.providerGroups}
962 selectedModel={flow.selectedModel}
963 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
964 optionDescriptors={providerOptionDescriptors}
965 onUpdateOptionSelections={flow.setSelectedModelOptions}
966 runtimeMode={flow.runtimeMode}
967 onUpdateRuntimeMode={flow.setRuntimeMode}
968 />
969 );
970
1034971 const startButton = (
1035972 <ComposerToolbarButton
1036973 accessibilityLabel={
......@@ -1120,6 +1057,7 @@ export function NewTaskDraftScreen(props: {
11201057 ) : null}
11211058 </View>
11221059 </KeyboardAvoidingView>
1060 {settingsSheet}
11231061 </View>
11241062 );
11251063 }
......@@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: {
11531091 </ComposerToolbarRow>
11541092 </View>
11551093 </KeyboardAvoidingView>
1094 {settingsSheet}
11561095 </View>
11571096 );
11581097}
apps/mobile/src/features/threads/ThreadComposer.tsx+57-124
......@@ -51,10 +51,10 @@ import {
5151 ComposerToolbarScroller,
5252 ComposerToolbarTrigger,
5353} from "../../components/ComposerToolbarTrigger";
54import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
54import { ControlPill } from "../../components/ControlPill";
5555import { ProviderIcon } from "../../components/ProviderIcon";
5656import type { DraftComposerImageAttachment } from "../../lib/composerImages";
57import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions";
57import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5858import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5959import type { RemoteClientConnectionState } from "../../lib/connection";
6060import {
......@@ -62,14 +62,11 @@ import {
6262 normalizeSearchQuery,
6363 scoreQueryMatch,
6464} from "@t3tools/shared/searchRanking";
65import {
66 applyProviderOptionMenuEvent,
67 buildProviderOptionMenuActions,
68 providerOptionsConfigurationLabel,
69 resolveProviderOptionDescriptors,
70} from "../../lib/providerOptions";
65import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
7166import { useComposerPathSearch } from "../../state/use-composer-path-search";
7267import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
68import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
69import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
7370
7471/**
7572 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
......@@ -273,15 +270,28 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
273270 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
274271 const inputRef = props.editorRef ?? fallbackInputRef;
275272 const [isFocused, setIsFocused] = useState(false);
273 const settingsSheetPresentation = useThreadSettingsSheetPresentation({
274 editorRef: inputRef,
275 isEditorFocused: isFocused,
276 });
276277 const wasExpandedBeforePreviewRef = useRef(false);
277278 const inFlightThreadIdsRef = useRef(new Set<string>());
278279 const { onExpandedChange } = props;
279280
280281 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
281282 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
282 const isExpanded = isFocused;
283 // Opening and closing count as active so the composer stays expanded while
284 // focus moves between its native editor and the settings modal.
285 const isExpanded = isFocused || settingsSheetPresentation.isActive;
283286 const canSend = hasContent;
284287
288 // Notify the parent from the derived value, not focus events: the parent
289 // sizes the feed inset from this, and blur-during-sheet would otherwise
290 // report collapsed while the composer still renders expanded.
291 useEffect(() => {
292 onExpandedChange?.(isExpanded);
293 }, [isExpanded, onExpandedChange]);
294
285295 const onPressImage = useCallback(
286296 (uri: string) => {
287297 wasExpandedBeforePreviewRef.current = isFocused;
......@@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
299309
300310 const handleFocus = useCallback(() => {
301311 setIsFocused(true);
302 onExpandedChange?.(true);
303 }, [onExpandedChange]);
312 }, []);
304313
305314 const handleBlur = useCallback(() => {
306315 setIsFocused(false);
307 onExpandedChange?.(false);
308 }, [onExpandedChange]);
316 }, []);
309317 const showStopAction =
310318 props.selectedThread.session?.status === "running" ||
311319 props.selectedThread.session?.status === "starting";
......@@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
588596 [props.serverConfig, currentModelSelection],
589597 );
590598 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
599 // An existing thread is bound to its harness: sessions can't move between
600 // provider instances, so the picker only offers the thread's own group.
601 const threadProviderGroups = useMemo(
602 () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId),
603 [providerGroups, currentModelSelection.instanceId],
604 );
591605 const currentModelOption =
592606 modelOptions.find(
593607 (option) =>
......@@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
602616 }),
603617 [currentModelOption?.capabilities, currentModelSelection.options],
604618 );
605 const configurationLabel = useMemo(
606 () => providerOptionsConfigurationLabel(providerOptionDescriptors),
607 [providerOptionDescriptors],
608 );
609 const modelMenuActions = useMemo(
610 () => buildModelMenuActions(providerGroups, currentModelSelection),
611 [providerGroups, currentModelSelection],
612 );
613
614 // ── Options menu ─────────────────────────────────────────
615 const optionsMenuActions = useMemo(
616 () => [
617 ...buildProviderOptionMenuActions(providerOptionDescriptors),
618 {
619 id: "options-runtime",
620 title: "Runtime",
621 subtitle:
622 currentRuntimeMode === "approval-required"
623 ? "Approve actions"
624 : currentRuntimeMode === "auto-accept-edits"
625 ? "Auto-accept edits"
626 : currentRuntimeMode === "auto"
627 ? "Auto"
628 : "Full access",
629 subactions: [
630 { id: "options:runtime:approval-required", title: "Approve actions" },
631 { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },
632 { id: "options:runtime:auto", title: "Auto" },
633 { id: "options:runtime:full-access", title: "Full access" },
634 ].map((option) => {
635 const value = option.id.replace("options:runtime:", "");
636 return {
637 id: option.id,
638 title: option.title,
639 state: currentRuntimeMode === value ? ("on" as const) : undefined,
640 };
641 }),
642 },
643 {
644 id: "options-interaction",
645 title: "Interaction",
646 subtitle: currentInteractionMode === "plan" ? "Plan" : "Default",
647 subactions: [
648 { id: "options:interaction:default", title: "Default" },
649 { id: "options:interaction:plan", title: "Plan" },
650 ].map((option) => {
651 const value = option.id.replace("options:interaction:", "");
652 return {
653 id: option.id,
654 title: option.title,
655 state: currentInteractionMode === value ? ("on" as const) : undefined,
656 };
657 }),
658 },
659 ],
660 [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors],
661 );
662
663 // ── Menu handlers ────────────────────────────────────────
664 function handleModelMenuAction(event: string) {
665 if (!event.startsWith("model:")) {
666 return;
667 }
668 const modelKey = event.slice("model:".length);
669 const option = modelOptions.find((o) => o.key === modelKey);
670 if (option) {
671 props.onUpdateModelSelection(option.selection);
672 }
673 }
674
675 function handleOptionsMenuAction(event: string) {
676 const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event);
677 if (providerOptions) {
678 props.onUpdateModelSelection({
679 ...currentModelSelection,
680 options: providerOptions,
681 });
682 return;
683 }
684 if (event.startsWith("options:runtime:")) {
685 const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode;
686 props.onUpdateRuntimeMode(runtimeMode);
687 return;
688 }
689 if (event.startsWith("options:interaction:")) {
690 const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode;
691 props.onUpdateInteractionMode(interactionMode);
692 }
693 }
619 const settingsSummaryLabel = threadSettingsSummaryLabel({
620 modelLabel: currentModelOption?.label ?? currentModelSelection.model,
621 optionDescriptors: providerOptionDescriptors,
622 runtimeMode: currentRuntimeMode,
623 interactionMode: currentInteractionMode,
624 });
694625
695626 return (
696627 <Animated.View
......@@ -861,28 +792,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
861792 onPress={() => void props.onPickDraftImages()}
862793 showChevron={false}
863794 />
864 <ControlPillMenu
865 actions={modelMenuActions}
866 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
867 >
868 <ComposerToolbarTrigger
869 accessibilityLabel="Model"
870 iconNode={
871 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
872 }
873 label={currentModelOption?.label ?? currentModelSelection.model}
874 />
875 </ControlPillMenu>
876 <ControlPillMenu
877 actions={optionsMenuActions}
878 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
879 >
880 <ComposerToolbarTrigger
881 accessibilityLabel="Configuration"
882 icon="slider.horizontal.3"
883 label={configurationLabel}
884 />
885 </ControlPillMenu>
795 <ComposerToolbarTrigger
796 accessibilityLabel="Thread settings"
797 iconNode={
798 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
799 }
800 label={settingsSummaryLabel}
801 maxWidth={320}
802 onPress={settingsSheetPresentation.open}
803 />
886804 {showStopAction ? (
887805 <ComposerToolbarButton
888806 accessibilityLabel="Stop"
......@@ -916,6 +834,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
916834 ) : null}
917835 </Animated.View>
918836
837 <ThreadSettingsSheet
838 visible={settingsSheetPresentation.isVisible}
839 onClose={settingsSheetPresentation.close}
840 onDismissed={settingsSheetPresentation.onDismissed}
841 providerGroups={threadProviderGroups}
842 selectedModel={currentModelSelection}
843 onSelectModel={(option) => props.onUpdateModelSelection(option.selection)}
844 optionDescriptors={providerOptionDescriptors}
845 onUpdateOptionSelections={(options) =>
846 props.onUpdateModelSelection({ ...currentModelSelection, options })
847 }
848 runtimeMode={currentRuntimeMode}
849 onUpdateRuntimeMode={props.onUpdateRuntimeMode}
850 />
851
919852 <ImageViewing
920853 images={previewImageUri ? [{ uri: previewImageUri }] : []}
921854 imageIndex={0}
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx created+678
......@@ -0,0 +1,678 @@
1import type {
2 ModelSelection,
3 ProviderInteractionMode,
4 ProviderOptionDescriptor,
5 ProviderOptionSelection,
6 RuntimeMode,
7} from "@t3tools/contracts";
8import {
9 getProviderOptionCurrentLabel,
10 getProviderOptionCurrentValue,
11 getProviderOptionDescriptors,
12} from "@t3tools/shared/model";
13import * as Haptics from "expo-haptics";
14import { useCallback, useEffect, useRef, useState } from "react";
15import {
16 Modal,
17 Platform,
18 Pressable,
19 ScrollView,
20 Switch,
21 useWindowDimensions,
22 View,
23} from "react-native";
24import { useSafeAreaInsets } from "react-native-safe-area-context";
25
26import { SymbolView } from "../../components/AppSymbol";
27import { AppText as Text } from "../../components/AppText";
28import { ProviderIcon } from "../../components/ProviderIcon";
29import { cn } from "../../lib/cn";
30import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
31import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
32import { useThemeColor } from "../../lib/useThemeColor";
33import { pendingModelAfterPress } from "./thread-settings-sheet-state";
34import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";
35
36/**
37 * The everyday harnesses stay expanded; every other provider (OpenRouter
38 * catalogs and friends) folds behind its header so a 300-model catalog can't
39 * bury the list.
40 */
41const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);
42
43/**
44 * Desktop-oriented effort keywords that don't belong in the phone picker.
45 * Prompt-injected values (ultrathink and friends) are filtered from the
46 * descriptor metadata; ultracode is a real option but a workflow trigger, not
47 * a reasoning level. A value set elsewhere still displays, it just isn't
48 * offered.
49 */
50const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]);
51
52const RUNTIME_MODE_CHOICES: ReadonlyArray<{
53 readonly mode: RuntimeMode;
54 readonly label: string;
55 readonly shortLabel: string;
56}> = [
57 { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" },
58 { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" },
59 { mode: "auto", label: "Auto", shortLabel: "Auto" },
60 { mode: "full-access", label: "Full access", shortLabel: "Full" },
61];
62
63/**
64 * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,
65 * covering model, provider options, runtime mode, and plan mode in one label.
66 */
67export function threadSettingsSummaryLabel(input: {
68 readonly modelLabel: string;
69 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
70 readonly runtimeMode: RuntimeMode;
71 readonly interactionMode: ProviderInteractionMode;
72}): string {
73 const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode);
74 return [
75 input.modelLabel,
76 ...providerOptionValueLabels(input.optionDescriptors),
77 ...(runtime ? [runtime.shortLabel] : []),
78 ...(input.interactionMode === "plan" ? ["Plan"] : []),
79 ].join(" · ");
80}
81
82function selectableChoices(descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>) {
83 const injected = new Set(descriptor.promptInjectedValues ?? []);
84 return descriptor.options.filter(
85 (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id),
86 );
87}
88
89function ModelRow(props: {
90 readonly option: ModelOption;
91 readonly selected: boolean;
92 readonly onPress: () => void;
93}) {
94 const primaryFg = useThemeColor("--color-primary-foreground");
95 return (
96 <Pressable
97 accessibilityRole="button"
98 accessibilityState={{ selected: props.selected }}
99 onPress={props.onPress}
100 // Selected rows get the same primary treatment as the submenu rows.
101 // Subtle backgrounds (bg-subtle-strong) get overridden by the OS
102 // selection chrome on iOS 26, so use the explicit high-contrast style
103 // everywhere instead.
104 className={cn(
105 "mx-2.5 flex-row items-center gap-2 rounded-xl px-3 py-3.5 active:opacity-70",
106 props.selected ? "bg-primary" : "bg-transparent",
107 )}
108 >
109 <Text
110 className={cn(
111 "shrink text-sm font-t3-medium",
112 props.selected ? "text-primary-foreground" : "text-foreground",
113 )}
114 numberOfLines={1}
115 >
116 {props.option.label}
117 </Text>
118 {props.option.isDefault ? (
119 <View className="rounded-md bg-subtle-strong px-1.5 py-0.5">
120 <Text className="text-3xs font-t3-bold text-foreground-muted">Default</Text>
121 </View>
122 ) : null}
123 {props.option.isLegacy ? (
124 <View className="rounded-md bg-subtle px-1.5 py-0.5">
125 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
126 </View>
127 ) : null}
128 <View className="flex-1" />
129 {props.selected ? (
130 <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" />
131 ) : null}
132 </Pressable>
133 );
134}
135
136/**
137 * Provider section header with the harness logo. Secondary providers render
138 * as a tappable fold (count + chevron while collapsed); primary providers
139 * and the group holding the current selection are static headers.
140 */
141function ProviderHeader(props: {
142 readonly driver: string | undefined;
143 readonly label: string;
144 readonly collapsible: boolean;
145 readonly collapsed: boolean;
146 readonly modelCount: number;
147 readonly onToggle: () => void;
148}) {
149 const iconSubtle = useThemeColor("--color-icon-subtle");
150 return (
151 <Pressable
152 accessibilityRole={props.collapsible ? "button" : "header"}
153 accessibilityState={props.collapsible ? { expanded: !props.collapsed } : undefined}
154 accessibilityLabel={
155 props.collapsible ? `${props.label}, ${props.modelCount} models` : props.label
156 }
157 disabled={!props.collapsible}
158 onPress={props.onToggle}
159 className={cn(
160 "mx-2.5 flex-row items-center gap-2 rounded-xl px-3",
161 props.collapsible ? "py-3.5 active:opacity-70" : "pb-2 pt-4",
162 )}
163 >
164 <ProviderIcon provider={props.driver} size={15} />
165 <Text className="text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted">
166 {props.label}
167 </Text>
168 {props.collapsible ? (
169 <>
170 <View className="flex-1" />
171 {props.collapsed ? (
172 <Text className="text-2xs font-t3-medium text-foreground-muted">
173 {props.modelCount}
174 </Text>
175 ) : null}
176 <SymbolView
177 name={props.collapsed ? "chevron.down" : "chevron.up"}
178 size={11}
179 tintColor={iconSubtle}
180 type="monochrome"
181 />
182 </>
183 ) : null}
184 </Pressable>
185 );
186}
187
188/** Compact row that opens a single-choice submenu panel. */
189function DisclosureRow(props: {
190 readonly label: string;
191 readonly value: string | undefined;
192 readonly disabled?: boolean;
193 readonly onPress: () => void;
194}) {
195 const iconSubtle = useThemeColor("--color-icon-subtle");
196 return (
197 <Pressable
198 accessibilityRole="button"
199 disabled={props.disabled}
200 onPress={props.onPress}
201 className={cn(
202 "flex-row items-center gap-2 px-5 py-3 active:opacity-70",
203 props.disabled && "opacity-40",
204 )}
205 >
206 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
207 <View className="flex-1" />
208 {props.value ? (
209 <Text className="text-sm text-foreground-muted" numberOfLines={1}>
210 {props.value}
211 </Text>
212 ) : null}
213 <SymbolView name="chevron.right" size={12} tintColor={iconSubtle} type="monochrome" />
214 </Pressable>
215 );
216}
217
218/** Single option inside a submenu panel. */
219function ChoiceRow(props: {
220 readonly label: string;
221 readonly selected: boolean;
222 readonly onPress: () => void;
223}) {
224 const primaryFg = useThemeColor("--color-primary-foreground");
225 return (
226 <Pressable
227 accessibilityRole="button"
228 accessibilityState={{ selected: props.selected }}
229 onPress={props.onPress}
230 className={cn(
231 "mx-2.5 flex-row items-center rounded-xl px-3 py-3.5 active:opacity-70",
232 props.selected ? "bg-primary" : "bg-transparent",
233 )}
234 >
235 <Text
236 className={cn(
237 "shrink text-sm font-t3-medium",
238 props.selected ? "text-primary-foreground" : "text-foreground",
239 )}
240 >
241 {props.label}
242 </Text>
243 <View className="flex-1" />
244 {props.selected ? (
245 <SymbolView name="checkmark" size={14} tintColor={primaryFg} type="monochrome" />
246 ) : null}
247 </Pressable>
248 );
249}
250
251function SwitchRow(props: {
252 readonly label: string;
253 readonly value: boolean;
254 readonly disabled?: boolean;
255 readonly onValueChange: (value: boolean) => void;
256}) {
257 const activeTrack = String(useThemeColor("--color-switch-active"));
258 const track = String(useThemeColor("--color-secondary-border"));
259 return (
260 <View
261 className={cn(
262 "flex-row items-center justify-between px-5 py-2.5",
263 props.disabled && "opacity-40",
264 )}
265 >
266 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
267 <Switch
268 disabled={props.disabled}
269 ios_backgroundColor={track}
270 onValueChange={props.onValueChange}
271 trackColor={{ false: track, true: activeTrack }}
272 value={props.value}
273 />
274 </View>
275 );
276}
277
278type SubmenuPage =
279 | { readonly kind: "descriptor"; readonly id: string }
280 | { readonly kind: "runtime" };
281
282/**
283 * Unified thread settings: the sheet is the provider-grouped model list
284 * (primary harnesses expanded, other providers folded, legacy behind the
285 * top-right pill) with a Save button, plus compact disclosure rows whose
286 * single-choice submenus stack in a small panel over the sheet so it never
287 * changes size. Model changes stage until Save — while staged, the settings
288 * rows edit the staged model's options and Save applies everything together.
289 *
290 * Callers control which harnesses are offered via providerGroups: an
291 * existing thread must pass only its own provider's group, since a session
292 * can't switch harness mid-thread.
293 *
294 * Rendered through an RN Modal (not the root OverlayPortal) so it also
295 * presents above natively-presented form sheets like the new-task draft.
296 * Callers must dismiss the keyboard when opening — the iOS keyboard window
297 * would otherwise cover the lower half of the sheet.
298 */
299export function ThreadSettingsSheet(props: {
300 readonly visible: boolean;
301 /**
302 * "save" = the Save/Done button (the user is finished configuring);
303 * "dismiss" = backdrop, grabber, or system back. Hosts only restore the
304 * keyboard for "save" so a stray tap outside a control never pops it.
305 */
306 readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
307 readonly onDismissed: () => void;
308 readonly providerGroups: ReadonlyArray<ProviderGroup>;
309 readonly selectedModel: ModelSelection | null;
310 readonly onSelectModel: (option: ModelOption) => void;
311 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
312 readonly onUpdateOptionSelections: (selections: ReadonlyArray<ProviderOptionSelection>) => void;
313 readonly runtimeMode: RuntimeMode;
314 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
315}) {
316 const insets = useSafeAreaInsets();
317 const { height: windowHeight } = useWindowDimensions();
318 const [showLegacyToggle, setShowLegacyToggle] = useState(false);
319 const [expandedProviders, setExpandedProviders] = useState<ReadonlySet<string>>(() => new Set());
320 const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
321 const [submenu, setSubmenu] = useState<SubmenuPage | null>(null);
322 const wasPresentedRef = useRef(false);
323 const notifyDismissed = useCallback(() => {
324 if (!wasPresentedRef.current) {
325 return;
326 }
327 wasPresentedRef.current = false;
328 props.onDismissed();
329 }, [props.onDismissed]);
330
331 // Every open starts fresh: no staged model, no submenu, legacy hidden,
332 // secondary providers folded. The sheet stays mounted between opens, so
333 // state would otherwise stick around.
334 useEffect(() => {
335 if (props.visible) {
336 wasPresentedRef.current = true;
337 setShowLegacyToggle(false);
338 setExpandedProviders(new Set());
339 setPendingModel(null);
340 setSubmenu(null);
341 } else if (Platform.OS === "android" && wasPresentedRef.current) {
342 // React Native only emits Modal.onDismiss on iOS. Android uses no exit
343 // animation below, so the post-commit effect is its dismissal boundary.
344 notifyDismissed();
345 }
346 }, [notifyDismissed, props.visible]);
347
348 const isApplied = (option: ModelOption) =>
349 option.selection.instanceId === props.selectedModel?.instanceId &&
350 option.selection.model === props.selectedModel.model;
351 // The list highlights the staged pick; Save turns it into the applied one.
352 const isDisplayed = (option: ModelOption) =>
353 pendingModel ? option.key === pendingModel.key : isApplied(option);
354
355 // While a model is staged, the settings rows describe and edit the staged
356 // model's options (kept on its pending selection); Save applies model and
357 // options together. Otherwise they edit the applied selection directly.
358 const displayedDescriptors = pendingModel
359 ? pendingModel.capabilities
360 ? getProviderOptionDescriptors({
361 caps: pendingModel.capabilities,
362 selections: pendingModel.selection.options,
363 })
364 : []
365 : props.optionDescriptors;
366
367 const hasLegacyModels = props.providerGroups.some((group) =>
368 group.models.some((model) => model.isLegacy),
369 );
370 // Legacy stays hidden unless the pill is toggled this open; a highlighted
371 // legacy model is exempted from the filter instead of forcing the whole
372 // legacy list visible.
373 const showLegacy = showLegacyToggle;
374
375 // Stable settings rows: the union of descriptors across the primary
376 // harnesses' current models (plus whatever the displayed model advertises)
377 // always renders, with unsupported rows disabled instead of vanishing when
378 // the selection changes. Keyed by label, not id — Claude and Codex use
379 // different ids for the same "Reasoning" concept.
380 const descriptorTemplate = (() => {
381 const seen = new Map<string, { type: "select" | "boolean" }>();
382 for (const group of props.providerGroups) {
383 const driver = group.models[0]?.providerDriver;
384 if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) {
385 continue;
386 }
387 for (const model of group.models) {
388 if (model.isLegacy) {
389 continue;
390 }
391 for (const descriptor of model.capabilities?.optionDescriptors ?? []) {
392 if (!seen.has(descriptor.label)) {
393 seen.set(descriptor.label, { type: descriptor.type });
394 }
395 }
396 }
397 }
398 for (const descriptor of displayedDescriptors) {
399 if (!seen.has(descriptor.label)) {
400 seen.set(descriptor.label, { type: descriptor.type });
401 }
402 }
403 return [...seen.entries()].map(([label, entry]) => ({ label, ...entry }));
404 })();
405
406 const handleSave = () => {
407 if (pendingModel) {
408 void Haptics.selectionAsync();
409 props.onSelectModel(pendingModel);
410 }
411 props.onClose("save");
412 };
413
414 const handleOptionChange = (id: string, value: string | boolean) => {
415 const next = applyProviderOptionSelection(displayedDescriptors, { id, value });
416 if (!next) {
417 return;
418 }
419 if (pendingModel) {
420 setPendingModel({
421 ...pendingModel,
422 selection: { ...pendingModel.selection, options: next },
423 });
424 } else {
425 props.onUpdateOptionSelections(next);
426 }
427 };
428
429 const toggleProvider = (providerKey: string) => {
430 setExpandedProviders((current) => {
431 const next = new Set(current);
432 if (!next.delete(providerKey)) {
433 next.add(providerKey);
434 }
435 return next;
436 });
437 };
438
439 const activeDescriptor =
440 submenu?.kind === "descriptor"
441 ? displayedDescriptors.find(
442 (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
443 )
444 : undefined;
445
446 const submenuContent =
447 submenu?.kind === "runtime"
448 ? {
449 title: "Runtime",
450 rows: RUNTIME_MODE_CHOICES.map((choice) => ({
451 id: choice.mode,
452 label: choice.label,
453 selected: choice.mode === props.runtimeMode,
454 onPress: () => {
455 void Haptics.selectionAsync();
456 props.onUpdateRuntimeMode(choice.mode);
457 setSubmenu(null);
458 },
459 })),
460 }
461 : activeDescriptor?.type === "select"
462 ? {
463 title: activeDescriptor.label,
464 rows: selectableChoices(activeDescriptor).map((choice) => ({
465 id: choice.id,
466 label: choice.label,
467 selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),
468 onPress: () => {
469 void Haptics.selectionAsync();
470 handleOptionChange(activeDescriptor.id, choice.id);
471 setSubmenu(null);
472 },
473 })),
474 }
475 : null;
476
477 return (
478 <Modal
479 transparent
480 statusBarTranslucent
481 navigationBarTranslucent
482 animationType={Platform.OS === "ios" ? "fade" : "none"}
483 visible={props.visible}
484 onDismiss={notifyDismissed}
485 onRequestClose={submenuContent ? () => setSubmenu(null) : () => props.onClose("dismiss")}
486 >
487 <View className="flex-1 justify-end">
488 <Pressable
489 accessibilityLabel="Close thread settings"
490 className="absolute inset-0 bg-backdrop"
491 onPress={() => props.onClose("dismiss")}
492 />
493 <View
494 className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet"
495 style={{ maxHeight: windowHeight * 0.85 }}
496 >
497 {/* The grabber doubles as the accessible close control: the dim
498 backdrop above a tall sheet is a sliver, and VoiceOver can't
499 reach it at all. */}
500 <Pressable
501 accessibilityLabel="Close thread settings"
502 accessibilityRole="button"
503 onPress={() => props.onClose("dismiss")}
504 className="items-center pb-1 pt-2.5"
505 >
506 <View className="h-1 w-9 rounded-full bg-subtle-strong" />
507 </Pressable>
508 {hasLegacyModels ? (
509 <View className="flex-row justify-end px-4 pb-1.5">
510 <Pressable
511 accessibilityRole="button"
512 accessibilityState={{ selected: showLegacy }}
513 hitSlop={8}
514 onPress={() => {
515 void Haptics.selectionAsync();
516 setShowLegacyToggle(!showLegacy);
517 }}
518 className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70"
519 >
520 <Text className="text-2xs font-t3-medium text-foreground-muted">
521 {showLegacy ? "Hide legacy models" : "Show legacy models"}
522 </Text>
523 </Pressable>
524 </View>
525 ) : null}
526 {/* Only the model list scrolls. Provider catalogs can run to
527 hundreds of models (OpenRouter), so the rows below stay pinned
528 and reachable instead of living at the end of that scroll. */}
529 <ScrollView
530 style={{ flexShrink: 1 }}
531 contentContainerStyle={{ paddingBottom: 8 }}
532 showsVerticalScrollIndicator={false}
533 >
534 {props.providerGroups.map((group) => {
535 const driver = group.models[0]?.providerDriver;
536 const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
537 const visibleModels = showLegacy
538 ? group.models
539 : group.models.filter((model) => !model.isLegacy || isDisplayed(model));
540 if (visibleModels.length === 0) {
541 return null;
542 }
543 const containsSelection = group.models.some(isDisplayed);
544 const collapsible = !isPrimary && !containsSelection;
545 const collapsed = collapsible && !expandedProviders.has(group.providerKey);
546 return (
547 <View key={group.providerKey}>
548 <ProviderHeader
549 driver={driver}
550 label={group.providerLabel}
551 collapsible={collapsible}
552 collapsed={collapsed}
553 modelCount={visibleModels.length}
554 onToggle={() => toggleProvider(group.providerKey)}
555 />
556 {collapsed
557 ? null
558 : visibleModels.map((option) => (
559 <ModelRow
560 key={option.key}
561 option={option}
562 selected={isDisplayed(option)}
563 onPress={() => {
564 void Haptics.selectionAsync();
565 // Re-tapping the applied model cancels staging.
566 setPendingModel((current) =>
567 pendingModelAfterPress({
568 current,
569 pressed: option,
570 pressedIsApplied: isApplied(option),
571 }),
572 );
573 }}
574 />
575 ))}
576 </View>
577 );
578 })}
579 </ScrollView>
580
581 <View className="mx-5 h-px bg-border" />
582
583 <View style={{ paddingBottom: insets.bottom + 12 }}>
584 {descriptorTemplate.map((entry) => {
585 const live = displayedDescriptors.find(
586 (descriptor) => descriptor.label === entry.label,
587 );
588 if ((live?.type ?? entry.type) === "select") {
589 return (
590 <DisclosureRow
591 key={entry.label}
592 label={entry.label}
593 value={live ? getProviderOptionCurrentLabel(live) : undefined}
594 disabled={!live}
595 onPress={() => {
596 if (live) {
597 setSubmenu({ kind: "descriptor", id: live.id });
598 }
599 }}
600 />
601 );
602 }
603 return (
604 <SwitchRow
605 key={entry.label}
606 label={entry.label}
607 value={live?.type === "boolean" ? (live.currentValue ?? false) : false}
608 disabled={!live}
609 onValueChange={(value) => {
610 if (live) {
611 handleOptionChange(live.id, value);
612 }
613 }}
614 />
615 );
616 })}
617 <DisclosureRow
618 label="Runtime"
619 value={
620 RUNTIME_MODE_CHOICES.find((choice) => choice.mode === props.runtimeMode)?.label
621 }
622 onPress={() => setSubmenu({ kind: "runtime" })}
623 />
624 <Pressable
625 accessibilityRole="button"
626 onPress={handleSave}
627 className="mx-4 mt-2 h-12 items-center justify-center rounded-full bg-primary active:opacity-80"
628 >
629 <Text className="text-sm font-t3-bold text-primary-foreground">
630 {pendingModel ? "Save" : "Done"}
631 </Text>
632 </Pressable>
633 </View>
634 </View>
635
636 {/* Submenus stack over the sheet instead of replacing its content,
637 so the main sheet keeps its size while drilling in and out. */}
638 {submenuContent ? (
639 <View className="absolute inset-0 justify-end">
640 <Pressable
641 accessibilityLabel={`Close ${submenuContent.title}`}
642 className="absolute inset-0 bg-backdrop"
643 onPress={() => setSubmenu(null)}
644 />
645 <View className="overflow-hidden rounded-t-[24px] border border-b-0 border-border bg-sheet">
646 <Pressable
647 accessibilityLabel={`Close ${submenuContent.title}`}
648 accessibilityRole="button"
649 onPress={() => setSubmenu(null)}
650 className="items-center pb-1 pt-2.5"
651 >
652 <View className="h-1 w-9 rounded-full bg-subtle-strong" />
653 </Pressable>
654 <Text className="px-5 pb-1.5 text-2xs font-t3-bold uppercase tracking-widest text-foreground-muted">
655 {submenuContent.title}
656 </Text>
657 <ScrollView
658 style={{ maxHeight: windowHeight * 0.5 }}
659 contentContainerStyle={{ paddingBottom: insets.bottom + 12 }}
660 showsVerticalScrollIndicator={false}
661 bounces={false}
662 >
663 {submenuContent.rows.map((row) => (
664 <ChoiceRow
665 key={row.id}
666 label={row.label}
667 selected={row.selected}
668 onPress={row.onPress}
669 />
670 ))}
671 </ScrollView>
672 </View>
673 </View>
674 ) : null}
675 </View>
676 </Modal>
677 );
678}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+15-7
......@@ -25,6 +25,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
2525import {
2626 buildModelOptions,
2727 groupByProvider,
28 resolveDefaultableModelSelection,
2829 resolveSelectableModelSelection,
2930} from "../../lib/modelOptions";
3031import { scopedProjectKey } from "../../lib/scopedEntities";
......@@ -147,7 +148,10 @@ type NewTaskFlowContextValue = {
147148 readonly reset: () => void;
148149 readonly setProject: (project: EnvironmentProject) => void;
149150 readonly selectEnvironment: (environmentId: EnvironmentId) => void;
150 readonly setSelectedModelKey: (key: string | null) => void;
151 readonly setSelectedModelKey: (
152 key: string | null,
153 options?: ReadonlyArray<ProviderOptionSelection>,
154 ) => void;
151155 readonly setWorkspaceMode: (mode: WorkspaceMode) => void;
152156 readonly selectBranch: (branch: VcsRef) => void;
153157 readonly setStartFromOrigin: (value: boolean) => void;
......@@ -359,14 +363,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
359363 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
360364 const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
361365
362 // Stored selections (draft and project default) only count while their
363 // provider is usable on the server; otherwise the server's default model
364 // wins instead of silently targeting a disabled provider.
366 // Stored selections only count while their provider is usable on the
367 // server; otherwise the server's default model wins instead of silently
368 // targeting a disabled provider. The draft selection is an explicit pick
369 // and passes through as-is; the project default (last used, possibly from
370 // desktop) is implicit and additionally never resolves to a legacy model.
365371 const draftModelSelection = resolveSelectableModelSelection(
366372 selectedEnvironmentServerConfig,
367373 selectedProjectDraft.modelSelection ?? null,
368374 );
369 const projectDefaultModelSelection = resolveSelectableModelSelection(
375 const projectDefaultModelSelection = resolveDefaultableModelSelection(
370376 selectedEnvironmentServerConfig,
371377 selectedProject?.defaultModelSelection ?? null,
372378 );
......@@ -404,7 +410,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
404410 [selectedEnvironmentServerConfig, selectedModel?.instanceId],
405411 );
406412 const setSelectedModelKey = useCallback(
407 (key: string | null) => {
413 // Options ride along in the same write: a follow-up setSelectedModelOptions
414 // call would rebuild the selection from the stale pre-switch model.
415 (key: string | null, options?: ReadonlyArray<ProviderOptionSelection>) => {
408416 if (!key || !selectedProjectDraftKey) {
409417 return;
410418 }
......@@ -413,7 +421,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
413421 return;
414422 }
415423 updateComposerDraftSettings(selectedProjectDraftKey, {
416 modelSelection: option.selection,
424 modelSelection: options ? { ...option.selection, options } : option.selection,
417425 });
418426 },
419427 [modelOptions, selectedProjectDraftKey],
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts created+64
......@@ -0,0 +1,64 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
4
5import type { ModelOption } from "../../lib/modelOptions";
6import { pendingModelAfterPress } from "./thread-settings-sheet-state";
7
8function modelOption(
9 model: string,
10 options: ReadonlyArray<ProviderOptionSelection> = [],
11): ModelOption {
12 return {
13 key: `codex:${model}`,
14 label: model,
15 subtitle: "Codex",
16 providerKey: "codex",
17 providerLabel: "Codex",
18 providerDriver: "codex",
19 isDefault: false,
20 isLegacy: false,
21 capabilities: null,
22 selection: {
23 instanceId: ProviderInstanceId.make("codex"),
24 model,
25 options,
26 },
27 };
28}
29
30describe("thread settings sheet state", () => {
31 it("clears staging when the applied model is pressed", () => {
32 expect(
33 pendingModelAfterPress({
34 current: modelOption("gpt-next"),
35 pressed: modelOption("gpt-current"),
36 pressedIsApplied: true,
37 }),
38 ).toBeNull();
39 });
40
41 it("preserves staged options when the highlighted model is pressed again", () => {
42 const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]);
43
44 expect(
45 pendingModelAfterPress({
46 current: pending,
47 pressed: modelOption("gpt-next"),
48 pressedIsApplied: false,
49 }),
50 ).toBe(pending);
51 });
52
53 it("stages a different model", () => {
54 const pressed = modelOption("gpt-other");
55
56 expect(
57 pendingModelAfterPress({
58 current: modelOption("gpt-next"),
59 pressed,
60 pressedIsApplied: false,
61 }),
62 ).toBe(pressed);
63 });
64});
apps/mobile/src/features/threads/thread-settings-sheet-state.ts created+13
......@@ -0,0 +1,13 @@
1import type { ModelOption } from "../../lib/modelOptions";
2
3/** Preserve staged provider options when the highlighted model is tapped again. */
4export function pendingModelAfterPress(input: {
5 readonly current: ModelOption | null;
6 readonly pressed: ModelOption;
7 readonly pressedIsApplied: boolean;
8}): ModelOption | null {
9 if (input.pressedIsApplied) {
10 return null;
11 }
12 return input.current?.key === input.pressed.key ? input.current : input.pressed;
13}
apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts created+98
......@@ -0,0 +1,98 @@
1import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2import { KeyboardController } from "react-native-keyboard-controller";
3
4import type { ComposerEditorHandle } from "../../components/ComposerEditor";
5
6export type ThreadSettingsSheetCloseReason = "save" | "dismiss";
7
8type PresentationPhase = "closed" | "opening" | "visible" | "closing";
9
10/**
11 * Keeps the custom native composer and the settings modal from owning focus at
12 * the same time. Opening waits for the keyboard dismissal to finish, while
13 * focus restoration waits for the modal's dismissal callback.
14 */
15export function useThreadSettingsSheetPresentation(input: {
16 readonly editorRef: RefObject<ComposerEditorHandle | null>;
17 readonly isEditorFocused: boolean;
18}) {
19 const [phase, setPhase] = useState<PresentationPhase>("closed");
20 const isActiveRef = useRef(false);
21 const isMountedRef = useRef(true);
22 const openingIdRef = useRef(0);
23 const restoreFocusOnSaveRef = useRef(false);
24 const shouldRestoreAfterDismissRef = useRef(false);
25
26 useEffect(
27 () => () => {
28 isMountedRef.current = false;
29 isActiveRef.current = false;
30 openingIdRef.current += 1;
31 },
32 [],
33 );
34
35 const open = useCallback(() => {
36 if (isActiveRef.current) {
37 return;
38 }
39
40 isActiveRef.current = true;
41 restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible();
42 shouldRestoreAfterDismissRef.current = false;
43 setPhase("opening");
44
45 const openingId = openingIdRef.current + 1;
46 openingIdRef.current = openingId;
47
48 // Keyboard.dismiss() only tracks React Native TextInputs. The composer is
49 // a custom native text view, so explicitly resign its first responder too.
50 input.editorRef.current?.blur();
51 void KeyboardController.dismiss().then(() => {
52 if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) {
53 return;
54 }
55 setPhase("visible");
56 });
57 }, [input.editorRef, input.isEditorFocused]);
58
59 const close = useCallback((reason: ThreadSettingsSheetCloseReason) => {
60 if (!isActiveRef.current) {
61 return;
62 }
63
64 openingIdRef.current += 1;
65 shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current;
66 setPhase("closing");
67 }, []);
68
69 const onDismissed = useCallback(() => {
70 const shouldRestoreFocus = shouldRestoreAfterDismissRef.current;
71 shouldRestoreAfterDismissRef.current = false;
72 restoreFocusOnSaveRef.current = false;
73 isActiveRef.current = false;
74 setPhase("closed");
75
76 if (shouldRestoreFocus) {
77 input.editorRef.current?.focus();
78 }
79 }, [input.editorRef]);
80
81 // The new-task screen can have an autofocus queued before the sheet opens.
82 // Preserve that intent for Save without allowing it to focus under the modal.
83 const restoreFocusAfterSave = useCallback(() => {
84 if (isActiveRef.current) {
85 restoreFocusOnSaveRef.current = true;
86 }
87 }, []);
88
89 return {
90 isActive: phase !== "closed",
91 isActiveRef,
92 isVisible: phase === "visible",
93 open,
94 close,
95 onDismissed,
96 restoreFocusAfterSave,
97 } as const;
98}
apps/mobile/src/lib/modelOptions.test.ts+43-46
......@@ -3,14 +3,14 @@ import { describe, expect, it } from "vite-plus/test";
33import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
44
55import {
6 buildModelMenuActions,
76 buildModelOptions,
87 groupByProvider,
8 resolveDefaultableModelSelection,
99 resolveSelectableModelSelection,
1010} from "./modelOptions";
1111
1212describe("mobile model options", () => {
13 it("folds legacy models into a provider-scoped menu", () => {
13 it("groups models by provider and flags legacy entries", () => {
1414 const config = {
1515 providers: [
1616 {
......@@ -39,51 +39,14 @@ describe("mobile model options", () => {
3939 ],
4040 } as unknown as ServerConfig;
4141
42 const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null);
43
44 expect(actions).toMatchObject([
45 {
46 title: "Codex",
47 subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }],
48 },
42 expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([
4943 {
50 id: "legacy-models:codex",
51 title: "Codex legacy models",
52 subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }],
53 },
54 ]);
55 });
56
57 it("omits an empty provider menu when every model is legacy", () => {
58 const config = {
59 providers: [
60 {
61 instanceId: "codex",
62 driver: "codex",
63 displayName: "Codex",
64 enabled: true,
65 installed: true,
66 auth: { status: "authenticated" },
67 models: [
68 {
69 slug: "gpt-5.4",
70 name: "GPT-5.4",
71 isCustom: false,
72 isLegacy: true,
73 capabilities: null,
74 },
75 ],
76 },
77 ],
78 } as unknown as ServerConfig;
79
80 expect(
81 buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null),
82 ).toMatchObject([
83 {
84 id: "legacy-models:codex",
85 title: "Codex legacy models",
86 subactions: [{ id: "model:codex:gpt-5.4" }],
44 providerKey: "codex",
45 providerLabel: "Codex",
46 models: [
47 { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false },
48 { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true },
49 ],
8750 },
8851 ]);
8952 });
......@@ -174,4 +137,38 @@ describe("mobile model options", () => {
174137 // No config (environment offline) — nothing to validate against.
175138 expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
176139 });
140
141 it("keeps legacy models out of implicit defaults", () => {
142 const config = {
143 providers: [
144 {
145 instanceId: "codex",
146 driver: "codex",
147 displayName: "Codex",
148 enabled: true,
149 installed: true,
150 auth: { status: "authenticated" },
151 models: [
152 { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null },
153 {
154 slug: "gpt-5.4",
155 name: "GPT-5.4",
156 isCustom: false,
157 isLegacy: true,
158 capabilities: null,
159 },
160 ],
161 },
162 ],
163 } as unknown as ServerConfig;
164
165 const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" };
166 const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" };
167
168 expect(resolveDefaultableModelSelection(config, current)).toBe(current);
169 // A legacy last-used selection falls through to the provider default.
170 expect(resolveDefaultableModelSelection(config, legacy)).toBeNull();
171 // Offline: nothing to validate against, selection passes through.
172 expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
173 });
177174});
apps/mobile/src/lib/modelOptions.ts+20-51
......@@ -3,7 +3,6 @@ import type {
33 ModelSelection,
44 ServerConfig as T3ServerConfig,
55} from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";
76import {
87 buildProviderOptionSelectionsFromDescriptors,
98 getProviderOptionDescriptors,
......@@ -85,6 +84,26 @@ export function resolveSelectableModelSelection(
8584 : null;
8685}
8786
87/**
88 * Like resolveSelectableModelSelection, but additionally rejects legacy
89 * models. Used for implicit defaults (stored draft, project last-used): a
90 * new thread should never quietly start on a legacy model, so those fall
91 * through to the provider's default instead. Explicit picks in the settings
92 * sheet are unaffected.
93 */
94export function resolveDefaultableModelSelection(
95 config: T3ServerConfig | null | undefined,
96 selection: ModelSelection | null,
97): ModelSelection | null {
98 const usable = resolveSelectableModelSelection(config, selection);
99 if (!usable || !config) {
100 return usable;
101 }
102 const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId);
103 const model = provider?.models.find((candidate) => candidate.slug === usable.model);
104 return model?.isLegacy === true ? null : usable;
105}
106
88107export function buildModelOptions(
89108 config: T3ServerConfig | null | undefined,
90109 fallbackModelSelection: ModelSelection | null,
......@@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
168187 models: group.models,
169188 }));
170189}
171
172function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
173 return {
174 id: `model:${option.key}`,
175 title: option.label,
176 state:
177 option.selection.instanceId === selectedModel?.instanceId &&
178 option.selection.model === selectedModel.model
179 ? "on"
180 : undefined,
181 };
182}
183
184export function buildModelMenuActions(
185 groups: ReadonlyArray<ProviderGroup>,
186 selectedModel: ModelSelection | null,
187): MenuAction[] {
188 return groups.flatMap((group) => {
189 const currentModels = group.models.filter((model) => !model.isLegacy);
190 const legacyModels = group.models.filter((model) => model.isLegacy);
191 const selected = group.models.find(
192 (model) =>
193 model.selection.instanceId === selectedModel?.instanceId &&
194 model.selection.model === selectedModel.model,
195 );
196
197 return [
198 ...(currentModels.length > 0
199 ? [
200 {
201 id: `provider:${group.providerKey}`,
202 title: group.providerLabel,
203 subtitle: selected && !selected.isLegacy ? selected.label : undefined,
204 subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
205 },
206 ]
207 : []),
208 ...(legacyModels.length > 0
209 ? [
210 {
211 id: `legacy-models:${group.providerKey}`,
212 title: `${group.providerLabel} legacy models`,
213 subtitle: selected?.isLegacy ? selected.label : undefined,
214 subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
215 },
216 ]
217 : []),
218 ];
219 });
220}
apps/mobile/src/lib/providerOptions.test.ts+15-37
......@@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test";
33import type { ModelCapabilities } from "@t3tools/contracts";
44
55import {
6 applyProviderOptionMenuEvent,
7 buildProviderOptionMenuActions,
8 providerOptionsConfigurationLabel,
6 applyProviderOptionSelection,
7 providerOptionValueLabels,
98 resolveProviderOptionDescriptors,
109} from "./providerOptions";
1110
......@@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = {
3534};
3635
3736describe("mobile provider options", () => {
38 it("renders the option descriptors advertised by the selected model", () => {
37 it("summarizes the option values currently in effect", () => {
3938 const descriptors = resolveProviderOptionDescriptors({
4039 capabilities: CODEX_CAPABILITIES,
4140 selections: undefined,
4241 });
4342
44 expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([
45 {
46 title: "Reasoning",
47 subtitle: "Medium",
48 subactions: [
49 { title: "Medium (default)", state: "on" },
50 { title: "High", state: undefined },
51 ],
52 },
53 {
54 title: "Service Tier",
55 subtitle: "Standard",
56 subactions: [
57 { title: "Standard (default)", state: "on" },
58 { title: "Fast", state: undefined },
59 ],
60 },
61 ]);
62 expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard");
43 expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]);
6344 });
6445
6546 it("updates generic select options without knowing provider-specific ids", () => {
......@@ -67,14 +48,18 @@ describe("mobile provider options", () => {
6748 capabilities: CODEX_CAPABILITIES,
6849 selections: undefined,
6950 });
70 const actions = buildProviderOptionMenuActions(descriptors);
71 const fastEvent = actions[1]?.subactions?.[1]?.id;
7251
73 expect(fastEvent).toBeDefined();
74 expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([
52 expect(
53 applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }),
54 ).toEqual([
7555 { id: "reasoningEffort", value: "medium" },
7656 { id: "serviceTier", value: "priority" },
7757 ]);
58 // Choices the model doesn't advertise are rejected, not stored.
59 expect(
60 applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "turbo" }),
61 ).toBeNull();
62 expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull();
7863 });
7964
8065 it("treats an unspecified boolean capability as off", () => {
......@@ -85,16 +70,9 @@ describe("mobile provider options", () => {
8570 selections: undefined,
8671 });
8772
88 expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([
89 {
90 title: "Fast Mode",
91 subtitle: "Off",
92 subactions: [
93 { title: "Off", state: "on" },
94 { title: "On", state: undefined },
95 ],
96 },
73 expect(providerOptionValueLabels(descriptors)).toEqual([]);
74 expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([
75 { id: "fastMode", value: true },
9776 ]);
98 expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration");
9977 });
10078});
apps/mobile/src/lib/providerOptions.ts+20-87
......@@ -3,48 +3,12 @@ import type {
33 ProviderOptionDescriptor,
44 ProviderOptionSelection,
55} from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";
76import {
87 buildProviderOptionSelectionsFromDescriptors,
98 getProviderOptionCurrentLabel,
10 getProviderOptionCurrentValue,
119 getProviderOptionDescriptors,
1210} from "@t3tools/shared/model";
1311
14const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:";
15
16function providerOptionEvent(id: string, value: string | boolean): string {
17 return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`;
18}
19
20function parseProviderOptionEvent(
21 event: string,
22): { readonly id: string; readonly value: string | boolean } | null {
23 if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) {
24 return null;
25 }
26
27 try {
28 const parsed: unknown = JSON.parse(
29 decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)),
30 );
31 if (
32 typeof parsed === "object" &&
33 parsed !== null &&
34 "id" in parsed &&
35 typeof parsed.id === "string" &&
36 "value" in parsed &&
37 (typeof parsed.value === "string" || typeof parsed.value === "boolean")
38 ) {
39 return { id: parsed.id, value: parsed.value };
40 }
41 } catch {
42 return null;
43 }
44
45 return null;
46}
47
4812export function resolveProviderOptionDescriptors(input: {
4913 readonly capabilities: ModelCapabilities | null | undefined;
5014 readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined;
......@@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: {
5822 });
5923}
6024
61export function buildProviderOptionMenuActions(
62 descriptors: ReadonlyArray<ProviderOptionDescriptor>,
63): ReadonlyArray<MenuAction> {
64 return descriptors.map((descriptor) => {
65 const currentValue =
66 descriptor.type === "boolean"
67 ? (descriptor.currentValue ?? false)
68 : getProviderOptionCurrentValue(descriptor);
69 const choices =
70 descriptor.type === "select"
71 ? descriptor.options.map((option) => ({
72 id: providerOptionEvent(descriptor.id, option.id),
73 title: `${option.label}${option.isDefault ? " (default)" : ""}`,
74 state: currentValue === option.id ? ("on" as const) : undefined,
75 }))
76 : ([false, true] as const).map((value) => ({
77 id: providerOptionEvent(descriptor.id, value),
78 title: value ? "On" : "Off",
79 state: currentValue === value ? ("on" as const) : undefined,
80 }));
81
82 return {
83 id: `provider-option-menu:${descriptor.id}`,
84 title: descriptor.label,
85 subtitle:
86 descriptor.type === "boolean"
87 ? currentValue
88 ? "On"
89 : "Off"
90 : getProviderOptionCurrentLabel(descriptor),
91 subactions: choices,
92 };
93 });
94}
95
96export function providerOptionsConfigurationLabel(
25/**
26 * Labels for the option values currently in effect (select values plus
27 * enabled booleans), used to summarize the thread configuration in the
28 * composer trigger pill.
29 */
30export function providerOptionValueLabels(
9731 descriptors: ReadonlyArray<ProviderOptionDescriptor>,
98): string {
99 const labels = descriptors.flatMap((descriptor) => {
32): ReadonlyArray<string> {
33 return descriptors.flatMap((descriptor) => {
10034 if (descriptor.type === "boolean") {
10135 return descriptor.currentValue ? [descriptor.label] : [];
10236 }
10337 const label = getProviderOptionCurrentLabel(descriptor);
10438 return label ? [label] : [];
10539 });
106 return labels.length > 0 ? labels.join(" · ") : "Configuration";
10740}
10841
109export function applyProviderOptionMenuEvent(
42/**
43 * Applies one option change (by descriptor id) and returns the full selection
44 * list to store on the model selection, or null when the change doesn't match
45 * an advertised descriptor / choice.
46 */
47export function applyProviderOptionSelection(
11048 descriptors: ReadonlyArray<ProviderOptionDescriptor>,
111 event: string,
49 change: ProviderOptionSelection,
11250): ReadonlyArray<ProviderOptionSelection> | null {
113 const selection = parseProviderOptionEvent(event);
114 if (!selection) {
115 return null;
116 }
117
118 const descriptor = descriptors.find((candidate) => candidate.id === selection.id);
51 const descriptor = descriptors.find((candidate) => candidate.id === change.id);
11952 if (!descriptor) {
12053 return null;
12154 }
12255 if (
123 (descriptor.type === "boolean" && typeof selection.value !== "boolean") ||
56 (descriptor.type === "boolean" && typeof change.value !== "boolean") ||
12457 (descriptor.type === "select" &&
125 (typeof selection.value !== "string" ||
126 !descriptor.options.some((option) => option.id === selection.value)))
58 (typeof change.value !== "string" ||
59 !descriptor.options.some((option) => option.id === change.value)))
12760 ) {
12861 return null;
12962 }
......@@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent(
13265 candidate.id === descriptor.id
13366 ? {
13467 ...candidate,
135 currentValue: selection.value,
68 currentValue: change.value,
13669 }
13770 : candidate,
13871 ) as ReadonlyArray<ProviderOptionDescriptor>;