From 5668f0d87c1b218e9b69dab27d1c48bda805b677 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sun, 20 Sep 2026 17:59:01 -0700 Subject: [PATCH 1/2] improvement(ui): use chip selects for bounded workflow choices --- .../components/filter-rule-row.tsx | 21 ++++- .../sort-builder/components/sort-rule-row.tsx | 11 ++- .../components/starter/input-format.tsx | 20 ++++- .../components/tools/usage-control.test.tsx | 88 +++++++++++++++++++ .../components/tools/usage-control.tsx | 29 ++++-- .../variables-input/variables-input.tsx | 13 ++- 6 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx index 33a1fc95fc7..9183647d01c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx @@ -2,6 +2,7 @@ import { useRef } from 'react' import { Badge, Button, + ChipSelect, CollapsibleCard, Combobox, type ComboboxOption, @@ -204,12 +205,18 @@ export function FilterRuleRow({ {index > 0 && (
- onUpdate(rule.id, 'logicalOperator', v as 'and' | 'or')} disabled={isReadOnly} - overlayContent={ + displayLabel={ getLabelHighlight('logicalOperator', rule.logicalOperator) ? ( {formatDisplayText(rule.logicalOperator, { @@ -247,13 +254,19 @@ export function FilterRuleRow({
- onUpdate(rule.id, 'operator', v)} disabled={isReadOnly} placeholder='Select operator' - overlayContent={ + displayLabel={ getLabelHighlight('operator', getOperatorLabel(rule.operator)) ? ( {formatDisplayText(getOperatorLabel(rule.operator), { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx index 5d6d1208ce9..331becadd63 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx @@ -1,6 +1,7 @@ import { Badge, Button, + ChipSelect, CollapsibleCard, Combobox, type ComboboxOption, @@ -128,13 +129,19 @@ export function SortRuleRow({
- onUpdate(rule.id, 'direction', v as 'asc' | 'desc')} disabled={isReadOnly} placeholder='Select direction' - overlayContent={ + displayLabel={ getLabelHighlight('direction', getDirectionLabel(rule.direction)) ? ( {formatDisplayText(getDirectionLabel(rule.direction), { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index a949d97a61b..65e3a069693 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -2,9 +2,9 @@ import { useCallback, useRef, useState } from 'react' import { Badge, Button, + ChipSelect, Code, CollapsibleCard, - Combobox, type ComboboxOption, calculateGutterWidth, cn, @@ -378,9 +378,15 @@ export function FieldFormat({ const renderValueInput = (field: Field) => { if (field.type === 'boolean') { return ( - !isReadOnly && updateField(field.id, 'value', v)} placeholder='Select value' disabled={isReadOnly} @@ -657,7 +663,13 @@ export function FieldFormat({ {showType && (
{renderFieldLabel('Type')} - updateField(field.id, 'type', value)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx new file mode 100644 index 00000000000..81bc6931f74 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx @@ -0,0 +1,88 @@ +/** @vitest-environment jsdom */ +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ToolUsageControl } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control' + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input', + () => ({ ShortInput: () => null }) +) + +let root: Root | undefined +let container: HTMLDivElement | undefined + +function mount(overrides: Partial> = {}) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.useFakeTimers() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + const onFixedChange = vi.fn() + act(() => + root?.render( + + ) + ) + const trigger = container.querySelector( + 'button[aria-label="Permission Mode"]' + )! + return { trigger, onFixedChange } +} + +async function key(node: Element, key: string) { + act(() => { + node.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(10) + }) +} + +afterEach(() => { + act(() => root?.unmount()) + container?.remove() + document.body.removeAttribute('style') + vi.useRealTimers() +}) + +describe('tool permission selection', () => { + it('keeps hints in the menu, skips unsupported Force and reports the stored value', async () => { + const { trigger, onFixedChange } = mount() + expect(trigger.textContent).toBe('Auto') + await key(trigger, 'ArrowDown') + const force = [...document.querySelectorAll('[role="menuitem"]')].find((item) => + item.textContent?.startsWith('Force') + )! + expect(force.textContent).toContain('(not supported by model)') + expect(force.getAttribute('aria-disabled')).toBe('true') + await key(document.activeElement!, 'ArrowDown') + expect(document.activeElement?.textContent).toBe('None(disable tool)') + await key(document.activeElement!, 'Enter') + expect(onFixedChange).toHaveBeenCalledExactlyOnceWith('none') + expect(document.querySelector('[role="menu"]')).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('enables supported Force and closes Escape without changing the value', async () => { + const { trigger, onFixedChange } = mount({ supportsForce: true }) + await key(trigger, 'ArrowDown') + await key(document.activeElement!, 'ArrowDown') + expect(document.activeElement?.textContent).toBe('Force(always use)') + await key(document.activeElement!, 'Escape') + expect(onFixedChange).not.toHaveBeenCalled() + expect(document.activeElement).toBe(trigger) + }) +}) 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 684602b559c..118af26e4dc 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,4 +1,4 @@ -import { Combobox, Label } from '@sim/emcn' +import { ChipSelect, Label } from '@sim/emcn' import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility' import type { StoredTool } from '@/lib/workflows/tool-input/types' import { FieldModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-mode-toggle/field-mode-toggle' @@ -41,7 +41,7 @@ function isUsageControlValue(value: string): value is UsageControlValue { * 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 + * Renders the same label row, `ChipSelect`, and `ShortInput` as every other sub-block field, so * the control matches the tool params beneath it. */ export function ToolUsageControl({ @@ -83,25 +83,36 @@ export function ToolUsageControl({ workflowSearchValuePath={[toolIndex, 'usageControlExpression']} /> ) : ( - { const unsupported = option.value === 'force' && !supportsForce return { value: option.value, - label: option.label, - disabled: unsupported, - suffixElement: ( - - {unsupported ? '(not supported by model)' : option.hint} + label: ( + + {option.label} + + {unsupported ? '(not supported by model)' : option.hint} + ), + searchTerms: [option.label] as const, + disabled: unsupported, } })} value={tool.usageControl ?? 'auto'} + displayLabel={ + MODE_OPTIONS.find((option) => option.value === (tool.usageControl ?? 'auto'))?.label + } 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/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index 84b832a7cbb..f41469e6bf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { Badge, Button, + ChipSelect, CollapsibleCard, Combobox, type ComboboxOption, @@ -474,13 +475,19 @@ export function VariablesInput({ )}
{assignment.type === 'boolean' && !isManualBoolean ? ( - !isReadOnly && updateAssignment(assignment.id, { value: v })} placeholder='Select value' disabled={isReadOnly} - overlayContent={ + displayLabel={ booleanLabelHighlight ? ( {formatDisplayText(assignment.value ?? '', { From ce497f1f85f135b53fdad7bd149f226966be5115 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sun, 20 Sep 2026 21:18:14 -0700 Subject: [PATCH 2/2] fix(ui): retain existing bounded workflow choice controls --- .../components/filter-rule-row.tsx | 21 +---- .../sort-builder/components/sort-rule-row.tsx | 11 +-- .../components/starter/input-format.tsx | 20 +---- .../components/tools/usage-control.test.tsx | 88 ------------------- .../components/tools/usage-control.tsx | 29 ++---- .../variables-input/variables-input.tsx | 13 +-- 6 files changed, 22 insertions(+), 160 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx index 9183647d01c..33a1fc95fc7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx @@ -2,7 +2,6 @@ import { useRef } from 'react' import { Badge, Button, - ChipSelect, CollapsibleCard, Combobox, type ComboboxOption, @@ -205,18 +204,12 @@ export function FilterRuleRow({ {index > 0 && (
- onUpdate(rule.id, 'logicalOperator', v as 'and' | 'or')} disabled={isReadOnly} - displayLabel={ + overlayContent={ getLabelHighlight('logicalOperator', rule.logicalOperator) ? ( {formatDisplayText(rule.logicalOperator, { @@ -254,19 +247,13 @@ export function FilterRuleRow({
- onUpdate(rule.id, 'operator', v)} disabled={isReadOnly} placeholder='Select operator' - displayLabel={ + overlayContent={ getLabelHighlight('operator', getOperatorLabel(rule.operator)) ? ( {formatDisplayText(getOperatorLabel(rule.operator), { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx index 331becadd63..5d6d1208ce9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx @@ -1,7 +1,6 @@ import { Badge, Button, - ChipSelect, CollapsibleCard, Combobox, type ComboboxOption, @@ -129,19 +128,13 @@ export function SortRuleRow({
- onUpdate(rule.id, 'direction', v as 'asc' | 'desc')} disabled={isReadOnly} placeholder='Select direction' - displayLabel={ + overlayContent={ getLabelHighlight('direction', getDirectionLabel(rule.direction)) ? ( {formatDisplayText(getDirectionLabel(rule.direction), { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 65e3a069693..a949d97a61b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -2,9 +2,9 @@ import { useCallback, useRef, useState } from 'react' import { Badge, Button, - ChipSelect, Code, CollapsibleCard, + Combobox, type ComboboxOption, calculateGutterWidth, cn, @@ -378,15 +378,9 @@ export function FieldFormat({ const renderValueInput = (field: Field) => { if (field.type === 'boolean') { return ( - !isReadOnly && updateField(field.id, 'value', v)} placeholder='Select value' disabled={isReadOnly} @@ -663,13 +657,7 @@ export function FieldFormat({ {showType && (
{renderFieldLabel('Type')} - updateField(field.id, 'type', value)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx deleted file mode 100644 index 81bc6931f74..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** @vitest-environment jsdom */ -import { act, type ComponentProps } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ToolUsageControl } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control' - -vi.mock( - '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input', - () => ({ ShortInput: () => null }) -) - -let root: Root | undefined -let container: HTMLDivElement | undefined - -function mount(overrides: Partial> = {}) { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) - vi.useFakeTimers() - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - const onFixedChange = vi.fn() - act(() => - root?.render( - - ) - ) - const trigger = container.querySelector( - 'button[aria-label="Permission Mode"]' - )! - return { trigger, onFixedChange } -} - -async function key(node: Element, key: string) { - act(() => { - node.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) - }) - await act(async () => { - await vi.advanceTimersByTimeAsync(10) - }) -} - -afterEach(() => { - act(() => root?.unmount()) - container?.remove() - document.body.removeAttribute('style') - vi.useRealTimers() -}) - -describe('tool permission selection', () => { - it('keeps hints in the menu, skips unsupported Force and reports the stored value', async () => { - const { trigger, onFixedChange } = mount() - expect(trigger.textContent).toBe('Auto') - await key(trigger, 'ArrowDown') - const force = [...document.querySelectorAll('[role="menuitem"]')].find((item) => - item.textContent?.startsWith('Force') - )! - expect(force.textContent).toContain('(not supported by model)') - expect(force.getAttribute('aria-disabled')).toBe('true') - await key(document.activeElement!, 'ArrowDown') - expect(document.activeElement?.textContent).toBe('None(disable tool)') - await key(document.activeElement!, 'Enter') - expect(onFixedChange).toHaveBeenCalledExactlyOnceWith('none') - expect(document.querySelector('[role="menu"]')).toBeNull() - expect(document.activeElement).toBe(trigger) - }) - - it('enables supported Force and closes Escape without changing the value', async () => { - const { trigger, onFixedChange } = mount({ supportsForce: true }) - await key(trigger, 'ArrowDown') - await key(document.activeElement!, 'ArrowDown') - expect(document.activeElement?.textContent).toBe('Force(always use)') - await key(document.activeElement!, 'Escape') - expect(onFixedChange).not.toHaveBeenCalled() - expect(document.activeElement).toBe(trigger) - }) -}) 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 118af26e4dc..684602b559c 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,4 +1,4 @@ -import { ChipSelect, Label } from '@sim/emcn' +import { Combobox, Label } from '@sim/emcn' import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility' import type { StoredTool } from '@/lib/workflows/tool-input/types' import { FieldModeToggle } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/field-mode-toggle/field-mode-toggle' @@ -41,7 +41,7 @@ function isUsageControlValue(value: string): value is UsageControlValue { * 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, `ChipSelect`, and `ShortInput` as every other sub-block field, so + * 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({ @@ -83,36 +83,25 @@ export function ToolUsageControl({ workflowSearchValuePath={[toolIndex, 'usageControlExpression']} /> ) : ( - { const unsupported = option.value === 'force' && !supportsForce return { value: option.value, - label: ( - - {option.label} - - {unsupported ? '(not supported by model)' : option.hint} - + label: option.label, + disabled: unsupported, + suffixElement: ( + + {unsupported ? '(not supported by model)' : option.hint} ), - searchTerms: [option.label] as const, - disabled: unsupported, } })} value={tool.usageControl ?? 'auto'} - displayLabel={ - MODE_OPTIONS.find((option) => option.value === (tool.usageControl ?? 'auto'))?.label - } 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/components/variables-input/variables-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx index f41469e6bf5..84b832a7cbb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/variables-input/variables-input.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react' import { Badge, Button, - ChipSelect, CollapsibleCard, Combobox, type ComboboxOption, @@ -475,19 +474,13 @@ export function VariablesInput({ )}
{assignment.type === 'boolean' && !isManualBoolean ? ( - !isReadOnly && updateAssignment(assignment.id, { value: v })} placeholder='Select value' disabled={isReadOnly} - displayLabel={ + overlayContent={ booleanLabelHighlight ? ( {formatDisplayText(assignment.value ?? '', {