Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
7 changes: 5 additions & 2 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const sendMessageOptions = useSendMessageOptions(
variant === "workspace" ? props.workspaceId : getProjectScopeId(props.projectPath)
);
// Extract model for convenience (don't create separate state - use hook as single source of truth)
// Extract models for convenience (don't create separate state - use hook as single source of truth)
// - preferredModel: gateway-transformed model for API calls
// - baseModel: canonical format for UI display and policy checks (e.g., ThinkingSlider)
const preferredModel = sendMessageOptions.model;
const baseModel = sendMessageOptions.baseModel;
const deferredModel = useDeferredValue(preferredModel);
const deferredInput = useDeferredValue(input);
const tokenCountPromise = useMemo(() => {
Expand Down Expand Up @@ -1349,7 +1352,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
className="flex items-center [&_.thinking-slider]:[@container(max-width:550px)]:hidden"
data-component="ThinkingSliderGroup"
>
<ThinkingSliderComponent modelString={preferredModel} />
<ThinkingSliderComponent modelString={baseModel} />
</div>

<div className="ml-4 flex items-center" data-component="ModelSettingsGroup">
Expand Down
106 changes: 78 additions & 28 deletions src/browser/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import React, {
} from "react";
import { cn } from "@/common/lib/utils";
import { Settings, Star } from "lucide-react";
import { GatewayIcon } from "./icons/GatewayIcon";
import { TooltipWrapper, Tooltip } from "./Tooltip";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { useGateway } from "@/browser/hooks/useGatewayModels";

interface ModelSelectorProps {
value: string;
Expand All @@ -27,6 +29,7 @@ export interface ModelSelectorRef {
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
({ value, onChange, recentModels, onComplete, defaultModel, onSetDefaultModel }, ref) => {
const { open: openSettings } = useSettings();
const gateway = useGateway();
const [isEditing, setIsEditing] = useState(false);
const [inputValue, setInputValue] = useState(value);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -190,8 +193,17 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
}, [highlightedIndex]);

if (!isEditing) {
const gatewayActive = gateway.isModelRoutingThroughGateway(value);
return (
<div ref={containerRef} className="relative flex items-center gap-1">
{gatewayActive && (
<TooltipWrapper inline>
<GatewayIcon className="text-accent h-3 w-3 shrink-0" active />
<Tooltip className="tooltip" align="center">
Using Mux Gateway
</Tooltip>
</TooltipWrapper>
)}
<div
className="text-muted-light font-monospace dir-rtl hover:bg-hover max-w-36 cursor-pointer truncate rounded-sm px-1 py-0.5 text-left font-mono text-[10px] leading-[11px] transition-colors duration-200"
onClick={handleClick}
Expand Down Expand Up @@ -250,36 +262,74 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
)}
onClick={() => handleSelectModel(model)}
>
<div className="grid w-full grid-cols-[1fr_24px] items-center gap-2">
<div className="grid w-full grid-cols-[1fr_auto] items-center gap-2">
<span className="min-w-0 truncate">{model}</span>
{onSetDefaultModel && (
<TooltipWrapper inline>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => handleSetDefault(e, model)}
className={cn(
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
defaultModel === model
? "text-yellow-400 border-yellow-400/40 cursor-default"
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
)}
aria-label={
defaultModel === model
<div className="flex items-center gap-0.5">
{/* Gateway toggle */}
{gateway.canToggleModel(model) && (
<TooltipWrapper inline>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
gateway.toggleModelGateway(model);
}}
className={cn(
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
gateway.modelUsesGateway(model)
? "text-accent border-accent/40"
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
)}
aria-label={
gateway.modelUsesGateway(model)
? "Disable Mux Gateway"
: "Enable Mux Gateway"
}
>
<GatewayIcon
className="h-3 w-3"
active={gateway.modelUsesGateway(model)}
/>
</button>
<Tooltip className="tooltip" align="center">
{gateway.modelUsesGateway(model)
? "Using Mux Gateway"
: "Use Mux Gateway"}
</Tooltip>
</TooltipWrapper>
)}
{/* Default model toggle */}
{onSetDefaultModel && (
<TooltipWrapper inline>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => handleSetDefault(e, model)}
className={cn(
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
defaultModel === model
? "text-yellow-400 border-yellow-400/40 cursor-default"
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
)}
aria-label={
defaultModel === model
? "Current default model"
: "Set as default model"
}
disabled={defaultModel === model}
>
<Star className="h-3 w-3" />
</button>
<Tooltip className="tooltip" align="center">
{defaultModel === model
? "Current default model"
: "Set as default model"
}
disabled={defaultModel === model}
>
<Star className="h-3 w-3" />
</button>
<Tooltip className="tooltip" align="center">
{defaultModel === model
? "Current default model"
: "Set as default model"}
</Tooltip>
</TooltipWrapper>
)}
: "Set as default model"}
</Tooltip>
</TooltipWrapper>
)}
</div>
</div>
</div>
))
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function ProviderWithIcon(props: ProviderWithIconProps) {
: props.provider;

return (
<span className={cn("inline-flex items-center gap-1", props.className)}>
<span className={cn("inline-flex items-center gap-1 whitespace-nowrap", props.className)}>
<ProviderIcon provider={props.provider} className={props.iconClassName} />
<span>{name}</span>
</span>
Expand Down
24 changes: 24 additions & 0 deletions src/browser/components/Settings/sections/ModelRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { Check, Pencil, Star, Trash2, X } from "lucide-react";
import { GatewayIcon } from "@/browser/components/icons/GatewayIcon";
import { cn } from "@/common/lib/utils";
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
Expand All @@ -16,12 +17,16 @@ export interface ModelRowProps {
editError?: string | null;
saving?: boolean;
hasActiveEdit?: boolean;
/** Whether gateway mode is enabled for this model */
isGatewayEnabled?: boolean;
onSetDefault: () => void;
onStartEdit?: () => void;
onSaveEdit?: () => void;
onCancelEdit?: () => void;
onEditChange?: (value: string) => void;
onRemove?: () => void;
/** Toggle gateway mode for this model */
onToggleGateway?: () => void;
}

export function ModelRow(props: ModelRowProps) {
Expand Down Expand Up @@ -90,6 +95,25 @@ export function ModelRow(props: ModelRowProps) {
</>
) : (
<>
{/* Gateway toggle button */}
{props.onToggleGateway && (
<TooltipWrapper inline>
<button
type="button"
onClick={props.onToggleGateway}
className={cn(
"p-0.5 transition-colors",
props.isGatewayEnabled ? "text-accent" : "text-muted hover:text-accent"
)}
aria-label={props.isGatewayEnabled ? "Disable Mux Gateway" : "Enable Mux Gateway"}
>
<GatewayIcon className="h-3.5 w-3.5" active={props.isGatewayEnabled} />
</button>
<Tooltip className="tooltip" align="center">
{props.isGatewayEnabled ? "Using Mux Gateway" : "Use Mux Gateway"}
</Tooltip>
</TooltipWrapper>
)}
{/* Favorite/default button */}
<TooltipWrapper inline>
<button
Expand Down
21 changes: 20 additions & 1 deletion src/browser/components/Settings/sections/ModelsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { Plus, Loader2 } from "lucide-react";
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
import { KNOWN_MODELS } from "@/common/constants/knownModels";
import { useModelLRU } from "@/browser/hooks/useModelLRU";
import { useGateway } from "@/browser/hooks/useGatewayModels";
import { ModelRow } from "./ModelRow";
import { useAPI } from "@/browser/contexts/API";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";

// Providers to exclude from the custom models UI (handled specially or internal)
const HIDDEN_PROVIDERS = new Set(["mux-gateway"]);

interface NewModelForm {
provider: string;
modelId: string;
Expand All @@ -25,6 +29,7 @@ export function ModelsSection() {
const [editing, setEditing] = useState<EditingState | null>(null);
const [error, setError] = useState<string | null>(null);
const { defaultModel, setDefaultModel } = useModelLRU();
const gateway = useGateway();

// Check if a model already exists (for duplicate prevention)
const modelExists = useCallback(
Expand Down Expand Up @@ -125,10 +130,12 @@ export function ModelsSection() {
);
}

// Get all custom models across providers
// Get all custom models across providers (excluding hidden providers like mux-gateway)
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)) {
// Skip hidden providers (mux-gateway models are accessed via the cloud toggle, not listed separately)
if (HIDDEN_PROVIDERS.has(provider)) continue;
if (providerConfig.models) {
for (const modelId of providerConfig.models) {
models.push({ provider, modelId, fullId: `${provider}:${modelId}` });
Expand Down Expand Up @@ -213,6 +220,7 @@ export function ModelsSection() {
editError={isModelEditing ? error : undefined}
saving={false}
hasActiveEdit={editing !== null}
isGatewayEnabled={gateway.modelUsesGateway(model.fullId)}
onSetDefault={() => setDefaultModel(model.fullId)}
onStartEdit={() => handleStartEdit(model.provider, model.modelId)}
onSaveEdit={handleSaveEdit}
Expand All @@ -221,6 +229,11 @@ export function ModelsSection() {
setEditing((prev) => (prev ? { ...prev, newModelId: value } : null))
}
onRemove={() => handleRemoveModel(model.provider, model.modelId)}
onToggleGateway={
gateway.canToggleModel(model.fullId)
? () => gateway.toggleModelGateway(model.fullId)
: undefined
}
/>
);
})}
Expand All @@ -241,7 +254,13 @@ export function ModelsSection() {
isCustom={false}
isDefault={defaultModel === model.fullId}
isEditing={false}
isGatewayEnabled={gateway.modelUsesGateway(model.fullId)}
onSetDefault={() => setDefaultModel(model.fullId)}
onToggleGateway={
gateway.canToggleModel(model.fullId)
? () => gateway.toggleModelGateway(model.fullId)
: undefined
}
/>
))}
</div>
Expand Down
27 changes: 27 additions & 0 deletions src/browser/components/Settings/sections/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ProviderName } from "@/common/constants/providers";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
import { useAPI } from "@/browser/contexts/API";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import { useGateway } from "@/browser/hooks/useGatewayModels";

interface FieldConfig {
key: string;
Expand Down Expand Up @@ -69,6 +70,7 @@ function getProviderFields(provider: ProviderName): FieldConfig[] {
export function ProvidersSection() {
const { api } = useAPI();
const { config, updateOptimistically } = useProvidersConfig();
const gateway = useGateway();
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [editingField, setEditingField] = useState<{
provider: string;
Expand Down Expand Up @@ -315,6 +317,31 @@ export function ProvidersSection() {
</div>
);
})}

{/* Gateway enabled toggle - only for mux-gateway when configured */}
{provider === "mux-gateway" && gateway.isConfigured && (
<div className="border-border-light flex items-center justify-between border-t pt-3">
<div>
<label className="text-foreground block text-xs font-medium">Enabled</label>
<span className="text-muted text-xs">Route requests through Mux Gateway</span>
</div>
<button
type="button"
onClick={gateway.toggleEnabled}
className={`relative h-5 w-9 rounded-full transition-colors ${
gateway.isEnabled ? "bg-accent" : "bg-border-medium"
}`}
role="switch"
aria-checked={gateway.isEnabled}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
gateway.isEnabled ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
</div>
)}
</div>
)}
</div>
Expand Down
35 changes: 35 additions & 0 deletions src/browser/components/icons/GatewayIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from "react";

interface GatewayIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
/** When true, shows the active/enabled state with double ring */
active?: boolean;
}

/**
* Gateway icon - represents routing through Mux Gateway.
* Circle with M logo. Active state adds outer ring.
*/
export function GatewayIcon(props: GatewayIconProps) {
const { active, ...svgProps } = props;

return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...svgProps}
>
{/* Outer glow ring when active */}
{active && <circle cx="12" cy="12" r="11" strokeWidth="1" opacity="0.5" />}
{/* Main circle */}
<circle cx="12" cy="12" r="8" />
{/* M letter */}
<path d="M8 16V8l4 5 4-5v8" />
</svg>
);
}
Loading