From 8917f1db47ba9d451ee3d9f3bd23304d19b3eee2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 26 Aug 2026 23:24:47 +0200 Subject: [PATCH 1/5] FE-1500: add Petrinaut website routing --- .gitignore | 1 + apps/petrinaut-website/README.md | 5 +- apps/petrinaut-website/package.json | 2 + apps/petrinaut-website/src/main.tsx | 5 +- apps/petrinaut-website/src/main/app.tsx | 20 -- .../brunch-demo/brunch-actual-mode-route.tsx | 13 +- .../main/app/brunch-demo/brunch-demo-app.tsx | 11 +- .../app/brunch-demo/brunch-endpoint.test.ts | 63 ++++ .../main/app/brunch-demo/brunch-endpoint.ts | 25 +- .../src/main/app/brunch-demo/brunch-route.ts | 9 - .../app/brunch-demo/brunch-search.test.ts | 43 +++ .../src/main/app/brunch-demo/brunch-search.ts | 16 ++ .../optimization-demo/optimization-route.ts | 5 - apps/petrinaut-website/src/routeTree.gen.ts | 95 +++++++ apps/petrinaut-website/src/router.ts | 14 + .../src/routes/-not-found-page.tsx | 55 ++++ apps/petrinaut-website/src/routes/__root.tsx | 8 + apps/petrinaut-website/src/routes/brunch.tsx | 13 + apps/petrinaut-website/src/routes/index.tsx | 7 + .../src/routes/optimization.tsx | 12 + apps/petrinaut-website/vercel.json | 6 +- apps/petrinaut-website/vite.config.ts | 7 + oxfmt.config.ts | 1 + yarn.lock | 268 +++++++++++++++++- 24 files changed, 637 insertions(+), 67 deletions(-) delete mode 100644 apps/petrinaut-website/src/main/app.tsx create mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts delete mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-route.ts create mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts create mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts delete mode 100644 apps/petrinaut-website/src/main/app/optimization-demo/optimization-route.ts create mode 100644 apps/petrinaut-website/src/routeTree.gen.ts create mode 100644 apps/petrinaut-website/src/router.ts create mode 100644 apps/petrinaut-website/src/routes/-not-found-page.tsx create mode 100644 apps/petrinaut-website/src/routes/__root.tsx create mode 100644 apps/petrinaut-website/src/routes/brunch.tsx create mode 100644 apps/petrinaut-website/src/routes/index.tsx create mode 100644 apps/petrinaut-website/src/routes/optimization.tsx diff --git a/.gitignore b/.gitignore index ecf3916c9a5..b98b125b28d 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ libs/@local/graph/store/typescript/src/generated # generated files *.gen.* +!apps/petrinaut-website/src/routeTree.gen.ts *.tsbuildinfo feed.atom feed.rss diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index d4fc4c4140f..c2ae8c4d0c3 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -33,8 +33,9 @@ Stopping the command also stops and removes its optimizer container. The development server proxies `/api/petrinaut-opt/*` to the optimizer on `127.0.0.1:4004`, avoiding development-only CORS changes to the Python service. Regular `yarn dev` does not enable optimization; use the dedicated command to -connect the website to the real optimizer service. Storybook provides a fake -optimizer for isolated UI development. +connect the website to the real optimizer service. The `/optimization` route +returns the website's not-found page when the provider is disabled. Storybook +provides a fake optimizer for isolated UI development. ## Environment variables diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 6458fcedb79..6bda1b30497 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -26,6 +26,7 @@ "@mantine/hooks": "8.3.5", "@pandacss/dev": "1.11.1", "@sentry/react": "10.64.0", + "@tanstack/react-router": "1.170.31", "ai": "6.0.182", "immer": "10.1.3", "react": "19.2.6", @@ -34,6 +35,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@tanstack/router-plugin": "1.168.34", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260511.1", diff --git a/apps/petrinaut-website/src/main.tsx b/apps/petrinaut-website/src/main.tsx index f45641dc79a..64a073587d3 100644 --- a/apps/petrinaut-website/src/main.tsx +++ b/apps/petrinaut-website/src/main.tsx @@ -2,10 +2,11 @@ import "@hashintel/petrinaut/styles.css"; import "./app.css"; import "./sentry/instrument"; import * as Sentry from "@sentry/react"; +import { RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { DemoApp } from "./main/app"; +import { router } from "./router"; import { SentryErrorTrackerProvider } from "./sentry/sentry-error-tracker-provider"; const root = createRoot(document.getElementById("root")!, { @@ -25,7 +26,7 @@ const root = createRoot(document.getElementById("root")!, { root.render( - + , ); diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx deleted file mode 100644 index 8b4f2172254..00000000000 --- a/apps/petrinaut-website/src/main/app.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { BrunchDemoApp } from "./app/brunch-demo/brunch-demo-app"; -import { isBrunchDemoRoute } from "./app/brunch-demo/brunch-route"; -import { LocalStorageDemoApp } from "./app/local-storage-demo/local-storage-demo-app"; -import { OptimizationDemoApp } from "./app/optimization-demo/optimization-demo-app"; -import { isOptimizationDemoRoute } from "./app/optimization-demo/optimization-route"; - -export const DemoApp = () => { - if (isBrunchDemoRoute()) { - return ; - } - - if ( - isOptimizationDemoRoute() && - import.meta.env.VITE_PETRINAUT_OPT_PROVIDER === "service" - ) { - return ; - } - - return ; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx index 28ada7d3f92..e18b4a5f393 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx @@ -1,19 +1,22 @@ import { BrunchActualModeProvider } from "./brunch-actual-mode-provider"; -import { getBrunchEndpointFromLocation } from "./brunch-endpoint"; +import { getBrunchEndpoint } from "./brunch-endpoint"; import { BrunchPetrinaut } from "./brunch-petrinaut"; import { BrunchStatusPage } from "./brunch-status-page"; +import type { BrunchRouteSearch } from "./brunch-search"; import type { ViewportAction } from "@hashintel/petrinaut/ui"; -export { BrunchActualModeProvider } from "./brunch-actual-mode-provider"; -export { getBrunchEndpointFromLocation } from "./brunch-endpoint"; - export const BrunchActualModeRoute = ({ + search, viewportActions, }: { + search: BrunchRouteSearch; viewportActions: ViewportAction[]; }) => { - const endpointResult = getBrunchEndpointFromLocation(window.location); + const endpointResult = getBrunchEndpoint({ + baseUrl: window.location.href, + search, + }); if (!endpointResult.ok) { return ( diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx index c7a11828aec..6b234fad4f5 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx @@ -1,8 +1,15 @@ import { useSentryFeedbackAction } from "../sentry-feedback-button"; import { BrunchActualModeRoute } from "./brunch-actual-mode-route"; -export const BrunchDemoApp = () => { +import type { BrunchRouteSearch } from "./brunch-search"; + +export const BrunchDemoApp = ({ search }: { search: BrunchRouteSearch }) => { const sentryFeedbackAction = useSentryFeedbackAction(); - return ; + return ( + + ); }; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts new file mode 100644 index 00000000000..21478dbb019 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { getBrunchEndpoint } from "./brunch-endpoint"; + +const baseUrl = "https://demo.petrinaut.org/brunch"; + +describe("getBrunchEndpoint", () => { + it("returns a friendly error when the endpoint is missing", () => { + expect(getBrunchEndpoint({ baseUrl, search: {} })).toEqual({ + ok: false, + error: "Missing Brunch stream endpoint. Add ?sse=.", + }); + }); + + it("distinguishes an empty endpoint from a missing one", () => { + expect(getBrunchEndpoint({ baseUrl, search: { sse: "" } })).toEqual({ + ok: false, + error: "Brunch endpoint is empty.", + }); + expect(getBrunchEndpoint({ baseUrl, search: { sse: " " } })).toEqual({ + ok: false, + error: "Brunch endpoint is empty.", + }); + }); + + it("resolves a relative endpoint and retains the run id", () => { + expect( + getBrunchEndpoint({ + baseUrl, + search: { runId: "run-1", sse: "/events" }, + }), + ).toEqual({ + ok: true, + endpoint: "https://demo.petrinaut.org/events", + runId: "run-1", + }); + }); + + it("adds HTTP for loopback endpoints without a protocol", () => { + expect( + getBrunchEndpoint({ + baseUrl, + search: { sse: "localhost:4000/events" }, + }), + ).toEqual({ + ok: true, + endpoint: "http://localhost:4000/events", + runId: undefined, + }); + }); + + it("rejects endpoints that do not use HTTP", () => { + expect( + getBrunchEndpoint({ + baseUrl, + search: { sse: "file:///tmp/events" }, + }), + ).toEqual({ + ok: false, + error: 'Brunch endpoint must use http(s), received "file:".', + }); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts index 3fea08294b0..2868c5e85b8 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts @@ -1,8 +1,10 @@ +import type { BrunchRouteSearch } from "./brunch-search"; + type BrunchEndpointResult = | { ok: true; endpoint: string; runId?: string } | { ok: false; error: string }; -const normalizeEndpoint = (value: string): string => { +const normalizeEndpoint = (value: string, baseUrl: string): string => { const trimmed = value.trim(); if (trimmed.length === 0) { @@ -13,7 +15,7 @@ const normalizeEndpoint = (value: string): string => { ? new URL(trimmed) : /^(localhost|127\.0\.0\.1|\[::1\])(?::|\/)/u.test(trimmed) ? new URL(`http://${trimmed}`) - : new URL(trimmed, window.location.href); + : new URL(trimmed, baseUrl); // EventSource throws synchronously on non-HTTP(S) URLs; reject them here so // the route renders the friendly status page instead. @@ -26,18 +28,19 @@ const normalizeEndpoint = (value: string): string => { return url.toString(); }; -export const getBrunchEndpointFromLocation = ( - location: Location, -): BrunchEndpointResult => { - const params = new URLSearchParams(location.search); - const rawEndpoint = params.get("sse") ?? undefined; - +export const getBrunchEndpoint = ({ + baseUrl, + search, +}: { + baseUrl: string; + search: BrunchRouteSearch; +}): BrunchEndpointResult => { try { - if (rawEndpoint !== undefined) { + if (search.sse !== undefined) { return { ok: true, - endpoint: normalizeEndpoint(rawEndpoint), - runId: params.get("runId") ?? undefined, + endpoint: normalizeEndpoint(search.sse, baseUrl), + runId: search.runId, }; } } catch (err) { diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-route.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-route.ts deleted file mode 100644 index 2bb6f47e54f..00000000000 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-route.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * This is temporary, until Petrinaut Demo app gets a real Router. - * Adding a real Router will require to consider every parts of the app, so this is just a quick and dirty solution. - */ -export const isBrunchDemoRoute = (): boolean => { - const path = window.location.pathname.replace(/\/+$/u, "") || "/"; - - return path === "/brunch"; -}; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts new file mode 100644 index 00000000000..895b655626e --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { brunchSearchSchema } from "./brunch-search"; + +describe("brunchSearchSchema", () => { + it("keeps string parameters", () => { + expect( + brunchSearchSchema.parse({ + runId: "run-1", + sse: "https://brunch.example/events", + }), + ).toEqual({ + runId: "run-1", + sse: "https://brunch.example/events", + }); + }); + + it("drops values the search parser pre-decoded away from strings", () => { + // `?runId=1e3` reaches the schema as the number 1000, not the original + // text, so coercing it back to a string would keep a corrupted id. + expect( + brunchSearchSchema.parse({ + runId: 1000, + sse: true, + }), + ).toEqual({ + runId: undefined, + sse: undefined, + }); + }); + + it("falls back for malformed structured parameters", () => { + expect( + brunchSearchSchema.parse({ + runId: ["run-1"], + sse: { href: "https://brunch.example/events" }, + }), + ).toEqual({ + runId: undefined, + sse: undefined, + }); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts new file mode 100644 index 00000000000..3bf22999e8e --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** + * The router's search parser JSON-decodes values before validation, so a + * numeric-looking parameter arrives pre-mangled (`?runId=1e3` becomes 1000, + * ids above 2^53 lose precision). Accepting only strings drops such values to + * `undefined` instead of coercing them into plausible-looking altered ids. + */ +const optionalSearchStringSchema = z.string().optional().catch(undefined); + +export const brunchSearchSchema = z.object({ + runId: optionalSearchStringSchema, + sse: optionalSearchStringSchema, +}); + +export type BrunchRouteSearch = z.infer; diff --git a/apps/petrinaut-website/src/main/app/optimization-demo/optimization-route.ts b/apps/petrinaut-website/src/main/app/optimization-demo/optimization-route.ts deleted file mode 100644 index 33aeb163341..00000000000 --- a/apps/petrinaut-website/src/main/app/optimization-demo/optimization-route.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const isOptimizationDemoRoute = (): boolean => { - const path = window.location.pathname.replace(/\/+$/u, "") || "/"; - - return path === "/optimization"; -}; diff --git a/apps/petrinaut-website/src/routeTree.gen.ts b/apps/petrinaut-website/src/routeTree.gen.ts new file mode 100644 index 00000000000..3a201760696 --- /dev/null +++ b/apps/petrinaut-website/src/routeTree.gen.ts @@ -0,0 +1,95 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as BrunchRouteImport } from "./routes/brunch"; +import { Route as OptimizationRouteImport } from "./routes/optimization"; + +const IndexRoute = IndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any); +const BrunchRoute = BrunchRouteImport.update({ + id: "/brunch", + path: "/brunch", + getParentRoute: () => rootRouteImport, +} as any); +const OptimizationRoute = OptimizationRouteImport.update({ + id: "/optimization", + path: "/optimization", + getParentRoute: () => rootRouteImport, +} as any); + +export interface FileRoutesByFullPath { + "/": typeof IndexRoute; + "/brunch": typeof BrunchRoute; + "/optimization": typeof OptimizationRoute; +} +export interface FileRoutesByTo { + "/": typeof IndexRoute; + "/brunch": typeof BrunchRoute; + "/optimization": typeof OptimizationRoute; +} +export interface FileRoutesById { + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/brunch": typeof BrunchRoute; + "/optimization": typeof OptimizationRoute; +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: "/" | "/brunch" | "/optimization"; + fileRoutesByTo: FileRoutesByTo; + to: "/" | "/brunch" | "/optimization"; + id: "__root__" | "/" | "/brunch" | "/optimization"; + fileRoutesById: FileRoutesById; +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute; + BrunchRoute: typeof BrunchRoute; + OptimizationRoute: typeof OptimizationRoute; +} + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/brunch": { + id: "/brunch"; + path: "/brunch"; + fullPath: "/brunch"; + preLoaderRoute: typeof BrunchRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/optimization": { + id: "/optimization"; + path: "/optimization"; + fullPath: "/optimization"; + preLoaderRoute: typeof OptimizationRouteImport; + parentRoute: typeof rootRouteImport; + }; + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + BrunchRoute: BrunchRoute, + OptimizationRoute: OptimizationRoute, +}; +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); diff --git a/apps/petrinaut-website/src/router.ts b/apps/petrinaut-website/src/router.ts new file mode 100644 index 00000000000..c8e17534465 --- /dev/null +++ b/apps/petrinaut-website/src/router.ts @@ -0,0 +1,14 @@ +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; + +export const router = createRouter({ + routeTree, + trailingSlash: "never", +}); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/apps/petrinaut-website/src/routes/-not-found-page.tsx b/apps/petrinaut-website/src/routes/-not-found-page.tsx new file mode 100644 index 00000000000..f2e98cf7d61 --- /dev/null +++ b/apps/petrinaut-website/src/routes/-not-found-page.tsx @@ -0,0 +1,55 @@ +import { Link } from "@tanstack/react-router"; + +import type { CSSProperties } from "react"; + +const pageStyle: CSSProperties = { + alignItems: "center", + background: "#f6f7f8", + color: "#1f2933", + display: "flex", + fontFamily: "Inter, system-ui, sans-serif", + height: "100vh", + justifyContent: "center", + padding: 24, + width: "100vw", +}; + +const panelStyle: CSSProperties = { + background: "#ffffff", + border: "1px solid #d7dce1", + borderRadius: 8, + boxShadow: "0 8px 24px rgba(31, 41, 51, 0.08)", + maxWidth: 560, + padding: 24, +}; + +const headingStyle: CSSProperties = { + fontSize: 20, + lineHeight: "28px", + margin: "0 0 8px", +}; + +const bodyStyle: CSSProperties = { + color: "#4b5563", + fontSize: 14, + lineHeight: "20px", + margin: "0 0 16px", +}; + +const linkStyle: CSSProperties = { + color: "#2563eb", + fontSize: 14, + fontWeight: 600, +}; + +export const NotFoundPage = () => ( +
+
+

