Skip to content

Commit b25b572

Browse files
authored
🤖 feat: unify model selector and settings page UX (#801)
_Generated with `mux`_ ## Summary Unifies the UX between the model selector dropdown and the Settings pages, with consistent provider icons throughout. ## Changes ### Models Settings Page - **Built-in models displayed**: Shows all models from `KNOWN_MODELS` (not just custom models) - **Favoriting UX**: Star icon to set any model as default - same interaction as model selector dropdown - **Aliases shown**: Displays model aliases (e.g., `sonnet`, `haiku`) with tooltip explaining `/m <alias>` usage - **Custom models first**: Custom models section at top for easier access - **Increased density**: More compact UI with smaller padding and gaps ### Provider Icons - **New shared components**: `ProviderIcon` and `ProviderWithIcon` in `src/browser/components/ProviderIcon.tsx` - **Icons in Settings**: Both Models and Providers sections now show provider icons - **New icons added**: Google (Gemini) and xAI - **Consistent styling**: All icons use `currentColor` for proper theme support - **Documentation**: Added `src/browser/assets/icons/README.md` with instructions for adding new icons ### Code Organization - Extracted `ModelRow` into separate component for reusability - Refactored `ModelDisplay` to use shared `ProviderIcon` - Removed duplicate icon rendering logic ## Icon Sources | Provider | Source | |----------|--------| | Anthropic | [anthropic.com/brand](https://www.anthropic.com/brand) | | OpenAI | [openai.com/brand](https://openai.com/brand) | | Google | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Google_Gemini_icon_2025.svg) | | xAI | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:XAI_Logo.svg) | | AWS | [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/) |
1 parent 2f62728 commit b25b572

File tree

11 files changed

+388
-187
lines changed

11 files changed

+388
-187
lines changed

src/browser/assets/icons/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Provider Icons
2+
3+
This directory contains SVG icons for AI providers displayed in the UI.
4+
5+
## Current icons
6+
7+
| File | Provider | Source |
8+
|------|----------|--------|
9+
| `anthropic.svg` | Anthropic | [Brand assets](https://www.anthropic.com/brand) |
10+
| `openai.svg` | OpenAI | [Brand guidelines](https://openai.com/brand) |
11+
| `google.svg` | Google (Gemini) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Google_Gemini_icon_2025.svg) |
12+
| `xai.svg` | xAI | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:XAI_Logo.svg) |
13+
| `aws.svg` | Amazon Bedrock | [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/) |
14+
| `mux.svg` | Mux Gateway | Internal |
15+
16+
## Adding a new icon
17+
18+
1. **Get the official SVG** from the provider's brand/press kit
19+
2. **Optimize the SVG** - remove unnecessary metadata, comments, and attributes
20+
3. **Ensure single color** - icons should use `fill="currentColor"` or a class like `.st0` that can be styled via CSS
21+
4. **Name the file** `{provider}.svg` matching the provider key in `src/common/constants/providers.ts`
22+
5. **Register in ProviderIcon** - add to `PROVIDER_ICONS` map in `src/browser/components/ProviderIcon.tsx`:
23+
24+
```tsx
25+
import NewProviderIcon from "@/browser/assets/icons/newprovider.svg?react";
26+
27+
const PROVIDER_ICONS: Partial<Record<ProviderName | "mux-gateway", React.FC>> = {
28+
// ...existing icons
29+
newprovider: NewProviderIcon,
30+
};
31+
```
32+
33+
## SVG requirements
34+
35+
- Monochrome (single fill color)
36+
- Use classes (`.st0`) or `currentColor` for fills so the icon inherits text color
37+
- Reasonable viewBox (icons are rendered at 1em × 1em)
38+
- No embedded raster images
Lines changed: 4 additions & 9 deletions
Loading
Lines changed: 5 additions & 0 deletions
Loading

src/browser/assets/icons/xai.svg

Lines changed: 10 additions & 0 deletions
Loading

src/browser/components/Messages/ModelDisplay.tsx

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import React from "react";
2-
import AnthropicIcon from "@/browser/assets/icons/anthropic.svg?react";
3-
import OpenAIIcon from "@/browser/assets/icons/openai.svg?react";
4-
import AWSIcon from "@/browser/assets/icons/aws.svg?react";
5-
import MuxIcon from "@/browser/assets/icons/mux.svg?react";
62
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
3+
import { ProviderIcon } from "@/browser/components/ProviderIcon";
74
import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay";
85

96
interface ModelDisplayProps {
@@ -36,22 +33,6 @@ function parseModelString(modelString: string): {
3633
return { provider, modelName: rest, isMuxGateway: false, innerProvider: "" };
3734
}
3835

39-
/** Get icon component for a provider name */
40-
function getProviderIcon(provider: string): React.ReactNode {
41-
switch (provider) {
42-
case "anthropic":
43-
return <AnthropicIcon />;
44-
case "openai":
45-
return <OpenAIIcon />;
46-
case "bedrock":
47-
return <AWSIcon />;
48-
case "mux-gateway":
49-
return <MuxIcon />;
50-
default:
51-
return null;
52-
}
53-
}
54-
5536
/**
5637
* Display a model name with its provider icon.
5738
* Supports format "provider:model-name" (e.g., "anthropic:claude-sonnet-4-5")
@@ -65,8 +46,7 @@ export const ModelDisplay: React.FC<ModelDisplayProps> = ({ modelString, showToo
6546
const { provider, modelName, isMuxGateway, innerProvider } = parseModelString(modelString);
6647

6748
// For mux-gateway, show the inner provider's icon (the model's actual provider)
68-
const providerIcon = isMuxGateway ? getProviderIcon(innerProvider) : getProviderIcon(provider);
69-
const muxIcon = isMuxGateway ? getProviderIcon("mux-gateway") : null;
49+
const iconProvider = isMuxGateway ? innerProvider : provider;
7050
const displayName = formatModelDisplayName(modelName);
7151
const suffix = isMuxGateway ? " (mux gateway)" : "";
7252

@@ -75,16 +55,10 @@ export const ModelDisplay: React.FC<ModelDisplayProps> = ({ modelString, showToo
7555

7656
const content = (
7757
<span className="inline normal-case" data-model-display>
78-
{muxIcon && (
79-
<span className={iconClass} data-model-icon="mux">
80-
{muxIcon}
81-
</span>
82-
)}
83-
{providerIcon && (
84-
<span className={iconClass} data-model-icon>
85-
{providerIcon}
86-
</span>
58+
{isMuxGateway && (
59+
<ProviderIcon provider="mux-gateway" className={iconClass} data-model-icon="mux" />
8760
)}
61+
<ProviderIcon provider={iconProvider} className={iconClass} data-model-icon />
8862
<span className="inline">
8963
{displayName}
9064
{suffix}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import React from "react";
2+
import AnthropicIcon from "@/browser/assets/icons/anthropic.svg?react";
3+
import OpenAIIcon from "@/browser/assets/icons/openai.svg?react";
4+
import GoogleIcon from "@/browser/assets/icons/google.svg?react";
5+
import XAIIcon from "@/browser/assets/icons/xai.svg?react";
6+
import AWSIcon from "@/browser/assets/icons/aws.svg?react";
7+
import MuxIcon from "@/browser/assets/icons/mux.svg?react";
8+
import { PROVIDER_DISPLAY_NAMES, type ProviderName } from "@/common/constants/providers";
9+
import { cn } from "@/common/lib/utils";
10+
11+
const PROVIDER_ICONS: Partial<Record<ProviderName | "mux-gateway", React.FC>> = {
12+
anthropic: AnthropicIcon,
13+
openai: OpenAIIcon,
14+
google: GoogleIcon,
15+
xai: XAIIcon,
16+
bedrock: AWSIcon,
17+
"mux-gateway": MuxIcon,
18+
};
19+
20+
export interface ProviderIconProps {
21+
provider: string;
22+
className?: string;
23+
}
24+
25+
/**
26+
* Renders a provider's icon if one exists, otherwise returns null.
27+
* Icons are sized to 1em by default to match surrounding text.
28+
*/
29+
export function ProviderIcon(props: ProviderIconProps) {
30+
const IconComponent = PROVIDER_ICONS[props.provider as keyof typeof PROVIDER_ICONS];
31+
if (!IconComponent) return null;
32+
33+
return (
34+
<span
35+
className={cn(
36+
"inline-block h-[1em] w-[1em] align-[-0.125em] [&_svg]:block [&_svg]:h-full [&_svg]:w-full [&_svg]:fill-current [&_svg_.st0]:fill-current",
37+
props.className
38+
)}
39+
>
40+
<IconComponent />
41+
</span>
42+
);
43+
}
44+
45+
export interface ProviderWithIconProps {
46+
provider: string;
47+
className?: string;
48+
iconClassName?: string;
49+
/** Show display name instead of raw provider key */
50+
displayName?: boolean;
51+
}
52+
53+
/**
54+
* Renders a provider name with its icon (if available).
55+
* Falls back to just the name if no icon exists for the provider.
56+
*/
57+
export function ProviderWithIcon(props: ProviderWithIconProps) {
58+
const name = props.displayName
59+
? (PROVIDER_DISPLAY_NAMES[props.provider as ProviderName] ?? props.provider)
60+
: props.provider;
61+
62+
return (
63+
<span className={cn("inline-flex items-center gap-1", props.className)}>
64+
<ProviderIcon provider={props.provider} className={props.iconClassName} />
65+
<span>{name}</span>
66+
</span>
67+
);
68+
}

src/browser/components/Settings/Settings.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export const SidebarNavigation: Story = {
262262

263263
// Content should update to show Models section text
264264
await waitFor(async () => {
265-
const modelsText = canvas.getByText(/Add custom models/i);
265+
const modelsText = canvas.getByText(/Manage your models/i);
266266
await expect(modelsText).toBeVisible();
267267
});
268268
},
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React from "react";
2+
import { Check, Pencil, Star, Trash2, X } from "lucide-react";
3+
import { cn } from "@/common/lib/utils";
4+
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
5+
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
6+
7+
export interface ModelRowProps {
8+
provider: string;
9+
modelId: string;
10+
fullId: string;
11+
aliases?: string[];
12+
isCustom: boolean;
13+
isDefault: boolean;
14+
isEditing: boolean;
15+
editValue?: string;
16+
editError?: string | null;
17+
saving?: boolean;
18+
hasActiveEdit?: boolean;
19+
onSetDefault: () => void;
20+
onStartEdit?: () => void;
21+
onSaveEdit?: () => void;
22+
onCancelEdit?: () => void;
23+
onEditChange?: (value: string) => void;
24+
onRemove?: () => void;
25+
}
26+
27+
export function ModelRow(props: ModelRowProps) {
28+
return (
29+
<div className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-3 py-1.5">
30+
<div className="flex min-w-0 flex-1 items-center gap-2">
31+
<ProviderWithIcon
32+
provider={props.provider}
33+
displayName
34+
className="text-muted w-20 shrink-0 text-xs"
35+
/>
36+
{props.isEditing ? (
37+
<div className="flex min-w-0 flex-1 items-center gap-1">
38+
<input
39+
type="text"
40+
value={props.editValue ?? props.modelId}
41+
onChange={(e) => props.onEditChange?.(e.target.value)}
42+
onKeyDown={(e) => {
43+
if (e.key === "Enter") props.onSaveEdit?.();
44+
if (e.key === "Escape") props.onCancelEdit?.();
45+
}}
46+
className="bg-modal-bg border-border-medium focus:border-accent min-w-0 flex-1 rounded border px-2 py-0.5 font-mono text-xs focus:outline-none"
47+
autoFocus
48+
/>
49+
{props.editError && <div className="text-error text-xs">{props.editError}</div>}
50+
</div>
51+
) : (
52+
<div className="flex min-w-0 flex-1 items-center gap-2">
53+
<span className="text-foreground min-w-0 truncate font-mono text-xs">
54+
{props.modelId}
55+
</span>
56+
{props.aliases && props.aliases.length > 0 && (
57+
<TooltipWrapper inline>
58+
<span className="text-muted-light shrink-0 text-xs">
59+
({props.aliases.join(", ")})
60+
</span>
61+
<Tooltip className="tooltip" align="center">
62+
Use with /m {props.aliases[0]}
63+
</Tooltip>
64+
</TooltipWrapper>
65+
)}
66+
</div>
67+
)}
68+
</div>
69+
<div className="ml-2 flex shrink-0 items-center gap-0.5">
70+
{props.isEditing ? (
71+
<>
72+
<button
73+
type="button"
74+
onClick={props.onSaveEdit}
75+
disabled={props.saving}
76+
className="text-accent hover:text-accent-dark p-0.5 transition-colors"
77+
title="Save changes (Enter)"
78+
>
79+
<Check className="h-3.5 w-3.5" />
80+
</button>
81+
<button
82+
type="button"
83+
onClick={props.onCancelEdit}
84+
disabled={props.saving}
85+
className="text-muted hover:text-foreground p-0.5 transition-colors"
86+
title="Cancel (Escape)"
87+
>
88+
<X className="h-3.5 w-3.5" />
89+
</button>
90+
</>
91+
) : (
92+
<>
93+
{/* Favorite/default button */}
94+
<TooltipWrapper inline>
95+
<button
96+
type="button"
97+
onClick={() => {
98+
if (!props.isDefault) props.onSetDefault();
99+
}}
100+
className={cn(
101+
"p-0.5 transition-colors",
102+
props.isDefault
103+
? "cursor-default text-yellow-400"
104+
: "text-muted hover:text-yellow-400"
105+
)}
106+
disabled={props.isDefault}
107+
aria-label={props.isDefault ? "Current default model" : "Set as default model"}
108+
>
109+
<Star className={cn("h-3.5 w-3.5", props.isDefault && "fill-current")} />
110+
</button>
111+
<Tooltip className="tooltip" align="center">
112+
{props.isDefault ? "Default model" : "Set as default"}
113+
</Tooltip>
114+
</TooltipWrapper>
115+
{/* Edit/delete buttons only for custom models */}
116+
{props.isCustom && (
117+
<>
118+
<button
119+
type="button"
120+
onClick={props.onStartEdit}
121+
disabled={Boolean(props.saving) || Boolean(props.hasActiveEdit)}
122+
className="text-muted hover:text-foreground p-0.5 transition-colors disabled:opacity-50"
123+
title="Edit model"
124+
>
125+
<Pencil className="h-3.5 w-3.5" />
126+
</button>
127+
<button
128+
type="button"
129+
onClick={props.onRemove}
130+
disabled={Boolean(props.saving) || Boolean(props.hasActiveEdit)}
131+
className="text-muted hover:text-error p-0.5 transition-colors disabled:opacity-50"
132+
title="Remove model"
133+
>
134+
<Trash2 className="h-3.5 w-3.5" />
135+
</button>
136+
</>
137+
)}
138+
</>
139+
)}
140+
</div>
141+
</div>
142+
);
143+
}

0 commit comments

Comments
 (0)