| ... | ... | @@ -6,6 +6,8 @@ import { |
| 6 | 6 | type EditorId, |
| 7 | 7 | type KeybindingCommand, |
| 8 | 8 | type MessageId, |
| 9 | getDefaultModel, |
| 10 | getModelOptions, |
| 9 | 11 | type ProjectId, |
| 10 | 12 | type ProjectEntry, |
| 11 | 13 | type ProjectScript, |
| ... | ... | @@ -16,10 +18,11 @@ import { |
| 16 | 18 | type ResolvedKeybindingsConfig, |
| 17 | 19 | type ProviderApprovalDecision, |
| 18 | 20 | type ServerProviderStatus, |
| 21 | type ProviderKind, |
| 19 | 22 | type ThreadId, |
| 20 | 23 | type TurnId, |
| 21 | 24 | normalizeModelSlug, |
| 22 | | resolveModelSlug, |
| 25 | resolveModelSlugForProvider, |
| 23 | 26 | OrchestrationThreadActivity, |
| 24 | 27 | } from "@t3tools/contracts"; |
| 25 | 28 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -49,6 +52,7 @@ import { |
| 49 | 52 | derivePhase, |
| 50 | 53 | deriveTimelineEntries, |
| 51 | 54 | type PendingApproval, |
| 55 | PROVIDER_OPTIONS, |
| 52 | 56 | deriveWorkLogEntries, |
| 53 | 57 | hasToolActivityForTurn, |
| 54 | 58 | isLatestTurnSettled, |
| ... | ... | @@ -509,6 +513,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 509 | 513 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 510 | 514 | const [isConnecting, _setIsConnecting] = useState(false); |
| 511 | 515 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 516 | const [selectedProviderByThread, setSelectedProviderByThread] = useState< |
| 517 | Partial<Record<ThreadId, ProviderKind>> |
| 518 | >({}); |
| 512 | 519 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 513 | 520 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 514 | 521 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| ... | ... | @@ -632,20 +639,45 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 632 | 639 | markThreadVisited, |
| 633 | 640 | ]); |
| 634 | 641 | |
| 635 | | const baseThreadModel = resolveModelSlug( |
| 636 | | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 642 | const sessionProvider = activeThread?.session?.provider ?? null; |
| 643 | const selectedProvider: ProviderKind = |
| 644 | (activeThread?.id ? selectedProviderByThread[activeThread.id] : undefined) ?? |
| 645 | sessionProvider ?? |
| 646 | "codex"; |
| 647 | const baseThreadModel = resolveModelSlugForProvider( |
| 648 | selectedProvider, |
| 649 | activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, |
| 637 | 650 | ); |
| 638 | | const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); |
| 651 | const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel); |
| 639 | 652 | const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; |
| 640 | 653 | const modelOptions = useMemo( |
| 641 | | () => getAppModelOptions(settings.customCodexModels, selectedModel), |
| 642 | | [selectedModel, settings.customCodexModels], |
| 643 | | ); |
| 644 | | const slashModelOptions = useMemo( |
| 645 | 654 | () => |
| 646 | | getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel), |
| 647 | | [composerTrigger?.query, selectedModel, settings.customCodexModels], |
| 648 | | ); |
| 655 | selectedProvider === "codex" |
| 656 | ? getAppModelOptions(settings.customCodexModels, selectedModel) |
| 657 | : getModelOptions(selectedProvider), |
| 658 | [selectedModel, selectedProvider, settings.customCodexModels], |
| 659 | ); |
| 660 | const slashModelOptions = useMemo(() => { |
| 661 | if (selectedProvider === "codex") { |
| 662 | return getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel); |
| 663 | } |
| 664 | const query = (composerTrigger?.query ?? "").trim().toLowerCase(); |
| 665 | if (!query) { |
| 666 | return modelOptions; |
| 667 | } |
| 668 | return modelOptions.filter(({ slug, name }) => { |
| 669 | const searchSlug = slug.toLowerCase(); |
| 670 | const searchName = name.toLowerCase(); |
| 671 | return searchSlug.includes(query) || searchName.includes(query); |
| 672 | }); |
| 673 | }, [composerTrigger?.query, modelOptions, selectedModel, selectedProvider, settings.customCodexModels]); |
| 674 | useEffect(() => { |
| 675 | if (!activeThread?.id || !sessionProvider) return; |
| 676 | setSelectedProviderByThread((existing) => { |
| 677 | if (existing[activeThread.id]) return existing; |
| 678 | return { ...existing, [activeThread.id]: sessionProvider }; |
| 679 | }); |
| 680 | }, [activeThread?.id, sessionProvider]); |
| 649 | 681 | const phase = derivePhase(activeThread?.session ?? null); |
| 650 | 682 | const isSendBusy = sendPhase !== "idle"; |
| 651 | 683 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| ... | ... | @@ -2127,6 +2159,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2127 | 2159 | attachments: turnAttachments, |
| 2128 | 2160 | }, |
| 2129 | 2161 | model: selectedModel || undefined, |
| 2162 | provider: selectedProvider, |
| 2130 | 2163 | effort: selectedEffort || undefined, |
| 2131 | 2164 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", |
| 2132 | 2165 | approvalPolicy, |
| ... | ... | @@ -2225,19 +2258,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2225 | 2258 | type: "thread.meta.update", |
| 2226 | 2259 | commandId: newCommandId(), |
| 2227 | 2260 | threadId: activeThread.id, |
| 2228 | | model: resolvedModel, |
| 2261 | model: resolveModelSlugForProvider(selectedProvider, model), |
| 2229 | 2262 | }); |
| 2230 | 2263 | } |
| 2231 | 2264 | scheduleComposerFocus(); |
| 2232 | 2265 | }, |
| 2233 | | [ |
| 2234 | | activeThread, |
| 2235 | | baseThreadModel, |
| 2236 | | isServerThread, |
| 2237 | | scheduleComposerFocus, |
| 2238 | | setComposerDraftModel, |
| 2239 | | threadId, |
| 2240 | | ], |
| 2266 | [activeThread, scheduleComposerFocus, selectedProvider], |
| 2267 | ); |
| 2268 | const onProviderSelect = useCallback( |
| 2269 | (provider: ProviderKind) => { |
| 2270 | if (!activeThread) return; |
| 2271 | setSelectedProviderByThread((existing) => ({ |
| 2272 | ...existing, |
| 2273 | [activeThread.id]: provider, |
| 2274 | })); |
| 2275 | scheduleComposerFocus(); |
| 2276 | }, |
| 2277 | [activeThread, scheduleComposerFocus], |
| 2241 | 2278 | ); |
| 2242 | 2279 | const onEffortSelect = useCallback( |
| 2243 | 2280 | (effort: ReasoningEffort) => { |
| ... | ... | @@ -2675,6 +2712,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2675 | 2712 | {/* Bottom toolbar */} |
| 2676 | 2713 | <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3"> |
| 2677 | 2714 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible"> |
| 2715 | {/* Model picker */} |
| 2716 | <ProviderPicker provider={selectedProvider} onProviderChange={onProviderSelect} /> |
| 2717 | |
| 2718 | {/* Divider */} |
| 2719 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 2720 | |
| 2678 | 2721 | {/* Model picker */} |
| 2679 | 2722 | <ModelPicker |
| 2680 | 2723 | model={selectedModel} |
| ... | ... | @@ -3824,9 +3867,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3824 | 3867 | }); |
| 3825 | 3868 | |
| 3826 | 3869 | const ModelPicker = memo(function ModelPicker(props: { |
| 3827 | | model: string; |
| 3828 | | options: ReadonlyArray<{ slug: string; name: string }>; |
| 3829 | | onModelChange: (model: string) => void; |
| 3870 | model: ModelSlug; |
| 3871 | options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>; |
| 3872 | onModelChange: (model: ModelSlug) => void; |
| 3830 | 3873 | }) { |
| 3831 | 3874 | return ( |
| 3832 | 3875 | <Select |
| ... | ... | @@ -3848,6 +3891,35 @@ const ModelPicker = memo(function ModelPicker(props: { |
| 3848 | 3891 | ); |
| 3849 | 3892 | }); |
| 3850 | 3893 | |
| 3894 | const ProviderPicker = memo(function ProviderPicker(props: { |
| 3895 | provider: ProviderKind; |
| 3896 | onProviderChange: (provider: ProviderKind) => void; |
| 3897 | }) { |
| 3898 | return ( |
| 3899 | <Select |
| 3900 | items={PROVIDER_OPTIONS.filter((option) => option.available).map((option) => ({ |
| 3901 | label: option.label, |
| 3902 | value: option.value, |
| 3903 | }))} |
| 3904 | value={props.provider} |
| 3905 | onValueChange={(value) => |
| 3906 | value === "codex" || value === "claudeCode" ? props.onProviderChange(value) : undefined |
| 3907 | } |
| 3908 | > |
| 3909 | <SelectTrigger size="sm" variant="ghost"> |
| 3910 | <SelectValue /> |
| 3911 | </SelectTrigger> |
| 3912 | <SelectPopup alignItemWithTrigger={false}> |
| 3913 | {PROVIDER_OPTIONS.filter((option) => option.available).map(({ value, label }) => ( |
| 3914 | <SelectItem key={value} value={value}> |
| 3915 | {label} |
| 3916 | </SelectItem> |
| 3917 | ))} |
| 3918 | </SelectPopup> |
| 3919 | </Select> |
| 3920 | ); |
| 3921 | }); |
| 3922 | |
| 3851 | 3923 | const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { |
| 3852 | 3924 | effort: ReasoningEffort; |
| 3853 | 3925 | onEffortChange: (effort: ReasoningEffort) => void; |