Skip to content

Commit c5ff29f

Browse files
committed
🤖 feat: add cloud icon toggle for Mux Gateway models
- Add whitespace-nowrap to ProviderWithIcon to prevent line breaks - Create useGatewayModels hook for managing gateway preferences - Add cloud icon toggle to ModelRow in settings - Add cloud icon toggle to ModelSelector dropdown - Transform model to gateway format when sending messages Users can now click the cloud icon on any model to route it through Mux Gateway. The preference is stored per-model and the transformation happens transparently when sending messages. _Generated with mux_
1 parent c65df0d commit c5ff29f

File tree

7 files changed

+171
-19
lines changed

7 files changed

+171
-19
lines changed

src/browser/components/ModelSelector.tsx

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import React, {
77
forwardRef,
88
} from "react";
99
import { cn } from "@/common/lib/utils";
10-
import { Settings, Star } from "lucide-react";
10+
import { Cloud, Settings, Star } from "lucide-react";
1111
import { TooltipWrapper, Tooltip } from "./Tooltip";
1212
import { useSettings } from "@/browser/contexts/SettingsContext";
13+
import { useGatewayModels } from "@/browser/hooks/useGatewayModels";
1314

1415
interface ModelSelectorProps {
1516
value: string;
@@ -27,6 +28,7 @@ export interface ModelSelectorRef {
2728
export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
2829
({ value, onChange, recentModels, onComplete, defaultModel, onSetDefaultModel }, ref) => {
2930
const { open: openSettings } = useSettings();
31+
const { isEnabled: isGatewayEnabled, toggle: toggleGateway } = useGatewayModels();
3032
const [isEditing, setIsEditing] = useState(false);
3133
const [inputValue, setInputValue] = useState(value);
3234
const [error, setError] = useState<string | null>(null);
@@ -190,8 +192,17 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
190192
}, [highlightedIndex]);
191193

192194
if (!isEditing) {
195+
const gatewayEnabled = isGatewayEnabled(value);
193196
return (
194197
<div ref={containerRef} className="relative flex items-center gap-1">
198+
{gatewayEnabled && (
199+
<TooltipWrapper inline>
200+
<Cloud className="text-accent h-3 w-3 shrink-0 fill-current" />
201+
<Tooltip className="tooltip" align="center">
202+
Using Mux Gateway
203+
</Tooltip>
204+
</TooltipWrapper>
205+
)}
195206
<div
196207
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"
197208
onClick={handleClick}
@@ -250,36 +261,67 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
250261
)}
251262
onClick={() => handleSelectModel(model)}
252263
>
253-
<div className="grid w-full grid-cols-[1fr_24px] items-center gap-2">
264+
<div className="grid w-full grid-cols-[1fr_auto] items-center gap-2">
254265
<span className="min-w-0 truncate">{model}</span>
255-
{onSetDefaultModel && (
266+
<div className="flex items-center gap-0.5">
267+
{/* Gateway toggle */}
256268
<TooltipWrapper inline>
257269
<button
258270
type="button"
259271
onMouseDown={(e) => e.preventDefault()}
260-
onClick={(e) => handleSetDefault(e, model)}
272+
onClick={(e) => {
273+
e.preventDefault();
274+
e.stopPropagation();
275+
toggleGateway(model);
276+
}}
261277
className={cn(
262278
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
263-
defaultModel === model
264-
? "text-yellow-400 border-yellow-400/40 cursor-default"
279+
isGatewayEnabled(model)
280+
? "text-accent border-accent/40"
265281
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
266282
)}
267283
aria-label={
268-
defaultModel === model
269-
? "Current default model"
270-
: "Set as default model"
284+
isGatewayEnabled(model) ? "Disable Mux Gateway" : "Enable Mux Gateway"
271285
}
272-
disabled={defaultModel === model}
273286
>
274-
<Star className="h-3 w-3" />
287+
<Cloud
288+
className={cn("h-3 w-3", isGatewayEnabled(model) && "fill-current")}
289+
/>
275290
</button>
276291
<Tooltip className="tooltip" align="center">
277-
{defaultModel === model
278-
? "Current default model"
279-
: "Set as default model"}
292+
{isGatewayEnabled(model) ? "Using Mux Gateway" : "Use Mux Gateway"}
280293
</Tooltip>
281294
</TooltipWrapper>
282-
)}
295+
{/* Default model toggle */}
296+
{onSetDefaultModel && (
297+
<TooltipWrapper inline>
298+
<button
299+
type="button"
300+
onMouseDown={(e) => e.preventDefault()}
301+
onClick={(e) => handleSetDefault(e, model)}
302+
className={cn(
303+
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
304+
defaultModel === model
305+
? "text-yellow-400 border-yellow-400/40 cursor-default"
306+
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
307+
)}
308+
aria-label={
309+
defaultModel === model
310+
? "Current default model"
311+
: "Set as default model"
312+
}
313+
disabled={defaultModel === model}
314+
>
315+
<Star className="h-3 w-3" />
316+
</button>
317+
<Tooltip className="tooltip" align="center">
318+
{defaultModel === model
319+
? "Current default model"
320+
: "Set as default model"}
321+
</Tooltip>
322+
</TooltipWrapper>
323+
)}
324+
</div>
283325
</div>
284326
</div>
285327
))

