From f8e1757b7906996d35fb2f3ada454998d320d0b6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:23:01 +0200 Subject: [PATCH 1/2] FE-1500: add controlled Petrinaut navigation --- .changeset/petrinaut-controlled-navigation.md | 8 + .../petrinaut/docs/drawing-a-net.md | 14 + libs/@hashintel/petrinaut/docs/experiments.md | 6 + .../petrinaut/docs/visual-settings.md | 4 + libs/@hashintel/petrinaut/src/main.ts | 12 + .../src/react/experiments/provider.test.tsx | 85 +++- .../src/react/experiments/provider.tsx | 45 ++- .../hooks/use-petrinaut-commands.test.tsx | 3 + .../hooks/use-petrinaut-mutations.test.tsx | 3 + libs/@hashintel/petrinaut/src/react/index.ts | 25 ++ .../src/react/navigation/index.test.tsx | 326 +++++++++++++++ .../petrinaut/src/react/navigation/index.tsx | 374 ++++++++++++++++++ .../src/react/optimizations/provider.test.tsx | 68 ++++ .../src/react/optimizations/provider.tsx | 39 +- .../src/react/petrinaut-provider.tsx | 65 +-- .../src/react/simulation/provider.test.tsx | 143 +++++++ .../src/react/simulation/provider.tsx | 90 ++++- .../src/react/state/active-net-provider.tsx | 40 +- .../src/react/state/editor-context.ts | 18 +- .../src/react/state/editor-provider.tsx | 368 +++++++++++++---- .../src/react/state/use-selection-cleanup.ts | 19 +- libs/@hashintel/petrinaut/src/ui/index.ts | 12 + .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 5 + .../BottomPanel/subviews/diagnostics.tsx | 10 +- .../panels/LeftSideBar/subviews/nets-list.tsx | 3 - .../experiments-story-fixtures.tsx | 3 + .../Editor/panels/ai-assistant-panel.test.tsx | 3 + .../Editor/panels/ai-assistant-panel.tsx | 36 +- .../SDCPN/components/viewport-controls.tsx | 12 +- .../SDCPN/hooks/use-apply-node-changes.ts | 57 +-- .../src/ui/views/SDCPN/sdcpn-canvas.tsx | 10 +- 31 files changed, 1689 insertions(+), 217 deletions(-) create mode 100644 .changeset/petrinaut-controlled-navigation.md create mode 100644 libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/react/navigation/index.tsx create mode 100644 libs/@hashintel/petrinaut/src/react/simulation/provider.test.tsx diff --git a/.changeset/petrinaut-controlled-navigation.md b/.changeset/petrinaut-controlled-navigation.md new file mode 100644 index 00000000000..2cef260d77c --- /dev/null +++ b/.changeset/petrinaut-controlled-navigation.md @@ -0,0 +1,8 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add a `navigation` prop to `Petrinaut`: a router-neutral controller through +which the host can read and drive the app location (mode, Simulate section and +resource, scenario, subnet, selection, and creation drawers), making them real +browser history destinations. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 581c48d4c83..8b4d76e5aaa 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -129,6 +129,20 @@ Toggle the sidebar with the button in the top-left corner. Press **Cmd+F** / **Ctrl+F** to open a search bar. Type to filter entities by name. Press **Escape** to close. +## Browser Back and Forward + +On the Petrinaut demo and full example pages, Browser **Back** and **Forward** +move through the app locations you visited. This includes switching global +modes or Simulate sections, opening an existing scenario, metric, experiment, +or optimization, opening or closing their creation drawers, changing subnet, +committing a selection, and opening or closing Viewport Settings. Creation +drawers opened from Simulation Settings or the timeline are included too. A +drag-selection gesture creates one location after you finish drawing the +selection box, rather than one for every pointer move. + +Browser history restores what you were looking at; it does not undo changes to +the Petri net. Use Petrinaut's Undo / Redo commands for document changes. + ## Undo / Redo Use the **Cmd+Z** / **Ctrl+Z** shortcut to undo the last action. Use the **Cmd+Shift+Z** / **Ctrl+Shift+Z** shortcut to redo the last action. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 2337d8ed2f5..b119c9ccfd8 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -50,6 +50,12 @@ In the experiment's view drawer (open it by clicking a row in the list, or any e There is no built-in restart action -- to re-run with the same configuration, **Create** a new experiment with the same settings. +Opening and closing an existing experiment participates in Browser Back / +Forward history on hosts with app navigation enabled. Experiment records and +results remain session data: browser navigation can reopen a record while the +current Petrinaut session is mounted, but reloading a copied experiment URL +does not recreate the run. + A confirmation prompt blocks browser/tab close while any experiment is initializing or running. ### Notifications diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index 31cd62d04e7..cc3b1bebbd3 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -2,6 +2,10 @@ Access the settings dialog via the **gear icon** in the viewport controls (bottom-right corner of the canvas). The viewport controls are a small floating cluster of buttons -- zoom in / out, fit-to-view, and the gear icon -- anchored to the bottom-right of the canvas. +On hosts with app navigation enabled, opening or closing this dialog is part of +Browser Back / Forward history. The setting values themselves remain saved +preferences and are not reverted by browser navigation. + ## Available settings ### Animations diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 459d21ddbcb..f55229d1f3c 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -17,6 +17,18 @@ export { PetrinautOptimizationContext } from "./react/optimization-context"; export type { PetrinautSlots } from "./ui/types/petrinaut-slots"; export type { ViewportAction } from "./ui/types/viewport-action"; +export type { + PetrinautNavigationAction, + PetrinautNavigationController, + PetrinautNavigationHistory, + PetrinautNavigationHistoryPolicy, + PetrinautNavigationIntent, + PetrinautNavigationOverlay, + PetrinautNavigationState, + PetrinautNavigationUpdate, + PetrinautNavigationUpdater, + PetrinautSimulateResource, +} from "./react/navigation"; export { definePetrinautAiInteractiveTool } from "./ui/types/ai-interactive-tool"; export type { PetrinautAiInteractiveTool, diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index 0a3ffb9cde9..bbfe50c6c99 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -14,6 +14,10 @@ import { import { compileHirArtifacts } from "@hashintel/petrinaut-core/hir"; import { LanguageClientContext } from "../lsp/context"; +import { + PetrinautNavigationProvider, + usePetrinautNavigation, +} from "../navigation"; import { NotificationsContext, type AddNotificationInput, @@ -23,6 +27,7 @@ import { ExperimentsContext, type ExperimentsContextValue } from "./context"; import { ExperimentsProvider } from "./provider"; import type { LanguageClientContextValue } from "../lsp/context"; +import type { PetrinautNavigationState } from "../navigation"; import type { MonteCarloToMainMessage, MonteCarloToWorkerMessage, @@ -182,16 +187,29 @@ const ExperimentsContextConsumer = ({ return null; }; +const NavigationContextConsumer = ({ + onNavigationState, +}: { + onNavigationState: (state: Readonly) => void; +}) => { + onNavigationState(usePetrinautNavigation().state); + return null; +}; + const TestWrapper = ({ addNotification, requestHirArtifacts, worker, onContextValue, + initialNavigationState, + onNavigationState, }: { addNotification?: (notification: AddNotificationInput) => string; requestHirArtifacts?: LanguageClientContextValue["requestHirArtifacts"]; worker: FakeMonteCarloWorker; onContextValue: (value: ExperimentsContextValue) => void; + initialNavigationState?: Partial; + onNavigationState: (state: Readonly) => void; }) => ( - - worker as WorkerLike< - MonteCarloToWorkerMessage, - MonteCarloToMainMessage - > - } - > - - + + + + worker as WorkerLike< + MonteCarloToWorkerMessage, + MonteCarloToMainMessage + > + } + > + + + @@ -221,12 +242,17 @@ function renderExperimentsProvider( options: { addNotification?: (notification: AddNotificationInput) => string; requestHirArtifacts?: LanguageClientContextValue["requestHirArtifacts"]; + initialNavigationState?: Partial; } = {}, ): { getValue: () => ExperimentsContextValue; + getNavigationState: () => Readonly; renderResult: RenderResult; } { const valueHolder = { current: null as ExperimentsContextValue | null }; + const navigationStateHolder = { + current: null as Readonly | null, + }; const captureValue = (value: ExperimentsContextValue) => { valueHolder.current = value; }; @@ -237,16 +263,55 @@ function renderExperimentsProvider( requestHirArtifacts={options.requestHirArtifacts} worker={worker} onContextValue={captureValue} + initialNavigationState={options.initialNavigationState} + onNavigationState={(state) => { + navigationStateHolder.current = state; + }} />, ); return { getValue: () => valueHolder.current!, + getNavigationState: () => navigationStateHolder.current!, renderResult, }; } describe("ExperimentsProvider", () => { + it("replaces the creation overlay with the created experiment location", async () => { + const worker = new FakeMonteCarloWorker(); + const { getNavigationState, getValue, renderResult } = + renderExperimentsProvider(worker, { + initialNavigationState: { overlay: { type: "create-experiment" } }, + }); + + try { + let experimentId = ""; + await act(async () => { + experimentId = await getValue().createExperiment({ + name: "Navigated experiment", + scenarioId: null, + scenarioParameterValues: {}, + runCount: 1, + seed: 42, + dt: 1, + maxTime: 10, + metricSpecs: CONSTANT_METRIC_SPEC, + }); + await flushWorkerSetup(); + }); + + expect(getNavigationState()).toMatchObject({ + mode: "simulate", + simulateView: "experiments", + simulateResource: { type: "experiment", id: experimentId }, + overlay: null, + }); + } finally { + renderResult.unmount(); + } + }); + it("creates an initializing experiment before the worker reports ready", async () => { const worker = new FakeMonteCarloWorker(); const { getValue, renderResult } = renderExperimentsProvider(worker); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index e04f3d4ecb0..cc96f945c06 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -23,6 +23,10 @@ import { useBlockWindowClose } from "../hooks/use-block-window-close"; import { useLatest } from "../hooks/use-latest"; import { useStableCallback } from "../hooks/use-stable-callback"; import { LanguageClientContext } from "../lsp/context"; +import { + openPetrinautSimulationResource, + usePetrinautNavigation, +} from "../navigation"; import { NotificationsContext } from "../notifications/context"; import { SDCPNContext } from "../state/sdcpn-context"; import { @@ -170,6 +174,7 @@ export const ExperimentsProvider: React.FC = ({ LanguageClientContext, ); const { addNotification } = use(NotificationsContext); + const navigation = usePetrinautNavigation(); const petriNetDefinitionRef = useLatest(petriNetDefinition); const extensionsRef = useLatest(extensions); const workerFactoryRef = useLatest(workerFactory ?? createMonteCarloWorker); @@ -180,9 +185,22 @@ export const ExperimentsProvider: React.FC = ({ new Map(), ); const [experiments, setExperiments] = useState([]); - const [selectedExperimentId, setSelectedExperimentId] = useState< - string | null - >(null); + const selectedExperimentId = + navigation.state.simulateResource?.type === "experiment" + ? navigation.state.simulateResource.id + : null; + const setSelectedExperimentId: ExperimentsContextValue["setSelectedExperimentId"] = + (experimentId) => { + navigation.navigate( + experimentId + ? openPetrinautSimulationResource({ + type: "experiment", + id: experimentId, + }) + : { simulateResource: null }, + { cause: "user", action: "simulation-resource" }, + ); + }; useBlockWindowClose({ shouldBlock: experiments.some(isExperimentActive) }); useEffect(() => { @@ -201,6 +219,18 @@ export const ExperimentsProvider: React.FC = ({ }; }, []); + useEffect(() => { + if ( + selectedExperimentId && + !experiments.some(({ id }) => id === selectedExperimentId) + ) { + navigation.navigate( + { simulateResource: null }, + { cause: "normalization", action: "simulation-resource" }, + ); + } + }, [experiments, navigation, selectedExperimentId]); + const patchExperiment = ( experimentId: string, patch: Partial, @@ -496,9 +526,12 @@ export const ExperimentsProvider: React.FC = ({ setExperiments((prev) => prev.filter((experiment) => experiment.id !== experimentId), ); - setSelectedExperimentId((current) => - current === experimentId ? null : current, - ); + if (selectedExperimentId === experimentId) { + navigation.navigate( + { simulateResource: null }, + { cause: "normalization", action: "simulation-resource" }, + ); + } }; const selectedExperiment = diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx index 3c5670618d0..ddb4f8f74fa 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -38,6 +38,7 @@ const editorContextValue = ( ): EditorContextValue => ({ ...initialEditorState, globalMode, + navigateTo: () => {}, setGlobalMode: () => {}, setEditionMode: () => {}, setAddComponentMode: () => {}, @@ -56,6 +57,8 @@ const editorContextValue = ( isNotHoveredConnection: () => false, selectedConnections: new Map(), setSelection: () => {}, + beginSelectionGesture: () => {}, + endSelectionGesture: () => {}, selectItem: () => {}, toggleItem: () => {}, clearSelection: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index 8f7a5aa1a5f..c11ca42bbb0 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -41,6 +41,7 @@ const editorContextValue = ( ): EditorContextValue => ({ ...initialEditorState, globalMode, + navigateTo: () => {}, setGlobalMode: () => {}, setEditionMode: () => {}, setAddComponentMode: () => {}, @@ -59,6 +60,8 @@ const editorContextValue = ( isNotHoveredConnection: () => false, selectedConnections: new Map(), setSelection: () => {}, + beginSelectionGesture: () => {}, + endSelectionGesture: () => {}, selectItem: () => {}, toggleItem: () => {}, clearSelection: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index c134c7521a2..25412e843e6 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -18,6 +18,31 @@ export type { ActualModeContextValue } from "./actual-mode-context"; // --- Provider unification --- export { PetrinautProvider } from "./petrinaut-provider"; export type { PetrinautProviderProps } from "./petrinaut-provider"; +export { + canonicalizePetrinautSelection, + defaultPetrinautNavigationHistoryPolicy, + defaultPetrinautNavigationState, + openPetrinautSimulationResource, + openPetrinautSimulationView, + openPetrinautSubnet, + PetrinautNavigationProvider, + petrinautNavigationStatesMatch, + revealPetrinautEditorItem, + usePetrinautNavigation, +} from "./navigation"; +export type { + PetrinautNavigationAction, + PetrinautNavigationController, + PetrinautNavigationHistory, + PetrinautNavigationHistoryPolicy, + PetrinautNavigationIntent, + PetrinautNavigationOverlay, + PetrinautNavigationProviderProps, + PetrinautNavigationState, + PetrinautNavigationUpdate, + PetrinautNavigationUpdater, + PetrinautSimulateResource, +} from "./navigation"; export { NetManagementContext, type NetManagement, diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx new file mode 100644 index 00000000000..4662233b93a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx @@ -0,0 +1,326 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { + canonicalizePetrinautSelection, + defaultPetrinautNavigationState, + navigationResourceToSimulateDrawer, + openPetrinautSimulationResource, + openPetrinautSubnet, + PetrinautNavigationProvider, + simulateDrawerToNavigationOverlay, + simulateDrawerToNavigationResource, + usePetrinautNavigation, +} from "."; + +import type { + PetrinautNavigationController, + PetrinautNavigationState, +} from "."; + +describe("Petrinaut navigation", () => { + test("passes an updater and app-history intent to a controlled host", () => { + const onNavigate = vi.fn(); + const controller: PetrinautNavigationController = { + state: defaultPetrinautNavigationState, + onNavigate, + }; + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + + ); + }; + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Update navigation" })); + + expect(onNavigate).toHaveBeenCalledOnce(); + const [update, options] = onNavigate.mock.calls[0]!; + expect(update(defaultPetrinautNavigationState)).toEqual({ + ...defaultPetrinautNavigationState, + subnetId: "subnet-a", + }); + expect(options).toEqual({ + history: "push", + intent: { cause: "user", action: "subnet" }, + }); + }); + + test("tracks optimistic controlled state across updates before rerender", () => { + const selectedPlace = { type: "place", id: "place-a" } as const; + const initialState: PetrinautNavigationState = { + ...defaultPetrinautNavigationState, + selection: [selectedPlace], + }; + let hostState = initialState; + const onNavigate = vi.fn( + (update) => { + hostState = update(hostState); + }, + ); + const controller: PetrinautNavigationController = { + state: initialState, + onNavigate, + }; + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + + ); + }; + + render( + + + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Queue selection updates" }), + ); + + expect(onNavigate).toHaveBeenCalledTimes(2); + expect(onNavigate.mock.calls.map(([, options]) => options.history)).toEqual( + ["push", "replace"], + ); + expect(hostState.selection).toEqual([selectedPlace]); + }); + + test("suppresses a duplicate controlled update before rerender", () => { + let hostState = defaultPetrinautNavigationState; + const onNavigate = vi.fn( + (update) => { + hostState = update(hostState); + }, + ); + const controller: PetrinautNavigationController = { + state: defaultPetrinautNavigationState, + onNavigate, + }; + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + + ); + }; + + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Repeat navigation" })); + + expect(onNavigate).toHaveBeenCalledOnce(); + expect(hostState.subnetId).toBe("subnet-a"); + }); + + test("allows a controlled host to override the default history policy", () => { + const historyPolicy = vi.fn(() => "replace" as const); + const onNavigate = vi.fn(); + const controller: PetrinautNavigationController = { + state: defaultPetrinautNavigationState, + historyPolicy, + onNavigate, + }; + const intent = { cause: "user", action: "subnet" } as const; + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + + ); + }; + + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Override history" })); + + expect(historyPolicy).toHaveBeenCalledOnce(); + expect(historyPolicy).toHaveBeenCalledWith(intent); + expect(onNavigate).toHaveBeenCalledOnce(); + expect(onNavigate.mock.calls[0]?.[1]).toEqual({ + history: "replace", + intent, + }); + }); + + test("uses replace for continuation and normalization intents", () => { + const onNavigate = vi.fn(); + const controller: PetrinautNavigationController = { + state: defaultPetrinautNavigationState, + onNavigate, + }; + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + <> + + + + ); + }; + + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Continue selection" })); + fireEvent.click(screen.getByRole("button", { name: "Normalize" })); + + expect(onNavigate.mock.calls[0]?.[1]).toMatchObject({ history: "replace" }); + // Normalizing an already-null subnet is a semantic no-op. + expect(onNavigate).toHaveBeenCalledOnce(); + }); + + test("canonicalizes complete selection", () => { + expect( + canonicalizePetrinautSelection([ + { type: "transition", id: "transition-b" }, + { type: "place", id: "place-a" }, + { type: "transition", id: "transition-b" }, + ]), + ).toEqual([ + { type: "place", id: "place-a" }, + { type: "transition", id: "transition-b" }, + ]); + }); + + test("creates complete atomic destinations", () => { + expect( + openPetrinautSimulationResource({ + type: "experiment", + id: "experiment-a", + })({ + ...defaultPetrinautNavigationState, + overlay: { type: "create-experiment" }, + selection: [{ type: "place", id: "place-a" }], + }), + ).toEqual({ + ...defaultPetrinautNavigationState, + mode: "simulate", + simulateView: "experiments", + simulateResource: { type: "experiment", id: "experiment-a" }, + selection: [{ type: "place", id: "place-a" }], + }); + expect( + openPetrinautSubnet("subnet-a")({ + ...defaultPetrinautNavigationState, + selection: [{ type: "place", id: "place-a" }], + }), + ).toEqual({ + ...defaultPetrinautNavigationState, + subnetId: "subnet-a", + selection: [], + }); + }); + + test("maps view resources and create overlays to drawers", () => { + expect( + simulateDrawerToNavigationResource({ + type: "view-scenario", + scenarioId: "scenario-a", + }), + ).toEqual({ type: "scenario", id: "scenario-a" }); + for (const type of [ + "create-scenario", + "create-metric", + "create-experiment", + "create-optimization", + ] as const) { + const drawer = { type }; + expect(simulateDrawerToNavigationResource(drawer)).toBeNull(); + expect(simulateDrawerToNavigationOverlay(drawer, null)).toEqual(drawer); + expect(navigationResourceToSimulateDrawer(null, drawer)).toEqual(drawer); + } + expect( + navigationResourceToSimulateDrawer({ + type: "experiment", + id: "experiment-a", + }), + ).toEqual({ type: "view-experiment", experimentId: "experiment-a" }); + expect( + navigationResourceToSimulateDrawer({ + type: "optimization", + id: "optimization-a", + }), + ).toEqual({ type: "closed" }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx new file mode 100644 index 00000000000..42947642365 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx @@ -0,0 +1,374 @@ +/** + * @layerRoot react.navigation + * @role Keeps Petrinaut's app location router-neutral and controlled by the host + */ + +import { createContext, use, useState, type ReactNode } from "react"; + +import { ActualModeContext } from "../actual-mode-context"; +import { useLatest } from "../hooks/use-latest"; + +import type { + EditorGlobalMode, + SimulateDrawerState, + SimulateViewMode, +} from "../state/editor-context"; +import type { SelectionItem } from "@hashintel/petrinaut-core"; + +export type PetrinautSimulateResource = + | { type: "scenario"; id: string } + | { type: "metric"; id: string } + | { type: "experiment"; id: string } + | { type: "optimization"; id: string }; + +export type PetrinautNavigationOverlay = + | { type: "viewport-settings" } + | { type: "create-scenario" } + | { type: "create-metric" } + | { type: "create-experiment" } + | { type: "create-optimization" } + | null; + +/** + * App location understood by the full editor. Hosts can encode this in any + * router they choose; Petrinaut itself never imports a router. + * + * `scenarioId: undefined` means "use the first available scenario", while + * `null` means that the user explicitly selected no scenario. + */ +export type PetrinautNavigationState = { + mode: EditorGlobalMode; + simulateView: SimulateViewMode; + simulateResource: PetrinautSimulateResource | null; + scenarioId: string | null | undefined; + subnetId: string | null; + selection: readonly SelectionItem[]; + overlay: PetrinautNavigationOverlay; +}; + +export const defaultPetrinautNavigationState: PetrinautNavigationState = { + mode: "edit", + simulateView: "experiments", + simulateResource: null, + scenarioId: undefined, + subnetId: null, + selection: [], + overlay: null, +}; + +export type PetrinautNavigationHistory = "push" | "replace"; + +export type PetrinautNavigationAction = + | "mode" + | "simulation-view" + | "simulation-resource" + | "scenario" + | "subnet" + | "selection" + | "overlay"; + +export type PetrinautNavigationIntent = + | { + cause: "user"; + action: PetrinautNavigationAction; + phase?: "discrete" | "start" | "continue"; + } + | { + cause: "normalization"; + action: PetrinautNavigationAction; + }; + +export type PetrinautNavigationUpdater = ( + current: Readonly, +) => State; + +export type PetrinautNavigationUpdate = + | Partial + | PetrinautNavigationUpdater; + +export type PetrinautNavigationHistoryPolicy = ( + intent: PetrinautNavigationIntent, +) => PetrinautNavigationHistory; + +export const defaultPetrinautNavigationHistoryPolicy: PetrinautNavigationHistoryPolicy = + (intent) => + intent.cause === "normalization" || intent.phase === "continue" + ? "replace" + : "push"; + +export type PetrinautNavigationController< + State extends object = PetrinautNavigationState, +> = { + state: Readonly; + /** Allows a host such as an iframe to constrain how navigation is recorded. */ + historyPolicy?: PetrinautNavigationHistoryPolicy; + /** + * Apply an updater to the host's freshest state. Passing the updater rather + * than a render-time snapshot makes concurrent and functional transitions + * safe for host routers. + */ + onNavigate: ( + update: PetrinautNavigationUpdater, + options: { + history: PetrinautNavigationHistory; + intent: PetrinautNavigationIntent; + }, + ) => void; +}; + +type PetrinautNavigationContextValue = { + controlled: boolean; + state: Readonly; + navigate: ( + update: PetrinautNavigationUpdate, + intent: PetrinautNavigationIntent, + ) => boolean; +}; + +const PetrinautNavigationContext = + createContext({ + controlled: false, + state: defaultPetrinautNavigationState, + navigate: () => false, + }); + +export type PetrinautNavigationProviderProps = { + children: ReactNode; + controller?: PetrinautNavigationController; + initialState?: Partial; +}; + +const selectionItemKey = (item: SelectionItem) => `${item.type}\0${item.id}`; + +/** Deduplicates and orders selection so equivalent app locations serialize identically. */ +export const canonicalizePetrinautSelection = ( + selection: readonly SelectionItem[], +): readonly SelectionItem[] => { + const unique = new Map(); + for (const item of selection) { + unique.set(selectionItemKey(item), item); + } + return Array.from(unique.values()).sort( + (left, right) => + left.type.localeCompare(right.type) || left.id.localeCompare(right.id), + ); +}; + +const selectionsMatch = ( + left: readonly SelectionItem[], + right: readonly SelectionItem[], +) => + left.length === right.length && + left.every((item, index) => { + const rightItem = right[index]!; + return item.type === rightItem.type && item.id === rightItem.id; + }); + +export const petrinautNavigationStatesMatch = ( + left: Readonly, + right: Readonly, +) => + left.mode === right.mode && + left.simulateView === right.simulateView && + left.simulateResource?.type === right.simulateResource?.type && + left.simulateResource?.id === right.simulateResource?.id && + left.scenarioId === right.scenarioId && + left.subnetId === right.subnetId && + selectionsMatch(left.selection, right.selection) && + left.overlay?.type === right.overlay?.type; + +const resolveNavigationUpdate = ( + current: Readonly, + update: PetrinautNavigationUpdate, +): PetrinautNavigationState => { + const updated = + typeof update === "function" ? update(current) : { ...current, ...update }; + + return { + ...updated, + selection: canonicalizePetrinautSelection(updated.selection), + }; +}; + +export const PetrinautNavigationProvider = ({ + children, + controller, + initialState, +}: PetrinautNavigationProviderProps) => { + const actualMode = use(ActualModeContext); + const [uncontrolledState, setUncontrolledState] = + useState(() => ({ + ...defaultPetrinautNavigationState, + ...(actualMode.available ? { mode: "actual" as const } : {}), + ...initialState, + selection: canonicalizePetrinautSelection(initialState?.selection ?? []), + })); + const state = controller?.state ?? uncontrolledState; + /** + * React normally rerenders after navigation, but several UI libraries emit + * related callbacks in the same event. Track the state those accepted + * callbacks imply so a later callback is compared with the earlier result, + * rather than with a stale render-time value. A controlled host still gets + * the updater and applies it to its own freshest state. + */ + const optimisticStateRef = useLatest(state); + + const navigate: PetrinautNavigationContextValue["navigate"] = ( + update, + intent, + ) => { + const updater: PetrinautNavigationUpdater = ( + current, + ) => resolveNavigationUpdate(current, update); + if (controller) { + const current = optimisticStateRef.current; + const preview = updater(current); + if (petrinautNavigationStatesMatch(current, preview)) { + return false; + } + optimisticStateRef.current = preview; + controller.onNavigate(updater, { + history: + controller.historyPolicy?.(intent) ?? + defaultPetrinautNavigationHistoryPolicy(intent), + intent, + }); + return true; + } else { + const current = optimisticStateRef.current; + const preview = updater(current); + if (petrinautNavigationStatesMatch(current, preview)) { + return false; + } + optimisticStateRef.current = preview; + setUncontrolledState((latest) => { + const next = updater(latest); + return petrinautNavigationStatesMatch(latest, next) ? latest : next; + }); + return true; + } + }; + + return ( + + {children} + + ); +}; + +/** Internal bridge used by state providers; exported for custom React shells. */ +export const usePetrinautNavigation = () => use(PetrinautNavigationContext); + +const simulateResourceTypeToView = ( + type: PetrinautSimulateResource["type"], +): SimulateViewMode => { + switch (type) { + case "scenario": + return "scenarios"; + case "metric": + return "metrics"; + case "experiment": + return "experiments"; + case "optimization": + return "optimizations"; + } +}; + +export const openPetrinautSimulationResource = + ( + resource: PetrinautSimulateResource, + ): PetrinautNavigationUpdater => + (current) => ({ + ...current, + mode: "simulate", + simulateView: simulateResourceTypeToView(resource.type), + simulateResource: resource, + overlay: null, + }); + +export const openPetrinautSimulationView = + ( + simulateView: SimulateViewMode, + ): PetrinautNavigationUpdater => + (current) => ({ + ...current, + mode: "simulate", + simulateView, + simulateResource: null, + }); + +export const openPetrinautSubnet = + ( + subnetId: string | null, + ): PetrinautNavigationUpdater => + (current) => ({ ...current, subnetId, selection: [] }); + +export const revealPetrinautEditorItem = + (item: SelectionItem): PetrinautNavigationUpdater => + (current) => ({ ...current, mode: "edit", selection: [item] }); + +export const simulateDrawerToNavigationResource = ( + drawer: SimulateDrawerState, +): PetrinautSimulateResource | null => { + switch (drawer.type) { + case "view-scenario": + return { type: "scenario", id: drawer.scenarioId }; + case "view-metric": + return { type: "metric", id: drawer.metricId }; + case "view-experiment": + return { type: "experiment", id: drawer.experimentId }; + case "closed": + case "create-scenario": + case "create-metric": + case "create-experiment": + case "create-optimization": + return null; + } +}; + +export const simulateDrawerToNavigationOverlay = ( + drawer: SimulateDrawerState, + current: PetrinautNavigationOverlay, +): PetrinautNavigationOverlay => { + switch (drawer.type) { + case "create-scenario": + case "create-metric": + case "create-experiment": + case "create-optimization": + return { type: drawer.type }; + case "closed": + case "view-scenario": + case "view-metric": + case "view-experiment": + return current?.type.startsWith("create-") ? null : current; + } +}; + +export const navigationResourceToSimulateDrawer = ( + resource: PetrinautSimulateResource | null, + overlay: PetrinautNavigationOverlay = null, +): SimulateDrawerState => { + switch (overlay?.type) { + case "create-scenario": + case "create-metric": + case "create-experiment": + case "create-optimization": + return { type: overlay.type }; + case "viewport-settings": + case undefined: + break; + } + switch (resource?.type) { + case "scenario": + return { type: "view-scenario", scenarioId: resource.id }; + case "metric": + return { type: "view-metric", metricId: resource.id }; + case "experiment": + return { type: "view-experiment", experimentId: resource.id }; + case "optimization": + case undefined: + return { type: "closed" }; + } +}; diff --git a/libs/@hashintel/petrinaut/src/react/optimizations/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/optimizations/provider.test.tsx index 44810f6e935..d8bef5545e3 100644 --- a/libs/@hashintel/petrinaut/src/react/optimizations/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/optimizations/provider.test.tsx @@ -12,6 +12,10 @@ import { } from "@hashintel/petrinaut-core"; import { sirModel } from "@hashintel/petrinaut-core/examples"; +import { + PetrinautNavigationProvider, + usePetrinautNavigation, +} from "../navigation"; import { PetrinautOptimizationContext } from "../optimization-context"; import { OptimizationsContext, @@ -19,6 +23,8 @@ import { } from "./context"; import { OptimizationsProvider } from "./provider"; +import type { PetrinautNavigationState } from "../navigation"; + const scenario = sirModel.petriNetDefinition.scenarios?.find( (candidate) => candidate.id === "scenario__seasonal_flu", ); @@ -73,6 +79,15 @@ const CaptureContext = ({ return null; }; +const CaptureNavigation = ({ + onValue, +}: { + onValue: (value: Readonly) => void; +}) => { + onValue(usePetrinautNavigation().state); + return null; +}; + function renderProvider(capability: PetrinautOptimization) { let latest: OptimizationsContextValue | null = null; render( @@ -161,6 +176,59 @@ class FakeClassifiedError extends Error { } describe("OptimizationsProvider", () => { + it("replaces the creation overlay with the created optimization location", async () => { + const capability: PetrinautOptimization = { + createOptimizationRun: () => Promise.resolve({ runId: "run-navigation" }), + async *attachOptimizationRun() { + yield { + type: "complete", + requestedTrials: 2, + completedTrials: 0, + prunedTrials: 0, + failedTrials: 0, + best: null, + seq: 1, + }; + }, + cancelOptimizationRun: () => Promise.resolve(), + }; + let latest: OptimizationsContextValue | null = null; + let navigationState: Readonly | null = null; + + render( + + + { + navigationState = value; + }} + /> + + { + latest = value; + }} + /> + + + , + ); + + let optimizationId = ""; + await act(async () => { + optimizationId = await latest!.createOptimization(input); + }); + + expect(navigationState).toMatchObject({ + mode: "simulate", + simulateView: "optimizations", + simulateResource: { type: "optimization", id: optimizationId }, + overlay: null, + }); + }); + it("retries a failed optimization from its original input", async () => { let call = 0; const capability: PetrinautOptimization = { diff --git a/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx b/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx index b965d71790a..277236662d4 100644 --- a/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx @@ -9,6 +9,10 @@ import { } from "@hashintel/petrinaut-core"; import { useBlockWindowClose } from "../hooks/use-block-window-close"; +import { + openPetrinautSimulationResource, + usePetrinautNavigation, +} from "../navigation"; import { PetrinautOptimizationContext } from "../optimization-context"; import { type OptimizationBest, @@ -294,13 +298,27 @@ const createOptimizationRecord = ( export const OptimizationsProvider = ({ children }: PropsWithChildren) => { const capability = use(PetrinautOptimizationContext); + const navigation = usePetrinautNavigation(); const abortControllersRef = useRef(new Map()); /** Server run ids of active detached runs, keyed by record id. */ const runIdsRef = useRef(new Map()); const [optimizations, setOptimizations] = useState([]); - const [selectedOptimizationId, setSelectedOptimizationId] = useState< - string | null - >(null); + const selectedOptimizationId = + navigation.state.simulateResource?.type === "optimization" + ? navigation.state.simulateResource.id + : null; + const setSelectedOptimizationId: OptimizationsContextValue["setSelectedOptimizationId"] = + (optimizationId) => { + navigation.navigate( + optimizationId + ? openPetrinautSimulationResource({ + type: "optimization", + id: optimizationId, + }) + : { simulateResource: null }, + { cause: "user", action: "simulation-resource" }, + ); + }; useBlockWindowClose({ shouldBlock: optimizations.some(isOptimizationActive), @@ -336,11 +354,20 @@ export const OptimizationsProvider = ({ children }: PropsWithChildren) => { setOptimizations((current) => current.filter((optimization) => optimization.id !== optimizationId), ); - setSelectedOptimizationId((current) => - current === optimizationId ? null : current, - ); }, []); + useEffect(() => { + if ( + selectedOptimizationId && + !optimizations.some(({ id }) => id === selectedOptimizationId) + ) { + navigation.navigate( + { simulateResource: null }, + { cause: "normalization", action: "simulation-resource" }, + ); + } + }, [navigation, optimizations, selectedOptimizationId]); + const markOptimizationCancelled = useCallback( (optimizationId: string) => { patchOptimization(optimizationId, (current) => ({ diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index 3dbad5250af..83a043f623f 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -10,6 +10,10 @@ import { ExecutionFrameProvider } from "./execution-frame/provider"; import { ExperimentsProvider } from "./experiments/provider"; import { PetrinautInstanceContext } from "./instance-context"; import { LanguageClientProvider } from "./lsp/provider"; +import { + PetrinautNavigationProvider, + type PetrinautNavigationController, +} from "./navigation"; import { NetManagementContext, type NetManagement, @@ -45,6 +49,8 @@ export type PetrinautProviderProps = { * LSP worker themselves rather than relying on the inlined-blob default. */ lspWorkerFactory?: LspWorkerFactory; + /** Optional host-owned, router-neutral app location. */ + navigation?: PetrinautNavigationController; children: ReactNode; }; @@ -60,42 +66,49 @@ export const PetrinautProvider: React.FC = ({ simulationWorkerFactory, monteCarloWorkerFactory, lspWorkerFactory, + navigation, children, }) => { const handleHistoryUndoRedo = useHandleHistoryAsUndoRedo( instance.handle.history, ); - // Keyed by handle id so a net switch fully resets net-scoped worker state. + // Keyed by handle id so a net switch fully resets net-scoped worker state + // and uncontrolled app locations. const inner = ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + {children} + + + + + + + + + + + ); diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.test.tsx new file mode 100644 index 00000000000..60995a8b323 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment jsdom + */ +import { + act, + render, + waitFor, + type RenderResult, +} from "@testing-library/react"; +import { use } from "react"; +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PETRINAUT_EXTENSIONS, + type Scenario, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +import { PetrinautNavigationProvider } from "../navigation"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { SimulationContext, type SimulationContextValue } from "./context"; +import { SimulationProvider } from "./provider"; + +const makeScenario = ( + id: string, + name: string, + defaultRate: number, +): Scenario => ({ + id, + name, + scenarioParameters: [ + { type: "real", identifier: "rate", default: defaultRate }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, +}); + +const makeSdcpn = (scenarios: Scenario[]): SDCPN => ({ + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + subnets: [], + componentInstances: [], + scenarios, +}); + +const makeSdcpnContextValue = (scenarios: Scenario[]): SDCPNContextValue => ({ + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: makeSdcpn(scenarios), + readonly: false, + extensions: DEFAULT_PETRINAUT_EXTENSIONS, + setTitle: () => {}, + title: "Test", + getItemType: () => null, +}); + +const SimulationContextConsumer = ({ + onContextValue, +}: { + onContextValue: (value: SimulationContextValue) => void; +}) => { + onContextValue(use(SimulationContext)); + return null; +}; + +const TestWrapper = ({ + scenarios, + onContextValue, +}: { + scenarios: Scenario[]; + onContextValue: (value: SimulationContextValue) => void; +}) => ( + + + + + + + +); + +function renderSimulationProvider(scenarios: Scenario[]): { + getValue: () => SimulationContextValue; + rerender: (nextScenarios: Scenario[]) => void; + renderResult: RenderResult; +} { + const valueHolder = { current: null as SimulationContextValue | null }; + const captureValue = (value: SimulationContextValue) => { + valueHolder.current = value; + }; + const renderResult = render( + , + ); + + return { + getValue: () => valueHolder.current!, + rerender: (nextScenarios) => + renderResult.rerender( + , + ), + renderResult, + }; +} + +describe("SimulationProvider", () => { + it("does not leak implicit first-scenario overrides after reorder and deletion", async () => { + const firstScenario = makeScenario("scenario-a", "Scenario A", 1); + const secondScenario = makeScenario("scenario-b", "Scenario B", 2); + const { getValue, rerender, renderResult } = renderSimulationProvider([ + firstScenario, + secondScenario, + ]); + + try { + expect(getValue().selectedScenarioId).toBe(firstScenario.id); + + act(() => { + getValue().setScenarioParameterValue("rate", "99"); + }); + + expect(getValue().scenarioParameterValues).toEqual({ rate: "99" }); + + rerender([secondScenario, firstScenario]); + + expect(getValue().selectedScenarioId).toBe(firstScenario.id); + expect(getValue().scenarioParameterValues).toEqual({ rate: "99" }); + + rerender([secondScenario]); + + await waitFor(() => { + expect(getValue().selectedScenarioId).toBe(secondScenario.id); + expect(getValue().scenarioParameterValues).toEqual({ rate: "2" }); + }); + } finally { + renderResult.unmount(); + } + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index 2a0104004f5..d36515ef011 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -22,6 +22,7 @@ import { deriveDefaultParameterValues } from "../hooks/use-default-parameter-val import { useLatest } from "../hooks/use-latest"; import { useStableCallback } from "../hooks/use-stable-callback"; import { LanguageClientContext } from "../lsp/context"; +import { usePetrinautNavigation } from "../navigation"; import { NotificationsContext } from "../notifications/context"; import { SDCPNContext } from "../state/sdcpn-context"; import { useStore } from "../use-store"; @@ -73,11 +74,13 @@ function getScenarioParameterDefaults( return values; } -function createInitialStateValues(): SimulationStateValues { +function createInitialStateValues(options?: { + selectedScenarioId?: string | null; +}): SimulationStateValues { return { parameterValues: {}, initialMarking: {}, - selectedScenarioId: undefined, + selectedScenarioId: options?.selectedScenarioId, scenarioParameterValues: {}, dt: 0.01, maxTime: null, @@ -170,16 +173,24 @@ export const SimulationProvider: React.FC = ({ const { requestHirArtifacts, requestScenarioHir } = use( LanguageClientContext, ); + const navigation = usePetrinautNavigation(); const { extensions, petriNetDefinition } = sdcpnContext; const { addNotification } = use(NotificationsContext); const petriNetDefinitionRef = useLatest(petriNetDefinition); const extensionsRef = useLatest(extensions); const workerFactoryRef = useLatest(workerFactory ?? createSimulationWorker); + const requestedScenarioId = navigation.state.scenarioId; + const effectiveSelectedScenarioId = getEffectiveSelectedScenarioId( + petriNetDefinition.scenarios, + requestedScenarioId, + ); // Configuration state (not managed by the simulation handle) const [stateValues, setStateValues] = useState(() => - createInitialStateValues(), + createInitialStateValues({ + selectedScenarioId: navigation.state.scenarioId, + }), ); const stateValuesRef = useLatest(stateValues); @@ -265,12 +276,48 @@ export const SimulationProvider: React.FC = ({ setErrorItemId(null); }; + const previousEffectiveScenarioIdRef = useRef(effectiveSelectedScenarioId); + useEffect(() => { + if ( + previousEffectiveScenarioIdRef.current === effectiveSelectedScenarioId + ) { + return; + } + previousEffectiveScenarioIdRef.current = effectiveSelectedScenarioId; + + initializationGenerationRef.current += 1; + simulationRef.current?.dispose(); + simulationRef.current = null; + setSimulation(null); + setError(null); + setErrorItemId(null); + setStateValues((prev) => ({ + ...prev, + selectedScenarioId: effectiveSelectedScenarioId, + scenarioParameterValues: getScenarioParameterDefaults( + petriNetDefinition.scenarios?.find( + (scenario) => scenario.id === effectiveSelectedScenarioId, + ), + ), + })); + }, [ + effectiveSelectedScenarioId, + petriNetDefinition.scenarios, + simulationRef, + ]); + const setSelectedScenarioId: SimulationContextValue["setSelectedScenarioId"] = (scenarioId) => { - if (stateValuesRef.current.selectedScenarioId !== scenarioId) { + const currentScenarioId = effectiveSelectedScenarioId; + if (currentScenarioId !== scenarioId) { invalidateSimulationForConfigurationChange(); } + navigation.navigate( + { scenarioId }, + { cause: "user", action: "scenario" }, + ); + setStateValues((prev) => { const scenario = petriNetDefinition.scenarios?.find( (s) => s.id === scenarioId, @@ -292,15 +339,22 @@ export const SimulationProvider: React.FC = ({ invalidateSimulationForConfigurationChange(); } + if ( + requestedScenarioId === undefined && + effectiveSelectedScenarioId !== null + ) { + navigation.navigate( + { scenarioId: effectiveSelectedScenarioId }, + { cause: "normalization", action: "scenario" }, + ); + } + setStateValues((prev) => ({ ...prev, selectedScenarioId: - prev.selectedScenarioId === undefined - ? getEffectiveSelectedScenarioId( - petriNetDefinition.scenarios, - prev.selectedScenarioId, - ) - : prev.selectedScenarioId, + requestedScenarioId === undefined + ? effectiveSelectedScenarioId + : requestedScenarioId, scenarioParameterValues: { ...prev.scenarioParameterValues, [identifier]: value, @@ -570,10 +624,18 @@ export const SimulationProvider: React.FC = ({ const simulationState = mapCoreState(simulation ? coreStatus : null); const totalFrames = frameSummary.count; - const effectiveSelectedScenarioId = getEffectiveSelectedScenarioId( - petriNetDefinition.scenarios, - stateValues.selectedScenarioId, - ); + useEffect(() => { + if ( + requestedScenarioId !== undefined && + requestedScenarioId !== null && + requestedScenarioId !== effectiveSelectedScenarioId + ) { + navigation.navigate( + { scenarioId: effectiveSelectedScenarioId }, + { cause: "normalization", action: "scenario" }, + ); + } + }, [effectiveSelectedScenarioId, navigation, requestedScenarioId]); const effectiveScenarioParameterValues = stateValues.selectedScenarioId === undefined || stateValues.selectedScenarioId === effectiveSelectedScenarioId diff --git a/libs/@hashintel/petrinaut/src/react/state/active-net-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/active-net-provider.tsx index fc250d919cf..78f267cedbb 100644 --- a/libs/@hashintel/petrinaut/src/react/state/active-net-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/active-net-provider.tsx @@ -1,5 +1,6 @@ -import { use, useState, type ReactNode } from "react"; +import { use, useEffect, type ReactNode } from "react"; +import { openPetrinautSubnet, usePetrinautNavigation } from "../navigation"; import { ActiveNetContext } from "./active-net-context"; import { SDCPNContext } from "./sdcpn-context"; @@ -7,32 +8,39 @@ import { SDCPNContext } from "./sdcpn-context"; * Derives the active net from the full SDCPN. When a subnet is active, editor * panels and canvas operations read that subnet's local places/transitions/etc. * - * activeSubnetId is scoped to the current petriNetId: switching nets resets it - * to null without a useEffect by storing the net id alongside the subnet id. + * activeSubnetId is part of Petrinaut's app location. Changing subnets clears + * selection in the same atomic transition. The navigation provider is keyed + * by document, so uncontrolled locations reset when the active handle changes. */ export const ActiveNetProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { - const { petriNetId, petriNetDefinition } = use(SDCPNContext); - const [activeState, setActiveState] = useState<{ - petriNetId: string | null; - subnetId: string | null; - } | null>(null); - - // Effective subnet id: null if we've switched to a different net since it was set. - const activeSubnetId = - activeState?.petriNetId === petriNetId ? activeState.subnetId : null; + const { petriNetDefinition } = use(SDCPNContext); + const navigation = usePetrinautNavigation(); + const requestedSubnetId = navigation.state.subnetId; const setActiveSubnetId = (subnetId: string | null) => { - setActiveState({ petriNetId, subnetId }); + navigation.navigate(openPetrinautSubnet(subnetId), { + cause: "user", + action: "subnet", + }); }; const subnet = - activeSubnetId !== null - ? petriNetDefinition.subnets?.find(({ id }) => id === activeSubnetId) + requestedSubnetId !== null + ? petriNetDefinition.subnets?.find(({ id }) => id === requestedSubnetId) : undefined; - const resolvedSubnetId = subnet ? activeSubnetId : null; + const resolvedSubnetId = subnet ? requestedSubnetId : null; + + useEffect(() => { + if (requestedSubnetId && !subnet) { + navigation.navigate(openPetrinautSubnet(null), { + cause: "normalization", + action: "subnet", + }); + } + }, [navigation, requestedSubnetId, subnet]); const activeNet = subnet ? { diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 7778c21bab4..625e3ec4c00 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -42,7 +42,15 @@ export type SimulateDrawerState = | { type: "view-metric"; metricId: string } | { type: "create-metric" } | { type: "view-experiment"; experimentId: string } - | { type: "create-experiment" }; + | { type: "create-experiment" } + | { type: "create-optimization" }; + +export type EditorNavigationTarget = { + globalMode?: EditorGlobalMode; + simulateViewMode?: SimulateViewMode; + simulateDrawer?: SimulateDrawerState; + selection?: SelectionMap; +}; /** * What is rendered on the simulation timeline chart. @@ -110,6 +118,8 @@ export type EditorState = { * The action functions for the editor. */ export type EditorActions = { + /** Navigate several editor surfaces as one app-history transition. */ + navigateTo: (target: EditorNavigationTarget) => void; setGlobalMode: (mode: EditorGlobalMode) => void; setEditionMode: (mode: EditorEditionMode) => void; setCursorMode: (mode: CursorMode) => void; @@ -131,7 +141,10 @@ export type EditorActions = { selectedConnections: SelectionMap; setSelection: ( selection: SelectionMap | ((prev: SelectionMap) => SelectionMap), + options?: { cause: "normalization" } | { batch: "react-flow" }, ) => void; + beginSelectionGesture: () => void; + endSelectionGesture: () => void; selectItem: (item: SelectionItem) => void; toggleItem: (item: SelectionItem) => void; clearSelection: () => void; @@ -195,6 +208,7 @@ export const initialEditorState: EditorState = { const DEFAULT_CONTEXT_VALUE: EditorContextValue = { ...initialEditorState, + navigateTo: () => {}, setGlobalMode: () => {}, setEditionMode: () => {}, setCursorMode: () => {}, @@ -211,6 +225,8 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { isNotSelectedConnection: () => false, selectedConnections: new Map(), setSelection: () => {}, + beginSelectionGesture: () => {}, + endSelectionGesture: () => {}, selectItem: () => {}, toggleItem: () => {}, clearSelection: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index 48403dc4a84..ce61078271f 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -1,4 +1,4 @@ -import { use, useRef, useState } from "react"; +import { use, useEffect, useRef, useState } from "react"; import { getNodeConnections, @@ -7,6 +7,12 @@ import { } from "@hashintel/petrinaut-core"; import { ActualModeContext } from "../actual-mode-context"; +import { + navigationResourceToSimulateDrawer, + simulateDrawerToNavigationOverlay, + simulateDrawerToNavigationResource, + usePetrinautNavigation, +} from "../navigation"; import { ActiveNetContext } from "./active-net-context"; import { type DraggingStateByNodeId, @@ -16,6 +22,7 @@ import { type EditorState, initialEditorState, } from "./editor-context"; +import { SDCPNContext } from "./sdcpn-context"; import { useSyncEditorToSettings } from "./use-sync-editor-to-settings"; import { UserSettingsContext } from "./user-settings-context"; @@ -30,19 +37,29 @@ const canvasSelections = (selection: SelectionMap) => s.type === "componentInstance", ); +const selectionFromNavigation = ( + items: readonly SelectionItem[], +): SelectionMap => new Map(items.map((item) => [item.id, item])); + +const selectionToNavigation = (selection: SelectionMap) => + Array.from(selection.values()); + export const EditorProvider: React.FC = ({ children }) => { const userSettings = use(UserSettingsContext); const actualMode = use(ActualModeContext); + const navigation = usePetrinautNavigation(); const { activeNet } = use(ActiveNetContext); + const { getItemType, petriNetDefinition } = use(SDCPNContext); const startsInActualMode = actualMode.available; const startsWithActualTimeline = startsInActualMode && actualMode.initialState !== null && (actualMode.status === "streaming" || actualMode.status === "complete"); + const initialSelection = selectionFromNavigation(navigation.state.selection); const [state, setState] = useState(() => ({ ...initialEditorState, - globalMode: startsInActualMode ? "actual" : initialEditorState.globalMode, + globalMode: navigation.state.mode, cursorMode: userSettings.cursorMode, isLeftSidebarOpen: userSettings.isLeftSidebarOpen, leftSidebarWidth: userSettings.leftSidebarWidth, @@ -55,11 +72,64 @@ export const EditorProvider: React.FC = ({ children }) => { ? "actual-timeline" : userSettings.activeBottomPanelTab, timelineChartType: userSettings.timelineChartType, + simulateViewMode: navigation.state.simulateView, + simulateDrawer: navigationResourceToSimulateDrawer( + navigation.state.simulateResource, + navigation.state.overlay, + ), + selection: initialSelection, + hasSelection: initialSelection.size > 0, + hasCanvasSelection: canvasSelections(initialSelection).length > 0, })); + const navigatedResource = navigation.state.simulateResource; + const navigatedSelection = navigation.state.selection; + useEffect(() => { + const invalidResource = + (navigatedResource?.type === "scenario" && + !petriNetDefinition.scenarios?.some( + ({ id }) => id === navigatedResource.id, + )) || + (navigatedResource?.type === "metric" && + !petriNetDefinition.metrics?.some( + ({ id }) => id === navigatedResource.id, + )); + const validSelection = navigatedSelection.filter( + (item) => getItemType(item.id) === item.type, + ); + const hasInvalidSelection = + validSelection.length !== navigatedSelection.length; + + if (invalidResource || hasInvalidSelection) { + navigation.navigate( + { + ...(invalidResource ? { simulateResource: null } : {}), + ...(hasInvalidSelection ? { selection: validSelection } : {}), + }, + { + cause: "normalization", + action: invalidResource ? "simulation-resource" : "selection", + }, + ); + } + }, [ + getItemType, + navigatedResource, + navigatedSelection, + navigation, + petriNetDefinition.metrics, + petriNetDefinition.scenarios, + ]); + const animationTimerRef = useRef | undefined>( undefined, ); + const selectionGestureRef = useRef({ active: false, hasNavigated: false }); + const selectionNavigationMountedRef = useRef(true); + const pendingSelectionNavigationRef = useRef<{ + updates: Array<(selection: SelectionMap) => SelectionMap>; + } | null>(null); + const selectionNavigationScheduledRef = useRef(false); /** * Returns state patch to enable panel animation. Must be spread into the @@ -89,23 +159,194 @@ export const EditorProvider: React.FC = ({ children }) => { const setSelection = ( selectionOrUpdater: SelectionMap | ((prev: SelectionMap) => SelectionMap), + options?: { cause: "normalization" } | { batch: "react-flow" }, ) => { - scheduleAnimationEnd(); - setState((prev) => { - const selection = - typeof selectionOrUpdater === "function" - ? selectionOrUpdater(prev.selection) - : selectionOrUpdater; - const hasSelection = selection.size > 0; - const animate = prev.hasSelection !== hasSelection; - return { - ...prev, - ...(animate ? animationPatch() : {}), - selection, - hasSelection, - hasCanvasSelection: canvasSelections(selection).length > 0, - }; - }); + const selectionUpdate = + typeof selectionOrUpdater === "function" + ? selectionOrUpdater + : () => selectionOrUpdater; + if (!(options && "cause" in options)) { + const current = selectionFromNavigation(navigation.state.selection); + const preview = selectionUpdate(current); + if (current.size > 0 !== preview.size > 0) { + scheduleAnimationEnd(); + setState((prev) => ({ ...prev, ...animationPatch() })); + } + } + + const navigateSelection = ( + updates: Array<(selection: SelectionMap) => SelectionMap>, + intent: + | { cause: "normalization"; action: "selection" } + | { + cause: "user"; + action: "selection"; + phase: "discrete" | "start" | "continue"; + }, + ) => { + const didNavigate = navigation.navigate((current) => { + const selection = updates.reduce( + (value, update) => update(value), + selectionFromNavigation(current.selection), + ); + return { ...current, selection: selectionToNavigation(selection) }; + }, intent); + if ( + didNavigate && + intent.cause === "user" && + selectionGestureRef.current.active + ) { + selectionGestureRef.current.hasNavigated = true; + } + }; + + if (options && "cause" in options) { + navigateSelection([selectionUpdate], { + cause: "normalization", + action: "selection", + }); + return; + } + + if (!options || !("batch" in options)) { + const gesture = selectionGestureRef.current; + navigateSelection([selectionUpdate], { + cause: "user", + action: "selection", + phase: gesture.active + ? gesture.hasNavigated + ? "continue" + : "start" + : "discrete", + }); + return; + } + + const pending = pendingSelectionNavigationRef.current ?? { updates: [] }; + pending.updates.push(selectionUpdate); + pendingSelectionNavigationRef.current = pending; + + if (!selectionNavigationScheduledRef.current) { + selectionNavigationScheduledRef.current = true; + queueMicrotask(() => { + selectionNavigationScheduledRef.current = false; + const queued = pendingSelectionNavigationRef.current; + pendingSelectionNavigationRef.current = null; + if (!queued || !selectionNavigationMountedRef.current) { + return; + } + + const gesture = selectionGestureRef.current; + navigateSelection(queued.updates, { + cause: "user", + action: "selection", + phase: gesture.active + ? gesture.hasNavigated + ? "continue" + : "start" + : "discrete", + }); + }); + } + }; + + const beginSelectionGesture = () => { + selectionGestureRef.current = { active: true, hasNavigated: false }; + }; + + const endSelectionGesture = () => { + selectionGestureRef.current = { active: false, hasNavigated: false }; + }; + + useEffect(() => { + selectionNavigationMountedRef.current = true; + const finishInterruptedGesture = () => { + selectionGestureRef.current = { active: false, hasNavigated: false }; + }; + window.addEventListener("pointerup", finishInterruptedGesture); + window.addEventListener("pointercancel", finishInterruptedGesture); + window.addEventListener("blur", finishInterruptedGesture); + return () => { + selectionNavigationMountedRef.current = false; + pendingSelectionNavigationRef.current = null; + finishInterruptedGesture(); + window.removeEventListener("pointerup", finishInterruptedGesture); + window.removeEventListener("pointercancel", finishInterruptedGesture); + window.removeEventListener("blur", finishInterruptedGesture); + }; + }, []); + + const navigateTo: EditorActions["navigateTo"] = (target) => { + const hasSelection = target.selection !== undefined; + const drawerChangesOverlay = + target.simulateDrawer !== undefined && + simulateDrawerToNavigationOverlay( + target.simulateDrawer, + navigation.state.overlay, + )?.type !== navigation.state.overlay?.type; + if (hasSelection) { + const selection = selectionFromNavigation(navigation.state.selection); + if (selection.size > 0 !== target.selection!.size > 0) { + scheduleAnimationEnd(); + } + } + + navigation.navigate( + (current) => ({ + ...current, + ...(target.globalMode !== undefined ? { mode: target.globalMode } : {}), + ...(target.simulateViewMode !== undefined + ? { + simulateView: target.simulateViewMode, + simulateResource: target.simulateDrawer + ? simulateDrawerToNavigationResource(target.simulateDrawer) + : null, + overlay: target.simulateDrawer + ? simulateDrawerToNavigationOverlay( + target.simulateDrawer, + current.overlay, + ) + : current.overlay, + } + : target.simulateDrawer !== undefined + ? { + simulateResource: simulateDrawerToNavigationResource( + target.simulateDrawer, + ), + overlay: simulateDrawerToNavigationOverlay( + target.simulateDrawer, + current.overlay, + ), + } + : {}), + ...(target.selection !== undefined + ? { selection: selectionToNavigation(target.selection) } + : {}), + }), + { + cause: "user", + action: + target.selection !== undefined + ? "selection" + : target.simulateDrawer !== undefined + ? drawerChangesOverlay + ? "overlay" + : "simulation-resource" + : target.simulateViewMode !== undefined + ? "simulation-view" + : "mode", + }, + ); + + // Mode, view, and drawer flow back in through `effectiveState`, which + // derives them from navigation state on every render; only the selection + // animation flag lives in local state. + const animateSelection = + hasSelection && + navigation.state.selection.length > 0 !== target.selection!.size > 0; + if (animateSelection) { + setState((prev) => ({ ...prev, ...animationPatch() })); + } }; const actions: Omit< @@ -118,8 +359,8 @@ export const EditorProvider: React.FC = ({ children }) => { | "isHoveredConnection" | "isNotHoveredConnection" > = { - setGlobalMode: (mode) => - setState((prev) => ({ ...prev, globalMode: mode })), + navigateTo, + setGlobalMode: (mode) => navigateTo({ globalMode: mode }), setEditionMode: (mode) => setState((prev) => ({ ...prev, @@ -168,50 +409,21 @@ export const EditorProvider: React.FC = ({ children }) => { setActiveBottomPanelTab: (tab) => setState((prev) => ({ ...prev, activeBottomPanelTab: tab })), setSelection, - selectItem: (item: SelectionItem) => { - scheduleAnimationEnd(); - setState((prev) => { - const newSelection: SelectionMap = new Map([[item.id, item]]); - const animate = !prev.hasSelection; - return { - ...prev, - ...(animate ? animationPatch() : {}), - selection: newSelection, - hasSelection: true, - hasCanvasSelection: canvasSelections(newSelection).length > 0, - }; - }); - }, - toggleItem: (item: SelectionItem) => { - scheduleAnimationEnd(); - setState((prev) => { - const newSelection = new Map(prev.selection); - if (newSelection.has(item.id)) { - newSelection.delete(item.id); + beginSelectionGesture, + endSelectionGesture, + selectItem: (item: SelectionItem) => + navigateTo({ selection: new Map([[item.id, item]]) }), + toggleItem: (item: SelectionItem) => + setSelection((prev) => { + const selection = new Map(prev); + if (selection.has(item.id)) { + selection.delete(item.id); } else { - newSelection.set(item.id, item); + selection.set(item.id, item); } - const hasSelection = newSelection.size > 0; - const animate = prev.hasSelection !== hasSelection; - return { - ...prev, - ...(animate ? animationPatch() : {}), - selection: newSelection, - hasSelection, - hasCanvasSelection: canvasSelections(newSelection).length > 0, - }; - }); - }, - clearSelection: () => { - scheduleAnimationEnd(); - setState((prev) => ({ - ...prev, - ...(prev.hasSelection ? animationPatch() : {}), - selection: new Map(), - hasSelection: false, - hasCanvasSelection: false, - })); - }, + return selection; + }), + clearSelection: () => setSelection(new Map()), setHoveredItem: (item: SelectionItem) => setState((prev) => ({ ...prev, hoveredItem: item })), clearHoveredItem: () => @@ -227,15 +439,16 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, draggingStateByNodeId: {} })), collapseAllPanels: () => { scheduleAnimationEnd(); + navigation.navigate( + { selection: [] }, + { cause: "user", action: "selection" }, + ); setState((prev) => ({ ...prev, ...animationPatch(), isLeftSidebarOpen: false, isSearchOpen: false, isBottomPanelOpen: false, - selection: new Map(), - hasSelection: false, - hasCanvasSelection: false, })); }, setTimelineChartType: (chartType) => @@ -245,9 +458,11 @@ export const EditorProvider: React.FC = ({ children }) => { setHiddenTimelineSeriesIds: (seriesIds) => setState((prev) => ({ ...prev, hiddenTimelineSeriesIds: seriesIds })), setSimulateViewMode: (mode) => - setState((prev) => ({ ...prev, simulateViewMode: mode })), - setSimulateDrawer: (drawer) => - setState((prev) => ({ ...prev, simulateDrawer: drawer })), + navigateTo({ + simulateViewMode: mode, + simulateDrawer: { type: "closed" }, + }), + setSimulateDrawer: (drawer) => navigateTo({ simulateDrawer: drawer }), setSearchOpen: (isOpen) => { scheduleAnimationEnd(); setState((prev) => { @@ -289,7 +504,20 @@ export const EditorProvider: React.FC = ({ children }) => { timelineChartType: state.timelineChartType, }); - const { selection, hoveredItem } = state; + const selection = selectionFromNavigation(navigation.state.selection); + const effectiveState: EditorState = { + ...state, + globalMode: navigation.state.mode, + simulateViewMode: navigation.state.simulateView, + simulateDrawer: navigationResourceToSimulateDrawer( + navigation.state.simulateResource, + navigation.state.overlay, + ), + selection, + hasSelection: selection.size > 0, + hasCanvasSelection: canvasSelections(selection).length > 0, + }; + const { hoveredItem } = effectiveState; const isSelected = (id: string) => selection.has(id); const selectedConnections = getNodeConnections( @@ -317,7 +545,7 @@ export const EditorProvider: React.FC = ({ children }) => { const searchInputRef = useRef(null); const contextValue: EditorContextValue = { - ...state, + ...effectiveState, ...actions, isSelected, isHovered, diff --git a/libs/@hashintel/petrinaut/src/react/state/use-selection-cleanup.ts b/libs/@hashintel/petrinaut/src/react/state/use-selection-cleanup.ts index 7b433b63be3..a4dbee39a3f 100644 --- a/libs/@hashintel/petrinaut/src/react/state/use-selection-cleanup.ts +++ b/libs/@hashintel/petrinaut/src/react/state/use-selection-cleanup.ts @@ -81,15 +81,18 @@ export function useSelectionCleanup() { } if (hasStale) { - setSelection((prev) => { - const cleaned: SelectionMap = new Map(); - for (const [id, item] of prev) { - if (validIds.has(id)) { - cleaned.set(id, item); + setSelection( + (prev) => { + const cleaned: SelectionMap = new Map(); + for (const [id, item] of prev) { + if (validIds.has(id)) { + cleaned.set(id, item); + } } - } - return cleaned; - }); + return cleaned; + }, + { cause: "normalization" }, + ); } // Clear hoveredItem if it references a deleted element diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index a9196f572e9..6dd28fd99e1 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -16,6 +16,18 @@ export type { PetrinautAiChatTransport, PetrinautProps, } from "./petrinaut"; +export type { + PetrinautNavigationAction, + PetrinautNavigationController, + PetrinautNavigationHistory, + PetrinautNavigationHistoryPolicy, + PetrinautNavigationIntent, + PetrinautNavigationOverlay, + PetrinautNavigationState, + PetrinautNavigationUpdate, + PetrinautNavigationUpdater, + PetrinautSimulateResource, +} from "../react/navigation"; export { definePetrinautAiInteractiveTool } from "./types/ai-interactive-tool"; export type { PetrinautAiInteractiveTool, diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 42ad6505c0c..a0799b917ee 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -50,6 +50,7 @@ export type PetrinautAiAssistant = { transport: PetrinautAiTransport; }; +import type { PetrinautNavigationController } from "../react/navigation"; import type { NetManagement } from "../react/net-management-context"; import type { PetrinautSlots } from "./types/petrinaut-slots"; import type { ViewportAction } from "./types/viewport-action"; @@ -100,6 +101,8 @@ export type PetrinautProps = { * `?worker` against the host's own copy of the worker source. */ lspWorkerFactory?: LspWorkerFactory; + /** Optional host-controlled, router-neutral app location. */ + navigation?: PetrinautNavigationController; /** * Presentation policy for the full editor. `review` keeps the full editor * surface while suppressing authoring actions for route-scoped read-only @@ -133,6 +136,7 @@ export const Petrinaut: FunctionComponent = ({ simulationWorkerFactory, monteCarloWorkerFactory, lspWorkerFactory, + navigation, presentationProfile = "editor", }) => { const portalContainerRef = useRef(null); @@ -159,6 +163,7 @@ export const Petrinaut: FunctionComponent = ({ simulationWorkerFactory={simulationWorkerFactory} monteCarloWorkerFactory={monteCarloWorkerFactory} lspWorkerFactory={lspWorkerFactory} + navigation={navigation} > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx index 962b9487a81..a594877263e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/diagnostics.tsx @@ -129,7 +129,7 @@ const DiagnosticsContent: React.FC = () => { LanguageClientContext, ); const { petriNetDefinition, getItemType } = use(SDCPNContext); - const { selectItem, setGlobalMode } = use(EditorContext); + const { navigateTo, selectItem } = use(EditorContext); const { state: simulationState, error: simulationError, @@ -239,10 +239,14 @@ const DiagnosticsContent: React.FC = () => { iconName="arrowRight" iconPosition="right" onClick={() => { - setGlobalMode("edit"); const itemType = getItemType(errorItemId); if (itemType) { - selectItem({ type: itemType, id: errorItemId }); + navigateTo({ + globalMode: "edit", + selection: new Map([ + [errorItemId, { type: itemType, id: errorItemId }], + ]), + }); } }} > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx index acf10f3d318..35a8609bb74 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx @@ -6,7 +6,6 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { usePetrinautMutations } from "../../../../../../react"; import { ActiveNetContext } from "../../../../../../react/state/active-net-context"; -import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; @@ -130,7 +129,6 @@ export const NetNavigationList: React.FC = () => { petriNetDefinition: { subnets }, } = use(SDCPNContext); const { activeSubnetId, setActiveSubnetId } = use(ActiveNetContext); - const { clearSelection } = use(EditorContext); const { updateSubnet, removeSubnet } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); const mutationActionsVisible = presentation.showMutationActions; @@ -149,7 +147,6 @@ export const NetNavigationList: React.FC = () => { const handleSelect = (subnetId: string | null) => { setActiveSubnetId(subnetId); - clearSelection(); }; const startEditing = (subnetId: string, currentName: string) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx index e0c2031b920..31edac3b510 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx @@ -232,6 +232,7 @@ export function FakeEditorProvider({ ...initialEditorState, globalMode: "simulate", simulateViewMode, + navigateTo: () => {}, setGlobalMode: () => {}, setEditionMode: () => {}, setAddComponentMode: () => {}, @@ -248,6 +249,8 @@ export function FakeEditorProvider({ isNotSelectedConnection: () => false, selectedConnections: new Map(), setSelection: () => {}, + beginSelectionGesture: () => {}, + endSelectionGesture: () => {}, selectItem: () => {}, toggleItem: () => {}, clearSelection: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index 20e12ccf48e..227d2fcb796 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -49,6 +49,7 @@ const emptySDCPN: SDCPN = { const editorContextValue: EditorContextValue = { ...initialEditorState, isAiAssistantOpen: true, + navigateTo: () => {}, setGlobalMode: () => {}, setEditionMode: () => {}, setAddComponentMode: () => {}, @@ -65,6 +66,8 @@ const editorContextValue: EditorContextValue = { isNotSelectedConnection: () => false, selectedConnections: new Map(), setSelection: () => {}, + beginSelectionGesture: () => {}, + endSelectionGesture: () => {}, selectItem: () => {}, toggleItem: () => {}, clearSelection: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index fc44facc2ea..ee333801bff 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -63,27 +63,25 @@ export type { const selectTarget = ( target: AiToolTarget, - actions: Pick< - EditorContextValue, - "selectItem" | "setGlobalMode" | "setSimulateDrawer" | "setSimulateViewMode" - >, + actions: Pick, ) => { if (target.kind === "selection") { actions.selectItem(target.item); return; } - actions.setGlobalMode("simulate"); - actions.setSimulateViewMode(target.mode); - actions.setSimulateDrawer( - target.mode === "scenarios" - ? target.itemId - ? { type: "view-scenario", scenarioId: target.itemId } - : { type: "closed" } - : target.itemId - ? { type: "view-metric", metricId: target.itemId } - : { type: "closed" }, - ); + actions.navigateTo({ + globalMode: "simulate", + simulateViewMode: target.mode, + simulateDrawer: + target.mode === "scenarios" + ? target.itemId + ? { type: "view-scenario", scenarioId: target.itemId } + : { type: "closed" } + : target.itemId + ? { type: "view-metric", metricId: target.itemId } + : { type: "closed" }, + }); }; const isPetrinautAiMutationToolName = ( @@ -224,12 +222,10 @@ export const AiAssistantPanel = ({ const { hasSelection, isAiAssistantOpen, + navigateTo, propertiesPanelWidth, selectItem, setAiAssistantOpen, - setGlobalMode, - setSimulateDrawer, - setSimulateViewMode, } = use(EditorContext); const { petriNetDefinition, setTitle, title } = use(SDCPNContext); @@ -695,10 +691,8 @@ export const AiAssistantPanel = ({ }} onSelectToolTarget={(target) => selectTarget(target, { + navigateTo, selectItem, - setGlobalMode, - setSimulateDrawer, - setSimulateViewMode, }) } onSendPrompt={(prompt) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx index 0d424c01b38..d9b91ee8cda 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx @@ -1,9 +1,10 @@ import { useReactFlow } from "@xyflow/react"; -import { use, useState } from "react"; +import { use } from "react"; import { Button } from "@hashintel/ds-components"; import { cx, css, cva } from "@hashintel/ds-helpers/css"; +import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; import { PANEL_MARGIN } from "../../../constants/ui"; import { usePetrinautPresentation } from "../../shared/presentation-context"; @@ -38,7 +39,14 @@ export const ViewportControls: React.FC<{ viewportActions?: ViewportAction[]; }> = ({ viewportActions }) => { const presentation = usePetrinautPresentation(); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const navigation = usePetrinautNavigation(); + const isSettingsOpen = navigation.state.overlay?.type === "viewport-settings"; + const setIsSettingsOpen = (open: boolean) => { + navigation.navigate( + { overlay: open ? { type: "viewport-settings" } : null }, + { cause: "user", action: "overlay" }, + ); + }; const { fitView, zoomIn, zoomOut } = useReactFlow(); const { collapseAllPanels, diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts index 74472767e75..3a78854c11d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts @@ -83,41 +83,44 @@ export function useApplyNodeChanges() { // ReactFlow fires separately in the same event tick) don't clobber // each other due to stale closure state. if (selectionChanged) { - setSelection((prevSelection) => { - const hasNonCanvasItems = Array.from(prevSelection.values()).some( - (item) => - item.type !== "place" && - item.type !== "transition" && - item.type !== "arc" && - item.type !== "componentInstance", - ); + setSelection( + (prevSelection) => { + const hasNonCanvasItems = Array.from(prevSelection.values()).some( + (item) => + item.type !== "place" && + item.type !== "transition" && + item.type !== "arc" && + item.type !== "componentInstance", + ); - const base: SelectionMap = new Map( - hasNonCanvasItems ? [] : prevSelection, - ); + const base: SelectionMap = new Map( + hasNonCanvasItems ? [] : prevSelection, + ); - let changed = hasNonCanvasItems && prevSelection.size > 0; + let changed = hasNonCanvasItems && prevSelection.size > 0; - for (const change of changes) { - if (change.type === "select") { - if (change.selected && !base.has(change.id)) { - const itemType = getItemType(change.id); - // Skip edges — they are only selectable via direct click - // (onEdgeClick), not via drag-to-select box selection. - if (itemType && itemType !== "arc") { - base.set(change.id, { type: itemType, id: change.id }); + for (const change of changes) { + if (change.type === "select") { + if (change.selected && !base.has(change.id)) { + const itemType = getItemType(change.id); + // Skip edges — they are only selectable via direct click + // (onEdgeClick), not via drag-to-select box selection. + if (itemType && itemType !== "arc") { + base.set(change.id, { type: itemType, id: change.id }); + changed = true; + } + } else if (!change.selected && base.has(change.id)) { + base.delete(change.id); changed = true; } - } else if (!change.selected && base.has(change.id)) { - base.delete(change.id); - changed = true; } } - } - // Avoid unnecessary re-renders when nothing actually changed - return changed ? base : prevSelection; - }); + // Avoid unnecessary re-renders when nothing actually changed + return changed ? base : prevSelection; + }, + { batch: "react-flow" }, + ); } // Commit all final positions from drag-end in a single atomic mutation diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx index cccc68a5462..91bd95fa12e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx @@ -127,7 +127,10 @@ export const SDCPNCanvas: React.FC<{ setEditionMode, componentSubnetId, cursorMode, + beginSelectionGesture, + endSelectionGesture, selectItem, + setSelection, clearSelection, hasCanvasSelection, setHoveredItem, @@ -319,10 +322,7 @@ export const SDCPNCanvas: React.FC<{ // Edge selection is handled here instead of in applyNodeChanges, // because we want edges selectable only by click, not by drag-to-select. function onEdgeClick(_event: React.MouseEvent, edge: { id: string }) { - selectItem({ - type: "arc", - id: edge.id, - }); + setSelection(new Map([[edge.id, { type: "arc", id: edge.id }]])); } function onNodeMouseEnter( @@ -480,6 +480,8 @@ export const SDCPNCanvas: React.FC<{ onNodeMouseLeave={onNodeMouseLeave} onEdgeMouseEnter={onEdgeMouseEnter} onEdgeMouseLeave={onEdgeMouseLeave} + onSelectionStart={beginSelectionGesture} + onSelectionEnd={endSelectionGesture} onPaneClick={onPaneClick} onDrop={isReadonly ? undefined : onDrop} onDragOver={isReadonly ? undefined : onDragOver} From e959446338f2d47bcedcdcc72116fa44cfdef0ab Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:24:01 +0200 Subject: [PATCH 2/2] FE-1500: route creation drawers --- .../src/ui/views/Editor/editor-view.tsx | 15 ++-- .../subviews/simulation-settings.tsx | 19 +++-- .../subviews/simulation-timeline/header.tsx | 16 ++-- .../experiments/experiments-view.tsx | 18 +---- .../SimulateView/metrics/metrics-view.tsx | 19 +---- .../optimizations/optimizations-view.tsx | 17 +--- .../SimulateView/scenarios/scenarios-view.tsx | 19 +---- .../SimulateView/simulate-view.stories.tsx | 3 + .../simulation-creation-drawer.test.tsx | 78 +++++++++++++++++++ .../Editor/simulation-creation-drawer.tsx | 29 +++++++ 10 files changed, 150 insertions(+), 83 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.tsx diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 9f00baa5f6c..534de3e6d0f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -24,7 +24,6 @@ import { import { usePetrinautCommands } from "../../../react"; import { ActualModeContext } from "../../../react/actual-mode-context"; -import { ExperimentsContext } from "../../../react/experiments/context"; import { EditorContext } from "../../../react/state/editor-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; @@ -49,6 +48,7 @@ import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; import { SimulateView } from "./panels/SimulateView/simulate-view"; +import { SimulationCreationDrawer } from "./simulation-creation-drawer"; import type { PetrinautAiAssistant } from "../../petrinaut"; import type { PetrinautSlots } from "../../types/petrinaut-slots"; @@ -133,18 +133,17 @@ export const EditorView = ({ const { globalMode: mode, isAiAssistantOpen, + navigateTo, setGlobalMode, editionMode, setEditionMode, cursorMode, setCursorMode, clearSelection, - setSimulateViewMode, setAiAssistantOpen, isBottomPanelOpen, bottomPanelHeight, } = use(EditorContext); - const { setSelectedExperimentId } = use(ExperimentsContext); const actualMode = use(ActualModeContext); const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< @@ -209,9 +208,11 @@ export const EditorView = ({ } function handleRunningExperimentClick(experimentId: string) { - setGlobalMode("simulate"); - setSimulateViewMode("experiments"); - setSelectedExperimentId(experimentId); + navigateTo({ + globalMode: "simulate", + simulateViewMode: "experiments", + simulateDrawer: { type: "view-experiment", experimentId }, + }); } async function handleImport() { @@ -496,6 +497,8 @@ export const EditorView = ({ )} + + ); }; 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 24fe14aeec9..67d5f37a538 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 @@ -7,7 +7,6 @@ import { SimulationContext } from "../../../../../../react/simulation/context"; import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { SimulationScenarioControls } from "../../../../shared/simulation-scenario-controls"; -import { CreateScenarioDrawer } from "../../SimulateView/scenarios/create-scenario-drawer"; import { ViewScenarioDrawer } from "../../SimulateView/scenarios/view-scenario-drawer"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -69,9 +68,9 @@ const smallLabelStyle = css({ fontWeight: "normal", }); -/** Full-editor composition around the scenario controls shared with Viewer. */ +/** Full-editor composition around the shared scenario controls. */ const SimulationSettingsContent: React.FC = () => { - const { setGlobalMode } = use(EditorContext); + const { navigateTo, setSimulateDrawer } = use(EditorContext); const { petriNetDefinition: { scenarios }, } = use(SDCPNContext); @@ -82,7 +81,6 @@ const SimulationSettingsContent: React.FC = () => { selectedScenarioId, } = use(SimulationContext); const [isViewScenarioOpen, setIsViewScenarioOpen] = useState(false); - const [isCreateScenarioOpen, setIsCreateScenarioOpen] = useState(false); const selectedScenario = scenarios?.find( (scenario) => scenario.id === selectedScenarioId, @@ -92,10 +90,6 @@ const SimulationSettingsContent: React.FC = () => { return (
- setIsCreateScenarioOpen(false)} - /> setIsViewScenarioOpen(false)} @@ -122,7 +116,7 @@ const SimulationSettingsContent: React.FC = () => { aria-label="Create scenario" tooltip="Create Scenario" iconName="plus" - onClick={() => setIsCreateScenarioOpen(true)} + onClick={() => setSimulateDrawer({ type: "create-scenario" })} />
- setIsCreateOpen(false)} - /> { - const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); + const { setSimulateDrawer } = use(EditorContext); const { experiments, selectedExperiment, @@ -118,7 +118,6 @@ export const ExperimentsView = () => { setSelectedExperimentId, } = use(ExperimentsContext); - const closeCreateDrawer = () => setIsCreateDrawerOpen(false); const closeViewDrawer = () => setSelectedExperimentId(null); return ( @@ -130,7 +129,7 @@ export const ExperimentsView = () => { tone="neutral" size="sm" prefix={} - onClick={() => setIsCreateDrawerOpen(true)} + onClick={() => setSimulateDrawer({ type: "create-experiment" })} > Create @@ -142,15 +141,6 @@ export const ExperimentsView = () => { onSelect={setSelectedExperimentId} /> - { - setIsCreateDrawerOpen(false); - setSelectedExperimentId(experimentId); - }} - /> - []; -type MetricDrawerState = - | { type: "closed" } - | { type: "view-metric"; metricId: string } - | { type: "create-metric" }; - const MetricList = ({ metrics, selectedId, @@ -54,9 +49,8 @@ const MetricList = ({ }; export const MetricsView = () => { - const [drawer, setDrawer] = useState({ - type: "closed", - }); + const { simulateDrawer: drawer, setSimulateDrawer: setDrawer } = + use(EditorContext); const { petriNetDefinition } = use(SDCPNContext); const metrics = petriNetDefinition.metrics ?? []; @@ -88,11 +82,6 @@ export const MetricsView = () => { onSelect={(id) => setDrawer({ type: "view-metric", metricId: id })} /> - - []; export const OptimizationsView = () => { - const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); + const { setSimulateDrawer } = use(EditorContext); const { optimizations, selectedOptimization, @@ -135,7 +135,7 @@ export const OptimizationsView = () => { tone="neutral" size="sm" prefix={} - onClick={() => setIsCreateDrawerOpen(true)} + onClick={() => setSimulateDrawer({ type: "create-optimization" })} > Create @@ -152,15 +152,6 @@ export const OptimizationsView = () => { } /> - setIsCreateDrawerOpen(false)} - onCreated={(optimizationId) => { - setIsCreateDrawerOpen(false); - setSelectedOptimizationId(optimizationId); - }} - /> - []; -type ScenarioDrawerState = - | { type: "closed" } - | { type: "view-scenario"; scenarioId: string } - | { type: "create-scenario" }; - const ScenarioList = ({ scenarios, selectedId, @@ -54,9 +49,8 @@ const ScenarioList = ({ }; export const ScenariosView = () => { - const [drawer, setDrawer] = useState({ - type: "closed", - }); + const { simulateDrawer: drawer, setSimulateDrawer: setDrawer } = + use(EditorContext); const { petriNetDefinition } = use(SDCPNContext); const scenarios = petriNetDefinition.scenarios ?? []; @@ -88,11 +82,6 @@ export const ScenariosView = () => { onSelect={(id) => setDrawer({ type: "view-scenario", scenarioId: id })} /> - - + @@ -359,6 +361,7 @@ const RunnableSimulateViewStory = ({ className={portalContainerStyle} /> + diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.test.tsx new file mode 100644 index 00000000000..33a0191c042 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + EditorContext, + initialEditorState, +} from "../../../react/state/editor-context"; +import { SimulationCreationDrawer } from "./simulation-creation-drawer"; + +import type { + EditorContextValue, + SimulateDrawerState, +} from "../../../react/state/editor-context"; + +vi.mock("./panels/SimulateView/experiments/create-experiment-drawer", () => ({ + CreateExperimentDrawer: ({ onClose }: { onClose: () => void }) => ( + + ), +})); +vi.mock("./panels/SimulateView/metrics/create-metric-drawer", () => ({ + CreateMetricDrawer: ({ onClose }: { onClose: () => void }) => ( + + ), +})); +vi.mock( + "./panels/SimulateView/optimizations/create-optimization-drawer", + () => ({ + CreateOptimizationDrawer: ({ onClose }: { onClose: () => void }) => ( + + ), + }), +); +vi.mock("./panels/SimulateView/scenarios/create-scenario-drawer", () => ({ + CreateScenarioDrawer: ({ onClose }: { onClose: () => void }) => ( + + ), +})); + +const drawerCases = [ + ["create-experiment", "experiment"], + ["create-metric", "metric"], + ["create-optimization", "optimization"], + ["create-scenario", "scenario"], +] as const satisfies readonly [SimulateDrawerState["type"], string][]; + +describe("SimulationCreationDrawer", () => { + it.each(drawerCases)( + "renders and closes %s from app state", + (type, label) => { + const setSimulateDrawer = vi.fn(); + const value = { + ...initialEditorState, + simulateDrawer: { type }, + setSimulateDrawer, + } as unknown as EditorContextValue; + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: label })); + expect(setSimulateDrawer).toHaveBeenCalledOnce(); + expect(setSimulateDrawer).toHaveBeenCalledWith({ type: "closed" }); + }, + ); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.tsx new file mode 100644 index 00000000000..2d5b74236ef --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/simulation-creation-drawer.tsx @@ -0,0 +1,29 @@ +import { use } from "react"; + +import { EditorContext } from "../../../react/state/editor-context"; +import { CreateExperimentDrawer } from "./panels/SimulateView/experiments/create-experiment-drawer"; +import { CreateMetricDrawer } from "./panels/SimulateView/metrics/create-metric-drawer"; +import { CreateOptimizationDrawer } from "./panels/SimulateView/optimizations/create-optimization-drawer"; +import { CreateScenarioDrawer } from "./panels/SimulateView/scenarios/create-scenario-drawer"; + +/** Renders the one create drawer addressed by Petrinaut's app location. */ +export const SimulationCreationDrawer = () => { + const { setSimulateDrawer, simulateDrawer } = use(EditorContext); + const closeDrawer = () => setSimulateDrawer({ type: "closed" }); + + switch (simulateDrawer.type) { + case "create-experiment": + return ; + case "create-metric": + return ; + case "create-optimization": + return ; + case "create-scenario": + return ; + case "closed": + case "view-experiment": + case "view-metric": + case "view-scenario": + return null; + } +};