authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 10:07:10-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log5707a6ee1dc470198ecb01a0b6181c5082c7a81f
tree9e3099ff0427533ef4cf79b559fffa4f5e8c9abf
parent291411a45e29f49c5e47dc15cb6b199f08634b32

Use grouped provider/model select in chat composer

Co-authored-by: codex <codex@users.noreply.github.com>

1 files changed, 70 insertions(+), 48 deletions(-)

apps/web/src/components/ChatView.tsx+70-48
...@@ -107,7 +107,15 @@ import {...@@ -107,7 +107,15 @@ import {
107 CheckIcon,107 CheckIcon,
108} from "lucide-react";108} from "lucide-react";
109import { Button } from "./ui/button";109import { Button } from "./ui/button";
110import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";110import {
111 Select,
112 SelectGroup,
113 SelectGroupLabel,
114 SelectItem,
115 SelectPopup,
116 SelectTrigger,
117 SelectValue,
118} from "./ui/select";
111import { Separator } from "./ui/separator";119import { Separator } from "./ui/separator";
112import { Group, GroupSeparator } from "./ui/group";120import { Group, GroupSeparator } from "./ui/group";
113import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";121import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
...@@ -2265,13 +2273,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2265,13 +2273,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
2265 },2273 },
2266 [activeThread, scheduleComposerFocus, selectedProvider],2274 [activeThread, scheduleComposerFocus, selectedProvider],
2267 );2275 );
2268 const onProviderSelect = useCallback(2276 const onProviderModelSelect = useCallback(
2269 (provider: ProviderKind) => {2277 (provider: ProviderKind, model: ModelSlug) => {
2278 const api = readNativeApi();
2270 if (!activeThread) return;2279 if (!activeThread) return;
2271 setSelectedProviderByThread((existing) => ({2280 setSelectedProviderByThread((existing) => ({
2272 ...existing,2281 ...existing,
2273 [activeThread.id]: provider,2282 [activeThread.id]: provider,
2274 }));2283 }));
2284 if (api) {
2285 void api.orchestration.dispatchCommand({
2286 type: "thread.meta.update",
2287 commandId: newCommandId(),
2288 threadId: activeThread.id,
2289 model: resolveModelSlugForProvider(provider, model),
2290 });
2291 }
2275 scheduleComposerFocus();2292 scheduleComposerFocus();
2276 },2293 },
2277 [activeThread, scheduleComposerFocus],2294 [activeThread, scheduleComposerFocus],
...@@ -2712,17 +2729,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2712,17 +2729,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
2712 {/* Bottom toolbar */}2729 {/* Bottom toolbar */}
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">2730 <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">
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">2731 <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 */}2732 {/* Provider/model picker */}
2716 <ProviderPicker provider={selectedProvider} onProviderChange={onProviderSelect} />2733 <ProviderModelPicker
27172734 provider={selectedProvider}
2718 {/* Divider */}
2719 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
2720
2721 {/* Model picker */}
2722 <ModelPicker
2723 model={selectedModel}2735 model={selectedModel}
2724 options={modelOptions}2736 onProviderModelChange={onProviderModelSelect}
2725 onModelChange={onModelSelect}
2726 />2737 />
27272738
2728 {/* Divider */}2739 {/* Divider */}
...@@ -3866,54 +3877,65 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3866,54 +3877,65 @@ const MessagesTimeline = memo(function MessagesTimeline({
3866 );3877 );
3867});3878});
38683879
3869const ModelPicker = memo(function ModelPicker(props: {3880const PROVIDER_MODEL_GROUPS: ReadonlyArray<{
3881 readonly provider: ProviderKind;
3882 readonly label: string;
3883 readonly options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>;
3884}> = PROVIDER_OPTIONS.filter((option) => option.available).map((option) => ({
3885 provider: option.value,
3886 label: option.label,
3887 options: getModelOptions(option.value),
3888}));
3889
3890const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3891 provider: ProviderKind;
3870 model: ModelSlug;3892 model: ModelSlug;
3871 options: ReadonlyArray<{ readonly slug: ModelSlug; readonly name: string }>;3893 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
3872 onModelChange: (model: ModelSlug) => void;
3873}) {3894}) {
3874 return (3895 const items = PROVIDER_MODEL_GROUPS.flatMap(({ provider, label, options }) =>
3875 <Select3896 options.map(({ slug, name }) => ({
3876 items={props.options.map((option) => ({ label: option.name, value: option.slug }))}3897 label: `${label} · ${name}`,
3877 value={props.model}3898 value: `${provider}:${slug}`,
3878 onValueChange={(value) => (value ? props.onModelChange(value) : undefined)}3899 })),
3879 >
3880 <SelectTrigger size="sm" variant="ghost">
3881 <SelectValue />
3882 </SelectTrigger>
3883 <SelectPopup alignItemWithTrigger={false}>
3884 {props.options.map(({ slug, name }) => (
3885 <SelectItem key={slug} value={slug}>
3886 {name}
3887 </SelectItem>
3888 ))}
3889 </SelectPopup>
3890 </Select>
3891 );3900 );
3892});
38933901
3894const ProviderPicker = memo(function ProviderPicker(props: {
3895 provider: ProviderKind;
3896 onProviderChange: (provider: ProviderKind) => void;
3897}) {
3898 return (3902 return (
3899 <Select3903 <Select
3900 items={PROVIDER_OPTIONS.filter((option) => option.available).map((option) => ({3904 items={items}
3901 label: option.label,3905 value={`${props.provider}:${props.model}`}
3902 value: option.value,
3903 }))}
3904 value={props.provider}
3905 onValueChange={(value) =>3906 onValueChange={(value) =>
3906 value === "codex" || value === "claudeCode" ? props.onProviderChange(value) : undefined3907 value
3908 ? (() => {
3909 if (value.startsWith("codex:")) {
3910 props.onProviderModelChange(
3911 "codex",
3912 resolveModelSlugForProvider("codex", value.slice("codex:".length)),
3913 );
3914 return;
3915 }
3916 if (value.startsWith("claudeCode:")) {
3917 props.onProviderModelChange(
3918 "claudeCode",
3919 resolveModelSlugForProvider("claudeCode", value.slice("claudeCode:".length)),
3920 );
3921 }
3922 })()
3923 : undefined
3907 }3924 }
3908 >3925 >
3909 <SelectTrigger size="sm" variant="ghost">3926 <SelectTrigger size="sm" variant="ghost">
3910 <SelectValue />3927 <SelectValue />
3911 </SelectTrigger>3928 </SelectTrigger>
3912 <SelectPopup alignItemWithTrigger={false}>3929 <SelectPopup alignItemWithTrigger={false}>
3913 {PROVIDER_OPTIONS.filter((option) => option.available).map(({ value, label }) => (3930 {PROVIDER_MODEL_GROUPS.map(({ provider, label, options }) => (
3914 <SelectItem key={value} value={value}>3931 <SelectGroup key={provider}>
3915 {label}3932 <SelectGroupLabel>{label}</SelectGroupLabel>
3916 </SelectItem>3933 {options.map(({ slug, name }) => (
3934 <SelectItem key={`${provider}:${slug}`} value={`${provider}:${slug}`}>
3935 {name}
3936 </SelectItem>
3937 ))}
3938 </SelectGroup>
3917 ))}3939 ))}
3918 </SelectPopup>3940 </SelectPopup>
3919 </Select>3941 </Select>