Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ai-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A complete, runnable AI workflow product built on top of the Workflow Builder SD
- 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 (`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.
- The panel's hints are neutral for guidance, warning for a gap the author has to close, and info for how the node behaves. The actions hint and the warning that every field is Hidden sit in live regions that stay mounted, so a screen reader announces them; the warning's region sits below the field rows, and the warning shows only when no other fields hint does.
- 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.<id>.response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
.fields {
--fields-gap: 0.5rem;

display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--fields-gap);
/* The accordion's grid track grows to the widest label; this keeps it at the panel's width. */
contain: inline-size;
}

/* Cancels the gap above the empty status region; `display: none` would drop it from the accessibility tree. */
.status:empty {
margin-top: calc(-1 * var(--fields-gap));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 { SOURCE_HINTS, decisionFieldsRenderer } from './decision-fields-control';
import { ALL_HIDDEN_HINT, SOURCE_HINTS, decisionFieldsRenderer } from './decision-fields-control';

vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@workflowbuilder/sdk')>();
Expand Down Expand Up @@ -191,7 +191,7 @@ describe('the decision fields control in the real properties panel', () => {
await settle();
}

const renderRefund = (decisionRequest: unknown = defaultDecisionRequest) =>
const renderPaletteNode = (decisionRequest: unknown = defaultDecisionRequest) =>
renderPanel([agent('draft-1', refundOutput), human(decisionRequest)], [edge('draft-1')]);

async function choose(key: string, mode: string) {
Expand All @@ -205,14 +205,14 @@ describe('the decision fields control in the real properties panel', () => {
}

it("lists the source's fields in its order, every one Hidden on a node fresh from the palette", async () => {
await renderRefund();
await renderPaletteNode();

expect(rowKeys()).toEqual(['refundAmount', 'orderDate', 'replyDraft', 'internalReasoning']);
expect(selects().map((select) => select.value)).toEqual(Array.from({ length: 4 }, () => 'hidden'));
});

it('a pick is one undo step and stores the contract shape, leaving the rest of the node alone', async () => {
await renderRefund();
await renderPaletteNode();

await choose('orderDate', 'readOnly');

Expand All @@ -229,7 +229,7 @@ describe('the decision fields control in the real properties panel', () => {

// Base UI reports a click on the selected item as a change; the stored entry lacks the source's title on purpose.
it('picking the mode a row already shows writes nothing', async () => {
await renderRefund({
await renderPaletteNode({
...defaultDecisionRequest,
schema: { type: 'object', properties: { orderDate: { type: 'string', readOnly: true } } },
});
Expand All @@ -240,7 +240,7 @@ describe('the decision fields control in the real properties panel', () => {
});

it('locks the dropdowns while the canvas is in the app bar read-only mode', async () => {
await renderRefund();
await renderPaletteNode();

act(() => useStore.getState().setToggleReadOnlyMode(true));
expect(selects().every((select) => select.disabled)).toBe(true);
Expand All @@ -250,7 +250,7 @@ describe('the decision fields control in the real properties panel', () => {
});

it('steps aside from Run until Reset, section header included, even with the canvas lock lifted', async () => {
await renderRefund();
await renderPaletteNode();
act(() =>
root.render(
<>
Expand Down Expand Up @@ -316,6 +316,47 @@ describe('the decision fields control in the real properties panel', () => {
text: hint.textContent,
}));

const statusRegion = () => container.querySelector('[data-decision-fields]')?.lastElementChild;

it('with every field Hidden, a warning in the status region below the rows says none is visible, and nothing above', async () => {
await renderPaletteNode();

expect(hints()).toEqual([{ variant: 'warning', text: ALL_HIDDEN_HINT }]);
expect(statusRegion()?.getAttribute('role')).toBe('status');
expect(statusRegion()?.querySelector('[data-hint]')?.textContent).toBe(ALL_HIDDEN_HINT);
});

it('setting one field to Read-only clears the all-hidden warning and leaves the status region empty', async () => {
await renderPaletteNode();
expect(hints()).toEqual([{ variant: 'warning', text: ALL_HIDDEN_HINT }]);

await choose('orderDate', 'readOnly');

expect(hints()).toEqual([]);
expect(statusRegion()?.getAttribute('role')).toBe('status');
expect(statusRegion()?.childElementCount).toBe(0);
});

it('with the source switched to Plain text after every field was Hidden, only the noFields warning shows', async () => {
await renderPaletteNode();
expect(hints()).toEqual([{ variant: 'warning', text: ALL_HIDDEN_HINT }]);

act(() =>
useStore.setState({
nodes: useStore
.getState()
.nodes.map((node) =>
node.id === 'draft-1'
? { ...node, data: { ...node.data, properties: { ...node.data.properties, outputSchema: undefined } } }
: node,
),
}),
);
await settle();

expect(hints()).toEqual([SOURCE_HINTS.noFields]);
});

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)], []);

Expand Down Expand Up @@ -395,12 +436,13 @@ describe('the decision fields control in the real properties panel', () => {
internalReasoning: 'Duplicate charge, refunded in full.',
};

it("lists the draft's four fields under their titles, in the draft's order, with the template's picks", async () => {
it("lists the draft's four fields under their titles, in the draft's order, with the template's picks and no hint", async () => {
await renderTemplate();

expect(rowLabels()).toEqual(['Refund amount', 'Order date', 'Reply draft', 'Internal reasoning']);
expect(selects().map((select) => select.value)).toEqual(['required', 'readOnly', 'editable', 'hidden']);
expect(hints()).toEqual([]);
expect(statusRegion()?.getAttribute('role')).toBe('status');
});

it('as shipped, the backend refuses an edit to the read-only and the hidden field and takes the rest', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type SourceHint,
fieldModeOf,
fieldRows,
isEveryFieldHidden,
sourceHintOf,
withFieldMode,
} from '../../../utils/human-decision/decision-fields';
Expand All @@ -41,6 +42,8 @@ export const SOURCE_HINTS = {
},
} satisfies Record<SourceHint, { variant: HintVariant; text: string }>;

export const ALL_HIDDEN_HINT = 'No field is visible to the decider. Set at least one field to Read-only or Editable.';

function DecisionFieldsControl({ data, handleChange, path, enabled, label }: ControlProps) {
const nodeId = useSingleSelectedElement()?.node?.id;
const request = readDecisionRequest(data);
Expand All @@ -65,6 +68,7 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con
const { schema } = request;
const rows = fieldRows(outputSchema, schema);
const sourceHint = sourceHintOf(sourceId, predecessors.length, rows);
const everyHidden = sourceHint === undefined && isEveryFieldHidden(rows, schema);
const pick = (key: string, mode: FieldMode) =>
handleChange(path, { ...data, schema: withFieldMode(schema, rows, key, mode) });

Expand All @@ -81,6 +85,10 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con
onPick={(mode) => pick(row.key, mode)}
/>
))}
{/* Screen readers often skip a live region mounted with its text, so the region stays mounted. */}
<div role="status" className={styles['status']}>
{everyHidden && <Hint variant="warning">{ALL_HIDDEN_HINT}</Hint>}
</div>
</div>
</Accordion>
);
Expand Down
17 changes: 17 additions & 0 deletions apps/ai-studio/src/utils/human-decision/decision-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type FieldRow,
fieldModeOf,
fieldRows,
isEveryFieldHidden,
sourceHintOf,
withFieldMode,
} from './decision-fields';
Expand Down Expand Up @@ -98,6 +99,22 @@ describe('sourceHintOf', () => {
});
});

describe('isEveryFieldHidden', () => {
const stored = { type: 'object', properties: { summary: { type: 'string', title: 'Summary' } } };
const readOnlyDate = { type: 'object', properties: { orderDate: { type: 'string', readOnly: true } } };

it.each([
['no rows', undefined, empty, false],
['a source that declares fields, every one Hidden', outputSchema, empty, true],
['a source that declares fields, one Read-only', outputSchema, readOnlyDate, false],
['only fields the source does not declare', undefined, stored, false],
['declared fields all Hidden, plus a stored field the source does not declare', outputSchema, stored, false],
['a source that declares no field the form can show', { type: 'object', properties: {} }, empty, false],
] as const)('%s', (_case, source, schema, expected) => {
expect(isEveryFieldHidden(fieldRows(source, schema), schema)).toBe(expected);
});
});

describe('fieldModeOf', () => {
it('reads every field of the palette preset as Hidden', () => {
expect(keysOf(empty).map((key) => fieldModeOf(empty, key))).toEqual(Array.from({ length: 5 }, () => 'hidden'));
Expand Down
5 changes: 5 additions & 0 deletions apps/ai-studio/src/utils/human-decision/decision-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export function sourceHintOf(
return rows.some((row) => row.declaration !== undefined) ? undefined : 'noFields';
}

/** The source declares fields, yet the decider's form shows none: a stored-only row is never Hidden. */
export function isEveryFieldHidden(rows: readonly FieldRow[], schema: unknown): boolean {
return rows.some((row) => row.declaration !== undefined) && shownFields(schema).length === 0;
}

// The contract's own encoding, so the stored schema is the decider's form: a field it leaves out is Hidden.
export function fieldModeOf(schema: unknown, key: string): FieldMode {
const declaration = schemaFields(schema).find(([name]) => name === key)?.[1];
Expand Down
Loading