Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/10692-debug-run-routing-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ so the field is hidden. A non-string `visibleWhen` is refused the same way. A bl
as `false`: that is the installed spec 17.4.0 reading. objectstack main refuses both at
`registerFlow`, so such a flow never runs there.

⚠️ **Dated note, 2026-09-27 — the Studio no longer decides a screen field's visibility up
front — objectui#10743.** Later in this same release the screen preview and the Debug run's
screen pause hand `visibleWhen` to `ScreenView` raw, and the flow runner's own renderer
decides it live over the screen's declared fields and the values being collected — the
spec's scope, "evaluated by the CLIENT against the screen's live collected values". A
predicate that cannot be evaluated is no longer read as hidden by the Studio: one that
references a name that is not a field on this screen is an error on the screen step, and
whether the renderer shows or hides its field is `ScreenView`'s fallback (objectui#8069).
The rest of this entry is kept as the reading of this change; the objectui#10743 entry
states what the Studio now does, and that the runtime's resume door still reads the run's
variables until objectstack-ai/objectstack#20178 lands.

**Template tokens.** A `NOW()`, `$User.*`, function or arithmetic token inside a longer
string rendered as `''`, so `'at {NOW()}'` became `'at '` where the runtime writes the
time. The Debug run still does not model these tokens; it now keeps them as written,
Expand Down
69 changes: 69 additions & 0 deletions .changeset/10743-studio-screen-visiblewhen-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the Studio screen preview and the Debug run decide a screen field's `visibleWhen` live, through the flow runner's own renderer, over the screen's declared fields

**What was wrong.** The Studio's screen preview (`ScreenPreview`) and the Debug run's screen
pause judged each field's `visibleWhen` once, up front, against the run's variables — the
reading the runtime's resume door takes when a screen is resumed — dropped every field they
judged hidden and stripped the predicate from the ones they kept. A predicate over a sibling
screen field, the only shape any shipped producer writes (`createOpportunity == true` in the
flows guide, `discount > 0` in the console sample), names a value the run does not hold at
the pause, so the field was judged hidden and stayed hidden however the author ticked or
typed: the preview's checkbox changed state and nothing appeared. Where the flow's own
variable of the same name was declared `false`, as the guide recommends, the field was judged
`false` and frozen just the same. Measured through the real `FlowSimulator` and
`ScreenPreview` on both sample shapes, with a no-predicate field as the control.

**What changed.** The spec declares the contract: `ScreenFieldSpec.visibleWhen` is "bare CEL
over the screen's own field names", "evaluated by the CLIENT against the screen's live
collected values — not by the server". The Studio now follows it with one client evaluator:

- `buildScreenSpec` keeps every authored field and carries its `visibleWhen` raw, as the
runtime `screen` executor sends it to the client. `ScreenView` — the renderer the flow
runner uses — decides it live over the screen's declared fields and the values being
collected, so ticking the sibling checkbox or typing a discount above zero reveals the
field in the preview exactly as it does for the end user, and the "N fields hidden by
their visible when condition" hint follows the values.
- The Studio no longer has an evaluator of its own for this key: `isFieldVisibleWhen`,
`fieldVisibility` and the simulator's `evalVisibleWhen` are removed. `buildScreenSpec`
takes no `variables`; `hiddenFieldCount` takes the built spec and the collected values.
- The Debug run's screen step judges SCOPE, never value: `unevaluableVisibleWhen` names, as
an error on the paused step, a field whose predicate references an identifier that is not
a field declared on this screen (a run variable such as `needsApproval == true`, the
runtime's `vars.*` root), does not parse as CEL (a `{var}` brace), or is a shape
`registerFlow` refuses — with the nearest declared field where one is close. A predicate
over a sibling field (`discount > 0`, `record.discount > 0`) is not reported; it used to
be named as "hidden because it could not be evaluated". A comprehension's iteration
variable (`["a","b"].exists(t, t == note)`) is bound, not a root, and is not reported.
- The Problems panel (`flow-expr-problems.ts`) judges the `screen` node's `fields[].visibleWhen`
column by that same rule instead of the flow scope: it used to warn that `discount` "is not
a reference in scope at this step" on the sibling-field shape and stay silent on a run
variable; it now warns on the run variable (`needsApproval` is not a field on this screen)
and is silent on the sibling. Every other expression slot keeps the flow scope.
- The screen field inspector's `visibleWhen` placeholder is a sibling-field example
(`createOpportunity == true`); it used to read `stage == "review"`, a run-variable shape.
- `ScreenView` exports `screenPredicateScope`, unchanged, so the Studio reads the bare roots a
predicate may name off the renderer's own scope; only the `record` namespace, which the
renderer binds inside `evalFieldPredicate`, is restated by name.

**What did not change.** Whether the renderer shows or hides a predicate it cannot evaluate
is `ScreenView`'s fallback and objectui#8069's open question; the Studio now renders through
that fallback and rules no direction of its own. The runner (`ScreenView`, `FlowRunner`) has
no behaviour change.

**The server side.** The runtime's resume door (`refuseInvalidScreenInput` in
`@objectstack/service-automation`) still evaluates `visibleWhen` over the run's variables
with the submitted values layered on top; narrowing it to the declared scope, and refusing
an undeclared identifier at `registerFlow` and `objectstack validate`, is
objectstack-ai/objectstack#20178 and is not on objectstack main at this change. Until it
lands, a predicate over a run variable is an error in the Studio and shown by the runner,
while the server enforces its `required` only when the run's variables make the predicate
true, and waives it when they make it false or leave it unevaluable.

**Still on the flow scope.** The inline inspector's `visibleWhen` cell (`FlowObjectListField`)
still offers the flow scope picker and its inline note still reads the flow scope, so on the
sibling-field shape it says "`discount` is not a reference in scope at this step" while the
Problems panel and the Debug run are silent, and says nothing on a run variable the other two
report; aligning that cell is a follow-up outside this change.
13 changes: 9 additions & 4 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,10 +603,15 @@ be faithfully modelled is surfaced loudly instead of faked.
registration (objectui#10615); an assignment interpolates `{var}` tokens
inside nested objects and arrays too, as the runtime's `interpolate` does,
and a token it does not model (`NOW()`, `$User.*`, arithmetic) is kept as
written and named on the step; a paused screen gates each field's
`visibleWhen` with the same CEL call, and one that cannot be evaluated hides
the field, as the runtime's resume door reads it (the flow runner's own
`ScreenView` still shows it);
written and named on the step; a paused screen renders through the flow
runner's own `ScreenView`, which decides each field's `visibleWhen` live over
the screen's declared fields and the values being typed (one client
evaluator, objectui#10743), and the screen step names as an error a predicate
that references a name that is not a field on this screen or a shape
`registerFlow` refuses — the Problems panel judges that column by the same
rule (a warning), while the inline inspector's `visibleWhen` cell still reads
the flow scope (a follow-up); the runtime's resume door still evaluates over
the run's variables until objectstack#20178 lands;
side-effect nodes write their mock to `outputVariable` (the legacy script
`outputVariables[]` list is ignored — the engine never binds those names,
framework#4278);
Expand Down
7 changes: 6 additions & 1 deletion packages/app-shell/src/views/ScreenView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,13 @@ export function visibleScreenFields(
* Fail-open is still the behaviour we want for a genuinely broken predicate —
* a syntax error, or a name that is not a field on this screen. Seeding only
* the declared names keeps that split intact.
*
* Exported for the Studio's screen diagnostics
* (`metadata-admin/previews/screen-spec.ts`), which read its keys as the names
* a predicate may reference (objectui#10743); the evaluation itself stays in
* {@link visibleScreenFields}.
*/
function screenPredicateScope(
export function screenPredicateScope(
screen: ScreenSpec,
values: Record<string, unknown>,
): Record<string, unknown> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,9 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
{ key: 'label', label: 'Label', kind: 'text', placeholder: 'Discount %' },
{ key: 'type', label: 'Type', kind: 'text', placeholder: 'number' },
{ key: 'required', label: 'Required', kind: 'boolean' },
{ key: 'visibleWhen', label: 'Visible when', kind: 'expression', placeholder: 'stage == "review"' },
// A sibling-field shape (the flows guide's example): the predicate binds
// this screen's own fields, never a run variable (objectui#10743).
{ key: 'visibleWhen', label: 'Visible when', kind: 'expression', placeholder: 'createOpportunity == true' },
],
}),
cfg('waitForInput', 'Wait for input', 'boolean', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Debug run's paused screen decides a field's `visibleWhen` live, through
* the flow runner's own renderer, over the screen's declared fields and the
* values the author is collecting — never over the run's variables
* (objectui#10743). Every run goes through the real `FlowSimulator`, and every
* screen through the real `ScreenPreview` → `ScreenView`, the way
* `FlowSimulatorPanel` mounts them (`node` + `snapshot.variables`).
*
* The three cases are the ruling's three pins, each with a control field that
* declares no predicate in the same tree:
*
* 1. a sibling-field predicate reveals its field live (the console sample's
* `note`, `visibleWhen: 'discount > 0'`, and the docs shape
* `createOpportunity == true`) — the Debug run used to judge the predicate
* once against the run's variables, drop the field and strip the predicate,
* so nothing the author typed or ticked ever brought it back;
* 2. a predicate over a run variable is not read from the run's variables, and
* the screen step names it as an error;
* 3. the control: a field with no predicate is drawn throughout.
*
* ⛔ Whether the renderer SHOWS or HIDES a predicate it cannot evaluate is its
* own fallback and objectui#8069's question. Pin 2 therefore reads the SCOPE
* only: the render is the same whether the run variable is `true` or `false`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';

vi.mock('../../../providers/AdapterProvider', () => ({
useAdapter: () => ({ fake: 'adapter' }),
}));

vi.mock('../../../providers/MetadataProvider', () => ({
useMetadata: () => ({ objects: [] }),
}));

import { ScreenPreview } from './ScreenPreview';
import { FlowSimulator } from './simulator/flow-simulator';
import type { SimEdge, SimNode } from './simulator/flow-sim-types';

afterEach(() => cleanup());

/** The console preview sample's `review` screen (apps/console `preview-samples.ts`), plus a control field. */
const consoleSample: SimNode = {
id: 'review',
type: 'screen',
label: 'CSM review',
config: {
fields: [
{ name: 'discount', label: 'Discount %', type: 'number', required: false },
{ name: 'note', label: 'Note', type: 'text', required: true, visibleWhen: 'discount > 0' },
{ name: 'comment', label: 'Comment', type: 'text' },
],
},
};

/** The docs' lead-conversion screen (`content/docs/automation/flows.mdx`), plus a control field. */
const docsShape: SimNode = {
id: 'screen_1',
type: 'screen',
label: 'Conversion Details',
config: {
fields: [
{ name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean', required: true, defaultValue: false },
{ name: 'opportunityName', label: 'Opportunity Name', type: 'text', required: true, visibleWhen: 'createOpportunity == true' },
{ name: 'plain', label: 'Plain', type: 'text' },
],
},
};

/** A predicate over a run variable — nothing on this screen declares `needsApproval`. */
const runVariableShape: SimNode = {
id: 'gate',
type: 'screen',
label: 'Gate',
config: {
fields: [
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'needsApproval == true' },
{ name: 'plain', label: 'Plain', type: 'text' },
],
},
};

/** start → `node` → end, run to the screen pause with `seed` as the run's variables. */
function pauseAt(node: SimNode, seed: Record<string, unknown> = {}) {
const nodes: SimNode[] = [{ id: 'start', type: 'start' }, node, { id: 'end', type: 'end' }];
const edges: SimEdge[] = [
{ id: 'e1', source: 'start', target: node.id },
{ id: 'e2', source: node.id, target: 'end' },
];
const sim = new FlowSimulator(nodes, edges);
sim.reset(seed);
sim.runToEnd();
expect(sim.state.status).toBe('paused');
expect(sim.state.pausedReason).toBe('screen');
const step = sim.state.steps.find((st) => st.nodeId === node.id);
return { sim, step, node, variables: sim.state.variables };
}

describe('the Debug run screen decides a sibling-field visibleWhen live (objectui#10743)', () => {
it('console sample: typing a discount above zero reveals Note; zero hides it; Comment (no predicate) is drawn throughout', () => {
const { node, variables, step } = pauseAt(consoleSample);
const { container } = render(<ScreenPreview node={node} variables={variables} />);
const discount = container.querySelector('#ff-discount') as HTMLInputElement | null;
expect(discount).not.toBeNull();
expect(screen.getByText('Comment')).toBeInTheDocument();

fireEvent.change(discount!, { target: { value: '5' } });
expect(screen.getByText('Note')).toBeInTheDocument();
expect(screen.getByText('Comment')).toBeInTheDocument();

fireEvent.change(discount!, { target: { value: '0' } });
expect(screen.queryByText('Note')).not.toBeInTheDocument();
expect(screen.getByText('Comment')).toBeInTheDocument();

// The sibling predicate is the renderer's to decide, not a step error, and
// the pause never names `note` as a field it hid.
expect(step?.status).toBe('paused');
expect(step?.error).toBeUndefined();
expect(step?.note ?? '').not.toMatch(/"note"/);
});

it('docs shape: ticking Create Opportunity reveals Opportunity Name; Plain (no predicate) is drawn throughout', () => {
const { node, variables, step } = pauseAt(docsShape);
const { container } = render(<ScreenPreview node={node} variables={variables} />);
expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument();
expect(screen.getByText('Plain')).toBeInTheDocument();
expect(screen.getByText(/hidden by .*visible when/i)).toBeInTheDocument();

fireEvent.click(container.querySelector('#ff-createOpportunity')!);
expect(screen.getByText('Opportunity Name')).toBeInTheDocument();
expect(screen.getByText('Plain')).toBeInTheDocument();
expect(screen.queryByText(/hidden by .*visible when/i)).not.toBeInTheDocument();

expect(step?.error).toBeUndefined();
expect(step?.note ?? '').not.toMatch(/"opportunityName"/);
});

it('docs shape with the flow variable declared (`createOpportunity: false` in the run, as the docs recommend): the tick still reveals the field', () => {
// This is the shape that froze hardest: the run held `false`, the predicate
// evaluated false against it, the field was dropped, and the checkbox the
// author ticked in the preview changed nothing.
const { node, variables } = pauseAt(docsShape, { createOpportunity: false });
expect(variables).toEqual({ createOpportunity: false });
const { container } = render(<ScreenPreview node={node} variables={variables} />);
expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument();
fireEvent.click(container.querySelector('#ff-createOpportunity')!);
expect(screen.getByText('Opportunity Name')).toBeInTheDocument();
expect(screen.getByText('Plain')).toBeInTheDocument();
});
});

describe('the Debug run screen does not read a visibleWhen from the run variables, and names it (objectui#10743)', () => {
it('the render is the same whether the run holds needsApproval true or false; Plain (no predicate) is drawn in both', () => {
const t = pauseAt(runVariableShape, { needsApproval: true });
const withTrue = render(<ScreenPreview node={t.node} variables={t.variables} />);
const reasonWithTrue = withTrue.queryByText('Reason') !== null;
expect(withTrue.getByText('Plain')).toBeInTheDocument();
withTrue.unmount();

const f = pauseAt(runVariableShape, { needsApproval: false });
const withFalse = render(<ScreenPreview node={f.node} variables={f.variables} />);
const reasonWithFalse = withFalse.queryByText('Reason') !== null;
expect(withFalse.getByText('Plain')).toBeInTheDocument();

// Scope, not direction: the run variable is not what decides the field.
expect(reasonWithFalse).toBe(reasonWithTrue);
});

it('the screen step names the field whose visibleWhen references a name that is not a field on this screen, whatever the run holds', () => {
for (const seed of [{}, { needsApproval: true }, { needsApproval: false }]) {
const { step } = pauseAt(runVariableShape, seed);
expect(step?.status).toBe('paused');
expect(step?.error).toMatch(/"reason"/);
expect(step?.error).toMatch(/needsApproval/);
expect(step?.error).not.toMatch(/"plain"/);
}
});
});
Loading
Loading