Page not found

+

The requested Petrinaut page does not exist.

+ + Back to Petrinaut + +
+
+); diff --git a/apps/petrinaut-website/src/routes/__root.tsx b/apps/petrinaut-website/src/routes/__root.tsx new file mode 100644 index 00000000000..2441d4b38b9 --- /dev/null +++ b/apps/petrinaut-website/src/routes/__root.tsx @@ -0,0 +1,8 @@ +import { Outlet, createRootRoute } from "@tanstack/react-router"; + +import { NotFoundPage } from "./-not-found-page"; + +export const Route = createRootRoute({ + component: Outlet, + notFoundComponent: NotFoundPage, +}); diff --git a/apps/petrinaut-website/src/routes/brunch.tsx b/apps/petrinaut-website/src/routes/brunch.tsx new file mode 100644 index 00000000000..dce6ebd46ff --- /dev/null +++ b/apps/petrinaut-website/src/routes/brunch.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, useSearch } from "@tanstack/react-router"; + +import { BrunchDemoApp } from "../main/app/brunch-demo/brunch-demo-app"; +import { brunchSearchSchema } from "../main/app/brunch-demo/brunch-search"; + +function BrunchRoute() { + return ; +} + +export const Route = createFileRoute("/brunch")({ + component: BrunchRoute, + validateSearch: brunchSearchSchema, +}); diff --git a/apps/petrinaut-website/src/routes/index.tsx b/apps/petrinaut-website/src/routes/index.tsx new file mode 100644 index 00000000000..9345bddcc2b --- /dev/null +++ b/apps/petrinaut-website/src/routes/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { LocalStorageDemoApp } from "../main/app/local-storage-demo/local-storage-demo-app"; + +export const Route = createFileRoute("/")({ + component: LocalStorageDemoApp, +}); diff --git a/apps/petrinaut-website/src/routes/optimization.tsx b/apps/petrinaut-website/src/routes/optimization.tsx new file mode 100644 index 00000000000..3b2de36aecb --- /dev/null +++ b/apps/petrinaut-website/src/routes/optimization.tsx @@ -0,0 +1,12 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; + +import { OptimizationDemoApp } from "../main/app/optimization-demo/optimization-demo-app"; + +export const Route = createFileRoute("/optimization")({ + beforeLoad: () => { + if (import.meta.env.VITE_PETRINAUT_OPT_PROVIDER !== "service") { + throw notFound(); + } + }, + component: OptimizationDemoApp, +}); diff --git a/apps/petrinaut-website/vercel.json b/apps/petrinaut-website/vercel.json index 9931af7a709..dad16e6f036 100644 --- a/apps/petrinaut-website/vercel.json +++ b/apps/petrinaut-website/vercel.json @@ -9,11 +9,7 @@ "outputDirectory": "./dist", "rewrites": [ { - "source": "/brunch", - "destination": "/" - }, - { - "source": "/optimization", + "source": "/((?!api(?:/|$)).*)", "destination": "/" } ], diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index 971ebffcd3d..3bee93448d0 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from "node:url"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { createServerAdapter } from "@whatwg-node/server"; import { defineConfig, loadEnv, type Plugin } from "vite"; @@ -83,6 +84,12 @@ export default defineConfig(({ mode }) => { plugins: [ petrinautApiDevPlugin(), + tanstackRouter({ + autoCodeSplitting: true, + quoteStyle: "double", + semicolons: true, + target: "react", + }), react({ // @hashintel/ds-components ships prebuilt jsx() calls; the compiler // can't recognize ref forwarding in that form and bails with diff --git a/oxfmt.config.ts b/oxfmt.config.ts index 499253ac6a0..81f50d11a7f 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -67,6 +67,7 @@ export default defineConfig({ "**/*.toml", // Autogenerated files "**/*.snap.*", + "apps/petrinaut-website/src/routeTree.gen.ts", "**/openapi.json", "**/*.aux.mir", ], diff --git a/yarn.lock b/yarn.lock index 4d048d43027..a66a9ff50aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -927,6 +927,8 @@ __metadata: "@mantine/hooks": "npm:8.3.5" "@pandacss/dev": "npm:1.11.1" "@sentry/react": "npm:10.64.0" + "@tanstack/react-router": "npm:1.170.31" + "@tanstack/router-plugin": "npm:1.168.34" "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" @@ -2436,7 +2438,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.16.0, @babel/core@npm:^7.18.5, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.26.0, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4, @babel/core@npm:^7.28.6, @babel/core@npm:^7.29.0": +"@babel/core@npm:^7.16.0, @babel/core@npm:^7.18.5, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.9, @babel/core@npm:^7.23.7, @babel/core@npm:^7.24.4, @babel/core@npm:^7.26.0, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4, @babel/core@npm:^7.28.5, @babel/core@npm:^7.28.6, @babel/core@npm:^7.29.0": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -2486,6 +2488,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.28.5, @babel/generator@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/generator@npm:7.29.8" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 + languageName: node + linkType: hard + "@babel/generator@npm:^8.0.0": version: 8.0.0 resolution: "@babel/generator@npm:8.0.0" @@ -2810,6 +2825,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.23.6, @babel/parser@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 + languageName: node + linkType: hard + "@babel/parser@npm:^8.0.0, @babel/parser@npm:^8.0.4": version: 8.0.4 resolution: "@babel/parser@npm:8.0.4" @@ -4030,7 +4056,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.18.10, @babel/template@npm:^7.20.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.25.9, @babel/template@npm:^7.27.1, @babel/template@npm:^7.28.6, @babel/template@npm:^7.29.7": +"@babel/template@npm:^7.18.10, @babel/template@npm:^7.20.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.25.9, @babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2, @babel/template@npm:^7.28.6, @babel/template@npm:^7.29.7": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" dependencies: @@ -4067,6 +4093,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.23.7": + version: 7.29.8 + resolution: "@babel/traverse@npm:7.29.8" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.8" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.8" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.8" + debug: "npm:^4.3.1" + checksum: 10c0/87a28989c434add26d787776ac6d30f749b89cb030f2a605c89f671a516a6fa165ac0476f07e2b69feed70128b2734cae1cbbf41dfe95ccf22081bd8f8b91923 + languageName: node + linkType: hard + "@babel/traverse@npm:^8.0.0": version: 8.0.4 resolution: "@babel/traverse@npm:8.0.4" @@ -4092,6 +4133,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.23.6, @babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + "@babel/types@npm:^8.0.0, @babel/types@npm:^8.0.4": version: 8.0.4 resolution: "@babel/types@npm:8.0.4" @@ -17329,6 +17380,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/history@npm:1.162.1": + version: 1.162.1 + resolution: "@tanstack/history@npm:1.162.1" + checksum: 10c0/14a82aafa92d391e0b1d7ebf849e1f717b6d054279183da15abd060bfcc10c21793d4eec5e7cfc076fd3e6ed3f0723e853007b4799ba4fa6a3f4e308580e87c3 + languageName: node + linkType: hard + "@tanstack/pacer-lite@npm:^0.1.1": version: 0.1.1 resolution: "@tanstack/pacer-lite@npm:0.1.1" @@ -17351,7 +17409,22 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-store@npm:^0.9.1": +"@tanstack/react-router@npm:1.170.31": + version: 1.170.31 + resolution: "@tanstack/react-router@npm:1.170.31" + dependencies: + "@tanstack/history": "npm:1.162.1" + "@tanstack/react-store": "npm:^0.9.3" + "@tanstack/router-core": "npm:1.171.26" + isbot: "npm:^5.1.22" + peerDependencies: + react: ">=18.0.0 || >=19.0.0" + react-dom: ">=18.0.0 || >=19.0.0" + checksum: 10c0/65c0060538618b17cd363de7852cbd1036122e5dd53dfa0f247973f7cf223e09f2f459882dd8177edbc4c7ab4d6adaf097681368ffee281fe4cbf9964094fd55 + languageName: node + linkType: hard + +"@tanstack/react-store@npm:^0.9.1, @tanstack/react-store@npm:^0.9.3": version: 0.9.3 resolution: "@tanstack/react-store@npm:0.9.3" dependencies: @@ -17388,6 +17461,84 @@ __metadata: languageName: node linkType: hard +"@tanstack/router-core@npm:1.171.26": + version: 1.171.26 + resolution: "@tanstack/router-core@npm:1.171.26" + dependencies: + "@tanstack/history": "npm:1.162.1" + cookie-es: "npm:^3.0.0" + seroval: "npm:^1.6.2" + seroval-plugins: "npm:^1.6.2" + checksum: 10c0/4e5974cf265c373e53d5f9e547c423cbbb2709e8fd6e542182db1756819d47169f8befe40327b6c3041204e6e7ede37d5812f46a11e3af1b997629cba63cfdad + languageName: node + linkType: hard + +"@tanstack/router-generator@npm:1.167.32": + version: 1.167.32 + resolution: "@tanstack/router-generator@npm:1.167.32" + dependencies: + "@babel/types": "npm:^7.28.5" + "@tanstack/router-core": "npm:1.171.26" + "@tanstack/router-utils": "npm:1.162.2" + "@tanstack/virtual-file-routes": "npm:1.162.0" + jiti: "npm:^2.7.0" + magic-string: "npm:^0.30.21" + prettier: "npm:^3.5.0" + zod: "npm:^4.4.3" + checksum: 10c0/44174238fe260e89181a669c3fe658a81eaaa526867e1a8c293249dda2ee0605ed996dd455912de924a0d93358c266befa2726a23772712bc4003296cbb8e530 + languageName: node + linkType: hard + +"@tanstack/router-plugin@npm:1.168.34": + version: 1.168.34 + resolution: "@tanstack/router-plugin@npm:1.168.34" + dependencies: + "@babel/core": "npm:^7.28.5" + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.28.5" + "@tanstack/router-core": "npm:1.171.26" + "@tanstack/router-generator": "npm:1.167.32" + "@tanstack/router-utils": "npm:1.162.2" + chokidar: "npm:^5.0.0" + unplugin: "npm:^3.0.0" + zod: "npm:^4.4.3" + peerDependencies: + "@rsbuild/core": ">=1.0.2 || ^2.0.0" + "@tanstack/react-router": ^1.170.31 + vite: ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0" + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: ">=5.92.0" + peerDependenciesMeta: + "@rsbuild/core": + optional: true + "@tanstack/react-router": + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + checksum: 10c0/f37990d53b66936046e8d454adbd3c8238ad44c9fa4673a4c1a8a6df741c57d03df377171e2cb2d466786cc5a8931b102d0d1c23e3dbc771a3c94bda9be731d4 + languageName: node + linkType: hard + +"@tanstack/router-utils@npm:1.162.2": + version: 1.162.2 + resolution: "@tanstack/router-utils@npm:1.162.2" + dependencies: + "@babel/generator": "npm:^7.28.5" + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + ansis: "npm:^4.1.0" + babel-dead-code-elimination: "npm:^1.0.12" + diff: "npm:^8.0.2" + pathe: "npm:^2.0.3" + tinyglobby: "npm:^0.2.15" + checksum: 10c0/b54f9ad957cf11a348e70b4fd3db3e36af8ede1d8be71f4d993316e5550194816a297f9101e6a057dab9fc694384a2d8b64b736e859e135dbbf9c87ae3560674 + languageName: node + linkType: hard + "@tanstack/store@npm:0.9.3, @tanstack/store@npm:^0.9.1": version: 0.9.3 resolution: "@tanstack/store@npm:0.9.3" @@ -17409,6 +17560,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/virtual-file-routes@npm:1.162.0": + version: 1.162.0 + resolution: "@tanstack/virtual-file-routes@npm:1.162.0" + checksum: 10c0/4c7f36e792b71935553e9e7dfa536e072417f5794074a20f79feef63d9745590b15e248162182a070de9033fa4193c18b98cc4c5d5c04e3df9db15a4ba2aae2e + languageName: node + linkType: hard + "@temporalio/activity@npm:1.20.2": version: 1.20.2 resolution: "@temporalio/activity@npm:1.20.2" @@ -22426,6 +22584,13 @@ __metadata: languageName: node linkType: hard +"ansis@npm:^4.1.0": + version: 4.3.1 + resolution: "ansis@npm:4.3.1" + checksum: 10c0/d1a48090f9c33b18f254a3496e5336a20391a51140b552a1c0bc38710ae7c8bc36a62658f28759797d7bce15e89b893e9ef1962ea1ea42a291d112263f6e593c + languageName: node + linkType: hard + "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -22995,6 +23160,18 @@ __metadata: languageName: node linkType: hard +"babel-dead-code-elimination@npm:^1.0.12": + version: 1.0.12 + resolution: "babel-dead-code-elimination@npm:1.0.12" + dependencies: + "@babel/core": "npm:^7.23.7" + "@babel/parser": "npm:^7.23.6" + "@babel/traverse": "npm:^7.23.7" + "@babel/types": "npm:^7.23.6" + checksum: 10c0/9289b66ce202a5f2b8c160f6a0ed16b97c42a9e06e92ea997df4e34d6a8309a01cf641b21467ee6a2713efd7e158b4b770e04a07f3f9d30eb05d428d186f7a60 + languageName: node + linkType: hard + "babel-loader@npm:10.0.0": version: 10.0.0 resolution: "babel-loader@npm:10.0.0" @@ -24711,6 +24888,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^3.0.0": + version: 3.1.1 + resolution: "cookie-es@npm:3.1.1" + checksum: 10c0/62cf0c325cc547b52477b351e9b5d068b3ffc74f7d143cdf7af854648dd1015fca3aed1498b355365e24b0746333f0b15688ed3a535abecc5ed0a046ae956a84 + languageName: node + linkType: hard + "cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -25794,7 +25978,7 @@ __metadata: languageName: node linkType: hard -"diff@npm:8.0.4, diff@npm:^8.0.3, diff@npm:~8.0.2": +"diff@npm:8.0.4, diff@npm:^8.0.2, diff@npm:^8.0.3, diff@npm:~8.0.2": version: 8.0.4 resolution: "diff@npm:8.0.4" checksum: 10c0/7ee5d03926db4039be7252ac3b0abaae1bd122a2ca971e5ca7270e444e36ff83dd906fad1a719740ca347e97ed5dc8f458a76a8391dbcd7aff363bdafb348a00 @@ -32086,6 +32270,13 @@ __metadata: languageName: node linkType: hard +"isbot@npm:^5.1.22": + version: 5.2.1 + resolution: "isbot@npm:5.2.1" + checksum: 10c0/83bf2852897694f28e46d742dc9a9e722fb650a7412d9288f4eab17247a706d242e98b5794caf93804d6566df54fbe4be9e38cc2604dba04a5018d69a6abe252 + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -32401,6 +32592,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.7.0": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 + languageName: node + linkType: hard + "jju@npm:~1.4.0": version: 1.4.0 resolution: "jju@npm:1.4.0" @@ -38536,7 +38736,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.2.5, prettier@npm:^3.8.1": +"prettier@npm:^3.2.5, prettier@npm:^3.5.0, prettier@npm:^3.8.1": version: 3.9.6 resolution: "prettier@npm:3.9.6" bin: @@ -41598,6 +41798,22 @@ __metadata: languageName: node linkType: hard +"seroval-plugins@npm:^1.6.2": + version: 1.6.3 + resolution: "seroval-plugins@npm:1.6.3" + peerDependencies: + seroval: ^1.0 + checksum: 10c0/687fb6677e87c4ddb4ada4eebea3bc28bdeb6763b34069265f302e20b607a9129fa4fcd5195285245f8dd5327c376caa7f6f0c514d6b04966761411a21f48d4e + languageName: node + linkType: hard + +"seroval@npm:^1.6.2": + version: 1.6.3 + resolution: "seroval@npm:1.6.3" + checksum: 10c0/4de15cba21784d3dd6fa292528374177efe7b82f7ef5b98d1d25b4255123de01bbc717ee75e85ec495c7e00ab233feb7066b6de2b46ba20d6cf4b2ddf20c3b0c + languageName: node + linkType: hard + "serve-index@npm:^1.9.1": version: 1.9.1 resolution: "serve-index@npm:1.9.1" @@ -44814,6 +45030,46 @@ __metadata: languageName: node linkType: hard +"unplugin@npm:^3.0.0": + version: 3.3.0 + resolution: "unplugin@npm:3.3.0" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + picomatch: "npm:^4.0.4" + webpack-virtual-modules: "npm:^0.6.2" + peerDependencies: + "@farmfe/core": "*" + "@rspack/core": "*" + bun-types-no-globals: "*" + esbuild: "*" + rolldown: "*" + rollup: "*" + unloader: "*" + vite: "*" + webpack: "*" + peerDependenciesMeta: + "@farmfe/core": + optional: true + "@rspack/core": + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + checksum: 10c0/86ee7c7fde40ffa8260ddeba5da51800e35c6cdd41ee8a556ec3827357b5d2ee13dcbe4ea6820451bc6c2b05b35165f96c7bf20662114af5ed52183a74886404 + languageName: node + linkType: hard + "unrs-resolver@npm:^1.7.11": version: 1.11.1 resolution: "unrs-resolver@npm:1.11.1" @@ -47150,7 +47406,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5, zod@npm:^4.2.0, zod@npm:^4.3.6": +"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5, zod@npm:^4.2.0, zod@npm:^4.3.6, zod@npm:^4.4.3": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 From 74fd21ef50445a67323d0b38c44691fb81a49fc3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 27 Aug 2026 03:44:07 +0200 Subject: [PATCH 2/5] FE-1500: make website route codegen explicit --- .gitignore | 3 +- apps/petrinaut-website/package.json | 2 + .../router-codegen-config.ts | 9 ++ .../scripts/generate-route-tree.ts | 10 ++ apps/petrinaut-website/src/routeTree.gen.ts | 95 ------------------- apps/petrinaut-website/tsconfig.json | 2 +- apps/petrinaut-website/turbo.json | 13 ++- apps/petrinaut-website/vite.config.ts | 9 +- yarn.lock | 1 + 9 files changed, 37 insertions(+), 107 deletions(-) create mode 100644 apps/petrinaut-website/router-codegen-config.ts create mode 100644 apps/petrinaut-website/scripts/generate-route-tree.ts delete mode 100644 apps/petrinaut-website/src/routeTree.gen.ts diff --git a/.gitignore b/.gitignore index b98b125b28d..4a63514f851 100644 --- a/.gitignore +++ b/.gitignore @@ -132,7 +132,8 @@ libs/@local/graph/store/typescript/src/generated # generated files *.gen.* -!apps/petrinaut-website/src/routeTree.gen.ts +# TanStack Router plugin scratch output. +.tanstack/ *.tsbuildinfo feed.atom feed.rss diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 6bda1b30497..679a30a3c6d 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -6,6 +6,7 @@ "scripts": { "brunch:fixture": "node --experimental-strip-types scripts/brunch-sse-fixture.ts", "build": "vite build", + "codegen": "node --experimental-strip-types scripts/generate-route-tree.ts", "dev": "vite", "dev:optimization": "node scripts/optimization-dev.mjs", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", @@ -35,6 +36,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@tanstack/router-generator": "1.167.32", "@tanstack/router-plugin": "1.168.34", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", diff --git a/apps/petrinaut-website/router-codegen-config.ts b/apps/petrinaut-website/router-codegen-config.ts new file mode 100644 index 00000000000..79a150bd1b3 --- /dev/null +++ b/apps/petrinaut-website/router-codegen-config.ts @@ -0,0 +1,9 @@ +import type { Config } from "@tanstack/router-generator"; + +/** Shared by Vite and the standalone CI route-tree generator. */ +export const routerCodegenConfig = { + autoCodeSplitting: true, + quoteStyle: "double", + semicolons: true, + target: "react", +} satisfies Partial; diff --git a/apps/petrinaut-website/scripts/generate-route-tree.ts b/apps/petrinaut-website/scripts/generate-route-tree.ts new file mode 100644 index 00000000000..ec11938bb3c --- /dev/null +++ b/apps/petrinaut-website/scripts/generate-route-tree.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; + +import { Generator, getConfig } from "@tanstack/router-generator"; + +import { routerCodegenConfig } from "../router-codegen-config.ts"; + +const appRoot = fileURLToPath(new URL("..", import.meta.url)); +const config = getConfig(routerCodegenConfig, appRoot); + +await new Generator({ config, root: appRoot }).run(); diff --git a/apps/petrinaut-website/src/routeTree.gen.ts b/apps/petrinaut-website/src/routeTree.gen.ts deleted file mode 100644 index 3a201760696..00000000000 --- a/apps/petrinaut-website/src/routeTree.gen.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - -// This file was automatically generated by TanStack Router. -// You should NOT make any changes in this file as it will be overwritten. -// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. - -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as IndexRouteImport } from "./routes/index"; -import { Route as BrunchRouteImport } from "./routes/brunch"; -import { Route as OptimizationRouteImport } from "./routes/optimization"; - -const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", - getParentRoute: () => rootRouteImport, -} as any); -const BrunchRoute = BrunchRouteImport.update({ - id: "/brunch", - path: "/brunch", - getParentRoute: () => rootRouteImport, -} as any); -const OptimizationRoute = OptimizationRouteImport.update({ - id: "/optimization", - path: "/optimization", - getParentRoute: () => rootRouteImport, -} as any); - -export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/brunch": typeof BrunchRoute; - "/optimization": typeof OptimizationRoute; -} -export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/brunch": typeof BrunchRoute; - "/optimization": typeof OptimizationRoute; -} -export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; - "/brunch": typeof BrunchRoute; - "/optimization": typeof OptimizationRoute; -} -export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/" | "/brunch" | "/optimization"; - fileRoutesByTo: FileRoutesByTo; - to: "/" | "/brunch" | "/optimization"; - id: "__root__" | "/" | "/brunch" | "/optimization"; - fileRoutesById: FileRoutesById; -} -export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - BrunchRoute: typeof BrunchRoute; - OptimizationRoute: typeof OptimizationRoute; -} - -declare module "@tanstack/react-router" { - interface FileRoutesByPath { - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/brunch": { - id: "/brunch"; - path: "/brunch"; - fullPath: "/brunch"; - preLoaderRoute: typeof BrunchRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/optimization": { - id: "/optimization"; - path: "/optimization"; - fullPath: "/optimization"; - preLoaderRoute: typeof OptimizationRouteImport; - parentRoute: typeof rootRouteImport; - }; - } -} - -const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, - BrunchRoute: BrunchRoute, - OptimizationRoute: OptimizationRoute, -}; -export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes(); diff --git a/apps/petrinaut-website/tsconfig.json b/apps/petrinaut-website/tsconfig.json index 32ce2125854..60cf1627251 100644 --- a/apps/petrinaut-website/tsconfig.json +++ b/apps/petrinaut-website/tsconfig.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "isolatedModules": true }, - "include": ["api", "src"] + "include": ["api", "src", "router-codegen-config.ts"] } diff --git a/apps/petrinaut-website/turbo.json b/apps/petrinaut-website/turbo.json index c6c060e9134..b7287618b3a 100644 --- a/apps/petrinaut-website/turbo.json +++ b/apps/petrinaut-website/turbo.json @@ -2,12 +2,17 @@ "extends": ["//"], "tasks": { "build": { - "dependsOn": ["^build"], + "dependsOn": ["codegen", "^build"], "outputs": ["dist/**"], - "cache": false + // `vite.config.ts` inlines these into the bundle through `define`, so a + // cached `dist` from one environment must never be restored for another. + // `loadEnv` reads them from `.env*` as well as the environment, and the + // gitignored `.env.local` is outside Turborepo's default inputs. + "env": ["SENTRY_DSN", "VITE_PETRINAUT_OPT_PROVIDER", "VITE_VERCEL_ENV"], + "inputs": ["$TURBO_DEFAULT$", ".env*"] }, - "lint:tsc": { - "dependsOn": ["^build"] + "codegen": { + "outputs": ["src/routeTree.gen.ts"] } } } diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index 3bee93448d0..b8d88b68f26 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -5,6 +5,8 @@ import react from "@vitejs/plugin-react"; import { createServerAdapter } from "@whatwg-node/server"; import { defineConfig, loadEnv, type Plugin } from "vite"; +import { routerCodegenConfig } from "./router-codegen-config.ts"; + import type { IncomingMessage, ServerResponse } from "node:http"; const appRoot = fileURLToPath(new URL(".", import.meta.url)); @@ -84,12 +86,7 @@ export default defineConfig(({ mode }) => { plugins: [ petrinautApiDevPlugin(), - tanstackRouter({ - autoCodeSplitting: true, - quoteStyle: "double", - semicolons: true, - target: "react", - }), + tanstackRouter(routerCodegenConfig), react({ // @hashintel/ds-components ships prebuilt jsx() calls; the compiler // can't recognize ref forwarding in that form and bails with diff --git a/yarn.lock b/yarn.lock index a66a9ff50aa..895dafc93bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -928,6 +928,7 @@ __metadata: "@pandacss/dev": "npm:1.11.1" "@sentry/react": "npm:10.64.0" "@tanstack/react-router": "npm:1.170.31" + "@tanstack/router-generator": "npm:1.167.32" "@tanstack/router-plugin": "npm:1.168.34" "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" From 706bd0278518b398108e27ebefa35ace61184bfd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:06:43 +0200 Subject: [PATCH 3/5] FE-1500: deduplicate frontend dependencies --- yarn.lock | 68 ++++--------------------------------------------------- 1 file changed, 5 insertions(+), 63 deletions(-) diff --git a/yarn.lock b/yarn.lock index 895dafc93bb..895707ea30c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2476,20 +2476,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.18.13, @babel/generator@npm:^7.22.9, @babel/generator@npm:^7.26.3, @babel/generator@npm:^7.29.6, @babel/generator@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/generator@npm:7.29.7" - dependencies: - "@babel/parser": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7 - languageName: node - linkType: hard - -"@babel/generator@npm:^7.28.5, @babel/generator@npm:^7.29.8": +"@babel/generator@npm:^7.18.13, @babel/generator@npm:^7.22.9, @babel/generator@npm:^7.26.3, @babel/generator@npm:^7.28.5, @babel/generator@npm:^7.29.6, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.29.8": version: 7.29.8 resolution: "@babel/generator@npm:7.29.8" dependencies: @@ -2815,18 +2802,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.6, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.3, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.29.3, @babel/parser@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/parser@npm:7.29.7" - dependencies: - "@babel/types": "npm:^7.29.7" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/65133038f80b54a714d6027cb77cee3f9a6b5c4c6842ce674301e13947cbcbfa8055e63acaf1b84c085d34226a14425b2c2b97b829e0e226d2e8f1299942a51d - languageName: node - linkType: hard - -"@babel/parser@npm:^7.23.6, @babel/parser@npm:^7.29.8": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.6, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.6, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.3, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.29.3, @babel/parser@npm:^7.29.7, @babel/parser@npm:^7.29.8": version: 7.29.8 resolution: "@babel/parser@npm:7.29.8" dependencies: @@ -4079,22 +4055,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.22.8, @babel/traverse@npm:^7.26.10, @babel/traverse@npm:^7.26.4, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4, @babel/traverse@npm:^7.28.5, @babel/traverse@npm:^7.29.0, @babel/traverse@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/traverse@npm:7.29.7" - dependencies: - "@babel/code-frame": "npm:^7.29.7" - "@babel/generator": "npm:^7.29.7" - "@babel/helper-globals": "npm:^7.29.7" - "@babel/parser": "npm:^7.29.7" - "@babel/template": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - debug: "npm:^4.3.1" - checksum: 10c0/e256a1fbdb956555b76f3c285b1e453f6bedec8b3afb61751d99d933efd11c7d79caf5ddf2493570058a9f7deaa1b48324380d7c1aa1443fd9508becbf56331a - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.23.7": +"@babel/traverse@npm:^7.22.8, @babel/traverse@npm:^7.23.7, @babel/traverse@npm:^7.26.10, @babel/traverse@npm:^7.26.4, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4, @babel/traverse@npm:^7.28.5, @babel/traverse@npm:^7.29.0, @babel/traverse@npm:^7.29.7": version: 7.29.8 resolution: "@babel/traverse@npm:7.29.8" dependencies: @@ -4124,17 +4085,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.5, @babel/types@npm:^7.25.6, @babel/types@npm:^7.26.10, @babel/types@npm:^7.26.3, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.29.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.4.4": - version: 7.29.7 - resolution: "@babel/types@npm:7.29.7" - dependencies: - "@babel/helper-string-parser": "npm:^7.29.7" - "@babel/helper-validator-identifier": "npm:^7.29.7" - checksum: 10c0/b6623994c69717fa27294f5fa46d59140338e2d86c6c1c13085c84ef7d53086ee357fbf4fe9abe3dd3da75734dc77c4c0df2f90fb29e667558bb3b3fb705e88f - languageName: node - linkType: hard - -"@babel/types@npm:^7.23.6, @babel/types@npm:^7.29.8": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.6, @babel/types@npm:^7.25.6, @babel/types@npm:^7.26.10, @babel/types@npm:^7.26.3, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.29.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8, @babel/types@npm:^7.4.4": version: 7.29.8 resolution: "@babel/types@npm:7.29.8" dependencies: @@ -32584,16 +32535,7 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^2.0.0, jiti@npm:^2.3.0": - version: 2.6.1 - resolution: "jiti@npm:2.6.1" - bin: - jiti: lib/jiti-cli.mjs - checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b - languageName: node - linkType: hard - -"jiti@npm:^2.7.0": +"jiti@npm:^2.0.0, jiti@npm:^2.3.0, jiti@npm:^2.7.0": version: 2.7.0 resolution: "jiti@npm:2.7.0" bin: From b73e8f5f0912a3b2b8b7a4ee6b55f1d3a52e7076 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:23:01 +0200 Subject: [PATCH 4/5] FE-1500: add controlled Petrinaut navigation --- .changeset/petrinaut-controlled-navigation.md | 10 + .../petrinaut-core-selection-vocabulary.md | 9 + libs/@hashintel/petrinaut-core/package.json | 4 + .../petrinaut-core/src/selection.ts | 13 + .../src/types/selection.test.ts | 28 + .../petrinaut-core/src/types/selection.ts | 54 +- libs/@hashintel/petrinaut-core/vite.config.ts | 3 + .../petrinaut/docs/drawing-a-net.md | 18 +- 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 | 91 ++- .../src/react/experiments/provider.tsx | 45 +- .../hooks/use-petrinaut-commands.test.tsx | 4 +- .../hooks/use-petrinaut-mutations.test.tsx | 4 +- libs/@hashintel/petrinaut/src/react/index.ts | 22 + .../src/react/navigation/index.test.tsx | 552 ++++++++++++++++++ .../petrinaut/src/react/navigation/index.tsx | 400 +++++++++++++ .../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 | 105 +++- .../src/react/state/active-net-provider.tsx | 40 +- .../src/react/state/editor-context.ts | 20 +- .../src/react/state/editor-provider.test.tsx | 329 +++++++++++ .../src/react/state/editor-provider.tsx | 391 ++++++++++--- .../src/react/state/use-selection-cleanup.ts | 19 +- libs/@hashintel/petrinaut/src/ui/index.ts | 12 + .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 5 + .../src/ui/views/Editor/editor-view.tsx | 22 +- .../BottomPanel/subviews/diagnostics.tsx | 18 +- .../panels/LeftSideBar/subviews/nets-list.tsx | 3 - .../experiments-story-fixtures.tsx | 4 +- .../Editor/panels/ai-assistant-panel.test.tsx | 4 +- .../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 +- 39 files changed, 2421 insertions(+), 260 deletions(-) create mode 100644 .changeset/petrinaut-controlled-navigation.md create mode 100644 .changeset/petrinaut-core-selection-vocabulary.md create mode 100644 libs/@hashintel/petrinaut-core/src/selection.ts create mode 100644 libs/@hashintel/petrinaut-core/src/types/selection.test.ts 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 create mode 100644 libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx diff --git a/.changeset/petrinaut-controlled-navigation.md b/.changeset/petrinaut-controlled-navigation.md new file mode 100644 index 00000000000..7ea48713a82 --- /dev/null +++ b/.changeset/petrinaut-controlled-navigation.md @@ -0,0 +1,10 @@ +--- +"@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. A creation drawer now layers over the record +already open instead of closing it, and the hamburger menu hides **Layout** on +a read-only net. diff --git a/.changeset/petrinaut-core-selection-vocabulary.md b/.changeset/petrinaut-core-selection-vocabulary.md new file mode 100644 index 00000000000..3981cdd0c29 --- /dev/null +++ b/.changeset/petrinaut-core-selection-vocabulary.md @@ -0,0 +1,9 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Expose the selection vocabulary as data: `selectionItemTypes` and +`canonicalizeSelection` are available from a dependency-free +`@hashintel/petrinaut-core/selection` entry, so hosts can validate and order +selection coming from a URL or an HTTP request without pulling the model or any +React code. diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json index 7d4d52b88de..9d67e457319 100644 --- a/libs/@hashintel/petrinaut-core/package.json +++ b/libs/@hashintel/petrinaut-core/package.json @@ -50,6 +50,10 @@ "types": "./dist/optimization.d.ts", "import": "./dist/optimization.js" }, + "./selection": { + "types": "./dist/selection.d.ts", + "import": "./dist/selection.js" + }, "./workers/lsp": { "types": "./dist/workers/lsp.d.ts", "import": "./dist/workers/lsp.js" diff --git a/libs/@hashintel/petrinaut-core/src/selection.ts b/libs/@hashintel/petrinaut-core/src/selection.ts new file mode 100644 index 00000000000..a1ec6bc159e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/selection.ts @@ -0,0 +1,13 @@ +/** + * Dependency-free entry exposing only the selection vocabulary. Hosts that + * validate selection outside the app — URL search params in a browser route, an + * HTTP request in a server function — can import this without pulling the model, + * the simulation engine, or any React code. + */ +export { canonicalizeSelection, selectionItemTypes } from "./types/selection"; +export type { + PanelTarget, + SelectionItem, + SelectionItemType, + SelectionMap, +} from "./types/selection"; diff --git a/libs/@hashintel/petrinaut-core/src/types/selection.test.ts b/libs/@hashintel/petrinaut-core/src/types/selection.test.ts new file mode 100644 index 00000000000..1d304e224f7 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/types/selection.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalizeSelection, selectionItemTypes } from "./selection"; + +describe("canonicalizeSelection", () => { + it("deduplicates and orders by type then id", () => { + expect( + canonicalizeSelection([ + { type: "transition", id: "transition-b" }, + { type: "place", id: "place-b" }, + { type: "place", id: "place-a" }, + { type: "transition", id: "transition-b" }, + ]), + ).toEqual([ + { type: "place", id: "place-a" }, + { type: "place", id: "place-b" }, + { type: "transition", id: "transition-b" }, + ]); + }); + + it("orders every selection item type deterministically", () => { + const oneOfEach = selectionItemTypes.map((type) => ({ type, id: "x" })); + + expect(canonicalizeSelection([...oneOfEach].reverse())).toEqual( + canonicalizeSelection(oneOfEach), + ); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/types/selection.ts b/libs/@hashintel/petrinaut-core/src/types/selection.ts index a6526b90994..f3af287b389 100644 --- a/libs/@hashintel/petrinaut-core/src/types/selection.ts +++ b/libs/@hashintel/petrinaut-core/src/types/selection.ts @@ -10,14 +10,23 @@ import { ARC_ID_PREFIX, ARC_ID_SEPARATOR } from "../arc-id"; * `/react/state/editor-context`. */ -export type SelectionItemType = - | "place" - | "transition" - | "arc" - | "componentInstance" - | "type" - | "differentialEquation" - | "parameter"; +/** + * The selection vocabulary, as data. Hosts that validate selection coming from + * outside the app (URL search params, an HTTP request) need the list at + * runtime, and deriving the type from it keeps the two exhaustive by + * construction. + */ +export const selectionItemTypes = [ + "place", + "transition", + "arc", + "componentInstance", + "type", + "differentialEquation", + "parameter", +] as const; + +export type SelectionItemType = (typeof selectionItemTypes)[number]; export type SelectionItem = | { type: "place"; id: string } @@ -36,6 +45,35 @@ export type PanelTarget = | { kind: "single"; item: SelectionItem } | { kind: "multi"; items: SelectionItem[] }; +const selectionItemKey = (item: SelectionItem) => `${item.type}\0${item.id}`; + +const compareCodeUnits = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; + +/** + * Deduplicates and orders a selection, so equivalent selections compare and + * serialize identically. Consumers rely on this being the one ordering: the + * editor compares selections positionally, and hosts encode them into URLs. + * + * Ordering is by UTF-16 code unit rather than `localeCompare`, so a host that + * canonicalizes on a server agrees with the browser. Collation is + * locale-dependent and weights punctuation below letters, which would order + * `place-b` and `placea` differently across runtimes. + */ +export const canonicalizeSelection = ( + 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) => + compareCodeUnits(left.type, right.type) || + compareCodeUnits(left.id, right.id), + ); +}; + export function parseArcId( arcId: string, ): { sourceId: string; targetId: string } | null { diff --git a/libs/@hashintel/petrinaut-core/vite.config.ts b/libs/@hashintel/petrinaut-core/vite.config.ts index 0f4d64b6da9..619e171809b 100644 --- a/libs/@hashintel/petrinaut-core/vite.config.ts +++ b/libs/@hashintel/petrinaut-core/vite.config.ts @@ -23,6 +23,9 @@ export default defineConfig(({ command }) => ({ // Dependency-free instantiation of compiled HIR artifacts. "hir-runtime": resolve(packageRoot, "src/hir-runtime.ts"), optimization: resolve(packageRoot, "src/optimization.ts"), + // Dependency-free entry: the selection vocabulary alone, for hosts that + // validate selection in a route or a server function. + selection: resolve(packageRoot, "src/selection.ts"), "examples/index": resolve(packageRoot, "src/examples/index.ts"), "workers/lsp": resolve(packageRoot, "src/workers/lsp.ts"), "workers/monte-carlo": resolve( diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index c67e2416e07..a615e35e937 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -20,7 +20,7 @@ Spans the full editor width and has three sections. **Left** - **Sidebar toggle** -- collapses or expands the left sidebar. -- **Menu** (hamburger icon) -- file operations: **Export** (YAML or JSON, each with or without visual info, or TikZ), **Layout** (apply auto-layout), and **Docs**. A standalone embed of Petrinaut may additionally show **New**, **Open**, **Import**, and **Load example**. +- **Menu** (hamburger icon) -- file operations: **Export** (YAML or JSON, each with or without visual info, or TikZ), **Layout** (apply auto-layout), and **Docs**. **Layout** is not offered on a read-only net, because it moves nodes. A standalone embed of Petrinaut may additionally show **New**, **Open**, **Import**, and **Load example**. - **Net title** -- editable inline title for the current net. Whether the title field is shown depends on the host application; the demo site shows it, but a Petrinaut embedded in another product may hide it. **Center** @@ -145,6 +145,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 hosts with app navigation enabled, 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. @@ -187,4 +201,4 @@ From the top-bar menu (hamburger icon), under **Export**: ## Auto-layout -From the hamburger menu, select **Layout** to apply an automatic graph layout (ELK) that rearranges all nodes. Useful after importing a net without positions or when a net has become cluttered. This will not always be an improvement! +From the hamburger menu, select **Layout** to apply an automatic graph layout (ELK) that rearranges all nodes. Useful after importing a net without positions or when a net has become cluttered. This will not always be an improvement! The item is hidden on a read-only net, which cannot accept the move. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index c074cb7baa5..3db73e8c8c8 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -61,6 +61,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 653102d60c8..90a7e158d4c 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, @@ -187,16 +192,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 - > - } - // One shard, so a single fake worker stands in for the whole - // experiment. Sharding itself is covered in petrinaut-core. - experimentShardCount={1} - > - - + + + + worker as WorkerLike< + MonteCarloToWorkerMessage, + MonteCarloToMainMessage + > + } + // One shard, so a single fake worker stands in for the whole + // experiment. Sharding itself is covered in petrinaut-core. + experimentShardCount={1} + > + + + @@ -229,12 +250,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; }; @@ -245,16 +271,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 62bc4ce47ef..41440954a45 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 { @@ -180,6 +184,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); @@ -191,9 +196,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(() => { @@ -212,6 +230,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, @@ -510,9 +540,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..33636eda5d2 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: () => {}, @@ -76,7 +79,6 @@ const editorContextValue = ( toggleAiAssistant: () => {}, searchInputRef: { current: null }, triggerPanelAnimation: () => {}, - __reinitialize: () => {}, }); type WrapperOptions = { 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..dc4e2a8ea12 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: () => {}, @@ -79,7 +82,6 @@ const editorContextValue = ( toggleAiAssistant: () => {}, searchInputRef: { current: null }, triggerPanelAnimation: () => {}, - __reinitialize: () => {}, }); type WrapperOptions = { diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index c134c7521a2..870a7b495e2 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -18,6 +18,28 @@ export type { ActualModeContextValue } from "./actual-mode-context"; // --- Provider unification --- export { PetrinautProvider } from "./petrinaut-provider"; export type { PetrinautProviderProps } from "./petrinaut-provider"; +export { + defaultPetrinautNavigationHistoryPolicy, + defaultPetrinautNavigationState, + openPetrinautSimulationResource, + openPetrinautSubnet, + PetrinautNavigationProvider, + petrinautNavigationStatesMatch, + 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..7fb12d5f7c2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx @@ -0,0 +1,552 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import { + 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("works uncontrolled: updates internal state without a controller", () => { + const Probe = () => { + const { controlled, navigate, state } = usePetrinautNavigation(); + return ( + <> + + {`${String(controlled)} ${state.mode} ${state.subnetId ?? "root"}`} + + + + ); + }; + + render( + + + , + ); + + // The initial location merges initialState over the defaults. + expect(screen.getByLabelText("Location").textContent).toBe( + "false edit subnet-b", + ); + + fireEvent.click( + screen.getByRole("button", { name: "Update uncontrolled" }), + ); + expect(screen.getByLabelText("Location").textContent).toBe( + "false simulate subnet-a", + ); + }); + + test("canonicalizes uncontrolled selection updates and suppresses duplicates", () => { + const navigateResults: boolean[] = []; + const Probe = () => { + const { navigate, state } = usePetrinautNavigation(); + return ( + <> + + {state.selection.map((item) => `${item.type}:${item.id}`).join(",")} + + + + ); + }; + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Select items" })); + expect(screen.getByLabelText("Selection").textContent).toBe( + "place:place-a,transition:transition-b", + ); + + // The same selection again is a semantic no-op. + fireEvent.click(screen.getByRole("button", { name: "Select items" })); + expect(navigateResults).toEqual([true, false]); + }); + + test("controlled: a declined navigation does not swallow a later retry", async () => { + // A host that declines (a navigation guard, an aborted transition) changes + // nothing, so it never rerenders. The optimistic preview must not outlive + // the event that produced it, or the user's retry is swallowed. + let acceptNavigation = false; + let hostState = defaultPetrinautNavigationState; + const onNavigate = vi.fn( + (update) => { + if (acceptNavigation) { + hostState = update(hostState); + } + }, + ); + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + + ); + }; + render( + + + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Retry without rerender" }), + ); + expect(onNavigate).toHaveBeenCalledOnce(); + + // A retry is a separate user event, so the preview is no longer fresh. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + acceptNavigation = true; + fireEvent.click( + screen.getByRole("button", { name: "Retry without rerender" }), + ); + expect(onNavigate).toHaveBeenCalledTimes(2); + expect(hostState.subnetId).toBe("subnet-a"); + }); + + test("controlled: a rerender before an async host applies does not lose the next update", () => { + // A host backed by a router applies on a later tick. Any unrelated + // rerender in that window must not roll the optimistic base back to the + // state the host has already been asked to leave. + const hostState = defaultPetrinautNavigationState; + const applied: PetrinautNavigationState[] = []; + const onNavigate = vi.fn( + (update) => { + applied.push(update(applied.at(-1) ?? hostState)); + }, + ); + const Probe = () => { + const { navigate } = usePetrinautNavigation(); + return ( + <> + + + + ); + }; + const tree = () => ( + + + + ); + const view = render(tree()); + + fireEvent.click(screen.getByRole("button", { name: "Select A" })); + expect(onNavigate).toHaveBeenCalledOnce(); + + // The router has not landed yet, so `state` is still the pre-selection + // value when this commit happens. + view.rerender(tree()); + + fireEvent.click(screen.getByRole("button", { name: "Clear selection" })); + expect(onNavigate).toHaveBeenCalledTimes(2); + expect(applied.at(-1)?.selection).toEqual([]); + }); + + 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", () => { + const openExperiment = { type: "experiment", id: "experiment-a" } as const; + const withOpenExperiment = { + ...defaultPetrinautNavigationState, + simulateResource: openExperiment, + }; + const empty = defaultPetrinautNavigationState; + + expect( + simulateDrawerToNavigationResource( + { type: "view-scenario", scenarioId: "scenario-a" }, + empty, + ), + ).toEqual({ type: "scenario", id: "scenario-a" }); + + for (const type of [ + "create-scenario", + "create-metric", + "create-experiment", + "create-optimization", + ] as const) { + const drawer = { type }; + // A create drawer layers over the open record rather than replacing it. + expect( + simulateDrawerToNavigationResource(drawer, withOpenExperiment), + ).toEqual(openExperiment); + expect(simulateDrawerToNavigationResource(drawer, empty)).toBeNull(); + expect(simulateDrawerToNavigationOverlay(drawer, null)).toEqual(drawer); + expect(navigationResourceToSimulateDrawer(null, drawer)).toEqual(drawer); + } + // Closing a create overlay reveals the record it was layered over, while + // closing the record's own drawer clears it. + expect( + simulateDrawerToNavigationResource( + { type: "closed" }, + { + ...defaultPetrinautNavigationState, + simulateResource: openExperiment, + overlay: { type: "create-experiment" }, + }, + ), + ).toEqual(openExperiment); + expect( + simulateDrawerToNavigationResource( + { type: "closed" }, + { + ...defaultPetrinautNavigationState, + simulateResource: openExperiment, + overlay: null, + }, + ), + ).toBeNull(); + 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..8749aab4961 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx @@ -0,0 +1,400 @@ +/** + * @layerRoot react.navigation + * @role Keeps Petrinaut's app location router-neutral and controlled by the host + */ + +import { + createContext, + use, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; + +import { canonicalizeSelection } from "@hashintel/petrinaut-core/selection"; + +import { ActualModeContext } from "../actual-mode-context"; + +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. + * + * A host may apply the update on a later tick, and may decline it outright. + * It should ignore an update that resolves to the state it already holds, + * so that a decline and a repeated request stay distinguishable. + */ + 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 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: canonicalizeSelection(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: canonicalizeSelection(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. + * + * The preview is kept with the `base` it was derived from, and dropped only + * once the host's state has moved off that base. A host backed by a router + * applies on a later tick, and any unrelated rerender in that window would + * otherwise roll the base back to the state the host was already asked to + * leave, so the next update would compare equal and never be sent. + * + * `fresh` marks the preview as belonging to the event that produced it. + * Suppressing a repeat is only right within that event: a host that declines + * an update (a navigation guard, an aborted transition) changes nothing and + * so never rerenders, and the user's retry must still reach it. + */ + const optimisticRef = useRef<{ + base: PetrinautNavigationState; + preview: PetrinautNavigationState; + fresh: boolean; + } | null>(null); + const freshnessTimerRef = useRef | undefined>( + undefined, + ); + useLayoutEffect(() => { + const optimistic = optimisticRef.current; + if (optimistic && !petrinautNavigationStatesMatch(optimistic.base, state)) { + optimisticRef.current = null; + } + }); + useEffect(() => () => clearTimeout(freshnessTimerRef.current), []); + + const navigate: PetrinautNavigationContextValue["navigate"] = ( + update, + intent, + ) => { + const updater: PetrinautNavigationUpdater = ( + current, + ) => resolveNavigationUpdate(current, update); + const optimistic = optimisticRef.current; + const current = optimistic?.preview ?? state; + const preview = updater(current); + if ( + (optimistic === null || optimistic.fresh) && + petrinautNavigationStatesMatch(current, preview) + ) { + return false; + } + + optimisticRef.current = { + base: optimistic?.base ?? state, + preview, + fresh: true, + }; + clearTimeout(freshnessTimerRef.current); + freshnessTimerRef.current = setTimeout(() => { + const pending = optimisticRef.current; + if (pending) { + optimisticRef.current = { ...pending, fresh: false }; + } + }, 0); + + if (controller) { + controller.onNavigate(updater, { + history: + controller.historyPolicy?.(intent) ?? + defaultPetrinautNavigationHistoryPolicy(intent), + intent, + }); + } else { + 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 openPetrinautSubnet = + ( + subnetId: string | null, + ): PetrinautNavigationUpdater => + (current) => ({ ...current, subnetId, selection: [] }); + +export const simulateDrawerToNavigationResource = ( + drawer: SimulateDrawerState, + current: Readonly, +): 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 }; + // A create drawer opens above whatever record is already open, the way + // `simulateDrawerToNavigationOverlay` keeps the overlay behind it. + case "create-scenario": + case "create-metric": + case "create-experiment": + case "create-optimization": + return current.simulateResource; + // `closed` means whichever drawer is on top. Closing a create overlay + // reveals the record it was layered over; closing that record's own + // drawer clears it. + case "closed": + return current.overlay?.type.startsWith("create-") + ? current.simulateResource + : 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..d8e2d90f31a 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,23 +276,46 @@ export const SimulationProvider: React.FC = ({ setErrorItemId(null); }; - const setSelectedScenarioId: SimulationContextValue["setSelectedScenarioId"] = - (scenarioId) => { - if (stateValuesRef.current.selectedScenarioId !== scenarioId) { - invalidateSimulationForConfigurationChange(); - } + const previousEffectiveScenarioIdRef = useRef(effectiveSelectedScenarioId); + useEffect(() => { + if ( + previousEffectiveScenarioIdRef.current === effectiveSelectedScenarioId + ) { + return; + } + previousEffectiveScenarioIdRef.current = effectiveSelectedScenarioId; - setStateValues((prev) => { - const scenario = petriNetDefinition.scenarios?.find( - (s) => s.id === scenarioId, - ); + 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, + ]); - return { - ...prev, - selectedScenarioId: scenarioId, - scenarioParameterValues: getScenarioParameterDefaults(scenario), - }; - }); + // Only navigates. The effective-scenario effect above owns the simulation + // disposal and parameter-default reset, so a user-initiated switch and a + // host- or history-initiated one take the same path — and a controlled host + // that declines the navigation keeps its running simulation untouched. + const setSelectedScenarioId: SimulationContextValue["setSelectedScenarioId"] = + (scenarioId) => { + navigation.navigate( + { scenarioId }, + { cause: "user", action: "scenario" }, + ); }; const setScenarioParameterValue: SimulationContextValue["setScenarioParameterValue"] = @@ -292,15 +326,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 +611,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 4d1ecf0dad7..2f5369b86ef 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; @@ -158,7 +171,6 @@ export type EditorActions = { setAiAssistantOpen: (isOpen: boolean) => void; toggleAiAssistant: () => void; triggerPanelAnimation: () => void; - __reinitialize: () => void; }; export type EditorContextValue = EditorState & @@ -195,6 +207,7 @@ export const initialEditorState: EditorState = { const DEFAULT_CONTEXT_VALUE: EditorContextValue = { ...initialEditorState, + navigateTo: () => {}, setGlobalMode: () => {}, setEditionMode: () => {}, setCursorMode: () => {}, @@ -211,6 +224,8 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { isNotSelectedConnection: () => false, selectedConnections: new Map(), setSelection: () => {}, + beginSelectionGesture: () => {}, + endSelectionGesture: () => {}, selectItem: () => {}, toggleItem: () => {}, clearSelection: () => {}, @@ -233,7 +248,6 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { toggleAiAssistant: () => {}, searchInputRef: createRef(), triggerPanelAnimation: () => {}, - __reinitialize: () => {}, }; export const EditorContext = createContext( diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx new file mode 100644 index 00000000000..9ae98454fb8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.test.tsx @@ -0,0 +1,329 @@ +/** + * @vitest-environment jsdom + */ +import { act, render } from "@testing-library/react"; +import { use, useState } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_PETRINAUT_EXTENSIONS, + type SDCPN, + type SelectionMap, +} from "@hashintel/petrinaut-core"; + +import { + defaultPetrinautNavigationState, + PetrinautNavigationProvider, + type PetrinautNavigationController, + type PetrinautNavigationState, +} from "../navigation"; +import { EditorContext, type EditorContextValue } from "./editor-context"; +import { EditorProvider } from "./editor-provider"; +import { SDCPNContext, type SDCPNContextValue } from "./sdcpn-context"; + +const emptySdcpn: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + subnets: [], + componentInstances: [], + scenarios: [], +}; + +const makeSdcpnContextValue = ( + getItemType: SDCPNContextValue["getItemType"], +): SDCPNContextValue => ({ + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: emptySdcpn, + readonly: false, + extensions: DEFAULT_PETRINAUT_EXTENSIONS, + setTitle: () => {}, + title: "Test", + getItemType, +}); + +type RecordedNavigation = { + history: "push" | "replace"; + intent: { cause: string; action: string; phase?: string }; +}; + +const EditorContextGrabber = ({ + onContextValue, +}: { + onContextValue: (value: EditorContextValue) => void; +}) => { + onContextValue(use(EditorContext)); + return null; +}; + +/** + * A minimal controlled host: applies every navigation to React state (as a + * real router integration would) and records the history/intent options. + */ +const TestHost = ({ + children, + initialState, + recorded, +}: { + children: React.ReactNode; + initialState?: Partial; + recorded: RecordedNavigation[]; +}) => { + const [state, setState] = useState({ + ...defaultPetrinautNavigationState, + ...initialState, + }); + const controller: PetrinautNavigationController = { + state, + onNavigate: (update, options) => { + recorded.push(options as RecordedNavigation); + setState(update); + }, + }; + return ( + + {children} + + ); +}; + +const selectionOf = (...ids: string[]): SelectionMap => + new Map(ids.map((id) => [id, { type: "place" as const, id }])); + +describe("EditorProvider selection gestures", () => { + let editor: EditorContextValue; + let recorded: RecordedNavigation[]; + + beforeEach(() => { + recorded = []; + render( + "place")}> + + + { + editor = value; + }} + /> + + + , + ); + }); + + const flushMicrotasks = () => act(async () => {}); + + it("coalesces react-flow batched updates and records one entry per gesture", async () => { + act(() => { + editor.beginSelectionGesture(); + // Two callbacks from the same react-flow event burst. + editor.setSelection(selectionOf("place-a"), { batch: "react-flow" }); + editor.setSelection(selectionOf("place-a", "place-b"), { + batch: "react-flow", + }); + }); + await flushMicrotasks(); + + // One navigation for the burst; the gesture's first commit pushes. + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + history: "push", + intent: { cause: "user", action: "selection", phase: "start" }, + }); + + // Later commits of the same gesture continue via replace. + act(() => { + editor.setSelection(selectionOf("place-c"), { batch: "react-flow" }); + }); + await flushMicrotasks(); + expect(recorded).toHaveLength(2); + expect(recorded[1]).toMatchObject({ + history: "replace", + intent: { phase: "continue" }, + }); + + // A discrete selection after the gesture ends pushes again. + act(() => { + editor.endSelectionGesture(); + editor.setSelection(selectionOf("place-d")); + }); + expect(recorded).toHaveLength(3); + expect(recorded[2]).toMatchObject({ + history: "push", + intent: { phase: "discrete" }, + }); + }); + + it("flushes the gesture's final batched change as a continuation", async () => { + act(() => { + editor.beginSelectionGesture(); + editor.setSelection(selectionOf("place-a"), { batch: "react-flow" }); + }); + await flushMicrotasks(); + expect(recorded).toHaveLength(1); + + // React Flow delivers the final selection change and the gesture end in + // the same pointerup event — before the batch's microtask would run. The + // gesture end must flush it as a continuation, not a discrete push. + act(() => { + editor.setSelection(selectionOf("place-a", "place-b"), { + batch: "react-flow", + }); + editor.endSelectionGesture(); + }); + await flushMicrotasks(); + expect(recorded).toHaveLength(2); + expect(recorded[1]).toMatchObject({ + history: "replace", + intent: { phase: "continue" }, + }); + }); + + it("resets an interrupted gesture on window pointerup", async () => { + act(() => { + editor.beginSelectionGesture(); + editor.setSelection(selectionOf("place-a"), { batch: "react-flow" }); + }); + await flushMicrotasks(); + expect(recorded[0]?.intent.phase).toBe("start"); + + // The pointer was released outside react-flow's handlers, so the gesture + // must not keep marking later selections as continuations. + act(() => { + window.dispatchEvent(new Event("pointerup")); + }); + act(() => { + editor.setSelection(selectionOf("place-b"), { batch: "react-flow" }); + }); + await flushMicrotasks(); + expect(recorded[1]?.intent.phase).toBe("discrete"); + }); +}); + +describe("EditorProvider deep-link normalization", () => { + it("strips unknown resources and stale selection items once, via replace", async () => { + const recorded: RecordedNavigation[] = []; + let editor: EditorContextValue; + // "place-a" still exists; "place-gone" was deleted from the net. + const getItemType: SDCPNContextValue["getItemType"] = (id) => + id === "place-a" ? "place" : null; + + const view = render( + + + + { + editor = value; + }} + /> + + + , + ); + await act(async () => {}); + + // One self-healing navigation: the unknown scenario resource and the + // deleted selection item are dropped, the valid item survives. + const normalizations = recorded.filter( + (entry) => entry.intent.cause === "normalization", + ); + expect(normalizations).toHaveLength(1); + expect(normalizations[0]?.history).toBe("replace"); + expect(editor!.simulateDrawer).toEqual({ type: "closed" }); + expect(Array.from(editor!.selection.keys())).toEqual(["place-a"]); + + // The normalized state is stable: re-rendering does not re-fire it. + view.rerender( + + + + { + editor = value; + }} + /> + + + , + ); + await act(async () => {}); + expect( + recorded.filter((entry) => entry.intent.cause === "normalization"), + ).toHaveLength(1); + }); +}); + +describe("EditorProvider creation drawers", () => { + let editor: EditorContextValue; + + const openExperimentWithCreateOverlay = () => { + const recorded: RecordedNavigation[] = []; + render( + "place")}> + + + { + editor = value; + }} + /> + + + , + ); + act(() => { + editor.setSimulateDrawer({ type: "create-experiment" }); + }); + }; + + it("reveals the record underneath when the create drawer closes", () => { + openExperimentWithCreateOverlay(); + // The create drawer layers over the open experiment. + expect(editor!.simulateDrawer).toEqual({ type: "create-experiment" }); + + act(() => { + editor!.setSimulateDrawer({ type: "closed" }); + }); + + expect(editor!.simulateDrawer).toEqual({ + type: "view-experiment", + experimentId: "experiment-a", + }); + }); + + it("drops the record when the Simulate section changes", () => { + openExperimentWithCreateOverlay(); + + act(() => { + editor!.setSimulateViewMode("scenarios"); + }); + + // The record belongs to the section being left, so it cannot reappear on + // switching back. + expect(editor!.simulateViewMode).toBe("scenarios"); + expect(editor!.simulateDrawer).toEqual({ type: "closed" }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index 48403dc4a84..a9b5e170c60 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"); - + // Navigation-owned fields (mode, Simulate view, drawer, selection) are NOT + // seeded here: `effectiveState` below derives them from navigation state on + // every render, so local copies would only be a stale second source of truth. const [state, setState] = useState(() => ({ ...initialEditorState, - globalMode: startsInActualMode ? "actual" : initialEditorState.globalMode, cursorMode: userSettings.cursorMode, isLeftSidebarOpen: userSettings.isLeftSidebarOpen, leftSidebarWidth: userSettings.leftSidebarWidth, @@ -57,9 +74,66 @@ export const EditorProvider: React.FC = ({ children }) => { timelineChartType: userSettings.timelineChartType, })); + 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) { + // The checks above read the committed state, but the update is applied + // to the host's freshest state, which an asynchronous host may already + // have moved on from. Re-filter inside the updater so normalization + // never writes back a selection the user has since replaced. + navigation.navigate( + (current) => ({ + ...current, + ...(invalidResource ? { simulateResource: null } : {}), + ...(hasInvalidSelection + ? { + selection: current.selection.filter( + (item) => getItemType(item.id) === item.type, + ), + } + : {}), + }), + { + 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); + const flushPendingSelectionNavigationRef = useRef<(() => void) | null>(null); /** * Returns state patch to enable panel animation. Must be spread into the @@ -89,23 +163,212 @@ 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; + const flush = () => { + selectionNavigationScheduledRef.current = false; + flushPendingSelectionNavigationRef.current = null; + 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", + }); }; - }); + flushPendingSelectionNavigationRef.current = flush; + queueMicrotask(flush); + } + }; + + const beginSelectionGesture = () => { + selectionGestureRef.current = { active: true, hasNavigated: false }; + }; + + // React Flow delivers a gesture's final selection change in the same event + // as its end callback, and the change flushes in a microtask. Flush it while + // the gesture still counts as active, so the gesture's last commit is a + // continuation rather than a separate discrete history entry. + const endSelectionGesture = () => { + flushPendingSelectionNavigationRef.current?.(); + selectionGestureRef.current = { active: false, hasNavigated: false }; + }; + + useEffect(() => { + selectionNavigationMountedRef.current = true; + const finishInterruptedGesture = () => { + flushPendingSelectionNavigationRef.current?.(); + 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, + // Switching section leaves the record behind: it belongs to the + // section being left. A `closed` drawer here means "reset the + // drawers for the new section", not "dismiss the drawer on top", + // so it does not go through the overlay-aware mapping. + simulateResource: + target.simulateDrawer && target.simulateDrawer.type !== "closed" + ? simulateDrawerToNavigationResource( + target.simulateDrawer, + current, + ) + : null, + overlay: target.simulateDrawer + ? simulateDrawerToNavigationOverlay( + target.simulateDrawer, + current.overlay, + ) + : current.overlay, + } + : target.simulateDrawer !== undefined + ? { + simulateResource: simulateDrawerToNavigationResource( + target.simulateDrawer, + current, + ), + 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 +381,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 +431,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 +461,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 +480,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) => { @@ -275,7 +512,6 @@ export const EditorProvider: React.FC = ({ children }) => { scheduleAnimationEnd(); setState((prev) => ({ ...prev, ...animationPatch() })); }, - __reinitialize: () => setState(initialEditorState), }; useSyncEditorToSettings({ @@ -289,7 +525,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 +566,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 c8477216979..9822ec6ef0e 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -46,6 +46,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"; @@ -96,6 +97,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; }; const noop = () => {}; @@ -123,6 +126,7 @@ export const Petrinaut: FunctionComponent = ({ simulationWorkerFactory, monteCarloWorkerFactory, lspWorkerFactory, + navigation, }) => { const portalContainerRef = useRef(null); const instance = useMemo( @@ -148,6 +152,7 @@ export const Petrinaut: FunctionComponent = ({ simulationWorkerFactory={simulationWorkerFactory} monteCarloWorkerFactory={monteCarloWorkerFactory} lspWorkerFactory={lspWorkerFactory} + navigation={navigation} > { const showNetManagementMenuItems = hideNetManagementControls === undefined; + // Auto-layout moves nodes, which a read-only net rejects, so the menu would + // otherwise offer an item that silently does nothing. + const isReadOnly = useIsReadOnly(); // Get data from sdcpn-store const { createNewNet, @@ -343,13 +347,17 @@ export const EditorView = ({ }, ] : []), - { - id: "layout", - text: "Layout", - onClick: () => { - void applyAutoLayout(); - }, - }, + ...(isReadOnly + ? [] + : [ + { + id: "layout", + text: "Layout", + onClick: () => { + void applyAutoLayout(); + }, + }, + ]), ...(showNetManagementMenuItems ? [ { 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..49d884a9f96 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,11 +239,19 @@ const DiagnosticsContent: React.FC = () => { iconName="arrowRight" iconPosition="right" onClick={() => { - setGlobalMode("edit"); + // The erroring item may have been deleted since the run; + // still switch to edit mode so the click always responds. const itemType = getItemType(errorItemId); - if (itemType) { - selectItem({ type: itemType, id: errorItemId }); - } + navigateTo({ + globalMode: "edit", + ...(itemType + ? { + selection: new Map([ + [errorItemId, { type: itemType, id: errorItemId }], + ]), + } + : {}), + }); }} > Edit Item 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 363dc53b037..548515b7a1c 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"; @@ -123,7 +122,6 @@ const NetsListContent: React.FC = () => { petriNetDefinition: { subnets }, } = use(SDCPNContext); const { activeSubnetId, setActiveSubnetId } = use(ActiveNetContext); - const { clearSelection } = use(EditorContext); const { updateSubnet, removeSubnet } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); @@ -141,7 +139,6 @@ const NetsListContent: 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..1d59eb2ccc7 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: () => {}, @@ -269,7 +272,6 @@ export function FakeEditorProvider({ setSimulateViewMode, setSearchOpen: () => {}, triggerPanelAnimation: () => {}, - __reinitialize: () => {}, searchInputRef, }), [simulateViewMode], 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..b9484cf7427 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: () => {}, @@ -87,7 +90,6 @@ const editorContextValue: EditorContextValue = { toggleAiAssistant: () => {}, searchInputRef: { current: null }, triggerPanelAnimation: () => {}, - __reinitialize: () => {}, }; const streamChunks = ( 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 f98c29614e1..4fd50141285 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 { ViewportSettingsDialog } from "./viewport-settings-dialog"; @@ -36,7 +37,14 @@ const blurredBackground = css({ backdropFilter: "[blur(10px)]" }); export const ViewportControls: React.FC<{ viewportActions?: ViewportAction[]; }> = ({ viewportActions }) => { - 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 { 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 5d54091b72f..fe4c92d9ca5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-canvas.tsx @@ -125,7 +125,10 @@ export const SDCPNCanvas: React.FC<{ setEditionMode, componentSubnetId, cursorMode, + beginSelectionGesture, + endSelectionGesture, selectItem, + setSelection, clearSelection, hasCanvasSelection, setHoveredItem, @@ -317,10 +320,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( @@ -478,6 +478,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 fe32d170a6455593fb411cfb75576cd573ce5e6b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:24:01 +0200 Subject: [PATCH 5/5] FE-1500: route creation drawers --- .../src/ui/views/Editor/editor-view.tsx | 15 ++-- .../subviews/simulation-settings.tsx | 17 ++-- .../subviews/simulation-timeline/header.tsx | 16 ++-- .../experiments/create-experiment-drawer.tsx | 5 +- .../experiments-story-fixtures.tsx | 10 ++- .../experiments/experiments-view.tsx | 18 +---- .../SimulateView/metrics/metrics-view.tsx | 19 +---- .../create-optimization-drawer.tsx | 5 +- .../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 +++++++ 13 files changed, 159 insertions(+), 92 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 584790aa79e..33ea981b724 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 { useEffectiveGlobalMode } from "../../../react/state/use-effective-global-mode"; @@ -52,6 +51,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"; @@ -143,18 +143,17 @@ export const EditorView = ({ // Get editor context const { 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< @@ -226,9 +225,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() { @@ -520,6 +521,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 6ad28889594..b7ce0f8780f 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 @@ -16,7 +16,6 @@ import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { Slider } from "../../../../../components/slider"; import { useScrollOverflow } from "../../../../../hooks/use-scroll-overflow"; -import { CreateScenarioDrawer } from "../../SimulateView/scenarios/create-scenario-drawer"; import { ViewScenarioDrawer } from "../../SimulateView/scenarios/view-scenario-drawer"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -306,7 +305,7 @@ const NO_SCENARIO = "__none__"; * Includes a scenario picker, parameters section, and computation settings. */ const SimulationSettingsContent: React.FC = () => { - const { setGlobalMode } = use(EditorContext); + const { navigateTo, setSimulateDrawer } = use(EditorContext); const { extensions, petriNetDefinition: { parameters, scenarios }, @@ -326,7 +325,6 @@ const SimulationSettingsContent: React.FC = () => { } = use(SimulationContext); const selectedScenarioId = contextScenarioId ?? NO_SCENARIO; - const [isCreateScenarioOpen, setIsCreateScenarioOpen] = useState(false); const [isViewScenarioOpen, setIsViewScenarioOpen] = useState(false); const isSimulationActive = @@ -365,10 +363,6 @@ const SimulationSettingsContent: React.FC = () => { return (
- setIsCreateScenarioOpen(false)} - /> setIsViewScenarioOpen(false)} @@ -444,7 +438,7 @@ const SimulationSettingsContent: React.FC = () => { aria-label="Create scenario" tooltip="Create Scenario" iconName="plus" - onClick={() => setIsCreateScenarioOpen(true)} + onClick={() => setSimulateDrawer({ type: "create-scenario" })} />
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx index 3272c52bc49..1d2d61dcc15 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/header.tsx @@ -9,7 +9,6 @@ import { type TimelineView, } from "../../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; -import { CreateMetricDrawer } from "../../../SimulateView/metrics/create-metric-drawer"; import { ViewMetricDrawer } from "../../../SimulateView/metrics/view-metric-drawer"; const CHART_TYPE_OPTIONS = [ @@ -83,7 +82,7 @@ const TimelineChartTypeSelector: React.FC = () => { }; const TimelineViewPicker: React.FC = () => { - const { timelineView, setTimelineView, setGlobalMode, setSimulateViewMode } = + const { navigateTo, setSimulateDrawer, timelineView, setTimelineView } = use(EditorContext); const { extensions, @@ -91,7 +90,6 @@ const TimelineViewPicker: React.FC = () => { } = use(SDCPNContext); const colorsEnabled = extensions.colors; - const [isCreateOpen, setIsCreateOpen] = useState(false); const [isViewOpen, setIsViewOpen] = useState(false); useEffect(() => { @@ -146,7 +144,7 @@ const TimelineViewPicker: React.FC = () => { aria-label="Create metric" tooltip="Create Metric" iconName="plus" - onClick={() => setIsCreateOpen(true)} + onClick={() => setSimulateDrawer({ type: "create-metric" })} /> @@ -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 })} /> - - void; - onCreated?: (optimizationId: string) => void; }) => { const { extensions, petriNetDefinition, title } = use(SDCPNContext); const { requestHirArtifacts } = use(LanguageClientContext); @@ -622,10 +620,9 @@ export const CreateOptimizationDrawer = ({ dt, maxTime, }); - const optimizationId = await createOptimization(input); + await createOptimization(input); resetState(); resetMetricForm(); - onCreated?.(optimizationId); } catch (submitError) { setIsSubmitting(false); setError( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimizations-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimizations-view.tsx index d18a8c0e939..30b3742c96f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimizations-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimizations-view.tsx @@ -1,4 +1,4 @@ -import { use, useState } from "react"; +import { use } from "react"; import { Button, Chip, Icon, LoadingSpinner } from "@hashintel/ds-components"; @@ -6,9 +6,9 @@ import { type OptimizationRecord, OptimizationsContext, } from "../../../../../../react/optimizations/context"; +import { EditorContext } from "../../../../../../react/state/editor-context"; import { Table, type TableColumn } from "../../../../../components/table"; import { SimulateSubviewFrame } from "../simulate-subview-frame"; -import { CreateOptimizationDrawer } from "./create-optimization-drawer"; import { ViewOptimizationDrawer } from "./view-optimization-drawer"; function formatStatus(status: OptimizationRecord["status"]): string { @@ -118,7 +118,7 @@ const optimizationColumns = [ ] satisfies readonly TableColumn[]; 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; + } +};