History

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

Theo Browne committed 3 months ago

Commit 30164cb1 parent 064041011 files · +1079 −469Browse files

Unified · Split

11 files
  1. apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +56 −117
  2. apps/mobile/src/features/threads/ThreadComposer.tsx +57 −124
  3. apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +678 −0
  4. apps/mobile/src/features/threads/new-task-flow-provider.tsx +15 −7
  5. apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +64 −0
  6. apps/mobile/src/features/threads/thread-settings-sheet-state.ts +13 −0
  7. apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts +98 −0
  8. apps/mobile/src/lib/modelOptions.test.ts +43 −46
  9. apps/mobile/src/lib/modelOptions.ts +20 −51
  10. apps/mobile/src/lib/providerOptions.test.ts +15 −37
  11. apps/mobile/src/lib/providerOptions.ts +20 −87
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+56−117
@@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri · 24 unmodified lines
2525 import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
2626 import { ProviderIcon } from "../../components/ProviderIcon";
2727 import { ComposerSurface } from "./ThreadComposer";
28+import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
29+import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
2830
2931 import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
3032 import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
31−import {
32− applyProviderOptionMenuEvent,
33− buildProviderOptionMenuActions,
34− providerOptionsConfigurationLabel,
35− resolveProviderOptionDescriptors,
36−} from "../../lib/providerOptions";
33+import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
3734 import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
3835 import {
3936 clearComposerDraftContent,
@@ -43,7 +40,7 @@ import { · 3 unmodified lines
4340 type ComposerDraft,
4441 } from "../../state/use-composer-drafts";
4542 import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
46−import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
43+import { resolveSelectableModelSelection } from "../../lib/modelOptions";
4744 import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
4845 import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
4946 import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
@@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { · 53 unmodified lines
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: { · 412 unmodified lines
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: { · 2 unmodified lines
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: { · 7 unmodified lines
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: { · 3 unmodified lines
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: { · 64 unmodified lines
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: { · 3 unmodified lines
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: { · 1 unmodified line
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: { · 200 unmodified lines
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: { · 47 unmodified lines
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: { · 19 unmodified lines
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: { · 83 unmodified lines
11201057 ) : null}
11211058 </View>
11221059 </KeyboardAvoidingView>
1060+ {settingsSheet}
11231061 </View>
11241062 );
11251063 }
@@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { · 27 unmodified lines
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 { · 50 unmodified lines
5151 ComposerToolbarScroller,
5252 ComposerToolbarTrigger,
5353 } from "../../components/ComposerToolbarTrigger";
54−import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
54+import { ControlPill } from "../../components/ControlPill";
5555 import { ProviderIcon } from "../../components/ProviderIcon";
5656 import type { DraftComposerImageAttachment } from "../../lib/composerImages";
57−import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions";
57+import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5858 import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5959 import type { RemoteClientConnectionState } from "../../lib/connection";
6060 import {
@@ -62,14 +62,11 @@ import { · 1 unmodified line
6262 normalizeSearchQuery,
6363 scoreQueryMatch,
6464 } from "@t3tools/shared/searchRanking";
65−import {
66− applyProviderOptionMenuEvent,
67− buildProviderOptionMenuActions,
68− providerOptionsConfigurationLabel,
69− resolveProviderOptionDescriptors,
70−} from "../../lib/providerOptions";
65+import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
7166 import { useComposerPathSearch } from "../../state/use-composer-path-search";
7267 import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
68+import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
69+import { 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 · 197 unmodified lines
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 · 11 unmodified lines
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 · 276 unmodified lines
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 · 8 unmodified lines
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 · 164 unmodified lines
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 · 27 unmodified lines
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+678−0
@@ -0,0 +1,678 @@
1+import type {
2+ ModelSelection,
3+ ProviderInteractionMode,
4+ ProviderOptionDescriptor,
5+ ProviderOptionSelection,
6+ RuntimeMode,
7+} from "@t3tools/contracts";
8+import {
9+ getProviderOptionCurrentLabel,
10+ getProviderOptionCurrentValue,
11+ getProviderOptionDescriptors,
12+} from "@t3tools/shared/model";
13+import * as Haptics from "expo-haptics";
14+import { useCallback, useEffect, useRef, useState } from "react";
15+import {
16+ Modal,
17+ Platform,
18+ Pressable,
19+ ScrollView,
20+ Switch,
21+ useWindowDimensions,
22+ View,
23+} from "react-native";
24+import { useSafeAreaInsets } from "react-native-safe-area-context";
25+
26+import { SymbolView } from "../../components/AppSymbol";
27+import { AppText as Text } from "../../components/AppText";
28+import { ProviderIcon } from "../../components/ProviderIcon";
29+import { cn } from "../../lib/cn";
30+import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
31+import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
32+import { useThemeColor } from "../../lib/useThemeColor";
33+import { pendingModelAfterPress } from "./thread-settings-sheet-state";
34+import 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+ */
41+const 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+ */
50+const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]);
51+
52+const 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+ */
67+export 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+
82+function 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+
89+function 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+ */
141+function 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. */
189+function 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. */
219+function 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+
251+function 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+
278+type 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+ */
299+export 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"; · 24 unmodified lines
2525 import {
2626 buildModelOptions,
2727 groupByProvider,
28+ resolveDefaultableModelSelection,
2829 resolveSelectableModelSelection,
2930 } from "../../lib/modelOptions";
3031 import { scopedProjectKey } from "../../lib/scopedEntities";
@@ -147,7 +148,10 @@ type NewTaskFlowContextValue = { · 116 unmodified lines
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) { · 205 unmodified lines
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) { · 31 unmodified lines
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) { · 2 unmodified lines
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+64−0
@@ -0,0 +1,64 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
4+
5+import type { ModelOption } from "../../lib/modelOptions";
6+import { pendingModelAfterPress } from "./thread-settings-sheet-state";
7+
8+function 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+
30+describe("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+13−0
@@ -0,0 +1,13 @@
1+import type { ModelOption } from "../../lib/modelOptions";
2+
3+/** Preserve staged provider options when the highlighted model is tapped again. */
4+export 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+98−0
@@ -0,0 +1,98 @@
1+import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2+import { KeyboardController } from "react-native-keyboard-controller";
3+
4+import type { ComposerEditorHandle } from "../../components/ComposerEditor";
5+
6+export type ThreadSettingsSheetCloseReason = "save" | "dismiss";
7+
8+type 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+ */
15+export 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"; · 2 unmodified lines
33 import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
44
55 import {
6− buildModelMenuActions,
76 buildModelOptions,
87 groupByProvider,
8+ resolveDefaultableModelSelection,
99 resolveSelectableModelSelection,
1010 } from "./modelOptions";
1111
1212 describe("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", () => { · 22 unmodified lines
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", () => { · 84 unmodified lines
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 { · 2 unmodified lines
33 ModelSelection,
44 ServerConfig as T3ServerConfig,
55 } from "@t3tools/contracts";
6−import type { MenuAction } from "@react-native-menu/menu";
76 import {
87 buildProviderOptionSelectionsFromDescriptors,
98 getProviderOptionDescriptors,
@@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( · 75 unmodified lines
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+ */
94+export 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+
88107 export function buildModelOptions(
89108 config: T3ServerConfig | null | undefined,
90109 fallbackModelSelection: ModelSelection | null,
@@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr · 77 unmodified lines
168187 models: group.models,
169188 }));
170189 }
171−
172−function 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−
184−export 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"; · 2 unmodified lines
33 import type { ModelCapabilities } from "@t3tools/contracts";
44
55 import {
6− applyProviderOptionMenuEvent,
7− buildProviderOptionMenuActions,
8− providerOptionsConfigurationLabel,
6+ applyProviderOptionSelection,
7+ providerOptionValueLabels,
98 resolveProviderOptionDescriptors,
109 } from "./providerOptions";
1110
@@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { · 23 unmodified lines
3534 };
3635
3736 describe("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", () => { · 1 unmodified line
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", () => { · 4 unmodified lines
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 { · 2 unmodified lines
33 ProviderOptionDescriptor,
44 ProviderOptionSelection,
55 } from "@t3tools/contracts";
6−import type { MenuAction } from "@react-native-menu/menu";
76 import {
87 buildProviderOptionSelectionsFromDescriptors,
98 getProviderOptionCurrentLabel,
10− getProviderOptionCurrentValue,
119 getProviderOptionDescriptors,
1210 } from "@t3tools/shared/model";
1311
14−const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:";
15−
16−function providerOptionEvent(id: string, value: string | boolean): string {
17− return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`;
18−}
19−
20−function 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−
4812 export function resolveProviderOptionDescriptors(input: {
4913 readonly capabilities: ModelCapabilities | null | undefined;
5014 readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined;
@@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { · 7 unmodified lines
5822 });
5923 }
6024
61−export 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−
96−export 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+ */
30+export 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
109−export 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+ */
47+export 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( · 2 unmodified lines
13265 candidate.id === descriptor.id
13366 ? {
13467 ...candidate,
135− currentValue: selection.value,
68+ currentValue: change.value,
13669 }
13770 : candidate,
13871 ) as ReadonlyArray<ProviderOptionDescriptor>;