Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/browser/assets/icons/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Provider Icons

This directory contains SVG icons for AI providers displayed in the UI.

## Current icons

| File | Provider | Source |
|------|----------|--------|
| `anthropic.svg` | Anthropic | [Brand assets](https://www.anthropic.com/brand) |
| `openai.svg` | OpenAI | [Brand guidelines](https://openai.com/brand) |
| `google.svg` | Google (Gemini) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Google_Gemini_icon_2025.svg) |
| `xai.svg` | xAI | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:XAI_Logo.svg) |
| `aws.svg` | Amazon Bedrock | [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/) |
| `mux.svg` | Mux Gateway | Internal |

## Adding a new icon

1. **Get the official SVG** from the provider's brand/press kit
2. **Optimize the SVG** - remove unnecessary metadata, comments, and attributes
3. **Ensure single color** - icons should use `fill="currentColor"` or a class like `.st0` that can be styled via CSS
4. **Name the file** `{provider}.svg` matching the provider key in `src/common/constants/providers.ts`
5. **Register in ProviderIcon** - add to `PROVIDER_ICONS` map in `src/browser/components/ProviderIcon.tsx`:

```tsx
import NewProviderIcon from "@/browser/assets/icons/newprovider.svg?react";

const PROVIDER_ICONS: Partial<Record<ProviderName | "mux-gateway", React.FC>> = {
// ...existing icons
newprovider: NewProviderIcon,
};
```

## SVG requirements

- Monochrome (single fill color)
- Use classes (`.st0`) or `currentColor` for fills so the icon inherits text color
- Reasonable viewBox (icons are rendered at 1em × 1em)
- No embedded raster images
13 changes: 4 additions & 9 deletions src/browser/assets/icons/anthropic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/browser/assets/icons/google.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/browser/assets/icons/xai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 5 additions & 31 deletions src/browser/components/Messages/ModelDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import React from "react";
import AnthropicIcon from "@/browser/assets/icons/anthropic.svg?react";
import OpenAIIcon from "@/browser/assets/icons/openai.svg?react";
import AWSIcon from "@/browser/assets/icons/aws.svg?react";
import MuxIcon from "@/browser/assets/icons/mux.svg?react";
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
import { ProviderIcon } from "@/browser/components/ProviderIcon";
import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay";

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

/** Get icon component for a provider name */
function getProviderIcon(provider: string): React.ReactNode {
switch (provider) {
case "anthropic":
return <AnthropicIcon />;
case "openai":
return <OpenAIIcon />;
case "bedrock":
return <AWSIcon />;
case "mux-gateway":
return <MuxIcon />;
default:
return null;
}
}

/**
* Display a model name with its provider icon.
* Supports format "provider:model-name" (e.g., "anthropic:claude-sonnet-4-5")
Expand All @@ -65,8 +46,7 @@ export const ModelDisplay: React.FC<ModelDisplayProps> = ({ modelString, showToo
const { provider, modelName, isMuxGateway, innerProvider } = parseModelString(modelString);

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

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

const content = (
<span className="inline normal-case" data-model-display>
{muxIcon && (
<span className={iconClass} data-model-icon="mux">
{muxIcon}
</span>
)}
{providerIcon && (
<span className={iconClass} data-model-icon>
{providerIcon}
</span>
{isMuxGateway && (
<ProviderIcon provider="mux-gateway" className={iconClass} data-model-icon="mux" />
)}
<ProviderIcon provider={iconProvider} className={iconClass} data-model-icon />
<span className="inline">
{displayName}
{suffix}
Expand Down
68 changes: 68 additions & 0 deletions src/browser/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from "react";
import AnthropicIcon from "@/browser/assets/icons/anthropic.svg?react";
import OpenAIIcon from "@/browser/assets/icons/openai.svg?react";
import GoogleIcon from "@/browser/assets/icons/google.svg?react";
import XAIIcon from "@/browser/assets/icons/xai.svg?react";
import AWSIcon from "@/browser/assets/icons/aws.svg?react";
import MuxIcon from "@/browser/assets/icons/mux.svg?react";
import { PROVIDER_DISPLAY_NAMES, type ProviderName } from "@/common/constants/providers";
import { cn } from "@/common/lib/utils";

const PROVIDER_ICONS: Partial<Record<ProviderName | "mux-gateway", React.FC>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
google: GoogleIcon,
xai: XAIIcon,
bedrock: AWSIcon,
"mux-gateway": MuxIcon,
};

export interface ProviderIconProps {
provider: string;
className?: string;
}

/**
* Renders a provider's icon if one exists, otherwise returns null.
* Icons are sized to 1em by default to match surrounding text.
*/
export function ProviderIcon(props: ProviderIconProps) {
const IconComponent = PROVIDER_ICONS[props.provider as keyof typeof PROVIDER_ICONS];
if (!IconComponent) return null;

return (
<span
className={cn(
"inline-block h-[1em] w-[1em] align-[-0.125em] [&_svg]:block [&_svg]:h-full [&_svg]:w-full [&_svg]:fill-current [&_svg_.st0]:fill-current",
props.className
)}
>
<IconComponent />
</span>
);
}

