Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/petrinaut-controlled-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@hashintel/petrinaut": patch
---

Add a `navigation` prop to `Petrinaut`: a router-neutral controller through
which the host can read and drive the app location (mode, Simulate section and
resource, scenario, subnet, selection, and creation drawers), making them real
browser history destinations.
23 changes: 18 additions & 5 deletions libs/@hashintel/petrinaut/docs/drawing-a-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** (JSON / JSON without visual info / TikZ), **Layout** (apply auto-layout), and **Docs**. 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**
Expand Down Expand Up @@ -129,6 +129,20 @@ Toggle the sidebar with the button in the top-left corner.

Press **Cmd+F** / **Ctrl+F** to open a search bar. Type to filter entities by name. Press **Escape** to close.

## Browser Back and Forward

On the Petrinaut demo and full example pages, Browser **Back** and **Forward**
move through the app locations you visited. This includes switching global
modes or Simulate sections, opening an existing scenario, metric, experiment,
or optimization, opening or closing their creation drawers, changing subnet,
committing a selection, and opening or closing Viewport Settings. Creation
drawers opened from Simulation Settings or the timeline are included too. A
drag-selection gesture creates one location after you finish drawing the
selection box, rather than one for every pointer move.

Browser history restores what you were looking at; it does not undo changes to
the Petri net. Use Petrinaut's Undo / Redo commands for document changes.

## Undo / Redo

Use the **Cmd+Z** / **Ctrl+Z** shortcut to undo the last action. Use the **Cmd+Shift+Z** / **Ctrl+Shift+Z** shortcut to redo the last action.
Expand Down Expand Up @@ -162,12 +176,11 @@ When enabled, node positions snap to a grid when placing or dragging. Toggle thi

From the top-bar menu (hamburger icon), under **Export**:

- **YAML** -- the full SDCPN: places, transitions, arcs, types, dynamics, parameters, scenarios, metrics, **and** canvas positions / display colours. This is the default format, and the one other Petrinaut instances re-import faithfully. It is easier to read and hand-edit than JSON: multi-line code fields (transition kernels, metrics) appear as indented blocks rather than escaped one-line strings.
- **YAML without visual info** -- the same payload minus node positions and type display colours. Useful when only the logical structure matters (sharing for review, embedding in another tool, comparing two nets without layout noise). On import, the receiving editor applies auto-layout to fill in positions.
- **JSON** / **JSON without visual info** -- the same two payloads encoded as JSON, for tools that expect it.
- **JSON** -- the full SDCPN: places, transitions, arcs, types, dynamics, parameters, scenarios, metrics, **and** canvas positions / display colours. The format other Petrinaut instances can re-import faithfully.
- **JSON without visual info** -- the same payload minus node positions and type display colours. Useful when only the logical structure matters (sharing for review, embedding in another tool, comparing two nets without layout noise). On import, the receiving editor applies auto-layout to fill in positions.
- **TikZ** -- a `.tex` file with a structural diagram. This is a simplified view: only the place / transition / arc structure is included. Token types, dynamics, read/inhibitor arcs, scenarios, and metrics are **not** encoded. Intended for papers and presentations.

**Import**: loads a net from a `.yaml`, `.yml`, or `.json` file -- the format is detected from the content, so files exported by any Petrinaut version load the same way. If node positions are missing, an automatic layout is applied on load.
**Import**: loads a net from a `.json` file. If node positions are missing, an automatic layout is applied on load.

## Auto-layout

Expand Down
6 changes: 6 additions & 0 deletions libs/@hashintel/petrinaut/docs/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ In the experiment's view drawer (open it by clicking a row in the list, or any e

There is no built-in restart action -- to re-run with the same configuration, **Create** a new experiment with the same settings.

Opening and closing an existing experiment participates in Browser Back /
Forward history on hosts with app navigation enabled. Experiment records and
results remain session data: browser navigation can reopen a record while the
current Petrinaut session is mounted, but reloading a copied experiment URL
does not recreate the run.

A confirmation prompt blocks browser/tab close while any experiment is initializing or running.

