Unified · Split
| @@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri · 24 unmodified lines | ||
| 25 | 25 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 26 | 26 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 27 | 27 | import { ComposerSurface } from "./ThreadComposer"; |
| 28 | +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | |
| 29 | +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | |
| 28 | 30 | |
| 29 | 31 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 30 | 32 | 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"; | |
| 37 | 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 38 | 35 | import { |
| 39 | 36 | clearComposerDraftContent, |
| @@ -43,7 +40,7 @@ import { · 3 unmodified lines | ||
| 43 | 40 | type ComposerDraft, |
| 44 | 41 | } from "../../state/use-composer-drafts"; |
| 45 | 42 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 46 | −import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; | |
| 43 | +import { resolveSelectableModelSelection } from "../../lib/modelOptions"; | |
| 47 | 44 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 48 | 45 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 49 | 46 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| @@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { · 53 unmodified lines | ||
| 103 | 100 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 104 | 101 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 105 | 102 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 103 | + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | |
| 104 | + editorRef: promptInputRef, | |
| 105 | + isEditorFocused: isComposerFocused, | |
| 106 | + }); | |
| 106 | 107 | const [importingShareKey, setImportingShareKey] = useState<string | null>(null); |
| 107 | 108 | const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); |
| 108 | 109 | const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); |
| @@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: { · 412 unmodified lines | ||
| 521 | 522 | |
| 522 | 523 | let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null; |
| 523 | 524 | 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 | + }); | |
| 525 | 534 | }); |
| 526 | 535 | |
| 527 | 536 | return () => { |
| @@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: { · 2 unmodified lines | ||
| 530 | 539 | cancelAnimationFrame(focusFrame); |
| 531 | 540 | } |
| 532 | 541 | }; |
| 533 | − }, [selectedProject]); | |
| 542 | + }, [ | |
| 543 | + selectedProject, | |
| 544 | + settingsSheetPresentation.isActiveRef, | |
| 545 | + settingsSheetPresentation.restoreFocusAfterSave, | |
| 546 | + ]); | |
| 534 | 547 | |
| 535 | 548 | const environmentMenuActions = useMemo( |
| 536 | 549 | () => |
| @@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: { · 7 unmodified lines | ||
| 544 | 557 | [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], |
| 545 | 558 | ); |
| 546 | 559 | |
| 547 | − const modelMenuActions = useMemo( | |
| 548 | − () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), | |
| 549 | − [flow.providerGroups, flow.selectedModel], | |
| 550 | − ); | |
| 551 | 560 | const providerOptionDescriptors = useMemo( |
| 552 | 561 | () => |
| 553 | 562 | resolveProviderOptionDescriptors({ |
| @@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: { · 3 unmodified lines | ||
| 557 | 566 | [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], |
| 558 | 567 | ); |
| 559 | 568 | |
| 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 | − | |
| 608 | 569 | const workspaceMenuActions = useMemo(() => { |
| 609 | 570 | const branchActions = |
| 610 | 571 | flow.availableBranches.length === 0 |
| @@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: { · 64 unmodified lines | ||
| 675 | 636 | flow.availableBranches.find((branch) => branch.current)?.name ?? |
| 676 | 637 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 677 | 638 | 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 | + }); | |
| 682 | 645 | const workspaceLabel = useMemo( |
| 683 | 646 | () => |
| 684 | 647 | formatWorkspaceLabel({ |
| @@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: { · 3 unmodified lines | ||
| 688 | 651 | }), |
| 689 | 652 | [currentBranchName, flow.selectedBranchName, flow.workspaceMode], |
| 690 | 653 | ); |
| 691 | − function handleModelMenuAction(event: string) { | |
| 692 | − if (isIncomingShareTransferPending || !event.startsWith("model:")) { | |
| 693 | − return; | |
| 694 | − } | |
| 695 | − flow.setSelectedModelKey(event.slice("model:".length)); | |
| 696 | − } | |
| 697 | − | |
| 698 | 654 | function handleEnvironmentMenuAction(event: string) { |
| 699 | 655 | if (isIncomingShareTransferPending || !event.startsWith("environment:")) { |
| 700 | 656 | return; |
| @@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: { · 1 unmodified line | ||
| 702 | 658 | flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); |
| 703 | 659 | } |
| 704 | 660 | |
| 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 | − | |
| 727 | 661 | function handleWorkspaceMenuAction(event: string) { |
| 728 | 662 | if (isIncomingShareTransferPending) { |
| 729 | 663 | return; |
| @@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: { · 200 unmodified lines | ||
| 930 | 864 | const isDarkMode = colorScheme === "dark"; |
| 931 | 865 | // Android expansion follows native editor focus so relayout cannot race |
| 932 | 866 | // 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; | |
| 934 | 870 | const canStart = |
| 935 | 871 | Boolean(flow.selectedProject) && |
| 936 | 872 | Boolean(flow.selectedModel) && |
| @@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: { · 47 unmodified lines | ||
| 984 | 920 | showChevron={false} |
| 985 | 921 | disabled={isIncomingShareTransferPending} |
| 986 | 922 | /> |
| 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 | + /> | |
| 1009 | 931 | <ControlPillMenu |
| 1010 | 932 | actions={environmentMenuActions} |
| 1011 | 933 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} |
| @@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: { · 19 unmodified lines | ||
| 1031 | 953 | </> |
| 1032 | 954 | ); |
| 1033 | 955 | |
| 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 | + | |
| 1034 | 971 | const startButton = ( |
| 1035 | 972 | <ComposerToolbarButton |
| 1036 | 973 | accessibilityLabel={ |
| @@ -1120,6 +1057,7 @@ export function NewTaskDraftScreen(props: { · 83 unmodified lines | ||
| 1120 | 1057 | ) : null} |
| 1121 | 1058 | </View> |
| 1122 | 1059 | </KeyboardAvoidingView> |
| 1060 | + {settingsSheet} | |
| 1123 | 1061 | </View> |
| 1124 | 1062 | ); |
| 1125 | 1063 | } |
| @@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { · 27 unmodified lines | ||
| 1153 | 1091 | </ComposerToolbarRow> |
| 1154 | 1092 | </View> |
| 1155 | 1093 | </KeyboardAvoidingView> |
| 1094 | + {settingsSheet} | |
| 1156 | 1095 | </View> |
| 1157 | 1096 | ); |
| 1158 | 1097 | } |
| @@ -51,10 +51,10 @@ import { · 50 unmodified lines | ||
| 51 | 51 | ComposerToolbarScroller, |
| 52 | 52 | ComposerToolbarTrigger, |
| 53 | 53 | } from "../../components/ComposerToolbarTrigger"; |
| 54 | −import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | |
| 54 | +import { ControlPill } from "../../components/ControlPill"; | |
| 55 | 55 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 56 | 56 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 57 | −import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | |
| 57 | +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | |
| 58 | 58 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 59 | 59 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 60 | 60 | import { |
| @@ -62,14 +62,11 @@ import { · 1 unmodified line | ||
| 62 | 62 | normalizeSearchQuery, |
| 63 | 63 | scoreQueryMatch, |
| 64 | 64 | } from "@t3tools/shared/searchRanking"; |
| 65 | −import { | |
| 66 | − applyProviderOptionMenuEvent, | |
| 67 | − buildProviderOptionMenuActions, | |
| 68 | − providerOptionsConfigurationLabel, | |
| 69 | − resolveProviderOptionDescriptors, | |
| 70 | −} from "../../lib/providerOptions"; | |
| 65 | +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | |
| 71 | 66 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 72 | 67 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; |
| 68 | +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | |
| 69 | +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | |
| 73 | 70 | |
| 74 | 71 | /** |
| 75 | 72 | * 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 | ||
| 273 | 270 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| 274 | 271 | const inputRef = props.editorRef ?? fallbackInputRef; |
| 275 | 272 | const [isFocused, setIsFocused] = useState(false); |
| 273 | + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | |
| 274 | + editorRef: inputRef, | |
| 275 | + isEditorFocused: isFocused, | |
| 276 | + }); | |
| 276 | 277 | const wasExpandedBeforePreviewRef = useRef(false); |
| 277 | 278 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 278 | 279 | const { onExpandedChange } = props; |
| 279 | 280 | |
| 280 | 281 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 281 | 282 | 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; | |
| 283 | 286 | const canSend = hasContent; |
| 284 | 287 | |
| 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 | + | |
| 285 | 295 | const onPressImage = useCallback( |
| 286 | 296 | (uri: string) => { |
| 287 | 297 | wasExpandedBeforePreviewRef.current = isFocused; |
| @@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 11 unmodified lines | ||
| 299 | 309 | |
| 300 | 310 | const handleFocus = useCallback(() => { |
| 301 | 311 | setIsFocused(true); |
| 302 | − onExpandedChange?.(true); | |
| 303 | − }, [onExpandedChange]); | |
| 312 | + }, []); | |
| 304 | 313 | |
| 305 | 314 | const handleBlur = useCallback(() => { |
| 306 | 315 | setIsFocused(false); |
| 307 | − onExpandedChange?.(false); | |
| 308 | − }, [onExpandedChange]); | |
| 316 | + }, []); | |
| 309 | 317 | const showStopAction = |
| 310 | 318 | props.selectedThread.session?.status === "running" || |
| 311 | 319 | props.selectedThread.session?.status === "starting"; |
| @@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 276 unmodified lines | ||
| 588 | 596 | [props.serverConfig, currentModelSelection], |
| 589 | 597 | ); |
| 590 | 598 | 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 | + ); | |
| 591 | 605 | const currentModelOption = |
| 592 | 606 | modelOptions.find( |
| 593 | 607 | (option) => |
| @@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 8 unmodified lines | ||
| 602 | 616 | }), |
| 603 | 617 | [currentModelOption?.capabilities, currentModelSelection.options], |
| 604 | 618 | ); |
| 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 | + }); | |
| 694 | 625 | |
| 695 | 626 | return ( |
| 696 | 627 | <Animated.View |
| @@ -861,28 +792,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 164 unmodified lines | ||
| 861 | 792 | onPress={() => void props.onPickDraftImages()} |
| 862 | 793 | showChevron={false} |
| 863 | 794 | /> |
| 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 | + /> | |
| 886 | 804 | {showStopAction ? ( |
| 887 | 805 | <ComposerToolbarButton |
| 888 | 806 | accessibilityLabel="Stop" |
| @@ -916,6 +834,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 27 unmodified lines | ||
| 916 | 834 | ) : null} |
| 917 | 835 | </Animated.View> |
| 918 | 836 | |
| 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 | + | |
| 919 | 852 | <ImageViewing |
| 920 | 853 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| 921 | 854 | imageIndex={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 | +} | |
| @@ -25,6 +25,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; · 24 unmodified lines | ||
| 25 | 25 | import { |
| 26 | 26 | buildModelOptions, |
| 27 | 27 | groupByProvider, |
| 28 | + resolveDefaultableModelSelection, | |
| 28 | 29 | resolveSelectableModelSelection, |
| 29 | 30 | } from "../../lib/modelOptions"; |
| 30 | 31 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| @@ -147,7 +148,10 @@ type NewTaskFlowContextValue = { · 116 unmodified lines | ||
| 147 | 148 | readonly reset: () => void; |
| 148 | 149 | readonly setProject: (project: EnvironmentProject) => void; |
| 149 | 150 | 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; | |
| 151 | 155 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; |
| 152 | 156 | readonly selectBranch: (branch: VcsRef) => void; |
| 153 | 157 | readonly setStartFromOrigin: (value: boolean) => void; |
| @@ -359,14 +363,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 205 unmodified lines | ||
| 359 | 363 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 360 | 364 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; |
| 361 | 365 | |
| 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. | |
| 365 | 371 | const draftModelSelection = resolveSelectableModelSelection( |
| 366 | 372 | selectedEnvironmentServerConfig, |
| 367 | 373 | selectedProjectDraft.modelSelection ?? null, |
| 368 | 374 | ); |
| 369 | − const projectDefaultModelSelection = resolveSelectableModelSelection( | |
| 375 | + const projectDefaultModelSelection = resolveDefaultableModelSelection( | |
| 370 | 376 | selectedEnvironmentServerConfig, |
| 371 | 377 | selectedProject?.defaultModelSelection ?? null, |
| 372 | 378 | ); |
| @@ -404,7 +410,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 31 unmodified lines | ||
| 404 | 410 | [selectedEnvironmentServerConfig, selectedModel?.instanceId], |
| 405 | 411 | ); |
| 406 | 412 | 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>) => { | |
| 408 | 416 | if (!key || !selectedProjectDraftKey) { |
| 409 | 417 | return; |
| 410 | 418 | } |
| @@ -413,7 +421,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { · 2 unmodified lines | ||
| 413 | 421 | return; |
| 414 | 422 | } |
| 415 | 423 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 416 | − modelSelection: option.selection, | |
| 424 | + modelSelection: options ? { ...option.selection, options } : option.selection, | |
| 417 | 425 | }); |
| 418 | 426 | }, |
| 419 | 427 | [modelOptions, selectedProjectDraftKey], |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -3,14 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines | ||
| 3 | 3 | import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | − buildModelMenuActions, | |
| 7 | 6 | buildModelOptions, |
| 8 | 7 | groupByProvider, |
| 8 | + resolveDefaultableModelSelection, | |
| 9 | 9 | resolveSelectableModelSelection, |
| 10 | 10 | } from "./modelOptions"; |
| 11 | 11 | |
| 12 | 12 | describe("mobile model options", () => { |
| 13 | − it("folds legacy models into a provider-scoped menu", () => { | |
| 13 | + it("groups models by provider and flags legacy entries", () => { | |
| 14 | 14 | const config = { |
| 15 | 15 | providers: [ |
| 16 | 16 | { |
| @@ -39,51 +39,14 @@ describe("mobile model options", () => { · 22 unmodified lines | ||
| 39 | 39 | ], |
| 40 | 40 | } as unknown as ServerConfig; |
| 41 | 41 | |
| 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([ | |
| 49 | 43 | { |
| 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 | + ], | |
| 87 | 50 | }, |
| 88 | 51 | ]); |
| 89 | 52 | }); |
| @@ -174,4 +137,38 @@ describe("mobile model options", () => { · 84 unmodified lines | ||
| 174 | 137 | // No config (environment offline) — nothing to validate against. |
| 175 | 138 | expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); |
| 176 | 139 | }); |
| 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 | + }); | |
| 177 | 174 | }); |
| @@ -3,7 +3,6 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | ModelSelection, |
| 4 | 4 | ServerConfig as T3ServerConfig, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | −import type { MenuAction } from "@react-native-menu/menu"; | |
| 7 | 6 | import { |
| 8 | 7 | buildProviderOptionSelectionsFromDescriptors, |
| 9 | 8 | getProviderOptionDescriptors, |
| @@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( · 75 unmodified lines | ||
| 85 | 84 | : null; |
| 86 | 85 | } |
| 87 | 86 | |
| 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 | + | |
| 88 | 107 | export function buildModelOptions( |
| 89 | 108 | config: T3ServerConfig | null | undefined, |
| 90 | 109 | fallbackModelSelection: ModelSelection | null, |
| @@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr · 77 unmodified lines | ||
| 168 | 187 | models: group.models, |
| 169 | 188 | })); |
| 170 | 189 | } |
| 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 | −} | |
| @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines | ||
| 3 | 3 | import type { ModelCapabilities } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | − applyProviderOptionMenuEvent, | |
| 7 | − buildProviderOptionMenuActions, | |
| 8 | − providerOptionsConfigurationLabel, | |
| 6 | + applyProviderOptionSelection, | |
| 7 | + providerOptionValueLabels, | |
| 9 | 8 | resolveProviderOptionDescriptors, |
| 10 | 9 | } from "./providerOptions"; |
| 11 | 10 | |
| @@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { · 23 unmodified lines | ||
| 35 | 34 | }; |
| 36 | 35 | |
| 37 | 36 | describe("mobile provider options", () => { |
| 38 | − it("renders the option descriptors advertised by the selected model", () => { | |
| 37 | + it("summarizes the option values currently in effect", () => { | |
| 39 | 38 | const descriptors = resolveProviderOptionDescriptors({ |
| 40 | 39 | capabilities: CODEX_CAPABILITIES, |
| 41 | 40 | selections: undefined, |
| 42 | 41 | }); |
| 43 | 42 | |
| 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"]); | |
| 63 | 44 | }); |
| 64 | 45 | |
| 65 | 46 | it("updates generic select options without knowing provider-specific ids", () => { |
| @@ -67,14 +48,18 @@ describe("mobile provider options", () => { · 1 unmodified line | ||
| 67 | 48 | capabilities: CODEX_CAPABILITIES, |
| 68 | 49 | selections: undefined, |
| 69 | 50 | }); |
| 70 | − const actions = buildProviderOptionMenuActions(descriptors); | |
| 71 | − const fastEvent = actions[1]?.subactions?.[1]?.id; | |
| 72 | 51 | |
| 73 | − expect(fastEvent).toBeDefined(); | |
| 74 | − expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ | |
| 52 | + expect( | |
| 53 | + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }), | |
| 54 | + ).toEqual([ | |
| 75 | 55 | { id: "reasoningEffort", value: "medium" }, |
| 76 | 56 | { id: "serviceTier", value: "priority" }, |
| 77 | 57 | ]); |
| 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(); | |
| 78 | 63 | }); |
| 79 | 64 | |
| 80 | 65 | it("treats an unspecified boolean capability as off", () => { |
| @@ -85,16 +70,9 @@ describe("mobile provider options", () => { · 4 unmodified lines | ||
| 85 | 70 | selections: undefined, |
| 86 | 71 | }); |
| 87 | 72 | |
| 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 }, | |
| 97 | 76 | ]); |
| 98 | − expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); | |
| 99 | 77 | }); |
| 100 | 78 | }); |
| @@ -3,48 +3,12 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | ProviderOptionDescriptor, |
| 4 | 4 | ProviderOptionSelection, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | −import type { MenuAction } from "@react-native-menu/menu"; | |
| 7 | 6 | import { |
| 8 | 7 | buildProviderOptionSelectionsFromDescriptors, |
| 9 | 8 | getProviderOptionCurrentLabel, |
| 10 | − getProviderOptionCurrentValue, | |
| 11 | 9 | getProviderOptionDescriptors, |
| 12 | 10 | } from "@t3tools/shared/model"; |
| 13 | 11 | |
| 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 | − | |
| 48 | 12 | export function resolveProviderOptionDescriptors(input: { |
| 49 | 13 | readonly capabilities: ModelCapabilities | null | undefined; |
| 50 | 14 | readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined; |
| @@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { · 7 unmodified lines | ||
| 58 | 22 | }); |
| 59 | 23 | } |
| 60 | 24 | |
| 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( | |
| 97 | 31 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, |
| 98 | −): string { | |
| 99 | − const labels = descriptors.flatMap((descriptor) => { | |
| 32 | +): ReadonlyArray<string> { | |
| 33 | + return descriptors.flatMap((descriptor) => { | |
| 100 | 34 | if (descriptor.type === "boolean") { |
| 101 | 35 | return descriptor.currentValue ? [descriptor.label] : []; |
| 102 | 36 | } |
| 103 | 37 | const label = getProviderOptionCurrentLabel(descriptor); |
| 104 | 38 | return label ? [label] : []; |
| 105 | 39 | }); |
| 106 | − return labels.length > 0 ? labels.join(" · ") : "Configuration"; | |
| 107 | 40 | } |
| 108 | 41 | |
| 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( | |
| 110 | 48 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, |
| 111 | − event: string, | |
| 49 | + change: ProviderOptionSelection, | |
| 112 | 50 | ): 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); | |
| 119 | 52 | if (!descriptor) { |
| 120 | 53 | return null; |
| 121 | 54 | } |
| 122 | 55 | if ( |
| 123 | − (descriptor.type === "boolean" && typeof selection.value !== "boolean") || | |
| 56 | + (descriptor.type === "boolean" && typeof change.value !== "boolean") || | |
| 124 | 57 | (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))) | |
| 127 | 60 | ) { |
| 128 | 61 | return null; |
| 129 | 62 | } |
| @@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent( · 2 unmodified lines | ||
| 132 | 65 | candidate.id === descriptor.id |
| 133 | 66 | ? { |
| 134 | 67 | ...candidate, |
| 135 | − currentValue: selection.value, | |
| 68 | + currentValue: change.value, | |
| 136 | 69 | } |
| 137 | 70 | : candidate, |
| 138 | 71 | ) as ReadonlyArray<ProviderOptionDescriptor>; |