export interface ProviderWithIconProps {
provider: string;
className?: string;
iconClassName?: string;
/** Show display name instead of raw provider key */
displayName?: boolean;
}

/**
* Renders a provider name with its icon (if available).
* Falls back to just the name if no icon exists for the provider.
*/
export function ProviderWithIcon(props: ProviderWithIconProps) {
const name = props.displayName
? (PROVIDER_DISPLAY_NAMES[props.provider as ProviderName] ?? props.provider)
: props.provider;

return (
<span className={cn("inline-flex items-center gap-1", props.className)}>
<ProviderIcon provider={props.provider} className={props.iconClassName} />
<span>{name}</span>
</span>
);
}
2 changes: 1 addition & 1 deletion src/browser/components/Settings/Settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const SidebarNavigation: Story = {

// Content should update to show Models section text
await waitFor(async () => {
const modelsText = canvas.getByText(/Add custom models/i);
const modelsText = canvas.getByText(/Manage your models/i);
await expect(modelsText).toBeVisible();
});
},
Expand Down
143 changes: 143 additions & 0 deletions src/browser/components/Settings/sections/ModelRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React from "react";
import { Check, Pencil, Star, Trash2, X } from "lucide-react";
import { cn } from "@/common/lib/utils";
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";

export interface ModelRowProps {
provider: string;
modelId: string;
fullId: string;
aliases?: string[];
isCustom: boolean;
isDefault: boolean;
isEditing: boolean;
editValue?: string;
editError?: string | null;
saving?: boolean;
hasActiveEdit?: boolean;
onSetDefault: () => void;
onStartEdit?: () => void;
onSaveEdit?: () => void;
onCancelEdit?: () => void;
onEditChange?: (value: string) => void;
onRemove?: () => void;
}

export function ModelRow(props: ModelRowProps) {
return (
<div className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-3 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ProviderWithIcon
provider={props.provider}
displayName
className="text-muted w-20 shrink-0 text-xs"
/>
{props.isEditing ? (
<div className="flex min-w-0 flex-1 items-center gap-1">
<input
type="text"
value={props.editValue ?? props.modelId}
onChange={(e) => props.onEditChange?.(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSaveEdit?.();
if (e.key === "Escape") props.onCancelEdit?.();
}}
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"
autoFocus
/>
{props.editError && <div className="text-error text-xs">{props.editError}</div>}
</div>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-foreground min-w-0 truncate font-mono text-xs">
{props.modelId}
</span>
{props.aliases && props.aliases.length > 0 && (
<TooltipWrapper inline>
<span className="text-muted-light shrink-0 text-xs">
({props.aliases.join(", ")})
</span>
<Tooltip className="tooltip" align="center">
Use with /m {props.aliases[0]}
</Tooltip>
</TooltipWrapper>
)}
</div>
)}
</div>
<div className="ml-2 flex shrink-0 items-center gap-0.5">
{props.isEditing ? (
<>
<button
type="button"
onClick={props.onSaveEdit}
disabled={props.saving}
className="text-accent hover:text-accent-dark p-0.5 transition-colors"
title="Save changes (Enter)"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={props.onCancelEdit}
disabled={props.saving}
className="text-muted hover:text-foreground p-0.5 transition-colors"
title="Cancel (Escape)"
>
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
{/* Favorite/default button */}
<TooltipWrapper inline>
<button
type="button"
onClick={() => {
if (!props.isDefault) props.onSetDefault();
}}
className={cn(
"p-0.5 transition-colors",
props.isDefault
? "cursor-default text-yellow-400"
: "text-muted hover:text-yellow-400"
)}
disabled={props.isDefault}
aria-label={props.isDefault ? "Current default model" : "Set as default model"}
>
<Star className={cn("h-3.5 w-3.5", props.isDefault && "fill-current")} />
</button>
<Tooltip className="tooltip" align="center">
{props.isDefault ? "Default model" : "Set as default"}
</Tooltip>
</TooltipWrapper>
{/* Edit/delete buttons only for custom models */}
{props.isCustom && (
<>
<button
type="button"
onClick={props.onStartEdit}
disabled={Boolean(props.saving) || Boolean(props.hasActiveEdit)}
className="text-muted hover:text-foreground p-0.5 transition-colors disabled:opacity-50"
title="Edit model"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={props.onRemove}
disabled={Boolean(props.saving) || Boolean(props.hasActiveEdit)}
className="text-muted hover:text-error p-0.5 transition-colors disabled:opacity-50"
title="Remove model"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</>
)}
</div>
</div>
);
}
Loading