Enable recipe deeplink parameters for pre-population (#5757)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Kai Lu
2025-12-03 07:45:25 -08:00
committed by GitHub
parent 039a845c42
commit 5d53643b61
8 changed files with 295 additions and 40 deletions
Generated
+1
View File
@@ -2789,6 +2789,7 @@ dependencies = [
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding",
"uuid", "uuid",
"webbrowser 1.0.4", "webbrowser 1.0.4",
"winapi", "winapi",
+1
View File
@@ -59,6 +59,7 @@ is-terminal = "0.4.16"
anstream = "0.6.18" anstream = "0.6.18"
url = "2.5.7" url = "2.5.7"
open = "5.3.2" open = "5.3.2"
urlencoding = "2.1"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] } winapi = { version = "0.3", features = ["wincred"] }
+26 -4
View File
@@ -379,6 +379,14 @@ enum RecipeCommand {
help = "recipe name to get recipe file or full path to the recipe file to generate deeplink" help = "recipe name to get recipe file or full path to the recipe file to generate deeplink"
)] )]
recipe_name: String, recipe_name: String,
/// Recipe parameters in key=value format (can be specified multiple times)
#[arg(
short = 'p',
long = "param",
value_name = "KEY=VALUE",
help = "Recipe parameter in key=value format (can be specified multiple times)"
)]
params: Vec<String>,
}, },
/// Open a recipe in Goose Desktop /// Open a recipe in Goose Desktop
@@ -387,6 +395,14 @@ enum RecipeCommand {
/// Recipe name to get recipe file to open /// Recipe name to get recipe file to open
#[arg(help = "recipe name or full path to the recipe file")] #[arg(help = "recipe name or full path to the recipe file")]
recipe_name: String, recipe_name: String,
/// Recipe parameters in key=value format (can be specified multiple times)
#[arg(
short = 'p',
long = "param",
value_name = "KEY=VALUE",
help = "Recipe parameter in key=value format (can be specified multiple times)"
)]
params: Vec<String>,
}, },
/// List available recipes /// List available recipes
@@ -1448,11 +1464,17 @@ pub async fn cli() -> anyhow::Result<()> {
RecipeCommand::Validate { recipe_name } => { RecipeCommand::Validate { recipe_name } => {
handle_validate(&recipe_name)?; handle_validate(&recipe_name)?;
} }
RecipeCommand::Deeplink { recipe_name } => { RecipeCommand::Deeplink {
handle_deeplink(&recipe_name)?; recipe_name,
params,
} => {
handle_deeplink(&recipe_name, &params)?;
} }
RecipeCommand::Open { recipe_name } => { RecipeCommand::Open {
handle_open(&recipe_name)?; recipe_name,
params,
} => {
handle_open(&recipe_name, &params)?;
} }
RecipeCommand::List { format, verbose } => { RecipeCommand::List { format, verbose } => {
handle_list(&format, verbose)?; handle_list(&format, verbose)?;
+170 -11
View File
@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use console::style; use console::style;
use goose::recipe::validate_recipe::validate_recipe_template_from_file; use goose::recipe::validate_recipe::validate_recipe_template_from_file;
use std::collections::HashMap;
use crate::recipes::github_recipe::RecipeSource; use crate::recipes::github_recipe::RecipeSource;
use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file}; use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file};
@@ -20,8 +21,9 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
Ok(()) Ok(())
} }
pub fn handle_deeplink(recipe_name: &str) -> Result<String> { pub fn handle_deeplink(recipe_name: &str, params: &[String]) -> Result<String> {
match generate_deeplink(recipe_name) { let params_map = parse_params(params)?;
match generate_deeplink(recipe_name, params_map) {
Ok((deeplink_url, recipe)) => { Ok((deeplink_url, recipe)) => {
println!( println!(
"{} Generated deeplink for: {}", "{} Generated deeplink for: {}",
@@ -42,10 +44,11 @@ pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
} }
} }
pub fn handle_open(recipe_name: &str) -> Result<()> { pub fn handle_open(recipe_name: &str, params: &[String]) -> Result<()> {
// Generate the deeplink using the helper function (no printing) // Generate the deeplink using the helper function (no printing)
// This reuses all the validation and encoding logic // This reuses all the validation and encoding logic
match generate_deeplink(recipe_name) { let params_map = parse_params(params)?;
match generate_deeplink(recipe_name, params_map) {
Ok((deeplink_url, recipe)) => { Ok((deeplink_url, recipe)) => {
// Attempt to open the deeplink // Attempt to open the deeplink
match open::that(&deeplink_url) { match open::that(&deeplink_url) {
@@ -131,13 +134,40 @@ pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
Ok(()) Ok(())
} }
fn generate_deeplink(recipe_name: &str) -> Result<(String, goose::recipe::Recipe)> { fn parse_params(params: &[String]) -> Result<HashMap<String, String>> {
let mut params_map = HashMap::new();
for param in params {
let parts: Vec<&str> = param.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!(
"Invalid parameter format: '{}'. Expected format: key=value",
param
));
}
params_map.insert(parts[0].to_string(), parts[1].to_string());
}
Ok(params_map)
}
fn generate_deeplink(
recipe_name: &str,
params: HashMap<String, String>,
) -> Result<(String, goose::recipe::Recipe)> {
let recipe_file = load_recipe_file(recipe_name)?; let recipe_file = load_recipe_file(recipe_name)?;
// Load the recipe file first to validate it // Load the recipe file first to validate it
let recipe = validate_recipe_template_from_file(&recipe_file)?; let recipe = validate_recipe_template_from_file(&recipe_file)?;
match recipe_deeplink::encode(&recipe) { match recipe_deeplink::encode(&recipe) {
Ok(encoded) => { Ok(encoded) => {
let full_url = format!("goose://recipe?config={}", encoded); let mut full_url = format!("goose://recipe?config={}", encoded);
// Append parameters as additional query parameters
for (key, value) in params {
// URL-encode the parameter keys and values
let encoded_key = urlencoding::encode(&key);
let encoded_value = urlencoding::encode(&value);
full_url.push_str(&format!("&{}={}", encoded_key, encoded_value));
}
Ok((full_url, recipe)) Ok((full_url, recipe))
} }
Err(err) => Err(anyhow::anyhow!("Failed to encode recipe: {}", err)), Err(err) => Err(anyhow::anyhow!("Failed to encode recipe: {}", err)),
@@ -188,7 +218,7 @@ instructions: "Test instructions"
let recipe_path = let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
let result = handle_deeplink(&recipe_path); let result = handle_deeplink(&recipe_path, &[]);
assert!(result.is_ok()); assert!(result.is_ok());
let url = result.unwrap(); let url = result.unwrap();
assert!(url.starts_with("goose://recipe?config=")); assert!(url.starts_with("goose://recipe?config="));
@@ -196,12 +226,27 @@ instructions: "Test instructions"
assert!(!encoded_part.is_empty()); assert!(!encoded_part.is_empty());
} }
#[test]
fn test_handle_deeplink_with_parameters() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
let params = vec!["name=John".to_string(), "age=30".to_string()];
let result = handle_deeplink(&recipe_path, &params);
assert!(result.is_ok());
let url = result.unwrap();
assert!(url.starts_with("goose://recipe?config="));
assert!(url.contains("&name=John"));
assert!(url.contains("&age=30"));
}
#[test] #[test]
fn test_handle_deeplink_invalid_recipe() { fn test_handle_deeplink_invalid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory"); let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path = let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT);
let result = handle_deeplink(&recipe_path); let result = handle_deeplink(&recipe_path, &[]);
assert!(result.is_err()); assert!(result.is_err());
} }
@@ -213,7 +258,7 @@ instructions: "Test instructions"
// Test handle_open - should attempt to open but may fail (that's expected in test environment) // Test handle_open - should attempt to open but may fail (that's expected in test environment)
// We just want to ensure it doesn't panic and handles the error gracefully // We just want to ensure it doesn't panic and handles the error gracefully
let result = handle_open(&recipe_path); let result = handle_open(&recipe_path, &[]);
// The result may be Ok or Err depending on whether the system can open the URL // The result may be Ok or Err depending on whether the system can open the URL
// In a test environment, it will likely fail to open, but that's fine // In a test environment, it will likely fail to open, but that's fine
// We're mainly testing that the function doesn't panic and processes the recipe correctly // We're mainly testing that the function doesn't panic and processes the recipe correctly
@@ -227,6 +272,27 @@ instructions: "Test instructions"
} }
} }
#[test]
fn test_handle_open_with_parameters() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
let params = vec!["name=Alice".to_string(), "role=developer".to_string()];
let result = handle_open(&recipe_path, &params);
// The result may be Ok or Err depending on whether the system can open the URL
// In a test environment, it will likely fail to open, but that's fine
// We're mainly testing that the function processes parameters correctly and doesn't panic
match result {
Ok(_) => {
// Successfully opened (unlikely in test environment)
}
Err(_) => {
// Failed to open (expected in test environment) - this is fine
}
}
}
#[test] #[test]
fn test_handle_validation_valid_recipe() { fn test_handle_validation_valid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory"); let temp_dir = TempDir::new().expect("Failed to create temp directory");
@@ -252,7 +318,7 @@ instructions: "Test instructions"
let recipe_path = let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
let result = generate_deeplink(&recipe_path); let result = generate_deeplink(&recipe_path, HashMap::new());
assert!(result.is_ok()); assert!(result.is_ok());
let (url, recipe) = result.unwrap(); let (url, recipe) = result.unwrap();
assert!(url.starts_with("goose://recipe?config=")); assert!(url.starts_with("goose://recipe?config="));
@@ -262,13 +328,106 @@ instructions: "Test instructions"
assert!(!encoded_part.is_empty()); assert!(!encoded_part.is_empty());
} }
#[test]
fn test_generate_deeplink_with_parameters() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
let mut params = HashMap::new();
params.insert("name".to_string(), "Alice".to_string());
params.insert("role".to_string(), "developer".to_string());
let result = generate_deeplink(&recipe_path, params);
assert!(result.is_ok());
let (url, recipe) = result.unwrap();
assert!(url.starts_with("goose://recipe?config="));
assert!(url.contains("&name=Alice"));
assert!(url.contains("&role=developer"));
assert_eq!(recipe.title, "Test Recipe with Valid JSON Schema");
}
#[test] #[test]
fn test_generate_deeplink_invalid_recipe() { fn test_generate_deeplink_invalid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory"); let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path = let recipe_path =
create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT);
let result = generate_deeplink(&recipe_path); let result = generate_deeplink(&recipe_path, HashMap::new());
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn test_parse_params_basic() {
let params = vec!["name=John".to_string(), "age=30".to_string()];
let result = parse_params(&params);
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.get("name"), Some(&"John".to_string()));
assert_eq!(map.get("age"), Some(&"30".to_string()));
}
#[test]
fn test_parse_params_with_equals_in_value() {
let params = vec!["key=value=with=equals".to_string()];
let result = parse_params(&params);
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.get("key"), Some(&"value=with=equals".to_string()));
}
#[test]
fn test_parse_params_empty_value() {
let params = vec!["key=".to_string()];
let result = parse_params(&params);
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.get("key"), Some(&"".to_string()));
}
#[test]
fn test_parse_params_no_equals() {
let params = vec!["invalid".to_string()];
let result = parse_params(&params);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Invalid parameter format"));
}
#[test]
fn test_parse_params_empty_key() {
let params = vec!["=value".to_string()];
let result = parse_params(&params);
assert!(result.is_ok());
let map = result.unwrap();
// Empty key is technically valid according to current implementation
assert_eq!(map.get(""), Some(&"value".to_string()));
}
#[test]
fn test_parse_params_special_characters() {
let params = vec![
"url=https://example.com/path?query=test".to_string(),
"message=Hello World!".to_string(),
"email=user@example.com".to_string(),
];
let result = parse_params(&params);
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(
map.get("url"),
Some(&"https://example.com/path?query=test".to_string())
);
assert_eq!(map.get("message"), Some(&"Hello World!".to_string()));
assert_eq!(map.get("email"), Some(&"user@example.com".to_string()));
}
#[test]
fn test_parse_params_empty_list() {
let params: Vec<String> = vec![];
let result = parse_params(&params);
assert!(result.is_ok());
let map = result.unwrap();
assert!(map.is_empty());
}
} }
+4
View File
@@ -449,6 +449,10 @@ function BaseChatContent({
parameters={recipe.parameters} parameters={recipe.parameters}
onSubmit={setRecipeUserParams} onSubmit={setRecipeUserParams}
onClose={() => setView('chat')} onClose={() => setView('chat')}
initialValues={
(window.appConfig?.get('recipeParameters') as Record<string, string> | undefined) ||
undefined
}
/> />
)} )}
@@ -6,29 +6,31 @@ interface ParameterInputModalProps {
parameters: Parameter[]; parameters: Parameter[];
onSubmit: (values: Record<string, string>) => void; onSubmit: (values: Record<string, string>) => void;
onClose: () => void; onClose: () => void;
initialValues?: Record<string, string>;
} }
const ParameterInputModal: React.FC<ParameterInputModalProps> = ({ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
parameters, parameters,
onSubmit, onSubmit,
onClose, onClose,
initialValues,
}) => { }) => {
const [inputValues, setInputValues] = useState<Record<string, string>>({}); const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [showCancelOptions, setShowCancelOptions] = useState(false); const [showCancelOptions, setShowCancelOptions] = useState(false);
// Pre-fill the form with default values from the recipe // Pre-fill the form with default values from the recipe and initialValues from deeplink
useEffect(() => { useEffect(() => {
const initialValues: Record<string, string> = {}; const defaultValues: Record<string, string> = {};
parameters.forEach((param) => { parameters.forEach((param) => {
if (param.requirement === 'optional' && param.default) { if (param.requirement === 'optional' && param.default) {
const defaultValue = defaultValues[param.key] =
param.input_type === 'boolean' ? param.default.toLowerCase() : param.default; param.input_type === 'boolean' ? param.default.toLowerCase() : param.default;
initialValues[param.key] = defaultValue;
} }
}); });
setInputValues(initialValues);
}, [parameters]); setInputValues({ ...defaultValues, ...initialValues });
}, [parameters, initialValues]);
const handleChange = (name: string, value: string): void => { const handleChange = (name: string, value: string): void => {
setInputValues((prevValues: Record<string, string>) => ({ ...prevValues, [name]: value })); setInputValues((prevValues: Record<string, string>) => ({ ...prevValues, [name]: value }));
+31 -1
View File
@@ -22,6 +22,12 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
const chatContext = useChatContext(); const chatContext = useChatContext();
const messages = chat.messages; const messages = chat.messages;
// Get recipe parameters from deeplink if available
const paramsFromConfig =
(window.appConfig?.get('recipeParameters') as Record<string, string> | null | undefined) ??
null;
const recipeParametersFromConfig = useRef<Record<string, string> | null>(paramsFromConfig);
const messagesRef = useRef(messages); const messagesRef = useRef(messages);
const isCreatingRecipeRef = useRef(false); const isCreatingRecipeRef = useRef(false);
const hasCheckedRecipeRef = useRef(false); const hasCheckedRecipeRef = useRef(false);
@@ -32,6 +38,27 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
const finalRecipe = chat.recipe; const finalRecipe = chat.recipe;
const resolvedRecipe = chat.resolvedRecipe; const resolvedRecipe = chat.resolvedRecipe;
// Initialize parameters from deeplink when recipe is loaded (from backend/deeplink)
useEffect(() => {
if (!chatContext || !finalRecipe) {
return;
}
// Only initialize if we have params from config and haven't set them yet
const hasNoParameters =
!chat.recipeParameterValues ||
(typeof chat.recipeParameterValues === 'object' &&
Object.keys(chat.recipeParameterValues).length === 0);
if (recipeParametersFromConfig.current && hasNoParameters) {
chatContext.setChat({
...chatContext.chat,
recipeParameterValues: recipeParametersFromConfig.current,
});
}
}, [chatContext, finalRecipe, chat]);
useEffect(() => { useEffect(() => {
if (!chatContext) return; if (!chatContext) return;
@@ -55,10 +82,13 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
setIsRecipeWarningModalOpen(false); setIsRecipeWarningModalOpen(false);
hasCheckedRecipeRef.current = false; // Reset check flag for new recipe hasCheckedRecipeRef.current = false; // Reset check flag for new recipe
// Initialize with parameters from deeplink if available
const initialParameterValues = recipeParametersFromConfig.current || null;
chatContext.setChat({ chatContext.setChat({
...chatContext.chat, ...chatContext.chat,
recipe: recipe, recipe: recipe,
recipeParameterValues: null, recipeParameterValues: initialParameterValues,
messages: [], messages: [],
}); });
} }
+54 -18
View File
@@ -181,7 +181,7 @@ if (process.platform !== 'darwin') {
const recentDirs = loadRecentDirs(); const recentDirs = loadRecentDirs();
const openDir = recentDirs.length > 0 ? recentDirs[0] : null; const openDir = recentDirs.length > 0 ? recentDirs[0] : null;
const recipeDeeplink = parseRecipeDeeplink(protocolUrl); const deeplinkData = parseRecipeDeeplink(protocolUrl);
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
createChat( createChat(
@@ -191,9 +191,10 @@ if (process.platform !== 'darwin') {
undefined, undefined,
undefined, undefined,
undefined, undefined,
recipeDeeplink || undefined, deeplinkData?.config,
scheduledJobId || undefined, scheduledJobId || undefined,
undefined undefined,
deeplinkData?.parameters
); );
}); });
return; // Skip the rest of the handler return; // Skip the rest of the handler
@@ -279,7 +280,7 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
} else if (parsedUrl.hostname === 'sessions') { } else if (parsedUrl.hostname === 'sessions') {
window.webContents.send('open-shared-session', pendingDeepLink); window.webContents.send('open-shared-session', pendingDeepLink);
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
const recipeDeeplink = parseRecipeDeeplink(parsedUrl.toString()); const deeplinkData = parseRecipeDeeplink(pendingDeepLink ?? parsedUrl.toString());
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
// Create a new window and ignore the passed-in window // Create a new window and ignore the passed-in window
@@ -290,9 +291,10 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
undefined, undefined,
undefined, undefined,
undefined, undefined,
recipeDeeplink || undefined, deeplinkData?.config,
scheduledJobId || undefined, scheduledJobId || undefined,
undefined undefined,
deeplinkData?.parameters
); );
pendingDeepLink = null; pendingDeepLink = null;
} }
@@ -310,8 +312,8 @@ app.on('open-url', async (_event, url) => {
console.log('[Main] Received open-url event:', url); console.log('[Main] Received open-url event:', url);
if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
console.log('[Main] Detected bot/recipe URL, creating new chat window'); console.log('[Main] Detected bot/recipe URL, creating new chat window');
let recipeDeeplink = parseRecipeDeeplink(url); const deeplinkData = parseRecipeDeeplink(url);
if (recipeDeeplink) { if (deeplinkData) {
windowDeeplinkURL = url; windowDeeplinkURL = url;
} }
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
@@ -324,9 +326,10 @@ app.on('open-url', async (_event, url) => {
undefined, undefined,
undefined, undefined,
undefined, undefined,
recipeDeeplink || undefined, deeplinkData?.config,
scheduledJobId || undefined, scheduledJobId || undefined,
undefined undefined,
deeplinkData?.parameters
); );
windowDeeplinkURL = null; windowDeeplinkURL = null;
return; // Skip the rest of the handler return; // Skip the rest of the handler
@@ -495,7 +498,8 @@ const createChat = async (
viewType?: string, viewType?: string,
recipeDeeplink?: string, // Raw deeplink decoded on server recipeDeeplink?: string, // Raw deeplink decoded on server
scheduledJobId?: string, // Scheduled job ID if applicable scheduledJobId?: string, // Scheduled job ID if applicable
recipeId?: string recipeId?: string,
recipeParameters?: Record<string, string> // Recipe parameter values from deeplink URL
) => { ) => {
updateEnvironmentVariables(envToggles); updateEnvironmentVariables(envToggles);
@@ -544,6 +548,7 @@ const createChat = async (
GOOSE_VERSION: version, GOOSE_VERSION: version,
recipeId: recipeId, recipeId: recipeId,
recipeDeeplink: recipeDeeplink, recipeDeeplink: recipeDeeplink,
recipeParameters: recipeParameters,
scheduledJobId: scheduledJobId, scheduledJobId: scheduledJobId,
}), }),
], ],
@@ -1017,9 +1022,9 @@ const openDirectoryDialog = async (): Promise<OpenDialogReturnValue> => {
addRecentDir(dirToAdd); addRecentDir(dirToAdd);
let recipeDeeplink: string | undefined = undefined; let deeplinkData: RecipeDeeplinkData | undefined = undefined;
if (windowDeeplinkURL) { if (windowDeeplinkURL) {
recipeDeeplink = parseRecipeDeeplink(windowDeeplinkURL); deeplinkData = parseRecipeDeeplink(windowDeeplinkURL);
} }
// Create a new window with the selected directory // Create a new window with the selected directory
await createChat( await createChat(
@@ -1029,14 +1034,21 @@ const openDirectoryDialog = async (): Promise<OpenDialogReturnValue> => {
undefined, undefined,
undefined, undefined,
undefined, undefined,
deeplinkData?.config,
undefined, undefined,
recipeDeeplink undefined,
deeplinkData?.parameters
); );
} }
return result; return result;
}; };
function parseRecipeDeeplink(url: string): string | undefined { interface RecipeDeeplinkData {
config: string;
parameters?: Record<string, string>;
}
function parseRecipeDeeplink(url: string): RecipeDeeplinkData | undefined {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
let recipeDeeplink = parsedUrl.searchParams.get('config'); let recipeDeeplink = parsedUrl.searchParams.get('config');
if (recipeDeeplink && !url.includes(recipeDeeplink)) { if (recipeDeeplink && !url.includes(recipeDeeplink)) {
@@ -1055,10 +1067,34 @@ function parseRecipeDeeplink(url: string): string | undefined {
} }
} }
} }
if (recipeDeeplink) { if (!recipeDeeplink) {
return recipeDeeplink; return undefined;
} }
return undefined;
// Extract all query parameters except 'config' and 'scheduledJob' as recipe parameters
// Use raw query string parsing to preserve '+' characters (consistent with config handling)
const parameters: Record<string, string> = {};
const search = parsedUrl.search || '';
const paramMatches = search.matchAll(/[?&]([^=&]+)=([^&]*)/g);
for (const match of paramMatches) {
const key = match[1];
const rawValue = match[2];
if (key !== 'config' && key !== 'scheduledJob') {
try {
parameters[key] = decodeURIComponent(rawValue);
} catch {
// If decoding fails, use raw value
parameters[key] = rawValue;
}
}
}
return {
config: recipeDeeplink,
parameters: Object.keys(parameters).length > 0 ? parameters : undefined,
};
} }
// Global error handler // Global error handler