| author | |
| committer | |
| log | 30164cb1ba8ea05fdd6be69215dba2cc2f0e2aa8 |
| tree | ba711e4f663b6f32082f70e7eddd4298fb6197d7 |
| parent | 06404107261cae24394d6abe39984fb445c5aff5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>11 files changed, 1079 insertions(+), 469 deletions(-)
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+56-117| ... | @@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri | ... | @@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri |
| 25 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | 25 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 26 | import { ProviderIcon } from "../../components/ProviderIcon"; | 26 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 27 | import { ComposerSurface } from "./ThreadComposer"; | 27 | import { ComposerSurface } from "./ThreadComposer"; |
| 28 | import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; | ||
| 29 | import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; | ||
| 28 | 30 | ||
| 29 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 31 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 30 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 32 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 31 | import { | 33 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 32 | applyProviderOptionMenuEvent, | ||
| 33 | buildProviderOptionMenuActions, | ||
| 34 | providerOptionsConfigurationLabel, | ||
| 35 | resolveProviderOptionDescriptors, | ||
| 36 | } from "../../lib/providerOptions"; | ||
| 37 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 38 | import { | 35 | import { |
| 39 | clearComposerDraftContent, | 36 | clearComposerDraftContent, |
| ... | @@ -43,7 +40,7 @@ import { | ... | @@ -43,7 +40,7 @@ import { |
| 43 | type ComposerDraft, | 40 | type ComposerDraft, |
| 44 | } from "../../state/use-composer-drafts"; | 41 | } from "../../state/use-composer-drafts"; |
| 45 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; | 42 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 46 | import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; | 43 | import { resolveSelectableModelSelection } from "../../lib/modelOptions"; |
| 47 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; | 44 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 48 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 45 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 49 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; | 46 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; |
| ... | @@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { | ... | @@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { |
| 103 | const promptInputRef = useRef<ComposerEditorHandle>(null); | 100 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 104 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | 101 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 105 | const [isComposerFocused, setIsComposerFocused] = useState(false); | 102 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 103 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | ||
| 104 | editorRef: promptInputRef, | ||
| 105 | isEditorFocused: isComposerFocused, | ||
| 106 | }); | ||
| 106 | const [importingShareKey, setImportingShareKey] = useState<string | null>(null); | 107 | const [importingShareKey, setImportingShareKey] = useState<string | null>(null); |
| 107 | const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); | 108 | const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); |
| 108 | const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); | 109 | const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null); |
| ... | @@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: { | ... | @@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: { |
| 521 | 522 | ||
| 522 | let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null; | 523 | let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null; |
| 523 | const interaction = InteractionManager.runAfterInteractions(() => { | 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 | return () => { | 536 | return () => { |
| ... | @@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: { | ... | @@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: { |
| 530 | cancelAnimationFrame(focusFrame); | 539 | cancelAnimationFrame(focusFrame); |
| 531 | } | 540 | } |
| 532 | }; | 541 | }; |
| 533 | }, [selectedProject]); | 542 | }, [ |
| 543 | selectedProject, | ||
| 544 | settingsSheetPresentation.isActiveRef, | ||
| 545 | settingsSheetPresentation.restoreFocusAfterSave, | ||
| 546 | ]); | ||
| 534 | 547 | ||
| 535 | const environmentMenuActions = useMemo( | 548 | const environmentMenuActions = useMemo( |
| 536 | () => | 549 | () => |
| ... | @@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: { | ... | @@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: { |
| 544 | [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], | 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 | const providerOptionDescriptors = useMemo( | 560 | const providerOptionDescriptors = useMemo( |
| 552 | () => | 561 | () => |
| 553 | resolveProviderOptionDescriptors({ | 562 | resolveProviderOptionDescriptors({ |
| ... | @@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: { | ... | @@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: { |
| 557 | [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], | 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 | const workspaceMenuActions = useMemo(() => { | 569 | const workspaceMenuActions = useMemo(() => { |
| 609 | const branchActions = | 570 | const branchActions = |
| 610 | flow.availableBranches.length === 0 | 571 | flow.availableBranches.length === 0 |
| ... | @@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: { | ... | @@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: { |
| 675 | flow.availableBranches.find((branch) => branch.current)?.name ?? | 636 | flow.availableBranches.find((branch) => branch.current)?.name ?? |
| 676 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? | 637 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? |
| 677 | null; | 638 | null; |
| 678 | const configurationLabel = useMemo( | 639 | const settingsSummaryLabel = threadSettingsSummaryLabel({ |
| 679 | () => providerOptionsConfigurationLabel(providerOptionDescriptors), | 640 | modelLabel: flow.selectedModelOption?.label ?? "Model", |
| 680 | [providerOptionDescriptors], | 641 | optionDescriptors: providerOptionDescriptors, |
| 681 | ); | 642 | runtimeMode: flow.runtimeMode, |
| 643 | interactionMode: flow.interactionMode, | ||
| 644 | }); | ||
| 682 | const workspaceLabel = useMemo( | 645 | const workspaceLabel = useMemo( |
| 683 | () => | 646 | () => |
| 684 | formatWorkspaceLabel({ | 647 | formatWorkspaceLabel({ |
| ... | @@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: { | ... | @@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: { |
| 688 | }), | 651 | }), |
| 689 | [currentBranchName, flow.selectedBranchName, flow.workspaceMode], | 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 | function handleEnvironmentMenuAction(event: string) { | 654 | function handleEnvironmentMenuAction(event: string) { |
| 699 | if (isIncomingShareTransferPending || !event.startsWith("environment:")) { | 655 | if (isIncomingShareTransferPending || !event.startsWith("environment:")) { |
| 700 | return; | 656 | return; |
| ... | @@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: { | ... | @@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: { |
| 702 | flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); | 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 | function handleWorkspaceMenuAction(event: string) { | 661 | function handleWorkspaceMenuAction(event: string) { |
| 728 | if (isIncomingShareTransferPending) { | 662 | if (isIncomingShareTransferPending) { |
| 729 | return; | 663 | return; |
| ... | @@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: { | ... | @@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: { |
| 930 | const isDarkMode = colorScheme === "dark"; | 864 | const isDarkMode = colorScheme === "dark"; |
| 931 | // Android expansion follows native editor focus so relayout cannot race | 865 | // Android expansion follows native editor focus so relayout cannot race |
| 932 | // the touch gesture that opens the keyboard. | 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 | const canStart = | 870 | const canStart = |
| 935 | Boolean(flow.selectedProject) && | 871 | Boolean(flow.selectedProject) && |
| 936 | Boolean(flow.selectedModel) && | 872 | Boolean(flow.selectedModel) && |
| ... | @@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: { | ... | @@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: { |
| 984 | showChevron={false} | 920 | showChevron={false} |
| 985 | disabled={isIncomingShareTransferPending} | 921 | disabled={isIncomingShareTransferPending} |
| 986 | /> | 922 | /> |
| 987 | <ControlPillMenu | 923 | <ComposerToolbarTrigger |
| 988 | actions={modelMenuActions} | 924 | accessibilityLabel="Thread settings" |
| 989 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | 925 | disabled={isIncomingShareTransferPending} |
| 990 | > | 926 | iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />} |
| 991 | <ComposerToolbarTrigger | 927 | label={settingsSummaryLabel} |
| 992 | accessibilityLabel="Model" | 928 | maxWidth={320} |
| 993 | disabled={isIncomingShareTransferPending} | 929 | onPress={settingsSheetPresentation.open} |
| 994 | iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />} | 930 | /> |
| 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> | ||
| 1009 | <ControlPillMenu | 931 | <ControlPillMenu |
| 1010 | actions={environmentMenuActions} | 932 | actions={environmentMenuActions} |
| 1011 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | 933 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} |
| ... | @@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: { |
| 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 | const startButton = ( | 971 | const startButton = ( |
| 1035 | <ComposerToolbarButton | 972 | <ComposerToolbarButton |
| 1036 | accessibilityLabel={ | 973 | accessibilityLabel={ |
| ... | @@ -1120,6 +1057,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1120,6 +1057,7 @@ export function NewTaskDraftScreen(props: { |
| 1120 | ) : null} | 1057 | ) : null} |
| 1121 | </View> | 1058 | </View> |
| 1122 | </KeyboardAvoidingView> | 1059 | </KeyboardAvoidingView> |
| 1060 | {settingsSheet} | ||
| 1123 | </View> | 1061 | </View> |
| 1124 | ); | 1062 | ); |
| 1125 | } | 1063 | } |
| ... | @@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { |
| 1153 | </ComposerToolbarRow> | 1091 | </ComposerToolbarRow> |
| 1154 | </View> | 1092 | </View> |
| 1155 | </KeyboardAvoidingView> | 1093 | </KeyboardAvoidingView> |
| 1094 | {settingsSheet} | ||
| 1156 | </View> | 1095 | </View> |
| 1157 | ); | 1096 | ); |
| 1158 | } | 1097 | } |
apps/mobile/src/features/threads/ThreadComposer.tsx+57-124| ... | @@ -51,10 +51,10 @@ import { | ... | @@ -51,10 +51,10 @@ import { |
| 51 | ComposerToolbarScroller, | 51 | ComposerToolbarScroller, |
| 52 | ComposerToolbarTrigger, | 52 | ComposerToolbarTrigger, |
| 53 | } from "../../components/ComposerToolbarTrigger"; | 53 | } from "../../components/ComposerToolbarTrigger"; |
| 54 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | 54 | import { ControlPill } from "../../components/ControlPill"; |
| 55 | import { ProviderIcon } from "../../components/ProviderIcon"; | 55 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 56 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 56 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 57 | import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 57 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 58 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 58 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 59 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 59 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 60 | import { | 60 | import { |
| ... | @@ -62,14 +62,11 @@ import { | ... | @@ -62,14 +62,11 @@ import { |
| 62 | normalizeSearchQuery, | 62 | normalizeSearchQuery, |
| 63 | scoreQueryMatch, | 63 | scoreQueryMatch, |
| 64 | } from "@t3tools/shared/searchRanking"; | 64 | } from "@t3tools/shared/searchRanking"; |
| 65 | import { | 65 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; |
| 66 | applyProviderOptionMenuEvent, | ||
| 67 | buildProviderOptionMenuActions, | ||
| 68 | providerOptionsConfigurationLabel, | ||
| 69 | resolveProviderOptionDescriptors, | ||
| 70 | } from "../../lib/providerOptions"; | ||
| 71 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | 66 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; |
| 72 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | 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 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). | 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 | ... | @@ -273,15 +270,28 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 273 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); | 270 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| 274 | const inputRef = props.editorRef ?? fallbackInputRef; | 271 | const inputRef = props.editorRef ?? fallbackInputRef; |
| 275 | const [isFocused, setIsFocused] = useState(false); | 272 | const [isFocused, setIsFocused] = useState(false); |
| 273 | const settingsSheetPresentation = useThreadSettingsSheetPresentation({ | ||
| 274 | editorRef: inputRef, | ||
| 275 | isEditorFocused: isFocused, | ||
| 276 | }); | ||
| 276 | const wasExpandedBeforePreviewRef = useRef(false); | 277 | const wasExpandedBeforePreviewRef = useRef(false); |
| 277 | const inFlightThreadIdsRef = useRef(new Set<string>()); | 278 | const inFlightThreadIdsRef = useRef(new Set<string>()); |
| 278 | const { onExpandedChange } = props; | 279 | const { onExpandedChange } = props; |
| 279 | 280 | ||
| 280 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | 281 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); |
| 281 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | 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 | const canSend = hasContent; | 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 | const onPressImage = useCallback( | 295 | const onPressImage = useCallback( |
| 286 | (uri: string) => { | 296 | (uri: string) => { |
| 287 | wasExpandedBeforePreviewRef.current = isFocused; | 297 | wasExpandedBeforePreviewRef.current = isFocused; |
| ... | @@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 299 | 309 | ||
| 300 | const handleFocus = useCallback(() => { | 310 | const handleFocus = useCallback(() => { |
| 301 | setIsFocused(true); | 311 | setIsFocused(true); |
| 302 | onExpandedChange?.(true); | 312 | }, []); |
| 303 | }, [onExpandedChange]); | ||
| 304 | 313 | ||
| 305 | const handleBlur = useCallback(() => { | 314 | const handleBlur = useCallback(() => { |
| 306 | setIsFocused(false); | 315 | setIsFocused(false); |
| 307 | onExpandedChange?.(false); | 316 | }, []); |
| 308 | }, [onExpandedChange]); | ||
| 309 | const showStopAction = | 317 | const showStopAction = |
| 310 | props.selectedThread.session?.status === "running" || | 318 | props.selectedThread.session?.status === "running" || |
| 311 | props.selectedThread.session?.status === "starting"; | 319 | props.selectedThread.session?.status === "starting"; |
| ... | @@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 588 | [props.serverConfig, currentModelSelection], | 596 | [props.serverConfig, currentModelSelection], |
| 589 | ); | 597 | ); |
| 590 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | 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 | const currentModelOption = | 605 | const currentModelOption = |
| 592 | modelOptions.find( | 606 | modelOptions.find( |
| 593 | (option) => | 607 | (option) => |
| ... | @@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 602 | }), | 616 | }), |
| 603 | [currentModelOption?.capabilities, currentModelSelection.options], | 617 | [currentModelOption?.capabilities, currentModelSelection.options], |
| 604 | ); | 618 | ); |
| 605 | const configurationLabel = useMemo( | 619 | const settingsSummaryLabel = threadSettingsSummaryLabel({ |
| 606 | () => providerOptionsConfigurationLabel(providerOptionDescriptors), | 620 | modelLabel: currentModelOption?.label ?? currentModelSelection.model, |
| 607 | [providerOptionDescriptors], | 621 | optionDescriptors: providerOptionDescriptors, |
| 608 | ); | 622 | runtimeMode: currentRuntimeMode, |
| 609 | const modelMenuActions = useMemo( | 623 | interactionMode: currentInteractionMode, |
| 610 | () => buildModelMenuActions(providerGroups, currentModelSelection), | 624 | }); |
| 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 | } | ||
| 694 | 625 | ||
| 695 | return ( | 626 | return ( |
| 696 | <Animated.View | 627 | <Animated.View |
| ... | @@ -861,28 +792,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -861,28 +792,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 861 | onPress={() => void props.onPickDraftImages()} | 792 | onPress={() => void props.onPickDraftImages()} |
| 862 | showChevron={false} | 793 | showChevron={false} |
| 863 | /> | 794 | /> |
| 864 | <ControlPillMenu | 795 | <ComposerToolbarTrigger |
| 865 | actions={modelMenuActions} | 796 | accessibilityLabel="Thread settings" |
| 866 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | 797 | iconNode={ |
| 867 | > | 798 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> |
| 868 | <ComposerToolbarTrigger | 799 | } |
| 869 | accessibilityLabel="Model" | 800 | label={settingsSummaryLabel} |
| 870 | iconNode={ | 801 | maxWidth={320} |
| 871 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | 802 | onPress={settingsSheetPresentation.open} |
| 872 | } | 803 | /> |
| 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> | ||
| 886 | {showStopAction ? ( | 804 | {showStopAction ? ( |
| 887 | <ComposerToolbarButton | 805 | <ComposerToolbarButton |
| 888 | accessibilityLabel="Stop" | 806 | accessibilityLabel="Stop" |
| ... | @@ -916,6 +834,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -916,6 +834,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 916 | ) : null} | 834 | ) : null} |
| 917 | </Animated.View> | 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 | <ImageViewing | 852 | <ImageViewing |
| 920 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | 853 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| 921 | imageIndex={0} | 854 | imageIndex={0} |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx created+678| ... | @@ -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"; | ... | @@ -25,6 +25,7 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 25 | import { | 25 | import { |
| 26 | buildModelOptions, | 26 | buildModelOptions, |
| 27 | groupByProvider, | 27 | groupByProvider, |
| 28 | resolveDefaultableModelSelection, | ||
| 28 | resolveSelectableModelSelection, | 29 | resolveSelectableModelSelection, |
| 29 | } from "../../lib/modelOptions"; | 30 | } from "../../lib/modelOptions"; |
| 30 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 31 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| ... | @@ -147,7 +148,10 @@ type NewTaskFlowContextValue = { | ... | @@ -147,7 +148,10 @@ type NewTaskFlowContextValue = { |
| 147 | readonly reset: () => void; | 148 | readonly reset: () => void; |
| 148 | readonly setProject: (project: EnvironmentProject) => void; | 149 | readonly setProject: (project: EnvironmentProject) => void; |
| 149 | readonly selectEnvironment: (environmentId: EnvironmentId) => void; | 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 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; | 155 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; |
| 152 | readonly selectBranch: (branch: VcsRef) => void; | 156 | readonly selectBranch: (branch: VcsRef) => void; |
| 153 | readonly setStartFromOrigin: (value: boolean) => void; | 157 | readonly setStartFromOrigin: (value: boolean) => void; |
| ... | @@ -359,14 +363,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -359,14 +363,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 359 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 363 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 360 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | 364 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; |
| 361 | 365 | ||
| 362 | // Stored selections (draft and project default) only count while their | 366 | // Stored selections only count while their provider is usable on the |
| 363 | // provider is usable on the server; otherwise the server's default model | 367 | // server; otherwise the server's default model wins instead of silently |
| 364 | // wins instead of silently targeting a disabled provider. | 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 | const draftModelSelection = resolveSelectableModelSelection( | 371 | const draftModelSelection = resolveSelectableModelSelection( |
| 366 | selectedEnvironmentServerConfig, | 372 | selectedEnvironmentServerConfig, |
| 367 | selectedProjectDraft.modelSelection ?? null, | 373 | selectedProjectDraft.modelSelection ?? null, |
| 368 | ); | 374 | ); |
| 369 | const projectDefaultModelSelection = resolveSelectableModelSelection( | 375 | const projectDefaultModelSelection = resolveDefaultableModelSelection( |
| 370 | selectedEnvironmentServerConfig, | 376 | selectedEnvironmentServerConfig, |
| 371 | selectedProject?.defaultModelSelection ?? null, | 377 | selectedProject?.defaultModelSelection ?? null, |
| 372 | ); | 378 | ); |
| ... | @@ -404,7 +410,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -404,7 +410,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 404 | [selectedEnvironmentServerConfig, selectedModel?.instanceId], | 410 | [selectedEnvironmentServerConfig, selectedModel?.instanceId], |
| 405 | ); | 411 | ); |
| 406 | const setSelectedModelKey = useCallback( | 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 | if (!key || !selectedProjectDraftKey) { | 416 | if (!key || !selectedProjectDraftKey) { |
| 409 | return; | 417 | return; |
| 410 | } | 418 | } |
| ... | @@ -413,7 +421,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -413,7 +421,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 413 | return; | 421 | return; |
| 414 | } | 422 | } |
| 415 | updateComposerDraftSettings(selectedProjectDraftKey, { | 423 | updateComposerDraftSettings(selectedProjectDraftKey, { |
| 416 | modelSelection: option.selection, | 424 | modelSelection: options ? { ...option.selection, options } : option.selection, |
| 417 | }); | 425 | }); |
| 418 | }, | 426 | }, |
| 419 | [modelOptions, selectedProjectDraftKey], | 427 | [modelOptions, selectedProjectDraftKey], |
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts created+64| ... | @@ -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 created+13| ... | @@ -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 created+98| ... | @@ -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"; | ... | @@ -3,14 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; | 3 | import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | buildModelMenuActions, | ||
| 7 | buildModelOptions, | 6 | buildModelOptions, |
| 8 | groupByProvider, | 7 | groupByProvider, |
| 8 | resolveDefaultableModelSelection, | ||
| 9 | resolveSelectableModelSelection, | 9 | resolveSelectableModelSelection, |
| 10 | } from "./modelOptions"; | 10 | } from "./modelOptions"; |
| 11 | 11 | ||
| 12 | describe("mobile model options", () => { | 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 | const config = { | 14 | const config = { |
| 15 | providers: [ | 15 | providers: [ |
| 16 | { | 16 | { |
| ... | @@ -39,51 +39,14 @@ describe("mobile model options", () => { | ... | @@ -39,51 +39,14 @@ describe("mobile model options", () => { |
| 39 | ], | 39 | ], |
| 40 | } as unknown as ServerConfig; | 40 | } as unknown as ServerConfig; |
| 41 | 41 | ||
| 42 | const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null); | 42 | expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([ |
| 43 | |||
| 44 | expect(actions).toMatchObject([ | ||
| 45 | { | ||
| 46 | title: "Codex", | ||
| 47 | subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }], | ||
| 48 | }, | ||
| 49 | { | 43 | { |
| 50 | id: "legacy-models:codex", | 44 | providerKey: "codex", |
| 51 | title: "Codex legacy models", | 45 | providerLabel: "Codex", |
| 52 | subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }], | 46 | models: [ |
| 53 | }, | 47 | { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false }, |
| 54 | ]); | 48 | { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, |
| 55 | }); | 49 | ], |
| 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" }], | ||
| 87 | }, | 50 | }, |
| 88 | ]); | 51 | ]); |
| 89 | }); | 52 | }); |
| ... | @@ -174,4 +137,38 @@ describe("mobile model options", () => { | ... | @@ -174,4 +137,38 @@ describe("mobile model options", () => { |
| 174 | // No config (environment offline) — nothing to validate against. | 137 | // No config (environment offline) — nothing to validate against. |
| 175 | expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); | 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 | }); |
apps/mobile/src/lib/modelOptions.ts+20-51| ... | @@ -3,7 +3,6 @@ import type { | ... | @@ -3,7 +3,6 @@ import type { |
| 3 | ModelSelection, | 3 | ModelSelection, |
| 4 | ServerConfig as T3ServerConfig, | 4 | ServerConfig as T3ServerConfig, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 7 | import { | 6 | import { |
| 8 | buildProviderOptionSelectionsFromDescriptors, | 7 | buildProviderOptionSelectionsFromDescriptors, |
| 9 | getProviderOptionDescriptors, | 8 | getProviderOptionDescriptors, |
| ... | @@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( | ... | @@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( |
| 85 | : null; | 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 | export function buildModelOptions( | 107 | export function buildModelOptions( |
| 89 | config: T3ServerConfig | null | undefined, | 108 | config: T3ServerConfig | null | undefined, |
| 90 | fallbackModelSelection: ModelSelection | null, | 109 | fallbackModelSelection: ModelSelection | null, |
| ... | @@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr | ... | @@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr |
| 168 | models: group.models, | 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 | } |
apps/mobile/src/lib/providerOptions.test.ts+15-37| ... | @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import type { ModelCapabilities } from "@t3tools/contracts"; | 3 | import type { ModelCapabilities } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | applyProviderOptionMenuEvent, | 6 | applyProviderOptionSelection, |
| 7 | buildProviderOptionMenuActions, | 7 | providerOptionValueLabels, |
| 8 | providerOptionsConfigurationLabel, | ||
| 9 | resolveProviderOptionDescriptors, | 8 | resolveProviderOptionDescriptors, |
| 10 | } from "./providerOptions"; | 9 | } from "./providerOptions"; |
| 11 | 10 | ||
| ... | @@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { | ... | @@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { |
| 35 | }; | 34 | }; |
| 36 | 35 | ||
| 37 | describe("mobile provider options", () => { | 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 | const descriptors = resolveProviderOptionDescriptors({ | 38 | const descriptors = resolveProviderOptionDescriptors({ |
| 40 | capabilities: CODEX_CAPABILITIES, | 39 | capabilities: CODEX_CAPABILITIES, |
| 41 | selections: undefined, | 40 | selections: undefined, |
| 42 | }); | 41 | }); |
| 43 | 42 | ||
| 44 | expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ | 43 | expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); |
| 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"); | ||
| 63 | }); | 44 | }); |
| 64 | 45 | ||
| 65 | it("updates generic select options without knowing provider-specific ids", () => { | 46 | it("updates generic select options without knowing provider-specific ids", () => { |
| ... | @@ -67,14 +48,18 @@ describe("mobile provider options", () => { | ... | @@ -67,14 +48,18 @@ describe("mobile provider options", () => { |
| 67 | capabilities: CODEX_CAPABILITIES, | 48 | capabilities: CODEX_CAPABILITIES, |
| 68 | selections: undefined, | 49 | selections: undefined, |
| 69 | }); | 50 | }); |
| 70 | const actions = buildProviderOptionMenuActions(descriptors); | ||
| 71 | const fastEvent = actions[1]?.subactions?.[1]?.id; | ||
| 72 | 51 | ||
| 73 | expect(fastEvent).toBeDefined(); | 52 | expect( |
| 74 | expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ | 53 | applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }), |
| 54 | ).toEqual([ | ||
| 75 | { id: "reasoningEffort", value: "medium" }, | 55 | { id: "reasoningEffort", value: "medium" }, |
| 76 | { id: "serviceTier", value: "priority" }, | 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 | it("treats an unspecified boolean capability as off", () => { | 65 | it("treats an unspecified boolean capability as off", () => { |
| ... | @@ -85,16 +70,9 @@ describe("mobile provider options", () => { | ... | @@ -85,16 +70,9 @@ describe("mobile provider options", () => { |
| 85 | selections: undefined, | 70 | selections: undefined, |
| 86 | }); | 71 | }); |
| 87 | 72 | ||
| 88 | expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ | 73 | expect(providerOptionValueLabels(descriptors)).toEqual([]); |
| 89 | { | 74 | expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ |
| 90 | title: "Fast Mode", | 75 | { id: "fastMode", value: true }, |
| 91 | subtitle: "Off", | ||
| 92 | subactions: [ | ||
| 93 | { title: "Off", state: "on" }, | ||
| 94 | { title: "On", state: undefined }, | ||
| 95 | ], | ||
| 96 | }, | ||
| 97 | ]); | 76 | ]); |
| 98 | expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); | ||
| 99 | }); | 77 | }); |
| 100 | }); | 78 | }); |
apps/mobile/src/lib/providerOptions.ts+20-87| ... | @@ -3,48 +3,12 @@ import type { | ... | @@ -3,48 +3,12 @@ import type { |
| 3 | ProviderOptionDescriptor, | 3 | ProviderOptionDescriptor, |
| 4 | ProviderOptionSelection, | 4 | ProviderOptionSelection, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 7 | import { | 6 | import { |
| 8 | buildProviderOptionSelectionsFromDescriptors, | 7 | buildProviderOptionSelectionsFromDescriptors, |
| 9 | getProviderOptionCurrentLabel, | 8 | getProviderOptionCurrentLabel, |
| 10 | getProviderOptionCurrentValue, | ||
| 11 | getProviderOptionDescriptors, | 9 | getProviderOptionDescriptors, |
| 12 | } from "@t3tools/shared/model"; | 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 | export function resolveProviderOptionDescriptors(input: { | 12 | export function resolveProviderOptionDescriptors(input: { |
| 49 | readonly capabilities: ModelCapabilities | null | undefined; | 13 | readonly capabilities: ModelCapabilities | null | undefined; |
| 50 | readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined; | 14 | readonly selections: ReadonlyArray<ProviderOptionSelection> | null | undefined; |
| ... | @@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { | ... | @@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { |
| 58 | }); | 22 | }); |
| 59 | } | 23 | } |
| 60 | 24 | ||
| 61 | export function buildProviderOptionMenuActions( | 25 | /** |
| 62 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | 26 | * Labels for the option values currently in effect (select values plus |
| 63 | ): ReadonlyArray<MenuAction> { | 27 | * enabled booleans), used to summarize the thread configuration in the |
| 64 | return descriptors.map((descriptor) => { | 28 | * composer trigger pill. |
| 65 | const currentValue = | 29 | */ |
| 66 | descriptor.type === "boolean" | 30 | export function providerOptionValueLabels( |
| 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( | ||
| 97 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | 31 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, |
| 98 | ): string { | 32 | ): ReadonlyArray<string> { |
| 99 | const labels = descriptors.flatMap((descriptor) => { | 33 | return descriptors.flatMap((descriptor) => { |
| 100 | if (descriptor.type === "boolean") { | 34 | if (descriptor.type === "boolean") { |
| 101 | return descriptor.currentValue ? [descriptor.label] : []; | 35 | return descriptor.currentValue ? [descriptor.label] : []; |
| 102 | } | 36 | } |
| 103 | const label = getProviderOptionCurrentLabel(descriptor); | 37 | const label = getProviderOptionCurrentLabel(descriptor); |
| 104 | return label ? [label] : []; | 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 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, | 48 | descriptors: ReadonlyArray<ProviderOptionDescriptor>, |
| 111 | event: string, | 49 | change: ProviderOptionSelection, |
| 112 | ): ReadonlyArray<ProviderOptionSelection> | null { | 50 | ): ReadonlyArray<ProviderOptionSelection> | null { |
| 113 | const selection = parseProviderOptionEvent(event); | 51 | const descriptor = descriptors.find((candidate) => candidate.id === change.id); |
| 114 | if (!selection) { | ||
| 115 | return null; | ||
| 116 | } | ||
| 117 | |||
| 118 | const descriptor = descriptors.find((candidate) => candidate.id === selection.id); | ||
| 119 | if (!descriptor) { | 52 | if (!descriptor) { |
| 120 | return null; | 53 | return null; |
| 121 | } | 54 | } |
| 122 | if ( | 55 | if ( |
| 123 | (descriptor.type === "boolean" && typeof selection.value !== "boolean") || | 56 | (descriptor.type === "boolean" && typeof change.value !== "boolean") || |
| 124 | (descriptor.type === "select" && | 57 | (descriptor.type === "select" && |
| 125 | (typeof selection.value !== "string" || | 58 | (typeof change.value !== "string" || |
| 126 | !descriptor.options.some((option) => option.id === selection.value))) | 59 | !descriptor.options.some((option) => option.id === change.value))) |
| 127 | ) { | 60 | ) { |
| 128 | return null; | 61 | return null; |
| 129 | } | 62 | } |
| ... | @@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent( | ... | @@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent( |
| 132 | candidate.id === descriptor.id | 65 | candidate.id === descriptor.id |
| 133 | ? { | 66 | ? { |
| 134 | ...candidate, | 67 | ...candidate, |
| 135 | currentValue: selection.value, | 68 | currentValue: change.value, |
| 136 | } | 69 | } |
| 137 | : candidate, | 70 | : candidate, |
| 138 | ) as ReadonlyArray<ProviderOptionDescriptor>; | 71 | ) as ReadonlyArray<ProviderOptionDescriptor>; |