diff --git a/.changeset/routed-simulation-panels.md b/.changeset/routed-simulation-panels.md new file mode 100644 index 00000000000..93741945071 --- /dev/null +++ b/.changeset/routed-simulation-panels.md @@ -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. diff --git a/.changeset/simulation-panel-guidance.md b/.changeset/simulation-panel-guidance.md new file mode 100644 index 00000000000..a98c8f4cb9b --- /dev/null +++ b/.changeset/simulation-panel-guidance.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Add simulation panel guidance to the AI assistant's documentation catalog. diff --git a/apps/petrinaut-website/src/examples/example-search.test.ts b/apps/petrinaut-website/src/examples/example-search.test.ts index 5f241d46c71..5bcd6103314 100644 --- a/apps/petrinaut-website/src/examples/example-search.test.ts +++ b/apps/petrinaut-website/src/examples/example-search.test.ts @@ -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({ diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index 9f460f4756c..76e382a72a3 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -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]; @@ -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. */ @@ -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 @@ -131,34 +143,54 @@ export const selectionToSearch = ( */ export const validateSharedExampleSearch = ( input: Record, -): 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 => { diff --git a/apps/petrinaut-website/src/examples/navigation-search.test.ts b/apps/petrinaut-website/src/examples/navigation-search.test.ts index a65302040a3..c54efd8bade 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.test.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + canonicalSearchString, sharedOverlays, sharedSimulateViews, validateSharedExampleSearch, @@ -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", @@ -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) => { diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index b03845a3807..93143cfb427 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -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 @@ -87,13 +86,27 @@ export const sharedSearchToNavigationState = ( scenarioId: scenarioFromSearch(search), subnetId: search.subnet ?? null, selection: selectionFromInput(search as Record), - 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 @@ -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, 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: @@ -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)), ), 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 72f42406b5d..6c81562aaaa 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 @@ -135,101 +135,84 @@ describe("useSharedSearchNavigation", () => { withClearedSharedLocation(controller.state).expandedSubView, ).toBeNull(); }); - it("keeps URL-unrepresentable state in memory and mirrors the shared subset", () => { - let controller!: PetrinautNavigationController; - const onSearchChange = vi.fn(); - render( - { - controller = value; - }} - onSearchChange={onSearchChange} - search={{ scenario: "scenario-1" }} - />, - ); - - // 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, - simulateResource: { type: "experiment", id: "experiment-1" }, - }), - { - history: "push", - intent: { cause: "user", action: "simulation-resource" }, - }, + it.each(["experiment", "scenario"] as const)( + "records the open %s and restores drawer/fullscreen with Back and Forward", + (resourceType) => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const probe = (search: SharedExampleSearch) => ( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={search} + /> ); - }); - expect(controller.state.simulateResource).toEqual({ - type: "experiment", - id: "experiment-1", - }); - expect(onSearchChange).not.toHaveBeenCalled(); - - // A subnet change is shared: it applies in memory AND writes the URL. - act(() => { - controller.onNavigate( - (current) => ({ ...current, subnetId: "subnet-1" }), - { history: "push", intent: { cause: "user", action: "subnet" } }, + const view = render(probe({})); + act(() => + controller.onNavigate( + (current) => ({ + ...current, + mode: "simulate", + simulateView: + resourceType === "scenario" ? "scenarios" : "experiments", + simulateResource: { type: resourceType, id: "record-1" }, + }), + { + history: "push", + intent: { cause: "user", action: "simulation-resource" }, + }, + ), ); - }); - expect(controller.state.subnetId).toBe("subnet-1"); - expect(controller.state.simulateResource).toEqual({ - type: "experiment", - id: "experiment-1", - }); - expect(onSearchChange).toHaveBeenCalledOnce(); - expect(onSearchChange).toHaveBeenCalledWith( - { scenario: "scenario-1", subnet: "subnet-1" }, - "push", - ); - }); - - it("merges an external URL change without resetting in-memory fields", () => { - let controller!: PetrinautNavigationController; - const onSearchChange = vi.fn(); - const view = render( - { - controller = value; - }} - onSearchChange={onSearchChange} - search={{ scenario: "scenario-1" }} - />, - ); - - act(() => { - controller.onNavigate( - (current) => ({ - ...current, - simulateResource: { type: "experiment", id: "experiment-1" }, - }), - { - history: "push", - intent: { cause: "user", action: "simulation-resource" }, - }, + const drawerSearch: SharedExampleSearch = { + mode: "simulate", + view: resourceType === "scenario" ? "scenarios" : undefined, + resourceType, + resourceId: "record-1", + }; + expect(onSearchChange).toHaveBeenLastCalledWith( + expect.objectContaining(drawerSearch), + "push", ); - }); + view.rerender(probe(drawerSearch)); - // Back/Forward delivers a different shared search: URL-owned fields - // update, and the one field the URL cannot carry survives. - view.rerender( - { - controller = value; - }} - onSearchChange={onSearchChange} - search={{ scenario: "scenario-2" }} - />, - ); - expect(controller.state.scenarioId).toBe("scenario-2"); - expect(controller.state.simulateResource).toEqual({ - type: "experiment", - id: "experiment-1", - }); - }); + act(() => + controller.onNavigate( + (current) => ({ ...current, simulatePresentation: "fullscreen" }), + { + history: "push", + intent: { cause: "user", action: "simulation-presentation" }, + }, + ), + ); + const fullscreenSearch = { + ...drawerSearch, + presentation: "fullscreen" as const, + }; + expect(onSearchChange).toHaveBeenLastCalledWith( + expect.objectContaining(fullscreenSearch), + "push", + ); + view.rerender(probe(fullscreenSearch)); + view.rerender(probe(drawerSearch)); + expect(controller.state.simulatePresentation ?? "panel").toBe("panel"); + expect(controller.state.simulateResource).toEqual({ + type: resourceType, + id: "record-1", + }); + view.rerender(probe({})); + expect(controller.state.simulateResource).toBeNull(); + view.rerender(probe(drawerSearch)); + view.rerender(probe(fullscreenSearch)); + expect(controller.state.simulatePresentation).toBe("fullscreen"); + expect(controller.state.simulateResource).toEqual({ + type: resourceType, + id: "record-1", + }); + expect(onSearchChange).toHaveBeenCalledTimes(2); + }, + ); it("returns a URL-owned field to the baseline when Back drops it", () => { let controller!: PetrinautNavigationController; 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 47d6f0064f8..539b9e13773 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -39,6 +39,8 @@ const mergeSharedSearch = ( mode: shared.mode, editView: shared.editView, simulateView: shared.simulateView, + simulateResource: shared.simulateResource, + simulatePresentation: shared.simulatePresentation, overlay: shared.overlay, }; }; @@ -61,14 +63,15 @@ export const withClearedSharedLocation = ( subnetId: null, selection: [], expandedSubView: null, + simulateResource: null, + simulatePresentation: undefined, }); /** * Navigation controller for pages whose URL carries the shared location: the - * scenario, the subnet, the focused item, its expanded properties section, - * the mode, the Simulate section and the open overlay. The editor also navigates - * the resource open inside Simulate, so the full location still lives in page - * state and only its shared projection reaches the URL. + * selected scenario, subnet, focused item, expanded properties section, + * mode, Simulate section, open record, overlay and panel presentation. + * Page state also retains multi-selection. * * `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 diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 83a818a15e3..ea5989d01f6 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -104,6 +104,7 @@ export const petrinautDocNames = [ "scenarios", "ad-hoc-scenarios", "experiments", + "simulation-panels", "actual-mode", "preview", "ai-assistant", @@ -129,7 +130,9 @@ export const petrinautDocSummaries: Record = { "ad-hoc-scenarios": "Inline initial state + parameters without saving a scenario: the shared form (scenario. variables, fixed/dynamic/swept-count rows chosen from the row gutter's menu, shared columns, phantom row, place totals, live type checking), its three surfaces (quick simulation, experiments, scenario creation and editing with Scenario Parameter toggles), interval selections — Sweep or Optimize by setting — with generated adhoc_* parameter names, saved scenarios shown in run mode.", experiments: - "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), parameter sweeps, constraints (parameter and state, pass threshold), Optimize toggles and an Objective section (metric, direction, steps) at creation, the drawer opening already optimizing, Stop on the Parameters card, one study per experiment, lifecycle/statuses, cancel/remove, header columns (Steps, Steps clear), metric charts, the Constraints and Sensitivity analysis cards, the steps table, Objective by step, compute backend, active-experiments popover.", + "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), parameter sweeps, constraints (parameter and state, pass threshold), Optimize toggles and an Objective section (metric, direction, steps) at creation, the panel opening already optimizing, Stop on the Parameters card, one study per experiment, lifecycle/statuses, cancel/remove, header columns (Steps, Steps clear), metric charts, the Constraints and Sensitivity analysis cards, the steps table, Objective by step, compute backend, active-experiments popover.", + "simulation-panels": + "Experiment and scenario panels, fullscreen controls, state preservation, docked and floating AI layout, links and browser history, session limits for experiments.", "actual-mode": "Actual mode: host-provided live execution view, Brunch stream URL route, read-only extension-free net, current limits.", preview: diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 59c0fa6f7da..11724dd090b 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -36,6 +36,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl - [Scenarios](scenarios.md) -- Save and switch between named simulation configurations. - [Ad-hoc Scenarios](ad-hoc-scenarios.md) -- The scenario form: define initial state and parameters inline for one run, or save them as a scenario. - [Experiments](experiments.md) -- Run Monte Carlo batches and inspect token-count distributions over time. +- [Simulation Panels](simulation-panels.md) -- Open scenarios and experiments beside the main view, expand to fullscreen, and use links and browser history. - [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch. - [Embedded Preview](preview.md) -- Explore a compact, read-only Petri net embedded in a host application. - [AI Assistant](ai-assistant.md) -- Build, review, and revise nets with text or inline Voice mode. diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 6b0367e2b4e..0d4b5cfbb84 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -9,7 +9,7 @@ Use an ad-hoc scenario for one-off runs and quick exploration. When you want to The same form appears in three places: 1. **Quick simulation** -- in the [Simulation Settings](simulation.md#simulation-settings) tab, with "No scenario" selected, the panel's two columns are the form's own tables: **Variables** above **Parameters** on the left, **Initial state** -- token counts and values -- on the right, no separate dialog. A quiet **Clear** button next to the Initial state title resets your entries. The next simulation run uses what you defined. Any [compile error](#errors) appears in the settings panel's error banner. -2. **Experiments** -- in the [create-experiment drawer](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. With [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value carries an interval toggle (see below). +2. **Experiments** -- in the [create-experiment panel](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. With [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value carries an interval toggle (see below). 3. **Scenario creation** -- [creating or editing a scenario](scenarios.md#creating-a-scenario) uses the same form with a **Scenario Parameter** toggle on each top-level Variable; see [Saving a scenario from the form](#saving-a-scenario-from-the-form). ## The form @@ -20,9 +20,9 @@ The form has up to three sections. Variables come first -- parameter overrides m - **Parameters** -- one row per [net-level parameter](petri-net-extensions.md#global-parameters), showing its type and its value. An untouched parameter shows its default quietly, marked with a small `default` tag; enter an expression to override the value for this run -- it may read the Variables above. In the quick-simulation embedding this section sits under Variables in the left column, beside Initial state. - **Initial state** -- one block per place in the net. Each place's title carries its token colour dot (grey for untyped places). -In the experiment drawer each section collapses: click the chevron in its header, or focus the header and press Left to collapse and Right to expand. Place headers inside Initial state collapse the same way everywhere, and a collapsed place shows a one-line summary of its rows and token total. In the quick-simulation embedding, places start collapsed. +In the experiment panel each section collapses: click the chevron in its header, or focus the header and press Left to collapse and Right to expand. Place headers inside Initial state collapse the same way everywhere, and a collapsed place shows a one-line summary of its rows and token total. In the quick-simulation embedding, places start collapsed. -Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the experiment drawer with sweeps enabled -- the interval toggle below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md#expression-language) scenarios use. Press Enter, Escape, or click elsewhere to close the editor. Escape closes only the innermost thing that is open -- a completion list, a bound edit, the editor itself -- and never the drawer or dialog around the form; close those from their own buttons. Closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. +Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the experiment panel with sweeps enabled -- the interval toggle below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md#expression-language) scenarios use. Press Enter, Escape, or click elsewhere to close the editor. Escape closes only the innermost thing that is open -- a completion list, a bound edit, the editor itself -- and never the panel or dialog around the form; close those from their own buttons. Closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. Opening a value with Enter or a second click selects its whole content, so typing replaces it. Opening by typing keeps the caret right after what you typed. @@ -70,7 +70,7 @@ Every expression is type-checked as you work. The open editor marks problems inl ## Interval selections (experiments) -In the create-experiment drawer, with [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value slot -- cells, counts, variables, shared columns, and net parameters -- carries a labeled interval toggle, purple while on: under the open cell editor, and on the row for Variables and Parameters. It reads **Sweep**, or **Optimize** when the [in-browser optimizer](experiments.md#optimizing-a-sweep) is on; the word is the same on every toggle of the form, and both mean the same thing. Turning it on replaces the expression with **Min** and **Max** cells; an interval declares nothing else, so there is no Scale or Step. Each bound is an expression cell with the same selection model as the rest of the form -- select it, press Enter (or click again) to edit, Enter or Escape to leave; Escape from a selected cell closes the editor. Turning the toggle off restores the expression you had, and the bounds are remembered too. A selected value shows its bounds (`0 … 12`) on a purple slot. Boolean and text values offer no toggle, and changing a selected Variable to boolean turns its toggle off; a cell muted by a shared column does not count. A row's gutter menu offers **Swept count** or **Optimized count** for a dynamic row's count, to match. +In the create-experiment panel, with [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value slot -- cells, counts, variables, shared columns, and net parameters -- carries a labeled interval toggle, purple while on: under the open cell editor, and on the row for Variables and Parameters. It reads **Sweep**, or **Optimize** when the [in-browser optimizer](experiments.md#optimizing-a-sweep) is on; the word is the same on every toggle of the form, and both mean the same thing. Turning it on replaces the expression with **Min** and **Max** cells; an interval declares nothing else, so there is no Scale or Step. Each bound is an expression cell with the same selection model as the rest of the form -- select it, press Enter (or click again) to edit, Enter or Escape to leave; Escape from a selected cell closes the editor. Turning the toggle off restores the expression you had, and the bounds are remembered too. A selected value shows its bounds (`0 … 12`) on a purple slot. Boolean and text values offer no toggle, and changing a selected Variable to boolean turns its toggle off; a cell muted by a shared column does not count. A row's gutter menu offers **Swept count** or **Optimized count** for a dynamic row's count, to match. Each selection becomes a swept parameter of the experiment with a deterministic name, shown in the sweep navigator under the value's path (`Space › item 0 › x`): @@ -80,9 +80,9 @@ Each selection becomes a swept parameter of the experiment with a deterministic - `adhoc_var_net_` -- a top-level Variable; place-scoped variables use the place's name as the scope. - `adhoc_param_` -- a net parameter override. -Bounds must resolve to constants, integer values need integer bounds, and the maximum must exceed the minimum; a value that does not run shows its problem on the bound, and the drawer's footer names it. The experiment then behaves like any [parameter sweep](experiments.md#parameter-sweeps): the initial state compiles at the navigator's selection, parameter overrides follow each run's draw. Under **Optimize**, the study searches the generated parameters like any others; only [Constraints](experiments.md#constraints) need a saved scenario. +Bounds must resolve to constants, integer values need integer bounds, and the maximum must exceed the minimum; a value that does not run shows its problem on the bound, and the panel's footer names it. The experiment then behaves like any [parameter sweep](experiments.md#parameter-sweeps): the initial state compiles at the navigator's selection, parameter overrides follow each run's draw. Under **Optimize**, the study searches the generated parameters like any others; only [Constraints](experiments.md#constraints) need a saved scenario. -A saved scenario shown through the form in the experiment drawer offers the same toggle on each numeric scenario parameter row. +A saved scenario shown through the form in the experiment panel offers the same toggle on each numeric scenario parameter row. ## Saving a scenario from the form @@ -94,4 +94,4 @@ Selecting a saved ad-hoc scenario in Simulation Settings shows it through the sa ## Errors -Ad-hoc definitions are validated as you type, on the value they belong to, and again when you run. In quick simulation, compile problems also appear in the Simulation Settings error banner; in the experiment drawer, in the footer. +Ad-hoc definitions are validated as you type, on the value they belong to, and again when you run. In quick simulation, compile problems also appear in the Simulation Settings error banner; in the experiment panel, in the footer. diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 1913dea1efc..dc748d5f8e5 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -19,7 +19,9 @@ Some hosts mark activity that happened in the tab you are not viewing. A numbere The assistant opens in a sidebar at the far right of the editor. It sits flush against the viewport, beside the canvas and its properties panel. Its left divider and resize highlight span the full panel height. The sidebar slides in at its full width while the canvas makes room. Closing it returns that space to the canvas. -The bottom toolbar stays centered on the editor when the docked assistant opens, moving only as far as needed to avoid overlapping the panels. +Experiment and Scenario panels make room for the docked assistant too. Their fullscreen presentation fills the main view beside it. Resizing or closing the assistant adjusts the available space; floating the assistant lets it sit above the open panel. + +The bottom toolbar stays centered on the remaining main view, moving when needed to avoid overlapping its panels. Choose **Float AI assistant** in the header to detach it into a rounded panel over the canvas. The canvas expands smoothly to reclaim the sidebar's space, and the floating panel reserves no space at the right edge. Drag anywhere in the header outside the tabs and action buttons to move it, or focus **Move AI assistant** and use the arrow keys. Hold **Shift** with an arrow key to move farther. The floating panel stays within the editor when the window changes size. @@ -236,7 +238,7 @@ ranges to minimize or maximize a metric. The experiment appears in a compact card with its status, run count, and results. Simulation cards use blue; optimization cards use purple and glow while running. Select **View -experiment** to inspect metric distributions in the Experiments drawer. The +experiment** to inspect metric distributions in the Experiments panel. The heatmap shows how values spread across runs; click a time step to see its histogram. Select **Cancel** to stop its work. The assistant receives the diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index d0aac65fa4f..4b094472861 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -7,7 +7,7 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo ## Creating an experiment 1. Switch to **Simulate** mode and open the **Experiments** tab. -2. Click **Create**. The Create Experiment drawer opens. +2. Click **Create**. The Create Experiment panel opens. 3. Fill in the configuration (see below). 4. Click **Run** -- **Create sweep** when a value is swept, **Optimize** when the in-browser optimizer will search it. The button reads **Starting** (or **Creating**) while the experiment starts. @@ -39,7 +39,7 @@ experiments are not restored after a reload. With "No scenario" selected, the Scenario section shows the [ad-hoc scenario form](ad-hoc-scenarios.md): define the initial state and parameter values inline for this experiment, without saving a scenario. Left untouched, the experiment runs from the manually-set markings and defaults. The experiments table shows "Ad-hoc scenario" in its Scenario column for such runs. With [Parameter sweeps](#parameter-sweeps) enabled, every numeric value of the form carries the same interval toggle -- see [Interval selections](ad-hoc-scenarios.md#interval-selections-experiments). -With a scenario selected, the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style -- a ratio parameter's edit applies only between 0 and 1; outside, the form marks it and the run keeps the previous value -- each numeric one with the interval toggle when Parameter sweeps is on, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. A swept parameter previews at the start of its range, the first combination the sweep runs, and the preview says so. The preview sits in its own tinted panel and scrolls as one, so a net with many places leaves the rest of the drawer in reach. +With a scenario selected, the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style -- a ratio parameter's edit applies only between 0 and 1; outside, the form marks it and the run keeps the previous value -- each numeric one with the interval toggle when Parameter sweeps is on, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. A swept parameter previews at the start of its range, the first combination the sweep runs, and the preview says so. The preview sits in its own tinted panel and scrolls as one, so a net with many places leaves the rest of the panel in reach. | **Runs** | `1000` | Positive integer; how many independent simulations to run. For a sweep the field reads **Max runs per selection**: each selection refines progressively (8, 25, 100, … 1000, 5000, …) up to this ceiling, so large budgets — 100,000 on the GPU — sharpen the distribution the longer you stay. | | **Time step (dt)** | `0.1` | Same meaning as in single-run simulations (see [Simulation](simulation.md#time-step-dt)). | | **Max time (seconds)** | `180` | Each run advances until simulation time reaches this value, then completes. | @@ -51,7 +51,7 @@ The model used is a snapshot of the current net at the time you press **Run**. E ### Constraints -With [Parameter sweeps](#parameter-sweeps) and [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) both on, flipping the first **Optimize** toggle on a saved scenario's parameter adds a **Constraints** section to the drawer, between [Objective](#optimizing-a-sweep) and Metrics. Its rows record boolean conditions the optimizer must respect when it [drives the sweep](#optimizing-a-sweep). The sweep itself ignores them: no run is excluded from the charts and the objective is never changed by them. An experiment created with **No scenario** has no Constraints section: the names of its generated parameters are not yours to write. Two kinds, added from the **Parameter constraint** and **State constraint** buttons under the list and mixed in one list, each row marked with a **Parameters** or **State** chip: +With [Parameter sweeps](#parameter-sweeps) and [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) both on, flipping the first **Optimize** toggle on a saved scenario's parameter adds a **Constraints** section to the panel, between [Objective](#optimizing-a-sweep) and Metrics. Its rows record boolean conditions the optimizer must respect when it [drives the sweep](#optimizing-a-sweep). The sweep itself ignores them: no run is excluded from the charts and the objective is never changed by them. An experiment created with **No scenario** has no Constraints section: the names of its generated parameters are not yours to write. Two kinds, added from the **Parameter constraint** and **State constraint** buttons under the list and mixed in one list, each row marked with a **Parameters** or **State** chip: - **Parameter constraints** -- one-line expressions over the sweep's parameters (`scenario.*` for scenario parameters, `parameters.*` for net parameters) that must produce a boolean, for example `scenario.min_load < scenario.max_load`. Before a step runs, the optimizer checks them at the step's values, snapped to the sweep's grid. A step whose values break one is **infeasible**: it costs one step and no simulation, the sliders do not move to it, it is reported as pruned with the constraint named, and its row is greyed in the steps table. - **State constraints** -- small code bodies that read the simulation `state` exactly like a metric and `return` a boolean, for example `return state.places.Queue.count <= 10;`. Every run of a step reports whether the condition held on every sampled frame: a run **passed** when it did and **failed** otherwise, and a run that errors reports neither, so its step's fraction is over the runs that reported. A state constraint runs beside the sweep's metrics on every batch, from the sweep's creation on, and it runs on the CPU: the WebGPU switch greys out while a state row is drafted, and a sweep with state constraints computes on the CPU whether or not a study drives it. @@ -64,15 +64,15 @@ Each row checks as you type: type errors, unknown names and a result that is not Experiments progress through these status labels: -| Status | Meaning | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Initializing** | The experiment has been created and its workers are starting up. | -| **Running** | Runs are in progress. | -| **Idle** | A sweep computing nothing: fresh, or its selected region fully sampled. Moving a parameter control resumes running. Grey in the list. | -| **Optimizing** | A sweep whose sliders a study drives (see [Optimizing a sweep](#optimizing-a-sweep)). An experiment created with **Optimize** opens in this state. The drawer's header reads it; the list keeps the sweep's own status, Running or Idle. | -| **Complete** | All runs finished without error. | -| **Error** | The experiment failed to start or hit an unrecoverable error. The drawer shows the error message. For a sweep the error belongs to the selection that failed: move a control and the next selection computes normally. | -| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | +| Status | Meaning | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Initializing** | The experiment has been created and its workers are starting up. | +| **Running** | Runs are in progress. | +| **Idle** | A sweep computing nothing: fresh, or its selected region fully sampled. Moving a parameter control resumes running. Grey in the list. | +| **Optimizing** | A sweep whose sliders a study drives (see [Optimizing a sweep](#optimizing-a-sweep)). An experiment created with **Optimize** opens in this state. The panel's header reads it; the list keeps the sweep's own status, Running or Idle. | +| **Complete** | All runs finished without error. | +| **Error** | The experiment failed to start or hit an unrecoverable error. The panel shows the error message. For a sweep the error belongs to the selection that failed: move a control and the next selection computes normally. | +| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | Experiments run in background Web Workers, so simulation playback and editor interactions stay responsive. Multiple experiments can run concurrently. @@ -93,7 +93,7 @@ Parameter sweeps are experimental and off by default. Turn on **Parameter sweeps Flip the toggle on any numeric value to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so a selection has a stable identity and revisiting one restores its results. With **No scenario** selected, the same toggle sits on every numeric value of the [form](ad-hoc-scenarios.md) -- a token count, a cell, a variable, a parameter override -- and each selection sweeps as a generated parameter named after the value, shown in the navigator under the value's path. -A sweep computes **what you have selected**, and nothing until something selects. A sweep created with **Create sweep** sits idle with every slider spanning its whole interval, its charts empty, and the line under the sliders says what to do -- collapse a control to a point or click the surface to compute a point, widen a range to sample across it -- until you move a control or click the surface. A sweep created with **Optimize** is selected by its study from its first step (see [Optimizing a sweep](#optimizing-a-sweep)). The results drawer grows a **Parameters** card across the top of its body, with one slider per swept parameter and the swept count under its title. Each slider selects a range on its interval, and starts spanning the whole of it: +A sweep computes **what you have selected**, and nothing until something selects. A sweep created with **Create sweep** sits idle with every slider spanning its whole interval, its charts empty, and the line under the sliders says what to do -- collapse a control to a point or click the surface to compute a point, widen a range to sample across it -- until you move a control or click the surface. A sweep created with **Optimize** is selected by its study from its first step (see [Optimizing a sweep](#optimizing-a-sweep)). The results panel grows a **Parameters** card across the top of its body, with one slider per swept parameter and the swept count under its title. Each slider selects a range on its interval, and starts spanning the whole of it: - **Range** (the default): Petrinaut runs **one stochastic simulation over the ranges** — every run draws its own value for each ranged parameter, spread across the selected interval — and the metric charts stream the live distribution **over the region**, sharpening exactly like a plain experiment's. Resize a range from either end to focus; compute restarts on the new selection. Range selections run on the GPU when the net qualifies — each run's parameter draw is uploaded alongside its state — and otherwise on the CPU at full parallelism; an initial state that a scenario derives from a ranged parameter holds at the range's midpoint, while the simulation itself reads each run's own value. - **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value — including on the GPU. @@ -106,27 +106,27 @@ Every selection uses the same seed sequence (common random numbers), and a run's The in-browser optimizer is experimental and off by default. Turn on **In-browser optimization** under Simulation in the [settings dialog](visual-settings.md#in-browser-optimization-experimental); the setting is offered only when the host application provides an optimizer that runs in your browser. Turning it off while a study runs cancels the study. -With it on, the interval toggles of the Create Experiment drawer read **Optimize**, and the first one you flip adds an **Objective** section to the drawer, between Scenario and [Constraints](#constraints): the metric to optimize (one of the experiment's metrics, the first by default), **Maximize** or **Minimize**, and the number of steps to take (30 by default, 1,000 at most). Each step computes eight runs at one point of the sweep before the optimizer reads the metric's value there, the mean over those runs on the last sampled frame; the line under the fields says so -- **30 steps · 8 runs each — the best point then refines to your run budget** -- or names the step budget the optimizer refuses (a run of more than 100,000 simulation steps, or steps × 8 runs × simulation steps over 5,000,000), and the footer stays disabled until it is met. A **No scenario** experiment optimizes too, over the generated parameters of its form values; only Constraints need a saved scenario. +With it on, the interval toggles of the Create Experiment panel read **Optimize**, and the first one you flip adds an **Objective** section to the panel, between Scenario and [Constraints](#constraints): the metric to optimize (one of the experiment's metrics, the first by default), **Maximize** or **Minimize**, and the number of steps to take (30 by default, 1,000 at most). Each step computes eight runs at one point of the sweep before the optimizer reads the metric's value there, the mean over those runs on the last sampled frame; the line under the fields says so -- **30 steps · 8 runs each — the best point then refines to your run budget** -- or names the step budget the optimizer refuses (a run of more than 100,000 simulation steps, or steps × 8 runs × simulation steps over 5,000,000), and the footer stays disabled until it is met. A **No scenario** experiment optimizes too, over the generated parameters of its form values; only Constraints need a saved scenario. -The footer reads **Optimize**, then **Starting** while the scenario compiles and the study registers. The experiment then opens already optimizing: the **Parameters** card is purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls are locked and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes; the **N computing** chip lists the step's batch as **Step N**. The optimizer draws its first steps at random, about a third of the requested steps and at least 2 and at most 10, then proposes each further step from the results so far. Every step's runs use the sweep's common random numbers, so the differences between steps come from the parameters, not from sampling luck. Steps run one after another, and one study at a time: a study started while another runs waits for it, reading **Optimizing** at step 1 with no runs until its turn. Parameters you did not sweep hold at the values the experiment was created with. If the optimizer cannot start -- the optimizer disconnected, or a budget the form did not catch -- nothing is created: the drawer stays open with the reason in its footer and every field as you left it. +The footer reads **Optimize**, then **Starting** while the scenario compiles and the study registers. The experiment then opens already optimizing: the **Parameters** card is purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls are locked and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes; the **N computing** chip lists the step's batch as **Step N**. The optimizer draws its first steps at random, about a third of the requested steps and at least 2 and at most 10, then proposes each further step from the results so far. Every step's runs use the sweep's common random numbers, so the differences between steps come from the parameters, not from sampling luck. Steps run one after another, and one study at a time: a study started while another runs waits for it, reading **Optimizing** at step 1 with no runs until its turn. Parameters you did not sweep hold at the values the experiment was created with. If the optimizer cannot start -- the optimizer disconnected, or a budget the form did not catch -- nothing is created: the panel stays open with the reason in its footer and every field as you left it. -While the study drives the sweep the card's header carries one purple **Stop** button: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the sliders unlock and the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the experiment's removal. The value is named for what it is: the best of the steps tried, not a confirmed result at that configuration. From there the sweep is yours to explore by hand -- sliders, **Point** and **Range**, the Surface -- with the study's picture kept; the card offers nothing more, and a new search is a new experiment. **Cancel** in the drawer's footer stops the study as well as the sweep; **Remove** discards both. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's drawer is its home, and removing the experiment removes it. +While the study drives the sweep the card's header carries one purple **Stop** button: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the sliders unlock and the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the experiment's removal. The value is named for what it is: the best of the steps tried, not a confirmed result at that configuration. From there the sweep is yours to explore by hand -- sliders, **Point** and **Range**, the Surface -- with the study's picture kept; the card offers nothing more, and a new search is a new experiment. **Cancel** in the panel's footer stops the study as well as the sweep; **Remove** discards both. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's panel is its home, and removing the experiment removes it. The first study in a browser downloads the Python runtime and the optimizer packages before its first step starts; the header reads **Optimizing** with no steps completed while that happens. Later studies reuse the browser's cache. Closing or reloading the page ends the study, and while one runs the browser asks you to confirm first; the study is gone on the next load, the sweep with it. The optimizer proposes with the same sampler, seed and start-up draws the [Petrinaut CLI](../../../@local/petrinaut-arch-docs/content/cli/usage-manual.mdx) uses, so a study's proposals match the CLI's step for step while the objective values it is told match. -An **Objective by step** strip sits under the sliders from the moment the drawer opens: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Infeasible draws carry no value and are left off the strip, and the best step so far is never one of them. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A sweep created with **Create sweep** has no strip. +An **Objective by step** strip sits under the sliders from the moment the panel opens: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Infeasible draws carry no value and are left off the strip, and the best step so far is never one of them. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A sweep created with **Create sweep** has no strip. -The drawer's shape is fixed when the experiment is created, and it holds through running, stopped and failed studies: an experiment created with **Optimize** has, from its first frame, a headline over the header's columns, **Steps** and **Steps clear** columns after **Compute** (see [Reading the header](#reading-the-header)), the **Constraints** and **Sensitivity analysis** cards after the metric charts and the steps table under them (see [Metric charts](#metric-charts)), empty until the steps fill them; a sweep created with **Create sweep** has none of them. Nothing appears later, and nothing moves. +The panel's shape is fixed when the experiment is created, and it holds through running, stopped and failed studies: an experiment created with **Optimize** has, from its first frame, a headline over the header's columns, **Steps** and **Steps clear** columns after **Compute** (see [Reading the header](#reading-the-header)), the **Constraints** and **Sensitivity analysis** cards after the metric charts and the steps table under them (see [Metric charts](#metric-charts)), empty until the steps fill them; a sweep created with **Create sweep** has none of them. Nothing appears later, and nothing moves. #### The surface view A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** card: a contour plot of one metric's final value over two parameters you pick, drawn from the points the sweep has computed. It starts empty. Every point you visit — by moving the sliders to a point, by clicking the plot, or through the optimizer — lands as a dot with its value, the field is interpolated between the dots once there are three, and the point being computed is a ring; its value joins the field once its batch completes. Points computed at other values of the parameters not shown are drawn too, projected onto the two you picked. The **X** and **Y** pickers sit in the row under the plot and the **Metric** picker in the row beneath them; every metric is measured at every point, so switching the shown metric repaints from what was already computed. The line under the card's title counts the points and what computes -- **computing the selected point** or **sampling across the selected ranges**, with the runs so far -- or, mid-drag, the values under the pointer. **The surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release every swept parameter collapses to a point -- the two shown at the place you released, the others at the middle of their current range -- which then computes. A dark ring marks where the navigator sits. While the optimizer drives the sweep the card is read-only: the plot only displays under a not-allowed cursor, the **X**, **Y** and **Metric** pickers lock, a purple **Read-only** mark sits beside them, and between two steps the line says the optimizer is choosing the next point. A cancelled sweep locks the same way, with the mark in grey. -The drawer arranges its parts by its width. The **Parameters** card spans the body under the header. Beneath it, at the drawer's full width, the **Surface** sits on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower drawer the metric cards come first, then **Surface**, so the charts you watch are at the top either way. A sweep with one swept parameter has no surface, and its cards take the whole width. Every card keeps a fixed height, and only the body scrolls, under the header. +The panel arranges its parts by its width. The **Parameters** card spans the body under the header. Beneath it, at the panel's full width, the **Surface** sits on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower panel the metric cards come first, then **Surface**, so the charts you watch are at the top either way. A sweep with one swept parameter has no surface, and its cards take the whole width. Every card keeps a fixed height, and only the body scrolls, under the header. ### Compute backend (experimental) -Experiments run on the CPU unless you ask for the GPU. Switch on **WebGPU** under **Settings → Simulation**, and the Create Experiment drawer gains a **Run on GPU** switch. Running on your graphics hardware is dramatically faster — a 4000-run experiment that takes six seconds on the CPU finishes in a few milliseconds. +Experiments run on the CPU unless you ask for the GPU. Switch on **WebGPU** under **Settings → Simulation**, and the Create Experiment panel gains a **Run on GPU** switch. Running on your graphics hardware is dramatically faster — a 4000-run experiment that takes six seconds on the CPU finishes in a few milliseconds. The choice is per experiment, not global, so a GPU experiment and a CPU experiment can run side by side — useful for comparing the two on the same model. Each gets its own GPU device, so nothing is shared between them. @@ -153,7 +153,7 @@ Two things to know before comparing results: ### Reading the header -Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**) and the run count, for example **SIR transmission sweep · Seasonal Flu · 100 runs**. Beneath it, a strip of labelled columns divided by hairlines, always on one line: in a narrow drawer the labels become tooltips and the columns read as chips, **Runs** and **Selection** shorten to their counts, and whatever still does not fit scrolls sideways under a fade at the edge. +Open an experiment's panel and its header names the experiment in one line: the name, the scenario (or **Default scenario**) and the run count, for example **SIR transmission sweep · Seasonal Flu · 100 runs**. Beneath it, a strip of labelled columns divided by hairlines, always on one line: in a narrow panel the labels become tooltips and the columns read as chips, **Runs** and **Selection** shorten to their counts, and whatever still does not fit scrolls sideways under a fade at the edge. | Column | Meaning | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -165,14 +165,14 @@ Open an experiment's drawer and its header names the experiment in one line: the | **Elapsed** | Plain experiments only: clock time the experiment has been simulating; it stops with the experiment and holds the total it took. A sweep never finishes, so it has no clock. | | **Activity** | The **N computing** chip: how many batches run right now, **0 computing** when nothing does. Click it while something runs to list them. | | **Compute** | Whether the run uses the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, it names the requirement the net did not meet. | -| **Steps** | Sweeps created with **Optimize**: the steps finished over the steps requested, with the runs per step, **4 / 30 · 8 runs each**; the count alone in a narrow drawer. | +| **Steps** | Sweeps created with **Optimize**: the steps finished over the steps requested, with the runs per step, **4 / 30 · 8 runs each**; the count alone in a narrow panel. | | **Steps clear** | Sweeps with a study over a sweep with [constraints](#constraints): the steps clear over the steps that simulated, **3 / 4 · 75%**. | A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep (the study's steps while one drives it), simulated time otherwise. If the experiment failed, the error reads in the line under the header; so does the error of a study that failed while driving a sweep. For a sweep created with **Optimize**, the title line also carries the study's headline at its right: **Starting · no best step yet** before the first step, **Step 5 of 30 · best step so far: step 2 (650.500)** while it runs, then **Finished 30 steps · …**, **Stopped after 17 of 30 steps · …** or **Failed after …**. While it runs, a chip beside the line says whether the study is still finding better steps: **Still improving** when the best moved within the last few completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. The chip's place is reserved, so nothing moves when it appears or goes. -Once the drawer's body has scrolled, the header condenses to one line, with the columns folded in as compact chips beside the title, the compute badge and the computing chip still among them; move the pointer over it, or Tab onto one of its controls, and it grows back. Nothing in the header moves when a status changes, a count goes to zero or a number grows a digit: every column is as wide as its widest value, and every card in the body keeps its height. +Once the panel's body has scrolled, the header condenses to one line, with the columns folded in as compact chips beside the title, the compute badge and the computing chip still among them; move the pointer over it, or Tab onto one of its controls, and it grows back. Nothing in the header moves when a status changes, a count goes to zero or a number grows a digit: every column is as wide as its widest value, and every card in the body keeps its height. **Elapsed** and **Duration** measure simulating only. Compiling the net's user code and starting the workers (or acquiring the GPU device and compiling the shader) happens before the clock starts, so the number is comparable between the two backends. An experiment that fails before it starts simulating shows `—` rather than a duration. @@ -189,40 +189,40 @@ Click (or drag across) a timeline chart to inspect single time steps — a popov #### The study's cards -For a sweep created with **Optimize**, two more cards follow the metric charts in the same grid, at the same height, from the moment the drawer opens. +For a sweep created with **Optimize**, two more cards follow the metric charts in the same grid, at the same height, from the moment the panel opens. - The **Constraints** card, only for a sweep with [constraints](#constraints). Its headline is the steps **clear** across the study over the steps that simulated, `14 / 20 · 70%`, with the pass threshold and the infeasible draws counted in the line under the title, **pass threshold 95% (alpha 0.05) · 2 infeasible draws**. Beneath it, one line gives the latest step's verdict -- **Clear**, **Limited · 6 / 8 runs passed · 75% · State constraint 1**, or **Infeasible: Parameter constraint 1** -- and one bar per state constraint shows the share of steps it passed, with a dashed mark at the threshold. The same headline sits in the header's strip as **Steps clear**. A step stopped mid-flight, or pruned because the sliders moved on, carries no verdict and counts in neither number. - The **Sensitivity analysis** card lists the swept parameters in the scenario's order with a bar for how much each one matters for reaching the best steps and a **Share** percentage per parameter; the rows keep their places as estimates land. The estimate is Optuna's PED-ANOVA: it takes the best tenth of the completed steps and measures how concentrated each parameter's values are there relative to its whole range, a relative importance that sums to 100% rather than a share of the objective's variance. It is computed by the optimizer running in your browser once the study is over, and again every few steps while a long study runs (every tenth step, or every twentieth of the requested steps when that is more) once it is past the floor. The line under the title names the statistic and says how many completed steps it is fitted on. Below the floor, 50 completed steps for a study of under 100 steps and 100 otherwise, the card is muted, the bars fade and the line says **below the N-step floor, treat as a hint**, N being the floor just named: a confident estimate over a handful of steps would mislead, and at the default 30 steps the card stays muted. A **Correlation** column beside the bars gives each parameter's signed correlation with the objective over the completed steps (`+0.34`, `−0.12`), computed from the steps themselves, so it is there from the third completed step whatever the floor. Before the first estimate the rows show a dash. A study that optimizes a single parameter has nothing to rank it against: its line says **PED-ANOVA ranks two or more parameters**, the card is never muted, and only the correlation column carries information. -Under both columns, at the drawer's full width, the **steps table** lists the study's steps newest first, each with its parameters, objective value and a state mark (complete, pruned or failed), the best step starred and tinted. It keeps a fixed height and scrolls on its own, and a long study shows its newest 200 steps while the header keeps the totals and the best. A sweep with constraints adds a **Runs passed** column (`52 / 60 · 87%`, the constraint with the fewest passing runs when there are several) and greys the rows of infeasible steps, their mark reading **Infeasible:** and the constraint's name. +Under both columns, at the panel's full width, the **steps table** lists the study's steps newest first, each with its parameters, objective value and a state mark (complete, pruned or failed), the best step starred and tinted. It keeps a fixed height and scrolls on its own, and a long study shows its newest 200 steps while the header keeps the totals and the best. A sweep with constraints adds a **Runs passed** column (`52 / 60 · 87%`, the constraint with the fewest passing runs when there are several) and greys the rows of infeasible steps, their mark reading **Infeasible:** and the constraint's name. + +### Panel and fullscreen views + +Experiment creation and results open in a [panel beside the main view](simulation-panels.md). Expand it to fullscreen for more space; form edits and chart choices stay in place. + +Experiments belong to the current session. Reloading a link after that session has ended shows an unavailable message; it does not rerun the experiment. ### Actions -In the experiment's view drawer (open it from the list, where the first click selects a row and a click on the selected row or Enter opens it, or via any experiment in the top-bar **Active experiments** popover): +In the experiment's view panel (open it with a single click in the list, with **Up** or **Down** while browsing the list, or via any experiment in the top-bar **Active experiments** popover): - **Cancel** -- stops the experiment. Offered while it is initializing or running, and while a study drives a sweep, which it stops too. Once a sweep is cancelled its sliders and its surface lock; a selection that failed locks nothing, and the next selection computes normally. - **Remove** -- deletes the record and disposes the experiment's workers (and, for a sweep, its study). It sits at the left edge of the footer. -- **Close** -- closes the drawer without affecting the experiment. +- **Close** -- closes the panel without affecting the experiment. 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 The **N computing** chip in the header's **Activity** column counts the batches running right now — a sweep pipelines the selection's batches two deep — and clicking it opens a compact list with each batch's label (**Selection**, or **Step N** while a study drives the sweep) and progress; it reads **0 computing** while nothing runs, and the list closes with its last batch. -A small toast appears when an experiment **completes** or **errors**, even if its drawer isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. +A small toast appears when an experiment **completes** or **errors**, even if its panel isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. ## Active experiments popover -When any experiment is **initializing** or **running**, the top bar shows an **Active experiments** flask icon with a count (e.g. "2 active"). Click it for a popover listing each in-flight experiment with its scenario, progress, status, and a time progress bar. Clicking a row jumps directly to Simulate mode, the Experiments tab, and that experiment's drawer. +When any experiment is **initializing** or **running**, the top bar shows an **Active experiments** flask icon with a count (e.g. "2 active"). Click it for a popover listing each in-flight experiment with its scenario, progress, status, and a time progress bar. Clicking a row jumps directly to Simulate mode, the Experiments tab, and that experiment's panel. The popover hides itself again once nothing is in flight. diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index b45f9146f66..bbcbba8433f 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -28,14 +28,18 @@ You will need scenarios when you want to: ## Creating a scenario 1. Switch to **Simulate** mode and open the **Scenarios** tab. -2. Click **Create**. The Create Scenario drawer opens. +2. Click **Create**. The Create Scenario panel opens. 3. Fill in **Scenario name** (required, unique among scenarios) and an optional description. 4. Add **Variables** -- one per value you want to drive from a single number, written `scenario.` in every expression below. Turn **Scenario Parameter** on to expose a Variable as a tunable parameter of the saved scenario: it needs a snake_case name, a constant expression as its default, and a value between 0 and 1 for a ratio. 5. Fill in **Parameters** -- an expression per net-level parameter whose default you want to override; the `default` tag marks the untouched ones. 6. Configure **Initial state** -- a count expression per untyped place, rows of cells per typed place (a Dynamic row builds many tokens from one count). See [Ad-hoc Scenarios](ad-hoc-scenarios.md#the-form) for the form itself. 7. Click **Create**. It is disabled while the name or any value has an error -- hover it to read the first. -The view drawer opens from the Scenarios list, which works like the other Simulate-mode lists: the first click selects a row, and a click on the selected row (or Enter) opens it. The list is a single Tab stop whose rows the arrow keys walk. The drawer shows the same form populated with the existing values, with **Close** and **Save** buttons. +The view panel opens from the Scenarios list with a single click. The list is a single Tab stop: **Up** and **Down** select and open the previous or next scenario while keeping focus in the list. The panel shows the same form populated with the existing values, with **Close** and **Save** buttons. Save your edits before selecting another scenario to keep them. + +## Panel and fullscreen views + +Scenario creation and editing open in a [panel beside the main view](simulation-panels.md). Expand it to fullscreen for more space; your unsaved edits stay in place. ## Expression language @@ -53,7 +57,7 @@ The subset is strict about booleans and equality: conditions and `&&`/`||` take ## Scenarios stored as code -Net files, the AI assistant and earlier versions of Petrinaut may store a scenario's initial state per place (one expression or one token spreadsheet per place) or as a single code block. Both run unchanged, and both preview as computed rows in Simulation Settings and the experiment drawer. Editing opens each in the form: a per-place scenario opens converted -- its parameters as exposed Variables, its expressions and rows as the form's blocks -- and saving stores it in the form's format; a code scenario opens with its name, description, Variables and Parameters editable and its code shown read-only in the Initial state slot -- edit its values here, change the code from the AI assistant or the net file, or recreate the scenario from the form (a Dynamic row builds many tokens from one count). A code scenario stores no form entries, only its scenario parameters: every Variable must be marked **Scenario Parameter** (the form refuses to save one that is not), and each is kept as its computed default. The code is a function body that returns an object keyed by **place name** -- a number for an untyped place (rounded, clamped to `>= 0`), an array of token objects for a typed one -- with `parameters`, `scenario` and `range` in scope; a key that is not a place name is a compile error, so a typo'd name fails the scenario instead of being silently ignored: +Net files, the AI assistant and earlier versions of Petrinaut may store a scenario's initial state per place (one expression or one token spreadsheet per place) or as a single code block. Both run unchanged, and both preview as computed rows in Simulation Settings and the experiment panel. Editing opens each in the form: a per-place scenario opens converted -- its parameters as exposed Variables, its expressions and rows as the form's blocks -- and saving stores it in the form's format; a code scenario opens with its name, description, Variables and Parameters editable and its code shown read-only in the Initial state slot -- edit its values here, change the code from the AI assistant or the net file, or recreate the scenario from the form (a Dynamic row builds many tokens from one count). A code scenario stores no form entries, only its scenario parameters: every Variable must be marked **Scenario Parameter** (the form refuses to save one that is not), and each is kept as its computed default. The code is a function body that returns an object keyed by **place name** -- a number for an untyped place (rounded, clamped to `>= 0`), an array of token objects for a typed one -- with `parameters`, `scenario` and `range` in scope; a key that is not a place name is a compile error, so a typo'd name fails the scenario instead of being silently ignored: ```ts return { diff --git a/libs/@hashintel/petrinaut/docs/simulation-panels.md b/libs/@hashintel/petrinaut/docs/simulation-panels.md new file mode 100644 index 00000000000..448455e0991 --- /dev/null +++ b/libs/@hashintel/petrinaut/docs/simulation-panels.md @@ -0,0 +1,33 @@ +# Simulation panels + +Experiment creation, experiment results, and scenario creation and editing open in a panel beside the main view. The list or canvas stays visible and usable alongside it. + +## Select and resize + +Click an experiment or scenario once to open its panel. The selected row has a shaded background and a dark left edge. With focus in the list, press **Up** or **Down** to select and open the previous or next row. Focus stays in the list so you can keep browsing with the keyboard. + +Drag the divider between the list and the panel to adjust their widths. The panel keeps your chosen width when you return from fullscreen. If the window or docked assistant leaves less room, the panel fits the available space while keeping part of the list visible. + +## Expand and return + +Click **Expand to fullscreen** in the panel header to cover the list while keeping the vertical tabs visible. The header shows the tab and resource names, such as **Scenarios › Mars Orbit**. Click **Show as panel** to return to the side-by-side layout. The same form or results stay open: unsaved edits, expanded charts, and scroll position carry across the resize. + +Each tab remembers its selected resource and panel or fullscreen presentation while the current model is open. Opening another model clears these remembered selections. Returning to a fullscreen resource shows it at full size immediately. Save scenario edits before switching tabs; unsaved form edits are not retained across tab changes. + +Fullscreen belongs to Simulate. Expanding a creation form from Edit opens its Simulate tab. Switching to Edit or Actual leaves the creation form in a side panel so the main view remains usable and your draft stays open. + +Expanding and collapsing animate the panel width and the tab name in the header when **Animations** is enabled in Settings. Both respect your system's reduced-motion preference. + +Both size controls and **Close panel** sit together on the header's right edge. Headers without a subtitle match the list header's height. Escape closes the panel while focus is inside it, unless an editor or menu handles Escape first. Closing an experiment's results leaves the experiment running. Save a scenario's edits before closing it or selecting another scenario to keep them. + +## Alongside the AI assistant + +A docked AI assistant takes space beside the main view and the simulation panel. Opening, closing, or resizing the assistant adjusts the space available to both. Fullscreen fills the main view beside the assistant. A floating assistant sits above the workspace. + +## Links and browser history + +On the Petrinaut website, the address includes the open experiment or scenario and whether it is fullscreen. Browser **Back** and **Forward** restore that view and its size. + +Switching to Edit or Actual keeps that mode on refresh or when sharing the link. The remembered resource and its size remain available when you return to Simulate. + +A scenario link requires the same model with that scenario saved. A link to a creation panel opens an empty form. Experiments belong to the current session; opening an experiment link after that session has ended shows an unavailable message and does not rerun it. diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx index 1ae59c12914..eadd2f1b5eb 100644 --- a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx @@ -620,3 +620,47 @@ describe("Petrinaut navigation", () => { }); }); }); + +test.each([null, { type: "metric", id: "metric-a" }] as const)( + "clears fullscreen when leaving a panel for %j", + (resource) => { + const Probe = () => { + const { state, navigate } = usePetrinautNavigation(); + return ( + <> + + {state.simulatePresentation ?? "panel"} + + + + ); + }; + const view = render( + + + , + ); + expect(screen.getByLabelText("Presentation").textContent).toBe( + "fullscreen", + ); + fireEvent.click(screen.getByRole("button", { name: "Leave panel" })); + expect(screen.getByLabelText("Presentation").textContent).toBe("panel"); + view.unmount(); + }, +); diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx index 97a9badf15e..1ee6184ff71 100644 --- a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx @@ -56,6 +56,8 @@ export type PetrinautNavigationState = { editView: EditViewMode; simulateView: SimulateViewMode; simulateResource: PetrinautSimulateResource | null; + /** Omission uses the panel presentation, including in existing host controllers. */ + simulatePresentation?: "panel" | "fullscreen"; scenarioId: string | null | undefined; subnetId: string | null; selection: readonly SelectionItem[]; @@ -82,6 +84,7 @@ export type PetrinautNavigationAction = | "edit-view" | "simulation-view" | "simulation-resource" + | "simulation-presentation" | "scenario" | "subnet" | "selection" @@ -182,6 +185,8 @@ export const petrinautNavigationStatesMatch = ( left.simulateView === right.simulateView && left.simulateResource?.type === right.simulateResource?.type && left.simulateResource?.id === right.simulateResource?.id && + (left.simulatePresentation ?? "panel") === + (right.simulatePresentation ?? "panel") && left.scenarioId === right.scenarioId && left.subnetId === right.subnetId && selectionsMatch(left.selection, right.selection) && @@ -211,6 +216,13 @@ const resolveNavigationUpdate = ( scopeChanged && updated.expandedSubView === current.expandedSubView ? null : updated.expandedSubView, + simulatePresentation: + updated.simulateResource?.type === "scenario" || + updated.simulateResource?.type === "experiment" || + updated.overlay?.type === "create-experiment" || + updated.overlay?.type === "create-scenario" + ? updated.simulatePresentation + : undefined, }; }; @@ -228,6 +240,32 @@ export const PetrinautNavigationProvider = ({ selection: canonicalizeSelection(initialState?.selection ?? []), })); const state = controller?.state ?? uncontrolledState; + const [simulationVisits, setSimulationVisits] = useState< + Partial< + Record< + SimulateViewMode, + Pick< + PetrinautNavigationState, + "simulateResource" | "simulatePresentation" + > + > + > + >({}); + const visit = simulationVisits[state.simulateView]; + if ( + visit === undefined || + visit.simulateResource?.type !== state.simulateResource?.type || + visit.simulateResource?.id !== state.simulateResource?.id || + visit.simulatePresentation !== state.simulatePresentation + ) { + setSimulationVisits({ + ...simulationVisits, + [state.simulateView]: { + simulateResource: state.simulateResource, + simulatePresentation: state.simulatePresentation, + }, + }); + } /** * React normally rerenders after navigation, but several UI libraries emit * related callbacks in the same event. Track the state those accepted @@ -268,7 +306,24 @@ export const PetrinautNavigationProvider = ({ ) => { const updater: PetrinautNavigationUpdater = ( current, - ) => resolveNavigationUpdate(current, update); + ) => { + const next = resolveNavigationUpdate(current, update); + if ( + intent.cause === "user" && + intent.action === "simulation-view" && + next.simulateView !== current.simulateView + ) { + return { + ...next, + simulateResource: + simulationVisits[next.simulateView]?.simulateResource ?? null, + simulatePresentation: + simulationVisits[next.simulateView]?.simulatePresentation, + overlay: null, + }; + } + return next; + }; const optimistic = optimisticRef.current; const current = optimistic?.preview ?? state; const preview = updater(current); diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index 2643a61d4fd..3df288b8823 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -510,10 +510,18 @@ export const EditorProvider: React.FC = ({ children }) => { setHiddenTimelineSeriesIds: (seriesIds) => setState((prev) => ({ ...prev, hiddenTimelineSeriesIds: seriesIds })), setSimulateViewMode: (mode) => - navigateTo({ - simulateViewMode: mode, - simulateDrawer: { type: "closed" }, - }), + navigation.navigate( + (current) => + current.simulateView === mode + ? current + : { + ...current, + simulateView: mode, + simulateResource: null, + overlay: null, + }, + { cause: "user", action: "simulation-view" }, + ), setSimulateDrawer: (drawer) => navigateTo({ simulateDrawer: drawer }), setSearchOpen: (isOpen) => { scheduleAnimationEnd(); diff --git a/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx index aaa50a9f6fa..c43f72e06f1 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/table.test.tsx @@ -50,13 +50,14 @@ const focusRow = (text: string): HTMLElement => { describe("Table keyboard flow", () => { it("is one tab stop whose rows the arrows walk", () => { + const onRowSelect = vi.fn(); const { container } = render( row.id} emptyLabel="Empty" - onRowSelect={() => {}} + onRowSelect={onRowSelect} />, ); @@ -65,15 +66,20 @@ describe("Table keyboard flow", () => { focusRow("First"); fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); expect(document.activeElement).toBe(rowShowing("Second")); + expect(onRowSelect).toHaveBeenLastCalledWith(ROWS[1]); fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); expect(document.activeElement).toBe(rowShowing("Third")); + expect(onRowSelect).toHaveBeenLastCalledWith(ROWS[2]); + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + expect(onRowSelect).toHaveBeenCalledTimes(2); fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" }); expect(document.activeElement).toBe(rowShowing("Second")); + expect(onRowSelect).toHaveBeenLastCalledWith(ROWS[1]); expect(container.querySelectorAll("[tabindex='0']")).toHaveLength(1); }); - it("activates select-first: the first click selects, the second opens", () => { + it("opens on the first click and keeps focus on that row", () => { const onRowSelect = vi.fn(); render(
{ ); const row = rowShowing("Second"); - fireEvent.pointerDown(row); - focusRow("Second"); - fireEvent.click(row, { detail: 1 }); - expect(onRowSelect).not.toHaveBeenCalled(); - fireEvent.pointerDown(row); fireEvent.click(row, { detail: 1 }); - expect(onRowSelect).toHaveBeenCalledWith(ROWS[1]); + expect(onRowSelect).toHaveBeenCalledExactlyOnceWith(ROWS[1]); + expect(document.activeElement).toBe(row); }); it("activates on Enter and Space", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/components/table.tsx b/libs/@hashintel/petrinaut/src/ui/components/table.tsx index eb969afa732..7b26da20996 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/table.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/table.tsx @@ -4,7 +4,6 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { focusLands } from "../worksheet/focus-flow"; import { useFocusStops } from "../worksheet/use-focus-stops"; -import { useSelectFirstActivation } from "../worksheet/use-select-first"; import type { FocusStop, FocusStopTarget } from "../worksheet/use-focus-stops"; import type { CSSProperties, ReactNode } from "react"; @@ -88,16 +87,21 @@ const tableRowStyle = css({ }); const selectedRowStyle = css({ - backgroundColor: "neutral.s05", + "&[aria-selected=true]": { + backgroundColor: "neutral.s30", + boxShadow: "[inset 3px 0 0 {colors.neutral.s120}]", + "& [role=cell]": { color: "neutral.s120" }, + "& [role=cell] > span": { color: "neutral.s120" }, + _hover: { backgroundColor: "neutral.s40" }, + }, }); const selectableTableRowStyle = css({ cursor: "pointer", outline: "none", - // Select-first needs the focused row visible to pointer users too, so the - // ring shows on any focus rather than only `:focus-visible`. - _focus: { - boxShadow: "[inset 0 0 0 2px {colors.neutral.a25}]", + _focusVisible: { + outline: "[2px solid {colors.neutral.s90}]", + outlineOffset: "[-2px]", }, }); @@ -165,8 +169,7 @@ const renderCellContent = ( /** * A read-only data table. With `onRowSelect` its rows follow the worksheet * keyboard flow: the table is one Tab stop, ArrowUp/ArrowDown walk the rows, - * and activation is select-first (the first click focuses a row, a click on - * the focused row or Enter/Space calls `onRowSelect`). + * and a click or arrow move selects and opens the focused row. */ export function Table({ columns, @@ -185,9 +188,17 @@ export function Table({ const { onKeyDown, onFocusTarget, tabIndexFor, attach } = useFocusStops({ stops, columnCount: 1, - focusTarget: (target) => focusLands(targets.current.get(target.stopId)), + focusTarget: (target) => { + const focused = focusLands(targets.current.get(target.stopId)); + const row = rows.find( + (candidate) => getRowId(candidate) === target.stopId, + ); + if (focused && row !== undefined && target.stopId !== selectedRowId) { + onRowSelect?.(row); + } + return focused; + }, }); - const { onPointerDown, shouldActivate } = useSelectFirstActivation(); if (rows.length === 0) { return
{emptyLabel}
; @@ -214,11 +225,9 @@ export function Table({ onFocusTarget(target); } }, - onPointerDown, onClick: (event: React.MouseEvent) => { - if (shouldActivate(event)) { - select(row); - } + event.currentTarget.focus(); + select(row); }, onKeyDown: (event: React.KeyboardEvent) => { if (event.target !== event.currentTarget) { diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.test.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.test.tsx index f97faacbf04..fcba21a017e 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.test.tsx @@ -1,15 +1,24 @@ /** * @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { useState } from "react"; import { afterEach, describe, expect, test, vi } from "vitest"; vi.mock("./views/Editor/editor-view", async () => { - const [{ use }, { SDCPNContext }, { TopBar }] = await Promise.all([ - import("react"), - import("../react/state/sdcpn-context"), - import("./views/Editor/components/TopBar/top-bar"), - ]); + const [{ use }, { SDCPNContext }, { TopBar }, { usePetrinautNavigation }] = + await Promise.all([ + import("react"), + import("../react/state/sdcpn-context"), + import("./views/Editor/components/TopBar/top-bar"), + import("../react/navigation"), + ]); return { EditorView: ({ titleEditable }: { titleEditable: boolean }) => { @@ -18,6 +27,7 @@ vi.mock("./views/Editor/editor-view", async () => { title, titleEditable: contextTitleEditable, } = use(SDCPNContext); + const { state, navigate } = usePetrinautNavigation(); return (
{ mode="edit" onModeChange={() => {}} /> + + {(["experiments", "scenarios"] as const).map((view) => ( + + ))} + + {state.simulateResource?.id ?? "none"}/ + {state.simulatePresentation ?? "panel"} +
); }, @@ -41,6 +85,7 @@ vi.mock("./views/Editor/editor-view", async () => { import { createJsonDocHandle, type SDCPN } from "@hashintel/petrinaut-core"; +import { defaultPetrinautNavigationState } from "../react/navigation"; import { Petrinaut } from "./petrinaut"; class ObserverStub { @@ -61,16 +106,25 @@ class WorkerStub extends EventTarget { globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver; globalThis.Worker = WorkerStub as unknown as typeof Worker; -const emptySdcpn: SDCPN = { +const testSdcpn: SDCPN = { places: [], transitions: [], types: [], differentialEquations: [], parameters: [], + scenarios: [ + { + id: "scenario-1", + name: "Scenario", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }, + ], }; const createHandle = () => - createJsonDocHandle({ initial: structuredClone(emptySdcpn) }); + createJsonDocHandle({ initial: structuredClone(testSdcpn) }); afterEach(cleanup); @@ -107,3 +161,54 @@ describe("Petrinaut title editing", () => { expect(setTitle.mock.calls[0]?.[0]).toBe("Renamed model"); }); }); + +test.each([false, true])( + "clears remembered simulation tabs when opening another document (controlled: %s)", + async (controlled) => { + const Editor = () => { + const [handle, setHandle] = useState(createHandle); + const [state, setState] = useState(defaultPetrinautNavigationState); + return ( + <> + + setState(update) } + : undefined + } + /> + + ); + }; + render(); + const location = () => + screen.getByLabelText("Simulation location").textContent; + fireEvent.click(screen.getByRole("button", { name: "Open scenario" })); + await waitFor(() => expect(location()).toBe("scenario-1/fullscreen")); + fireEvent.click(screen.getByRole("button", { name: "experiments" })); + await waitFor(() => expect(location()).toBe("none/panel")); + fireEvent.click(screen.getByRole("button", { name: "scenarios" })); + await waitFor(() => expect(location()).toBe("scenario-1/fullscreen")); + fireEvent.click(screen.getByRole("button", { name: "experiments" })); + fireEvent.click( + screen.getByRole("button", { name: "Open another document" }), + ); + fireEvent.click(screen.getByRole("button", { name: "scenarios" })); + await waitFor(() => expect(location()).toBe("none/panel")); + }, +); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index c6467f57b28..bda6f91b058 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -48,7 +48,7 @@ const toolbarContainerStyle = css({ gap: "1", }); -// Spans the editor so the bar centres on the viewport rather than on the space +// Spans the main view so the bar centres on the viewport rather than on the space // between the panels, and lets clicks through everywhere the bar itself is not. const bottomBarLaneStyle = css({ position: "absolute", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx index 7bcc8265c82..940a411aabf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.test.tsx @@ -8,14 +8,17 @@ import { useBottomBarLayout } from "./use-bottom-bar-layout"; const laneRef = createRef(); const barRef = createRef(); +let laneWidth = 2000; vi.mock("../../../../../react/hooks/use-element-size", () => ({ - useElementSize: (ref: unknown) => ({ width: ref === laneRef ? 2000 : 600 }), + useElementSize: (ref: unknown) => ({ + width: ref === laneRef ? laneWidth : 600, + }), })); afterEach(cleanup); -it("centers on the full editor and moves only to clear occupied space", () => { +it("centers in the available main view as sibling panels resize it", () => { const defaults = renderHook(() => use(EditorContext)).result.current; let editor = { ...defaults, @@ -41,15 +44,18 @@ it("centers on the full editor and moves only to clear occupied space", () => { ); expect(result.current.offsetX).toBe(0); editor = { ...editor, isAiAssistantOpen: true }; + laneWidth = 1580; rerender(); expect(result.current.offsetX).toBe(0); expect(result.current.isCollapsed).toBe(false); editor = { ...editor, hasSelection: true }; + laneWidth = 1200; rerender(); - expect(result.current.offsetX).toBe(-182); + expect(result.current.offsetX).toBe(-162); editor = { ...editor, aiAssistantPlacement: "floating" }; + laneWidth = 2000; rerender(); expect(result.current.offsetX).toBe(0); @@ -58,9 +64,11 @@ it("centers on the full editor and moves only to clear occupied space", () => { aiAssistantPlacement: "docked", aiAssistantWidth: 1000, }; + laneWidth = 1000; rerender(); expect(result.current.isCollapsed).toBe(true); editor = { ...editor, isAiAssistantOpen: false }; + laneWidth = 2000; rerender(); expect(result.current.offsetX).toBe(0); expect(result.current.isCollapsed).toBe(false); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts index ae4eaac3cd5..5a535e54ff3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts @@ -1,7 +1,6 @@ -import { use, useCallback, useState } from "react"; +import { useCallback, useState } from "react"; import { useElementSize } from "../../../../../react/hooks/use-element-size"; -import { EditorContext } from "../../../../../react/state/editor-context"; import { VIEWPORT_CONTROLS_CLEARANCE } from "../../../../constants/ui"; import { useCanvasInsets } from "../../../../hooks/use-canvas-insets"; import { fitsWithinBounds, getBottomBarOffset } from "./bottom-bar-placement"; @@ -37,7 +36,7 @@ export interface BottomBarLayout { * the way mirrored state does. */ export const useBottomBarLayout = ( - /** Spans the editor; the bar is centred in it and measured against it. */ + /** Spans the main view; the bar is centred in it and measured against it. */ laneRef: React.RefObject, barRef: React.RefObject, { @@ -85,18 +84,6 @@ export const useBottomBarLayout = ( ); const insets = useCanvasInsets(); - const { - isAiAssistantOpen, - aiAssistantPlacement, - isAiAssistantCollapsed, - aiAssistantWidth, - } = use(EditorContext); - const dockedAssistantWidth = - isAiAssistantOpen && - aiAssistantPlacement === "docked" && - !isAiAssistantCollapsed - ? Math.min(aiAssistantWidth, containerWidth) - : 0; const bounds = { containerWidth, leftInset: insets.left, @@ -104,9 +91,7 @@ export const useBottomBarLayout = ( // so they bound it the same way a panel does. They are absent in actual // mode, where `SDCPNCanvas` does not render them. rightInset: - dockedAssistantWidth + - insets.right + - (hasViewportControls ? VIEWPORT_CONTROLS_CLEARANCE : 0), + insets.right + (hasViewportControls ? VIEWPORT_CONTROLS_CLEARANCE : 0), margin: BOTTOM_BAR_MARGIN, }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx index 1b31f539c3a..0143cb5992f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx @@ -21,6 +21,9 @@ const lifecycle = vi.hoisted(() => ({ vi.mock("../../../react", () => ({ usePetrinautCommands: () => ({ applyAutoLayout: vi.fn() }), })); +vi.mock("../../../react/hooks/use-element-size", () => ({ + useElementSize: () => ({ width: 1200, height: 800 }), +})); vi.mock("../../../react/state/use-selection-cleanup", () => ({ useSelectionCleanup: () => {}, })); @@ -37,6 +40,7 @@ vi.mock("./panels/ai-assistant-panel", () => ({ }, })); vi.mock("./panels/SimulateView/simulate-view", () => ({ + SimulateViewTabs: () =>
row.id }]} + emptyLabel="No scenarios" + rows={rows} + getRowId={(row) => row.id} + selectedRowId={selected?.id} + onRowSelect={(row) => + navigate( + { simulateResource: { type: "scenario", id: row.id } }, + { cause: "user", action: "simulation-resource" }, + ) + } + /> + {selected && ( + + navigate( + { simulateResource: null }, + { cause: "user", action: "simulation-resource" }, + ) + } + > + + + )} + + ); + }; + render( + + + , + ); + const moonRow = screen.getByRole("row", { name: "Moon" }); + fireEvent.click(moonRow, { detail: 1 }); + expect(screen.getByRole("region", { name: "Moon" })).toBeDefined(); + expect(document.activeElement).toBe(moonRow); + fireEvent.keyDown(moonRow, { key: "ArrowDown" }); + const earthRow = screen.getByRole("row", { name: "Earth" }); + expect(screen.getByRole("region", { name: "Earth" })).toBeDefined(); + expect(earthRow.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(earthRow); + fireEvent.keyDown(earthRow, { key: "ArrowDown" }); + const marsRow = screen.getByRole("row", { name: "Mars" }); + expect(screen.getByRole("region", { name: "Mars" })).toBeDefined(); + expect(document.activeElement).toBe(marsRow); + fireEvent.keyDown(marsRow, { key: "ArrowUp" }); + expect(document.activeElement).toBe(earthRow); + expect(screen.getByRole("region", { name: "Earth" })).toBeDefined(); + const close = screen.getByRole("button", { name: "Close panel" }); + close.focus(); + fireEvent.click(close); + expect(screen.queryByRole("region")).toBeNull(); + expect(document.activeElement).toBe(earthRow); + expect(earthRow.getAttribute("aria-selected")).toBe("false"); +}); + +const NavigationProbe = () => { + const { state } = usePetrinautNavigation(); + return ( + <> + + {state.simulatePresentation ?? "panel"} + + + {state.mode}/{state.simulateView}/{state.simulateResource?.id ?? "none"} + + + ); +}; + +it("keeps edited content, scroll and focus in the same workspace panel when its route expands and contracts", async () => { + const view = render( + + + {}} layer="creation"> + + + + + + +