Skip to content
5 changes: 5 additions & 0 deletions .changeset/routed-simulation-panels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add resizable, routed Experiment and Scenario panels with keyboard selection and fullscreen views that keep the tabs and AI assistant accessible.
5 changes: 5 additions & 0 deletions .changeset/simulation-panel-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Add simulation panel guidance to the AI assistant's documentation catalog.
41 changes: 41 additions & 0 deletions apps/petrinaut-website/src/examples/example-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,47 @@ describe("example search contract", () => {
expect(search.expandedSection).toBeUndefined();
}
});

it("validates complete resource links and fullscreen creation links", () => {
const resource = {
resourceType: "scenario",
resourceId: "scenario / one",
presentation: "fullscreen",
};
const validated = validateSharedExampleSearch(resource);
expect(validated).toMatchObject(resource);
expect(canonicalSearchString(validated)).toBe(
"presentation=fullscreen&resourceId=scenario+%2F+one&resourceType=scenario",
);
expect(
validateSharedExampleSearch({
overlay: "create-experiment",
presentation: "fullscreen",
}).presentation,
).toBe("fullscreen");
for (const invalid of [
{ resourceType: "unknown", resourceId: "one" },
{ resourceType: "scenario" },
{ resourceType: "experiment", resourceId: "" },
{ resourceId: "one" },
]) {
const search = validateSharedExampleSearch({
...invalid,
presentation: "fullscreen",
});
expect(search.resourceType).toBeUndefined();
expect(search.resourceId).toBeUndefined();
expect(search.presentation).toBeUndefined();
}
expect(
validateSharedExampleSearch({
resourceType: "metric",
resourceId: "one",
presentation: "fullscreen",
}).presentation,
).toBeUndefined();
});

