Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/petrinaut-controlled-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/petrinaut-core-selection-vocabulary.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ libs/@local/graph/store/typescript/src/generated

# generated files
*.gen.*
# TanStack Router plugin scratch output.
.tanstack/
*.tsbuildinfo
feed.atom
feed.rss
Expand Down
5 changes: 3 additions & 2 deletions apps/petrinaut-website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions apps/petrinaut-website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -26,6 +27,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",
Expand All @@ -34,6 +36,8 @@
"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",
"@typescript/native-preview": "7.0.0-dev.20260511.1",
Expand Down
9 changes: 9 additions & 0 deletions apps/petrinaut-website/router-codegen-config.ts
Original file line number Diff line number Diff line change
@@ -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<Config>;
10 changes: 10 additions & 0 deletions apps/petrinaut-website/scripts/generate-route-tree.ts
Original file line number Diff line number Diff line change
@@ -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();
5 changes: 3 additions & 2 deletions apps/petrinaut-website/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")!, {
Expand All @@ -25,7 +26,7 @@ const root = createRoot(document.getElementById("root")!, {
root.render(
<StrictMode>
<SentryErrorTrackerProvider>
<DemoApp />
<RouterProvider router={router} />
</SentryErrorTrackerProvider>
</StrictMode>,
);
20 changes: 0 additions & 20 deletions apps/petrinaut-website/src/main/app.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <BrunchActualModeRoute viewportActions={[sentryFeedbackAction]} />;
return (
<BrunchActualModeRoute
search={search}
viewportActions={[sentryFeedbackAction]}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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=<url>.",
});
});

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:".',
});
});
});
25 changes: 14 additions & 11 deletions apps/petrinaut-website/src/main/app/brunch-demo/brunch-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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.
Expand All @@ -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) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
});
});
});
16 changes: 16 additions & 0 deletions apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts
Original file line number Diff line number Diff line change
@@ -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<typeof brunchSearchSchema>;

This file was deleted.

14 changes: 14 additions & 0 deletions apps/petrinaut-website/src/router.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading