From d603e352614b853cc0549d4d5b8650de34a898dc Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 27 Aug 2026 04:08:24 +0200 Subject: [PATCH 01/17] FE-1517: Scenario creation authors through the ad-hoc form --- .changeset/adhoc-scenario-authoring.md | 6 + .../petrinaut/docs/ad-hoc-scenarios.md | 37 ++-- libs/@hashintel/petrinaut/docs/scenarios.md | 2 + .../petrinaut/docs/visual-settings.md | 4 + .../src/react/experiments/provider.tsx | 6 +- .../petrinaut/src/react/lsp/context.ts | 10 +- .../src/react/simulation/provider.tsx | 7 + .../src/react/simulation/use-scenario-hir.ts | 36 +++- .../ad-hoc-scenario-form.tsx | 8 +- .../use-ad-hoc-lsp-session.ts | 10 +- .../ad-hoc-scenario-authoring.test.tsx | 144 +++++++++++++++ .../scenarios/ad-hoc-scenario-authoring.tsx | 166 ++++++++++++++++++ .../scenarios/create-scenario-drawer.tsx | 81 +++++++++ .../SimulateView/scenarios/scenario-form.tsx | 2 +- .../SimulateView/scenarios/scenario-lsp.ts | 14 +- .../scenarios/view-scenario-drawer.tsx | 101 +++++++++++ .../content/ui/ad-hoc-form.mdx | 6 +- 17 files changed, 608 insertions(+), 32 deletions(-) create mode 100644 .changeset/adhoc-scenario-authoring.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx 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/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 404d507219c..0a82710ce4f 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,20 @@ 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. 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/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/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.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..611e02ab8b7 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -710,6 +710,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. 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..f1a48c9a0c0 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,19 @@ 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; +}; + +const loweringKey = ( + scenario: Scenario, + adHocContext: AdHocSynthesisContext | undefined, +): string => { const initialState = scenario.initialState.type === "per_place" ? { @@ -34,9 +45,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 } + : {}), + } satisfies LoweringPayload); }; const PENDING: ScenarioHirState = { hir: null, error: null }; @@ -51,9 +67,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 +83,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.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx index 9a84c5e8676..320ad42bbfa 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx @@ -107,6 +107,11 @@ export interface AdHocScenarioFormProps { }) => 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; } /** @@ -158,8 +163,9 @@ export const AdHocScenarioForm: React.FC = ({ withVariables = true, renderLayout, className, + sessionId: externalSessionId, }) => { - const sessionId = useAdHocLspSession(state); + const sessionId = useAdHocLspSession(state, externalSessionId); const { diagnosticsByUri, requestFormatExpression } = use( LanguageClientContext, ); diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts index 9ab281032b6..a9fcb59f29b 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts @@ -11,12 +11,18 @@ import type { AdHocScenarioState } from "@hashintel/petrinaut-core"; * `diagnosticsByUri`. With the default (no-op) language client this is * harmless: no documents exist and no diagnostics ever arrive. */ -export function useAdHocLspSession(state: AdHocScenarioState): string { +export function useAdHocLspSession( + state: AdHocScenarioState, + /** Externally-owned session id, so a host (a drawer footer) can address + * this session's diagnostics; generated per mount when omitted. */ + externalSessionId?: string, +): string { const { initializeAdHocSession, updateAdHocSession, killAdHocSession } = use( LanguageClientContext, ); // useState (not useRef/useMemo) — needed for a stable per-mount value. - const [sessionId] = useState(() => crypto.randomUUID()); + const [generatedSessionId] = useState(() => crypto.randomUUID()); + const sessionId = externalSessionId ?? generatedSessionId; const initializedRef = useRef(false); useEffect(() => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.test.tsx new file mode 100644 index 00000000000..7ee770907c5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.test.tsx @@ -0,0 +1,144 @@ +/** + * @vitest-environment jsdom + */ +import { render } from "@testing-library/react"; +import { act } from "react"; +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PETRINAUT_EXTENSIONS, + scenarioSchema, +} from "@hashintel/petrinaut-core"; + +import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; +import { useAdHocScenarioAuthoring } from "./ad-hoc-scenario-authoring"; + +import type { SDCPNContextValue } from "../../../../../../react/state/sdcpn-context"; +import type { AdHocScenarioState, SDCPN } from "@hashintel/petrinaut-core"; + +const sdcpn: SDCPN = { + places: [ + { + id: "place-queue", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + types: [], + parameters: [ + { + id: "param-rate", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "1.5", + }, + ], + differentialEquations: [], +}; + +const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: sdcpn, + readonly: false, + extensions: DEFAULT_PETRINAUT_EXTENSIONS, + setTitle: () => {}, + title: "Test", + getItemType: () => null, +}; + +const draftState: AdHocScenarioState = { + variables: [ + { + name: "baseLoad", + type: "integer", + expression: "6", + optimize: null, + exposed: true, + }, + ], + netParameters: [ + { + parameterId: "param-rate", + expression: "scenario.baseLoad / 2", + optimize: null, + }, + ], + places: { + "place-queue": { + kind: "uncoloured", + count: { expression: "scenario.baseLoad", optimize: null }, + }, + }, +}; + +const Harness = ({ + existingNames, + onValue, +}: { + existingNames: ReadonlySet; + onValue: (value: ReturnType) => void; +}) => { + onValue(useAdHocScenarioAuthoring({ existingScenarioNames: existingNames })); + return null; +}; + +function renderAuthoring(existingNames: ReadonlySet = new Set()) { + const holder: { + current: ReturnType | null; + } = { current: null }; + const capture = (value: ReturnType) => { + holder.current = value; + }; + render( + + + , + ); + return holder as { current: ReturnType }; +} + +describe("useAdHocScenarioAuthoring", () => { + it("derives parameters and overrides, and persists the ad-hoc state", () => { + const authoring = renderAuthoring(); + act(() => { + authoring.current.setName("Morning rush"); + authoring.current.setState(draftState); + }); + + expect(authoring.current.canSave).toBe(true); + const scenario = authoring.current.buildScenario("scenario-1"); + expect(scenario).not.toBeNull(); + // The exposed Variable became the scenario's one tunable parameter. + expect(scenario!.scenarioParameters).toEqual([ + { type: "integer", identifier: "base_load", default: 6 }, + ]); + // The override survived, rewritten to read the exposed parameter. + expect(scenario!.parameterOverrides["param-rate"]).toContain("base_load"); + expect(scenario!.initialState).toEqual({ + type: "adhoc", + content: draftState, + }); + // The persisted shape passes the schema the save paths validate with. + expect(scenarioSchema.safeParse(scenario).success).toBe(true); + }); + + it("blocks saving on a duplicate name or broken state", () => { + const authoring = renderAuthoring(new Set(["Morning rush"])); + act(() => { + authoring.current.setName("Morning rush"); + authoring.current.setState(draftState); + }); + expect(authoring.current.canSave).toBe(false); + expect(authoring.current.firstError).toContain("already exists"); + expect(authoring.current.buildScenario("scenario-1")).toBeNull(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx new file mode 100644 index 00000000000..ab48585ab89 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx @@ -0,0 +1,166 @@ +/** + * The ad-hoc authoring surface of the scenario drawers (behind the Ad-hoc + * scenarios setting): name + description above the ad-hoc Initial State + + * Parameters form in expose mode — each top-level Variable offers a + * "Scenario Parameter" toggle, and the exposed Variables become the saved + * scenario's tunable parameters. One mode only: no "Define as code" here. + * + * Saving persists the form state itself (`initialState.type: "adhoc"`) and + * derives `scenarioParameters` and `parameterOverrides` from it through + * `synthesizeAdHocScenario` — those stay the compiler's inputs, so every + * other consumer of the saved scenario works unchanged. + */ + +import { use, useState } from "react"; + +import { Drawer, Form, TextArea, TextInput } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; +import { + EMPTY_AD_HOC_STATE, + synthesizeAdHocScenario, +} from "@hashintel/petrinaut-core"; + +import { LanguageClientContext } from "../../../../../../react/lsp/context"; +import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; +import { AdHocScenarioForm } from "../../../../../components/ad-hoc-scenario-form/ad-hoc-scenario-form"; +import { validateScenarioName } from "./scenario-form"; +import { summarizeScenarioLspErrors } from "./scenario-lsp"; + +import type { AdHocScenarioState, Scenario } from "@hashintel/petrinaut-core"; + +const fieldsStyle = css({ + display: "flex", + flexDirection: "column", + gap: "4", + marginBottom: "5", +}); + +export interface AdHocScenarioDraft { + name: string; + description: string; + state: AdHocScenarioState; +} + +export interface UseAdHocScenarioAuthoringOptions { + initial?: Partial; + /** Names of other scenarios; the draft's name must not match any. */ + existingScenarioNames: ReadonlySet; +} + +/** + * The draft state plus everything the footer needs: the error summary + * (name validation, synthesis errors, this form's LSP diagnostics) and + * `buildScenario`, which derives the persisted shape from the draft. + */ +export function useAdHocScenarioAuthoring({ + initial, + existingScenarioNames, +}: UseAdHocScenarioAuthoringOptions) { + const { extensions, petriNetDefinition } = use(SDCPNContext); + const { diagnosticsByUri } = use(LanguageClientContext); + + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [state, setState] = useState( + initial?.state ?? EMPTY_AD_HOC_STATE, + ); + // Owned here (not generated inside the form) so the footer can address + // exactly this form's diagnostics. + const [sessionId] = useState(() => crypto.randomUUID()); + + const context = { + netParameters: extensions.parameters ? petriNetDefinition.parameters : [], + places: petriNetDefinition.places, + types: extensions.colors ? petriNetDefinition.types : [], + }; + + const nameError = validateScenarioName(name, existingScenarioNames); + const synthesized = synthesizeAdHocScenario(state, context); + const synthesisErrors = synthesized.ok ? [] : synthesized.errors; + const { count: lspErrorCount, firstMessage: firstLspMessage } = + summarizeScenarioLspErrors(diagnosticsByUri, { + adHocSessionId: sessionId, + }); + + const errorCount = + (nameError ? 1 : 0) + synthesisErrors.length + lspErrorCount; + const firstError = + nameError ?? synthesisErrors[0]?.message ?? firstLspMessage; + + const buildScenario = (id: string): Scenario | null => { + if (!synthesized.ok || nameError) { + return null; + } + return { + id, + name: name.trim(), + description: description.trim() === "" ? undefined : description.trim(), + // Derived from the form state; the state itself stays the authoring + // source of truth and round-trips through the edit drawer. + scenarioParameters: synthesized.scenario.scenarioParameters, + parameterOverrides: synthesized.scenario.parameterOverrides, + initialState: { type: "adhoc", content: state }, + }; + }; + + return { + name, + setName, + description, + setDescription, + state, + setState, + sessionId, + context, + nameError, + errorCount, + firstError, + canSave: errorCount === 0, + buildScenario, + }; +} + +export type AdHocScenarioAuthoring = ReturnType< + typeof useAdHocScenarioAuthoring +>; + +/** The drawer body: name, description, and the expose-mode ad-hoc form. */ +export const AdHocScenarioAuthoringBody: React.FC<{ + authoring: AdHocScenarioAuthoring; +}> = ({ authoring }) => ( + +
+ + + + +