diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.test.tsx index eb4279b52cb..fa945575592 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.test.tsx @@ -4,6 +4,37 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input', + () => ({ + ShortInput: ({ + config, + value, + onChange, + onBlur, + disabled, + allowReferences, + }: { + config: { id: string } + value: string + onChange: (value: string) => void + onBlur: () => void + disabled: boolean + allowReferences?: boolean + }) => ( + onChange(event.target.value)} + onBlur={onBlur} + disabled={disabled} + /> + ), + }) +) + import { RetrySettings } from './retry-settings' const policy = { enabled: true as const, maxTries: 5, waitBetweenTriesMs: 2000 } @@ -25,7 +56,15 @@ afterEach(() => { function renderSettings(props: Partial[0]> = {}) { const onChange = vi.fn() act(() => { - root.render() + root.render( + + ) }) return { onChange } } @@ -46,6 +85,33 @@ describe('RetrySettings', () => { expect(field('block-retry-max-tries')!.value).toBe('5') }) + it('commits the normalized value when the field loses focus', () => { + const { onChange } = renderSettings() + const maxTries = field('block-retry-max-tries')! + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + + act(() => { + setValue.call(maxTries, '2.7') + maxTries.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(field('block-retry-max-tries')!.value).toBe('2.7') + expect(onChange).not.toHaveBeenCalled() + + act(() => { + maxTries.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + + expect(onChange).toHaveBeenCalledWith({ ...policy, maxTries: 2 }) + expect(field('block-retry-max-tries')!.value).toBe('5') + }) + + it('turns off the reference pickers on the numeric fields', () => { + renderSettings() + + expect(field('block-retry-max-tries')!.dataset.allowReferences).toBe('false') + expect(field('block-retry-wait')!.dataset.allowReferences).toBe('false') + }) + it('renders only the switch while retry is off', () => { renderSettings({ retry: { ...policy, enabled: false } }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.tsx index 87ac1f8271a..6f0cbefffa5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/retry-settings/retry-settings.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { ChipInput, FieldDivider, Label, Switch } from '@sim/emcn' +import { FieldDivider, Label, Switch } from '@sim/emcn' import { BLOCK_RETRY_DEFAULT_TRIES, BLOCK_RETRY_DEFAULT_WAIT_MS, @@ -9,37 +9,55 @@ import { normalizeBlockRetryTries, normalizeBlockRetryWaitMs, } from '@sim/workflow-types/workflow' +import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input' +import type { SubBlockConfig } from '@/blocks/types' interface RetrySettingsProps { + blockId: string retry: BlockRetryConfig | undefined disabled: boolean onChange: (retry: BlockRetryConfig) => void } interface RetryNumberFieldProps { - id: string - title: string + blockId: string + config: SubBlockConfig value: number disabled: boolean normalize: (value: unknown) => number onCommit: (value: number) => void } +const MAX_TRIES_CONFIG = { + id: 'block-retry-max-tries', + title: 'Max tries', + type: 'short-input', + connectionDroppable: false, +} as const satisfies SubBlockConfig + +const WAIT_CONFIG = { + id: 'block-retry-wait', + title: 'Wait between tries (ms)', + type: 'short-input', + connectionDroppable: false, +} as const satisfies SubBlockConfig + /** * A bounded number field that commits on blur. * - * Typed as text with a numeric input mode rather than `type='number'`: the - * native spinner is all that buys, and it does not fit the field chrome the rest - * of the panel uses. Bounds are applied on commit through the same normalizer - * execution uses, so the field cannot clamp differently from the executor. + * Renders the same `ShortInput` every other sub-block text field uses, so it + * carries the panel's field chrome. Retry values are plain numbers that never + * resolve references, so the reference pickers are turned off. Bounds are + * applied on commit through the same normalizer execution uses, so the field + * cannot clamp differently from the executor. * * The draft exists only while the field is being edited; clearing it on commit * lets an external change — a collaborator's edit, or an undo — flow straight * through on the next render with no resync. */ function RetryNumberField({ - id, - title, + blockId, + config, value, disabled, normalize, @@ -57,15 +75,18 @@ function RetryNumberField({ return (
- - + +
+ setDraft(event.target.value)} + onChange={setDraft} onBlur={commit} disabled={disabled} + allowReferences={false} /> ) @@ -79,7 +100,7 @@ function RetryNumberField({ * with `enabled: false` when it is switched off, so turning it back on restores * what was configured rather than snapping to the defaults. */ -export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) { +export function RetrySettings({ blockId, retry, disabled, onChange }: RetrySettingsProps) { const enabled = retry?.enabled === true const maxTries = retry?.maxTries ?? BLOCK_RETRY_DEFAULT_TRIES const waitBetweenTriesMs = retry?.waitBetweenTriesMs ?? BLOCK_RETRY_DEFAULT_WAIT_MS @@ -106,8 +127,8 @@ export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) <>
+ /** Whether the env-var and tag reference pickers may open. Defaults to `true`. */ + allowReferences?: boolean + /** Called when the input loses focus. */ + onBlur?: () => void } /** @@ -81,6 +85,8 @@ export const ShortInput = memo(function ShortInput({ wandControlRef, hideInternalWand = false, workflowSearchValuePath = [], + allowReferences = true, + onBlur, }: ShortInputProps) { const activeSearchTarget = useActiveSearchTarget() const [localContent, setLocalContent] = useState('') @@ -284,7 +290,8 @@ export const ShortInput = memo(function ShortInput({ const handleBlur = useCallback(() => { setIsFocused(false) - }, []) + onBlur?.() + }, [onBlur]) // Expose wand control handlers to parent via ref useImperativeHandle( @@ -325,6 +332,7 @@ export const ShortInput = memo(function ShortInput({ disabled={disabled} isStreaming={wandHook.isStreaming} previewValue={previewValue} + allowReferences={allowReferences} shouldForceEnvDropdown={shouldForceEnvDropdown} shouldForceTagDropdown={shouldForceTagDropdown} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller/sub-block-input-controller.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller/sub-block-input-controller.tsx index 18627eeb96d..995a6901787 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller/sub-block-input-controller.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller/sub-block-input-controller.tsx @@ -33,6 +33,8 @@ export interface SubBlockInputControllerProps { onStreamingEnd?: () => void /** Optional preview value for read-only preview. */ previewValue?: string | null + /** Whether the env-var and tag reference pickers may open. Defaults to `true`. */ + allowReferences?: boolean /** * Optional callback to force/show the env var dropdown (e.g., API key fields). * Return { show: true, searchTerm?: string } to override defaults. @@ -82,6 +84,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re isStreaming, onStreamingEnd, previewValue, + allowReferences, shouldForceEnvDropdown, shouldForceTagDropdown, children, @@ -98,6 +101,7 @@ export function SubBlockInputController(props: SubBlockInputControllerProps): Re isStreaming, onStreamingEnd, previewValue, + allowReferences, shouldForceEnvDropdown, shouldForceTagDropdown, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx index d9de3abfb8b..e79ab62abcd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx @@ -1,8 +1,11 @@ -import { Button, ChipCombobox, cn, Label, Tooltip } from '@sim/emcn' +import { Combobox, cn, Label, Tooltip } from '@sim/emcn' import { ArrowLeftRight } from '@sim/emcn/icons' import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility' import type { StoredTool } from '@/lib/workflows/tool-input/types' import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input' +import type { SubBlockConfig } from '@/blocks/types' + +type UsageControlValue = NonNullable interface ToolUsageControlProps { blockId: string @@ -12,33 +15,34 @@ interface ToolUsageControlProps { mode: CanonicalMode supportsForce: boolean disabled: boolean - onFixedChange: (value: NonNullable) => void + onFixedChange: (value: UsageControlValue) => void onExpressionChange: (value: string) => void onModeToggle: () => void } const MODE_OPTIONS = [ - { - value: 'auto', - label: 'Auto', - suffixElement: (model decides), - }, - { - value: 'force', - label: 'Force', - suffixElement: (always use), - }, - { - value: 'none', - label: 'None', - suffixElement: (disable tool), - }, -] as const + { value: 'auto', label: 'Auto', hint: '(model decides)' }, + { value: 'force', label: 'Force', hint: '(always use)' }, + { value: 'none', label: 'None', hint: '(disable tool)' }, +] as const satisfies ReadonlyArray<{ value: UsageControlValue; label: string; hint: string }> + +const EXPRESSION_CONFIG = { + id: 'usageControlExpression', + title: 'Permission Mode', + type: 'short-input', +} as const satisfies SubBlockConfig + +function isUsageControlValue(value: string): value is UsageControlValue { + return MODE_OPTIONS.some((option) => option.value === value) +} /** * Permission Mode control for one agent tool. Selector mode picks a fixed `usageControl`, and * Variable mode edits a `usageControlExpression` that must resolve to auto, force, or none. * Both values are kept so toggling modes does not discard the inactive one. + * + * Renders the same label row, `Combobox`, and `ShortInput` as every other sub-block field, so + * the control matches the tool params beneath it. */ export function ToolUsageControl({ blockId, @@ -57,40 +61,38 @@ export function ToolUsageControl({ return (
- - - - - - {toggleLabel} - + +
+ + + + + +

{toggleLabel}

+
+
+
{mode === 'advanced' ? ( ) : ( - ({ - ...option, - disabled: option.value === 'force' && !supportsForce, - suffixElement: - option.value === 'force' && !supportsForce ? ( - (not supported by model) - ) : ( - option.suffixElement + { + const unsupported = option.value === 'force' && !supportsForce + return { + value: option.value, + label: option.label, + disabled: unsupported, + suffixElement: ( + + {unsupported ? '(not supported by model)' : option.hint} + ), - onSelect: () => onFixedChange(option.value), - }))} + } + })} value={tool.usageControl ?? 'auto'} + onChange={(value) => { + if (isUsageControlValue(value)) onFixedChange(value) + }} + editable={false} disabled={disabled} aria-label='Permission Mode' /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input.ts index b91ba626b44..8723ae4922c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input.ts @@ -38,6 +38,11 @@ export interface UseSubBlockInputOptions { onStreamingEnd?: () => void /** Optional preview value for read-only preview displays. */ previewValue?: string | null + /** + * Whether the env-var and tag reference pickers may open. Defaults to `true`; pass `false` for + * fields whose value can never hold a reference. + */ + allowReferences?: boolean /** * Optional callback to force/show the env var dropdown (e.g., API key fields). * Return { show: true, searchTerm?: string } to override defaults. @@ -160,6 +165,7 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI isStreaming = false, onStreamingEnd, previewValue, + allowReferences = true, shouldForceEnvDropdown, shouldForceTagDropdown, } = options @@ -558,8 +564,8 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI valueString, isDisabled, cursorPosition, - showEnvVars, - showTags, + showEnvVars: allowReferences && showEnvVars, + showTags: allowReferences && showTags, searchTerm, activeSourceBlockId, handlers: { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index dc345c60fa2..f24e926e2cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -801,6 +801,7 @@ export function Editor() { {showRetrySettings && (