it("validates settings sections only for the user settings dialog", () => {
expect(
validateSharedExampleSearch({
Expand Down
86 changes: 59 additions & 27 deletions apps/petrinaut-website/src/examples/example-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export const sharedSettingsSections = [
"labs",
] as const;

export const sharedResourceTypes = [
"scenario",
"metric",
"experiment",
] as const;

export type SharedEditView = (typeof sharedEditViews)[number];
export type SharedMode = (typeof sharedModes)[number];
export type SharedSimulateView = (typeof sharedSimulateViews)[number];
Expand All @@ -73,6 +79,9 @@ export type SharedExampleSearch = {
settings?: (typeof sharedSettingsSections)[number];
expandedPanel?: string;
expandedSection?: string;
resourceType?: (typeof sharedResourceTypes)[number];
resourceId?: string;
presentation?: "fullscreen";
};

/** The keys this contract owns. Anything else in a URL is foreign. */
Expand All @@ -88,6 +97,9 @@ const sharedSearchKeys = [
"settings",
"expandedPanel",
"expandedSection",
"resourceType",
"resourceId",
"presentation",
] as const satisfies readonly (keyof SharedExampleSearch)[];

// `.catch(undefined)` is the contract's whole validation story: anything a URL
Expand Down Expand Up @@ -131,34 +143,54 @@ export const selectionToSearch = (
*/
export const validateSharedExampleSearch = (
input: Record<string, unknown>,
): SharedExampleSearch => ({
scenario: optionalNonEmptyString.parse(input.scenario),
subnet: optionalNonEmptyString.parse(input.subnet),
mode: input.mode === "notebook" ? "edit" : optionalMode.parse(input.mode),
editView:
input.mode === "notebook"
? "definitions"
: optionalEditView.parse(
input.editView === "notebook" ? "definitions" : input.editView,
),
view: optionalSimulateView.parse(input.view),
overlay: optionalOverlay.parse(input.overlay),
expandedPanel: optionalNonEmptyString.parse(input.expandedSection)
? optionalNonEmptyString.parse(input.expandedPanel)
: undefined,
expandedSection: optionalNonEmptyString.parse(input.expandedPanel)
? optionalNonEmptyString.parse(input.expandedSection)
: undefined,
settings:
input.overlay === "user-settings"
? z
.enum(sharedSettingsSections)
.optional()
.catch(undefined)
.parse(input.settings)
): SharedExampleSearch => {
const resourceType = z
.enum(sharedResourceTypes)
.optional()
.catch(undefined)
.parse(input.resourceType);
const resourceId = optionalNonEmptyString.parse(input.resourceId);
const hasResource = resourceType !== undefined && resourceId !== undefined;
const canExpand =
(hasResource && resourceType !== "metric") ||
input.overlay === "create-scenario" ||
input.overlay === "create-experiment";

return {
scenario: optionalNonEmptyString.parse(input.scenario),
subnet: optionalNonEmptyString.parse(input.subnet),
mode: input.mode === "notebook" ? "edit" : optionalMode.parse(input.mode),
editView:
input.mode === "notebook"
? "definitions"
: optionalEditView.parse(
input.editView === "notebook" ? "definitions" : input.editView,
),
view: optionalSimulateView.parse(input.view),
overlay: optionalOverlay.parse(input.overlay),
expandedPanel: optionalNonEmptyString.parse(input.expandedSection)
? optionalNonEmptyString.parse(input.expandedPanel)
: undefined,
...selectionToSearch(selectionFromInput(input)),
});
expandedSection: optionalNonEmptyString.parse(input.expandedPanel)
? optionalNonEmptyString.parse(input.expandedSection)
: undefined,
settings:
input.overlay === "user-settings"
? z
.enum(sharedSettingsSections)
.optional()
.catch(undefined)
.parse(input.settings)
: undefined,
...selectionToSearch(selectionFromInput(input)),
resourceType: hasResource ? resourceType : undefined,
resourceId: hasResource ? resourceId : undefined,
presentation:
canExpand && input.presentation === "fullscreen"
? "fullscreen"
: undefined,
};
};

/** Canonical query string for a validated search: sorted, contract keys only. */
export const canonicalSearchString = (search: SharedExampleSearch): string => {
Expand Down
63 changes: 63 additions & 0 deletions apps/petrinaut-website/src/examples/navigation-search.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
canonicalSearchString,
sharedOverlays,
sharedSimulateViews,
validateSharedExampleSearch,
Expand All @@ -12,6 +13,39 @@ import {
} from "./navigation-search";

describe("navigation state projection", () => {
it.each([
{ mode: "edit", editView: "canvas" },
{ mode: "edit", editView: "definitions" },
{ mode: "actual", editView: "canvas" },
] as const)(
"round-trips $mode/$editView URLs with remembered simulation resources",
(destination) => {
const baseline = sharedSearchToNavigationState({
mode: destination.mode,
});
for (const resourceType of ["scenario", "experiment"] as const) {
for (const presentation of [undefined, "fullscreen"] as const) {
const state = {
...sharedSearchToNavigationState(
{ resourceType, resourceId: "record / one", presentation },
baseline,
),
...destination,
};
const url = canonicalSearchString(
navigationStateToSharedSearch(state, baseline),
);
const search = validateSharedExampleSearch(
Object.fromEntries(new URLSearchParams(url)),
);
expect(sharedSearchToNavigationState(search, baseline)).toEqual(
state,
);
}
}
},
);

it("routes Definitions within Edit and preserves the selection in shared links", () => {
const search = {
editView: "definitions",
Expand Down Expand Up @@ -70,6 +104,35 @@ describe("navigation state projection", () => {
applyPreviewNavigationUpdate(search, (current) => current),
).toMatchObject(search);
});

it.each(["scenario", "experiment"] as const)(
"opens a direct %s link and preserves its presentation through Preview",
(resourceType) => {
const search = {
resourceType,
resourceId: "record / one",
presentation: "fullscreen" as const,
};
const state = sharedSearchToNavigationState(search);
expect(state.mode).toBe("simulate");
expect(state.simulateView).toBe(
resourceType === "scenario" ? "scenarios" : "experiments",
);
expect(state.simulateResource).toEqual({
type: resourceType,
id: "record / one",
});
expect(state.simulatePresentation).toBe("fullscreen");
expect(navigationStateToSharedSearch(state)).toMatchObject(search);
expect(
applyPreviewNavigationUpdate(search, (current) => ({
...current,
subnetId: "subnet",
})),
).toMatchObject(search);
},
);

it.each(["general", "viewport", "simulation", "labs"] as const)(
"round-trips the %s settings section in Simulate",
(settings) => {
Expand Down
47 changes: 38 additions & 9 deletions apps/petrinaut-website/src/examples/navigation-search.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* 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, its expanded properties section, the editor's mode, its
* Simulate section and the overlay it has open. It leaves out `simulateResource`,
* which names a run or a record inside the open document rather than a place in the app.
* The URL carries the selected scenario, subnet, focused item, expanded
* properties section, editor mode, Simulate section, open record, overlay
* and panel presentation.
*
* 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
Expand Down Expand Up @@ -87,13 +86,27 @@ export const sharedSearchToNavigationState = (
scenarioId: scenarioFromSearch(search),
subnetId: search.subnet ?? null,
selection: selectionFromInput(search as Record<string, unknown>),
mode: search.mode ?? baseline.mode,
editView: search.editView ?? baseline.editView,
expandedSubView:
search.expandedPanel && search.expandedSection
? { container: search.expandedPanel, id: search.expandedSection }
: null,
simulateView: search.view ?? baseline.simulateView,
mode:
search.mode ??
(search.resourceType && search.resourceId ? "simulate" : baseline.mode),
simulateView:
search.resourceType && search.resourceId
? search.resourceType === "scenario"
? "scenarios"
: search.resourceType === "experiment"
? "experiments"
: "metrics"
: (search.view ?? baseline.simulateView),
simulateResource:
search.resourceType && search.resourceId
? { type: search.resourceType, id: search.resourceId }
: baseline.simulateResource,
simulatePresentation: search.presentation ?? baseline.simulatePresentation,
overlay:
search.overlay === undefined
? baseline.overlay
Expand All @@ -108,14 +121,27 @@ export const navigationStateToSharedSearch = (
const editView = editViewToSearch(state.editView);
const view = simulateViewToSearch(state.simulateView);
const overlay = overlayToSearch(state.overlay);
const canExpand =
state.simulateResource?.type === "scenario" ||
state.simulateResource?.type === "experiment" ||
overlay === "create-scenario" ||
overlay === "create-experiment";
return {
resourceType: state.simulateResource?.type,
resourceId: state.simulateResource?.id,
presentation:
canExpand && state.simulatePresentation === "fullscreen"
? "fullscreen"
: undefined,
Comment thread
cursor[bot] marked this conversation as resolved.
scenario: scenarioToSearch(state.scenarioId),
subnet: state.subnetId ?? undefined,
expandedPanel: state.expandedSubView?.container,
expandedSection: state.expandedSubView?.id,
// 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,
// Resource links imply Simulate unless they explicitly name a mode.
mode:
state.simulateResource || mode !== modeToSearch(baseline.mode)
? mode
: undefined,
editView:
editView === editViewToSearch(baseline.editView) ? undefined : editView,
view:
Expand Down Expand Up @@ -163,6 +189,9 @@ export const applyPreviewNavigationUpdate = (
settings: search.settings,
expandedPanel: search.expandedPanel,
expandedSection: search.expandedSection,
resourceType: search.resourceType,
resourceId: search.resourceId,
presentation: search.presentation,
...navigationStateToPreviewSearch(
update(previewSearchToNavigationState(search)),
),
Expand Down
Loading
Loading