From 08a255b4ae994b145a2f63376b29f4abe99b8293 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 14 Sep 2026 18:07:32 -0700 Subject: [PATCH 1/2] improvement(editor): render tool permission mode and retry fields with standard sub-block inputs --- .../retry-settings/retry-settings.test.tsx | 53 +++++++- .../retry-settings/retry-settings.tsx | 70 ++++++---- .../components/tools/usage-control.tsx | 124 ++++++++++-------- .../panel/components/editor/editor.tsx | 1 + 4 files changed, 165 insertions(+), 83 deletions(-) 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..230d00fdf44 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,31 @@ 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, + disabled, + }: { + config: { id: string } + value: string + onChange: (value: string) => void + disabled: boolean + }) => ( + onChange(event.target.value)} + disabled={disabled} + /> + ), + }) +) + import { RetrySettings } from './retry-settings' const policy = { enabled: true as const, maxTries: 5, waitBetweenTriesMs: 2000 } @@ -25,7 +50,15 @@ afterEach(() => { function renderSettings(props: Partial[0]> = {}) { const onChange = vi.fn() act(() => { - root.render() + root.render( + + ) }) return { onChange } } @@ -46,6 +79,24 @@ describe('RetrySettings', () => { expect(field('block-retry-max-tries')!.value).toBe('5') }) + it('keeps only digits while typing and commits the value on blur', () => { + const { onChange } = renderSettings() + const maxTries = field('block-retry-max-tries')! + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + + act(() => { + setValue.call(maxTries, '<2') + maxTries.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(field('block-retry-max-tries')!.value).toBe('2') + + act(() => { + maxTries.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + + expect(onChange).toHaveBeenCalledWith({ ...policy, maxTries: 2 }) + }) + 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..12e918f84b4 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,56 @@ 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 input is restricted to digits; that also keeps the + * `<` and `{{` reference pickers from opening. 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,16 +76,19 @@ function RetryNumberField({ return (
- - setDraft(event.target.value)} - onBlur={commit} - disabled={disabled} - /> +
+ +
+
+ setDraft(next.replace(/\D/g, ''))} + disabled={disabled} + /> +
) } @@ -79,7 +101,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 +128,8 @@ export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) <>
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/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 && ( Date: Mon, 14 Sep 2026 18:15:09 -0700 Subject: [PATCH 2/2] fix(editor): turn off reference pickers on retry number fields --- .../retry-settings/retry-settings.test.tsx | 21 ++++++++++++--- .../retry-settings/retry-settings.tsx | 27 +++++++++---------- .../components/short-input/short-input.tsx | 10 ++++++- .../sub-block-input-controller.tsx | 4 +++ .../sub-block/hooks/use-sub-block-input.ts | 10 +++++-- 5 files changed, 52 insertions(+), 20 deletions(-) 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 230d00fdf44..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 @@ -12,17 +12,23 @@ vi.mock( 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} /> ), @@ -79,22 +85,31 @@ describe('RetrySettings', () => { expect(field('block-retry-max-tries')!.value).toBe('5') }) - it('keeps only digits while typing and commits the value on blur', () => { + 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') + setValue.call(maxTries, '2.7') maxTries.dispatchEvent(new Event('input', { bubbles: true })) }) - expect(field('block-retry-max-tries')!.value).toBe('2') + 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', () => { 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 12e918f84b4..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 @@ -47,10 +47,9 @@ const WAIT_CONFIG = { * * 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 input is restricted to digits; that also keeps the - * `<` and `{{` reference pickers from opening. Bounds are applied on commit - * through the same normalizer execution uses, so the field cannot clamp - * differently from the executor. + * 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 @@ -79,16 +78,16 @@ function RetryNumberField({
-
- setDraft(next.replace(/\D/g, ''))} - disabled={disabled} - /> -
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx index c4028b6368c..eaf2449b9a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input.tsx @@ -52,6 +52,10 @@ interface ShortInputProps { /** Whether to hide the internal wand button (controlled by parent) */ hideInternalWand?: boolean workflowSearchValuePath?: Array + /** 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/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: {