src/browser/components/ProviderIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function ProviderWithIcon(props: ProviderWithIconProps) {
6767
: props.provider;
6868

6969
return (
70-
<span className={cn("inline-flex items-center gap-1", props.className)}>
70+
<span className={cn("inline-flex items-center gap-1 whitespace-nowrap", props.className)}>
7171
<ProviderIcon provider={props.provider} className={props.iconClassName} />
7272
<span>{name}</span>
7373
</span>

src/browser/components/Settings/sections/ModelRow.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { Check, Pencil, Star, Trash2, X } from "lucide-react";
2+
import { Check, Cloud, Pencil, Star, Trash2, X } from "lucide-react";
33
import { cn } from "@/common/lib/utils";
44
import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip";
55
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
@@ -16,12 +16,16 @@ export interface ModelRowProps {
1616
editError?: string | null;
1717
saving?: boolean;
1818
hasActiveEdit?: boolean;
19+
/** Whether gateway mode is enabled for this model */
20+
isGatewayEnabled?: boolean;
1921
onSetDefault: () => void;
2022
onStartEdit?: () => void;
2123
onSaveEdit?: () => void;
2224
onCancelEdit?: () => void;
2325
onEditChange?: (value: string) => void;
2426
onRemove?: () => void;
27+
/** Toggle gateway mode for this model */
28+
onToggleGateway?: () => void;
2529
}
2630

2731
export function ModelRow(props: ModelRowProps) {
@@ -90,6 +94,25 @@ export function ModelRow(props: ModelRowProps) {
9094
</>
9195
) : (
9296
<>
97+
{/* Gateway toggle button */}
98+
{props.onToggleGateway && (
99+
<TooltipWrapper inline>
100+
<button
101+
type="button"
102+
onClick={props.onToggleGateway}
103+
className={cn(
104+
"p-0.5 transition-colors",
105+
props.isGatewayEnabled ? "text-accent" : "text-muted hover:text-accent"
106+
)}
107+
aria-label={props.isGatewayEnabled ? "Disable Mux Gateway" : "Enable Mux Gateway"}
108+
>
109+
<Cloud className={cn("h-3.5 w-3.5", props.isGatewayEnabled && "fill-current")} />
110+
</button>
111+
<Tooltip className="tooltip" align="center">
112+
{props.isGatewayEnabled ? "Using Mux Gateway" : "Use Mux Gateway"}
113+
</Tooltip>
114+
</TooltipWrapper>
115+
)}
93116
{/* Favorite/default button */}
94117
<TooltipWrapper inline>
95118
<button

src/browser/components/Settings/sections/ModelsSection.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Plus, Loader2 } from "lucide-react";
33
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
44
import { KNOWN_MODELS } from "@/common/constants/knownModels";
55
import { useModelLRU } from "@/browser/hooks/useModelLRU";
6+
import { useGatewayModels } from "@/browser/hooks/useGatewayModels";
67
import { ModelRow } from "./ModelRow";
78
import { useAPI } from "@/browser/contexts/API";
89
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
@@ -25,6 +26,7 @@ export function ModelsSection() {
2526
const [editing, setEditing] = useState<EditingState | null>(null);
2627
const [error, setError] = useState<string | null>(null);
2728
const { defaultModel, setDefaultModel } = useModelLRU();
29+
const { isEnabled: isGatewayEnabled, toggle: toggleGateway } = useGatewayModels();
2830

2931
// Check if a model already exists (for duplicate prevention)
3032
const modelExists = useCallback(
@@ -213,6 +215,7 @@ export function ModelsSection() {
213215
editError={isModelEditing ? error : undefined}
214216
saving={false}
215217
hasActiveEdit={editing !== null}
218+
isGatewayEnabled={isGatewayEnabled(model.fullId)}
216219
onSetDefault={() => setDefaultModel(model.fullId)}
217220
onStartEdit={() => handleStartEdit(model.provider, model.modelId)}
218221
onSaveEdit={handleSaveEdit}
@@ -221,6 +224,7 @@ export function ModelsSection() {
221224
setEditing((prev) => (prev ? { ...prev, newModelId: value } : null))
222225
}
223226
onRemove={() => handleRemoveModel(model.provider, model.modelId)}
227+
onToggleGateway={() => toggleGateway(model.fullId)}
224228
/>
225229
);
226230
})}
@@ -241,7 +245,9 @@ export function ModelsSection() {
241245
isCustom={false}
242246
isDefault={defaultModel === model.fullId}
243247
isEditing={false}
248+
isGatewayEnabled={isGatewayEnabled(model.fullId)}
244249
onSetDefault={() => setDefaultModel(model.fullId)}
250+
onToggleGateway={() => toggleGateway(model.fullId)}
245251
/>
246252
))}
247253
</div>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useCallback } from "react";
2+
import { usePersistedState, readPersistedState, updatePersistedState } from "./usePersistedState";
3+
4+
const GATEWAY_MODELS_KEY = "gateway-models";
5+
6+
/**
7+
* Check if a model is gateway-enabled (static read, no reactivity)
8+
*/
9+
export function isGatewayEnabled(modelId: string): boolean {
10+
const gatewayModels = readPersistedState<string[]>(GATEWAY_MODELS_KEY, []);
11+
return gatewayModels.includes(modelId);
12+
}
13+
14+
/**
15+
* Transform a model ID to gateway format if gateway is enabled for it.
16+
* Example: "anthropic:claude-opus-4-5" → "mux-gateway:anthropic/claude-opus-4-5"
17+
*/
18+
export function toGatewayModel(modelId: string): string {
19+
if (!isGatewayEnabled(modelId)) {
20+
return modelId;
21+
}
22+
// Transform provider:model to mux-gateway:provider/model
23+
const colonIndex = modelId.indexOf(":");
24+
if (colonIndex === -1) {
25+
return modelId;
26+
}
27+
const provider = modelId.slice(0, colonIndex);
28+
const model = modelId.slice(colonIndex + 1);
29+
return `mux-gateway:${provider}/${model}`;
30+
}
31+
32+
/**
33+
* Toggle gateway mode for a model (static update, no reactivity)
34+
*/
35+
export function toggleGatewayModel(modelId: string): void {
36+
const gatewayModels = readPersistedState<string[]>(GATEWAY_MODELS_KEY, []);
37+
if (gatewayModels.includes(modelId)) {
38+
updatePersistedState(
39+
GATEWAY_MODELS_KEY,
40+
gatewayModels.filter((m) => m !== modelId)
41+
);
42+
} else {
43+
updatePersistedState(GATEWAY_MODELS_KEY, [...gatewayModels, modelId]);
44+
}
45+
}
46+
47+
/**
48+
* Hook to manage which models use the Mux Gateway.
49+
* Returns reactive state and toggle function.
50+
*/
51+
export function useGatewayModels() {
52+
const [gatewayModels, setGatewayModels] = usePersistedState<string[]>(GATEWAY_MODELS_KEY, [], {
53+
listener: true,
54+
});
55+
56+
const isEnabled = useCallback(
57+
(modelId: string) => gatewayModels.includes(modelId),
58+
[gatewayModels]
59+
);
60+
61+
const toggle = useCallback(
62+
(modelId: string) => {
63+
setGatewayModels((prev) => {
64+
if (prev.includes(modelId)) {
65+
return prev.filter((m) => m !== modelId);
66+
}
67+
return [...prev, modelId];
68+
});
69+
},
70+
[setGatewayModels]
71+
);
72+
73+
return { gatewayModels, isEnabled, toggle };
74+
}

