diff --git a/.changeset/adhoc-form-editing-and-layout-fixes.md b/.changeset/adhoc-form-editing-and-layout-fixes.md new file mode 100644 index 00000000000..f0313cea99a --- /dev/null +++ b/.changeset/adhoc-form-editing-and-layout-fixes.md @@ -0,0 +1,9 @@ +--- +"@hashintel/petrinaut": patch +--- + +Fix three defects in the ad-hoc scenario form: the optimize bounds popover +ignored every press (Min, Max, Step and Scale were uneditable, and each press +dismissed it), a focused section painted over the sticky header of the section +hosting it, and the experiment drawer's computed initial state grew unbounded +instead of scrolling in its own region. diff --git a/.changeset/adhoc-scenario-authoring.md b/.changeset/adhoc-scenario-authoring.md new file mode 100644 index 00000000000..878f7461861 --- /dev/null +++ b/.changeset/adhoc-scenario-authoring.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut": patch +"@hashintel/petrinaut-core": patch +--- + +Behind the new experimental "Ad-hoc scenarios" setting, the scenario creation form authors scenarios through the ad-hoc form: exposed Variables become the saved scenario's tunable parameters, and the definition persists as `initialState.type: "adhoc"`. diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 12521a63b30..3ebc9879efa 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -474,6 +474,14 @@ export { type AdHocActionInput, type AdHocActionName, } from "./simulation/authoring/scenario/ad-hoc/ad-hoc-actions"; +export { + CLASSIC_RUN_ROW_CAP, + classicRunParameterValues, + classicRunVariables, + classicScenarioRunState, + initialMarkingToAdHocPlaces, + type TruncatedPlace, +} from "./simulation/authoring/scenario/ad-hoc/materialize-run-state"; export { adHocScenarioStateSchema } from "./simulation/authoring/scenario/ad-hoc/ad-hoc-state-schema"; export { createHirMetricEvaluator } from "./simulation/frames/hir-metric"; export { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.test.ts new file mode 100644 index 00000000000..54d5cd766ca --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; + +import { + classicRunParameterValues, + classicRunVariables, + classicScenarioRunState, + initialMarkingToAdHocPlaces, +} from "./materialize-run-state"; + +import type { Color, Place, Scenario } from "../../../../types/sdcpn"; +import type { InitialMarking } from "../../../api"; + +const place = (id: string, name: string, colorId: string | null): Place => ({ + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); + +const SATELLITE: Color = { + id: "colour-satellite", + name: "Satellite", + iconSlug: "circle", + displayColor: "#3676b8", + elements: [ + { elementId: "e1", name: "altitude", type: "real" }, + { elementId: "e2", name: "active", type: "boolean" }, + { elementId: "e3", name: "tag", type: "uuid" }, + ], +}; + +const CONTEXT = { + places: [ + place("place-space", "Space", "colour-satellite"), + place("place-debris", "Debris", null), + ], + types: [SATELLITE], +}; + +const SCENARIO: Scenario = { + id: "scenario-moon", + name: "Moon Orbit", + scenarioParameters: [ + { type: "real", identifier: "launch_rate", default: 0.3 }, + { type: "integer", identifier: "initialAltitude", default: 20 }, + { type: "boolean", identifier: "night_mode", default: 0 }, + { type: "ratio", identifier: "mix", default: 0.5 }, + ], + parameterOverrides: { "param-gravity": "9.81" }, + initialState: { type: "code", content: "return {};" }, +}; + +describe("initialMarkingToAdHocPlaces", () => { + const marking: InitialMarking = { + "place-space": [ + { altitude: 400, active: true, tag: "0000-aa" }, + { altitude: 550.5, active: false, tag: "0000-bb" }, + ], + "place-debris": 40, + }; + + it("converts tokens to literal fixed rows in colour-element order", () => { + const { places, truncated } = initialMarkingToAdHocPlaces(marking, CONTEXT); + expect(truncated).toEqual([]); + const space = places["place-space"]!; + expect(space.kind).toBe("coloured"); + if (space.kind !== "coloured") { + return; + } + expect(space.rows).toHaveLength(2); + expect(space.rows[0]!.kind).toBe("fixed"); + expect(space.rows[0]!.cells.map((cell) => cell.expression)).toEqual([ + "400", + "true", + '"0000-aa"', + ]); + expect(space.rows[1]!.cells[1]!.expression).toBe("false"); + expect(space.variables).toEqual([]); + }); + + it("converts counts to literal uncoloured states", () => { + const { places } = initialMarkingToAdHocPlaces(marking, CONTEXT); + const debris = places["place-debris"]!; + expect(debris.kind).toBe("uncoloured"); + if (debris.kind === "uncoloured") { + expect(debris.count.expression).toBe("40"); + } + }); + + it("caps rows per place and reports the cut", () => { + const long: InitialMarking = { + "place-space": Array.from({ length: 130 }, (_, index) => ({ + altitude: index, + active: false, + tag: "t", + })), + }; + const { places, truncated } = initialMarkingToAdHocPlaces(long, CONTEXT); + const space = places["place-space"]!; + expect(space.kind === "coloured" && space.rows.length).toBe(100); + expect(truncated).toEqual([{ placeName: "Space", shown: 100, total: 130 }]); + }); + + it("keeps every token when the cap is lifted", () => { + // The quick-simulation form seeds its editable draft from the marking, + // where a cap would drop tokens the moment the user typed. + const long = { + "place-space": Array.from({ length: 130 }, (_unused, index) => ({ + altitude: index, + active: false, + tag: "t", + })), + }; + const { places, truncated } = initialMarkingToAdHocPlaces( + long, + CONTEXT, + Number.POSITIVE_INFINITY, + ); + const space = places["place-space"]!; + expect(space.kind === "coloured" && space.rows.length).toBe(130); + expect(truncated).toEqual([]); + }); + + it("skips places absent from the marking", () => { + const { places } = initialMarkingToAdHocPlaces( + { "place-debris": 0 }, + CONTEXT, + ); + expect(places["place-space"]).toBeUndefined(); + }); +}); + +describe("classicRunVariables", () => { + it("names Variables by the identifier verbatim and seeds overrides", () => { + const variables = classicRunVariables(SCENARIO, { + initialAltitude: "35", + night_mode: "1", + }); + expect(variables.map((variable) => variable.name)).toEqual([ + "launch_rate", + "initialAltitude", + "night_mode", + "mix", + ]); + expect(variables[0]).toMatchObject({ + type: "real", + expression: "0.3", + exposed: true, + }); + expect(variables[1]!.expression).toBe("35"); + expect(variables[2]).toMatchObject({ type: "boolean", expression: "true" }); + expect(variables[3]).toMatchObject({ type: "real", expression: "0.5" }); + }); +}); + +describe("classicScenarioRunState", () => { + it("carries overrides and the materialized marking", () => { + const { state } = classicScenarioRunState( + SCENARIO, + { "place-debris": 3 }, + CONTEXT, + {}, + ); + expect(state.netParameters).toEqual([ + { parameterId: "param-gravity", expression: "9.81", optimize: null }, + ]); + expect(state.places["place-debris"]).toBeDefined(); + expect(state.variables).toHaveLength(4); + }); +}); + +describe("classicRunParameterValues", () => { + const stateWith = (expressions: Record) => ({ + variables: classicRunVariables(SCENARIO, {}).map((variable) => + Object.prototype.hasOwnProperty.call(expressions, variable.name) + ? { ...variable, expression: expressions[variable.name]! } + : variable, + ), + netParameters: [], + places: {}, + }); + + it("pushes literal values under the classic identifiers", () => { + const values = classicRunParameterValues( + stateWith({ initialAltitude: "42", night_mode: "true" }), + SCENARIO, + ); + expect(values).toContainEqual({ + identifier: "initialAltitude", + value: "42", + }); + expect(values).toContainEqual({ identifier: "night_mode", value: "1" }); + expect(values).toContainEqual({ identifier: "launch_rate", value: "0.3" }); + }); + + it("reads an emptied cell as its type's neutral", () => { + // The cell shows `0` / `false` when empty, and the run has to agree: + // ignoring the edit left the previous value in effect behind it. + const values = classicRunParameterValues( + stateWith({ initialAltitude: "", night_mode: "" }), + SCENARIO, + ); + expect(values).toContainEqual({ + identifier: "initialAltitude", + value: "0", + }); + expect(values).toContainEqual({ identifier: "night_mode", value: "0" }); + }); + + it("skips non-literal expressions so the previous value stands", () => { + const values = classicRunParameterValues( + stateWith({ initialAltitude: "42 +", night_mode: "maybe" }), + SCENARIO, + ); + expect( + values.find((value) => value.identifier === "initialAltitude"), + ).toBeUndefined(); + expect( + values.find((value) => value.identifier === "night_mode"), + ).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts new file mode 100644 index 00000000000..ae464963e8e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/materialize-run-state.ts @@ -0,0 +1,217 @@ +/** + * A classic scenario shown through the ad-hoc form: Simulation Settings + * renders every selected scenario in the form's run mode, and a scenario + * that was not authored ad-hoc has no `AdHocScenarioState` to show — so one + * is materialized from what the run will actually start with. The compiled + * initial marking (`compileScenario`) becomes literal read-only token rows, + * the scenario's parameter overrides become read-only parameter entries, + * and its scenario parameters become the editable exposed Variables. + * + * Classic scenario parameter identifiers are used verbatim as the Variable + * names, so value pushes never route through the ad-hoc snake_case name + * mapping — a camelCase identifier must round-trip unchanged. + */ + +import type { + AdHocPlaceState, + AdHocScenarioState, + AdHocVariable, + Color, + Place, + Scenario, +} from "../../../../types/sdcpn"; +import type { InitialMarking } from "../../../api"; + +/** Places longer than this render only their first rows in the preview. */ +export const CLASSIC_RUN_ROW_CAP = 100; + +export type MaterializeContext = { + places: Place[]; + types: Color[]; +}; + +export type TruncatedPlace = { + placeName: string; + shown: number; + total: number; +}; + +function literalExpression(value: number | boolean | bigint | string): string { + switch (typeof value) { + case "number": + return String(value); + case "bigint": + return value.toString(); + case "boolean": + return value ? "true" : "false"; + default: + return JSON.stringify(value); + } +} + +/** + * Converts a compiled initial marking into read-only ad-hoc place states: + * one fixed row per token, cells ordered by the colour's elements, counts + * as literals. Rows beyond {@link CLASSIC_RUN_ROW_CAP} are dropped and the + * cut is reported, so the caller can say what the preview omits. + */ +export function initialMarkingToAdHocPlaces( + marking: InitialMarking, + context: MaterializeContext, + rowCap: number = CLASSIC_RUN_ROW_CAP, +): { places: Record; truncated: TruncatedPlace[] } { + const places: Record = {}; + const truncated: TruncatedPlace[] = []; + for (const place of context.places) { + const entry = Object.prototype.hasOwnProperty.call(marking, place.id) + ? marking[place.id] + : undefined; + if (entry === undefined) { + continue; + } + if (typeof entry === "number") { + places[place.id] = { + kind: "uncoloured", + count: { expression: literalExpression(entry), optimize: null }, + }; + continue; + } + const colour = context.types.find((type) => type.id === place.colorId); + if (!colour) { + continue; + } + const shown = entry.slice(0, rowCap); + if (entry.length > shown.length) { + truncated.push({ + placeName: place.name, + shown: shown.length, + total: entry.length, + }); + } + places[place.id] = { + kind: "coloured", + variables: [], + rows: shown.map((token) => ({ + kind: "fixed", + cells: colour.elements.map((element) => ({ + expression: literalExpression( + token[element.name] ?? (element.type === "boolean" ? false : 0), + ), + optimize: null, + })), + })), + sharedColumns: {}, + }; + } + return { places, truncated }; +} + +const variableType = ( + type: Scenario["scenarioParameters"][number]["type"], +): AdHocVariable["type"] => (type === "ratio" ? "real" : type); + +/** + * The editable half of the pseudo-state: one exposed Variable per scenario + * parameter, named by the parameter's identifier verbatim, seeded from this + * run's override where one exists (engine values are numeric strings; + * booleans arrive as "1"/"0" and become literals). + */ +export function classicRunVariables( + scenario: Scenario, + overrides: Readonly>, +): AdHocVariable[] { + return scenario.scenarioParameters + .filter((parameter) => parameter.identifier.trim() !== "") + .map((parameter) => { + const override = Object.prototype.hasOwnProperty.call( + overrides, + parameter.identifier, + ) + ? overrides[parameter.identifier] + : undefined; + const numeric = override ?? String(parameter.default); + const expression = + parameter.type === "boolean" + ? Number(numeric) === 0 + ? "false" + : "true" + : numeric; + return { + name: parameter.identifier, + type: variableType(parameter.type), + expression, + exposed: true, + optimize: null, + }; + }); +} + +/** + * The full pseudo-state the form renders for a classic scenario: editable + * scenario parameters, read-only parameter overrides, and the materialized + * initial state. + */ +export function classicScenarioRunState( + scenario: Scenario, + marking: InitialMarking, + context: MaterializeContext, + overrides: Readonly>, +): { state: AdHocScenarioState; truncated: TruncatedPlace[] } { + const { places, truncated } = initialMarkingToAdHocPlaces(marking, context); + return { + state: { + variables: classicRunVariables(scenario, overrides), + netParameters: Object.entries(scenario.parameterOverrides).map( + ([parameterId, expression]) => ({ + parameterId, + expression, + optimize: null, + }), + ), + places, + }, + truncated, + }; +} + +/** + * The run values an edited pseudo-state produces, keyed by the classic + * identifiers. Only literal values push (`12`, `0.5`, `true`) — an + * expression mid-edit produces nothing, and the previous value stands until + * the text is a literal again. + */ +export function classicRunParameterValues( + state: AdHocScenarioState, + scenario: Scenario, +): { identifier: string; value: string }[] { + const values: { identifier: string; value: string }[] = []; + for (const parameter of scenario.scenarioParameters) { + const variable = state.variables.find( + (candidate) => candidate.name === parameter.identifier, + ); + if (!variable) { + continue; + } + const text = variable.expression.trim(); + // An emptied cell reads as its type's neutral everywhere else in the + // form, so a run takes the neutral too. Ignoring it instead left the + // previous value running behind a cell that showed `0` / `false`. + if (text === "") { + values.push({ identifier: parameter.identifier, value: "0" }); + continue; + } + if (parameter.type === "boolean") { + if (text === "true" || text === "false") { + values.push({ + identifier: parameter.identifier, + value: text === "true" ? "1" : "0", + }); + } + continue; + } + if (Number.isFinite(Number(text))) { + values.push({ identifier: parameter.identifier, value: text }); + } + } + return values; +} diff --git a/libs/@hashintel/petrinaut/.storybook/preview.tsx b/libs/@hashintel/petrinaut/.storybook/preview.tsx index 4f7b441f25e..e9f248b4d5f 100644 --- a/libs/@hashintel/petrinaut/.storybook/preview.tsx +++ b/libs/@hashintel/petrinaut/.storybook/preview.tsx @@ -2,9 +2,25 @@ import "../src/ui/index.css"; import { useRef } from "react"; import { PortalContainerContext } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; import type { Preview } from "@storybook/react-vite"; +// The layer covers the whole story, so it must let presses through to the +// story beneath it — but its children are the portalled surfaces themselves +// (menus, selects, the ad-hoc value editor), and those have to be clickable. +// Without the child rule every portalled surface in Storybook is inert, which +// the app never is: there the portal container is `.petrinaut-root` itself. +const portalLayerStyle = css({ + position: "absolute", + inset: "[0]", + zIndex: "[99999]", + pointerEvents: "none", + "& > *": { + pointerEvents: "auto", + }, +}); + const preview: Preview = { decorators: [ (Story) => { @@ -15,18 +31,7 @@ const preview: Preview = { // Required (for now) given design tokens are scoped to .petrinaut-root className="petrinaut-root" > -
+
diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 404d507219c..75fa8ee8e29 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -2,27 +2,32 @@ An **ad-hoc scenario** is an initial state and a set of parameter values defined inline, right where you run -- without saving a [scenario](scenarios.md) first. Petrinaut compiles what you enter through a scenario generated for that run. Nothing is added to the net's scenario list, and leaving the form discards nothing: your entries stay until you clear them. -Use an ad-hoc scenario for one-off runs and quick exploration. When you want to keep a configuration, name it, or compare several setups, [create a scenario](scenarios.md#creating-a-scenario). +Use an ad-hoc scenario for one-off runs and quick exploration. When you want to keep a configuration, name it, or compare several setups, [create a scenario](scenarios.md#creating-a-scenario) -- with the feature enabled, the creation form is the same ad-hoc form, plus a **Scenario Parameter** toggle on each Variable. + +## Enabling the feature + +Ad-hoc scenarios are **experimental and off by default**. Turn them on in the viewport settings dialog (the gear button over the canvas): the **Ad-hoc scenarios** toggle under General. While the setting is off, every surface below renders exactly as before the feature -- "No scenario" simply means the model's own initial marking. ## Where the form appears The same form appears in three places, always when **no scenario is selected**: -1. **Quick simulation** -- in the [Simulation Settings](simulation.md#simulation-settings) tab, with "No scenario" selected, the **Initial state** column edits token counts and values directly in the panel -- no separate dialog. A **Clear** button appears next to the column's title once you have entries. This embedding shows only the initial state: the panel's own parameter inputs set parameter values, and there are no Variables here. The next simulation run uses what you defined. Any [compile error](#errors) appears in the settings panel's error banner. +1. **Quick simulation** -- in the [Simulation Settings](simulation.md#simulation-settings) tab, with "No scenario" selected, the **Parameters** and **Initial state** columns are the form's own tables: parameter overrides as a spreadsheet on the left, token counts and values in the middle -- no separate dialog. A **Clear** button appears next to the Initial state title once you have entries. There are no Variables in this embedding. The next simulation run uses what you defined. Any [compile error](#errors) appears in the settings panel's error banner. 2. **Experiments** -- in the [create-experiment drawer](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. -3. **Optimizations** -- in the [create-optimization drawer](optimization.md#creating-an-optimization), the scenario picker offers **Ad-hoc (define inline)**. This is the only surface where the form shows **Optimize** controls (see below). +3. **Optimizations** -- in the [create-optimization drawer](optimization.md#creating-an-optimization), the scenario picker offers **No scenario** too. This is the only surface where the form shows **Optimize** controls (see below). +4. **Scenario creation** -- [creating or editing a scenario](scenarios.md#creating-a-scenario) uses the same form with a **Scenario Parameter** toggle on each top-level Variable; see [Saved ad-hoc scenarios](#saved-ad-hoc-scenarios). ## The form -The form has up to three sections: +The form has up to three sections. Variables come first -- parameter overrides may read them: -- **Parameters** -- one row per [net-level parameter](petri-net-extensions.md#global-parameters), showing its type and its value. An untouched parameter shows its default quietly, marked with a small `default` tag; enter an expression to override the value for this run. In the quick-simulation embedding this section is its own panel beside Initial state. - **Variables** -- named values (real, integer, or boolean) written as `scenario.` in every expression below, exactly as scenario parameters are written in scenario code. Use them to drive many values from one number. Add one from the dimmed **Add a variable** line at the bottom of the list: like any cell, a first click selects it and a second click (or Enter, or its gutter's `+`) adds the variable -- or reach it with the down arrow from the last row; the fresh name opens ready to type. Each row starts with a small variable-glyph gutter whose menu offers **Delete variable**, and the add line's gutter shows a `+`. A variable's name edits like any other cell: select it, then press Enter (or click again) to edit, and Enter or Escape to leave. Its type select is a cell too: arrow keys move past it, Enter opens it. In the quick-simulation embedding, Variables sit above Parameters in the left column. -- **Initial state** -- one block per place in the net. +- **Parameters** -- one row per [net-level parameter](petri-net-extensions.md#global-parameters), showing its type and its value. An untouched parameter shows its default quietly, marked with a small `default` tag; enter an expression to override the value for this run -- it may read the Variables above. In the quick-simulation embedding this section is its own panel beside Initial state. +- **Initial state** -- one block per place in the net. Each place's title carries its token colour dot (grey for untyped places). In the experiment and optimization drawers each section collapses: click the chevron in its header, or focus the header and press Left to collapse and Right to expand. Place headers inside Initial state collapse the same way everywhere, and a collapsed place shows a one-line summary of its rows and token total. In the quick-simulation embedding, places start collapsed. -Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the optimization drawer -- the Optimize control below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md) scenarios use. Press Enter, Escape, or click elsewhere to close the editor; closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. +Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the optimization drawer -- the Optimize control below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md) scenarios use. Press Enter, Escape, or click elsewhere to close the editor. Escape closes only the innermost thing that is open -- a completion list, a bound edit, the editor itself -- and never the drawer or dialog around the form; close those from their own buttons. Closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. Opening a value with Enter or a second click selects its whole content, so typing replaces it. Opening by typing keeps the caret right after what you typed. @@ -40,7 +45,7 @@ Focusing a value highlights what it is connected to, in amber. A cell that reads ### Places without a token type -A place without a token type is one line: the place's name and a single **token count** slot after a `×` mark. +A place without a token type is its name above one full-width **token count** cell -- the same expression cell as everywhere else, in its own bordered box. ### Places with a token type @@ -76,14 +81,22 @@ At least one Optimize selection is required to run; a cell muted by a shared col Each selection becomes a generated scenario parameter with a deterministic name, and optimization results attribute back to your selections by these names: -- `adhoc..r.` -- a cell in a fixed or dynamic row. -- `adhoc..col.` -- a shared column value. -- `adhoc.count.` -- an untyped place's count; `adhoc.count..r` for a dynamic row's count. -- `adhoc.var.net.` -- a top-level Variable; place-scoped variables use the place's name as the scope. -- `adhoc.param.` -- a net parameter override. +- `adhoc__r_` -- a cell in a fixed or dynamic row. +- `adhoc__col_` -- a shared column value. +- `adhoc_count_` -- an untyped place's count; `adhoc_count__r` for a dynamic row's count. +- `adhoc_var_net_` -- a top-level Variable; place-scoped variables use the place's name as the scope. +- `adhoc_param_` -- a net parameter override. Optimized values follow the same rules as [scenario parameter domains](optimization.md#search-domains): bounds must be expressions that resolve to finite constants, integer domains need integer bounds and a positive step, and logarithmic domains need a positive minimum. One optimized value cannot appear in another optimized value's bounds. +## Saved ad-hoc scenarios + +With the feature enabled, [creating a scenario](scenarios.md#creating-a-scenario) opens the same form -- name and description above it -- as the one authoring surface (there is no "Define as code" toggle in this mode). Each top-level Variable's row carries a **Scenario Parameter** toggle: an exposed Variable becomes one of the saved scenario's tunable parameters, named after the Variable in snake_case (`baseLoad` becomes `base_load`), defaulting to its expression's value -- which must therefore be a constant. Everyone running the scenario can then adjust it wherever scenario parameters appear, without editing the scenario. + +Saving keeps your form entries as the scenario's definition, so editing the scenario reopens exactly the form you left. + +Selecting a saved ad-hoc scenario in Simulation Settings shows it through the same form, read-only: only the scenario parameters (the exposed Variables) take value edits, for that run alone; auxiliary Variables stay hidden, and the parameter overrides and initial state can be browsed with the usual keyboard navigation but not changed. A scenario authored this way always edits through the ad-hoc form, whatever the setting says -- the classical form cannot represent it. + ## Errors Ad-hoc definitions are validated as you type, on the value they belong to, and again when you run. In quick simulation, compile problems also appear in the Simulation Settings error banner; in the experiment and optimization drawers, in the footer. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index a752a2896f8..c7a61f1e909 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -20,6 +20,8 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo | **Scenario parameters** | each scenario parameter's default | When a scenario is selected, you can override its scenario parameters per experiment. Expressions are evaluated once at start. | With "No scenario" selected, the Scenario section shows the [ad-hoc scenario form](ad-hoc-scenarios.md): define the initial state and parameter values inline for this experiment, without saving a scenario. Left untouched, the experiment runs from the manually-set markings and defaults as before. The experiments table shows "Ad-hoc scenario" in its Scenario column for such runs. + +With a scenario selected (and ad-hoc scenarios enabled), the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. The preview sits in its own tinted panel and scrolls as one, so a net with many places leaves the rest of the drawer in reach. | **Runs** | `1000` | Positive integer; how many independent simulations to run. | | **Time step (dt)** | `0.1` | Same meaning as in single-run simulations (see [Simulation](simulation.md#time-step-dt)). | | **Max time (seconds)** | `180` | Each run advances until simulation time reaches this value, then completes. | diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index 5cff7caed1f..7847c2e55f7 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -37,6 +37,8 @@ You will need scenarios when you want to: The view drawer (opened by clicking a row in the Scenarios list) is the same form populated with the existing values. It has **Close** and **Save** buttons. +With the experimental [Ad-hoc scenarios](ad-hoc-scenarios.md#enabling-the-feature) setting on, the Create Scenario drawer instead shows the [ad-hoc form](ad-hoc-scenarios.md#saved-ad-hoc-scenarios): name and description above one inline Initial State + Parameters form, with a **Scenario Parameter** toggle on each Variable and no "Define as code" toggle. A scenario created that way always edits through the same form. + ## Initial state: per-place vs code The Initial State section has a **Define as code** toggle. diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index bb14bd02f49..0157d283d5e 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -32,7 +32,7 @@ Quick-action buttons next to the picker let you edit the selected scenario, crea Override values for this run: - With **No scenario** selected: each [net-level parameter](petri-net-extensions.md#global-parameters) shows its name and variable name. Boolean parameters use a toggle; real and integer parameters use a numeric input pre-filled with the default. -- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter bindings](scenarios.md#parameter-bindings) and are not editable here. +- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter bindings](scenarios.md#parameter-bindings) and are not editable here. Every selected scenario shows through the [ad-hoc form](ad-hoc-scenarios.md): its scenario parameters take value edits in the left column, and its parameter overrides and initial state sit read-only in the right one -- browsable with the same keyboard navigation, but only a scenario edit (the pencil next to the picker) changes them. A scenario saved from the ad-hoc form shows its definition; any other scenario shows a computed preview of the exact tokens the run will start with, recomputed as you change parameter values (very large places preview their first 100 rows). Changes here do not modify the parameter definition or the scenario -- they only apply to the simulation. Parameter values are locked while a simulation is running. Reset the simulation to change them. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index cc3b1bebbd3..2eab8fd2383 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -44,6 +44,10 @@ Controls selection box behavior in [Select mode](drawing-a-net.md#pan-and-select Replaces the tabbed left sidebar with a unified **tree view** showing all entities (nodes, types, equations, parameters) in a single hierarchy. +### Ad-hoc scenarios (experimental) + +Off by default. Enables the [ad-hoc scenario form](ad-hoc-scenarios.md): defining initial state and parameters inline in Simulation Settings, the experiment and optimization drawers, and the scenario creation form. Off, "No scenario" everywhere means the model's own initial marking, as before. + ### Arcs rendering Choose how arcs are drawn between nodes: diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index ed0ef39eb8b..a7b65e2546c 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -181,8 +181,8 @@ const LanguageClientOverride = ({ Promise.resolve(compileHirArtifacts(sdcpn, extensions))), // Lower for real (inline instead of in a worker), so scenarios the // provider synthesizes — the ad-hoc path — actually compile. - requestScenarioHir: (scenario) => - Promise.resolve(lowerScenarioToHir(scenario)), + requestScenarioHir: (scenario, adHocContext) => + Promise.resolve(lowerScenarioToHir(scenario, { adHocContext })), }} > {children} diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index ead89c92f72..f50fc327de7 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -363,7 +363,11 @@ export const ExperimentsProvider: React.FC = ({ throw new Error(parsedScenarioValues.errors.join("\n")); } - const scenarioHir = await requestScenarioHir(selectedScenario); + const scenarioHir = await requestScenarioHir(selectedScenario, { + netParameters: globalParameters, + places: sdcpn.places, + types: sdcpn.types, + }); const compiledScenario = compileScenario( selectedScenario, scenarioHir, diff --git a/libs/@hashintel/petrinaut/src/react/lsp/context.ts b/libs/@hashintel/petrinaut/src/react/lsp/context.ts index 7f9c1283633..57ac591ea5a 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/context.ts +++ b/libs/@hashintel/petrinaut/src/react/lsp/context.ts @@ -1,6 +1,7 @@ import { createContext } from "react"; import type { + AdHocSynthesisContext, CompletionList, Diagnostic, DocumentUri, @@ -56,9 +57,14 @@ export interface LanguageClientContextValue { /** * Lower a scenario's expressions and code-mode body to HIR (in the * language worker). `compileScenario` type-checks and interprets the - * result. + * result. A scenario whose initial state is `adhoc` synthesizes against + * `adHocContext` first — without it, lowering that scenario reports an + * error item. */ - requestScenarioHir: (scenario: ScenarioLoweringInput) => Promise; + requestScenarioHir: ( + scenario: ScenarioLoweringInput, + adHocContext?: AdHocSynthesisContext, + ) => Promise; /** * Re-print a single scenario-expression canonically (normalized spacing, * minimal parentheses, numeric literals preserved). Resolves null when the diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index 67fc72b6f54..c602a3adba4 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -442,23 +442,18 @@ export const SimulationProvider: React.FC = ({ ? currentState.parameterValues : {}; // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render - const namedScenarioToCompile = tweakedScenarioRef.current; - // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render - const adHocRunState = adHocRunScenarioRef.current; - if (namedScenarioToCompile === null && adHocRunState?.ok === false) { + const scenarioToCompile = scenarioToCompileRef.current; + + // Refused before anything is torn down: a definition that does not + // compile leaves the run that is already going, and only reports. + if (scenarioToCompile?.kind === "invalid") { const refusal = new Error( - `The inline scenario definition has errors:\n${adHocRunState.messages.join("\n")}`, + `The inline initial state does not compile:\n${scenarioToCompile.messages.join("\n")}`, ); setError(refusal.message); setErrorItemId(null); throw refusal; } - const adHocScenarioToCompile = adHocRunState?.ok - ? adHocRunState.scenario - : null; - const scenarioToCompile = namedScenarioToCompile ?? adHocScenarioToCompile; - const adHocRun = - namedScenarioToCompile === null && adHocScenarioToCompile !== null; // Dispose any active simulation before starting a new one. Update both // the ref and React state so same-tick callers see the cleared handle. @@ -477,30 +472,28 @@ export const SimulationProvider: React.FC = ({ let initialMarking = manualInitialMarking; let parameterValues: Record = manualParameterValues; if (scenarioToCompile) { - const scenarioHir = await requestScenarioHir({ - parameterOverrides: scenarioToCompile.parameterOverrides, - initialState: scenarioToCompile.initialState, - }); + const scenarioHir = await requestScenarioHir( + { + parameterOverrides: scenarioToCompile.scenario.parameterOverrides, + initialState: scenarioToCompile.scenario.initialState, + }, + // A persisted ad-hoc scenario synthesizes against the net inside + // the worker; other kinds ignore the context. + { + netParameters: simulationExtensions.parameters + ? sdcpn.parameters + : [], + places: sdcpn.places, + types: sdcpn.types, + }, + ); if (initializationGenerationRef.current !== generation) { return; } - // The ad-hoc definition reads net parameters at the panel's current - // values (the render path compiles it the same way); a named - // scenario keeps the parameters' own defaults, its overrides rule. - const compileParameters = simulationExtensions.parameters - ? adHocRun - ? sdcpn.parameters.map((parameter) => ({ - ...parameter, - defaultValue: - manualParameterValues[parameter.variableName] ?? - parameter.defaultValue, - })) - : sdcpn.parameters - : []; const outcome = compileScenario( - scenarioToCompile, + scenarioToCompile.scenario, scenarioHir, - compileParameters, + scenarioToCompile.netParameters, sdcpn.places, sdcpn.types, ); @@ -710,6 +703,13 @@ export const SimulationProvider: React.FC = ({ const scenarioHirState = useScenarioHir( selectedScenario ?? (adHocSynthesized?.ok ? adHocSynthesized.scenario : undefined), + // A persisted ad-hoc scenario synthesizes in the worker against the + // net context; the quick-sim definition was synthesized above already. + { + netParameters: extensions.parameters ? petriNetDefinition.parameters : [], + places: petriNetDefinition.places, + types: petriNetDefinition.types, + }, ); // Build a scenario with user-tweaked parameter values. @@ -807,25 +807,36 @@ export const SimulationProvider: React.FC = ({ }; } - // Snapshot for `initialize`, which compiles the scenario itself. - const tweakedScenarioRef = useLatest(tweakedScenario); - // The quick-sim ad-hoc definition, synthesized to an ordinary scenario: - // Play compiles it exactly like a selected scenario, so the run starts - // from the defined tokens rather than the manual marking. A failed - // synthesis is carried too — a run must refuse a broken definition, the - // way a broken named scenario refuses, never silently fall back to the - // manual marking behind the error banner. - const adHocRunScenarioRef = useLatest( - adHocSynthesized - ? adHocSynthesized.ok - ? { ok: true as const, scenario: adHocSynthesized.scenario } - : { - ok: false as const, - messages: adHocSynthesized.errors.map( - (synthesisError) => synthesisError.message, - ), - } - : null, + // Snapshot for `initialize`, which compiles the scenario itself: the + // saved (tweaked) scenario, or the quick-sim ad-hoc definition already + // synthesized above — a run must consume the same definition AND the + // same net parameters the render preview compiled with (the ad-hoc + // preview overlays the panel's values onto the defaults). + const scenarioToCompileRef = useLatest( + tweakedScenario + ? { + kind: "scenario" as const, + scenario: tweakedScenario, + netParameters: extensions.parameters + ? petriNetDefinition.parameters + : [], + } + : adHocSynthesized + ? adHocSynthesized.ok + ? { + kind: "scenario" as const, + scenario: adHocSynthesized.scenario, + netParameters: adHocNetParameters, + } + : // A broken inline definition must refuse to run — falling back + // to the manual marking would silently ignore what was typed. + { + kind: "invalid" as const, + messages: adHocSynthesized.errors.map( + (synthesisError) => synthesisError.message, + ), + } + : null, ); const contextValue: SimulationContextValue = { diff --git a/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts b/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts index 99a084c2c14..f0faad75517 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts +++ b/libs/@hashintel/petrinaut/src/react/simulation/use-scenario-hir.ts @@ -3,6 +3,7 @@ import { use, useEffect, useState } from "react"; import { LanguageClientContext } from "../lsp/context"; import type { + AdHocSynthesisContext, Scenario, ScenarioHir, ScenarioLoweringInput, @@ -17,9 +18,46 @@ export type ScenarioHirState = { /** Serializes the parts of a scenario that lowering depends on: its code, * not its parameter defaults or coloured-place token rows — tweaking a value - * or editing a row must not re-lower. The key doubles as the request payload - * (parsed back in the effect), so the effect depends on nothing else. */ -const loweringKey = (scenario: Scenario): string => { + * or editing a row must not re-lower. An ad-hoc scenario also depends on the + * net context it synthesizes against, so that joins the key. The key doubles + * as the request payload (parsed back in the effect), so the effect depends + * on nothing else. */ +type LoweringPayload = { + scenario: ScenarioLoweringInput; + adHocContext?: AdHocSynthesisContext; +}; + +/** + * Synthesis reads only a place's id, name, and colour, and only a type's + * identity and elements — geometry and dynamics fields must not churn the + * key (dragging a node would re-lower otherwise). + */ +const projectAdHocContext = ( + context: AdHocSynthesisContext, +): AdHocSynthesisContext => ({ + netParameters: context.netParameters, + places: context.places.map((place) => ({ + id: place.id, + name: place.name, + colorId: place.colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + })), + types: context.types.map((type) => ({ + id: type.id, + name: type.name, + iconSlug: type.iconSlug, + displayColor: type.displayColor, + elements: type.elements, + })), +}); + +const loweringKey = ( + scenario: Scenario, + adHocContext: AdHocSynthesisContext | undefined, +): string => { const initialState = scenario.initialState.type === "per_place" ? { @@ -34,9 +72,14 @@ const loweringKey = (scenario: Scenario): string => { } : scenario.initialState; return JSON.stringify({ - parameterOverrides: scenario.parameterOverrides, - initialState, - } satisfies ScenarioLoweringInput); + scenario: { + parameterOverrides: scenario.parameterOverrides, + initialState, + }, + ...(scenario.initialState.type === "adhoc" && adHocContext + ? { adHocContext: projectAdHocContext(adHocContext) } + : {}), + } satisfies LoweringPayload); }; const PENDING: ScenarioHirState = { hir: null, error: null }; @@ -51,9 +94,11 @@ const PENDING: ScenarioHirState = { hir: null, error: null }; */ export function useScenarioHir( scenario: Scenario | undefined, + /** The net context an `adhoc` initial state synthesizes against. */ + adHocContext?: AdHocSynthesisContext, ): ScenarioHirState { const { requestScenarioHir } = use(LanguageClientContext); - const key = scenario ? loweringKey(scenario) : null; + const key = scenario ? loweringKey(scenario, adHocContext) : null; const [entry, setEntry] = useState<{ key: string; @@ -65,8 +110,8 @@ export function useScenarioHir( return; } let cancelled = false; - const input = JSON.parse(key) as ScenarioLoweringInput; - requestScenarioHir(input) + const payload = JSON.parse(key) as LoweringPayload; + requestScenarioHir(payload.scenario, payload.adHocContext) .then((hir) => { if (!cancelled) { setEntry({ key, state: { hir, error: null } }); diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx index 5805444a701..44539bacbc7 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx @@ -58,6 +58,9 @@ class ObserverStub { globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver = ObserverStub as unknown as typeof IntersectionObserver; +// jsdom implements no scrolling at all, and the Scale list scrolls itself to +// the selected option when it opens. +Element.prototype.scrollTo = () => {}; afterEach(cleanup); @@ -111,12 +114,14 @@ const Harness: React.FC<{ initial?: AdHocScenarioState; withVariables?: boolean; renderLayout?: React.ComponentProps["renderLayout"]; + mode?: React.ComponentProps["mode"]; }> = ({ selection = "optimize", onState, initial = EMPTY_AD_HOC_STATE, withVariables, renderLayout, + mode, }) => { const [state, setState] = useState(initial); return ( @@ -130,6 +135,7 @@ const Harness: React.FC<{ selection={selection} withVariables={withVariables} renderLayout={renderLayout} + mode={mode} /> ); }; @@ -917,6 +923,66 @@ describe("AdHocScenarioForm", () => { expect(screen.queryByText("Scale")).toBe(null); }); + it("keeps the optimize bounds usable while the slab is open", async () => { + let latest: AdHocScenarioState | undefined; + const initial: AdHocScenarioState = { + variables: [ + { + name: "n", + type: "integer", + expression: "2", + optimize: { min: "0", max: "10", step: "1", scale: "linear" }, + }, + ], + netParameters: [], + places: {}, + }; + render( + { + latest = state; + }} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "n" })); + const minCell = await screen.findByRole("button", { name: "Min of n" }); + const slab = document.querySelector("[data-adhoc-slab]"); + + // The slab takes its own pointer events: a portal container is often a + // pointer-transparent layer, and a click-through slab hands every press + // to the row beneath it — which the dismiss handler reads as a press + // outside and closes the slab on. + expect(slab?.className).toContain("pointer-events_auto"); + + // A layer opened from inside the slab lives inside it, so pressing its + // options is not an outside press. + fireEvent.click(screen.getByRole("combobox", { name: "Scale of n" })); + const scaleList = await screen.findByRole("listbox"); + expect(slab?.contains(scaleList)).toBe(true); + fireEvent.keyDown(scaleList, { key: "Escape" }); + + // Editing one bound keeps focus in its editor. Each keystroke dispatches + // and re-renders the slab, and the one-shot Min selection must not + // re-arm on the re-render: it used to steal focus mid-edit, so the next + // character overwrote Min instead. + fireEvent.keyDown(minCell, { key: "ArrowRight" }); + fireEvent.click(screen.getByRole("button", { name: "Max of n" })); + const editor = await screen.findByRole("textbox", { name: "Expression" }); + // The real editor focuses itself on mount; the jsdom stand-in does not. + editor.focus(); + fireEvent.change(editor, { target: { value: "12" } }); + expect(latest?.variables[0]?.optimize?.max).toBe("12"); + expect(document.activeElement).toBe(editor); + // ...on the commit after that one too, which is where a guard re-armed + // by the ref churn would have taken the focus. + fireEvent.change(editor, { target: { value: "125" } }); + expect(latest?.variables[0]?.optimize?.max).toBe("125"); + expect(document.activeElement).toBe(editor); + expect(latest?.variables[0]?.optimize?.min).toBe("0"); + }); + it("selects on pointer click and opens the menu from the dots", async () => { render(); fireEvent.click( @@ -1069,6 +1135,91 @@ describe("AdHocScenarioForm", () => { expect(latest?.variables).toHaveLength(1); }); + const RUN_STATE: AdHocScenarioState = { + variables: [ + { + name: "altitude", + type: "real", + expression: "400", + optimize: null, + exposed: true, + }, + { name: "helper", type: "real", expression: "2", optimize: null }, + ], + netParameters: [], + places: { + "place-pumps": { + kind: "coloured", + variables: [ + { name: "boost", type: "real", expression: "1", optimize: null }, + ], + rows: [ + { + kind: "fixed", + cells: [ + { expression: "400", optimize: null }, + { expression: "false", optimize: null }, + ], + }, + ], + sharedColumns: {}, + }, + }, + }; + + it("run mode lists only exposed variables and offers no structural edits", () => { + render(); + + // The exposed Variable is a static-name row; the auxiliary top-level + // and per-place Variables are gone entirely. + expect(screen.getByText("altitude")).toBeTruthy(); + expect(screen.queryByText("helper")).toBe(null); + expect(screen.queryByText("boost")).toBe(null); + // No add-lines, no row menus, no dots affordance. + expect(screen.queryByRole("button", { name: /Add a variable/ })).toBe(null); + expect(screen.queryByRole("button", { name: /Add a token row/ })).toBe( + null, + ); + expect(screen.queryByRole("button", { name: "Row 1 menu" })).toBe(null); + }); + + it("run mode edits exposed values and nothing else, but still walks", () => { + let latest: AdHocScenarioState | undefined; + render( + { + latest = state; + }} + />, + ); + + // A read-only cell: activation opens no editor, Delete clears nothing, + // but arrows still walk the grid. + const cell = screen.getByRole("button", { + name: "Pumps › item 0 › pressure", + }); + cell.focus(); + fireEvent.click(cell, { detail: 0 }); + expect(screen.queryByLabelText("Expression")).toBe(null); + fireEvent.keyDown(cell, { key: "Delete" }); + expect(latest).toBe(undefined); + fireEvent.keyDown(cell, { key: "ArrowRight" }); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Pumps › item 0 › worn", + ); + + // The exposed Variable's value cell opens and edits. + const value = screen.getByRole("button", { name: "altitude" }); + value.focus(); + fireEvent.click(value, { detail: 0 }); + const editor = screen.getByLabelText("Expression"); + fireEvent.change(editor, { target: { value: "500" } }); + expect(latest?.variables[0]?.expression).toBe("500"); + }); + it("renderLayout columns: vertical arrows stay, horizontal ones cross with memory", () => { render( React.ReactNode; /** Classname for the form's root element (the keyboard-handling div). */ className?: string; + /** + * Externally-owned LSP session id, so the host can address this form's + * diagnostics (a drawer footer summing errors); generated when omitted. + */ + sessionId?: string; } /** @@ -155,11 +172,13 @@ export const AdHocScenarioForm: React.FC = ({ onChange, context, selection, + mode = "author", withVariables = true, renderLayout, className, + sessionId: externalSessionId, }) => { - const sessionId = useAdHocLspSession(state); + const sessionId = useAdHocLspSession(state, externalSessionId); const { diagnosticsByUri, requestFormatExpression } = use( LanguageClientContext, ); @@ -171,6 +190,10 @@ export const AdHocScenarioForm: React.FC = ({ state, context, onChange, + // Run mode shows a saved scenario: the host owns the computed + // parameters and marking and recomputes them from the values edited + // here, so those arrivals are not separate undo steps. + mode === "run", ); // Escape pressed while focus is inside the form never reaches the host: @@ -270,6 +293,7 @@ export const AdHocScenarioForm: React.FC = ({ highlight, setFocusedValue, formatExpression: requestFormatExpression, + mode, dense: renderLayout !== undefined, overlayKeyDown: { capture: handleKeyDown, bubble: stopDeleteKeys }, }; @@ -279,13 +303,18 @@ export const AdHocScenarioForm: React.FC = ({ ) : null; - const variableRows = withVariables ? ( - - ) : null; + // Run mode lists the scenario's parameters (the exposed Variables, value + // edits only); authoring gets the full Variables editor. + const variableRows = + mode === "run" ? ( + + ) : withVariables ? ( + + ) : null; const placesList = (
diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts index 6df72ac2f83..20c03d73a62 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts @@ -33,7 +33,18 @@ export type AdHocFormSelection = "none" | "optimize" | "expose"; export const adHocSelectionText = (selection: AdHocFormSelection): string => selection === "expose" ? "Scenario Parameter" : "Optimize"; +/** + * What the form lets the user change. "author" is the full editor. "run" + * shows a saved scenario for a run: only the exposed top-level Variables + * (the scenario's parameters) accept value edits; auxiliary Variables are + * hidden, and everything else is read-only yet stays keyboard-navigable + * and selectable. + */ +export type AdHocFormMode = "author" | "run"; + export interface AdHocFormServices { + /** What the form lets the user change; see {@link AdHocFormMode}. */ + mode: AdHocFormMode; /** The whole form state, as currently edited. */ formState: AdHocScenarioState; /** @@ -82,6 +93,7 @@ export interface AdHocFormServices { } export const AdHocFormContext = createContext({ + mode: "author", formState: { variables: [], netParameters: [], places: {} }, dispatch: () => {}, synthesisContext: { netParameters: [], places: [], types: [] }, diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx index 19096519d8f..4c7ba34c4a1 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx @@ -15,28 +15,14 @@ import { FormSpreadsheet } from "./spreadsheet/form-spreadsheet"; import { cellStyle, dependencyHighlightStyle as highlightStyle, + staticNameCellStyle, + staticTypeCellStyle, } from "./spreadsheet/form-table"; import { OptimizeToggle } from "./spreadsheet/optimize-toggle"; import { ValueEditor } from "./value-editor"; import type { AdHocNetParameter } from "@hashintel/petrinaut-core"; -const parameterNameCellStyle = css({ - width: "[170px]", - display: "flex", - alignItems: "center", - height: "[28px]", - paddingX: "2", - fontSize: "xs", - fontWeight: "medium", - color: "neutral.s110", - overflow: "hidden", - whiteSpace: "nowrap", - // A long name fades out instead of clipping to an ellipsis. - maskImage: - "[linear-gradient(to right, black calc(100% - 14px), transparent)]", -}); - // An untouched parameter shows its default quietly: a small uppercase tag, // then the value — both lighter than an actual override. const defaultDisplayStyle = css({ @@ -56,19 +42,6 @@ const defaultTagStyle = css({ color: "neutral.s80", }); -// Matches the variables' type-select face: same width, plain (non-code) -// capitalized text, so the two columns read as one grammar. -const parameterTypeCellStyle = css({ - width: "[84px]", - display: "flex", - alignItems: "center", - height: "[28px]", - paddingX: "2", - fontSize: "xs", - color: "neutral.s80", - textTransform: "capitalize", -}); - const parameterOptimizeCellStyle = css({ width: "[92px]", paddingX: "1", @@ -80,8 +53,14 @@ export interface ParameterRowsProps { } export const ParameterRows: React.FC = ({ entries }) => { - const { synthesisContext, selection, highlight, setFocusedValue, dispatch } = - use(AdHocFormContext); + const { + mode, + synthesisContext, + selection, + highlight, + setFocusedValue, + dispatch, + } = use(AdHocFormContext); const { register, onKeyDown, attach } = useFocusGrid(); const entryFor = (parameterId: string): AdHocNetParameter => @@ -107,10 +86,10 @@ export const ParameterRows: React.FC = ({ entries }) => { className={cx(cellStyle, rowHighlight)} style={{ width: 170 }} > -
{parameter.name}
+
{parameter.name}
-
{parameter.type}
+
{parameter.type}
= ({ entries }) => { ) } kind={parameter.type} + readOnly={mode === "run"} placeholder={parameter.defaultValue} triggerRef={register(parameterIndex, 0)} onTriggerKeyDown={onKeyDown(parameterIndex, 0)} diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx index 09fcba381e4..a1a587e1a49 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx @@ -217,13 +217,18 @@ const PlaceBlockContents: React.FC = ({ colour, state, }) => { + const { mode } = use(AdHocFormContext); return ( <> - + {/* Run mode shows no auxiliary Variables — they are the saved + definition's internals, not something a run adjusts. */} + {mode === "run" ? null : ( + + )} ); @@ -324,7 +329,7 @@ export const UncolouredPlaceBlock: React.FC = ({ place, state, }) => { - const { dense } = use(AdHocFormContext); + const { mode, dense } = use(AdHocFormContext); const target = { kind: "count" as const, placeId: place.id, row: null }; // The count cell is a single-element member: vertical arrows leave to the // neighbouring member, horizontal ones cross into a sibling column. @@ -351,6 +356,7 @@ export const UncolouredPlaceBlock: React.FC = ({ value={state.count} target={target} kind="count" + readOnly={mode === "run"} placeholder="0 tokens" className={uncolouredCountTriggerStyle} triggerRef={attachTrigger} diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/scenario-parameter-rows.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/scenario-parameter-rows.tsx new file mode 100644 index 00000000000..18ccfe3ae85 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/scenario-parameter-rows.tsx @@ -0,0 +1,87 @@ +/** + * The scenario-parameters table of the form's run mode: one row per exposed + * top-level Variable — a static name, a static type, and the one thing a + * run may change, the value. No gutter, no phantom line, no rename: the + * scenario's structure belongs to its author, values belong to the run. + */ + +import { use } from "react"; + +import { cx } from "@hashintel/ds-helpers/css"; + +import { useFocusGrid } from "../../worksheet/use-focus-grid"; +import { adHocVariableKey } from "./dependency-highlight"; +import { AdHocFormContext } from "./form-context"; +import { FormSpreadsheet } from "./spreadsheet/form-spreadsheet"; +import { + cellStyle, + dependencyHighlightStyle as highlightStyle, + staticNameCellStyle, + staticTypeCellStyle, +} from "./spreadsheet/form-table"; +import { ValueEditor } from "./value-editor"; + +import type { AdHocVariable } from "@hashintel/petrinaut-core"; + +export interface ScenarioParameterRowsProps { + /** + * The form's top-level Variables, exposed and auxiliary alike; the rows + * render the exposed ones under their original indices, so value edits + * dispatch against the right slot. + */ + variables: AdHocVariable[]; +} + +export const ScenarioParameterRows: React.FC = ({ + variables, +}) => { + const { highlight } = use(AdHocFormContext); + const { register, onKeyDown, attach } = useFocusGrid(); + + const exposed = variables.flatMap((variable, index) => + variable.exposed ? [{ variable, index }] : [], + ); + if (exposed.length === 0) { + return null; + } + + return ( + + + {exposed.map(({ variable, index }, rowIndex) => { + const target = { + kind: "variable" as const, + placeId: null, + index, + }; + const highlighted = highlight.variableKeys.has( + adHocVariableKey(null, variable.name), + ); + const rowHighlight = highlighted && highlightStyle; + return ( + + +
{variable.name}
+ + +
{variable.type}
+ + + + + + ); + })} + +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/form-table.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/form-table.ts index d5d689647c7..fbbbe04ea26 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/form-table.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/form-table.ts @@ -326,6 +326,38 @@ export const phantomGutterButtonStyle = css({ }, }); +/** + * A read-structure name cell (a parameter's or scenario parameter's name): + * plain face, fading out under a mask when it overflows. + */ +export const staticNameCellStyle = css({ + display: "flex", + alignItems: "center", + height: "[28px]", + paddingX: "2", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s110", + overflow: "hidden", + whiteSpace: "nowrap", + maskImage: + "[linear-gradient(to right, black calc(100% - 14px), transparent)]", +}); + +/** + * A read-structure type cell, matching the variables' type-select face: + * plain capitalized text at the same width. + */ +export const staticTypeCellStyle = css({ + display: "flex", + alignItems: "center", + height: "[28px]", + paddingX: "2", + fontSize: "xs", + color: "neutral.s80", + textTransform: "capitalize", +}); + /** * Lightens a gutter cell one surface step. The Variables lists wear it so * their gutters read quieter than the token tables'. diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-cell.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-cell.tsx index e95ea6b70e7..d780858c902 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-cell.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/spreadsheet/gutter-cell.tsx @@ -39,6 +39,12 @@ export interface GutterCellProps { onKeyDown: React.KeyboardEventHandler; /** Delete/Backspace on the selected gutter. */ onDelete: () => void; + /** + * No row actions: the menu, the dots affordance, and Delete are gone, + * while the button stays focusable so the row can still be selected and + * walked (the form's run mode). + */ + readOnly?: boolean; } export const GutterCell: React.FC = ({ @@ -56,6 +62,7 @@ export const GutterCell: React.FC = ({ onBlur, onKeyDown, onDelete, + readOnly = false, }) => { const button = ( + {readOnly ? null : ( + + )} {menuAnchor ? ( = ({ colour, state, }) => { - const { formState, synthesisContext, selection, setFocusedValue, dispatch } = - use(AdHocFormContext); + const { + mode, + formState, + synthesisContext, + selection, + setFocusedValue, + dispatch, + } = use(AdHocFormContext); + // Run mode: cells and gutters stay focusable and walkable, but nothing + // edits and no rows are added or removed. + const readOnly = mode === "run"; const elements = colour.elements; const columnCount = elements.length; // Nonces, so a repeat click re-triggers the editor's auto-open behaviour. @@ -224,7 +233,7 @@ export const TokenTable: React.FC = ({ ] : [{ id: `cells-${index}`, kind: "row", gutter: true }], ), - { id: "phantom", kind: "row" }, + ...(readOnly ? [] : [{ id: "phantom", kind: "row" } as const]), ]; const { attach, onKeyDown, onFocusTarget } = useFocusStops({ @@ -335,6 +344,7 @@ export const TokenTable: React.FC = ({ value={row.count} target={countTarget} kind="count" + readOnly={readOnly} placeholder="1" className={cx( stripEditorStyle, @@ -441,6 +451,7 @@ export const TokenTable: React.FC = ({ column: "gutter", })} onDelete={() => deleteRow(rowIndex)} + readOnly={readOnly} /> {elements.map((element, columnIndex) => { @@ -486,6 +497,7 @@ export const TokenTable: React.FC = ({ } kind={element.type} derived={Boolean(shared)} + readOnly={readOnly} autoOpen={autoOpen} onOpenDerived={() => openSharedEditor(element.name)} triggerRef={registerTarget({ @@ -526,9 +538,11 @@ export const TokenTable: React.FC = ({ > @@ -600,6 +617,7 @@ export const TokenTable: React.FC = ({ value={shared} target={target} kind={element.type} + readOnly={readOnly} autoOpen={ sharedAutoOpen?.field === element.name ? sharedAutoOpen.nonce @@ -631,47 +649,50 @@ export const TokenTable: React.FC = ({ {/* Phantom trailing row: a first click selects a phantom cell; a click on the selected cell, a double-click, or Enter materializes - the row. The gutter's + materializes directly. */} - - materializeRow(0)} - > - {elements.map((element, columnIndex) => ( - - + + + , + ); + + const nestedTrigger = screen.getByRole("button", { + name: "Toggle Nested section", + }); + const nestedHeader = nestedTrigger.closest( + '[class*="pos_sticky"]', + )!; + const nestedRoot = nestedHeader.parentElement!; + + // Sections nest — a drawer section hosts the ad-hoc form, which brings + // its own — so a focused section that outranked a header painted its + // title and rows straight through the header pinned above it. + const focusWithinTier = /\[&:focus-within\]:z_\[(\d+)\]/.exec( + nestedRoot.className, + ); + const headerTier = /(?:^|\s)z_\[(\d+)\]/.exec(nestedHeader.className); + expect(focusWithinTier?.[1]).toBeDefined(); + expect(headerTier?.[1]).toBeDefined(); + expect(Number(focusWithinTier![1])).toBeLessThan(Number(headerTier![1])); + + // ...while still winning against a section that is not focused. + const restingTier = /(?:^|\s)z_\[(\d+)\]/.exec(nestedRoot.className); + expect(Number(restingTier![1])).toBeLessThan(Number(focusWithinTier![1])); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/components/section.tsx b/libs/@hashintel/petrinaut/src/ui/components/section.tsx index f29d9a62dac..81214b5f779 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/section.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/section.tsx @@ -37,8 +37,16 @@ const sectionStyle = css({ zIndex: "[0]", // No vertical padding here — the sticky header owns its own padding so it // can fully cover scrolling content underneath it. + // + // A focused section paints over its unfocused siblings, but never over a + // sticky header: sections nest (a drawer section hosting the ad-hoc form, + // which brings its own), and the host's header sits at 2 in the same + // stacking context. Lifting a focused section above that let the nested + // section's title and rows paint straight through the header pinned above + // them, so 1 is the ceiling here — high enough to win against a sibling + // at 0, low enough to stay under every header. "&:focus-within": { - zIndex: "[3]", + zIndex: "[1]", }, }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/scenario-run-state.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/scenario-run-state.ts new file mode 100644 index 00000000000..d123a6eab3b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/scenario-run-state.ts @@ -0,0 +1,70 @@ +/** + * The pure state behind running a saved ad-hoc scenario from Simulation + * Settings: the panel edits a local copy of the scenario's definition whose + * only writable slots are the exposed Variables' values, and every edit is + * pushed to the run as ordinary scenario-parameter values — the engine + * contract (scenario id + parameter values) stays untouched. + */ + +import { + adHocExposedParameterIdentifier, + synthesizeAdHocScenario, +} from "@hashintel/petrinaut-core"; + +import type { + AdHocScenarioState, + AdHocSynthesisContext, +} from "@hashintel/petrinaut-core"; + +/** + * The panel's editable copy of a saved ad-hoc definition: each exposed + * Variable's expression is seeded from this session's run override where + * one exists, so re-selecting the scenario keeps the values the user set. + * Engine values are numeric strings (booleans as "1"/"0"); a boolean + * Variable's expression gets the literal back. + */ +export function seedScenarioRunState( + content: AdHocScenarioState, + overrides: Record, +): AdHocScenarioState { + return { + ...content, + variables: content.variables.map((variable) => { + if (!variable.exposed) { + return variable; + } + const override = + overrides[adHocExposedParameterIdentifier(variable.name)]; + if (override === undefined) { + return variable; + } + const expression = + variable.type === "boolean" + ? override === "0" + ? "false" + : "true" + : override; + return { ...variable, expression }; + }), + }; +} + +/** + * The run values an edited copy produces: synthesis resolves each exposed + * Variable's expression to its scenario parameter's constant. A copy that + * does not synthesize (a value mid-edit) produces nothing — the previous + * values stand until the expression resolves again. + */ +export function scenarioRunParameterValues( + state: AdHocScenarioState, + context: AdHocSynthesisContext, +): { identifier: string; value: string }[] { + const synthesized = synthesizeAdHocScenario(state, context); + if (!synthesized.ok) { + return []; + } + return synthesized.scenario.scenarioParameters.map((parameter) => ({ + identifier: parameter.identifier, + value: String(parameter.default), + })); +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx index d7799401001..1d2b9f0b03c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx @@ -10,9 +10,18 @@ import { Toggle, } from "@hashintel/ds-components"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; -import { EMPTY_AD_HOC_STATE } from "@hashintel/petrinaut-core"; +import { + classicRunParameterValues, + classicRunVariables, + classicScenarioRunState, + compileScenario, + createUserKeyedRecord, + EMPTY_AD_HOC_STATE, + initialMarkingToAdHocPlaces, +} from "@hashintel/petrinaut-core"; import { SimulationContext } from "../../../../../../react/simulation/context"; +import { useScenarioHir } from "../../../../../../react/simulation/use-scenario-hir"; import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; @@ -23,8 +32,13 @@ import { import { Slider } from "../../../../../components/slider"; import { useScrollOverflow } from "../../../../../hooks/use-scroll-overflow"; import { ViewScenarioDrawer } from "../../SimulateView/scenarios/view-scenario-drawer"; +import { + scenarioRunParameterValues, + seedScenarioRunState, +} from "./scenario-run-state"; import type { SubView } from "../../../../../components/sub-view/types"; +import type { AdHocScenarioState, Scenario } from "@hashintel/petrinaut-core"; // -- Styles ------------------------------------------------------------------- @@ -308,6 +322,20 @@ const emptyMessageStyle = css({ fontStyle: "italic", }); +// The classic-scenario preview's status line: why the Initial state section +// is not (fully) showing yet — compiling, a compilation error, or a row cap. +const runNoticeStyle = css({ + fontSize: "xs", + color: "neutral.s90", + backgroundColor: "[rgb(217 119 6 / 0.08)]", + border: "1px solid", + borderColor: "[rgb(217 119 6 / 0.35)]", + borderRadius: "sm", + paddingX: "2", + paddingY: "1.5", + marginBottom: "2", +}); + // Error callout shown when the selected scenario fails to compile, so a // broken scenario is never silently ignored. It docks at the panel's bottom, // below the columns: appearing takes height from the content grid — whose @@ -398,6 +426,7 @@ const SimulationSettingsContent: React.FC = () => { adHocScenario, setAdHocScenario, adHocNetParameters, + initialMarking, } = use(SimulationContext); const { enableAdHocScenarios } = use(UserSettingsContext); @@ -415,6 +444,231 @@ const SimulationSettingsContent: React.FC = () => { // the feature. const adHocActive = enableAdHocScenarios && !selectedScenario; + // A selected ad-hoc scenario shows through the form too, in run mode: + // the panel edits a local copy whose only writable slots are the exposed + // Variables (the scenario's parameters); every edit resolves to ordinary + // scenario-parameter values, so the run path is the selected scenario's. + const selectedAdHocScenario = + enableAdHocScenarios && selectedScenario?.initialState.type === "adhoc" + ? selectedScenario + : undefined; + // Any other selected scenario shows through the same form: its scenario + // parameters stay editable, and the right column previews the state the + // run will actually start with — the compiled initial marking, + // materialized into literal read-only rows. + const selectedClassicScenario = + enableAdHocScenarios && selectedScenario && !selectedAdHocScenario + ? selectedScenario + : undefined; + const classicHir = useScenarioHir(selectedClassicScenario); + // `seededFrom` is the persisted scenario object the run state came from: + // saving an edit to the selected scenario replaces that object, so the + // form reseeds to the new definition instead of showing the old one while + // the run compiles the new. `seed` counts reseeds — it keys the form so a + // reseed (or a scenario switch) remounts it, discarding an undo history + // whose snapshots belong to another definition. + const [scenarioRun, setScenarioRun] = useState<{ + scenarioId: string; + seededFrom: Scenario; + seed: number; + state: AdHocScenarioState; + } | null>(null); + if ( + selectedAdHocScenario && + selectedAdHocScenario.initialState.type === "adhoc" && + (scenarioRun?.scenarioId !== selectedAdHocScenario.id || + scenarioRun.seededFrom !== selectedAdHocScenario) + ) { + setScenarioRun({ + scenarioId: selectedAdHocScenario.id, + seededFrom: selectedAdHocScenario, + seed: (scenarioRun?.seed ?? 0) + 1, + state: seedScenarioRunState( + selectedAdHocScenario.initialState.content, + scenarioParameterValues, + ), + }); + } + if ( + selectedClassicScenario && + (scenarioRun?.scenarioId !== selectedClassicScenario.id || + scenarioRun.seededFrom !== selectedClassicScenario) + ) { + // Only the Variables (the editable scenario parameters) live in local + // state; the overrides and places are derived from compilation below. + setScenarioRun({ + scenarioId: selectedClassicScenario.id, + seededFrom: selectedClassicScenario, + seed: (scenarioRun?.seed ?? 0) + 1, + state: { + variables: classicRunVariables( + selectedClassicScenario, + scenarioParameterValues, + ), + netParameters: [], + places: {}, + }, + }); + } + // Deselecting (No scenario, or the ad-hoc embedding) drops the run state + // entirely: reselecting the same scenario later must reseed from the + // then-current parameter values, not resurface stale edits the run no + // longer uses. + if (!selectedAdHocScenario && !selectedClassicScenario && scenarioRun) { + setScenarioRun(null); + } + // Until a definition exists, the form stands in for the markings entered + // on the canvas: those are what a run uses while the draft is null, so + // showing an empty place for each of them would be a lie the run does not + // tell. Seeding also means the first edit materializes a draft that + // already holds them, so nothing is silently zeroed. No row cap: a cap + // here would drop tokens from the draft the moment the user typed. + const seededAdHocState: AdHocScenarioState = { + ...EMPTY_AD_HOC_STATE, + places: initialMarkingToAdHocPlaces( + initialMarking, + { places, types: extensions.colors ? types : [] }, + Number.POSITIVE_INFINITY, + ).places, + }; + + const adHocFormContext = { + // The quick-sim embedding sees the overlaid parameters the run compiles + // against (the raw panel inputs are hidden while it is live); a selected + // scenario keeps the net's own defaults — its overrides rule. + netParameters: adHocActive ? adHocNetParameters : globalParameters, + places, + types: extensions.colors ? types : [], + }; + const onScenarioRunChange = (next: AdHocScenarioState) => { + if (!selectedAdHocScenario) { + return; + } + setScenarioRun((current) => current && { ...current, state: next }); + for (const { identifier, value } of scenarioRunParameterValues( + next, + adHocFormContext, + )) { + setScenarioParameterValue(identifier, value); + } + }; + const onClassicRunChange = (next: AdHocScenarioState) => { + if (!selectedClassicScenario) { + return; + } + setScenarioRun( + (current) => + current && { + ...current, + state: { variables: next.variables, netParameters: [], places: {} }, + }, + ); + for (const { identifier, value } of classicRunParameterValues( + next, + selectedClassicScenario, + )) { + setScenarioParameterValue(identifier, value); + } + }; + + // What the run-mode branch renders, for either scenario kind. A classic + // scenario's places come from compiling its initial state with the + // current parameter values; until that preview is ready (or when it + // fails) the notice explains and the Initial state section stays empty. + const scenarioRunView: { + state: AdHocScenarioState; + onChange: (next: AdHocScenarioState) => void; + notice: string | null; + previewReady: boolean; + } | null = (() => { + if ( + selectedAdHocScenario && + scenarioRun?.scenarioId === selectedAdHocScenario.id + ) { + return { + state: scenarioRun.state, + onChange: onScenarioRunChange, + notice: null, + previewReady: true, + }; + } + if ( + !selectedClassicScenario || + scenarioRun?.scenarioId !== selectedClassicScenario.id + ) { + return null; + } + const withoutPreview = (notice: string) => ({ + state: { + variables: scenarioRun.state.variables, + netParameters: Object.entries( + selectedClassicScenario.parameterOverrides, + ).map(([parameterId, expression]) => ({ + parameterId, + expression, + optimize: null, + })), + places: {}, + }, + onChange: onClassicRunChange, + notice, + previewReady: false, + }); + if (classicHir.error !== null) { + return withoutPreview( + `The initial state preview could not be compiled: ${classicHir.error}`, + ); + } + if (classicHir.hir === null) { + return withoutPreview("Compiling the initial state preview…"); + } + const numericValues = createUserKeyedRecord(); + for (const [identifier, value] of Object.entries(scenarioParameterValues)) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + numericValues[identifier] = parsed; + } + } + const outcome = compileScenario( + selectedClassicScenario, + classicHir.hir, + globalParameters, + places, + adHocFormContext.types, + { scenarioParameterValues: numericValues }, + ); + if (!outcome.ok) { + return withoutPreview( + `The initial state preview could not be computed: ${outcome.errors + .map((error) => error.message) + .join(" · ")}`, + ); + } + const materialized = classicScenarioRunState( + selectedClassicScenario, + outcome.result.initialState, + { places, types: adHocFormContext.types }, + scenarioParameterValues, + ); + return { + state: { + ...materialized.state, + variables: scenarioRun.state.variables, + }, + onChange: onClassicRunChange, + notice: + materialized.truncated.length > 0 + ? `Preview truncated: ${materialized.truncated + .map( + (cut) => + `${cut.placeName} shows ${cut.shown} of ${cut.total} rows`, + ) + .join(" · ")}` + : null, + previewReady: true, + }; + })(); + // When a scenario is selected, show its scenario parameters + overridden net params. // When no scenario, show net-level parameters. const displayParams: Array<{ @@ -573,16 +827,9 @@ const SimulationSettingsContent: React.FC = () => { the keyboard flow. This embedding offers no Optimize/expose toggles. */
)} /> + ) : scenarioRunView ? ( + /* A selected ad-hoc scenario, shown through the form in run mode: + the scenario's parameters (its exposed Variables) take value + edits in the left column; Parameters and Initial state sit + read-only in the right one, still walkable and selectable. */ + ( +
+ +
+ +
+
+
+ Scenario parameters +
+ +
+ {scenarioParameterRows ?? ( +
+ This scenario exposes no parameters +
+ )} +
+
+
+
+ +
+ +
+
+
+
Parameters
+ +
+ {parameterRows ?? ( +
+ No parameters defined +
+ )} +
+
+
+
Initial state
+ +
+ {scenarioRunView.notice === null ? null : ( +
+ {scenarioRunView.notice} +
+ )} + {scenarioRunView.previewReady ? placesList : null} +
+
+
+
+
+
+ )} + /> ) : (
{/* Parameters Section */} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx index cd7122cea06..e7f5f45c7fe 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx @@ -41,6 +41,7 @@ import { getExperimentMetricDiagnosticError, type MetricLspDiagnosticSummary, } from "./experiment-metric-lsp-validation"; +import { ExperimentScenarioRun } from "./experiment-scenario-run"; import type { AdHocScenarioState, @@ -1071,7 +1072,32 @@ export const CreateExperimentDrawer = ({
{selectedScenario ? ( - selectedScenario.scenarioParameters.length === 0 ? ( + enableAdHocScenarios ? ( + // The selected scenario shows through the ad-hoc form in run + // mode: scenario parameters editable in worksheet style, and + // a collapsed "Computed state" preview of the exact values + // and tokens each run starts with. + + setParamValues((prev) => { + const next = { ...prev }; + for (const update of updates) { + next[update.identifier] = update.value; + } + return next; + }) + } + /> + ) : selectedScenario.scenarioParameters.length === 0 ? (
No scenario parameters
) : ( selectedScenario.scenarioParameters.map((param) => ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx new file mode 100644 index 00000000000..6cbad9e74fd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx @@ -0,0 +1,117 @@ +/** + * @vitest-environment jsdom + */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { lowerScenarioToHir } from "@hashintel/petrinaut-core/hir"; + +import { + DEFAULT_LANGUAGE_CLIENT_CONTEXT, + LanguageClientContext, +} from "../../../../../../react/lsp/context"; +import { ExperimentScenarioRun } from "./experiment-scenario-run"; + +import type { + AdHocSynthesisContext, + Scenario, +} from "@hashintel/petrinaut-core"; + +// Monaco cannot run in jsdom; the expression editor becomes a plain textarea. +vi.mock("../../../../../monaco/code-editor", () => ({ + CodeEditor: ({ value }: { value?: string }) => ( +