From 807e43ca1e56eefbe919ccdf26a6503010a285a4 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:33:37 +0000 Subject: [PATCH] feat(agent): reuse chat Quick/Powerful model selector Agent Mode's composer now uses the same suggested Quick/Powerful selector as chat, backed by OpenSecret auto:quick and auto:powerful. The previous GLM 5.2 Agent-only default migrates to Quick; other explicit model choices are left unchanged. --- frontend/src-tauri/src/agent.rs | 14 +- frontend/src/components/AgentMode.tsx | 390 ++-------------------- frontend/src/components/ModelSelector.tsx | 35 +- frontend/src/services/agentModels.test.ts | 41 ++- frontend/src/services/agentModels.ts | 23 +- 5 files changed, 107 insertions(+), 396 deletions(-) diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index bda5c55f6..d08b984cb 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -69,8 +69,8 @@ use web_permission::{ }; use web_tools::WebToolState; -const DEFAULT_AGENT_MODEL: &str = "glm-5-2"; -const LEGACY_AGENT_DEFAULT_MODEL: &str = "auto:powerful"; +const DEFAULT_AGENT_MODEL: &str = "auto:quick"; +const PREVIOUS_RECOMMENDED_AGENT_MODEL: &str = "glm-5-2"; const DEFAULT_GOOSE_MODE: &str = "smart_approve"; // Keep Goose on its ActionRequired path so Maple can apply the currently selected // policy at every tool boundary, including when the user changes it mid-run. @@ -8807,7 +8807,7 @@ fn load_agent_config_file(path: &Path) -> Result { fn migrate_agent_config(config: &mut AgentConfig) -> bool { let mut changed = false; - if config.default_model == LEGACY_AGENT_DEFAULT_MODEL { + if config.default_model == PREVIOUS_RECOMMENDED_AGENT_MODEL { config.default_model = default_agent_model(); changed = true; } @@ -11627,7 +11627,7 @@ mod tests { } #[test] - fn fresh_agent_config_defaults_to_glm() { + fn fresh_agent_config_defaults_to_quick() { assert_eq!(AgentConfig::default().default_model, DEFAULT_AGENT_MODEL); assert!(AgentConfig::default().mcp_servers.is_empty()); @@ -13373,10 +13373,10 @@ mod tests { } #[test] - fn legacy_powerful_agent_default_migrates_to_glm() { + fn previous_glm_agent_default_migrates_to_quick() { let mut config = AgentConfig { default_project_root: Some("/tmp/project".to_string()), - default_model: LEGACY_AGENT_DEFAULT_MODEL.to_string(), + default_model: PREVIOUS_RECOMMENDED_AGENT_MODEL.to_string(), mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), @@ -13389,7 +13389,7 @@ mod tests { #[test] fn explicit_agent_model_choices_are_not_migrated() { - for model in ["kimi-k2-6", "auto:quick", "glm-5-2", "gemma-3-27b"] { + for model in ["kimi-k2-6", "auto:quick", "auto:powerful", "gemma-3-27b"] { let mut config = AgentConfig { default_project_root: None, default_model: model.to_string(), diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 37fddd6cc..6bbf32e99 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -13,11 +13,7 @@ import { useOpenAI } from "@/ai/useOpenAi"; import { AlertCircle, ArrowUp, - Brain, - Camera, Check, - ChevronDown, - ChevronLeft, Circle, Expand, FilePenLine, @@ -33,8 +29,7 @@ import { ShieldCheck, Shrink, Trash, - X, - Zap + X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -86,6 +81,7 @@ import { RenameAgentProjectDialog } from "@/components/RenameAgentProjectDialog" import { AgentProjectSettingsDialog } from "@/components/agent/AgentProjectSettingsDialog"; import { RenameAgentTaskDialog } from "@/components/RenameAgentTaskDialog"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; +import { ModelSelector } from "@/components/ModelSelector"; import { ToolActivityCard } from "@/components/ToolActivityCard"; import { AgentMcpMenu, AgentMcpServersDialog } from "@/components/agent/AgentMcpControls"; import { AgentSidebarInfoCard } from "@/components/agent/AgentSidebarInfoCard"; @@ -181,7 +177,7 @@ import { } from "@/services/agentTimeline"; import { DEFAULT_AGENT_MODEL, - PRIMARY_AGENT_MODEL_IDS, + migrateAgentModelPreference, reconcileAgentModel, resolveAgentModelForSession, resolveAgentModelContextLimit, @@ -274,7 +270,10 @@ type AgentModelPreference = { function readAgentModelPreference(): string | null { try { - return localStorage.getItem(AGENT_MODEL_PREFERENCE_KEY)?.trim() || null; + const stored = localStorage.getItem(AGENT_MODEL_PREFERENCE_KEY)?.trim() || null; + const migrated = migrateAgentModelPreference(stored); + if (migrated !== stored) persistAgentModelPreference(migrated); + return migrated; } catch { return null; } @@ -345,38 +344,22 @@ const AGENT_PERMISSION_MODES: Array<{ } ]; -const QUICK_AGENT_MODEL = { - id: QUICK_MODEL_ALIAS, - label: "Quick", - icon: Zap, - description: "Fast, everyday responses", - access: "free" as ModelAccessTier, - capabilities: { vision: false, reasoning: true } -} as const; - -const LEGACY_POWERFUL_AGENT_ALIAS = { - id: POWERFUL_MODEL_ALIAS, - label: "Powerful", - icon: Brain, - description: "Deeper thinking & analysis", - access: "pro" as ModelAccessTier, - capabilities: { vision: true, reasoning: true } -} as const; - -const PRIMARY_AGENT_MODELS = PRIMARY_AGENT_MODEL_IDS.map((id) => - id === DEFAULT_AGENT_MODEL - ? { - id: DEFAULT_AGENT_MODEL, - label: "GLM 5.2", - icon: Brain, - description: "Recommended for Agent Mode", - access: "pro" as ModelAccessTier, - capabilities: { vision: false, reasoning: true } - } - : QUICK_AGENT_MODEL -); - -const FALLBACK_AGENT_MODEL_ALIASES = [QUICK_AGENT_MODEL, LEGACY_POWERFUL_AGENT_ALIAS] as const; +const FALLBACK_AGENT_MODEL_ALIASES = [ + { + id: QUICK_MODEL_ALIAS, + label: "Quick", + description: "Fast, everyday responses", + access: "free" as ModelAccessTier, + capabilities: { vision: false, reasoning: true } + }, + { + id: POWERFUL_MODEL_ALIAS, + label: "Powerful", + description: "Deeper thinking & analysis", + access: "pro" as ModelAccessTier, + capabilities: { vision: true, reasoning: true } + } +] as const; const FALLBACK_ALIAS_TARGETS = { [QUICK_MODEL_ALIAS]: "gpt-oss-120b", @@ -5165,7 +5148,7 @@ function AgentComposer({
- void; -}) { - const { availableModels, modelAliases } = useModelState(); - const { billingStatus } = useBillingState(); - const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); - const [selectedModelName, setSelectedModelName] = useState(""); - const [showAdvanced, setShowAdvanced] = useState(false); - - const modelById = useMemo(() => { - return new Map(availableModels.map((availableModel) => [availableModel.id, availableModel])); - }, [availableModels]); - - const aliasById = useMemo(() => { - return new Map(modelAliases.map((alias) => [alias.id, alias])); - }, [modelAliases]); - - const getAlias = useCallback( - (modelId: string): OpenSecretModelAlias | undefined => { - const alias = aliasById.get(modelId as OpenSecretModelAlias["id"]); - if (alias) return alias; - - const fallback = FALLBACK_AGENT_MODEL_ALIASES.find( - (primaryModel) => primaryModel.id === modelId - ); - if (!fallback) return undefined; - - return { - id: fallback.id, - label: fallback.label, - short_name: fallback.label, - description: fallback.description, - target_model: "", - access: fallback.access, - capabilities: fallback.capabilities - }; - }, - [aliasById] - ); - - const getTargetModel = useCallback( - (alias: OpenSecretModelAlias | undefined) => { - if (!alias?.target_model) return undefined; - return modelById.get(alias.target_model); - }, - [modelById] - ); - - const getAccess = useCallback( - (modelId: string): ModelAccessTier => { - const alias = getAlias(modelId); - if (alias) { - return getTargetModel(alias)?.access || alias.access || "free"; - } - const primaryModel = PRIMARY_AGENT_MODELS.find((candidate) => candidate.id === modelId); - return modelById.get(modelId)?.access || primaryModel?.access || "free"; - }, - [getAlias, getTargetModel, modelById] - ); - - const hasAccessToModel = useCallback( - (modelId: string) => { - const access = getAccess(modelId); - if (access === "free") return true; - - const planName = billingStatus?.product_name?.toLowerCase() || ""; - return planName.includes("pro") || planName.includes("max") || planName.includes("team"); - }, - [billingStatus?.product_name, getAccess] - ); - - const getDisplayLabel = (modelId: string): string => { - const alias = getAlias(modelId); - if (alias) return alias.short_name || alias.label; - - const selectedModel = modelById.get(modelId); - const primaryModel = PRIMARY_AGENT_MODELS.find((candidate) => candidate.id === modelId); - return ( - selectedModel?.short_name || selectedModel?.display_name || primaryModel?.label || modelId - ); - }; - - const getDisplayNameText = (modelId: string): string => { - const alias = getAlias(modelId); - if (alias) return alias.label; - - const selectedModel = modelById.get(modelId); - const primaryModel = PRIMARY_AGENT_MODELS.find((candidate) => candidate.id === modelId); - return ( - selectedModel?.display_name || selectedModel?.short_name || primaryModel?.label || modelId - ); - }; - - const handlePrimarySelect = (targetModel: string) => { - if (!hasAccessToModel(targetModel)) { - setSelectedModelName(getDisplayNameText(targetModel)); - setUpgradeDialogOpen(true); - return; - } - - onModelChange(targetModel); - }; - - const getModelBadges = (modelId: string): string[] => { - const selectedModel = modelById.get(modelId); - const badges = selectedModel?.badges || []; - return badges.filter( - (badge) => - badge !== "Pro" && - (selectedModel?.access === "free" || badge.toLowerCase() !== selectedModel?.access) - ); - }; - - const getDisplayName = (modelId: string, showLock = false) => { - const selectedModel = modelById.get(modelId); - const elements: React.ReactNode[] = []; - - if (selectedModel) { - elements.push(selectedModel.display_name || selectedModel.short_name || modelId); - - const badges = getModelBadges(modelId); - badges.forEach((badge, index) => { - let badgeClass = "rounded-md px-1.5 py-0.5 text-[10px] font-medium"; - - if (badge === "Coming Soon") { - badgeClass += " bg-muted text-muted-foreground"; - } else if (badge === "New") { - badgeClass += " bg-maple-info/10 text-maple-info"; - } else if (badge === "Reasoning") { - badgeClass += " bg-maple-error/10 text-maple-error"; - } else if (badge === "Beta") { - badgeClass += " bg-maple-warning/10 text-maple-warning"; - } else { - badgeClass += " bg-[hsl(var(--maple-primary))]/10 text-[hsl(var(--maple-primary))]"; - } - - elements.push( - - {badge} - - ); - }); - - if (showLock && !hasAccessToModel(modelId)) { - elements.push(); - } - - if (selectedModel.capabilities?.vision) { - elements.push(); - } - } else { - elements.push(getDisplayNameText(modelId)); - } - - return {elements}; - }; - - return ( - <> - - - - - - {!showAdvanced ? ( -
- {PRIMARY_AGENT_MODELS.map((primaryModel) => { - const alias = getAlias(primaryModel.id); - const Icon = primaryModel.icon; - const targetModel = primaryModel.id; - const isActive = model === targetModel; - const requiresUpgrade = !hasAccessToModel(targetModel); - - return ( - handlePrimarySelect(targetModel)} - className={cn( - "flex cursor-pointer items-center gap-2 px-3 py-1.5", - requiresUpgrade && - "hover:bg-[hsl(var(--maple-primary-container))] dark:hover:bg-[hsl(var(--maple-primary))]/10" - )} - > - -
-
- - {alias?.label || primaryModel.label} - - {requiresUpgrade && } -
-
- {alias?.description || primaryModel.description} -
-
- {isActive && } -
- ); - })} - - - - { - event.preventDefault(); - setShowAdvanced(true); - }} - className="flex cursor-pointer items-center gap-2 px-3 py-1.5" - > - -
- More models -
All models
-
-
-
- ) : ( -
- { - event.preventDefault(); - setShowAdvanced(false); - }} - className="mb-1 flex cursor-pointer items-center gap-2 px-3 py-1.5" - > - - Back - - - - -
- {availableModels.length === 0 ? ( - - Loading models... - - ) : ( - [...availableModels] - .filter(isSelectableChatModel) - .filter( - (availableModel, index, self) => - self.findIndex((candidate) => candidate.id === availableModel.id) === index - ) - .sort((a, b) => { - const aDisabled = a.enabled === false; - const bDisabled = b.enabled === false; - const aRestricted = !hasAccessToModel(a.id); - const bRestricted = !hasAccessToModel(b.id); - - if (aDisabled && !bDisabled) return 1; - if (!aDisabled && bDisabled) return -1; - if (aRestricted && !bRestricted) return 1; - if (!aRestricted && bRestricted) return -1; - - return (a.sort_order ?? 999) - (b.sort_order ?? 999); - }) - .map((availableModel) => { - const isDisabled = availableModel.enabled === false; - const isRestricted = !hasAccessToModel(availableModel.id); - const selectedAliasTarget = getAlias(model)?.target_model; - const isActive = - model === availableModel.id || selectedAliasTarget === availableModel.id; - - return ( - { - if (isDisabled) return; - if (isRestricted) { - setSelectedModelName( - availableModel.display_name || availableModel.id - ); - setUpgradeDialogOpen(true); - } else { - onModelChange(availableModel.id); - setShowAdvanced(false); - } - }} - className={cn( - "group flex items-center justify-between", - isDisabled && "cursor-not-allowed opacity-50", - isRestricted && - "hover:bg-[hsl(var(--maple-primary-container))] dark:hover:bg-[hsl(var(--maple-primary))]/10" - )} - disabled={isDisabled} - > -
-
{getDisplayName(availableModel.id, true)}
-
- {isActive && } -
- ); - }) - )} -
-
- )} -
-
- - - - ); -} - function AgentTimeline({ items, isResponsePending, diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 197ecb410..239287147 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -74,10 +74,20 @@ function buildFallbackModelAliases(models: OpenSecretModel[]): OpenSecretModelAl }); } -export function ModelSelector() { +export type ModelSelectorProps = { + disabled?: boolean; + model?: string; + onModelChange?: (modelId: string, metadata?: OpenSecretModel) => void; +}; + +export function ModelSelector({ + disabled = false, + model: controlledModel, + onModelChange +}: ModelSelectorProps = {}) { const { - model, - setModel, + model: unmanagedModel, + setModel: setUnmanagedModel, availableModels, setAvailableModels, modelAliases, @@ -86,6 +96,8 @@ export function ModelSelector() { } = useModelState(); const { billingStatus } = useBillingState(); const os = useOpenSecret(); + const isManagedSelection = onModelChange != null; + const model = isManagedSelection ? (controlledModel ?? "") : unmanagedModel; const isFetching = useRef(false); const hasFetched = useRef(false); const currentModelRef = useRef(model); @@ -93,6 +105,17 @@ export function ModelSelector() { const [selectedModelName, setSelectedModelName] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); + const setModel = useCallback( + (modelId: string, metadata?: OpenSecretModel) => { + if (onModelChange) { + onModelChange(modelId, metadata); + return; + } + setUnmanagedModel(modelId, metadata); + }, + [onModelChange, setUnmanagedModel] + ); + useEffect(() => { currentModelRef.current = model; }, [model]); @@ -175,8 +198,9 @@ export function ModelSelector() { }, [os, reconcileSelectedConcreteModel, setAvailableModels, setHasWhisperModel, setModelAliases]); useEffect(() => { + if (isManagedSelection) return; void fetchCatalog(); - }, [fetchCatalog]); + }, [fetchCatalog, isManagedSelection]); const getAlias = useCallback( (modelId: string): OpenSecretModelAlias | undefined => { @@ -326,6 +350,7 @@ export function ModelSelector() {