diff --git a/src/browser/assets/icons/README.md b/src/browser/assets/icons/README.md new file mode 100644 index 000000000..df5977c60 --- /dev/null +++ b/src/browser/assets/icons/README.md @@ -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> = { + // ...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 diff --git a/src/browser/assets/icons/anthropic.svg b/src/browser/assets/icons/anthropic.svg index 4b8ac74d8..2308cd010 100644 --- a/src/browser/assets/icons/anthropic.svg +++ b/src/browser/assets/icons/anthropic.svg @@ -1,9 +1,4 @@ - - - - - - \ No newline at end of file + + + + diff --git a/src/browser/assets/icons/google.svg b/src/browser/assets/icons/google.svg new file mode 100644 index 000000000..2f272fd63 --- /dev/null +++ b/src/browser/assets/icons/google.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/browser/assets/icons/xai.svg b/src/browser/assets/icons/xai.svg new file mode 100644 index 000000000..faffef116 --- /dev/null +++ b/src/browser/assets/icons/xai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/browser/components/Messages/ModelDisplay.tsx b/src/browser/components/Messages/ModelDisplay.tsx index 50f99f548..d75536480 100644 --- a/src/browser/components/Messages/ModelDisplay.tsx +++ b/src/browser/components/Messages/ModelDisplay.tsx @@ -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 { @@ -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 ; - case "openai": - return ; - case "bedrock": - return ; - case "mux-gateway": - return ; - default: - return null; - } -} - /** * Display a model name with its provider icon. * Supports format "provider:model-name" (e.g., "anthropic:claude-sonnet-4-5") @@ -65,8 +46,7 @@ export const ModelDisplay: React.FC = ({ 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)" : ""; @@ -75,16 +55,10 @@ export const ModelDisplay: React.FC = ({ modelString, showToo const content = ( - {muxIcon && ( - - {muxIcon} - - )} - {providerIcon && ( - - {providerIcon} - + {isMuxGateway && ( + )} + {displayName} {suffix} diff --git a/src/browser/components/ProviderIcon.tsx b/src/browser/components/ProviderIcon.tsx new file mode 100644 index 000000000..115b30ab0 --- /dev/null +++ b/src/browser/components/ProviderIcon.tsx @@ -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> = { + 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 ( + + + + ); +} + +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 ( + + + {name} + + ); +} diff --git a/src/browser/components/Settings/Settings.stories.tsx b/src/browser/components/Settings/Settings.stories.tsx index 34bc95b4a..481582ef8 100644 --- a/src/browser/components/Settings/Settings.stories.tsx +++ b/src/browser/components/Settings/Settings.stories.tsx @@ -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(); }); }, diff --git a/src/browser/components/Settings/sections/ModelRow.tsx b/src/browser/components/Settings/sections/ModelRow.tsx new file mode 100644 index 000000000..69d057bec --- /dev/null +++ b/src/browser/components/Settings/sections/ModelRow.tsx @@ -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 ( +
+
+ + {props.isEditing ? ( +
+ 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 &&
{props.editError}
} +
+ ) : ( +
+ + {props.modelId} + + {props.aliases && props.aliases.length > 0 && ( + + + ({props.aliases.join(", ")}) + + + Use with /m {props.aliases[0]} + + + )} +
+ )} +
+
+ {props.isEditing ? ( + <> + + + + ) : ( + <> + {/* Favorite/default button */} + + + + {props.isDefault ? "Default model" : "Set as default"} + + + {/* Edit/delete buttons only for custom models */} + {props.isCustom && ( + <> + + + + )} + + )} +
+
+ ); +} diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx index 3c634e772..8aac16926 100644 --- a/src/browser/components/Settings/sections/ModelsSection.tsx +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -1,7 +1,10 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Plus, Trash2, Pencil, Check, X, Loader2 } from "lucide-react"; +import { Plus, Loader2 } from "lucide-react"; import type { ProvidersConfigMap } from "../types"; import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { useModelLRU } from "@/browser/hooks/useModelLRU"; +import { ModelRow } from "./ModelRow"; interface NewModelForm { provider: string; @@ -20,6 +23,7 @@ export function ModelsSection() { const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(null); const [error, setError] = useState(null); + const { defaultModel, setDefaultModel } = useModelLRU(); // Load config on mount useEffect(() => { @@ -153,164 +157,125 @@ export function ModelsSection() { } // Get all custom models across providers - const getAllModels = (): Array<{ provider: string; modelId: string }> => { - const models: Array<{ provider: string; modelId: string }> = []; + const getCustomModels = (): Array<{ provider: string; modelId: string; fullId: string }> => { + const models: Array<{ provider: string; modelId: string; fullId: string }> = []; for (const [provider, providerConfig] of Object.entries(config)) { if (providerConfig.models) { for (const modelId of providerConfig.models) { - models.push({ provider, modelId }); + models.push({ provider, modelId, fullId: `${provider}:${modelId}` }); } } } return models; }; - const allModels = getAllModels(); + // Get built-in models from KNOWN_MODELS + const builtInModels = Object.values(KNOWN_MODELS).map((model) => ({ + provider: model.provider, + modelId: model.providerModelId, + fullId: model.id, + aliases: model.aliases, + })); + + const customModels = getCustomModels(); return (

- Add custom models to use with your providers. These will appear in the model selector. + Manage your models. Click the star to set a default model for new workspaces.

- {/* Add new model form */} -
-
Add Custom Model
-
- - setNewModel((prev) => ({ ...prev, modelId: e.target.value }))} - placeholder="model-id (e.g., gpt-4-turbo)" - className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none" - onKeyDown={(e) => { - if (e.key === "Enter") void handleAddModel(); - }} - /> - + {/* Custom Models - shown first */} +
+
Custom Models
+ + {/* Add new model form */} +
+
+ + setNewModel((prev) => ({ ...prev, modelId: e.target.value }))} + placeholder="model-id" + className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1 font-mono text-xs focus:outline-none" + onKeyDown={(e) => { + if (e.key === "Enter") void handleAddModel(); + }} + /> + +
+ {error && !editing &&
{error}
}
- {error && !editing &&
{error}
} + + {/* List custom models */} + {customModels.map((model) => { + const isModelEditing = + editing?.provider === model.provider && editing?.originalModelId === model.modelId; + return ( + setDefaultModel(model.fullId)} + onStartEdit={() => handleStartEdit(model.provider, model.modelId)} + onSaveEdit={() => void handleSaveEdit()} + onCancelEdit={handleCancelEdit} + onEditChange={(value) => + setEditing((prev) => (prev ? { ...prev, newModelId: value } : null)) + } + onRemove={() => void handleRemoveModel(model.provider, model.modelId)} + /> + ); + })}
- {/* List of custom models */} - {allModels.length > 0 ? ( -
-
- Custom Models -
- {allModels.map(({ provider, modelId }) => { - const isEditing = - editing?.provider === provider && editing?.originalModelId === modelId; - - return ( -
-
- - {PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ?? - provider} - - {isEditing ? ( -
- - setEditing((prev) => - prev ? { ...prev, newModelId: e.target.value } : null - ) - } - onKeyDown={(e) => { - if (e.key === "Enter") void handleSaveEdit(); - if (e.key === "Escape") handleCancelEdit(); - }} - className="bg-modal-bg border-border-medium focus:border-accent min-w-0 flex-1 rounded border px-2 py-1 font-mono text-xs focus:outline-none" - autoFocus - /> - {error &&
{error}
} -
- ) : ( - - {modelId} - - )} -
-
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- ); - })} -
- ) : ( -
- No custom models configured. Add one above to get started. + {/* Built-in Models */} +
+
+ Built-in Models
- )} + {builtInModels.map((model) => ( + setDefaultModel(model.fullId)} + /> + ))} +
); } diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index bd97027e5..ba9e863c1 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useCallback } from "react"; import { ChevronDown, ChevronRight, Check, X } from "lucide-react"; import type { ProvidersConfigMap } from "../types"; -import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; +import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; import type { ProviderName } from "@/common/constants/providers"; +import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; interface FieldConfig { key: string; @@ -204,9 +205,11 @@ export function ProvidersSection() { ) : ( )} - - {PROVIDER_DISPLAY_NAMES[provider]} - +
{ await ui.settings.open(); await ui.settings.selectSection("Models"); - // Verify add model form elements - use exact match for title - await expect(page.getByText("Add Custom Model", { exact: true })).toBeVisible(); + // Verify add model form elements + await expect(page.getByText("Custom Models")).toBeVisible(); await expect(page.getByRole("combobox")).toBeVisible(); // Provider dropdown await expect(page.getByPlaceholder(/model-id/i)).toBeVisible(); await expect(page.getByRole("button", { name: /^Add$/i })).toBeVisible();