### Notifications
Expand Down
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut/docs/visual-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions libs/@hashintel/petrinaut/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 75 additions & 10 deletions libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -182,16 +187,29 @@ const ExperimentsContextConsumer = ({
return null;
};

const NavigationContextConsumer = ({
onNavigationState,
}: {
onNavigationState: (state: Readonly<PetrinautNavigationState>) => 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<PetrinautNavigationState>;
onNavigationState: (state: Readonly<PetrinautNavigationState>) => void;
}) => (
<NotificationsContext
value={{
Expand All @@ -201,16 +219,19 @@ const TestWrapper = ({
>
<SDCPNContext.Provider value={sdcpnContextValue}>
<LanguageClientOverride requestHirArtifacts={requestHirArtifacts}>
<ExperimentsProvider
workerFactory={() =>
worker as WorkerLike<
MonteCarloToWorkerMessage,
MonteCarloToMainMessage
>
}
>
<ExperimentsContextConsumer onContextValue={onContextValue} />
</ExperimentsProvider>
<PetrinautNavigationProvider initialState={initialNavigationState}>
<NavigationContextConsumer onNavigationState={onNavigationState} />
<ExperimentsProvider
workerFactory={() =>
worker as WorkerLike<
MonteCarloToWorkerMessage,
MonteCarloToMainMessage
>
}
>
<ExperimentsContextConsumer onContextValue={onContextValue} />
</ExperimentsProvider>
</PetrinautNavigationProvider>
</LanguageClientOverride>
</SDCPNContext.Provider>
</NotificationsContext>
Expand All @@ -221,12 +242,17 @@ function renderExperimentsProvider(
options: {
addNotification?: (notification: AddNotificationInput) => string;
requestHirArtifacts?: LanguageClientContextValue["requestHirArtifacts"];
initialNavigationState?: Partial<PetrinautNavigationState>;
} = {},
): {
getValue: () => ExperimentsContextValue;
getNavigationState: () => Readonly<PetrinautNavigationState>;
renderResult: RenderResult;
} {
const valueHolder = { current: null as ExperimentsContextValue | null };
const navigationStateHolder = {
current: null as Readonly<PetrinautNavigationState> | null,
};
const captureValue = (value: ExperimentsContextValue) => {
valueHolder.current = value;
};
Expand All @@ -237,16 +263,55 @@ function renderExperimentsProvider(
requestHirArtifacts={options.requestHirArtifacts}
worker={worker}
onContextValue={captureValue}
initialNavigationState={options.initialNavigationState}
onNavigationState={(state) => {
navigationStateHolder.current = state;
}}
/>,
);

return {
getValue: () => valueHolder.current!,
getNavigationState: () => navigationStateHolder.current!,
renderResult,
};
}

describe("ExperimentsProvider", () => {
it("replaces the creation overlay with the created experiment location", async () => {
const worker = new FakeMonteCarloWorker();
const { getNavigationState, getValue, renderResult } =
renderExperimentsProvider(worker, {
initialNavigationState: { overlay: { type: "create-experiment" } },
});

try {
let experimentId = "";
await act(async () => {
experimentId = await getValue().createExperiment({
name: "Navigated experiment",
scenarioId: null,
scenarioParameterValues: {},
runCount: 1,
seed: 42,
dt: 1,
maxTime: 10,
metricSpecs: CONSTANT_METRIC_SPEC,
});
await flushWorkerSetup();
});

expect(getNavigationState()).toMatchObject({
mode: "simulate",
simulateView: "experiments",
simulateResource: { type: "experiment", id: experimentId },
overlay: null,
});
} finally {
renderResult.unmount();
}
});

it("creates an initializing experiment before the worker reports ready", async () => {
const worker = new FakeMonteCarloWorker();
const { getValue, renderResult } = renderExperimentsProvider(worker);
Expand Down
45 changes: 39 additions & 6 deletions libs/@hashintel/petrinaut/src/react/experiments/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -170,6 +174,7 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
LanguageClientContext,
);
const { addNotification } = use(NotificationsContext);
const navigation = usePetrinautNavigation();
const petriNetDefinitionRef = useLatest(petriNetDefinition);
const extensionsRef = useLatest(extensions);
const workerFactoryRef = useLatest(workerFactory ?? createMonteCarloWorker);
Expand All @@ -180,9 +185,22 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
new Map<string, PendingExperimentRegistration>(),
);
const [experiments, setExperiments] = useState<ExperimentRecord[]>([]);
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(() => {
Expand All @@ -201,6 +219,18 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
};
}, []);

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<ExperimentRecord>,
Expand Down Expand Up @@ -496,9 +526,12 @@ export const ExperimentsProvider: React.FC<ExperimentsProviderProps> = ({
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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const editorContextValue = (
): EditorContextValue => ({
...initialEditorState,
globalMode,
navigateTo: () => {},
setGlobalMode: () => {},
setEditionMode: () => {},
setAddComponentMode: () => {},
Expand All @@ -56,6 +57,8 @@ const editorContextValue = (
isNotHoveredConnection: () => false,
selectedConnections: new Map(),
setSelection: () => {},
beginSelectionGesture: () => {},
endSelectionGesture: () => {},
selectItem: () => {},
toggleItem: () => {},
clearSelection: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const editorContextValue = (
): EditorContextValue => ({
...initialEditorState,
globalMode,
navigateTo: () => {},
setGlobalMode: () => {},
setEditionMode: () => {},
setAddComponentMode: () => {},
Expand All @@ -59,6 +60,8 @@ const editorContextValue = (
isNotHoveredConnection: () => false,
selectedConnections: new Map(),
setSelection: () => {},
beginSelectionGesture: () => {},
endSelectionGesture: () => {},
selectItem: () => {},
toggleItem: () => {},
clearSelection: () => {},
Expand Down
25 changes: 25 additions & 0 deletions libs/@hashintel/petrinaut/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ export type { ActualModeContextValue } from "./actual-mode-context";
// --- Provider unification ---
export { PetrinautProvider } from "./petrinaut-provider";
export type { PetrinautProviderProps } from "./petrinaut-provider";
export {
canonicalizePetrinautSelection,
defaultPetrinautNavigationHistoryPolicy,
defaultPetrinautNavigationState,
openPetrinautSimulationResource,
openPetrinautSimulationView,
openPetrinautSubnet,
PetrinautNavigationProvider,
petrinautNavigationStatesMatch,
revealPetrinautEditorItem,
usePetrinautNavigation,
} from "./navigation";
export type {
PetrinautNavigationAction,
PetrinautNavigationController,
PetrinautNavigationHistory,
PetrinautNavigationHistoryPolicy,
PetrinautNavigationIntent,
PetrinautNavigationOverlay,
PetrinautNavigationProviderProps,
PetrinautNavigationState,
PetrinautNavigationUpdate,
PetrinautNavigationUpdater,
PetrinautSimulateResource,
} from "./navigation";
export {
NetManagementContext,
type NetManagement,
Expand Down
Loading
Loading