src/browser/hooks/useSendMessageOptions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useThinkingLevel } from "./useThinkingLevel";
22
import { useMode } from "@/browser/contexts/ModeContext";
33
import { usePersistedState } from "./usePersistedState";
44
import { getDefaultModel } from "./useModelLRU";
5+
import { toGatewayModel } from "./useGatewayModels";
56
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils";
67
import { getModelKey } from "@/common/constants/storage";
78
import type { SendMessageOptions } from "@/common/orpc/types";
@@ -26,9 +27,12 @@ function constructSendMessageOptions(
2627
const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined;
2728

2829
// Ensure model is always a valid string (defensive against corrupted localStorage)
29-
const model =
30+
const baseModel =
3031
typeof preferredModel === "string" && preferredModel ? preferredModel : fallbackModel;
3132

33+
// Transform to gateway format if gateway is enabled for this model
34+
const model = toGatewayModel(baseModel);
35+
3236
// Enforce thinking policy at the UI boundary as well (e.g., gpt-5-pro → high only)
3337
const uiThinking = enforceThinkingPolicy(model, thinkingLevel);
3438

src/browser/utils/messages/sendOptions.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants
22
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils";
33
import { readPersistedState } from "@/browser/hooks/usePersistedState";
44
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
5+
import { toGatewayModel } from "@/browser/hooks/useGatewayModels";
56
import type { SendMessageOptions } from "@/common/orpc/types";
67
import type { UIMode } from "@/common/types/mode";
78
import type { ThinkingLevel } from "@/common/types/thinking";
@@ -38,7 +39,9 @@ function getProviderOptions(): MuxProviderOptions {
3839
*/
3940
export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptions {
4041
// Read model preference (workspace-specific), fallback to LRU default
41-
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModel());
42+
const baseModel = readPersistedState<string>(getModelKey(workspaceId), getDefaultModel());
43+
// Transform to gateway format if gateway is enabled for this model
44+
const model = toGatewayModel(baseModel);
4245

4346
// Read thinking level (workspace-specific)
4447
const thinkingLevel = readPersistedState<ThinkingLevel>(

0 commit comments

Comments
 (0)