From a91082a409665fd5d864678bf0d72e3773955233 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 2 Sep 2026 03:58:47 +0200 Subject: [PATCH 1/6] Add URL navigation to every Petrinaut website demo --- .../brunch-demo/brunch-actual-mode-route.tsx | 8 +++++- .../main/app/brunch-demo/brunch-demo-app.tsx | 15 ++++++++++- .../main/app/brunch-demo/brunch-petrinaut.tsx | 11 +++++++- .../app/brunch-demo/brunch-search.test.ts | 24 +++++++++++++---- .../src/main/app/brunch-demo/brunch-search.ts | 21 +++++++++++++-- .../local-storage-demo-app.tsx | 16 +++++++++++- .../optimization-demo-app.tsx | 8 ++++-- apps/petrinaut-website/src/routes/brunch.tsx | 26 ++++++++++++++++--- apps/petrinaut-website/src/routes/index.tsx | 24 +++++++++++++++-- .../src/routes/optimization.tsx | 25 ++++++++++++++++-- .../content/website/router-integration.mdx | 19 +++++++++++--- 11 files changed, 172 insertions(+), 25 deletions(-) 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 e18b4a5f393..4dd0ee6542a 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 @@ -4,12 +4,15 @@ import { BrunchPetrinaut } from "./brunch-petrinaut"; import { BrunchStatusPage } from "./brunch-status-page"; import type { BrunchRouteSearch } from "./brunch-search"; +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; import type { ViewportAction } from "@hashintel/petrinaut/ui"; export const BrunchActualModeRoute = ({ + navigation, search, viewportActions, }: { + navigation: PetrinautNavigationController; search: BrunchRouteSearch; viewportActions: ViewportAction[]; }) => { @@ -33,7 +36,10 @@ export const BrunchActualModeRoute = ({ key={`${endpointResult.endpoint}:${endpointResult.runId ?? ""}`} runId={endpointResult.runId} > - + ); }; 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 e813850a1d8..c798cbc254b 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 @@ -3,16 +3,29 @@ * @role Brunch Actual Mode demo: streams a live net from a Brunch endpoint */ +import { useSharedSearchNavigation } from "../../../examples/use-shared-search-navigation"; import { useSentryFeedbackAction } from "../sentry-feedback-button"; import { BrunchActualModeRoute } from "./brunch-actual-mode-route"; +import type { SharedExampleSearch } from "../../../examples/example-search"; import type { BrunchRouteSearch } from "./brunch-search"; -export const BrunchDemoApp = ({ search }: { search: BrunchRouteSearch }) => { +export const BrunchDemoApp = ({ + onSearchChange, + search, +}: { + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: BrunchRouteSearch; +}) => { const sentryFeedbackAction = useSentryFeedbackAction(); + const navigation = useSharedSearchNavigation(search, onSearchChange); return ( diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx index 57c45fda007..06e61a3a8c5 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx @@ -4,7 +4,10 @@ import { createJsonDocHandle, PETRINAUT_EXTENSION_NAMES, } from "@hashintel/petrinaut-core"; -import { ActualModeContext } from "@hashintel/petrinaut/react"; +import { + ActualModeContext, + type PetrinautNavigationController, +} from "@hashintel/petrinaut/react"; import { Petrinaut, type ViewportAction } from "@hashintel/petrinaut/ui"; import { BrunchStatusPage } from "./brunch-status-page"; @@ -20,11 +23,13 @@ const getSourceKey = (source: ActualModeSource): string => const BrunchPetrinautWithHandle = ({ definition, + navigation, source, title, viewportActions, }: { definition: SDCPN; + navigation: PetrinautNavigationController; source: ActualModeSource; title: string; viewportActions: ViewportAction[]; @@ -46,6 +51,7 @@ const BrunchPetrinautWithHandle = ({ {}} title={title} @@ -56,8 +62,10 @@ const BrunchPetrinautWithHandle = ({ }; export const BrunchPetrinaut = ({ + navigation, viewportActions, }: { + navigation: PetrinautNavigationController; viewportActions: ViewportAction[]; }) => { const actualMode = use(ActualModeContext); @@ -102,6 +110,7 @@ export const BrunchPetrinaut = ({ { +describe("validateBrunchSearch", () => { it("keeps string parameters", () => { expect( - brunchSearchSchema.parse({ + validateBrunchSearch({ runId: "run-1", sse: "https://brunch.example/events", }), @@ -19,7 +19,7 @@ describe("brunchSearchSchema", () => { // `?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({ + validateBrunchSearch({ runId: 1000, sse: true, }), @@ -31,7 +31,7 @@ describe("brunchSearchSchema", () => { it("falls back for malformed structured parameters", () => { expect( - brunchSearchSchema.parse({ + validateBrunchSearch({ runId: ["run-1"], sse: { href: "https://brunch.example/events" }, }), @@ -40,4 +40,18 @@ describe("brunchSearchSchema", () => { sse: undefined, }); }); + + it("carries the shared example location next to the stream keys", () => { + expect( + validateBrunchSearch({ + sse: "https://brunch.example/events", + scenario: "scenario_baseline", + subnet: "subnet_dispatch", + }), + ).toEqual({ + sse: "https://brunch.example/events", + scenario: "scenario_baseline", + subnet: "subnet_dispatch", + }); + }); }); 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 index 3bf22999e8e..645dd83f9f4 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + validateSharedExampleSearch, + type SharedExampleSearch, +} from "../../../examples/example-search"; + /** * The router's search parser JSON-decodes values before validation, so a * numeric-looking parameter arrives pre-mangled (`?runId=1e3` becomes 1000, @@ -8,9 +13,21 @@ import { z } from "zod"; */ const optionalSearchStringSchema = z.string().optional().catch(undefined); -export const brunchSearchSchema = z.object({ +const brunchStreamSearchSchema = z.object({ runId: optionalSearchStringSchema, sse: optionalSearchStringSchema, }); -export type BrunchRouteSearch = z.infer; +export type BrunchRouteSearch = z.infer & + SharedExampleSearch; + +/** + * A Brunch URL names the stream (`sse`, `runId`) and speaks the shared example + * contract for the location inside the net. + */ +export const validateBrunchSearch = ( + input: Record, +): BrunchRouteSearch => ({ + ...brunchStreamSearchSchema.parse(input), + ...validateSharedExampleSearch(input), +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index d558d90c757..cfd17532e7c 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -27,6 +27,7 @@ import { WalkthroughProvider, } from "@hashintel/petrinaut/ui"; +import { useSharedSearchNavigation } from "../../../examples/use-shared-search-navigation"; import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; import { CommandPalette } from "../command-palette"; import { useSentryFeedbackAction } from "../sentry-feedback-button"; @@ -48,6 +49,8 @@ import { } from "./use-local-storage-sdcpns"; import { walkthroughSteps } from "./walkthrough/walkthrough-steps"; +import type { SharedExampleSearch } from "../../../examples/example-search"; + const isEmptySDCPN = (sdcpn: SDCPN) => sdcpn.places.length === 0 && sdcpn.transitions.length === 0 && @@ -183,10 +186,20 @@ const DemoCommands = ({ * Switching files replaces the active handle instead of keeping handles alive * for background nets. */ -export const LocalStorageDemoApp = () => { +export const LocalStorageDemoApp = ({ + onSearchChange, + search, +}: { + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: SharedExampleSearch; +}) => { const sentryFeedbackAction = useSentryFeedbackAction(); const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState(); + const navigation = useSharedSearchNavigation(search, onSearchChange); const { aiMessagesByNetId, setAiMessagesByNetId } = useLocalStorageAiMessages(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); @@ -432,6 +445,7 @@ export const LocalStorageDemoApp = () => { existingNets={existingNets} createNewNet={createNewNet} loadPetriNet={loadPetriNet} + navigation={navigation} readonly={false} setTitle={setTitle} title={currentNet.title} diff --git a/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx b/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx index 8cc9053da7f..78ec54ee071 100644 --- a/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx @@ -6,8 +6,12 @@ import { LocalStorageDemoApp } from "../local-storage-demo/local-storage-demo-app"; import { PetrinautOptOptimizationProvider } from "./petrinaut-opt-optimization-provider"; -export const OptimizationDemoApp = () => ( +import type { ComponentProps } from "react"; + +export const OptimizationDemoApp = ( + props: ComponentProps, +) => ( - + ); diff --git a/apps/petrinaut-website/src/routes/brunch.tsx b/apps/petrinaut-website/src/routes/brunch.tsx index dce6ebd46ff..6deb13307d2 100644 --- a/apps/petrinaut-website/src/routes/brunch.tsx +++ b/apps/petrinaut-website/src/routes/brunch.tsx @@ -1,13 +1,31 @@ -import { createFileRoute, useSearch } from "@tanstack/react-router"; +import { + createFileRoute, + useNavigate, + useSearch, +} from "@tanstack/react-router"; import { BrunchDemoApp } from "../main/app/brunch-demo/brunch-demo-app"; -import { brunchSearchSchema } from "../main/app/brunch-demo/brunch-search"; +import { validateBrunchSearch } from "../main/app/brunch-demo/brunch-search"; function BrunchRoute() { - return ; + const navigate = useNavigate({ from: "/brunch" }); + const search = useSearch({ from: "/brunch" }); + + return ( + { + // The stream keys identify the run; only the shared location moves. + void navigate({ + replace: history === "replace", + search: { runId: search.runId, sse: search.sse, ...nextSearch }, + }); + }} + search={search} + /> + ); } export const Route = createFileRoute("/brunch")({ component: BrunchRoute, - validateSearch: brunchSearchSchema, + validateSearch: validateBrunchSearch, }); diff --git a/apps/petrinaut-website/src/routes/index.tsx b/apps/petrinaut-website/src/routes/index.tsx index 9345bddcc2b..4f969a72595 100644 --- a/apps/petrinaut-website/src/routes/index.tsx +++ b/apps/petrinaut-website/src/routes/index.tsx @@ -1,7 +1,27 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { + createFileRoute, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { validateSharedExampleSearch } from "../examples/example-search"; import { LocalStorageDemoApp } from "../main/app/local-storage-demo/local-storage-demo-app"; +function IndexRoute() { + const navigate = useNavigate({ from: "/" }); + const search = useSearch({ from: "/" }); + + return ( + { + void navigate({ replace: history === "replace", search: nextSearch }); + }} + search={search} + /> + ); +} + export const Route = createFileRoute("/")({ - component: LocalStorageDemoApp, + component: IndexRoute, + validateSearch: validateSharedExampleSearch, }); diff --git a/apps/petrinaut-website/src/routes/optimization.tsx b/apps/petrinaut-website/src/routes/optimization.tsx index 3b2de36aecb..0b0103e178d 100644 --- a/apps/petrinaut-website/src/routes/optimization.tsx +++ b/apps/petrinaut-website/src/routes/optimization.tsx @@ -1,12 +1,33 @@ -import { createFileRoute, notFound } from "@tanstack/react-router"; +import { + createFileRoute, + notFound, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { validateSharedExampleSearch } from "../examples/example-search"; import { OptimizationDemoApp } from "../main/app/optimization-demo/optimization-demo-app"; +function OptimizationRoute() { + const navigate = useNavigate({ from: "/optimization" }); + const search = useSearch({ from: "/optimization" }); + + return ( + { + void navigate({ replace: history === "replace", search: nextSearch }); + }} + search={search} + /> + ); +} + export const Route = createFileRoute("/optimization")({ beforeLoad: () => { if (import.meta.env.VITE_PETRINAUT_OPT_PROVIDER !== "service") { throw notFound(); } }, - component: OptimizationDemoApp, + component: OptimizationRoute, + validateSearch: validateSharedExampleSearch, }); diff --git a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx index 9dff86c5041..a539cab496a 100644 --- a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx @@ -51,8 +51,19 @@ encoder to build embed URLs. Query params outside the contract are not rewritten on load. TanStack Router only re-stringifies the URL when the parsed search changes, so stripping them eagerly would need a document reload, which an embed must not do. The first -navigation drops them, because both routes write a fresh contract-only search -rather than merging into the existing one. +navigation drops them, because the routes write a fresh contract-only search +rather than merging into the existing one. `/brunch` is the exception: its +`sse` and `runId` params name the stream, so it carries them over and replaces +only the contract part. + +## Which routes speak the contract + +Every page that mounts Petrinaut does: the editable demo at `/` and +`/optimization`, the Brunch stream at `/brunch`, and the example pages at +`/examples/$slug` and `/embed/examples/$slug`. The demo and Brunch pages hold +their model outside the URL, so a shared link there names a location inside +whatever net the visitor has open; the example pages name the model in the +path as well. ## What a URL carries, and what it does not @@ -70,5 +81,5 @@ an embed with no scenario has nothing to run. The embed route itself honours ## Hosting Petrinaut without a router The prop is optional. Omit it and Petrinaut keeps its location in internal -state, which is what Storybook and the local-storage demo do. Nothing about the -editor's behaviour depends on a host router being present. +state, which is what Storybook does. Nothing about the editor's behaviour +depends on a host router being present. From d4f2df75e8662313b2e243811505cd04210bd568 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 3 Sep 2026 18:30:57 +0200 Subject: [PATCH 2/6] FE-1500: Open the Brunch demo in Actual mode A controlled navigation controller replaces PetrinautNavigationProvider's initial state, including the Actual-mode default it applies when a live stream is available, so the Brunch route opened in Edit mode and the execution frame read the local simulation instead of the stream. useSharedSearchNavigation takes an initialState option for the fields the URL does not carry, and the Brunch demo seeds mode: actual. The URL still wins for the fields it owns. --- .../use-shared-search-navigation.test.tsx | 62 +++++++++++++++++- .../examples/use-shared-search-navigation.ts | 19 +++++- .../app/brunch-demo/brunch-demo-app.test.tsx | 65 +++++++++++++++++++ .../main/app/brunch-demo/brunch-demo-app.tsx | 8 ++- .../content/website/router-integration.mdx | 11 ++++ 5 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx index a571b5d7caf..ab5581030ee 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx @@ -6,13 +6,18 @@ import { describe, expect, it, vi } from "vitest"; import { useSharedSearchNavigation } from "./use-shared-search-navigation"; import type { SharedExampleSearch } from "./example-search"; -import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; +import type { + PetrinautNavigationController, + PetrinautNavigationState, +} from "@hashintel/petrinaut/react"; const Probe = ({ + initialState, onController, onSearchChange, search, }: { + initialState?: Partial; onController: (controller: PetrinautNavigationController) => void; onSearchChange: ( search: SharedExampleSearch, @@ -20,7 +25,9 @@ const Probe = ({ ) => void; search: SharedExampleSearch; }) => { - onController(useSharedSearchNavigation(search, onSearchChange)); + onController( + useSharedSearchNavigation(search, onSearchChange, { initialState }), + ); return null; }; @@ -99,4 +106,55 @@ describe("useSharedSearchNavigation", () => { expect(controller.state.scenarioId).toBe("scenario-2"); expect(controller.state.mode).toBe("simulate"); }); + + it("seeds the fields the URL does not carry, letting the URL win over the seed", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ subnet: "subnet-from-url" }} + />, + ); + + // A controlled host replaces the provider's initial state, so a page that + // opens in a non-default mode has to seed it here. + expect(controller.state.mode).toBe("actual"); + // `subnet` is URL-owned, so the link the visitor opened wins. + expect(controller.state.subnetId).toBe("subnet-from-url"); + expect(onSearchChange).not.toHaveBeenCalled(); + }); + + it("keeps a seeded field across an external URL change", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ scenario: "scenario-1" }} + />, + ); + + expect(controller.state.scenarioId).toBe("scenario-1"); + expect(controller.state.mode).toBe("actual"); + }); }); diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts index 18d7dce3959..4fbfd47a2eb 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -1,5 +1,7 @@ import { useLayoutEffect, useRef, useState } from "react"; +import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react"; + import { sharedSearchesMatch, type SharedExampleSearch, @@ -38,6 +40,11 @@ const mergeSharedSearch = ( * (global mode, overlays), so the full location lives in page state and only * its shared projection is mirrored to the URL — otherwise every control * driving a non-shared field would silently snap back. + * + * `initialState` seeds the fields the URL does not carry. A controlled host + * replaces `PetrinautNavigationProvider`'s own initial state, including the + * Actual-mode default it applies when a live stream is available, so a page + * that opens in a non-default mode states that mode here. */ export const useSharedSearchNavigation = ( search: SharedExampleSearch, @@ -45,11 +52,19 @@ export const useSharedSearchNavigation = ( search: SharedExampleSearch, history: "push" | "replace", ) => void, - options?: { historyPolicy?: PetrinautNavigationHistoryPolicy }, + options?: { + historyPolicy?: PetrinautNavigationHistoryPolicy; + initialState?: Partial; + }, ): PetrinautNavigationController => { const [navigationState, setNavigationState] = useState(() => - sharedSearchToNavigationState(search), + // The URL wins over the seed for the fields it owns, so a shared link + // still resolves to the location it names. + mergeSharedSearch( + { ...defaultPetrinautNavigationState, ...options?.initialState }, + search, + ), ); // Merge external URL changes (Back/Forward, a normalization redirect) diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx new file mode 100644 index 00000000000..fe2ae065b41 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; + +const controllers: PetrinautNavigationController[] = []; + +vi.mock("../sentry-feedback-button", () => ({ + useSentryFeedbackAction: () => ({ + key: "sentry-feedback", + icon: null, + label: "Feedback", + tooltip: "Feedback", + }), +})); + +vi.mock("./brunch-actual-mode-route", () => ({ + BrunchActualModeRoute: ({ + navigation, + }: { + navigation: PetrinautNavigationController; + }) => { + controllers.push(navigation); + return null; + }, +})); + +const { BrunchDemoApp } = await import("./brunch-demo-app"); + +describe("BrunchDemoApp", () => { + it("opens the controlled location in Actual mode", () => { + // A controlled host replaces the navigation provider's initial state, + // including the Actual-mode default it applies when a stream is + // available. Opening in Edit mode would point the execution frame at the + // local simulation instead of the Brunch stream. + render( + {}} + search={{ sse: "https://brunch.example/events" }} + />, + ); + + expect(controllers.at(-1)!.state.mode).toBe("actual"); + }); + + it("still resolves a shared location from the URL", () => { + render( + {}} + search={{ + sse: "https://brunch.example/events", + scenario: "scenario-1", + subnet: "subnet-1", + }} + />, + ); + + const controller = controllers.at(-1)!; + expect(controller.state.mode).toBe("actual"); + expect(controller.state.scenarioId).toBe("scenario-1"); + expect(controller.state.subnetId).toBe("subnet-1"); + }); +}); 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 c798cbc254b..821d91208d1 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 @@ -21,7 +21,13 @@ export const BrunchDemoApp = ({ search: BrunchRouteSearch; }) => { const sentryFeedbackAction = useSentryFeedbackAction(); - const navigation = useSharedSearchNavigation(search, onSearchChange); + // Petrinaut only mounts below once the Brunch stream is available, and the + // stream is the whole point of this route, so the location starts in Actual + // mode. Without this the controlled state would open in Edit mode and the + // execution frame would read the local simulation instead of the stream. + const navigation = useSharedSearchNavigation(search, onSearchChange, { + initialState: { mode: "actual" }, + }); return ( Date: Thu, 3 Sep 2026 18:47:41 +0200 Subject: [PATCH 3/6] FE-1500: Fix review findings in the shared URL navigation The hook mistook the echo of its own URL write for an external change and merged the lossy projection back over the in-memory location, so selecting a second node cleared the selection. The echo is now suppressed once, and a later Back or Forward onto the same location still merges. Switching nets in the editable demo carried the previous net's location: Petrinaut resets its own location per document by keying on the handle id, which only covers an uncontrolled location. The demo clears the URL on a net switch instead. Selection changes no longer push a browser history entry on the editable demo, where Back would otherwise take dozens of presses to leave the page. The stream-key carry-over moves out of the route into withBrunchStreamKeys and is applied to the router's own previous search. initialState accepts only the fields the URL does not own, rather than discarding the rest. --- .../use-shared-search-navigation.test.tsx | 59 ++++++- .../examples/use-shared-search-navigation.ts | 28 +++- .../app/brunch-demo/brunch-search.test.ts | 50 +++++- .../src/main/app/brunch-demo/brunch-search.ts | 15 ++ .../local-storage-demo-app.test.tsx | 146 +++++++++++++++++- .../local-storage-demo-app.tsx | 26 +++- apps/petrinaut-website/src/routes/brunch.tsx | 10 +- .../content/handle/host-integration.mdx | 1 + .../content/website/router-integration.mdx | 11 +- 9 files changed, 324 insertions(+), 22 deletions(-) diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx index ab5581030ee..117c29e7ca9 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx @@ -17,7 +17,9 @@ const Probe = ({ onSearchChange, search, }: { - initialState?: Partial; + initialState?: Partial< + Omit + >; onController: (controller: PetrinautNavigationController) => void; onSearchChange: ( search: SharedExampleSearch, @@ -107,12 +109,58 @@ describe("useSharedSearchNavigation", () => { expect(controller.state.mode).toBe("simulate"); }); - it("seeds the fields the URL does not carry, letting the URL win over the seed", () => { + it("keeps a multi-item selection when the router echoes its own URL write", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ itemType: "place", itemId: "place-1" }} + />, + ); + + // A second selected item cannot be spelled in the URL, so the write drops + // the item entirely. The echo of that write must not be mistaken for an + // external change, or it merges the lossy projection back over the + // in-memory selection and clears it. + act(() => { + controller.onNavigate( + (current) => ({ + ...current, + selection: [ + { type: "place", id: "place-1" }, + { type: "place", id: "place-2" }, + ], + }), + { history: "push", intent: { cause: "user", action: "selection" } }, + ); + }); + + expect(onSearchChange).toHaveBeenCalledWith({}, "push"); + expect(controller.state.selection).toHaveLength(2); + + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + + expect(controller.state.selection).toHaveLength(2); + }); + + it("seeds the fields the URL does not carry, alongside the ones it does", () => { let controller!: PetrinautNavigationController; const onSearchChange = vi.fn(); render( { controller = value; }} @@ -122,9 +170,10 @@ describe("useSharedSearchNavigation", () => { ); // A controlled host replaces the provider's initial state, so a page that - // opens in a non-default mode has to seed it here. + // opens in a non-default mode has to seed it here. The URL-owned fields + // still come from the link the visitor opened; the option's type excludes + // them rather than accepting a value it would discard. expect(controller.state.mode).toBe("actual"); - // `subnet` is URL-owned, so the link the visitor opened wins. expect(controller.state.subnetId).toBe("subnet-from-url"); expect(onSearchChange).not.toHaveBeenCalled(); }); diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts index 4fbfd47a2eb..0174541f17d 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -17,6 +17,9 @@ import type { PetrinautNavigationState, } from "@hashintel/petrinaut/react"; +/** The location fields the shared search carries, and therefore owns. */ +type UrlOwnedField = "scenarioId" | "subnetId" | "selection"; + /** * Overwrites the URL-owned fields of the in-memory location with the current * shared search, keeping the fields the URL cannot represent. @@ -41,7 +44,8 @@ const mergeSharedSearch = ( * its shared projection is mirrored to the URL — otherwise every control * driving a non-shared field would silently snap back. * - * `initialState` seeds the fields the URL does not carry. A controlled host + * `initialState` seeds the fields the URL does not carry, and accepts only + * those: the URL owns the rest and would overwrite them. A controlled host * replaces `PetrinautNavigationProvider`'s own initial state, including the * Actual-mode default it applies when a live stream is available, so a page * that opens in a non-default mode states that mode here. @@ -54,7 +58,7 @@ export const useSharedSearchNavigation = ( ) => void, options?: { historyPolicy?: PetrinautNavigationHistoryPolicy; - initialState?: Partial; + initialState?: Partial>; }, ): PetrinautNavigationController => { const [navigationState, setNavigationState] = @@ -68,11 +72,22 @@ export const useSharedSearchNavigation = ( ); // Merge external URL changes (Back/Forward, a normalization redirect) - // into the in-memory location during render. + // into the in-memory location during render. The hook's own write is + // suppressed once, when the router delivers it back: the in-memory location + // already holds it, and the shared projection cannot represent all of it. const [previousSearch, setPreviousSearch] = useState(search); + const [writtenSearch, setWrittenSearch] = + useState(null); if (!sharedSearchesMatch(search, previousSearch)) { + const isOwnWrite = + writtenSearch !== null && sharedSearchesMatch(search, writtenSearch); setPreviousSearch(search); - setNavigationState((current) => mergeSharedSearch(current, search)); + // Cleared either way, so a later Back or Forward onto the same location + // still merges rather than being mistaken for the same echo again. + setWrittenSearch(null); + if (!isOwnWrite) { + setNavigationState((current) => mergeSharedSearch(current, search)); + } } // Freshest committed location for callbacks that can fire several times @@ -103,6 +118,11 @@ export const useSharedSearchNavigation = ( const nextSearch = navigationStateToSharedSearch(next); if (!sharedSearchesMatch(nextSearch, latestSearchRef.current)) { latestSearchRef.current = nextSearch; + // The router delivers this write back as a new `search` prop, and the + // merge above must not treat that echo as an external change: the + // projection is lossy, so re-merging it would clear a selection of + // any size but one. + setWrittenSearch(nextSearch); onSearchChange(nextSearch, history); } }, 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 index 41bb759cbbf..fa76f94f14d 100644 --- 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 @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { validateBrunchSearch } from "./brunch-search"; +import { validateBrunchSearch, withBrunchStreamKeys } from "./brunch-search"; describe("validateBrunchSearch", () => { it("keeps string parameters", () => { @@ -41,7 +41,7 @@ describe("validateBrunchSearch", () => { }); }); - it("carries the shared example location next to the stream keys", () => { + it("keeps the shared example location next to the stream keys", () => { expect( validateBrunchSearch({ sse: "https://brunch.example/events", @@ -55,3 +55,49 @@ describe("validateBrunchSearch", () => { }); }); }); + +describe("withBrunchStreamKeys", () => { + it("carries the stream keys over a navigation", () => { + // Dropping them would leave getBrunchEndpoint without an endpoint, and + // the live editor would be replaced by the status page mid-run. + expect( + withBrunchStreamKeys( + { runId: "run-1", sse: "https://brunch.example/events" }, + { subnet: "subnet-1" }, + ), + ).toEqual({ + runId: "run-1", + sse: "https://brunch.example/events", + subnet: "subnet-1", + }); + }); + + it("replaces the contract part rather than merging into it", () => { + // The hook always produces a complete contract search, so a key it omits + // is a key the new location does not have. + expect( + withBrunchStreamKeys( + { + runId: "run-1", + sse: "https://brunch.example/events", + scenario: "scenario-1", + itemType: "place", + itemId: "place-1", + }, + { subnet: "subnet-1" }, + ), + ).toEqual({ + runId: "run-1", + sse: "https://brunch.example/events", + subnet: "subnet-1", + }); + }); + + it("adds no stream keys when the current search has none", () => { + expect(withBrunchStreamKeys({}, { scenario: "scenario-1" })).toEqual({ + runId: undefined, + sse: undefined, + scenario: "scenario-1", + }); + }); +}); 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 index 645dd83f9f4..283395871b5 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts @@ -31,3 +31,18 @@ export const validateBrunchSearch = ( ...brunchStreamSearchSchema.parse(input), ...validateSharedExampleSearch(input), }); + +/** + * Replaces the contract part of a Brunch search and carries the stream keys + * over. Every other route writes a contract-only search; here the stream keys + * name the run, and dropping them would swap the live editor for the + * missing-endpoint status page mid-run. + */ +export const withBrunchStreamKeys = ( + current: BrunchRouteSearch, + next: SharedExampleSearch, +): BrunchRouteSearch => ({ + runId: current.runId, + sse: current.sse, + ...next, +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx index 6fe948c18a2..0243a428e78 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx @@ -1,16 +1,32 @@ /** * @vitest-environment jsdom */ +import { act, cleanup, render } from "@testing-library/react"; import { isValidElement, type ReactNode } from "react"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { VoiceInterviewControl } from "../voice-interview/voice-interview-control"; -import { getBrunchVoiceMode } from "./local-storage-demo-app"; +import { + getBrunchVoiceMode, + LocalStorageDemoApp, +} from "./local-storage-demo-app"; + +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; const defaultTransportOptions = vi.hoisted(() => ({ current: null as unknown, })); +const editorProps = vi.hoisted(() => ({ + current: null as { + navigation?: unknown; + createNewNet?: (params: { + petriNetDefinition: unknown; + title: string; + }) => void; + } | null, +})); + vi.mock("./brunch-principal", () => ({ getOrCreateBrunchPrincipal: () => "test-principal", })); @@ -21,7 +37,10 @@ vi.mock("@hashintel/petrinaut/ui", () => ({ defaultTransportOptions.current = options; } }, - Petrinaut: () => null, + Petrinaut: (props: Record) => { + editorProps.current = props; + return null; + }, WalkthroughProvider: ({ children }: { children: ReactNode }) => children, definePetrinautAiInteractiveTool: (definition: unknown) => definition, })); @@ -76,3 +95,124 @@ describe("local storage demo Brunch voice integration", () => { ); }); }); + +/** + * Node supplies its own `localStorage` global that shadows the jsdom one and + * carries no `setItem`, so the demo's storage hooks cannot read a seed from + * it. An in-memory store gives them one. + */ +const stubStorage = () => { + const entries = new Map(); + vi.stubGlobal("localStorage", { + get length() { + return entries.size; + }, + clear: () => entries.clear(), + getItem: (key: string) => entries.get(key) ?? null, + key: (index: number) => [...entries.keys()][index] ?? null, + removeItem: (key: string) => entries.delete(key), + setItem: (key: string, value: string) => entries.set(key, value), + } satisfies Storage); +}; + +const seedStoredNet = () => { + stubStorage(); + localStorage.setItem( + "petrinaut-sdcpn", + JSON.stringify({ + "net-1": { + id: "net-1", + title: "Seeded net", + lastUpdated: "2020-01-01T00:00:00.000Z", + sdcpn: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + }, + }), + ); +}; + +/** + * `navigation` is an optional prop, so dropping it from the editor compiles + * and leaves every other check green while the demo silently stops mirroring + * its location to the URL. These render the real component to pin the wiring. + */ +describe("local storage demo URL navigation", () => { + // Without this, a tree left mounted by an earlier case re-renders after the + // next one and overwrites the captured props with its own controller. + afterEach(() => { + cleanup(); + editorProps.current = null; + }); + + const mountedNavigation = (): PetrinautNavigationController => { + const navigation = editorProps.current?.navigation; + expect(navigation).toBeDefined(); + return navigation as PetrinautNavigationController; + }; + + test("resolves a URL-borne location into the controller it hands the editor", () => { + seedStoredNet(); + + render( + {}} + search={{ subnet: "subnet-1", itemType: "place", itemId: "place-1" }} + />, + ); + + const navigation = mountedNavigation(); + expect(navigation.state.subnetId).toBe("subnet-1"); + expect(navigation.state.selection).toEqual([ + { type: "place", id: "place-1" }, + ]); + }); + + test("writes an editor navigation back to the URL", () => { + seedStoredNet(); + const onSearchChange = vi.fn(); + + render(); + + mountedNavigation().onNavigate( + (current) => ({ ...current, subnetId: "subnet-2" }), + { history: "push", intent: { cause: "user", action: "subnet" } }, + ); + + expect(onSearchChange).toHaveBeenCalledWith({ subnet: "subnet-2" }, "push"); + }); + + test("clears the shared location when a new net replaces the open one", () => { + seedStoredNet(); + const onSearchChange = vi.fn(); + + render( + , + ); + + // A location names a place inside the net that was open, so carrying it + // into the next net would select something that is not there. Petrinaut's + // own per-document reset does not cover a controlled location. + act(() => { + editorProps.current?.createNewNet?.({ + petriNetDefinition: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + title: "Another net", + }); + }); + + expect(onSearchChange).toHaveBeenCalledWith({}, "replace"); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index cfd17532e7c..a082fdfc9b6 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -16,6 +16,7 @@ import { } from "@hashintel/petrinaut-core"; import { CommandRegistryProvider, + defaultPetrinautNavigationHistoryPolicy, useCommand, } from "@hashintel/petrinaut/react"; import { @@ -199,7 +200,26 @@ export const LocalStorageDemoApp = ({ const sentryFeedbackAction = useSentryFeedbackAction(); const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState(); - const navigation = useSharedSearchNavigation(search, onSearchChange); + const navigation = useSharedSearchNavigation(search, onSearchChange, { + // The editor changes the selection on almost every click, and each shared + // write would otherwise be a browser history entry, so Back would take + // dozens of presses to leave the page. Scenario and subnet moves are the + // locations worth stepping back through. + historyPolicy: (intent) => + intent.action === "selection" + ? "replace" + : defaultPetrinautNavigationHistoryPolicy(intent), + }); + + /** + * The location belongs to the net that was open. Petrinaut resets its own + * location per document by keying on the handle id, but that only resets an + * uncontrolled location, so the host clears the URL instead and the hook + * merges the empty search back over the fields the URL owns. + */ + const clearSharedLocation = () => { + onSearchChange({}, "replace"); + }; const { aiMessagesByNetId, setAiMessagesByNetId } = useLocalStorageAiMessages(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); @@ -314,6 +334,7 @@ export const LocalStorageDemoApp = ({ }); setActiveHandle(createActiveHandle(newNet)); setCurrentNetId(newNet.id); + clearSharedLocation(); }; const loadPetriNet = (petriNetId: string) => { @@ -342,6 +363,9 @@ export const LocalStorageDemoApp = ({ } setActiveHandle(createActiveHandle(netToLoad)); setCurrentNetId(petriNetId); + if (petriNetId !== currentNetId) { + clearSharedLocation(); + } }; const setTitle = (title: string) => { diff --git a/apps/petrinaut-website/src/routes/brunch.tsx b/apps/petrinaut-website/src/routes/brunch.tsx index 6deb13307d2..a92b2a94bb7 100644 --- a/apps/petrinaut-website/src/routes/brunch.tsx +++ b/apps/petrinaut-website/src/routes/brunch.tsx @@ -5,7 +5,10 @@ import { } from "@tanstack/react-router"; import { BrunchDemoApp } from "../main/app/brunch-demo/brunch-demo-app"; -import { validateBrunchSearch } from "../main/app/brunch-demo/brunch-search"; +import { + validateBrunchSearch, + withBrunchStreamKeys, +} from "../main/app/brunch-demo/brunch-search"; function BrunchRoute() { const navigate = useNavigate({ from: "/brunch" }); @@ -14,10 +17,11 @@ function BrunchRoute() { return ( { - // The stream keys identify the run; only the shared location moves. void navigate({ replace: history === "replace", - search: { runId: search.runId, sse: search.sse, ...nextSearch }, + // Applied to the router's own previous search, so two navigations + // in one event compose instead of the second reverting the first. + search: (previous) => withBrunchStreamKeys(previous, nextSearch), }); }} search={search} diff --git a/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx b/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx index 4f39623ffe6..ec12800aa2c 100644 --- a/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx @@ -383,6 +383,7 @@ full prop list, from `petrinaut/src/ui/petrinaut.tsx`: | `existingNets`, `createNewNet`, `loadPetriNet` | Multi-net management hooks backing the burger menu. Omit them when the host owns navigation. | | `aiAssistant` | Transport, message store, and interactive tools for the AI assistant panel. Omitting it hides the panel. | | `viewportActions` | Extra buttons appended to the canvas zoom/fit controls. | +| `navigation` | Host-controlled, router-neutral app location. Omitted: Petrinaut keeps its location internally. See Router integration. | | `slots` | Host components injected into the top bar (`topBarStart`, `topBarEnd`) plus `titleStyle`. | | `simulationWorkerFactory`, `monteCarloWorkerFactory`, `lspWorkerFactory` | Worker construction for hosts consuming the published dist, typically via the bundler's `?worker` import. Omitted: inlined-blob workers. | diff --git a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx index ce115cc804c..500f9f362e8 100644 --- a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx @@ -71,10 +71,13 @@ only the contract part. Every page that mounts Petrinaut does: the editable demo at `/` and `/optimization`, the Brunch stream at `/brunch`, and the example pages at -`/examples/$slug` and `/embed/examples/$slug`. The demo and Brunch pages hold -their model outside the URL, so a shared link there names a location inside -whatever net the visitor has open; the example pages name the model in the -path as well. +`/examples/$slug` and `/embed/examples/$slug`. + +They differ in whether the URL also names the model. The editable demo holds +its nets in local storage, so a link there names a location inside whatever +net the visitor has open. `/brunch` names its model with the stream keys it +carries over, so a copied URL reproduces the run. The example pages name the +model in the path. ## What a URL carries, and what it does not From c1ef9b32954955ddaf5b4b3dc737cdc2be155e4a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 3 Sep 2026 21:02:07 +0200 Subject: [PATCH 4/6] FE-1500: keep a history entry for a discrete selection --- .../local-storage-demo-app.test.tsx | 28 +++++++++++++++++++ .../local-storage-demo-app.tsx | 20 ++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx index 0243a428e78..49d06a3013e 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx @@ -5,6 +5,8 @@ import { act, cleanup, render } from "@testing-library/react"; import { isValidElement, type ReactNode } from "react"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { defaultPetrinautNavigationHistoryPolicy } from "@hashintel/petrinaut/react"; + import { VoiceInterviewControl } from "../voice-interview/voice-interview-control"; import { getBrunchVoiceMode, @@ -186,6 +188,32 @@ describe("local storage demo URL navigation", () => { expect(onSearchChange).toHaveBeenCalledWith({ subnet: "subnet-2" }, "push"); }); + test("leaves history to the library default, so a discrete click pushes", () => { + seedStoredNet(); + + render( {}} search={{}} />); + + // Constraining this page's policy once made selections replace, which left + // the page with no history entries at all and sent the first Back press + // off the site. The default keeps drag churn to one entry by replacing + // continuing intents, so it needs no host override. + expect(mountedNavigation().historyPolicy).toBeUndefined(); + expect( + defaultPetrinautNavigationHistoryPolicy({ + cause: "user", + action: "selection", + phase: "discrete", + }), + ).toBe("push"); + expect( + defaultPetrinautNavigationHistoryPolicy({ + cause: "user", + action: "selection", + phase: "continue", + }), + ).toBe("replace"); + }); + test("clears the shared location when a new net replaces the open one", () => { seedStoredNet(); const onSearchChange = vi.fn(); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index a082fdfc9b6..63634e35586 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -16,7 +16,6 @@ import { } from "@hashintel/petrinaut-core"; import { CommandRegistryProvider, - defaultPetrinautNavigationHistoryPolicy, useCommand, } from "@hashintel/petrinaut/react"; import { @@ -200,16 +199,15 @@ export const LocalStorageDemoApp = ({ const sentryFeedbackAction = useSentryFeedbackAction(); const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState(); - const navigation = useSharedSearchNavigation(search, onSearchChange, { - // The editor changes the selection on almost every click, and each shared - // write would otherwise be a browser history entry, so Back would take - // dozens of presses to leave the page. Scenario and subnet moves are the - // locations worth stepping back through. - historyPolicy: (intent) => - intent.action === "selection" - ? "replace" - : defaultPetrinautNavigationHistoryPolicy(intent), - }); + /** + * History is left to the library's default on purpose. That default already + * replaces rather than pushes while an intent continues, so a drag-select + * records one entry instead of one per intermediate selection, and a discrete + * click is the only thing that pushes. Making selections replace as well + * removed every entry this page can produce, which left the first Back press + * leaving the site instead of retracing the net. + */ + const navigation = useSharedSearchNavigation(search, onSearchChange); /** * The location belongs to the net that was open. Petrinaut resets its own From 378de14e7ff348905017941d5ff7745226a531fd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 00:26:18 +0200 Subject: [PATCH 5/6] FE-1500: clear the in-memory location when the net changes --- .../examples/use-shared-search-navigation.ts | 14 +++++++ .../local-storage-demo-app.test.tsx | 40 +++++++++++++++++++ .../local-storage-demo-app.tsx | 18 +++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts index 0174541f17d..50e54683d93 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -37,6 +37,20 @@ const mergeSharedSearch = ( }; }; +/** + * The URL-owned fields, cleared, for a host whose document is being replaced. + * + * Writing an empty search is not enough on its own: the shared projection is + * lossy, so a location the URL already renders as empty — a multi-item + * selection, for one — leaves the `search` prop unchanged, the merge below + * never runs, and the in-memory selection survives into the next document. + * Passing this to `onNavigate` clears the location itself and lets the write + * to the URL fall out of the usual path. + */ +export const withClearedSharedLocation = ( + current: PetrinautNavigationState, +): PetrinautNavigationState => mergeSharedSearch(current, {}); + /** * Navigation controller for pages whose URL carries the shared * scenario/subnet/selection subset. The editor navigates more than that diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx index 49d06a3013e..02e1bfe1b7f 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx @@ -243,4 +243,44 @@ describe("local storage demo URL navigation", () => { expect(onSearchChange).toHaveBeenCalledWith({}, "replace"); }); + + test("clears a multi-item selection the URL never carried", () => { + seedStoredNet(); + const onSearchChange = vi.fn(); + + render(); + + // A selection of more than one item projects to an empty search, so the + // URL is already empty and writing `{}` to it changes no prop. Clearing + // only through the URL therefore left this selection in place and carried + // ids from the old net into the next one. + act(() => { + mountedNavigation().onNavigate( + (current) => ({ + ...current, + selection: [ + { type: "place", id: "place-1" }, + { type: "place", id: "place-2" }, + ], + }), + { history: "push", intent: { cause: "user", action: "selection" } }, + ); + }); + expect(mountedNavigation().state.selection).toHaveLength(2); + + act(() => { + editorProps.current?.createNewNet?.({ + petriNetDefinition: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + title: "Another net", + }); + }); + + expect(mountedNavigation().state.selection).toEqual([]); + }); }); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index 63634e35586..9b40e97ea1c 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -27,7 +27,10 @@ import { WalkthroughProvider, } from "@hashintel/petrinaut/ui"; -import { useSharedSearchNavigation } from "../../../examples/use-shared-search-navigation"; +import { + useSharedSearchNavigation, + withClearedSharedLocation, +} from "../../../examples/use-shared-search-navigation"; import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; import { CommandPalette } from "../command-palette"; import { useSentryFeedbackAction } from "../sentry-feedback-button"; @@ -212,11 +215,18 @@ export const LocalStorageDemoApp = ({ /** * The location belongs to the net that was open. Petrinaut resets its own * location per document by keying on the handle id, but that only resets an - * uncontrolled location, so the host clears the URL instead and the hook - * merges the empty search back over the fields the URL owns. + * uncontrolled location, so the host clears this one. + * + * Cleared through the controller rather than by writing an empty search: the + * shared projection is lossy, so a location the URL already renders as empty + * leaves the search prop unchanged and the in-memory selection would survive + * into the next net. */ const clearSharedLocation = () => { - onSearchChange({}, "replace"); + navigation.onNavigate(withClearedSharedLocation, { + history: "replace", + intent: { cause: "normalization", action: "selection" }, + }); }; const { aiMessagesByNetId, setAiMessagesByNetId } = useLocalStorageAiMessages(); From 0f0ce0c49d6b77616bab2db8d27bc39be4b75433 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 00:48:11 +0200 Subject: [PATCH 6/6] FE-1500: carry the mode, Simulate section and overlay in the URL --- .changeset/preview-navigation-vocabularies.md | 5 + .../src/examples/example-search.ts | 51 ++++++++ .../src/examples/navigation-search.ts | 77 ++++++++++-- .../src/examples/oembed-endpoint.test.ts | 4 +- .../use-shared-search-navigation.test.tsx | 110 +++++++++++++++--- .../examples/use-shared-search-navigation.ts | 58 +++++---- libs/@hashintel/petrinaut/src/react/index.ts | 7 ++ 7 files changed, 265 insertions(+), 47 deletions(-) create mode 100644 .changeset/preview-navigation-vocabularies.md diff --git a/.changeset/preview-navigation-vocabularies.md b/.changeset/preview-navigation-vocabularies.md new file mode 100644 index 00000000000..d3d1d1e9349 --- /dev/null +++ b/.changeset/preview-navigation-vocabularies.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Export the `EditorGlobalMode` and `SimulateViewMode` types, so a host encoding Petrinaut's navigation state into its own router can spell both vocabularies and fail its build when either gains a member. diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index fedbd8071a6..730a513f729 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -13,15 +13,53 @@ import { type SelectionItemType, } from "@hashintel/petrinaut-core/selection"; +/** + * The editor's mode, its Simulate section, and the overlay it has open, spelled + * for a URL. + * + * Declared here rather than imported so this module stays free of the editor: + * the oEmbed server function speaks the same contract and must not bundle it. + * `navigation-search.ts` maps each of these onto the editor's own vocabulary + * with an exhaustive switch, so a rename on either side fails to compile. + */ +export const sharedModes = ["edit", "simulate", "actual", "notebook"] as const; + +export const sharedSimulateViews = [ + "scenarios", + "metrics", + "experiments", + "optimizations", +] as const; + +export const sharedOverlays = [ + "viewport-settings", + "create-scenario", + "create-metric", + "create-experiment", + "create-optimization", +] as const; + +export type SharedMode = (typeof sharedModes)[number]; +export type SharedSimulateView = (typeof sharedSimulateViews)[number]; +export type SharedOverlay = (typeof sharedOverlays)[number]; + /** * Search params understood by every example surface. A URL carries at most one * focused item: multi-selection is in-app state, not a shareable location. + * + * A field the URL leaves out means "whatever this page starts from", which for + * every page but `/brunch` is the editor's own default. That is what lets Back + * undo a mode change or close an overlay: the entry it returns to simply does + * not name the field. */ export type SharedExampleSearch = { scenario?: string; subnet?: string; itemType?: SelectionItemType; itemId?: string; + mode?: SharedMode; + view?: SharedSimulateView; + overlay?: SharedOverlay; }; /** The keys this contract owns. Anything else in a URL is foreign. */ @@ -30,6 +68,9 @@ const sharedSearchKeys = [ "subnet", "itemType", "itemId", + "mode", + "view", + "overlay", ] as const satisfies readonly (keyof SharedExampleSearch)[]; // `.catch(undefined)` is the contract's whole validation story: anything a URL @@ -41,6 +82,13 @@ const optionalSelectionItemType = z .optional() .catch(undefined); +const optionalMode = z.enum(sharedModes).optional().catch(undefined); +const optionalSimulateView = z + .enum(sharedSimulateViews) + .optional() + .catch(undefined); +const optionalOverlay = z.enum(sharedOverlays).optional().catch(undefined); + /** The focused item, when the URL names a complete one. */ export const selectionFromInput = ( input: Record, @@ -68,6 +116,9 @@ export const validateSharedExampleSearch = ( ): SharedExampleSearch => ({ scenario: optionalNonEmptyString.parse(input.scenario), subnet: optionalNonEmptyString.parse(input.subnet), + mode: optionalMode.parse(input.mode), + view: optionalSimulateView.parse(input.view), + overlay: optionalOverlay.parse(input.overlay), ...selectionToSearch(selectionFromInput(input)), }); diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index 94698850184..7e836f0c001 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -1,8 +1,16 @@ /** - * Projects the example URL contract onto Petrinaut's navigation state. The - * editor navigates more than the URL carries (mode, Simulate section, - * overlays), so those fields take editor defaults here and live in page state - * instead — see `useSharedSearchNavigation`. + * Projects the example URL contract onto Petrinaut's navigation state. + * + * The URL carries the location a reader can act on: the scenario, the subnet, + * the focused item, the editor's mode, its Simulate section, and the overlay it + * has open. It deliberately leaves out `simulateResource`, which names a run or + * a record inside the open document rather than a place in the app. + * + * Every field is decoded against a BASELINE — the location its page starts + * from. A URL that does not name a field means "the baseline's value", which is + * what makes Back undo a mode change or close an overlay: the entry Back + * returns to simply omits the field. The baseline is the editor's own default + * everywhere except `/brunch`, which starts in Actual mode. */ import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react"; @@ -10,9 +18,17 @@ import { selectionFromInput, selectionToSearch, type SharedExampleSearch, + type SharedMode, + type SharedOverlay, + type SharedSimulateView, } from "./example-search"; -import type { PetrinautNavigationState } from "@hashintel/petrinaut/react"; +import type { + EditorGlobalMode, + PetrinautNavigationOverlay, + PetrinautNavigationState, + SimulateViewMode, +} from "@hashintel/petrinaut/react"; /** `none` is an explicit no-scenario choice; absence means "first available". */ const scenarioFromSearch = ( @@ -28,19 +44,58 @@ const scenarioToSearch = ( scenarioId: string | null | undefined, ): string | undefined => (scenarioId === null ? "none" : scenarioId); +/** + * The editor's vocabularies, narrowed to the contract's. These are assignments + * rather than casts, so adding a mode, a Simulate section or an overlay to the + * editor fails this file's type check until the contract decides whether the + * URL should carry it. + */ +const modeToSearch = (mode: EditorGlobalMode): SharedMode => mode; + +const simulateViewToSearch = (view: SimulateViewMode): SharedSimulateView => + view; + +const overlayToSearch = ( + overlay: PetrinautNavigationOverlay, +): SharedOverlay | undefined => overlay?.type; + +const overlayFromSearch = ( + overlay: SharedOverlay, +): PetrinautNavigationOverlay => ({ type: overlay }); + export const sharedSearchToNavigationState = ( search: SharedExampleSearch, + baseline: PetrinautNavigationState = defaultPetrinautNavigationState, ): PetrinautNavigationState => ({ - ...defaultPetrinautNavigationState, + ...baseline, scenarioId: scenarioFromSearch(search), subnetId: search.subnet ?? null, selection: selectionFromInput(search as Record), + mode: search.mode ?? baseline.mode, + simulateView: search.view ?? baseline.simulateView, + overlay: + search.overlay === undefined + ? baseline.overlay + : overlayFromSearch(search.overlay), }); export const navigationStateToSharedSearch = ( state: Readonly, -): SharedExampleSearch => ({ - scenario: scenarioToSearch(state.scenarioId), - subnet: state.subnetId ?? undefined, - ...selectionToSearch(state.selection), -}); + baseline: PetrinautNavigationState = defaultPetrinautNavigationState, +): SharedExampleSearch => { + const mode = modeToSearch(state.mode); + const view = simulateViewToSearch(state.simulateView); + const overlay = overlayToSearch(state.overlay); + return { + scenario: scenarioToSearch(state.scenarioId), + subnet: state.subnetId ?? undefined, + // Omitted at the baseline, so an untouched page keeps a clean URL and the + // decode above puts the baseline back. + mode: mode === modeToSearch(baseline.mode) ? undefined : mode, + view: + view === simulateViewToSearch(baseline.simulateView) ? undefined : view, + overlay: + overlay === overlayToSearch(baseline.overlay) ? undefined : overlay, + ...selectionToSearch(state.selection), + }; +}; diff --git a/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts b/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts index dd8285d8a93..565d17c5a1f 100644 --- a/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts +++ b/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts @@ -62,6 +62,8 @@ describe("Petrinaut oEmbed endpoint", () => { it("preserves only valid embed state from the source URL", async () => { const source = new URL("https://demo.petrinaut.org/examples/gases-2-spn"); source.searchParams.set("mode", "simulate"); + // Neither of these is a contract key, so both drop: the Simulate section + // is carried as `view`, and `section` is somebody else's spelling. source.searchParams.set("section", "metrics"); source.searchParams.set("scenario", "scenario-1"); source.searchParams.set("subnet", "subnet-1"); @@ -76,7 +78,7 @@ describe("Petrinaut oEmbed endpoint", () => { expect(response.status).toBe(200); expect(body.html).toBe( - '', + '', ); }); diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx index 117c29e7ca9..36812ea7ae7 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx @@ -47,15 +47,24 @@ describe("useSharedSearchNavigation", () => { />, ); - // A mode change is not part of the URL contract: it applies in memory - // and produces no URL write. + // The resource open inside Simulate is the one location field the URL does + // not carry: it applies in memory and produces no URL write. act(() => { - controller.onNavigate((current) => ({ ...current, mode: "simulate" }), { - history: "push", - intent: { cause: "user", action: "mode" }, - }); + controller.onNavigate( + (current) => ({ + ...current, + simulateResource: { type: "experiment", id: "experiment-1" }, + }), + { + history: "push", + intent: { cause: "user", action: "simulation-resource" }, + }, + ); + }); + expect(controller.state.simulateResource).toEqual({ + type: "experiment", + id: "experiment-1", }); - expect(controller.state.mode).toBe("simulate"); expect(onSearchChange).not.toHaveBeenCalled(); // A subnet change is shared: it applies in memory AND writes the URL. @@ -66,7 +75,10 @@ describe("useSharedSearchNavigation", () => { ); }); expect(controller.state.subnetId).toBe("subnet-1"); - expect(controller.state.mode).toBe("simulate"); + expect(controller.state.simulateResource).toEqual({ + type: "experiment", + id: "experiment-1", + }); expect(onSearchChange).toHaveBeenCalledOnce(); expect(onSearchChange).toHaveBeenCalledWith( { scenario: "scenario-1", subnet: "subnet-1" }, @@ -88,14 +100,20 @@ describe("useSharedSearchNavigation", () => { ); act(() => { - controller.onNavigate((current) => ({ ...current, mode: "simulate" }), { - history: "push", - intent: { cause: "user", action: "mode" }, - }); + controller.onNavigate( + (current) => ({ + ...current, + simulateResource: { type: "experiment", id: "experiment-1" }, + }), + { + history: "push", + intent: { cause: "user", action: "simulation-resource" }, + }, + ); }); // Back/Forward delivers a different shared search: URL-owned fields - // update, the in-memory mode survives. + // update, and the one field the URL cannot carry survives. view.rerender( { @@ -106,7 +124,71 @@ describe("useSharedSearchNavigation", () => { />, ); expect(controller.state.scenarioId).toBe("scenario-2"); - expect(controller.state.mode).toBe("simulate"); + expect(controller.state.simulateResource).toEqual({ + type: "experiment", + id: "experiment-1", + }); + }); + + it("returns a URL-owned field to the baseline when Back drops it", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + + // Opening the create-experiment overlay in Simulate is a location, so it + // reaches the URL and therefore the history stack. + act(() => { + controller.onNavigate( + (current) => ({ + ...current, + mode: "simulate", + overlay: { type: "create-experiment" }, + }), + { history: "push", intent: { cause: "user", action: "overlay" } }, + ); + }); + expect(onSearchChange).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "simulate", + overlay: "create-experiment", + }), + "push", + ); + + // The router delivers the hook's own write back first; that echo is + // suppressed, since the in-memory location already holds it. + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ mode: "simulate", overlay: "create-experiment" }} + />, + ); + expect(controller.state.overlay).toEqual({ type: "create-experiment" }); + + // Back then returns to an entry that names neither, which is what closes + // the overlay and restores the mode rather than leaving them applied. + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + expect(controller.state.overlay).toBeNull(); + expect(controller.state.mode).toBe("edit"); }); it("keeps a multi-item selection when the router echoes its own URL write", () => { diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts index 50e54683d93..f3e0b7de177 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -17,23 +17,27 @@ import type { PetrinautNavigationState, } from "@hashintel/petrinaut/react"; -/** The location fields the shared search carries, and therefore owns. */ -type UrlOwnedField = "scenarioId" | "subnetId" | "selection"; - /** * Overwrites the URL-owned fields of the in-memory location with the current * shared search, keeping the fields the URL cannot represent. + * + * A field the search omits resolves to the page's baseline, so Back onto an + * entry that does not name it returns to where the page started. */ const mergeSharedSearch = ( current: PetrinautNavigationState, search: SharedExampleSearch, + baseline: PetrinautNavigationState, ): PetrinautNavigationState => { - const shared = sharedSearchToNavigationState(search); + const shared = sharedSearchToNavigationState(search, baseline); return { ...current, scenarioId: shared.scenarioId, subnetId: shared.subnetId, selection: shared.selection, + mode: shared.mode, + simulateView: shared.simulateView, + overlay: shared.overlay, }; }; @@ -49,17 +53,22 @@ const mergeSharedSearch = ( */ export const withClearedSharedLocation = ( current: PetrinautNavigationState, -): PetrinautNavigationState => mergeSharedSearch(current, {}); +): PetrinautNavigationState => ({ + ...current, + scenarioId: undefined, + subnetId: null, + selection: [], +}); /** - * Navigation controller for pages whose URL carries the shared - * scenario/subnet/selection subset. The editor navigates more than that - * (global mode, overlays), so the full location lives in page state and only - * its shared projection is mirrored to the URL — otherwise every control - * driving a non-shared field would silently snap back. + * Navigation controller for pages whose URL carries the shared location: the + * scenario, the subnet, the focused item, the mode, the Simulate section and + * the open overlay. The editor navigates one field more than that — the + * resource open inside Simulate — so the full location still lives in page + * state and only its shared projection reaches the URL. * - * `initialState` seeds the fields the URL does not carry, and accepts only - * those: the URL owns the rest and would overwrite them. A controlled host + * `initialState` is the location this page starts from, for every field the URL + * does not name; the URL overrides whatever it does name. A controlled host * replaces `PetrinautNavigationProvider`'s own initial state, including the * Actual-mode default it applies when a live stream is available, so a page * that opens in a non-default mode states that mode here. @@ -72,17 +81,22 @@ export const useSharedSearchNavigation = ( ) => void, options?: { historyPolicy?: PetrinautNavigationHistoryPolicy; - initialState?: Partial>; + initialState?: Partial; }, ): PetrinautNavigationController => { + // Snapshotted once: the caller passes a fresh object literal every render, + // and this is the value every absent URL field resolves to for the life of + // the page. + const [baseline] = useState(() => ({ + ...defaultPetrinautNavigationState, + ...options?.initialState, + })); + const [navigationState, setNavigationState] = useState(() => - // The URL wins over the seed for the fields it owns, so a shared link - // still resolves to the location it names. - mergeSharedSearch( - { ...defaultPetrinautNavigationState, ...options?.initialState }, - search, - ), + // The URL wins over the baseline for the fields it names, so a shared + // link still resolves to the location it carries. + mergeSharedSearch(baseline, search, baseline), ); // Merge external URL changes (Back/Forward, a normalization redirect) @@ -100,7 +114,9 @@ export const useSharedSearchNavigation = ( // still merges rather than being mistaken for the same echo again. setWrittenSearch(null); if (!isOwnWrite) { - setNavigationState((current) => mergeSharedSearch(current, search)); + setNavigationState((current) => + mergeSharedSearch(current, search, baseline), + ); } } @@ -129,7 +145,7 @@ export const useSharedSearchNavigation = ( navigationStateRef.current = next; setNavigationState(next); - const nextSearch = navigationStateToSharedSearch(next); + const nextSearch = navigationStateToSharedSearch(next, baseline); if (!sharedSearchesMatch(nextSearch, latestSearchRef.current)) { latestSearchRef.current = nextSearch; // The router delivers this write back as a new `search` prop, and the diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index 49086a0d5dc..23f48ce35d1 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -49,6 +49,13 @@ export type { PetrinautNavigationUpdater, PetrinautSimulateResource, } from "./navigation"; +// The vocabularies two navigation fields are drawn from. A host encoding the +// location into a router needs to spell them, and to fail its own build when +// either gains a member. +export type { + EditorGlobalMode, + SimulateViewMode, +} from "./state/editor-context"; export { NetManagementContext, type NetManagement,