import React, { useState, useEffect, FormEvent, useCallback } from 'react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { ScheduledJob } from '../../schedule'; import { CronPicker } from './CronPicker'; import { Recipe, decodeRecipe } from '../../recipe'; import { getStorageDirectory } from '../../recipe/recipe_management'; import ClockIcon from '../../assets/clock-icon.svg'; import * as yaml from 'yaml'; export interface NewSchedulePayload { id: string; recipe_source: string; cron: string; execution_mode?: string; } interface ScheduleModalProps { isOpen: boolean; onClose: () => void; onSubmit: (payload: NewSchedulePayload | string) => Promise; schedule: ScheduledJob | null; isLoadingExternally: boolean; apiErrorExternally: string | null; initialDeepLink: string | null; } type SourceType = 'file' | 'deeplink'; interface CleanExtension { name: string; type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http'; cmd?: string; args?: string[]; uri?: string; display_name?: string; tools?: unknown[]; instructions?: string; env_keys?: string[]; timeout?: number; description?: string; bundled?: boolean; } interface CleanRecipe { title: string; description: string; instructions?: string; prompt?: string; activities?: string[]; extensions?: CleanExtension[]; author?: { contact?: string; metadata?: string; }; schedule?: { window_title?: string; working_directory?: string; }; } async function parseDeepLink(deepLink: string): Promise { try { const url = new URL(deepLink); if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { return null; } const recipeParam = url.searchParams.get('config'); if (!recipeParam) { return null; } return await decodeRecipe(recipeParam); } catch (error) { console.error('Failed to parse deep link:', error); return null; } } function recipeToYaml(recipe: Recipe): string { const cleanRecipe: CleanRecipe = { title: recipe.title, description: recipe.description, }; if (recipe.instructions) { cleanRecipe.instructions = recipe.instructions; } if (recipe.prompt) { cleanRecipe.prompt = recipe.prompt; } if (recipe.activities && recipe.activities.length > 0) { cleanRecipe.activities = recipe.activities; } if (recipe.extensions && recipe.extensions.length > 0) { cleanRecipe.extensions = recipe.extensions.map((ext) => { const cleanExt: CleanExtension = { name: ext.name, type: 'builtin', }; if ('type' in ext && ext.type) { cleanExt.type = ext.type as CleanExtension['type']; const extAny = ext as Record; if (ext.type === 'sse' && extAny.uri) { cleanExt.uri = extAny.uri as string; } else if (ext.type === 'streamable_http' && extAny.uri) { cleanExt.uri = extAny.uri as string; } else if (ext.type === 'stdio') { if (extAny.cmd) { cleanExt.cmd = extAny.cmd as string; } if (extAny.args) { cleanExt.args = extAny.args as string[]; } } else if (ext.type === 'builtin' && extAny.display_name) { cleanExt.display_name = extAny.display_name as string; } if ((ext.type as string) === 'frontend') { if (extAny.tools) { cleanExt.tools = extAny.tools as unknown[]; } if (extAny.instructions) { cleanExt.instructions = extAny.instructions as string; } } } else { const extAny = ext as Record; if (extAny.cmd) { cleanExt.type = 'stdio'; cleanExt.cmd = extAny.cmd as string; if (extAny.args) { cleanExt.args = extAny.args as string[]; } } else if (extAny.command) { cleanExt.type = 'stdio'; cleanExt.cmd = extAny.command as string; } else if (extAny.uri) { cleanExt.type = 'streamable_http'; cleanExt.uri = extAny.uri as string; } else if (extAny.tools) { cleanExt.type = 'frontend'; cleanExt.tools = extAny.tools as unknown[]; if (extAny.instructions) { cleanExt.instructions = extAny.instructions as string; } } else { cleanExt.type = 'builtin'; } } if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) { cleanExt.env_keys = ext.env_keys; } if ('timeout' in ext && ext.timeout) { cleanExt.timeout = ext.timeout as number; } if ('description' in ext && ext.description) { cleanExt.description = ext.description as string; } if ('bundled' in ext && ext.bundled !== undefined) { cleanExt.bundled = ext.bundled as boolean; } return cleanExt; }); } if (recipe.author) { cleanRecipe.author = { contact: recipe.author.contact || undefined, metadata: recipe.author.metadata || undefined, }; } cleanRecipe.schedule = { window_title: `${recipe.title} - Scheduled`, }; return yaml.stringify(cleanRecipe); } const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1'; export const ScheduleModal: React.FC = ({ isOpen, onClose, onSubmit, schedule, isLoadingExternally, apiErrorExternally, initialDeepLink, }) => { const isEditMode = !!schedule; const [scheduleId, setScheduleId] = useState(''); const [sourceType, setSourceType] = useState('file'); const [recipeSourcePath, setRecipeSourcePath] = useState(''); const [deepLinkInput, setDeepLinkInput] = useState(''); const [parsedRecipe, setParsedRecipe] = useState(null); const [cronExpression, setCronExpression] = useState('0 0 14 * * *'); const [internalValidationError, setInternalValidationError] = useState(null); const handleDeepLinkChange = useCallback(async (value: string) => { setDeepLinkInput(value); setInternalValidationError(null); if (value.trim()) { try { const recipe = await parseDeepLink(value.trim()); if (recipe) { setParsedRecipe(recipe); if (recipe.title) { const cleanId = recipe.title .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-'); setScheduleId(cleanId); } } else { setParsedRecipe(null); setInternalValidationError( 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' ); } } catch { setParsedRecipe(null); setInternalValidationError( 'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.' ); } } else { setParsedRecipe(null); } }, []); useEffect(() => { if (isOpen) { if (schedule) { setScheduleId(schedule.id); setCronExpression(schedule.cron); } else { setScheduleId(''); setSourceType('file'); setRecipeSourcePath(''); setDeepLinkInput(''); setParsedRecipe(null); setCronExpression('0 0 14 * * *'); setInternalValidationError(null); if (initialDeepLink) { setSourceType('deeplink'); handleDeepLinkChange(initialDeepLink); } } } }, [isOpen, schedule, initialDeepLink, handleDeepLinkChange]); const handleBrowseFile = async () => { const defaultPath = getStorageDirectory(true); const filePath = await window.electron.selectFileOrDirectory(defaultPath); if (filePath) { if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { setRecipeSourcePath(filePath); setInternalValidationError(null); } else { setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)'); } } }; const handleLocalSubmit = async (event: FormEvent) => { event.preventDefault(); setInternalValidationError(null); if (isEditMode) { await onSubmit(cronExpression); return; } if (!scheduleId.trim()) { setInternalValidationError('Schedule ID is required.'); return; } let finalRecipeSource = ''; if (sourceType === 'file') { if (!recipeSourcePath) { setInternalValidationError('Recipe source file is required.'); return; } finalRecipeSource = recipeSourcePath; } else if (sourceType === 'deeplink') { if (!deepLinkInput.trim()) { setInternalValidationError('Deep link is required.'); return; } if (!parsedRecipe) { setInternalValidationError('Invalid deep link. Please check the format.'); return; } try { const yamlContent = recipeToYaml(parsedRecipe); const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`; const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.'; const tempFilePath = `${tempDir}/${tempFileName}`; const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent); if (!writeSuccess) { setInternalValidationError('Failed to create temporary recipe file.'); return; } finalRecipeSource = tempFilePath; } catch (error) { console.error('Failed to convert recipe to YAML:', error); setInternalValidationError('Failed to process the recipe from deep link.'); return; } } const newSchedulePayload: NewSchedulePayload = { id: scheduleId.trim(), recipe_source: finalRecipeSource, cron: cronExpression, }; await onSubmit(newSchedulePayload); }; if (!isOpen) return null; return (
Clock

{isEditMode ? 'Edit Schedule' : 'Create New Schedule'}

{isEditMode &&

{schedule.id}

}
{apiErrorExternally && (

{apiErrorExternally}

)} {internalValidationError && (

{internalValidationError}

)} {!isEditMode && ( <>
setScheduleId(e.target.value)} placeholder="e.g., daily-summary-job" required />
{sourceType === 'file' && (
{recipeSourcePath && (

Selected: {recipeSourcePath}

)}
)} {sourceType === 'deeplink' && (
handleDeepLinkChange(e.target.value)} placeholder="Paste goose://bot or goose://recipe link here..." className="rounded-full" /> {parsedRecipe && (

✓ Recipe parsed successfully

Title: {parsedRecipe.title}

Description: {parsedRecipe.description}

)}
)}
)}
); };