From 5184bd58731f8f40b89b56645ed859ecce57cb2b Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 14:27:54 +0200 Subject: [PATCH 1/6] feat(ai-studio): the Human task panel groups title and description --- .../decision-form-control.test.tsx | 5 +- .../nodes/human-decision/uischema.test.tsx | 123 ++++++++++++++++++ .../src/nodes/human-decision/uischema.ts | 21 ++- 3 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 apps/ai-studio/src/nodes/human-decision/uischema.test.tsx diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx index 2d763905e..76ca079be 100644 --- a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-control.test.tsx @@ -955,7 +955,10 @@ describe.each([ expect(fieldOf('Reply draft')?.value).toBe('Dear customer'); expect(fieldOf('Reply draft')?.disabled).toBe(true); expect(labelled('Decision')).toBeUndefined(); - expect(container.querySelectorAll('button')).toHaveLength(0); + const outsideSectionHeaders = [...container.querySelectorAll('button')].filter( + (button) => button.closest('[aria-expanded]') === null, + ); + expect(outsideSectionHeaders).toHaveLength(0); }); it('shows a rejection with its reason and the proposal it turned down', () => { diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx new file mode 100644 index 000000000..2f3371488 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx @@ -0,0 +1,123 @@ +import { useChangesTrackerStore, useStore } from '@workflowbuilder/sdk'; +import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { humanDecisionNodeType, humanDecisionPaletteItem } from '.'; +// SDK internals by path: the public API mounts these only inside a whole . +import { registerCustomRenderers } from '../../../../../packages/sdk/src/features/json-form/extension-registry'; +import { NodeProperties } from '../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties'; +import { decisionFieldsRenderer } from '../../components/human-decision/decision-fields/decision-fields-control'; +import { decisionFormRenderer } from '../../components/human-decision/decision-form/decision-form-control'; +import { resetExecution } from '../../stores/use-execution-store'; +import { defaultPropertiesData } from './default-properties-data'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]); + +const HUMAN = 'human-1'; + +const human: WorkflowBuilderNode = { + id: HUMAN, + type: humanDecisionNodeType, + position: { x: 350, y: 0 }, + data: { + segments: [], + properties: { ...defaultPropertiesData, label: 'Review Refund', description: 'Checks the refund' }, + type: humanDecisionNodeType, + icon: 'UserCheck', + }, +}; + +const edges: WorkflowBuilderEdge[] = []; + +function Host() { + const node = useStore((state) => state.nodes.find((candidate) => candidate.id === HUMAN)); + return node ? : null; +} + +const storedProperties = () => useStore.getState().nodes.find((node) => node.id === HUMAN)?.data.properties; + +// JsonForms debounces onChange by 10 ms. +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + }); + +// The editor's text controls keep the typing locally and hand the value over on blur. +function commit(element: HTMLInputElement, text: string) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(element, text); + act(() => { + element.dispatchEvent(new Event('input', { bubbles: true })); + }); + act(() => { + element.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); +} + +describe('the Human task properties panel', () => { + let container: HTMLDivElement; + let root: ReturnType; + let dataUpdates = 0; + let unsubscribe: () => void; + + beforeEach(async () => { + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + dataUpdates = 0; + unsubscribe = useChangesTrackerStore.subscribe((state) => { + if (state.lastChangeName === 'dataUpdate') dataUpdates += 1; + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + useStore.setState({ + nodes: [human], + edges, + selectedNodesIds: [HUMAN], + selectedEdgesIds: [], + data: [humanDecisionPaletteItem as never], + }); + act(() => root.render()); + await settle(); + }); + + afterEach(() => { + unsubscribe(); + act(() => root.unmount()); + container.remove(); + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + }); + + const sectionHeaders = () => [...container.querySelectorAll('[aria-expanded]')].map((element) => element.textContent); + const input = (placeholder: string) => { + const found = container.querySelector(`input[placeholder="${placeholder}"]`); + if (!found) throw new Error(`no field with the placeholder ${placeholder}`); + return found; + }; + + it('opens with General information, above the fields the decider sees', () => { + expect(sectionHeaders().slice(0, 2)).toEqual(['General information', 'Fields the decider sees']); + }); + + it("groups the node's title and description, and no Status field", () => { + expect(input('Node Title...').value).toBe('Review Refund'); + expect(input('Type your description here...').value).toBe('Checks the refund'); + expect(container.textContent).not.toContain('Status'); + }); + + it('a description is stored on the node as one undo step, leaving the request alone', async () => { + commit(input('Type your description here...'), 'A person approves the refund'); + await settle(); + + expect(storedProperties()?.['description']).toBe('A person approves the refund'); + expect(storedProperties()?.['decisionRequest']).toBe(defaultPropertiesData.decisionRequest); + expect(dataUpdates).toBe(1); + }); +}); diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.ts b/apps/ai-studio/src/nodes/human-decision/uischema.ts index 877b3a190..7573e91b6 100644 --- a/apps/ai-studio/src/nodes/human-decision/uischema.ts +++ b/apps/ai-studio/src/nodes/human-decision/uischema.ts @@ -9,11 +9,24 @@ const scope = getScope; export const uischema: UISchema = { type: 'VerticalLayout', elements: [ + // Not the SDK's `generalInformation`: it adds a Status field and shows only on a node with a `type`. { - type: 'Text', - scope: scope('properties.label'), - label: 'Title', - placeholder: 'Node Title...', + type: 'Accordion', + label: 'General information', + elements: [ + { + type: 'Text', + scope: scope('properties.label'), + label: 'Title', + placeholder: 'Node Title...', + }, + { + type: 'Text', + scope: scope('properties.description'), + label: 'Description', + placeholder: 'Type your description here...', + }, + ], }, // Custom elements: the `UISchema` union is closed (follow-up: uischema-custom-element-typing). { From 40dbb60615e1048f945842255cf9fe2b1f581ca8 Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 14:39:13 +0200 Subject: [PATCH 2/6] feat(ai-studio): the decision fields hints come in info and warning --- .../decision-fields-control.module.css | 10 ---- .../decision-fields-control.test.tsx | 52 +++++++++++++------ .../decision-fields-control.tsx | 23 +++++--- .../human-decision/hint/hint.module.css | 34 ++++++++++++ .../human-decision/hint/hint.test.tsx | 49 +++++++++++++++++ .../components/human-decision/hint/hint.tsx | 22 ++++++++ 6 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 apps/ai-studio/src/components/human-decision/hint/hint.module.css create mode 100644 apps/ai-studio/src/components/human-decision/hint/hint.test.tsx create mode 100644 apps/ai-studio/src/components/human-decision/hint/hint.tsx diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css index 73a5894cf..38c70f02d 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css @@ -5,13 +5,3 @@ /* The accordion's grid track grows to the widest label; this keeps it at the panel's width. */ contain: inline-size; } - -.hint { - composes: wb-text-body-s from global; - - margin: 0; - padding: 0.75rem; - border-radius: var(--wb-ds-radius-75); - background: var(--wb-ds-ui-bg-inset-subtle); - color: var(--wb-ds-ui-text-muted-default); -} diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx index 72b0ec109..613dca255 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx @@ -309,24 +309,45 @@ describe('the decision fields control in the real properties panel', () => { schema: { type: 'object', properties: { replyDraft: { type: 'string', title: 'Reply draft' } } }, }; const rowLabels = () => rows().map((row) => row.querySelector('span')?.textContent); - - it('with nothing connected, says to connect a block and keeps listing the stored fields', async () => { + const hints = () => + [...container.querySelectorAll('[data-hint]')].map((hint) => ({ + variant: hint.dataset['hint'], + text: hint.textContent, + })); + + const UNCONNECTED = + 'Nothing leads into this block yet. Connect a block before it — its output fields will appear here (e.g. the AI step). Run won’t start until this block has an incoming connection.'; + const NO_FIELDS = + 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.'; + + it('with nothing connected, a neutral hint says nothing leads in, and keeps listing the stored fields', async () => { await renderPanel([agent('draft-1', refundOutput), human(stored)], []); - expect(container.textContent).toContain('Connect a block before this one'); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([{ variant: 'neutral', text: UNCONNECTED }]); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); - it('with two predecessors and no declared source, says several blocks lead in and lists the stored fields', async () => { + it('with nothing connected, Run does not start, as the hint says: the backend refuses the snapshot', async () => { + await renderPanel([agent('draft-1', refundOutput), human(stored)], []); + + const { nodes, edges } = useStore.getState(); + const parsed = workflowSnapshotSchema.safeParse(structuredClone({ nodes, edges })); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain('source_missing'); + }); + + it('with two predecessors and no declared source, a neutral hint says several blocks lead in', async () => { await renderPanel( [agent('draft-1', refundOutput), agent('draft-2', refundOutput), human(stored)], [edge('draft-1'), edge('draft-2')], ); - expect(container.textContent).toContain('Several blocks lead into this one'); - expect(container.textContent).not.toContain('Connect a block before this one'); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([ + { + variant: 'neutral', + text: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.', + }, + ]); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); @@ -334,8 +355,7 @@ describe('the decision fields control in the real properties panel', () => { const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' }; await renderPanel([agent('draft-1', refundOutput), human(ghost)], [edge('draft-1')]); - expect(container.textContent).not.toContain('declares no output fields'); - expect(container.textContent).not.toContain('Connect a block before this one'); + expect(hints()).toEqual([]); expect(rowLabels()).toEqual([ 'Refund amount (not in the source)', 'Order date (not in the source)', @@ -347,8 +367,7 @@ describe('the decision fields control in the real properties panel', () => { const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' }; await renderPanel([agent('draft-1', refundOutput), human(ghost)], []); - expect(container.textContent).toContain('Connect a block before this one'); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([{ variant: 'neutral', text: UNCONNECTED }]); }); describe('on the "Refund Review" template', () => { @@ -389,7 +408,7 @@ describe('the decision fields control in the real properties panel', () => { expect(rowLabels()).toEqual(['Refund amount', 'Order date', 'Reply draft', 'Internal reasoning']); expect(selects().map((select) => select.value)).toEqual(['required', 'readOnly', 'editable', 'hidden']); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([]); }); it('as shipped, the backend refuses an edit to the read-only and the hidden field and takes the rest', async () => { @@ -459,11 +478,10 @@ describe('the decision fields control in the real properties panel', () => { node.id === 'draft-1' ? { ...node, data: { ...node.data, properties: change(node.data.properties) } } : node, ); - it('with the draft on Plain text, says it declares no fields and keeps listing the stored ones', async () => { + it('with the draft on Plain text, a warning says it declares no fields, and the stored ones stay listed', async () => { await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: undefined }))); - expect(container.textContent).toContain('declares no output fields'); - expect(container.textContent).not.toContain('Connect a block before this one'); + expect(hints()).toEqual([{ variant: 'warning', text: NO_FIELDS }]); expect(rowLabels()).toEqual([ 'Refund amount (not in the source)', 'Order date (not in the source)', @@ -476,7 +494,7 @@ describe('the decision fields control in the real properties panel', () => { await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: amountOnly }))); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([]); expect(rowLabels()).toEqual([ 'Refund amount', 'Order date (not in the source)', diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx index b9c619bf2..956e0ce9a 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx @@ -21,14 +21,25 @@ import { withFieldMode, } from '../../../utils/human-decision/decision-fields'; import { readDecisionRequest } from '../../../utils/human-decision/decision-request'; +import { Hint, type HintVariant } from '../hint/hint'; import { FieldModeRow } from './field-mode-row'; +// The backend refuses a Human task with no predecessor, so `unconnected` may say Run won't start; +// nothing refuses an empty form yet, so `noFields` promises nothing about Run. const HINTS = { - unconnected: 'Connect a block before this one — its output fields will appear here (e.g. the AI step).', - ambiguous: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.', - noFields: - 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.', -} satisfies Record; + unconnected: { + variant: 'neutral', + text: 'Nothing leads into this block yet. Connect a block before it — its output fields will appear here (e.g. the AI step). Run won’t start until this block has an incoming connection.', + }, + ambiguous: { + variant: 'neutral', + text: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.', + }, + noFields: { + variant: 'warning', + text: 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.', + }, +} satisfies Record; function DecisionFieldsControl({ data, handleChange, path, enabled, label }: ControlProps) { const nodeId = useSingleSelectedElement()?.node?.id; @@ -61,7 +72,7 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con return (
- {hint &&

{HINTS[hint]}

} + {hint && {HINTS[hint].text}} {rows.map((row) => ( { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +describe('Hint', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = (variant: HintVariant) => act(() => root.render(Some guidance)); + const hint = () => container.querySelector('[data-hint]'); + const icon = () => container.querySelector('[data-icon]')?.dataset['icon']; + + it.each([ + ['neutral', undefined], + ['info', 'Info'], + ['warning', 'Warning'], + ] as const)('the %s variant shows the text with the %s icon', (variant, expectedIcon) => { + render(variant); + + expect(hint()?.dataset['hint']).toBe(variant); + expect(hint()?.textContent).toBe('Some guidance'); + expect(icon()).toBe(expectedIcon); + }); + + // A switch or a new edge changes the hint in place, and a screen reader hears the new text. + it('is a status region', () => { + render('info'); + + expect(hint()?.getAttribute('role')).toBe('status'); + }); +}); diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.tsx new file mode 100644 index 000000000..0e505cfc9 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/hint/hint.tsx @@ -0,0 +1,22 @@ +import { Icon } from '@workflowbuilder/sdk'; +import clsx from 'clsx'; +import type { ReactNode } from 'react'; + +import styles from './hint.module.css'; + +export type HintVariant = 'neutral' | 'info' | 'warning'; + +const ICONS = { neutral: undefined, info: 'Info', warning: 'Warning' } as const; + +type Props = { variant: HintVariant; children: ReactNode }; + +/** A note in a properties section: neutral for guidance, info for how the node behaves, warning for a gap. */ +export function Hint({ variant, children }: Props) { + const icon = ICONS[variant]; + return ( +

+ {icon && } + {children} +

+ ); +} From 6b323bcd6333821738369cfcef416a3700d3bc77 Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 15:22:49 +0200 Subject: [PATCH 3/6] feat(ai-studio): the author picks what the decider can do --- apps/ai-studio/src/app/app.tsx | 5 +- .../decision-actions-control.module.css | 34 ++ .../decision-actions-control.test.tsx | 305 ++++++++++++++++++ .../decision-actions-control.tsx | 104 ++++++ .../human-decision/default-properties-data.ts | 22 +- .../nodes/human-decision/uischema.test.tsx | 7 +- .../src/nodes/human-decision/uischema.ts | 7 +- .../human-decision/decision-actions.test.ts | 36 ++- .../utils/human-decision/decision-actions.ts | 22 ++ 9 files changed, 528 insertions(+), 14 deletions(-) create mode 100644 apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css create mode 100644 apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx create mode 100644 apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx index 50ffc1a00..d4147cbb8 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -10,6 +10,7 @@ import { AiStudioControls } from '../components/controls/ai-studio-controls'; import { DisclaimerModal } from '../components/disclaimer/disclaimer-modal'; import { ExecutionHighlighting } from '../components/execution/highlighting'; import { ExecutionLogPanel } from '../components/execution/log-panel'; +import { decisionActionsRenderer } from '../components/human-decision/decision-actions/decision-actions-control'; import { decisionFieldsRenderer } from '../components/human-decision/decision-fields/decision-fields-control'; import { decisionFormRenderer } from '../components/human-decision/decision-form/decision-form-control'; import { HumanDecisionNodeTemplate } from '../components/human-decision/node-template/human-decision-template'; @@ -26,7 +27,9 @@ const flagship = supportTriageFlow.value; // Module-level: `nodeTemplates` must keep the same reference across renders. const nodeTemplates = { [humanDecisionNodeType]: HumanDecisionNodeTemplate }; -const jsonForm = { renderers: [decisionFormRenderer, responseControlRenderer, decisionFieldsRenderer] }; +const jsonForm = { + renderers: [decisionFormRenderer, responseControlRenderer, decisionFieldsRenderer, decisionActionsRenderer], +}; // A start node is where the run begins, so it can never be a connection target. const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode; diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css new file mode 100644 index 000000000..ae9f246b6 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css @@ -0,0 +1,34 @@ +.actions { + display: flex; + flex-direction: column; + gap: var(--wb-ds-space-100); +} + +.row { + display: flex; + gap: var(--wb-ds-space-100); + align-items: center; + min-height: 2.25rem; +} + +.label { + composes: wb-text-body-s from global; + + flex: 1; + min-width: 0; + color: var(--wb-ds-ui-text-default); +} + +.note { + composes: wb-text-label-s from global; + + color: var(--wb-ds-ui-text-muted-default); + white-space: nowrap; +} + +.static { + composes: wb-text-body-s from global; + + color: var(--wb-ds-ui-text-muted-default); + white-space: nowrap; +} diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx new file mode 100644 index 000000000..5b24ece99 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx @@ -0,0 +1,305 @@ +import { getHandleId, useChangesTrackerStore, useStore } from '@workflowbuilder/sdk'; +import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// SDK internals by path: the public API mounts these only inside a whole . +import { registerCustomRenderers } from '../../../../../../packages/sdk/src/features/json-form/extension-registry'; +import { NodeProperties } from '../../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties'; +// The real receiver, not a copy, as in ../../../nodes/human-decision/decision-request-contract.test.ts +// (follow-up: decision-request-contract-test-home). +import { decisionRequestSchema } from '../../../../../backend/src/domain/decision/decision-request-schema'; +import { refundReviewFlow } from '../../../data/refund-review-flow'; +import { useRunLocksCanvas } from '../../../hooks/use-run-locks-canvas'; +import { humanDecisionNodeType, humanDecisionPaletteItem } from '../../../nodes/human-decision'; +import { defaultDecisionRequest, defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; +import { executionEvent as event } from '../../../stores/execution-event.fixture'; +import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store'; +import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control'; +import { decisionFormRenderer } from '../decision-form/decision-form-control'; +import { decisionActionsRenderer } from './decision-actions-control'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); + +const HUMAN = 'human-1'; +const REJECTED = getHandleId({ handleType: 'source', innerId: 'rejected' }); + +const [approveAction] = defaultDecisionRequest.actions; +const withoutReject = { ...defaultDecisionRequest, actions: [approveAction] }; + +function human(decisionRequest: unknown): WorkflowBuilderNode { + return { + id: HUMAN, + type: humanDecisionNodeType, + position: { x: 350, y: 0 }, + data: { + segments: [], + properties: { label: 'Review Refund', description: '', decisionRequest }, + type: humanDecisionNodeType, + icon: 'UserCheck', + }, + }; +} + +function edgeFrom(sourceHandle: string): WorkflowBuilderEdge { + return { + id: `edge-${sourceHandle}`, + source: HUMAN, + sourceHandle, + target: 'next-1', + targetHandle: 'target', + type: 'labelEdge', + data: {}, + }; +} + +function Host() { + const node = useStore((state) => state.nodes.find((candidate) => candidate.id === HUMAN)); + return node ? : null; +} + +function RunLock() { + useRunLocksCanvas(); + return null; +} + +const storedRequest = () => + useStore.getState().nodes.find((node) => node.id === HUMAN)?.data.properties['decisionRequest'] as + | typeof defaultDecisionRequest + | undefined; + +// JsonForms debounces onChange by 10 ms. +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + }); + +const SINGLE = 'Reject is off, so the gate has a single output: Approved — the decider can only approve.'; +const UNWIRED = + '“Rejected” has no path yet. That is not a validation error — a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.'; +const WIRED = + 'Both outputs carry equal weight. An unwired “Rejected” output is not a validation error — a run that reaches it ends as rejected.'; + +describe('the decider actions control in the real properties panel', () => { + let container: HTMLDivElement; + let root: ReturnType; + let dataUpdates = 0; + let unsubscribe: () => void; + + beforeEach(() => { + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + dataUpdates = 0; + unsubscribe = useChangesTrackerStore.subscribe((state) => { + if (state.lastChangeName === 'dataUpdate') dataUpdates += 1; + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + unsubscribe(); + act(() => root.unmount()); + container.remove(); + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + }); + + async function renderPanel(nodes: WorkflowBuilderNode[], edges: WorkflowBuilderEdge[] = []) { + useStore.setState({ + nodes, + edges, + selectedNodesIds: [HUMAN], + selectedEdgesIds: [], + data: [humanDecisionPaletteItem as never], + }); + act(() => root.render()); + await settle(); + } + + const row = (action: string) => container.querySelector(`[data-decider-action="${action}"]`); + const toggle = (action: string) => row(action)?.querySelector('[role="switch"]') ?? null; + const isOn = (action: string) => toggle(action)?.getAttribute('aria-checked') === 'true'; + const checkbox = (action: string) => row(action)?.querySelector('input[type="checkbox"]'); + const nameOf = (action: string) => { + const id = toggle(action)?.getAttribute('aria-labelledby') ?? ''; + return container.querySelector(`[id="${id}"]`)?.textContent; + }; + const hints = () => + [...container.querySelectorAll('[data-decider-actions] [data-hint]')].map((hint) => ({ + variant: hint.dataset['hint'], + text: hint.textContent, + })); + const sectionHeader = () => + [...container.querySelectorAll('[aria-expanded]')].find((element) => element.textContent === 'Decider actions'); + + // The switch forwards a click to a hidden checkbox; jsdom does not run that forwarding, so the test clicks it. + async function flip(action: string) { + const target = checkbox(action); + if (!target) throw new Error(`no switch for ${action}`); + act(() => { + target.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await settle(); + } + + it('sits under the fields the decider sees', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + const headers = [...container.querySelectorAll('[aria-expanded]')].map((element) => element.textContent); + expect(headers.indexOf('Decider actions')).toBe(headers.indexOf('Fields the decider sees') + 1); + }); + + it('on a node fresh from the palette: Approve always on, Reject on, a reason required, no path from Rejected', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + expect(row('approve')?.textContent).toBe('ApproveAlways on'); + expect(toggle('approve')).toBeNull(); + expect(isOn('reject')).toBe(true); + expect(row('reject')?.textContent).not.toContain('single output'); + expect(isOn('reasonRequired')).toBe(true); + expect(row('reasonRequired')?.textContent).not.toContain('optional'); + expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + }); + + it('names each switch after its row', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + expect(nameOf('reject')).toBe('Reject'); + expect(nameOf('reasonRequired')).toBe('Reason required'); + }); + + it('with an edge from the Rejected port, says both outputs carry equal weight', async () => { + await renderPanel([human(defaultDecisionRequest)], [edgeFrom(REJECTED)]); + + expect(hints()).toEqual([{ variant: 'info', text: WIRED }]); + }); + + it('an edge from the Approved port alone leaves Rejected without a path', async () => { + await renderPanel([human(defaultDecisionRequest)], [edgeFrom(approveAction.port)]); + + expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + }); + + it('turning Reject off is one undo step: the request keeps Approve alone and the rest as it was', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + await flip('reject'); + + expect(dataUpdates).toBe(1); + expect(storedRequest()?.actions).toEqual([approveAction]); + expect(storedRequest()?.schema).toBe(defaultDecisionRequest.schema); + expect(isOn('reject')).toBe(false); + expect(row('reject')?.textContent).toContain('single output'); + expect(row('reasonRequired')).toBeNull(); + expect(hints()).toEqual([{ variant: 'info', text: SINGLE }]); + }); + + it('turning Reject on is one undo step and adds it back on the fixed Rejected port, a reason required', async () => { + await renderPanel([human(withoutReject)]); + + await flip('reject'); + + expect(dataUpdates).toBe(1); + expect(storedRequest()?.actions).toEqual([approveAction, defaultRejectAction]); + expect(storedRequest()?.actions[1]).toMatchObject({ port: REJECTED, reasonRequired: true }); + expect(isOn('reasonRequired')).toBe(true); + expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + }); + + it('Reason required off is one undo step, stores it and notes the reason is optional; on again requires it', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + await flip('reasonRequired'); + + expect(dataUpdates).toBe(1); + expect(storedRequest()?.actions[1]).toEqual({ ...defaultRejectAction, reasonRequired: false }); + expect(isOn('reasonRequired')).toBe(false); + expect(row('reasonRequired')?.textContent).toContain('optional'); + + await flip('reasonRequired'); + + expect(dataUpdates).toBe(2); + expect(storedRequest()?.actions[1]).toEqual(defaultRejectAction); + }); + + it.each([ + ['Reject off', 'reject', defaultDecisionRequest], + ['Reject on', 'reject', withoutReject], + ['Reason required off', 'reasonRequired', defaultDecisionRequest], + ] as const)('%s stores a request the backend takes', async (_name, action, request) => { + await renderPanel([human(request)]); + + await flip(action); + + const parsed = decisionRequestSchema.safeParse(storedRequest()); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + }); + + it('on the "Refund Review" template, Rejected has a path, and turning Reject off keeps the request valid', async () => { + const { nodes, edges } = refundReviewFlow.value.diagram; + await renderPanel(nodes, edges); + + expect(hints()).toEqual([{ variant: 'info', text: WIRED }]); + + await flip('reject'); + + expect(decisionRequestSchema.safeParse(storedRequest()).success).toBe(true); + expect(hints()).toEqual([{ variant: 'info', text: SINGLE }]); + }); + + it.todo('turning Reject off removes the edge from the Rejected port in the same undo step'); + + it('locks the switches while the canvas is in the app bar read-only mode', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + act(() => useStore.getState().setToggleReadOnlyMode(true)); + expect(toggle('reject')?.hasAttribute('data-disabled')).toBe(true); + expect(toggle('reasonRequired')?.hasAttribute('data-disabled')).toBe(true); + expect(checkbox('reject')?.disabled).toBe(true); + + act(() => useStore.getState().setToggleReadOnlyMode(false)); + expect(toggle('reject')?.hasAttribute('data-disabled')).toBe(false); + expect(checkbox('reject')?.disabled).toBe(false); + }); + + it('steps aside from Run until Reset, section header included, even with the canvas lock lifted', async () => { + await renderPanel([human(defaultDecisionRequest)]); + act(() => + root.render( + <> + + + , + ), + ); + expect(sectionHeader()).toBeDefined(); + + act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream')); + expect(sectionHeader()).toBeUndefined(); + + act(() => useStore.getState().setToggleReadOnlyMode(false)); + act(() => applyEvent(event({ type: 'node_waiting', nodeId: HUMAN }))); + expect(sectionHeader()).toBeUndefined(); + + act(() => applyEvent(event({ type: 'execution_completed' }))); + expect(sectionHeader()).toBeUndefined(); + + act(() => resetExecution()); + expect(sectionHeader()).toBeDefined(); + expect(isOn('reject')).toBe(true); + }); + + it('renders nothing for a request without a resume action', async () => { + await renderPanel([human({ ...defaultDecisionRequest, actions: [defaultRejectAction] })]); + + expect(sectionHeader()).toBeUndefined(); + }); +}); diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx new file mode 100644 index 000000000..9c53ab89c --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx @@ -0,0 +1,104 @@ +import { + rankWith, + uiTypeIs, + useSingleSelectedElement, + useStore, + withJsonFormsControlProps, +} from '@workflowbuilder/sdk'; +import type { ControlProps, JsonFormsRendererExtension } from '@workflowbuilder/sdk'; +import { Accordion, Switch } from '@workflowbuilder/ui'; +import { useId } from 'react'; + +import styles from './decision-actions-control.module.css'; + +import { defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; +import { useExecutionStore } from '../../../stores/use-execution-store'; +import { rejectPortOf, withReasonRequired, withReject } from '../../../utils/human-decision/decision-actions'; +import { readDecisionRequest } from '../../../utils/human-decision/decision-request'; +import { isPlainObject } from '../../../utils/is-plain-object'; +import { Hint } from '../hint/hint'; + +const OUTPUTS_HINTS = { + single: 'Reject is off, so the gate has a single output: Approved — the decider can only approve.', + unwired: + '“Rejected” has no path yet. That is not a validation error — a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.', + wired: + 'Both outputs carry equal weight. An unwired “Rejected” output is not a validation error — a run that reaches it ends as rejected.', +}; + +function DecisionActionsControl({ data, handleChange, path, enabled, label }: ControlProps) { + const nodeId = useSingleSelectedElement()?.node?.id; + const request = readDecisionRequest(data); + const actions: readonly unknown[] = isPlainObject(data) && Array.isArray(data['actions']) ? data['actions'] : []; + const rejectPort = rejectPortOf(actions); + const rejectWired = useStore( + (state) => + rejectPort !== undefined && + state.edges.some((edge) => edge.source === nodeId && edge.sourceHandle === rejectPort), + ); + // From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock. + const isRunShown = useExecutionStore((state) => state.executionId !== undefined); + const rejectLabelId = useId(); + const reasonLabelId = useId(); + + if (request === undefined || isRunShown) { + return null; + } + + const reject = request.actions.reject; + const toggleReject = (on: boolean) => + // Turning Reject off leaves the edge from its port: the SDK drops a removed handle's edges only under a + // `sourceHandle` key, and an action keeps it under `port`. The fix belongs there, to stay one undo step + // (follow-up: decision-reject-edge-cleanup). + handleChange(path, { ...data, actions: withReject(actions, on, defaultRejectAction) }); + const toggleReason = (required: boolean) => + handleChange(path, { ...data, actions: withReasonRequired(actions, required) }); + + let outputs: keyof typeof OUTPUTS_HINTS = 'single'; + if (reject !== undefined) { + outputs = rejectWired ? 'wired' : 'unwired'; + } + + return ( + +
+
+ Approve + Always on +
+
+ + Reject + + {reject === undefined && single output} + +
+ {reject !== undefined && ( +
+ + Reason required + + {!reject.reasonRequired && optional} + +
+ )} + {OUTPUTS_HINTS[outputs]} +
+
+ ); +} + +export const decisionActionsRenderer: JsonFormsRendererExtension = { + tester: rankWith(5, uiTypeIs('DecisionActions')), + renderer: withJsonFormsControlProps(DecisionActionsControl), +}; diff --git a/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts index 3fd7398d2..e23d1a637 100644 --- a/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts +++ b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts @@ -1,10 +1,22 @@ import { getHandleId } from '@workflowbuilder/sdk'; import type { NodeDataProperties } from '@workflowbuilder/sdk'; -import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; +import type { + DecisionRequest, + RejectDecisionAction, +} from '@workflow-builder/types/workflow-execution/decision-request'; import type { HumanDecisionSchema } from './schema'; +/** Also what the Reject switch adds back, on the same port, so the node's "Rejected" handle keeps its id. */ +export const defaultRejectAction = { + name: 'reject', + label: 'Reject', + effect: 'reject', + port: getHandleId({ handleType: 'source', innerId: 'rejected' }), + reasonRequired: true, +} satisfies RejectDecisionAction; + export const defaultDecisionRequest = { version: 1, actions: [ @@ -14,13 +26,7 @@ export const defaultDecisionRequest = { effect: 'resume', port: getHandleId({ handleType: 'source', innerId: 'approved' }), }, - { - name: 'reject', - label: 'Reject', - effect: 'reject', - port: getHandleId({ handleType: 'source', innerId: 'rejected' }), - reasonRequired: true, - }, + defaultRejectAction, ], schema: { type: 'object', properties: {} }, } satisfies DecisionRequest; diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx index 2f3371488..5b702cf12 100644 --- a/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx +++ b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx @@ -8,6 +8,7 @@ import { humanDecisionNodeType, humanDecisionPaletteItem } from '.'; // SDK internals by path: the public API mounts these only inside a whole . import { registerCustomRenderers } from '../../../../../packages/sdk/src/features/json-form/extension-registry'; import { NodeProperties } from '../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties'; +import { decisionActionsRenderer } from '../../components/human-decision/decision-actions/decision-actions-control'; import { decisionFieldsRenderer } from '../../components/human-decision/decision-fields/decision-fields-control'; import { decisionFormRenderer } from '../../components/human-decision/decision-form/decision-form-control'; import { resetExecution } from '../../stores/use-execution-store'; @@ -18,7 +19,7 @@ vi.mock('@workflowbuilder/sdk', async (importOriginal) => { return { ...actual, Icon: ({ name }: { name: string }) => }; }); -registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]); +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); const HUMAN = 'human-1'; @@ -102,8 +103,8 @@ describe('the Human task properties panel', () => { return found; }; - it('opens with General information, above the fields the decider sees', () => { - expect(sectionHeaders().slice(0, 2)).toEqual(['General information', 'Fields the decider sees']); + it('opens with General information, then the fields the decider sees, then the decider actions', () => { + expect(sectionHeaders()).toEqual(['General information', 'Fields the decider sees', 'Decider actions']); }); it("groups the node's title and description, and no Status field", () => { diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.ts b/apps/ai-studio/src/nodes/human-decision/uischema.ts index 7573e91b6..fa3f31259 100644 --- a/apps/ai-studio/src/nodes/human-decision/uischema.ts +++ b/apps/ai-studio/src/nodes/human-decision/uischema.ts @@ -5,7 +5,7 @@ import type { HumanDecisionSchema } from './schema'; const scope = getScope; -// Authoring the rest of the request lands later (follow-up: decision-request-properties-ui). +// Deadline, rerun and action labels are not authorable yet (follow-up: decision-request-properties-ui). export const uischema: UISchema = { type: 'VerticalLayout', elements: [ @@ -34,6 +34,11 @@ export const uischema: UISchema = { scope: scope('properties.decisionRequest'), label: 'Fields the decider sees', } as unknown as UISchema, + { + type: 'DecisionActions', + scope: scope('properties.decisionRequest'), + label: 'Decider actions', + } as unknown as UISchema, // The run-time decision form. { type: 'DecisionForm', scope: scope('properties.decisionRequest') } as unknown as UISchema, ], diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts index d067ceb54..a505b3abc 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { offeredActions } from './decision-actions'; +import { offeredActions, rejectPortOf, withReasonRequired, withReject } from './decision-actions'; import { reviewRequest } from './review-request.fixture'; const [approve, reject] = reviewRequest.actions; @@ -42,3 +42,37 @@ describe('offeredActions', () => { expect(offeredActions([null, { name: '', effect: 'resume' }])).toBeUndefined(); }); }); + +describe('the reject switches', () => { + const rerun = { name: 'redraft', label: 'Ask again', effect: 'rerun-source' }; + const added = { + name: 'reject', + label: 'Reject', + effect: 'reject', + port: 'source:inner:rejected', + reasonRequired: true, + }; + + it('reads the port the reject routes on, and none without a reject', () => { + expect(rejectPortOf(reviewRequest.actions)).toBe('source:inner:rejected'); + expect(rejectPortOf([approve])).toBeUndefined(); + expect(rejectPortOf([approve, { ...reject, port: '' }])).toBeUndefined(); + }); + + it('turned off, drops the reject and keeps every other action in its place', () => { + expect(withReject([rerun, reject, approve], false, added)).toEqual([rerun, approve]); + }); + + it('turned on, appends the given reject', () => { + expect(withReject([approve], true, added)).toEqual([approve, added]); + }); + + it('turned on over a stored reject, keeps that one and its settings', () => { + expect(withReject(reviewRequest.actions, true, added)).toEqual(reviewRequest.actions); + }); + + it('sets reasonRequired on the reject alone, and leaves a request without one as it was', () => { + expect(withReasonRequired(reviewRequest.actions, true)).toEqual([approve, { ...reject, reasonRequired: true }]); + expect(withReasonRequired([approve], true)).toEqual([approve]); + }); +}); diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.ts index c8f283cb5..7b390c29c 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.ts @@ -29,3 +29,25 @@ export function offeredActions(actions: readonly unknown[]): OfferedActions | un const resume = offerOf(withEffect('resume')); return resume === undefined ? undefined : { resume, reject: rejectOfferOf(withEffect('reject')) }; } + +function isReject(entry: unknown): entry is Record { + return isPlainObject(entry) && entry['effect'] === 'reject'; +} + +/** The port the reject action routes on, when the request has one. */ +export function rejectPortOf(actions: readonly unknown[]): string | undefined { + const port = actions.find(isReject)?.['port']; + return hasText(port) ? port : undefined; +} + +/** The actions with the reject turned on or off; turned on, it is `rejectAction`, a stored reject is kept. */ +export function withReject(actions: readonly unknown[], on: boolean, rejectAction: unknown): unknown[] { + if (!on) { + return actions.filter((entry) => !isReject(entry)); + } + return actions.some(isReject) ? [...actions] : [...actions, rejectAction]; +} + +export function withReasonRequired(actions: readonly unknown[], reasonRequired: boolean): unknown[] { + return actions.map((entry) => (isReject(entry) ? { ...entry, reasonRequired } : entry)); +} From dfee2af207adb72e9df70d29ec1a61e6a034a8ae Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 18:08:08 +0200 Subject: [PATCH 4/6] fix(ai-studio): decider actions and hints review fixes --- apps/ai-studio/README.md | 1 + .../decision-actions-control.test.tsx | 41 ++++++++++--- .../decision-actions-control.tsx | 61 +++++++++++++------ .../decision-fields-control.test.tsx | 6 +- .../decision-fields-control.tsx | 13 ++-- .../decision-form-control.test.tsx | 6 +- .../human-decision/hint/hint.module.css | 5 +- .../human-decision/hint/hint.test.tsx | 15 ++++- .../components/human-decision/hint/hint.tsx | 11 +++- apps/ai-studio/src/hooks/use-is-run-shown.ts | 6 ++ .../human-decision/decision-actions.test.ts | 9 ++- .../utils/human-decision/decision-actions.ts | 28 +++++---- 12 files changed, 138 insertions(+), 64 deletions(-) create mode 100644 apps/ai-studio/src/hooks/use-is-run-shown.ts diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index 7430391d7..9a6a668e0 100644 --- a/apps/ai-studio/README.md +++ b/apps/ai-studio/README.md @@ -15,6 +15,7 @@ A complete, runnable AI workflow product built on top of the Workflow Builder SD - A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port. - The author picks what the decider's form shows. The panel lists the fields the proposal source (the named `proposalSourceNodeId`, else the single predecessor) declares under `properties.outputSchema` that the form can show (text, number, yes/no, and nullable text or yes/no; integers, nullable numbers, objects, arrays and keys with a dot or bracket are left out), each at one of four levels: Hidden, Read-only, Editable, or Editable and required. The control lives in `src/components/human-decision/decision-fields/`; from the moment the backend starts a run until Reset it is hidden, so the panel shows only the run (the decision form, then the record). - The picks are `decisionRequest.schema` itself, in the contract's format, so the canvas, the Run payload and the decider's form read one schema: a field it leaves out is Hidden, and Editable and required drops `null` from a nullable type, so a null the model left holds Approve back. A required field the decider empties holds it back too, because the backend refuses it. A required text field emptied or left with only whitespace shows no error of its own, and nothing on screen says why Approve is disabled. A field the source no longer declares stays in the schema, listed as "not in the source", until the author hides it. A stored field of a type the form cannot show gets no row, and the next pick drops it from the schema, `readOnly` and `required` included. Hidden keeps a field off the form and refuses edits to it; the value stays in the source's output, which the log panel shows and later nodes read. +- The author picks what the decider can do in Decider actions (`src/components/human-decision/decision-actions/`), hidden from Run until Reset like the fields. Approve is always on. The Reject switch removes the `reject` action, and with it the "Rejected" port; turned on again it adds the default reject back on the fixed "Rejected" port with a reason required, so a custom label, name, port or an optional reason do not survive off and on. Reason required writes `reasonRequired`. The panel's hints come in three variants: neutral for guidance (nothing or several blocks lead in), warning for a gap (the source declares no fields the form can show) and info for how the node behaves (a single output, "Rejected" without a path, or both outputs wired); only the actions hint is a live region, since its own switch changes it. Known limit: turning Reject off leaves the edge from "Rejected" in the graph, unseen on the canvas, and turning it on again brings that edge back (follow-up: decision-reject-edge-cleanup). - A rejection ends the run as a result, not a dead end: the run closes `completed`, the log panel names the outcome and who settled it, and the reject handle needs no edge. The node's output carries `resolvedBy` beside the other decision fields. The run's pill stays `completed` on purpose, a rejection being a result and not a failure; whether the panel marks it visually is for the design pass. - The person decides in the node's properties sidebar: the editor's own form over `decisionRequest.schema`, filled from the proposal source's output, with read-only fields disabled and only the changed editable fields sent as `edits`. Approve and "Reject…" sit in the panel's footer. The panel shows no Delete button for any selection, deliberately for now; deleting stays on the Delete and Backspace keys. From the moment the backend starts a run until Reset the canvas is read-only, so the form reads the graph the run executes; `use-run-locks-canvas.ts` lists the exceptions. Should undo change the picks under an open form, the form keeps the fields it opened with. - An AI Agent node that answers as structured fields: the Response format dropdown sets `properties.outputSchema` to a preset JSON Schema, the worker asks the model for that shape (see [apps/execution-worker/README.md](../execution-worker/README.md#ai-agent-structured-output)), and in Refund Review the draft's fields fill the decision form, and the reply the person approves, edits included, is what the customer gets. Plain text stays the default and returns `{ response }`. The draft's `internalReasoning` stays off the form because the decision request's schema leaves it out, and out of the confirmation only because the second agent's prompt says so; nothing downstream enforces that. A node switched to a structured format has no `response`, so a downstream `{{ nodes..response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent. diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx index 5b24ece99..4cece693a 100644 --- a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx @@ -1,4 +1,4 @@ -import { getHandleId, useChangesTrackerStore, useStore } from '@workflowbuilder/sdk'; +import { getHandleId, registerFunctionDecorator, useChangesTrackerStore, useStore } from '@workflowbuilder/sdk'; import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; @@ -14,6 +14,8 @@ import { refundReviewFlow } from '../../../data/refund-review-flow'; import { useRunLocksCanvas } from '../../../hooks/use-run-locks-canvas'; import { humanDecisionNodeType, humanDecisionPaletteItem } from '../../../nodes/human-decision'; import { defaultDecisionRequest, defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; +import { trackFutureChangeDecorator } from '../../../plugins/undo-redo/functions/decorators'; +import { undo, useUndoRedoStore } from '../../../plugins/undo-redo/stores/use-undo-redo-store'; import { executionEvent as event } from '../../../stores/execution-event.fixture'; import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store'; import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control'; @@ -26,6 +28,7 @@ vi.mock('@workflowbuilder/sdk', async (importOriginal) => { }); registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); +registerFunctionDecorator('trackFutureChange', { callback: trackFutureChangeDecorator, name: 'undoRedo' }); const HUMAN = 'human-1'; const REJECTED = getHandleId({ handleType: 'source', innerId: 'rejected' }); @@ -89,15 +92,16 @@ const WIRED = describe('the decider actions control in the real properties panel', () => { let container: HTMLDivElement; let root: ReturnType; - let dataUpdates = 0; + let trackedChanges = 0; let unsubscribe: () => void; beforeEach(() => { useStore.setState(useStore.getInitialState(), true); resetExecution(); - dataUpdates = 0; - unsubscribe = useChangesTrackerStore.subscribe((state) => { - if (state.lastChangeName === 'dataUpdate') dataUpdates += 1; + useUndoRedoStore.setState({ past: [], future: [], snapshotsWatchers: {} }); + trackedChanges = 0; + unsubscribe = useChangesTrackerStore.subscribe(() => { + trackedChanges += 1; }); container = document.createElement('div'); document.body.append(container); @@ -132,6 +136,10 @@ describe('the decider actions control in the real properties panel', () => { const id = toggle(action)?.getAttribute('aria-labelledby') ?? ''; return container.querySelector(`[id="${id}"]`)?.textContent; }; + const descriptionOf = (action: string) => { + const id = toggle(action)?.getAttribute('aria-describedby'); + return id ? container.querySelector(`[id="${id}"]`)?.textContent : undefined; + }; const hints = () => [...container.querySelectorAll('[data-decider-actions] [data-hint]')].map((hint) => ({ variant: hint.dataset['hint'], @@ -164,9 +172,12 @@ describe('the decider actions control in the real properties panel', () => { expect(toggle('approve')).toBeNull(); expect(isOn('reject')).toBe(true); expect(row('reject')?.textContent).not.toContain('single output'); + expect(descriptionOf('reject')).toBeUndefined(); expect(isOn('reasonRequired')).toBe(true); expect(row('reasonRequired')?.textContent).not.toContain('optional'); + expect(descriptionOf('reasonRequired')).toBeUndefined(); expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + expect(container.querySelector('[data-decider-actions] [data-hint]')?.getAttribute('role')).toBe('status'); }); it('names each switch after its row', async () => { @@ -193,13 +204,21 @@ describe('the decider actions control in the real properties panel', () => { await flip('reject'); - expect(dataUpdates).toBe(1); + expect(trackedChanges).toBe(1); expect(storedRequest()?.actions).toEqual([approveAction]); expect(storedRequest()?.schema).toBe(defaultDecisionRequest.schema); expect(isOn('reject')).toBe(false); expect(row('reject')?.textContent).toContain('single output'); + expect(descriptionOf('reject')).toBe('single output'); expect(row('reasonRequired')).toBeNull(); expect(hints()).toEqual([{ variant: 'info', text: SINGLE }]); + + act(() => undo()); + await settle(); + + expect(storedRequest()?.actions).toEqual(defaultDecisionRequest.actions); + expect(isOn('reject')).toBe(true); + expect(useUndoRedoStore.getState().past).toEqual([]); }); it('turning Reject on is one undo step and adds it back on the fixed Rejected port, a reason required', async () => { @@ -207,7 +226,7 @@ describe('the decider actions control in the real properties panel', () => { await flip('reject'); - expect(dataUpdates).toBe(1); + expect(trackedChanges).toBe(1); expect(storedRequest()?.actions).toEqual([approveAction, defaultRejectAction]); expect(storedRequest()?.actions[1]).toMatchObject({ port: REJECTED, reasonRequired: true }); expect(isOn('reasonRequired')).toBe(true); @@ -219,14 +238,15 @@ describe('the decider actions control in the real properties panel', () => { await flip('reasonRequired'); - expect(dataUpdates).toBe(1); + expect(trackedChanges).toBe(1); expect(storedRequest()?.actions[1]).toEqual({ ...defaultRejectAction, reasonRequired: false }); expect(isOn('reasonRequired')).toBe(false); expect(row('reasonRequired')?.textContent).toContain('optional'); + expect(descriptionOf('reasonRequired')).toBe('optional'); await flip('reasonRequired'); - expect(dataUpdates).toBe(2); + expect(trackedChanges).toBe(2); expect(storedRequest()?.actions[1]).toEqual(defaultRejectAction); }); @@ -255,7 +275,8 @@ describe('the decider actions control in the real properties panel', () => { expect(hints()).toEqual([{ variant: 'info', text: SINGLE }]); }); - it.todo('turning Reject off removes the edge from the Rejected port in the same undo step'); + it.todo('turning Reject off removes the Rejected edge in the same undo step'); + it.todo('turning Reject on again brings the Rejected port back without its old edge'); it('locks the switches while the canvas is in the app bar read-only mode', async () => { await renderPanel([human(defaultDecisionRequest)]); diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx index 9c53ab89c..1e30b4bb1 100644 --- a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx @@ -11,35 +11,44 @@ import { useId } from 'react'; import styles from './decision-actions-control.module.css'; +import { useIsRunShown } from '../../../hooks/use-is-run-shown'; import { defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; -import { useExecutionStore } from '../../../stores/use-execution-store'; import { rejectPortOf, withReasonRequired, withReject } from '../../../utils/human-decision/decision-actions'; import { readDecisionRequest } from '../../../utils/human-decision/decision-request'; -import { isPlainObject } from '../../../utils/is-plain-object'; -import { Hint } from '../hint/hint'; +import { Hint, type HintVariant } from '../hint/hint'; -const OUTPUTS_HINTS = { - single: 'Reject is off, so the gate has a single output: Approved — the decider can only approve.', - unwired: - '“Rejected” has no path yet. That is not a validation error — a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.', - wired: - 'Both outputs carry equal weight. An unwired “Rejected” output is not a validation error — a run that reaches it ends as rejected.', -}; +type OutputHint = 'single' | 'unwired' | 'wired'; + +const OUTPUT_HINTS = { + single: { + variant: 'info', + text: 'Reject is off, so the gate has a single output: Approved — the decider can only approve.', + }, + unwired: { + variant: 'info', + text: '“Rejected” has no path yet. That is not a validation error — a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.', + }, + wired: { + variant: 'info', + text: 'Both outputs carry equal weight. An unwired “Rejected” output is not a validation error — a run that reaches it ends as rejected.', + }, +} satisfies Record; function DecisionActionsControl({ data, handleChange, path, enabled, label }: ControlProps) { const nodeId = useSingleSelectedElement()?.node?.id; const request = readDecisionRequest(data); - const actions: readonly unknown[] = isPlainObject(data) && Array.isArray(data['actions']) ? data['actions'] : []; + const actions: readonly unknown[] = request === undefined ? [] : data['actions']; const rejectPort = rejectPortOf(actions); const rejectWired = useStore( (state) => rejectPort !== undefined && state.edges.some((edge) => edge.source === nodeId && edge.sourceHandle === rejectPort), ); - // From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock. - const isRunShown = useExecutionStore((state) => state.executionId !== undefined); + const isRunShown = useIsRunShown(); const rejectLabelId = useId(); + const rejectNoteId = useId(); const reasonLabelId = useId(); + const reasonNoteId = useId(); if (request === undefined || isRunShown) { return null; @@ -47,16 +56,16 @@ function DecisionActionsControl({ data, handleChange, path, enabled, label }: Co const reject = request.actions.reject; const toggleReject = (on: boolean) => - // Turning Reject off leaves the edge from its port: the SDK drops a removed handle's edges only under a - // `sourceHandle` key, and an action keeps it under `port`. The fix belongs there, to stay one undo step + // Turning Reject off leaves the edge from its port (the SDK drops edges only under a `sourceHandle` key, + // an action keeps it under `port`), and turning it on again brings that edge back // (follow-up: decision-reject-edge-cleanup). handleChange(path, { ...data, actions: withReject(actions, on, defaultRejectAction) }); const toggleReason = (required: boolean) => handleChange(path, { ...data, actions: withReasonRequired(actions, required) }); - let outputs: keyof typeof OUTPUTS_HINTS = 'single'; + let outputHint: OutputHint = 'single'; if (reject !== undefined) { - outputs = rejectWired ? 'wired' : 'unwired'; + outputHint = rejectWired ? 'wired' : 'unwired'; } return ( @@ -70,9 +79,14 @@ function DecisionActionsControl({ data, handleChange, path, enabled, label }: Co Reject - {reject === undefined && single output} + {reject === undefined && ( + + single output + + )} Reason required - {!reject.reasonRequired && optional} + {!reject.reasonRequired && ( + + optional + + )}
)} - {OUTPUTS_HINTS[outputs]} + + {OUTPUT_HINTS[outputHint].text} +
); diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx index 613dca255..5497dc4ef 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx @@ -27,6 +27,7 @@ import { useExecutionStore, } from '../../../stores/use-execution-store'; import { FIELD_MODES } from '../../../utils/human-decision/decision-fields'; +import { decisionActionsRenderer } from '../decision-actions/decision-actions-control'; import { decisionFormRenderer } from '../decision-form/decision-form-control'; import { decisionFieldsRenderer } from './decision-fields-control'; @@ -64,7 +65,7 @@ declare global { } globalThis.IS_REACT_ACT_ENVIRONMENT = true; -registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]); +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); const HUMAN = 'human-1'; @@ -310,7 +311,7 @@ describe('the decision fields control in the real properties panel', () => { }; const rowLabels = () => rows().map((row) => row.querySelector('span')?.textContent); const hints = () => - [...container.querySelectorAll('[data-hint]')].map((hint) => ({ + [...container.querySelectorAll('[data-decision-fields] [data-hint]')].map((hint) => ({ variant: hint.dataset['hint'], text: hint.textContent, })); @@ -324,6 +325,7 @@ describe('the decision fields control in the real properties panel', () => { await renderPanel([agent('draft-1', refundOutput), human(stored)], []); expect(hints()).toEqual([{ variant: 'neutral', text: UNCONNECTED }]); + expect(container.querySelector('[data-decision-fields] [data-hint]')?.hasAttribute('role')).toBe(false); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx index 956e0ce9a..cfe6f4438 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx @@ -10,8 +10,8 @@ import { Accordion } from '@workflowbuilder/ui'; import styles from './decision-fields-control.module.css'; +import { useIsRunShown } from '../../../hooks/use-is-run-shown'; import { proposalSourceIdOf } from '../../../hooks/use-node-decision'; -import { useExecutionStore } from '../../../stores/use-execution-store'; import { type FieldMode, type SourceHint, @@ -26,7 +26,7 @@ import { FieldModeRow } from './field-mode-row'; // The backend refuses a Human task with no predecessor, so `unconnected` may say Run won't start; // nothing refuses an empty form yet, so `noFields` promises nothing about Run. -const HINTS = { +const SOURCE_HINTS = { unconnected: { variant: 'neutral', text: 'Nothing leads into this block yet. Connect a block before it — its output fields will appear here (e.g. the AI step). Run won’t start until this block has an incoming connection.', @@ -56,8 +56,7 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con ? undefined : state.nodes.find((node) => node.id === sourceId)?.data.properties['outputSchema'], ); - // From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock. - const isRunShown = useExecutionStore((state) => state.executionId !== undefined); + const isRunShown = useIsRunShown(); if (request === undefined || isRunShown) { return null; @@ -65,14 +64,14 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con const { schema } = request; const rows = fieldRows(outputSchema, schema); - const hint = sourceHintOf(sourceId, predecessors.length, rows); + const sourceHint = sourceHintOf(sourceId, predecessors.length, rows); const pick = (key: string, mode: FieldMode) => handleChange(path, { ...data, schema: withFieldMode(schema, rows, key, mode) }); return ( -
- {hint && {HINTS[hint].text}} +
+ {sourceHint && {SOURCE_HINTS[sourceHint].text}} {rows.map((row) => ( button.closest('[aria-expanded]') === null, - ); - expect(outsideSectionHeaders).toHaveLength(0); + expect(button('Approve')).toBeUndefined(); + expect(button('Reject…')).toBeUndefined(); }); it('shows a rejection with its reason and the proposal it turned down', () => { diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.module.css b/apps/ai-studio/src/components/human-decision/hint/hint.module.css index 506db73ff..07a6a51c4 100644 --- a/apps/ai-studio/src/components/human-decision/hint/hint.module.css +++ b/apps/ai-studio/src/components/human-decision/hint/hint.module.css @@ -24,8 +24,11 @@ } .hint--warning { + /* missing token: a semantic --wb-ds-ui-bg-warning-* */ + --hint-warning-bg: var(--wb-ds-components-snackbar-bg-warning); + padding: var(--wb-ds-space-150); - background: var(--wb-ds-components-snackbar-bg-warning); /* missing token */ + background: var(--hint-warning-bg); color: var(--wb-ds-ui-text-warning-default); } diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx index 57719a888..7bc45d72c 100644 --- a/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx +++ b/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx @@ -24,7 +24,14 @@ describe('Hint', () => { container.remove(); }); - const render = (variant: HintVariant) => act(() => root.render(Some guidance)); + const render = (variant: HintVariant, live?: boolean) => + act(() => + root.render( + + Some guidance + , + ), + ); const hint = () => container.querySelector('[data-hint]'); const icon = () => container.querySelector('[data-icon]')?.dataset['icon']; @@ -40,10 +47,12 @@ describe('Hint', () => { expect(icon()).toBe(expectedIcon); }); - // A switch or a new edge changes the hint in place, and a screen reader hears the new text. - it('is a status region', () => { + // A selection change puts new text in the same mounted hints, which a status region would read out each time. + it('is a status region only when live', () => { render('info'); + expect(hint()?.hasAttribute('role')).toBe(false); + render('info', true); expect(hint()?.getAttribute('role')).toBe('status'); }); }); diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.tsx index 0e505cfc9..1f7af95fe 100644 --- a/apps/ai-studio/src/components/human-decision/hint/hint.tsx +++ b/apps/ai-studio/src/components/human-decision/hint/hint.tsx @@ -8,13 +8,18 @@ export type HintVariant = 'neutral' | 'info' | 'warning'; const ICONS = { neutral: undefined, info: 'Info', warning: 'Warning' } as const; -type Props = { variant: HintVariant; children: ReactNode }; +// `live` announces a new text, for a hint a switch in the same section changes in place. +type Props = { variant: HintVariant; live?: boolean; children: ReactNode }; /** A note in a properties section: neutral for guidance, info for how the node behaves, warning for a gap. */ -export function Hint({ variant, children }: Props) { +export function Hint({ variant, live = false, children }: Props) { const icon = ICONS[variant]; return ( -

+

{icon && } {children}

diff --git a/apps/ai-studio/src/hooks/use-is-run-shown.ts b/apps/ai-studio/src/hooks/use-is-run-shown.ts new file mode 100644 index 000000000..1b97946a8 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-is-run-shown.ts @@ -0,0 +1,6 @@ +import { useExecutionStore } from '../stores/use-execution-store'; + +/** From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock. */ +export function useIsRunShown() { + return useExecutionStore((state) => state.executionId !== undefined); +} diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts index a505b3abc..885c526b2 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts @@ -67,10 +67,17 @@ describe('the reject switches', () => { expect(withReject([approve], true, added)).toEqual([approve, added]); }); - it('turned on over a stored reject, keeps that one and its settings', () => { + it('turned on over a reject the decider is offered, keeps that one and its settings', () => { expect(withReject(reviewRequest.actions, true, added)).toEqual(reviewRequest.actions); }); + it('turned on over a stored reject the decider is not offered, puts the given one in its place', () => { + const unnamed = { effect: 'reject', port: 'source:inner:rejected', reasonRequired: false }; + + expect(offeredActions([approve, unnamed])?.reject).toBeUndefined(); + expect(withReject([approve, unnamed], true, added)).toEqual([approve, added]); + }); + it('sets reasonRequired on the reject alone, and leaves a request without one as it was', () => { expect(withReasonRequired(reviewRequest.actions, true)).toEqual([approve, { ...reject, reasonRequired: true }]); expect(withReasonRequired([approve], true)).toEqual([approve]); diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.ts index 7b390c29c..cc7de091d 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.ts @@ -22,30 +22,32 @@ function rejectOfferOf(entry: Record | undefined): RejectOffer return offer === undefined ? undefined : { ...offer, reasonRequired: entry?.['reasonRequired'] === true }; } -// Authored node data: only the array is proven. A `rerun-source` action is left out: the endpoint answers 501 for it. -export function offeredActions(actions: readonly unknown[]): OfferedActions | undefined { - const entries = actions.filter(isPlainObject); - const withEffect = (effect: string) => entries.find((entry) => entry['effect'] === effect); - const resume = offerOf(withEffect('resume')); - return resume === undefined ? undefined : { resume, reject: rejectOfferOf(withEffect('reject')) }; -} - function isReject(entry: unknown): entry is Record { return isPlainObject(entry) && entry['effect'] === 'reject'; } -/** The port the reject action routes on, when the request has one. */ +function offeredReject(actions: readonly unknown[]): RejectOffer | undefined { + return rejectOfferOf(actions.find(isReject)); +} + +// Authored node data: only the array is proven. A `rerun-source` action is left out: the endpoint answers 501 for it. +export function offeredActions(actions: readonly unknown[]): OfferedActions | undefined { + const resume = offerOf(actions.filter(isPlainObject).find((entry) => entry['effect'] === 'resume')); + return resume === undefined ? undefined : { resume, reject: offeredReject(actions) }; +} + export function rejectPortOf(actions: readonly unknown[]): string | undefined { const port = actions.find(isReject)?.['port']; return hasText(port) ? port : undefined; } -/** The actions with the reject turned on or off; turned on, it is `rejectAction`, a stored reject is kept. */ +/** Turned on, keeps a reject the decider is offered and replaces any other stored reject with `rejectAction`. */ export function withReject(actions: readonly unknown[], on: boolean, rejectAction: unknown): unknown[] { - if (!on) { - return actions.filter((entry) => !isReject(entry)); + if (on && offeredReject(actions) !== undefined) { + return [...actions]; } - return actions.some(isReject) ? [...actions] : [...actions, rejectAction]; + const withoutReject = actions.filter((entry) => !isReject(entry)); + return on ? [...withoutReject, rejectAction] : withoutReject; } export function withReasonRequired(actions: readonly unknown[], reasonRequired: boolean): unknown[] { From 8713926ed8cdaa406b0f72d22bb35366127456f0 Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 19:08:41 +0200 Subject: [PATCH 5/6] fix(ai-studio): decider actions round 2 review fixes --- apps/ai-studio/README.md | 3 ++- .../decision-actions/decision-actions-control.tsx | 4 ++-- .../decision-form/decision-form-panel.test.tsx | 3 ++- .../components/human-decision/hint/hint.module.css | 6 ++---- .../components/human-decision/hint/hint.test.tsx | 1 - .../src/components/human-decision/hint/hint.tsx | 2 +- .../src/nodes/human-decision/uischema.test.tsx | 10 +++++----- .../utils/human-decision/decision-actions.test.ts | 13 ++++++++++--- .../src/utils/human-decision/decision-actions.ts | 9 ++++----- .../src/utils/human-decision/decision-request.ts | 8 ++++++-- 10 files changed, 34 insertions(+), 25 deletions(-) diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index 9a6a668e0..f794c34b8 100644 --- a/apps/ai-studio/README.md +++ b/apps/ai-studio/README.md @@ -15,7 +15,8 @@ A complete, runnable AI workflow product built on top of the Workflow Builder SD - A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port. - The author picks what the decider's form shows. The panel lists the fields the proposal source (the named `proposalSourceNodeId`, else the single predecessor) declares under `properties.outputSchema` that the form can show (text, number, yes/no, and nullable text or yes/no; integers, nullable numbers, objects, arrays and keys with a dot or bracket are left out), each at one of four levels: Hidden, Read-only, Editable, or Editable and required. The control lives in `src/components/human-decision/decision-fields/`; from the moment the backend starts a run until Reset it is hidden, so the panel shows only the run (the decision form, then the record). - The picks are `decisionRequest.schema` itself, in the contract's format, so the canvas, the Run payload and the decider's form read one schema: a field it leaves out is Hidden, and Editable and required drops `null` from a nullable type, so a null the model left holds Approve back. A required field the decider empties holds it back too, because the backend refuses it. A required text field emptied or left with only whitespace shows no error of its own, and nothing on screen says why Approve is disabled. A field the source no longer declares stays in the schema, listed as "not in the source", until the author hides it. A stored field of a type the form cannot show gets no row, and the next pick drops it from the schema, `readOnly` and `required` included. Hidden keeps a field off the form and refuses edits to it; the value stays in the source's output, which the log panel shows and later nodes read. -- The author picks what the decider can do in Decider actions (`src/components/human-decision/decision-actions/`), hidden from Run until Reset like the fields. Approve is always on. The Reject switch removes the `reject` action, and with it the "Rejected" port; turned on again it adds the default reject back on the fixed "Rejected" port with a reason required, so a custom label, name, port or an optional reason do not survive off and on. Reason required writes `reasonRequired`. The panel's hints come in three variants: neutral for guidance (nothing or several blocks lead in), warning for a gap (the source declares no fields the form can show) and info for how the node behaves (a single output, "Rejected" without a path, or both outputs wired); only the actions hint is a live region, since its own switch changes it. Known limit: turning Reject off leaves the edge from "Rejected" in the graph, unseen on the canvas, and turning it on again brings that edge back (follow-up: decision-reject-edge-cleanup). +- The Human task panel opens with General information, its own accordion with Title and Description (the SDK's `generalInformation` adds Status and shows only on a node with a `type`), above "Fields the decider sees" and Decider actions. +- The author picks what the decider can do in Decider actions (`src/components/human-decision/decision-actions/`), hidden from Run until Reset like the fields. Approve is always on. The Reject switch removes the `reject` action, and with it the "Rejected" port; turned on again it adds the default reject back on the fixed "Rejected" port with a reason required, so a custom label, name, port or an optional reason do not survive off and on. Reason required writes `reasonRequired`. The panel's hints come in three variants: neutral for guidance (nothing or several blocks lead in), warning for a gap (the source declares no fields the form can show) and info for how the node behaves (a single output, "Rejected" without a path, or both outputs wired); only the actions hint is a live region, so a screen reader reads its new text after a switch, and also when the selection moves to another Human task or an edge from "Rejected" changes. Known limit: turning Reject off leaves the edge from "Rejected" in the graph, unseen on the canvas but still read by the backend (a Human task it leads into, with another predecessor and no `proposalSourceNodeId`, is refused as `source_ambiguous`), and turning it on again brings that edge back (follow-up: decision-reject-edge-cleanup). - A rejection ends the run as a result, not a dead end: the run closes `completed`, the log panel names the outcome and who settled it, and the reject handle needs no edge. The node's output carries `resolvedBy` beside the other decision fields. The run's pill stays `completed` on purpose, a rejection being a result and not a failure; whether the panel marks it visually is for the design pass. - The person decides in the node's properties sidebar: the editor's own form over `decisionRequest.schema`, filled from the proposal source's output, with read-only fields disabled and only the changed editable fields sent as `edits`. Approve and "Reject…" sit in the panel's footer. The panel shows no Delete button for any selection, deliberately for now; deleting stays on the Delete and Backspace keys. From the moment the backend starts a run until Reset the canvas is read-only, so the form reads the graph the run executes; `use-run-locks-canvas.ts` lists the exceptions. Should undo change the picks under an open form, the form keeps the fields it opened with. - An AI Agent node that answers as structured fields: the Response format dropdown sets `properties.outputSchema` to a preset JSON Schema, the worker asks the model for that shape (see [apps/execution-worker/README.md](../execution-worker/README.md#ai-agent-structured-output)), and in Refund Review the draft's fields fill the decision form, and the reply the person approves, edits included, is what the customer gets. Plain text stays the default and returns `{ response }`. The draft's `internalReasoning` stays off the form because the decision request's schema leaves it out, and out of the confirmation only because the second agent's prompt says so; nothing downstream enforces that. A node switched to a structured format has no `response`, so a downstream `{{ nodes..response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent. diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx index 1e30b4bb1..52b4aa043 100644 --- a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx @@ -14,7 +14,7 @@ import styles from './decision-actions-control.module.css'; import { useIsRunShown } from '../../../hooks/use-is-run-shown'; import { defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; import { rejectPortOf, withReasonRequired, withReject } from '../../../utils/human-decision/decision-actions'; -import { readDecisionRequest } from '../../../utils/human-decision/decision-request'; +import { authoredActionsOf, readDecisionRequest } from '../../../utils/human-decision/decision-request'; import { Hint, type HintVariant } from '../hint/hint'; type OutputHint = 'single' | 'unwired' | 'wired'; @@ -37,7 +37,7 @@ const OUTPUT_HINTS = { function DecisionActionsControl({ data, handleChange, path, enabled, label }: ControlProps) { const nodeId = useSingleSelectedElement()?.node?.id; const request = readDecisionRequest(data); - const actions: readonly unknown[] = request === undefined ? [] : data['actions']; + const actions = authoredActionsOf(data); const rejectPort = rejectPortOf(actions); const rejectWired = useStore( (state) => diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx index c08c7dc1f..e73b9b1ca 100644 --- a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx @@ -13,6 +13,7 @@ import { plugin } from '../../../plugin'; import { executionEvent as event } from '../../../stores/execution-event.fixture'; import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store'; import { reviewRequest } from '../../../utils/human-decision/review-request.fixture'; +import { decisionActionsRenderer } from '../decision-actions/decision-actions-control'; import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control'; import { decisionFormRenderer } from './decision-form-control'; @@ -24,7 +25,7 @@ vi.mock('@workflowbuilder/sdk', async (importOriginal) => { vi.mock('../../../adapters/submit-decision', () => ({ submitDecision: vi.fn() })); const submit = vi.mocked(submitDecision); -registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]); +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); plugin(); function agent(id: string): WorkflowBuilderNode { diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.module.css b/apps/ai-studio/src/components/human-decision/hint/hint.module.css index 07a6a51c4..d5b3884ac 100644 --- a/apps/ai-studio/src/components/human-decision/hint/hint.module.css +++ b/apps/ai-studio/src/components/human-decision/hint/hint.module.css @@ -24,11 +24,9 @@ } .hint--warning { - /* missing token: a semantic --wb-ds-ui-bg-warning-* */ - --hint-warning-bg: var(--wb-ds-components-snackbar-bg-warning); - padding: var(--wb-ds-space-150); - background: var(--hint-warning-bg); + /* missing token: a semantic --wb-ds-ui-bg-warning-* */ + background: var(--wb-ds-components-snackbar-bg-warning); color: var(--wb-ds-ui-text-warning-default); } diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx index 7bc45d72c..ea7081ca0 100644 --- a/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx +++ b/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx @@ -47,7 +47,6 @@ describe('Hint', () => { expect(icon()).toBe(expectedIcon); }); - // A selection change puts new text in the same mounted hints, which a status region would read out each time. it('is a status region only when live', () => { render('info'); expect(hint()?.hasAttribute('role')).toBe(false); diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.tsx index 1f7af95fe..9c23555a6 100644 --- a/apps/ai-studio/src/components/human-decision/hint/hint.tsx +++ b/apps/ai-studio/src/components/human-decision/hint/hint.tsx @@ -8,7 +8,7 @@ export type HintVariant = 'neutral' | 'info' | 'warning'; const ICONS = { neutral: undefined, info: 'Info', warning: 'Warning' } as const; -// `live` announces a new text, for a hint a switch in the same section changes in place. +// `live` reads out every new text: a switch's, and, as the panel stays mounted, a new selection's or edge's too. type Props = { variant: HintVariant; live?: boolean; children: ReactNode }; /** A note in a properties section: neutral for guidance, info for how the node behaves, warning for a gap. */ diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx index 5b702cf12..5685abce7 100644 --- a/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx +++ b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx @@ -64,15 +64,15 @@ function commit(element: HTMLInputElement, text: string) { describe('the Human task properties panel', () => { let container: HTMLDivElement; let root: ReturnType; - let dataUpdates = 0; + let trackedChanges = 0; let unsubscribe: () => void; beforeEach(async () => { useStore.setState(useStore.getInitialState(), true); resetExecution(); - dataUpdates = 0; - unsubscribe = useChangesTrackerStore.subscribe((state) => { - if (state.lastChangeName === 'dataUpdate') dataUpdates += 1; + trackedChanges = 0; + unsubscribe = useChangesTrackerStore.subscribe(() => { + trackedChanges += 1; }); container = document.createElement('div'); document.body.append(container); @@ -119,6 +119,6 @@ describe('the Human task properties panel', () => { expect(storedProperties()?.['description']).toBe('A person approves the refund'); expect(storedProperties()?.['decisionRequest']).toBe(defaultPropertiesData.decisionRequest); - expect(dataUpdates).toBe(1); + expect(trackedChanges).toBe(1); }); }); diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts index 885c526b2..af1fedabb 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts @@ -37,6 +37,13 @@ describe('offeredActions', () => { expect(offeredActions([{ ...approve, label: ' ' }])?.resume.label).toBe('approve'); }); + it('offers no reject when the reject has no port to route on', () => { + const portless = { name: 'reject', label: 'Reject', effect: 'reject', reasonRequired: false }; + + expect(offeredActions([approve, portless])?.reject).toBeUndefined(); + expect(offeredActions([approve, { ...reject, port: ' ' }])?.reject).toBeUndefined(); + }); + it('offers nothing without a usable resume action, whatever else the request carries', () => { expect(offeredActions([reject])).toBeUndefined(); expect(offeredActions([null, { name: '', effect: 'resume' }])).toBeUndefined(); @@ -67,11 +74,11 @@ describe('the reject switches', () => { expect(withReject([approve], true, added)).toEqual([approve, added]); }); - it('turned on over a reject the decider is offered, keeps that one and its settings', () => { - expect(withReject(reviewRequest.actions, true, added)).toEqual(reviewRequest.actions); + it('turned on over a stored reject listed first, drops it and appends the given one', () => { + expect(withReject([reject, approve], true, added)).toEqual([approve, added]); }); - it('turned on over a stored reject the decider is not offered, puts the given one in its place', () => { + it('turned on over a stored reject the decider is not offered, drops it and appends the given one', () => { const unnamed = { effect: 'reject', port: 'source:inner:rejected', reasonRequired: false }; expect(offeredActions([approve, unnamed])?.reject).toBeUndefined(); diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.ts index cc7de091d..55310ac54 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.ts @@ -26,8 +26,10 @@ function isReject(entry: unknown): entry is Record { return isPlainObject(entry) && entry['effect'] === 'reject'; } +// A reject without a port has no handle on the canvas and Publish refuses it, so it counts as off. function offeredReject(actions: readonly unknown[]): RejectOffer | undefined { - return rejectOfferOf(actions.find(isReject)); + const entry = actions.find(isReject); + return hasText(entry?.['port']) ? rejectOfferOf(entry) : undefined; } // Authored node data: only the array is proven. A `rerun-source` action is left out: the endpoint answers 501 for it. @@ -41,11 +43,8 @@ export function rejectPortOf(actions: readonly unknown[]): string | undefined { return hasText(port) ? port : undefined; } -/** Turned on, keeps a reject the decider is offered and replaces any other stored reject with `rejectAction`. */ +/** Drops every stored reject and, turned on, appends `rejectAction`. The switch turns it on only while it shows off. */ export function withReject(actions: readonly unknown[], on: boolean, rejectAction: unknown): unknown[] { - if (on && offeredReject(actions) !== undefined) { - return [...actions]; - } const withoutReject = actions.filter((entry) => !isReject(entry)); return on ? [...withoutReject, rejectAction] : withoutReject; } diff --git a/apps/ai-studio/src/utils/human-decision/decision-request.ts b/apps/ai-studio/src/utils/human-decision/decision-request.ts index d7bdcb442..cda3a2361 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-request.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-request.ts @@ -7,14 +7,18 @@ import { type OfferedActions, offeredActions } from './decision-actions'; /** What the form takes from the node's authored request. */ type DecisionFormRequest = { schema: JsonSchema; actions: OfferedActions; proposalSourceNodeId: string | undefined }; +export function authoredActionsOf(data: unknown): readonly unknown[] { + return isPlainObject(data) && Array.isArray(data['actions']) ? data['actions'] : []; +} + // Publish refuses a request without a resume action or a form schema, so a parked node has both. // The request's own `uiSchema` is not used yet (follow-up: decision-form-authored-ui-schema). export function readDecisionRequest(data: unknown): DecisionFormRequest | undefined { if (!isPlainObject(data)) { return undefined; } - const { actions, schema, proposalSourceNodeId } = data; - const offered = Array.isArray(actions) ? offeredActions(actions) : undefined; + const { schema, proposalSourceNodeId } = data; + const offered = offeredActions(authoredActionsOf(data)); if (offered === undefined || !isFormSchema(schema)) { return undefined; } From d37b6d93074cc77616becef97c8090e4fa45dc72 Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Thu, 1 Oct 2026 15:31:22 +0200 Subject: [PATCH 6/6] fix(ai-studio): ASCII hint texts, hint tests and README by behaviour --- apps/ai-studio/README.md | 4 ++-- .../decision-actions-control.test.tsx | 22 +++++++------------ .../decision-actions-control.tsx | 8 +++---- .../decision-fields-control.test.tsx | 20 +++++------------ .../decision-fields-control.tsx | 8 +++---- 5 files changed, 23 insertions(+), 39 deletions(-) diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index f794c34b8..854845bda 100644 --- a/apps/ai-studio/README.md +++ b/apps/ai-studio/README.md @@ -15,8 +15,8 @@ A complete, runnable AI workflow product built on top of the Workflow Builder SD - A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port. - The author picks what the decider's form shows. The panel lists the fields the proposal source (the named `proposalSourceNodeId`, else the single predecessor) declares under `properties.outputSchema` that the form can show (text, number, yes/no, and nullable text or yes/no; integers, nullable numbers, objects, arrays and keys with a dot or bracket are left out), each at one of four levels: Hidden, Read-only, Editable, or Editable and required. The control lives in `src/components/human-decision/decision-fields/`; from the moment the backend starts a run until Reset it is hidden, so the panel shows only the run (the decision form, then the record). - The picks are `decisionRequest.schema` itself, in the contract's format, so the canvas, the Run payload and the decider's form read one schema: a field it leaves out is Hidden, and Editable and required drops `null` from a nullable type, so a null the model left holds Approve back. A required field the decider empties holds it back too, because the backend refuses it. A required text field emptied or left with only whitespace shows no error of its own, and nothing on screen says why Approve is disabled. A field the source no longer declares stays in the schema, listed as "not in the source", until the author hides it. A stored field of a type the form cannot show gets no row, and the next pick drops it from the schema, `readOnly` and `required` included. Hidden keeps a field off the form and refuses edits to it; the value stays in the source's output, which the log panel shows and later nodes read. -- The Human task panel opens with General information, its own accordion with Title and Description (the SDK's `generalInformation` adds Status and shows only on a node with a `type`), above "Fields the decider sees" and Decider actions. -- The author picks what the decider can do in Decider actions (`src/components/human-decision/decision-actions/`), hidden from Run until Reset like the fields. Approve is always on. The Reject switch removes the `reject` action, and with it the "Rejected" port; turned on again it adds the default reject back on the fixed "Rejected" port with a reason required, so a custom label, name, port or an optional reason do not survive off and on. Reason required writes `reasonRequired`. The panel's hints come in three variants: neutral for guidance (nothing or several blocks lead in), warning for a gap (the source declares no fields the form can show) and info for how the node behaves (a single output, "Rejected" without a path, or both outputs wired); only the actions hint is a live region, so a screen reader reads its new text after a switch, and also when the selection moves to another Human task or an edge from "Rejected" changes. Known limit: turning Reject off leaves the edge from "Rejected" in the graph, unseen on the canvas but still read by the backend (a Human task it leads into, with another predecessor and no `proposalSourceNodeId`, is refused as `source_ambiguous`), and turning it on again brings that edge back (follow-up: decision-reject-edge-cleanup). +- The author picks what the decider can do (`src/components/human-decision/decision-actions/`), hidden from Run until Reset like the fields. Approve is always available. Turning Reject off removes the `reject` action and its port; turning it on again restores the default reject action, so earlier changes to it are lost. Requiring a reason sets `reasonRequired`. +- The panel's hints are neutral for guidance, warning for a gap the author has to close, and info for how the node behaves. Only the actions hint is a live region, so a screen reader announces its changes. - A rejection ends the run as a result, not a dead end: the run closes `completed`, the log panel names the outcome and who settled it, and the reject handle needs no edge. The node's output carries `resolvedBy` beside the other decision fields. The run's pill stays `completed` on purpose, a rejection being a result and not a failure; whether the panel marks it visually is for the design pass. - The person decides in the node's properties sidebar: the editor's own form over `decisionRequest.schema`, filled from the proposal source's output, with read-only fields disabled and only the changed editable fields sent as `edits`. Approve and "Reject…" sit in the panel's footer. The panel shows no Delete button for any selection, deliberately for now; deleting stays on the Delete and Backspace keys. From the moment the backend starts a run until Reset the canvas is read-only, so the form reads the graph the run executes; `use-run-locks-canvas.ts` lists the exceptions. Should undo change the picks under an open form, the form keeps the fields it opened with. - An AI Agent node that answers as structured fields: the Response format dropdown sets `properties.outputSchema` to a preset JSON Schema, the worker asks the model for that shape (see [apps/execution-worker/README.md](../execution-worker/README.md#ai-agent-structured-output)), and in Refund Review the draft's fields fill the decision form, and the reply the person approves, edits included, is what the customer gets. Plain text stays the default and returns `{ response }`. The draft's `internalReasoning` stays off the form because the decision request's schema leaves it out, and out of the confirmation only because the second agent's prompt says so; nothing downstream enforces that. A node switched to a structured format has no `response`, so a downstream `{{ nodes..response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent. diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx index 4cece693a..feba09ebc 100644 --- a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx @@ -20,7 +20,7 @@ import { executionEvent as event } from '../../../stores/execution-event.fixture import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store'; import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control'; import { decisionFormRenderer } from '../decision-form/decision-form-control'; -import { decisionActionsRenderer } from './decision-actions-control'; +import { OUTPUT_HINTS, decisionActionsRenderer } from './decision-actions-control'; vi.mock('@workflowbuilder/sdk', async (importOriginal) => { const actual = await importOriginal(); @@ -83,12 +83,6 @@ const settle = () => await new Promise((resolve) => setTimeout(resolve, 40)); }); -const SINGLE = 'Reject is off, so the gate has a single output: Approved — the decider can only approve.'; -const UNWIRED = - '“Rejected” has no path yet. That is not a validation error — a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.'; -const WIRED = - 'Both outputs carry equal weight. An unwired “Rejected” output is not a validation error — a run that reaches it ends as rejected.'; - describe('the decider actions control in the real properties panel', () => { let container: HTMLDivElement; let root: ReturnType; @@ -176,7 +170,7 @@ describe('the decider actions control in the real properties panel', () => { expect(isOn('reasonRequired')).toBe(true); expect(row('reasonRequired')?.textContent).not.toContain('optional'); expect(descriptionOf('reasonRequired')).toBeUndefined(); - expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + expect(hints()).toEqual([OUTPUT_HINTS.unwired]); expect(container.querySelector('[data-decider-actions] [data-hint]')?.getAttribute('role')).toBe('status'); }); @@ -190,13 +184,13 @@ describe('the decider actions control in the real properties panel', () => { it('with an edge from the Rejected port, says both outputs carry equal weight', async () => { await renderPanel([human(defaultDecisionRequest)], [edgeFrom(REJECTED)]); - expect(hints()).toEqual([{ variant: 'info', text: WIRED }]); + expect(hints()).toEqual([OUTPUT_HINTS.wired]); }); it('an edge from the Approved port alone leaves Rejected without a path', async () => { await renderPanel([human(defaultDecisionRequest)], [edgeFrom(approveAction.port)]); - expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + expect(hints()).toEqual([OUTPUT_HINTS.unwired]); }); it('turning Reject off is one undo step: the request keeps Approve alone and the rest as it was', async () => { @@ -211,7 +205,7 @@ describe('the decider actions control in the real properties panel', () => { expect(row('reject')?.textContent).toContain('single output'); expect(descriptionOf('reject')).toBe('single output'); expect(row('reasonRequired')).toBeNull(); - expect(hints()).toEqual([{ variant: 'info', text: SINGLE }]); + expect(hints()).toEqual([OUTPUT_HINTS.single]); act(() => undo()); await settle(); @@ -230,7 +224,7 @@ describe('the decider actions control in the real properties panel', () => { expect(storedRequest()?.actions).toEqual([approveAction, defaultRejectAction]); expect(storedRequest()?.actions[1]).toMatchObject({ port: REJECTED, reasonRequired: true }); expect(isOn('reasonRequired')).toBe(true); - expect(hints()).toEqual([{ variant: 'info', text: UNWIRED }]); + expect(hints()).toEqual([OUTPUT_HINTS.unwired]); }); it('Reason required off is one undo step, stores it and notes the reason is optional; on again requires it', async () => { @@ -267,12 +261,12 @@ describe('the decider actions control in the real properties panel', () => { const { nodes, edges } = refundReviewFlow.value.diagram; await renderPanel(nodes, edges); - expect(hints()).toEqual([{ variant: 'info', text: WIRED }]); + expect(hints()).toEqual([OUTPUT_HINTS.wired]); await flip('reject'); expect(decisionRequestSchema.safeParse(storedRequest()).success).toBe(true); - expect(hints()).toEqual([{ variant: 'info', text: SINGLE }]); + expect(hints()).toEqual([OUTPUT_HINTS.single]); }); it.todo('turning Reject off removes the Rejected edge in the same undo step'); diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx index 52b4aa043..b8ffc5160 100644 --- a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx @@ -19,18 +19,18 @@ import { Hint, type HintVariant } from '../hint/hint'; type OutputHint = 'single' | 'unwired' | 'wired'; -const OUTPUT_HINTS = { +export const OUTPUT_HINTS = { single: { variant: 'info', - text: 'Reject is off, so the gate has a single output: Approved — the decider can only approve.', + text: 'Reject is off, so the gate has a single output: Approved - the decider can only approve.', }, unwired: { variant: 'info', - text: '“Rejected” has no path yet. That is not a validation error — a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.', + text: '"Rejected" has no path yet. That is not a validation error - a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.', }, wired: { variant: 'info', - text: 'Both outputs carry equal weight. An unwired “Rejected” output is not a validation error — a run that reaches it ends as rejected.', + text: 'Both outputs carry equal weight. An unwired "Rejected" output is not a validation error - a run that reaches it ends as rejected.', }, } satisfies Record; diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx index 5497dc4ef..41ef3d6d2 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx @@ -29,7 +29,7 @@ import { import { FIELD_MODES } from '../../../utils/human-decision/decision-fields'; import { decisionActionsRenderer } from '../decision-actions/decision-actions-control'; import { decisionFormRenderer } from '../decision-form/decision-form-control'; -import { decisionFieldsRenderer } from './decision-fields-control'; +import { SOURCE_HINTS, decisionFieldsRenderer } from './decision-fields-control'; vi.mock('@workflowbuilder/sdk', async (importOriginal) => { const actual = await importOriginal(); @@ -316,15 +316,10 @@ describe('the decision fields control in the real properties panel', () => { text: hint.textContent, })); - const UNCONNECTED = - 'Nothing leads into this block yet. Connect a block before it — its output fields will appear here (e.g. the AI step). Run won’t start until this block has an incoming connection.'; - const NO_FIELDS = - 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.'; - it('with nothing connected, a neutral hint says nothing leads in, and keeps listing the stored fields', async () => { await renderPanel([agent('draft-1', refundOutput), human(stored)], []); - expect(hints()).toEqual([{ variant: 'neutral', text: UNCONNECTED }]); + expect(hints()).toEqual([SOURCE_HINTS.unconnected]); expect(container.querySelector('[data-decision-fields] [data-hint]')?.hasAttribute('role')).toBe(false); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); @@ -344,12 +339,7 @@ describe('the decision fields control in the real properties panel', () => { [edge('draft-1'), edge('draft-2')], ); - expect(hints()).toEqual([ - { - variant: 'neutral', - text: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.', - }, - ]); + expect(hints()).toEqual([SOURCE_HINTS.ambiguous]); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); @@ -369,7 +359,7 @@ describe('the decision fields control in the real properties panel', () => { const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' }; await renderPanel([agent('draft-1', refundOutput), human(ghost)], []); - expect(hints()).toEqual([{ variant: 'neutral', text: UNCONNECTED }]); + expect(hints()).toEqual([SOURCE_HINTS.unconnected]); }); describe('on the "Refund Review" template', () => { @@ -483,7 +473,7 @@ describe('the decision fields control in the real properties panel', () => { it('with the draft on Plain text, a warning says it declares no fields, and the stored ones stay listed', async () => { await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: undefined }))); - expect(hints()).toEqual([{ variant: 'warning', text: NO_FIELDS }]); + expect(hints()).toEqual([SOURCE_HINTS.noFields]); expect(rowLabels()).toEqual([ 'Refund amount (not in the source)', 'Order date (not in the source)', diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx index cfe6f4438..4f9dce253 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx @@ -26,18 +26,18 @@ import { FieldModeRow } from './field-mode-row'; // The backend refuses a Human task with no predecessor, so `unconnected` may say Run won't start; // nothing refuses an empty form yet, so `noFields` promises nothing about Run. -const SOURCE_HINTS = { +export const SOURCE_HINTS = { unconnected: { variant: 'neutral', - text: 'Nothing leads into this block yet. Connect a block before it — its output fields will appear here (e.g. the AI step). Run won’t start until this block has an incoming connection.', + text: "Nothing leads into this block yet. Connect a block before it - its output fields will appear here (e.g. the AI step). Run won't start until this block has an incoming connection.", }, ambiguous: { variant: 'neutral', - text: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.', + text: 'Several blocks lead into this one - keep one connection before it so its output fields appear here.', }, noFields: { variant: 'warning', - text: 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.', + text: 'The block before this one declares no output fields the form can show (text, number, yes/no) - for an AI step, pick a structured Response format.', }, } satisfies Record;