From 6ff7f650c4a942ab43470892d9e3cffd4a2f4fef Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 28 May 2026 18:57:40 +0100 Subject: [PATCH 01/18] add /processes page to hash --- apps/hash-frontend/next.config.js | 1 + apps/hash-frontend/package.json | 1 + apps/hash-frontend/src/pages/process.page.tsx | 26 -- .../process.page/process-editor-wrapper.tsx | 283 ----------------- .../use-process-save-and-load.tsx | 286 ------------------ .../use-persisted-nets.ts | 92 ------ .../use-petri-net-revisions.ts | 109 ------- .../process-editor-wrapper/version-picker.tsx | 269 ---------------- .../layout/layout-with-sidebar/sidebar.tsx | 7 + apps/petrinaut-website/src/main/app.tsx | 1 - .../src/ui/petrinaut-story-provider.tsx | 4 +- .../petrinaut/src/ui/petrinaut.stories.tsx | 4 +- .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 14 +- .../petrinaut/src/ui/types/petrinaut-slots.ts | 5 + .../Editor/components/TopBar/top-bar.tsx | 6 +- .../src/ui/views/Editor/editor-view.tsx | 14 +- .../legacy-base-tsconfig-to-refactor.json | 3 + yarn.lock | 1 + 18 files changed, 47 insertions(+), 1079 deletions(-) delete mode 100644 apps/hash-frontend/src/pages/process.page.tsx delete mode 100644 apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx delete mode 100644 apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx delete mode 100644 apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts delete mode 100644 apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-petri-net-revisions.ts delete mode 100644 apps/hash-frontend/src/pages/process.page/process-editor-wrapper/version-picker.tsx diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 19ce65f5d5c..9a2ade0d671 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -165,6 +165,7 @@ export default withSentryConfig( "@hashintel/block-design-system", "@hashintel/design-system", "@hashintel/petrinaut", + "@hashintel/petrinaut-core", "@hashintel/ds-components", "@hashintel/ds-helpers", "@hashintel/type-editor", diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index 31ae59c86fc..14cb07a672a 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -39,6 +39,7 @@ "@hashintel/ds-components": "workspace:*", "@hashintel/ds-helpers": "workspace:*", "@hashintel/petrinaut": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", "@hashintel/query-editor": "workspace:*", "@hashintel/type-editor": "workspace:*", "@lit-labs/react": "1.2.1", diff --git a/apps/hash-frontend/src/pages/process.page.tsx b/apps/hash-frontend/src/pages/process.page.tsx deleted file mode 100644 index 01f33062112..00000000000 --- a/apps/hash-frontend/src/pages/process.page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import dynamic from "next/dynamic"; - -import { getLayoutWithSidebar } from "../shared/layout"; - -import type { NextPageWithLayout } from "../shared/layout"; - -// Petrinaut uses Web Workers, Canvas, Monaco Editor, and the TypeScript compiler -// which all require browser APIs — must not be server-rendered. -const ProcessEditorWrapper = dynamic( - () => - import("./process.page/process-editor-wrapper").then((mod) => ({ - default: mod.ProcessEditorWrapper, - })), - { ssr: false }, -); - -const ProcessPage: NextPageWithLayout = () => { - return ; -}; - -ProcessPage.getLayout = (page) => - getLayoutWithSidebar(page, { - fullWidth: true, - }); - -export default ProcessPage; diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx deleted file mode 100644 index 9a04914bd1c..00000000000 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import "@hashintel/petrinaut/dist/main.css"; -import { Box, Stack } from "@mui/material"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { AlertModal } from "@hashintel/design-system"; -import { Button } from "@hashintel/ds-components"; -import { - createJsonDocHandle, - Petrinaut, - type PetrinautDocHandle, - type PetrinautSlots, - type SDCPN, -} from "@hashintel/petrinaut"; - -import { - type PersistedNet, - useProcessSaveAndLoad, -} from "./process-editor-wrapper/use-process-save-and-load"; -import { - type PetriNetRevision, - usePetriNetRevisions, -} from "./process-editor-wrapper/use-process-save-and-load/use-petri-net-revisions"; -import { VersionPicker } from "./process-editor-wrapper/version-picker"; - -import type { EntityId } from "@blockprotocol/type-system"; - -const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], -}; - -/** - * Helper to ensure that we copy all fields of the SDCPN when loading a revision. - */ -const SDCPN_FIELDS = { - places: true, - transitions: true, - types: true, - differentialEquations: true, - parameters: true, - scenarios: true, - metrics: true, -} as const satisfies Record; - -/** - * Mirror a single SDCPN field from `source` onto `target`. - */ -const copySdcpnField = ( - target: SDCPN, - source: SDCPN, - key: K, -): void => { - /* eslint-disable no-param-reassign -- mutating the Immer draft is - the whole point of this helper. */ - if (source[key] === undefined) { - delete (target as Partial)[key]; - } else { - target[key] = source[key]; - } - /* eslint-enable no-param-reassign */ -}; - -export const ProcessEditorWrapper = () => { - const [selectedNetId, setSelectedNetId] = useState(null); - const [title, setTitle] = useState("Process"); - - /** - * The handle is the source of truth for the current net's document. A - * fresh handle is created when the user loads a different persisted net - * or asks for a new empty one — this naturally resets undo/redo history. - */ - const [handle, setHandle] = useState(() => - createJsonDocHandle({ initial: emptySDCPN }), - ); - - /** - * Mirror of the handle's current document, kept in React state so the - * save/load logic can read it as a plain SDCPN (for `isDirty` checks and - * persisting to the graph). Updated synchronously when the handle changes - * via `handle.subscribe`. - */ - const [petriNetDefinition, setPetriNetDefinition] = useState( - () => handle.doc() ?? emptySDCPN, - ); - useEffect(() => { - setPetriNetDefinition(handle.doc() ?? emptySDCPN); - return handle.subscribe((event) => { - setPetriNetDefinition(event.next); - }); - }, [handle]); - - const setPetriNet = useCallback((sdcpn: SDCPN) => { - setHandle(createJsonDocHandle({ initial: sdcpn })); - }, []); - - const [switchTargetPendingConfirmation, setSwitchTargetPendingConfirmation] = - useState(null); - - /** - * Decision-time of the server revision currently mirrored in the editor. - */ - const [loadedRevisionTime, setLoadedRevisionTime] = useState( - null, - ); - - const { revisions, refetch: refetchRevisions } = - usePetriNetRevisions(selectedNetId); - - const { - isDirty, - loadPersistedNet, - persistedNets, - persistPending, - persistToGraph, - userEditable, - setUserEditable, - } = useProcessSaveAndLoad({ - petriNet: petriNetDefinition, - refetchRevisions, - selectedNetId, - setLoadedRevisionTime, - setPetriNet, - setSelectedNetId, - setTitle, - title, - }); - - const createNewNet = useCallback( - ({ - petriNetDefinition: newPetriNetDefinition, - title: newTitle, - }: { - petriNetDefinition: SDCPN; - title: string; - }) => { - setPetriNet(newPetriNetDefinition); - setSelectedNetId(null); - setUserEditable(true); - setTitle(newTitle); - setLoadedRevisionTime(null); - }, - [setPetriNet, setSelectedNetId, setUserEditable, setTitle], - ); - - /** - * Replace the editor state with a past revision of the active entity. - * Doesn't change `selectedNetId` — it's still the same entity, just - * pinned to an older decision time. Subsequent edits + save create a - * new top revision on the existing baseId (linear-edit model). - * - * Mutates the existing handle in place via `change()` rather than - * recreating it through `setPetriNet`. A fresh handle would force a - * full editor remount (Petrinaut keys worker providers on `handle.id`). - */ - const loadRevision = useCallback( - (revision: PetriNetRevision) => { - handle.change((draft) => { - for (const key of Object.keys(SDCPN_FIELDS) as (keyof SDCPN)[]) { - copySdcpnField(draft, revision.definition, key); - } - }); - setTitle(revision.title); - setLoadedRevisionTime(revision.decisionTime); - }, - [handle, setTitle], - ); - - const loadNetFromId = useCallback( - (netId: EntityId) => { - const foundNet = persistedNets.find((net) => net.entityId === netId); - - if (!foundNet) { - throw new Error(`Net ${netId} not found`); - } - - if (isDirty) { - setSwitchTargetPendingConfirmation(foundNet); - } else { - loadPersistedNet(foundNet); - } - }, - [isDirty, loadPersistedNet, persistedNets], - ); - - const existingNetOptions = useMemo(() => { - return persistedNets - .filter((net) => net.userEditable && net.entityId !== selectedNetId) - .map((net) => ({ - netId: net.entityId, - title: net.title, - lastUpdated: net.lastUpdated, - })); - }, [persistedNets, selectedNetId]); - - /** - * Top-bar content injected into Petrinaut via the `slots` API: - * - `VersionPicker` — shows the active server revision (vN), a `Draft` - * badge when local edits diverge from the latest revision, and a - * dropdown to browse history. Hidden entirely when there are no - * saved revisions yet (i.e. brand-new net). - * - The Save/Create button — disabled until there's something to save. - * - * Hidden when the active net is not user-editable; we don't surface a - * "save as copy" affordance from here today. - */ - const slots = useMemo(() => { - if (!userEditable) { - return {}; - } - - return { - topBarEnd: ( - <> - - - - ), - }; - }, [ - isDirty, - loadRevision, - loadedRevisionTime, - persistPending, - persistToGraph, - revisions, - selectedNetId, - userEditable, - ]); - - return ( - - {switchTargetPendingConfirmation && ( - { - setSwitchTargetPendingConfirmation(null); - loadPersistedNet(switchTargetPendingConfirmation); - }} - calloutMessage="You have unsaved changes which will be discarded. Are you sure you want to switch to another net?" - confirmButtonText="Switch" - contentStyle={{ - maxWidth: 450, - }} - header="Switch and discard changes?" - open - close={() => setSwitchTargetPendingConfirmation(null)} - type="warning" - /> - )} - - - loadNetFromId(id as EntityId)} - readonly={!userEditable} - setTitle={setTitle} - slots={slots} - title={title} - /> - - - ); -}; diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx deleted file mode 100644 index ef87142fbcf..00000000000 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { useMutation } from "@apollo/client"; -import { - type Dispatch, - type SetStateAction, - useCallback, - useMemo, - useState, -} from "react"; - -import { isSDCPNEqual } from "@hashintel/petrinaut"; -import { HashEntity } from "@local/hash-graph-sdk/entity"; -import { - blockProtocolDataTypes, - systemEntityTypes, - systemPropertyTypes, -} from "@local/hash-isomorphic-utils/ontology-type-ids"; - -import { - createEntityMutation, - updateEntityMutation, -} from "../../../graphql/queries/knowledge/entity.queries"; -import { useActiveWorkspace } from "../../shared/workspace-context"; -import { - getPersistedNetsFromSubgraph, - usePersistedNets, -} from "./use-process-save-and-load/use-persisted-nets"; - -import type { - CreateEntityMutation, - CreateEntityMutationVariables, - UpdateEntityMutation, - UpdateEntityMutationVariables, -} from "../../../graphql/api-types.gen"; -import type { - EntityId, - PropertyObjectWithMetadata, -} from "@blockprotocol/type-system"; -import type { SDCPN } from "@hashintel/petrinaut"; -import type { PetriNetPropertiesWithMetadata } from "@local/hash-isomorphic-utils/system-types/petrinet"; - -export type PersistedNet = { - entityId: EntityId; - title: string; - definition: SDCPN; - userEditable: boolean; - lastUpdated: string; -}; - -type UseProcessSaveAndLoadParams = { - petriNet: SDCPN; - selectedNetId: EntityId | null; - /** - * Replace the entire active net with a new SDCPN. Internally the consumer - * recreates the document handle, which resets undo/redo history — so this - * is intended for net-switch / load flows, not user mutations. - */ - setPetriNet: (sdcpn: SDCPN) => void; - setSelectedNetId: Dispatch>; - setLoadedRevisionTime: Dispatch>; - setTitle: Dispatch>; - title: string; - refetchRevisions: () => Promise; -}; - -export const useProcessSaveAndLoad = ({ - petriNet, - selectedNetId, - setSelectedNetId, - setLoadedRevisionTime, - setPetriNet, - setTitle, - title, - refetchRevisions, -}: UseProcessSaveAndLoadParams): { - isDirty: boolean; - loadPersistedNet: (net: PersistedNet) => void; - persistedNets: PersistedNet[]; - persistPending: boolean; - persistToGraph: () => void; - setUserEditable: Dispatch>; - userEditable: boolean; -} => { - const { persistedNets, refetch } = usePersistedNets(); - - const { activeWorkspaceWebId } = useActiveWorkspace(); - - const [persistPending, setPersistPending] = useState(false); - - const [userEditable, setUserEditable] = useState(true); - - const [createEntity] = useMutation< - CreateEntityMutation, - CreateEntityMutationVariables - >(createEntityMutation); - - const [updateEntity] = useMutation< - UpdateEntityMutation, - UpdateEntityMutationVariables - >(updateEntityMutation); - - const persistedNet = useMemo(() => { - return persistedNets.find((net) => net.entityId === selectedNetId); - }, [persistedNets, selectedNetId]); - - const isDirty = useMemo(() => { - if (!persistedNet) { - return true; - } - - return ( - title !== persistedNet.title || - !isSDCPNEqual(petriNet, persistedNet.definition) - ); - }, [petriNet, persistedNet, title]); - - const loadPersistedNet = useCallback( - (net: PersistedNet) => { - setSelectedNetId(net.entityId); - setPetriNet(net.definition); - setTitle(net.title); - setUserEditable(net.userEditable); - setLoadedRevisionTime(net.lastUpdated); - }, - [ - setLoadedRevisionTime, - setPetriNet, - setSelectedNetId, - setTitle, - setUserEditable, - ], - ); - - const refetchPersistedNets = useCallback( - async ({ updatedEntityId }: { updatedEntityId: EntityId | null }) => { - const [updatedNetsData] = await Promise.all([ - refetch(), - // For updates `selectedNetId` is unchanged, so Apollo won't - // auto-refire the history query — kick it explicitly. For creates - // it's a benign duplicate (Apollo deduplicates by variables). - refetchRevisions(), - ]); - - const transformedNets = getPersistedNetsFromSubgraph( - updatedNetsData.data, - ); - - if (updatedEntityId) { - const updatedNet = transformedNets.find( - (net) => net.entityId === updatedEntityId, - ); - - if (updatedNet) { - setSelectedNetId(updatedNet.entityId); - setUserEditable(updatedNet.userEditable); - setLoadedRevisionTime(updatedNet.lastUpdated); - } - } - }, - [ - refetch, - refetchRevisions, - setLoadedRevisionTime, - setSelectedNetId, - setUserEditable, - ], - ); - - const persistToGraph = useCallback(async () => { - if (!activeWorkspaceWebId) { - return; - } - - setPersistPending(true); - - try { - let persistedEntityId = selectedNetId; - - if (selectedNetId) { - await updateEntity({ - variables: { - entityUpdate: { - entityId: selectedNetId, - propertyPatches: [ - { - op: "replace", - path: [ - systemPropertyTypes.definitionObject.propertyTypeBaseUrl, - ], - property: { - metadata: { - dataTypeId: blockProtocolDataTypes.object.dataTypeId, - }, - value: petriNet, - }, - }, - { - op: "replace", - path: [systemPropertyTypes.title.propertyTypeBaseUrl], - property: { - metadata: { - dataTypeId: blockProtocolDataTypes.text.dataTypeId, - }, - value: title, - }, - }, - ], - }, - }, - }); - } else { - const createdEntityData = await createEntity({ - variables: { - entityTypeIds: [systemEntityTypes.petriNet.entityTypeId], - webId: activeWorkspaceWebId, - properties: { - value: { - "https://hash.ai/@h/types/property-type/definition-object/": { - metadata: { - dataTypeId: blockProtocolDataTypes.object.dataTypeId, - }, - value: petriNet, - }, - "https://hash.ai/@h/types/property-type/title/": { - metadata: { - dataTypeId: blockProtocolDataTypes.text.dataTypeId, - }, - value: title, - }, - }, - } satisfies PetriNetPropertiesWithMetadata as PropertyObjectWithMetadata, - }, - }); - - if (!createdEntityData.data?.createEntity) { - throw new Error("Failed to create petri net"); - } - - const createdEntity = new HashEntity( - createdEntityData.data.createEntity, - ); - - persistedEntityId = createdEntity.entityId; - } - - if (!persistedEntityId) { - throw new Error("Somehow no entityId available after persisting net"); - } - - await refetchPersistedNets({ updatedEntityId: persistedEntityId }); - setSelectedNetId(persistedEntityId); - setUserEditable(true); - } finally { - setPersistPending(false); - } - }, [ - activeWorkspaceWebId, - createEntity, - petriNet, - refetchPersistedNets, - selectedNetId, - setSelectedNetId, - title, - updateEntity, - ]); - - return useMemo( - () => ({ - isDirty, - loadPersistedNet, - persistedNets, - persistPending, - persistToGraph, - setUserEditable, - userEditable, - }), - [ - isDirty, - loadPersistedNet, - persistPending, - persistedNets, - persistToGraph, - setUserEditable, - userEditable, - ], - ); -}; diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts deleted file mode 100644 index d3a1d20fbcb..00000000000 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-persisted-nets.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useQuery } from "@apollo/client"; -import { useMemo } from "react"; - -import { getRoots } from "@blockprotocol/graph/stdlib"; -import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; -import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; -import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; - -import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries"; - -import type { - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables, -} from "../../../../graphql/api-types.gen"; -import type { PersistedNet } from "../use-process-save-and-load"; -import type { SDCPN } from "@hashintel/petrinaut"; -import type { PetriNet } from "@local/hash-isomorphic-utils/system-types/petrinet"; - -export const getPersistedNetsFromSubgraph = ( - data: QueryEntitySubgraphQuery, -): PersistedNet[] => { - const subgraph = deserializeQueryEntitySubgraphResponse( - data.queryEntitySubgraph, - ).subgraph; - - const nets = getRoots(subgraph); - - return nets.map((net) => { - const netTitle = - net.properties["https://hash.ai/@h/types/property-type/title/"]; - - const rawDefinition = - net.properties[ - "https://hash.ai/@h/types/property-type/definition-object/" - ]; - - const definition = rawDefinition as SDCPN; - - const userEditable = - !!data.queryEntitySubgraph.entityPermissions?.[net.entityId]?.update; - - const lastUpdated = - net.metadata.temporalVersioning.decisionTime.start.limit; - - return { - entityId: net.entityId, - title: netTitle, - definition, - userEditable, - lastUpdated, - }; - }); -}; - -export const usePersistedNets = () => { - const { data, refetch } = useQuery< - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables - >(queryEntitySubgraphQuery, { - variables: { - request: { - filter: { - equal: [ - { - path: ["type", "versionedUrl"], - }, - { - parameter: systemEntityTypes.petriNet.entityTypeId, - }, - ], - }, - traversalPaths: [], - includeDrafts: false, - temporalAxes: currentTimeInstantTemporalAxes, - includePermissions: true, - }, - }, - }); - - const persistedNets = useMemo(() => { - if (!data) { - return []; - } - - return getPersistedNetsFromSubgraph(data); - }, [data]); - - return { - persistedNets, - refetch, - }; -}; diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-petri-net-revisions.ts b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-petri-net-revisions.ts deleted file mode 100644 index 9cfe9c4a8f1..00000000000 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/use-process-save-and-load/use-petri-net-revisions.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useQuery } from "@apollo/client"; -import { useCallback, useMemo } from "react"; - -import { getRoots } from "@blockprotocol/graph/stdlib"; -import { splitEntityId } from "@blockprotocol/type-system"; -import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; -import { fullDecisionTimeAxis } from "@local/hash-isomorphic-utils/graph-queries"; - -import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries"; - -import type { - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables, -} from "../../../../graphql/api-types.gen"; -import type { EntityId } from "@blockprotocol/type-system"; -import type { SDCPN } from "@hashintel/petrinaut"; -import type { PetriNet } from "@local/hash-isomorphic-utils/system-types/petrinet"; - -/** - * One past revision of a persisted Petri net entity. Index 0 in the - * returned array is the most recent; the last entry is the oldest. - */ -export type PetriNetRevision = { - /** - * ISO timestamp at which this revision started being the truth. - * Doubles as a stable key for React lists and the "decision-time" - * coordinate for downstream queries. - */ - decisionTime: string; - title: string; - definition: SDCPN; -}; - -/** - * Fetches every revision of a single Petri net entity. - */ -export const usePetriNetRevisions = ( - entityId: EntityId | null, -): { - revisions: PetriNetRevision[]; - refetch: () => Promise; -} => { - const [webId, entityUuid] = entityId - ? splitEntityId(entityId) - : [undefined, undefined]; - - const { data, refetch: rawRefetch } = useQuery< - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables - >(queryEntitySubgraphQuery, { - skip: !entityId, - variables: { - request: { - filter: { - all: [ - { equal: [{ path: ["uuid"] }, { parameter: entityUuid ?? "" }] }, - { equal: [{ path: ["webId"] }, { parameter: webId ?? "" }] }, - ], - }, - traversalPaths: [], - includeDrafts: true, - temporalAxes: fullDecisionTimeAxis, - includePermissions: false, - }, - }, - }); - - const refetch = useCallback(async () => { - if (!entityId) { - /** - * Apollo's `refetch()` bypasses `skip` and reuses whatever variables - * the query was set up with — for an unsaved net those are empty - * strings, which the graph rejects with "could not convert '' to UUID". - */ - return; - } - await rawRefetch(); - }, [entityId, rawRefetch]); - - const revisions = useMemo(() => { - if (!data) { - return []; - } - - const subgraph = deserializeQueryEntitySubgraphResponse( - data.queryEntitySubgraph, - ).subgraph; - - return getRoots(subgraph) - .map((edition): PetriNetRevision => { - const title = - edition.properties["https://hash.ai/@h/types/property-type/title/"]; - - const definition = edition.properties[ - "https://hash.ai/@h/types/property-type/definition-object/" - ] as SDCPN; - - return { - decisionTime: - edition.metadata.temporalVersioning.decisionTime.start.limit, - title, - definition, - }; - }) - .sort((a, b) => (a.decisionTime < b.decisionTime ? 1 : -1)); - }, [data]); - - return { revisions, refetch }; -}; diff --git a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/version-picker.tsx b/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/version-picker.tsx deleted file mode 100644 index 8dde1ca3a80..00000000000 --- a/apps/hash-frontend/src/pages/process.page/process-editor-wrapper/version-picker.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { - Box, - ButtonBase, - Menu, - Stack, - Typography, - menuItemClasses, -} from "@mui/material"; -import { - bindMenu, - bindTrigger, - usePopupState, -} from "material-ui-popup-state/hooks"; - -import { FontAwesomeIcon } from "@hashintel/design-system"; - -import { MenuItem } from "../../../shared/ui/menu-item"; - -import type { PetriNetRevision } from "./use-process-save-and-load/use-petri-net-revisions"; - -/** - * Locale is left `undefined` so `Intl.DateTimeFormat` picks up the browser's preference - * - * The host renders client-side only (`process.page.tsx` uses `dynamic` with `ssr: false`), - * so there's no SSR/hydration mismatch risk from the runtime locale. - */ -const dateFormatter = new Intl.DateTimeFormat(undefined, { - day: "2-digit", - month: "short", - year: "numeric", -}); - -const timeFormatter = new Intl.DateTimeFormat(undefined, { - hour: "2-digit", - minute: "2-digit", -}); - -const formatRevisionParts = ( - isoTimestamp: string, -): { date: string; time: string } => { - const date = new Date(isoTimestamp); - - if (Number.isNaN(date.getTime())) { - return { date: isoTimestamp, time: "" }; - } - - return { - date: dateFormatter.format(date), - time: timeFormatter.format(date), - }; -}; - -const VERSION_COLUMN_WIDTH = 22; -const DATE_COLUMN_WIDTH = 80; -const TIME_COLUMN_WIDTH = 30; - -const DraftBadge = () => ( - ({ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - paddingX: 0.7, - paddingY: 0.5, - lineHeight: 1, - backgroundColor: "#F0F0F0", - color: palette.gray[90], - borderRadius: 1, - fontSize: 12, - fontWeight: 500, - })} - > - Draft - -); - -export type VersionPickerProps = { - /** - * All saved revisions of the active entity, newest first. The version - * label is derived as `revisions.length - index` (so the entry at - * index 0 is "vN", the latest, and the last entry is "v1"). - */ - revisions: PetriNetRevision[]; - /** - * Decision-time of the revision currently loaded in the editor, or - * `null` if the active net is unsaved. - */ - loadedRevisionTime: string | null; - /** - * Whether the editor state diverges from the loaded revision. Used to - * surface the "Draft" badge — the linear-edit model means a save while - * dirty creates a new top revision (vN+1). - */ - isDirty: boolean; - onLoadRevision: (revision: PetriNetRevision) => void; -}; - -/** - * Top-bar control for browsing the server-side revision history of a persisted Petri net. - * - * In-memory undo/redo is handled separately by Petrinaut's - * `VersionHistoryButton` — these two histories intentionally don't share - * a surface (keystroke-level deltas vs persisted snapshots). - */ -export const VersionPicker = ({ - revisions, - loadedRevisionTime, - isDirty, - onLoadRevision, -}: VersionPickerProps) => { - const popupState = usePopupState({ - variant: "popover", - popupId: "petrinaut-version-picker", - }); - - const loadedIndex = loadedRevisionTime - ? revisions.findIndex( - (revision) => revision.decisionTime === loadedRevisionTime, - ) - : -1; - - const versionNumber = - loadedIndex >= 0 ? revisions.length - loadedIndex : null; - - if (versionNumber === null && !isDirty && revisions.length === 0) { - return null; - } - - return ( - <> - ({ - alignItems: "center", - borderRadius: 1, - color: palette.gray[90], - cursor: revisions.length === 0 ? "default" : "pointer", - gap: 1, - paddingX: 0.75, - paddingY: 0.5, - transition: transitions.create("background-color"), - "&:hover": { - backgroundColor: - revisions.length === 0 ? "transparent" : palette.gray[15], - }, - "&.Mui-disabled": { - color: palette.gray[60], - }, - })} - > - {versionNumber !== null && ( - - v{versionNumber} - - )} - {isDirty && } - {revisions.length > 0 && ( - ({ - fontSize: 10, - color: palette.gray[80], - })} - /> - )} - - - ({ - borderRadius: "8px", - marginTop: 0.5, - minWidth: 240, - maxHeight: 320, - border: `1px solid ${palette.gray[20]}`, - }), - }, - }} - > - {revisions.map((revision, index) => { - const isLoaded = index === loadedIndex; - const versionN = revisions.length - index; - const { date, time } = formatRevisionParts(revision.decisionTime); - - return ( - { - popupState.close(); - onLoadRevision(revision); - }} - selected={isLoaded} - sx={({ palette }) => ({ - display: "flex", - justifyContent: "space-between", - gap: 2, - paddingY: 1, - color: palette.gray[90], - [`&.${menuItemClasses.selected}`]: { - backgroundColor: palette.gray[20], - color: palette.gray[90], - }, - [`&.${menuItemClasses.focusVisible}, &:hover`]: { - backgroundColor: palette.gray[15], - }, - [`&.${menuItemClasses.selected}.${menuItemClasses.focusVisible}, &.${menuItemClasses.selected}:hover`]: - { - backgroundColor: palette.gray[30], - }, - })} - > - - ({ - fontSize: 13, - fontWeight: 500, - color: palette.gray[90], - minWidth: VERSION_COLUMN_WIDTH, - fontVariantNumeric: "tabular-nums", - })} - > - v{versionN} - - ({ - fontSize: 13, - color: palette.gray[70], - minWidth: DATE_COLUMN_WIDTH, - fontVariantNumeric: "tabular-nums", - })} - > - {date} - - ({ - fontSize: 13, - color: palette.gray[70], - minWidth: TIME_COLUMN_WIDTH, - fontVariantNumeric: "tabular-nums", - })} - > - {time} - - - - ); - })} - - - ); -}; diff --git a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx index cb7c216805c..563f177cb5d 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar.tsx @@ -21,6 +21,7 @@ import { useActiveWorkspace } from "../../../pages/shared/workspace-context"; import { useDraftEntitiesCount } from "../../draft-entities-count-context"; import { ArrowRightToLineIcon } from "../../icons"; import { BoltLightIcon } from "../../icons/bolt-light-icon"; +import { ChartNetworkRegularIcon } from "../../icons/chart-network-regular-icon"; import { InboxIcon } from "../../icons/inbox-icon"; import { NoteIcon } from "../../icons/note-icon"; import { useInvites } from "../../invites-context"; @@ -149,6 +150,12 @@ export const PageSidebar: FunctionComponent = () => { }, ...workersSection, ...toggleableLinks, + { + title: "Processes", + path: "/processes", + icon: , + activeIfPathMatches: /^\/processes(\/|$)/, + }, { title: "Inbox", path: shouldInboxLinkToActions diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index d4452c45b5d..66ad2acf2ef 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -284,7 +284,6 @@ export const DevApp = () => { handle={activeHandle.handle} existingNets={existingNets} createNewNet={createNewNet} - hideNetManagementControls={false} loadPetriNet={loadPetriNet} readonly={false} setTitle={setTitle} diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx index b9062f10817..0eac194b9ae 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx @@ -37,14 +37,14 @@ export const PetrinautStoryProvider = ({ aiAssistant, initialTitle = "New Process", initialDefinition = emptySDCPN, - hideNetManagementControls = false, + hideNetManagementControls, readonly = false, children, }: { aiAssistant?: PetrinautAiAssistant; initialTitle?: string; initialDefinition?: SDCPN; - hideNetManagementControls?: boolean; + hideNetManagementControls?: "all" | "except-title"; readonly?: boolean; children?: ReactNode; }) => { diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index 75167abefce..27b8e7f0d8f 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -48,7 +48,7 @@ export const HiddenNetManagement: Story = { ), @@ -101,7 +101,7 @@ const HandleSpikeRender = ({ handle={handle} title={title} setTitle={setTitle} - hideNetManagementControls + hideNetManagementControls="all" />
 void;
   readonly?: boolean;
-  hideNetManagementControls?: boolean;
+  /**
+   * Controls visibility of net-management UI in the editor's top bar and
+   * burger menu.
+   *
+   * - [omitted] (default): show the title, includethe "New", "Open", "Import",
+   *   and "Load example" menu items in the burger menu.
+   * - `"except-title"`: hide the management menu items but keep the title
+   *   viewable and editable in the top bar.
+   * - `"all"`: hide the title and all net-management menu items.
+   */
+  hideNetManagementControls?: "all" | "except-title";
   existingNets?: MinimalNetMetadata[];
   createNewNet?: (params: { petriNetDefinition: SDCPN; title: string }) => void;
   loadPetriNet?: (petriNetId: string) => void;
@@ -110,7 +120,7 @@ export const Petrinaut: FunctionComponent = ({
   title = "Untitled",
   setTitle = noop,
   readonly = false,
-  hideNetManagementControls = true,
+  hideNetManagementControls,
   existingNets = [],
   createNewNet = noop,
   loadPetriNet = noop,
diff --git a/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts b/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts
index 047fb0acd3f..5e7818b9c53 100644
--- a/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts
+++ b/libs/@hashintel/petrinaut/src/ui/types/petrinaut-slots.ts
@@ -11,6 +11,11 @@
  * styles are scoped — or just use `@hashintel/ds-components` directly.
  */
 export type PetrinautSlots = {
+  /**
+   * Rendered at the leading edge of the top bar, before the built-in
+   * sidebar-toggle and burger-menu buttons.
+   */
+  topBarStart?: React.ReactNode;
   /**
    * Rendered at the trailing edge of the top bar, after the built-in
    * running-experiments popover and version-history button.
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx
index a95897bd1b8..43af5d8337b 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/TopBar/top-bar.tsx
@@ -53,7 +53,7 @@ interface TopBarProps {
   menuItems: MenuItem[];
   title: string;
   onTitleChange: (value: string) => void;
-  hideNetManagementControls: boolean;
+  hideNetManagementControls?: "all" | "except-title";
   mode: EditorState["globalMode"];
   onModeChange: (mode: EditorState["globalMode"]) => void;
   onRunningExperimentClick?: (experiment: ExperimentRecord) => void;
@@ -77,6 +77,8 @@ export const TopBar: React.FC = ({
   return (
     
+ {slots?.topBarStart} + + + ), + }; + }, [ + handleSaveClick, + isDirty, + loadRevision, + loadedRevisionTime, + persistPending, + revisions, + router, + selectedNetId, + userEditable, + ]); + + return ( + + {pendingView && loadedView && ( + { + const target = pendingView; + setPendingView(null); + applyView(target); + }} + calloutMessage="You have unsaved changes which will be discarded. Are you sure you want to switch?" + confirmButtonText="Discard" + contentStyle={{ + maxWidth: 450, + }} + header="Discard unsaved changes?" + open + close={() => { + const revertPath = pathForLoadedView(loadedView); + setPendingView(null); + // Restore the URL to the editor's loaded view so URL and + // editor stay in sync. + void router.replace(revertPath); + }} + type="warning" + /> + )} + + + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx new file mode 100644 index 00000000000..759bbf12c55 --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx @@ -0,0 +1,295 @@ +import { useMutation } from "@apollo/client"; +import { + type Dispatch, + type SetStateAction, + useCallback, + useMemo, + useState, +} from "react"; + +import { isSDCPNEqual } from "@hashintel/petrinaut"; +import { HashEntity } from "@local/hash-graph-sdk/entity"; +import { + blockProtocolDataTypes, + systemEntityTypes, + systemPropertyTypes, +} from "@local/hash-isomorphic-utils/ontology-type-ids"; + +import { + createEntityMutation, + updateEntityMutation, +} from "../../../../graphql/queries/knowledge/entity.queries"; +import { + getPersistedNetsFromSubgraph, + type PersistedNet, + usePersistedNets, +} from "../../../processes.page/use-persisted-nets"; +import { useActiveWorkspace } from "../../../shared/workspace-context"; + +import type { + CreateEntityMutation, + CreateEntityMutationVariables, + UpdateEntityMutation, + UpdateEntityMutationVariables, +} from "../../../../graphql/api-types.gen"; +import type { + EntityId, + PropertyObjectWithMetadata, +} from "@blockprotocol/type-system"; +import type { SDCPN } from "@hashintel/petrinaut"; +import type { PetriNetPropertiesWithMetadata } from "@local/hash-isomorphic-utils/system-types/petrinet"; + +export type { PersistedNet } from "../../../processes.page/use-persisted-nets"; + +type UseProcessSaveAndLoadParams = { + petriNet: SDCPN; + selectedNetId: EntityId | null; + /** + * Replace the entire active net with a new SDCPN. Internally the consumer + * recreates the document handle, which resets undo/redo history — so this + * is intended for net-switch / load flows, not user mutations. + */ + setPetriNet: (sdcpn: SDCPN) => void; + setSelectedNetId: Dispatch>; + setLoadedRevisionTime: Dispatch>; + setTitle: Dispatch>; + title: string; + refetchRevisions: () => Promise; +}; + +export const useProcessSaveAndLoad = ({ + petriNet, + selectedNetId, + setSelectedNetId, + setLoadedRevisionTime, + setPetriNet, + setTitle, + title, + refetchRevisions, +}: UseProcessSaveAndLoadParams): { + isDirty: boolean; + loadPersistedNet: (net: PersistedNet) => void; + persistedNets: PersistedNet[]; + persistedNetsLoading: boolean; + persistPending: boolean; + /** + * Persist the active net. On success, resolves with the entity id of the + * persisted (created or updated) entity; the caller is responsible for any + * URL navigation needed after a create. + */ + persistToGraph: () => Promise; + setUserEditable: Dispatch>; + userEditable: boolean; +} => { + const { + persistedNets, + loading: persistedNetsLoading, + refetch, + } = usePersistedNets(); + + const { activeWorkspaceWebId } = useActiveWorkspace(); + + const [persistPending, setPersistPending] = useState(false); + + const [userEditable, setUserEditable] = useState(true); + + const [createEntity] = useMutation< + CreateEntityMutation, + CreateEntityMutationVariables + >(createEntityMutation); + + const [updateEntity] = useMutation< + UpdateEntityMutation, + UpdateEntityMutationVariables + >(updateEntityMutation); + + const persistedNet = useMemo(() => { + return persistedNets.find((net) => net.entityId === selectedNetId); + }, [persistedNets, selectedNetId]); + + const isDirty = useMemo(() => { + if (!persistedNet) { + return true; + } + + return ( + title !== persistedNet.title || + !isSDCPNEqual(petriNet, persistedNet.definition) + ); + }, [petriNet, persistedNet, title]); + + const loadPersistedNet = useCallback( + (net: PersistedNet) => { + setSelectedNetId(net.entityId); + setPetriNet(net.definition); + setTitle(net.title); + setUserEditable(net.userEditable); + setLoadedRevisionTime(net.lastUpdated); + }, + [ + setLoadedRevisionTime, + setPetriNet, + setSelectedNetId, + setTitle, + setUserEditable, + ], + ); + + const refetchPersistedNets = useCallback( + async ({ updatedEntityId }: { updatedEntityId: EntityId | null }) => { + const [updatedNetsData] = await Promise.all([ + refetch(), + // For updates `selectedNetId` is unchanged, so Apollo won't + // auto-refire the history query — kick it explicitly. For creates + // it's a benign duplicate (Apollo deduplicates by variables). + refetchRevisions(), + ]); + + const transformedNets = getPersistedNetsFromSubgraph( + updatedNetsData.data, + ); + + if (updatedEntityId) { + const updatedNet = transformedNets.find( + (net) => net.entityId === updatedEntityId, + ); + + if (updatedNet) { + setSelectedNetId(updatedNet.entityId); + setUserEditable(updatedNet.userEditable); + setLoadedRevisionTime(updatedNet.lastUpdated); + } + } + }, + [ + refetch, + refetchRevisions, + setLoadedRevisionTime, + setSelectedNetId, + setUserEditable, + ], + ); + + const persistToGraph = useCallback(async (): Promise => { + if (!activeWorkspaceWebId) { + return null; + } + + setPersistPending(true); + + try { + let persistedEntityId = selectedNetId; + + if (selectedNetId) { + await updateEntity({ + variables: { + entityUpdate: { + entityId: selectedNetId, + propertyPatches: [ + { + op: "replace", + path: [ + systemPropertyTypes.definitionObject.propertyTypeBaseUrl, + ], + property: { + metadata: { + dataTypeId: blockProtocolDataTypes.object.dataTypeId, + }, + value: petriNet, + }, + }, + { + op: "replace", + path: [systemPropertyTypes.title.propertyTypeBaseUrl], + property: { + metadata: { + dataTypeId: blockProtocolDataTypes.text.dataTypeId, + }, + value: title, + }, + }, + ], + }, + }, + }); + } else { + const createdEntityData = await createEntity({ + variables: { + entityTypeIds: [systemEntityTypes.petriNet.entityTypeId], + webId: activeWorkspaceWebId, + properties: { + value: { + "https://hash.ai/@h/types/property-type/definition-object/": { + metadata: { + dataTypeId: blockProtocolDataTypes.object.dataTypeId, + }, + value: petriNet, + }, + "https://hash.ai/@h/types/property-type/title/": { + metadata: { + dataTypeId: blockProtocolDataTypes.text.dataTypeId, + }, + value: title, + }, + }, + } satisfies PetriNetPropertiesWithMetadata as PropertyObjectWithMetadata, + }, + }); + + if (!createdEntityData.data?.createEntity) { + throw new Error("Failed to create petri net"); + } + + const createdEntity = new HashEntity( + createdEntityData.data.createEntity, + ); + + persistedEntityId = createdEntity.entityId; + } + + if (!persistedEntityId) { + throw new Error("Somehow no entityId available after persisting net"); + } + + await refetchPersistedNets({ updatedEntityId: persistedEntityId }); + setSelectedNetId(persistedEntityId); + setUserEditable(true); + + return persistedEntityId; + } finally { + setPersistPending(false); + } + }, [ + activeWorkspaceWebId, + createEntity, + petriNet, + refetchPersistedNets, + selectedNetId, + setSelectedNetId, + title, + updateEntity, + ]); + + return useMemo( + () => ({ + isDirty, + loadPersistedNet, + persistedNets, + persistedNetsLoading, + persistPending, + persistToGraph, + setUserEditable, + userEditable, + }), + [ + isDirty, + loadPersistedNet, + persistPending, + persistedNets, + persistedNetsLoading, + persistToGraph, + setUserEditable, + userEditable, + ], + ); +}; diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load/use-petri-net-revisions.ts b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load/use-petri-net-revisions.ts new file mode 100644 index 00000000000..fd4d1f9e8b0 --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load/use-petri-net-revisions.ts @@ -0,0 +1,109 @@ +import { useQuery } from "@apollo/client"; +import { useCallback, useMemo } from "react"; + +import { getRoots } from "@blockprotocol/graph/stdlib"; +import { splitEntityId } from "@blockprotocol/type-system"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { fullDecisionTimeAxis } from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntitySubgraphQuery } from "../../../../../graphql/queries/knowledge/entity.queries"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../../../graphql/api-types.gen"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { SDCPN } from "@hashintel/petrinaut"; +import type { PetriNet } from "@local/hash-isomorphic-utils/system-types/petrinet"; + +/** + * One past revision of a persisted Petri net entity. Index 0 in the + * returned array is the most recent; the last entry is the oldest. + */ +export type PetriNetRevision = { + /** + * ISO timestamp at which this revision started being the truth. + * Doubles as a stable key for React lists and the "decision-time" + * coordinate for downstream queries. + */ + decisionTime: string; + title: string; + definition: SDCPN; +}; + +/** + * Fetches every revision of a single Petri net entity. + */ +export const usePetriNetRevisions = ( + entityId: EntityId | null, +): { + revisions: PetriNetRevision[]; + refetch: () => Promise; +} => { + const [webId, entityUuid] = entityId + ? splitEntityId(entityId) + : [undefined, undefined]; + + const { data, refetch: rawRefetch } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + skip: !entityId, + variables: { + request: { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid ?? "" }] }, + { equal: [{ path: ["webId"] }, { parameter: webId ?? "" }] }, + ], + }, + traversalPaths: [], + includeDrafts: true, + temporalAxes: fullDecisionTimeAxis, + includePermissions: false, + }, + }, + }); + + const refetch = useCallback(async () => { + if (!entityId) { + /** + * Apollo's `refetch()` bypasses `skip` and reuses whatever variables + * the query was set up with — for an unsaved net those are empty + * strings, which the graph rejects with "could not convert '' to UUID". + */ + return; + } + await rawRefetch(); + }, [entityId, rawRefetch]); + + const revisions = useMemo(() => { + if (!data) { + return []; + } + + const subgraph = deserializeQueryEntitySubgraphResponse( + data.queryEntitySubgraph, + ).subgraph; + + return getRoots(subgraph) + .map((edition): PetriNetRevision => { + const title = + edition.properties["https://hash.ai/@h/types/property-type/title/"]; + + const definition = edition.properties[ + "https://hash.ai/@h/types/property-type/definition-object/" + ] as SDCPN; + + return { + decisionTime: + edition.metadata.temporalVersioning.decisionTime.start.limit, + title, + definition, + }; + }) + .sort((a, b) => (a.decisionTime < b.decisionTime ? 1 : -1)); + }, [data]); + + return { revisions, refetch }; +}; diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/version-picker.tsx b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/version-picker.tsx new file mode 100644 index 00000000000..a86f0cc1f10 --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/version-picker.tsx @@ -0,0 +1,269 @@ +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; +import { + Box, + ButtonBase, + Menu, + Stack, + Typography, + menuItemClasses, +} from "@mui/material"; +import { + bindMenu, + bindTrigger, + usePopupState, +} from "material-ui-popup-state/hooks"; + +import { FontAwesomeIcon } from "@hashintel/design-system"; + +import { MenuItem } from "../../../../shared/ui/menu-item"; + +import type { PetriNetRevision } from "./use-process-save-and-load/use-petri-net-revisions"; + +/** + * Locale is left `undefined` so `Intl.DateTimeFormat` picks up the browser's preference + * + * The host renders client-side only (`[uuid].page.tsx` uses `dynamic` with `ssr: false`), + * so there's no SSR/hydration mismatch risk from the runtime locale. + */ +const dateFormatter = new Intl.DateTimeFormat(undefined, { + day: "2-digit", + month: "short", + year: "numeric", +}); + +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", +}); + +const formatRevisionParts = ( + isoTimestamp: string, +): { date: string; time: string } => { + const date = new Date(isoTimestamp); + + if (Number.isNaN(date.getTime())) { + return { date: isoTimestamp, time: "" }; + } + + return { + date: dateFormatter.format(date), + time: timeFormatter.format(date), + }; +}; + +const VERSION_COLUMN_WIDTH = 22; +const DATE_COLUMN_WIDTH = 80; +const TIME_COLUMN_WIDTH = 30; + +const DraftBadge = () => ( + ({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + paddingX: 0.7, + paddingY: 0.5, + lineHeight: 1, + backgroundColor: "#F0F0F0", + color: palette.gray[90], + borderRadius: 1, + fontSize: 12, + fontWeight: 500, + })} + > + Draft + +); + +export type VersionPickerProps = { + /** + * All saved revisions of the active entity, newest first. The version + * label is derived as `revisions.length - index` (so the entry at + * index 0 is "vN", the latest, and the last entry is "v1"). + */ + revisions: PetriNetRevision[]; + /** + * Decision-time of the revision currently loaded in the editor, or + * `null` if the active net is unsaved. + */ + loadedRevisionTime: string | null; + /** + * Whether the editor state diverges from the loaded revision. Used to + * surface the "Draft" badge — the linear-edit model means a save while + * dirty creates a new top revision (vN+1). + */ + isDirty: boolean; + onLoadRevision: (revision: PetriNetRevision) => void; +}; + +/** + * Top-bar control for browsing the server-side revision history of a persisted Petri net. + * + * In-memory undo/redo is handled separately by Petrinaut's + * `VersionHistoryButton` — these two histories intentionally don't share + * a surface (keystroke-level deltas vs persisted snapshots). + */ +export const VersionPicker = ({ + revisions, + loadedRevisionTime, + isDirty, + onLoadRevision, +}: VersionPickerProps) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "petrinaut-version-picker", + }); + + const loadedIndex = loadedRevisionTime + ? revisions.findIndex( + (revision) => revision.decisionTime === loadedRevisionTime, + ) + : -1; + + const versionNumber = + loadedIndex >= 0 ? revisions.length - loadedIndex : null; + + if (versionNumber === null && !isDirty && revisions.length === 0) { + return null; + } + + return ( + <> + ({ + alignItems: "center", + borderRadius: 1, + color: palette.gray[90], + cursor: revisions.length === 0 ? "default" : "pointer", + gap: 1, + paddingX: 0.75, + paddingY: 0.5, + transition: transitions.create("background-color"), + "&:hover": { + backgroundColor: + revisions.length === 0 ? "transparent" : palette.gray[15], + }, + "&.Mui-disabled": { + color: palette.gray[60], + }, + })} + > + {versionNumber !== null && ( + + v{versionNumber} + + )} + {isDirty && } + {revisions.length > 0 && ( + ({ + fontSize: 10, + color: palette.gray[80], + })} + /> + )} + + + ({ + borderRadius: "8px", + marginTop: 0.5, + minWidth: 240, + maxHeight: 320, + border: `1px solid ${palette.gray[20]}`, + }), + }, + }} + > + {revisions.map((revision, index) => { + const isLoaded = index === loadedIndex; + const versionN = revisions.length - index; + const { date, time } = formatRevisionParts(revision.decisionTime); + + return ( + { + popupState.close(); + onLoadRevision(revision); + }} + selected={isLoaded} + sx={({ palette }) => ({ + display: "flex", + justifyContent: "space-between", + gap: 2, + paddingY: 1, + color: palette.gray[90], + [`&.${menuItemClasses.selected}`]: { + backgroundColor: palette.gray[20], + color: palette.gray[90], + }, + [`&.${menuItemClasses.focusVisible}, &:hover`]: { + backgroundColor: palette.gray[15], + }, + [`&.${menuItemClasses.selected}.${menuItemClasses.focusVisible}, &.${menuItemClasses.selected}:hover`]: + { + backgroundColor: palette.gray[30], + }, + })} + > + + ({ + fontSize: 13, + fontWeight: 500, + color: palette.gray[90], + minWidth: VERSION_COLUMN_WIDTH, + fontVariantNumeric: "tabular-nums", + })} + > + v{versionN} + + ({ + fontSize: 13, + color: palette.gray[70], + minWidth: DATE_COLUMN_WIDTH, + fontVariantNumeric: "tabular-nums", + })} + > + {date} + + ({ + fontSize: 13, + color: palette.gray[70], + minWidth: TIME_COLUMN_WIDTH, + fontVariantNumeric: "tabular-nums", + })} + > + {time} + + + + ); + })} + + + ); +}; From fc5e213b332a7de2d918ef9e20f72ce44756ec2b Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 29 May 2026 12:18:36 +0100 Subject: [PATCH 03/18] format. clean up comments --- apps/hash-frontend/src/pages/processes.page.tsx | 13 +++---------- .../src/pages/processes.page/process-tile.tsx | 5 +++-- .../src/pages/processes/[uuid].page.tsx | 6 +----- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/apps/hash-frontend/src/pages/processes.page.tsx b/apps/hash-frontend/src/pages/processes.page.tsx index b79ed49ab83..25b24157278 100644 --- a/apps/hash-frontend/src/pages/processes.page.tsx +++ b/apps/hash-frontend/src/pages/processes.page.tsx @@ -19,11 +19,9 @@ const contentMaxWidth = 1000; const SectionHeading = ({ children }: { children: React.ReactNode }) => ( ({ color: palette.gray[80], - fontSize: 16, - fontWeight: 600, mb: 2, })} > @@ -32,12 +30,8 @@ const SectionHeading = ({ children }: { children: React.ReactNode }) => ( ); /** - * Lists Petri net "processes" — both the user's persisted entities and a - * curated set of examples imported from `@hashintel/petrinaut-core/examples`. - * - * Clicking a saved net opens it in the editor at `/processes/`. - * Clicking an example opens a draft seeded with that example's definition - * at `/processes/draft?example=`. + * Lists Petri net "processes" — both those in the db visible to the user, + * and examples imported from `@hashintel/petrinaut-core/examples`. */ const ProcessesPage: NextPageWithLayout = () => { const { persistedNets } = usePersistedNets(); @@ -94,7 +88,6 @@ const ProcessesPage: NextPageWithLayout = () => { - Your processes {sortedNets.length === 0 ? ( ({ diff --git a/apps/hash-frontend/src/pages/processes.page/process-tile.tsx b/apps/hash-frontend/src/pages/processes.page/process-tile.tsx index 74d64688d53..f43fe4f37f0 100644 --- a/apps/hash-frontend/src/pages/processes.page/process-tile.tsx +++ b/apps/hash-frontend/src/pages/processes.page/process-tile.tsx @@ -34,8 +34,9 @@ export const ProcessTile = ({ textDecoration: "none", transition: transitions.create(["border-color", "box-shadow"]), "&:hover": { - borderColor: palette.gray[30], - boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.04)", + borderColor: palette.gray[40], + boxShadow: + "0px 4px 12px rgba(0, 0, 0, 0.08), 0px 2px 4px rgba(0, 0, 0, 0.04)", }, })} > diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page.tsx b/apps/hash-frontend/src/pages/processes/[uuid].page.tsx index b6ff62038ea..92fa0cc9225 100644 --- a/apps/hash-frontend/src/pages/processes/[uuid].page.tsx +++ b/apps/hash-frontend/src/pages/processes/[uuid].page.tsx @@ -19,11 +19,7 @@ const ProcessEditor = dynamic( ); /** - * Single Next.js page file backing both `/processes/draft` and - * `/processes/`. Using one file means navigating from a freshly - * saved draft (`router.replace("/processes/")`) doesn't remount - * the editor — undo/redo history and the active document handle survive - * the URL transition. + * Single page backing both `/processes/draft` and `/processes/`. */ const ProcessRoutePage: NextPageWithLayout = () => { const router = useRouter(); From abbaefeb4d60751167ccf5e8b3289a949991abf0 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 29 May 2026 12:29:31 +0100 Subject: [PATCH 04/18] address pr feedback --- .../src/pages/processes.page.tsx | 26 ++++++++++--------- .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/hash-frontend/src/pages/processes.page.tsx b/apps/hash-frontend/src/pages/processes.page.tsx index 25b24157278..ab02c3d6286 100644 --- a/apps/hash-frontend/src/pages/processes.page.tsx +++ b/apps/hash-frontend/src/pages/processes.page.tsx @@ -34,7 +34,7 @@ const SectionHeading = ({ children }: { children: React.ReactNode }) => ( * and examples imported from `@hashintel/petrinaut-core/examples`. */ const ProcessesPage: NextPageWithLayout = () => { - const { persistedNets } = usePersistedNets(); + const { persistedNets, loading } = usePersistedNets(); const sortedNets = useMemo( () => @@ -44,7 +44,7 @@ const ProcessesPage: NextPageWithLayout = () => { [persistedNets], ); - const maxWidth = { lg: `max(${contentMaxWidth}px, "70%")` } as const; + const maxWidth = { lg: `max(${contentMaxWidth}px, 70%)` } as const; return ( <> @@ -89,16 +89,18 @@ const ProcessesPage: NextPageWithLayout = () => { {sortedNets.length === 0 ? ( - ({ - color: palette.gray[70], - fontSize: 14, - })} - > - You haven't created any processes yet —{" "} - start from scratch or open an - example below. - + !loading && ( + ({ + color: palette.gray[70], + fontSize: 14, + })} + > + You haven't created any processes yet —{" "} + start from scratch or open + an example below. + + ) ) : ( = ({ title = "Untitled", setTitle = noop, readonly = false, - hideNetManagementControls, + hideNetManagementControls = undefined, existingNets = [], createNewNet = noop, loadPetriNet = noop, From 7d65c0654355a26319452cbdc35ce6ae542a0a9a Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 29 May 2026 17:57:55 +0100 Subject: [PATCH 05/18] iframe petrinaut in hash --- apps/hash-frontend/instrumentation-client.ts | 64 +- apps/hash-frontend/next.config.js | 16 + apps/hash-frontend/src/lib/csp.ts | 65 +- apps/hash-frontend/src/middleware.page.ts | 15 +- apps/hash-frontend/src/pages/_app.page.tsx | 48 +- .../src/pages/processes/[uuid].page.tsx | 32 +- .../processes/[uuid].page/process-editor.tsx | 683 +++++++++++------- .../use-process-save-and-load.tsx | 178 +++-- .../src/pages/processes/[uuid]/embed.page.tsx | 34 + .../[uuid]/embed.page/embed-content.tsx | 337 +++++++++ .../embed.page}/version-picker.tsx | 41 +- .../processes/shared/iframe-error-reporter.ts | 100 +++ .../src/pages/processes/shared/messages.ts | 206 ++++++ .../pages/processes/shared/use-host-bridge.ts | 142 ++++ .../processes/shared/use-iframe-bridge.ts | 98 +++ .../ai-assistant-contents.tsx | 2 + 16 files changed, 1643 insertions(+), 418 deletions(-) create mode 100644 apps/hash-frontend/src/pages/processes/[uuid]/embed.page.tsx create mode 100644 apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-content.tsx rename apps/hash-frontend/src/pages/processes/{[uuid].page/process-editor => [uuid]/embed.page}/version-picker.tsx (86%) create mode 100644 apps/hash-frontend/src/pages/processes/shared/iframe-error-reporter.ts create mode 100644 apps/hash-frontend/src/pages/processes/shared/messages.ts create mode 100644 apps/hash-frontend/src/pages/processes/shared/use-host-bridge.ts create mode 100644 apps/hash-frontend/src/pages/processes/shared/use-iframe-bridge.ts diff --git a/apps/hash-frontend/instrumentation-client.ts b/apps/hash-frontend/instrumentation-client.ts index 982242cc992..cc40988a568 100644 --- a/apps/hash-frontend/instrumentation-client.ts +++ b/apps/hash-frontend/instrumentation-client.ts @@ -10,29 +10,47 @@ import { SENTRY_ENVIRONMENT, SENTRY_REPLAYS_SESSION_SAMPLE_RATE, } from "./src/lib/public-env"; +import { installIframeErrorReporter } from "./src/pages/processes/shared/iframe-error-reporter"; -Sentry.init({ - dsn: SENTRY_DSN, - enabled: !!SENTRY_DSN, - environment: SENTRY_ENVIRONMENT, - integrations: [ - Sentry.browserApiErrorsIntegration(), - Sentry.browserProfilingIntegration(), - Sentry.browserSessionIntegration(), - Sentry.browserTracingIntegration(), - Sentry.graphqlClientIntegration({ - endpoints: [/\/graphql$/], - }), - Sentry.replayIntegration(), - ], - release: buildStamp, - replaysOnErrorSampleRate: 1, - replaysSessionSampleRate: parseFloat( - SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0", - ), - sendDefaultPii: true, - tracePropagationTargets: ["localhost", /^https:\/\/(?:.*\.)?hash\.ai/], - tracesSampleRate: isProduction ? 1.0 : 0, -}); +/** + * The Petrinaut embed page (`/processes//embed`) runs inside a + * sandboxed null-origin iframe with `connect-src 'self'`. Sentry's + * transport would be blocked by CSP and the resulting events would lack + * the host's authenticated-user context anyway. Instead we install a + * tiny reporter that forwards errors to the host over the postMessage + * bridge, and the host's Sentry SDK captures them with iframe-specific + * tags. + */ +const isPetrinautEmbedDocument = + typeof window !== "undefined" && + /^\/processes\/[^/]+\/embed/.test(window.location.pathname); + +if (isPetrinautEmbedDocument) { + installIframeErrorReporter(); +} else { + Sentry.init({ + dsn: SENTRY_DSN, + enabled: !!SENTRY_DSN, + environment: SENTRY_ENVIRONMENT, + integrations: [ + Sentry.browserApiErrorsIntegration(), + Sentry.browserProfilingIntegration(), + Sentry.browserSessionIntegration(), + Sentry.browserTracingIntegration(), + Sentry.graphqlClientIntegration({ + endpoints: [/\/graphql$/], + }), + Sentry.replayIntegration(), + ], + release: buildStamp, + replaysOnErrorSampleRate: 1, + replaysSessionSampleRate: parseFloat( + SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0", + ), + sendDefaultPii: true, + tracePropagationTargets: ["localhost", /^https:\/\/(?:.*\.)?hash\.ai/], + tracesSampleRate: isProduction ? 1.0 : 0, + }); +} export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 9a2ade0d671..c79e9bd7f39 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -139,6 +139,22 @@ export default withSentryConfig( }, ], }, + { + /** + * Self-hosted fonts referenced from `globals.scss` (Inter, Open + * Sauce Two, Apercu, IBM Plex, …). The Petrinaut embed route is + * loaded into a sandboxed null-origin iframe; the browser's + * anonymous-CORS rule for font fetches treats the same-host + * request as cross-origin and rejects it without these headers. + */ + source: "/fonts/:path*", + headers: [ + { + key: "access-control-allow-origin", + value: "*", + }, + ], + }, ]; }, pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.jsx", "api.ts"], diff --git a/apps/hash-frontend/src/lib/csp.ts b/apps/hash-frontend/src/lib/csp.ts index 4b327029a49..c5925e0dd5a 100644 --- a/apps/hash-frontend/src/lib/csp.ts +++ b/apps/hash-frontend/src/lib/csp.ts @@ -6,6 +6,11 @@ import { apiOrigin } from "@local/hash-isomorphic-utils/environment"; +const buildDirectiveString = (directives: Record): string => + Object.entries(directives) + .map(([key, values]) => `${key} ${values.join(" ")}`) + .join("; "); + export const buildCspHeader = (nonce: string): string => { const directives: Record = { "default-src": ["'self'"], @@ -86,7 +91,61 @@ export const buildCspHeader = (nonce: string): string => { "form-action": ["'self'"], }; - return Object.entries(directives) - .map(([key, values]) => `${key} ${values.join(" ")}`) - .join("; "); + return buildDirectiveString(directives); +}; + +/** + * Stricter CSP for the Petrinaut embed route (`/processes//embed`). + * + * The embed route is loaded into a sandboxed null-origin iframe so user- + * provided code (place visualizers, metric/scenario expressions) can be + * compiled with `new Function()` without endangering the parent HASH origin. + * + * Key differences vs the default CSP: + * - `script-src` includes `'unsafe-eval'` so Babel + `new Function()` work. + * - `connect-src` is `'none'` — the iframe should not talk to anyone over + * the network. All persistence + AI requests round-trip through the host + * via postMessage. + * - `frame-ancestors 'self'` — only HASH itself may embed this route. + * - `worker-src` allows `blob:` because Monaco / petrinaut spawn workers + * from blob URLs. + */ +export const buildEmbedCspHeader = (nonce: string): string => { + const directives: Record = { + "default-src": ["'none'"], + + "script-src": [ + "'self'", + `'nonce-${nonce}'`, + "'wasm-unsafe-eval'", + // The whole point of the embed route: user-provided code is compiled + // with `new Function()`, which requires `'unsafe-eval'`. Contained to + // the null-origin iframe. + "'unsafe-eval'", + ], + + "style-src": [ + "'self'", + // Required for Emotion/MUI CSS-in-JS inline style injection. + "'unsafe-inline'", + ], + + "img-src": ["'self'", "data:", "blob:"], + + "font-src": ["'self'", "data:"], + + "connect-src": ["'self'"], + + "worker-src": ["'self'", "blob:"], + + "frame-src": ["'none'"], + + "frame-ancestors": ["'self'"], + + "object-src": ["'none'"], + "base-uri": ["'none'"], + "form-action": ["'none'"], + }; + + return buildDirectiveString(directives); }; diff --git a/apps/hash-frontend/src/middleware.page.ts b/apps/hash-frontend/src/middleware.page.ts index c8db177f62a..3d7ef727a38 100644 --- a/apps/hash-frontend/src/middleware.page.ts +++ b/apps/hash-frontend/src/middleware.page.ts @@ -1,7 +1,7 @@ import { get } from "@vercel/edge-config"; import { type NextRequest, NextResponse } from "next/server"; -import { buildCspHeader } from "./lib/csp"; +import { buildCspHeader, buildEmbedCspHeader } from "./lib/csp"; import { returnTypeAsJson, versionedUrlRegExp, @@ -30,9 +30,20 @@ const applyCspHeaders = ( return response; }; +/** + * Matches the Petrinaut iframe embed route, which gets a stricter, eval- + * permitting CSP applied on top of being loaded into a sandboxed null-origin + * iframe by the host page. + * + * @see {@link buildEmbedCspHeader} + */ +const petrinautEmbedRouteRegExp = /^\/processes\/[^/]+\/embed(?:\/|$)/; + export const middleware = async (request: NextRequest) => { const nonce = generateNonce(); - const cspHeader = buildCspHeader(nonce); + const cspHeader = petrinautEmbedRouteRegExp.test(request.nextUrl.pathname) + ? buildEmbedCspHeader(nonce) + : buildCspHeader(nonce); // Forward the nonce to server-side rendering via a request header so that // _document.page.tsx can read it and apply it to / . diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index fcd40f8d97a..79986eb1eae 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -35,6 +35,7 @@ import { NotificationCountContextProvider } from "../shared/notification-count-c import { PropertyTypesContextProvider } from "../shared/property-types-context"; import { RoutePageInfoProvider } from "../shared/routing"; import { ErrorFallback } from "./_app.page/error-fallback"; +import { reportIframeReactError } from "./processes/shared/iframe-error-reporter"; import { redirectInGetInitialProps } from "./shared/_app.util"; import { AuthInfoProvider, useAuthInfo } from "./shared/auth-info-context"; import { DataTypesContextProvider } from "./shared/data-types-context"; @@ -200,10 +201,55 @@ const App: FunctionComponent = ({ ); }; +const PETRINAUT_EMBED_PATHNAME = "/processes/[uuid]/embed"; + +/** + * Minimal `_app` shell for the Petrinaut embed route. Provides only what + * the iframe genuinely uses (Emotion + MUI theme + global keyframe styles + * and error boundary) and skips: + */ +const PetrinautEmbedAppShell: FunctionComponent = ({ + Component, + pageProps, + emotionCache = clientSideEmotionCache, +}) => ( + + + + + { + scope.setTag("error-boundary", "_app-embed"); + }} + /** + * Forward into the host's Sentry. The boundary's local + * captureException is a no-op here because Sentry isn't + * initialised inside the embed iframe (see + * `instrumentation-client.ts`). + */ + onError={(error) => reportIframeReactError(error)} + fallback={ErrorFallback} + > + + + + + {globalStyles} + +); + const AppWithTypeSystemContextProvider: AppPage = ( props, ) => { - const { initialAuthenticatedUserSubgraph, user } = props; + const { + initialAuthenticatedUserSubgraph, + user, + router: { pathname }, + } = props; + + if (pathname === PETRINAUT_EMBED_PATHNAME) { + return ; + } return ( diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page.tsx b/apps/hash-frontend/src/pages/processes/[uuid].page.tsx index 92fa0cc9225..5625817865c 100644 --- a/apps/hash-frontend/src/pages/processes/[uuid].page.tsx +++ b/apps/hash-frontend/src/pages/processes/[uuid].page.tsx @@ -1,25 +1,29 @@ -import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import { useMemo } from "react"; import { getLayoutWithSidebar } from "../../shared/layout"; import { exampleTileBySlug } from "../processes.page/example-tiles-data"; +import { + ProcessEditor, + type ProcessEditorView, +} from "./[uuid].page/process-editor"; import type { NextPageWithLayout } from "../../shared/layout"; -import type { ProcessEditorView } from "./[uuid].page/process-editor"; - -// Petrinaut uses Web Workers, Canvas, Monaco Editor, and the TypeScript compiler -// which all require browser APIs — must not be server-rendered. -const ProcessEditor = dynamic( - () => - import("./[uuid].page/process-editor").then((mod) => ({ - default: mod.ProcessEditor, - })), - { ssr: false }, -); /** * Single page backing both `/processes/draft` and `/processes/`. + * + * We deliberately render `` even before `router.isReady` + * (passing `view={null}`) so the editor's iframe element commits on the + * very first React render. That parallelises the iframe-bundle download + * with `router` resolution + Apollo's `persistedNets` query — the iframe + * starts loading immediately and is typically ready by the time we have + * everything we need to send `init`. + * + * `ProcessEditor` itself isn't imported via `next/dynamic` any more + * because it no longer pulls Petrinaut into its bundle — only an iframe + * element and the postMessage bridge — so chunk-splitting it just costs an + * extra round-trip for no payoff. */ const ProcessRoutePage: NextPageWithLayout = () => { const router = useRouter(); @@ -64,10 +68,6 @@ const ProcessRoutePage: NextPageWithLayout = () => { return { kind: "saved", entityUuid: uuidParam }; }, [router.isReady, router.query.uuid, router.query.example]); - if (!view) { - return null; - } - return ; }; diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor.tsx b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor.tsx index 63b8f9147d9..9a38c4586c7 100644 --- a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor.tsx +++ b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor.tsx @@ -1,25 +1,20 @@ -import "@hashintel/petrinaut/dist/main.css"; -import { Box, Stack } from "@mui/material"; +import { Box, Skeleton, Stack } from "@mui/material"; +import * as Sentry from "@sentry/nextjs"; import { useRouter } from "next/router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { extractEntityUuidFromEntityId } from "@blockprotocol/type-system"; import { AlertModal } from "@hashintel/design-system"; -import { Button } from "@hashintel/ds-components"; -import { - createJsonDocHandle, - Petrinaut, - type PetrinautDocHandle, - type PetrinautSlots, - type SDCPN, -} from "@hashintel/petrinaut"; +import { type SDCPN } from "@hashintel/petrinaut"; -import { useProcessSaveAndLoad } from "./process-editor/use-process-save-and-load"; import { - type PetriNetRevision, - usePetriNetRevisions, -} from "./process-editor/use-process-save-and-load/use-petri-net-revisions"; -import { VersionPicker } from "./process-editor/version-picker"; + type HostNetMode, + type RevisionSummary, + type SavedSnapshot, +} from "../shared/messages"; +import { useHostBridge } from "../shared/use-host-bridge"; +import { useProcessSaveAndLoad } from "./process-editor/use-process-save-and-load"; +import { usePetriNetRevisions } from "./process-editor/use-process-save-and-load/use-petri-net-revisions"; import type { EntityId } from "@blockprotocol/type-system"; @@ -32,35 +27,17 @@ const emptySDCPN: SDCPN = { }; /** - * Helper to ensure that we copy all fields of the SDCPN when loading a revision. - */ -const SDCPN_FIELDS = { - places: true, - transitions: true, - types: true, - differentialEquations: true, - parameters: true, - scenarios: true, - metrics: true, -} as const satisfies Record; - -/** - * Mirror a single SDCPN field from `source` onto `target`. + * URL the iframe is mounted at. Stable across the editor's lifetime — the + * actual net being edited is driven entirely by `init`/`load` messages over + * the postMessage bridge, so we don't need to remount the iframe (or recreate + * its workers) just because the user navigated to a different net. + * + * `/processes/draft/embed` matches the `[uuid]/embed.page.tsx` route with + * `uuid` set to the literal string "draft"; the embed page doesn't read the + * URL parameter so any value would work, but a constant keeps the network + * tab tidy. */ -const copySdcpnField = ( - target: SDCPN, - source: SDCPN, - key: K, -): void => { - /* eslint-disable no-param-reassign -- mutating the Immer draft is - the whole point of this helper. */ - if (source[key] === undefined) { - delete (target as Partial)[key]; - } else { - target[key] = source[key]; - } - /* eslint-enable no-param-reassign */ -}; +const PETRINAUT_EMBED_SRC = "/processes/draft/embed"; /** * URL-derived view that the editor renders. The host page resolves this from @@ -109,106 +86,176 @@ const viewMatchesLoaded = ( return false; }; -const noNetSwitchingError = () => { - // Net switching is handled entirely by URL navigation from the - // `/processes` list page; the in-editor "New"/"Open" menu items are - // hidden via `hideNetManagementControls="except-title"` so these - // callbacks should never fire. - throw new Error( - "Net switching from inside Petrinaut is not supported in hash-frontend; " + - "navigate to /processes instead.", - ); +/** + * Resolved content for the active view: the SDCPN + title to load into the + * iframe, the `HostNetMode` describing it, and the `SavedSnapshot` the + * iframe should compare against for dirty-tracking. + */ +type ResolvedView = { + loadedView: LoadedView; + definition: SDCPN; + title: string; + mode: HostNetMode; + savedSnapshot: SavedSnapshot; }; -export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { - const router = useRouter(); +const buildRevisionSummaries = ( + revisions: ReadonlyArray<{ decisionTime: string; title: string }>, +): RevisionSummary[] => + revisions.map(({ decisionTime, title }) => ({ decisionTime, title })); - const [selectedNetId, setSelectedNetId] = useState(null); - const [title, setTitle] = useState("Process"); +/** + * Loading-state overlay rendered above the still-warming iframe. Mirrors + * Petrinaut's broad layout (top bar with back / title / version-picker / + * save, plus a left rail and the canvas) so the transition into the real + * editor doesn't cause a visible reflow. + */ +const ProcessEditorLoadingSkeleton = () => ( + ({ + position: "absolute", + inset: 0, + backgroundColor: palette.common.white, + padding: 1.5, + gap: 1.5, + })} + aria-hidden + > + {/* Top bar: back button + title + version picker + save button */} + + + + + + + + + {/* Body: left rail + canvas */} + + + + + +); +/** + * Process editor host. Mounts a sandboxed null-origin iframe at + * {@link PETRINAUT_EMBED_SRC} so user-provided code (visualizers, + * metrics, scenarios) runs with `'unsafe-eval'` allowed but contained + * away from the parent HASH origin's cookies, storage, and APIs. + * + * The host owns: + * - URL routing and the discard-changes modal + * - `beforeunload` guard + * - Reads/writes to the graph (persisted net list + create/update mutations, + * revision history) + * + * The iframe owns: + * - The doc handle, title, panels, simulation/Monte-Carlo workers, Monaco + * - Dirty tracking (live SDCPN/title vs the `savedSnapshot` we last sent) + * + * Dirty status flows host -> iframe via `savedSnapshot`, iframe -> host via + * `dirtyChanged` (cached here for the modal + `beforeunload`). + */ +export const ProcessEditor = ({ + view, +}: { /** - * The handle is the source of truth for the current net's document. A - * fresh handle is created when the user loads a different persisted net - * or asks for a new empty one — this naturally resets undo/redo history. + * Resolved URL view. `null` while we're still waiting on `router.isReady` + * — in that state the editor still renders its iframe element (so the + * iframe bundle starts downloading immediately) but the bridge effects + * stay dormant until a non-null view arrives. */ - const [handle, setHandle] = useState(() => - createJsonDocHandle({ initial: emptySDCPN }), - ); + view: ProcessEditorView | null; +}) => { + const router = useRouter(); + + const iframeRef = useRef(null); + + const [selectedNetId, setSelectedNetId] = useState(null); /** - * Mirror of the handle's current document, kept in React state so the - * save/load logic can read it as a plain SDCPN (for `isDirty` checks and - * persisting to the graph). Updated synchronously when the handle changes - * via `handle.subscribe`. + * Cached dirty flag mirrored from the iframe's `dirtyChanged` events. The + * host doesn't compute this — only stores it for the discard modal + + * `beforeunload` guard. */ - const [petriNetDefinition, setPetriNetDefinition] = useState( - () => handle.doc() ?? emptySDCPN, - ); - useEffect(() => { - setPetriNetDefinition(handle.doc() ?? emptySDCPN); - return handle.subscribe((event) => { - setPetriNetDefinition(event.next); - }); - }, [handle]); - - const setPetriNet = useCallback((sdcpn: SDCPN) => { - setHandle(createJsonDocHandle({ initial: sdcpn })); - }, []); + const [isDirty, setIsDirty] = useState(false); /** - * Tracks which {@link ProcessEditorView} is currently materialised into - * the editor state. Compared against the incoming `view` prop on every - * render to decide whether to (re)apply it. + * Tracks which {@link ProcessEditorView} is currently materialised inside + * the iframe. Compared against the incoming `view` prop on every render + * to decide whether to (re)apply it. */ const [loadedView, setLoadedView] = useState(null); /** - * Decision-time of the server revision currently mirrored in the editor. + * Pending view-change waiting on user confirmation, set when the URL + * changed away from a dirty editor state. Confirming applies it; + * cancelling reverts the URL back to the loaded view. */ - const [loadedRevisionTime, setLoadedRevisionTime] = useState( + const [pendingView, setPendingView] = useState( null, ); + /** + * UUID of the entity we just saved a draft into and are now navigating + * to via `router.replace`. While set, the reconciliation effect ignores + * the stale draft `view` until Next.js's router catches up to the new URL. + */ + const expectedSavedUuidRef = useRef(null); + const { revisions, refetch: refetchRevisions } = usePetriNetRevisions(selectedNetId); const { - isDirty, loadPersistedNet, + persistDefinition, persistedNets, persistedNetsLoading, - persistPending, - persistToGraph, - userEditable, setUserEditable, + userEditable, } = useProcessSaveAndLoad({ - petriNet: petriNetDefinition, refetchRevisions, selectedNetId, - setLoadedRevisionTime, - setPetriNet, setSelectedNetId, - setTitle, - title, }); /** - * Apply a {@link ProcessEditorView} to the editor state, replacing the - * current handle/title/selectedNetId. Used both for the initial load and - * whenever the URL navigates to a different view. + * Resolve the incoming `view` prop into the data the iframe needs (SDCPN, + * title, mode, savedSnapshot). Returns `null` while we're still waiting + * for `persistedNets` to load (saved view) — which the reconciliation + * effect treats as a "not yet ready" signal. */ - const applyView = useCallback( - (target: ProcessEditorView) => { + const resolveView = useCallback( + (target: ProcessEditorView): ResolvedView | null => { if (target.kind === "draft") { const seedTitle = target.seed?.title ?? "Process"; const seedDefinition = target.seed?.petriNetDefinition ?? emptySDCPN; - setHandle(createJsonDocHandle({ initial: seedDefinition })); - setTitle(seedTitle); - setSelectedNetId(null); - setUserEditable(true); - setLoadedRevisionTime(null); - setLoadedView({ kind: "draft", seedKey: target.seedKey }); - return; + return { + loadedView: { kind: "draft", seedKey: target.seedKey }, + definition: seedDefinition, + title: seedTitle, + mode: { kind: "draft", seedKey: target.seedKey }, + savedSnapshot: null, + }; } const targetNet = persistedNets.find( @@ -216,46 +263,185 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { extractEntityUuidFromEntityId(net.entityId) === target.entityUuid, ); if (!targetNet) { - return; + return null; } - loadPersistedNet(targetNet); - setLoadedView({ kind: "saved", entityId: targetNet.entityId }); + return { + loadedView: { kind: "saved", entityId: targetNet.entityId }, + definition: targetNet.definition, + title: targetNet.title, + mode: { + kind: "saved", + entityId: targetNet.entityId, + userEditable: targetNet.userEditable, + }, + savedSnapshot: { + definition: targetNet.definition, + title: targetNet.title, + decisionTime: targetNet.lastUpdated, + }, + }; }, - [ - loadPersistedNet, - persistedNets, - setSelectedNetId, - setTitle, - setUserEditable, - ], + [persistedNets], ); + const bridge = useHostBridge({ + iframeRef, + handlers: { + onDirtyChanged: setIsDirty, + onRequestNavigateBack: () => { + void router.push("/processes"); + }, + onRequestRevision: (decisionTime) => { + const revision = revisions.find( + (rev) => rev.decisionTime === decisionTime, + ); + if (!revision || !loadedView || loadedView.kind !== "saved") { + return; + } + bridge.send({ + kind: "load", + definition: revision.definition, + title: revision.title, + mode: { + kind: "saved", + entityId: loadedView.entityId, + userEditable, + }, + savedSnapshot: { + definition: revision.definition, + title: revision.title, + decisionTime: revision.decisionTime, + }, + revisions: buildRevisionSummaries(revisions), + }); + }, + onReportError: ({ source, name, message, stack, mode }) => { + /** + * Reconstruct an Error from the iframe's serialised payload so + * Sentry's stack-trace processing has something to chew on. The + * synthetic Error's stack is replaced with the iframe's own, + * which Sentry will resolve against the same source maps as the + * embed-page bundle (uploaded as part of the host's release). + */ + const reconstructed = Object.assign(new Error(message), { + name, + stack, + }); + Sentry.captureException(reconstructed, { + tags: { + "petrinaut.source": source, + "petrinaut.mode": mode?.kind ?? "unknown", + }, + contexts: { + petrinaut: { mode }, + }, + }); + }, + onRequestSave: ({ requestId, definition, title }) => { + const wasCreate = selectedNetId === null; + void persistDefinition(definition, title) + .then((result) => { + if (wasCreate) { + expectedSavedUuidRef.current = extractEntityUuidFromEntityId( + result.entityId, + ); + setLoadedView({ kind: "saved", entityId: result.entityId }); + void router.replace( + `/processes/${extractEntityUuidFromEntityId(result.entityId)}`, + ); + } + bridge.send({ + kind: "saveResult", + requestId, + result: { + ok: true, + mode: { + kind: "saved", + entityId: result.entityId, + userEditable: result.userEditable, + }, + savedSnapshot: { + definition, + title, + decisionTime: result.decisionTime, + }, + revisions: buildRevisionSummaries(revisions), + }, + }); + }) + .catch((error: unknown) => { + bridge.send({ + kind: "saveResult", + requestId, + result: { + ok: false, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + }, + }, + }); + /** - * Pending view-change waiting on user confirmation, set when the URL - * changed away from a dirty editor state. Confirming applies it; - * cancelling reverts the URL back to the loaded view. + * Apply a resolved view: mirror local host state used by the save flow, + * record the new `loadedView`. Returns the resolved view so the caller + * can issue the matching `init` / `load` message. */ - const [pendingView, setPendingView] = useState( - null, + const adoptResolvedView = useCallback( + (resolved: ResolvedView) => { + if (resolved.loadedView.kind === "saved") { + const entityId = resolved.loadedView.entityId; + const targetNet = persistedNets.find( + (net) => net.entityId === entityId, + ); + if (targetNet) { + loadPersistedNet(targetNet); + } + } else { + setSelectedNetId(null); + setUserEditable(true); + } + setLoadedView(resolved.loadedView); + }, + [loadPersistedNet, persistedNets, setUserEditable], ); /** - * UUID of the entity we just saved a draft into and are now navigating to - * via `router.replace`. While set, the reconciliation effect ignores the - * stale draft `view` until Next.js's router catches up to the new URL. - * - * Without this guard, the brief window where `loadedView` is `"saved"` but - * `view` is still `"draft"` would otherwise look like a "user discarded the - * draft to navigate elsewhere" change and trigger the discard-changes modal. + * Bootstrap: on the first render where the iframe is ready, the view has + * resolved from the URL, and we have all the data we need to materialise + * it, push `init`. Subsequent view changes (including URL navigation) + * flow through the reconciliation effect below as `load`. */ - const expectedSavedUuidRef = useRef(null); + useEffect(() => { + if (!bridge.isReady || loadedView !== null || view === null) { + return; + } + const resolved = resolveView(view); + if (!resolved) { + return; + } + adoptResolvedView(resolved); + + bridge.send({ + kind: "init", + initialDefinition: resolved.definition, + initialTitle: resolved.title, + readonly: + resolved.mode.kind === "saved" ? !resolved.mode.userEditable : false, + mode: resolved.mode, + savedSnapshot: resolved.savedSnapshot, + revisions: buildRevisionSummaries(revisions), + }); + }, [adoptResolvedView, bridge, loadedView, resolveView, revisions, view]); /** - * Reconciles the incoming `view` prop with the editor's `loadedView`. + * Reconciles the incoming `view` prop with the editor's `loadedView` for + * subsequent navigations. * * Three outcomes: * - Already loaded: no-op. - * - Mismatch and not dirty: apply immediately. + * - Mismatch and not dirty: send `load` immediately. * - Mismatch and dirty: stash as `pendingView` and surface the discard * modal. If the user cancels, the URL is reverted to the loaded view. * @@ -263,6 +449,10 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { * for the next render once the query resolves. */ useEffect(() => { + if (!bridge.isReady || loadedView === null || view === null) { + return; + } + if (expectedSavedUuidRef.current !== null) { if ( view.kind === "saved" && @@ -278,7 +468,8 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { return; } } - if (loadedView && viewMatchesLoaded(view, loadedView)) { + + if (viewMatchesLoaded(view, loadedView)) { return; } if (view.kind === "saved" && persistedNetsLoading) { @@ -293,24 +484,67 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { ) { return; } - if (loadedView !== null && isDirty) { + + if (isDirty) { setPendingView(view); return; } - applyView(view); + + const resolved = resolveView(view); + if (!resolved) { + return; + } + adoptResolvedView(resolved); + + bridge.send({ + kind: "load", + definition: resolved.definition, + title: resolved.title, + mode: resolved.mode, + savedSnapshot: resolved.savedSnapshot, + revisions: buildRevisionSummaries(revisions), + }); }, [ - view, + adoptResolvedView, + bridge, + isDirty, loadedView, persistedNets, persistedNetsLoading, - isDirty, - applyView, + resolveView, + revisions, + view, ]); + /** + * Whenever the host's revision list refreshes (via Apollo's cache) push + * it down to the iframe so the version picker stays current. + */ + useEffect(() => { + if (!bridge.isReady || loadedView === null) { + return; + } + bridge.send({ + kind: "revisionsList", + revisions: buildRevisionSummaries(revisions), + }); + }, [bridge, loadedView, revisions]); + + /** + * Mirror updated `userEditable` permission to the iframe (e.g. the + * persisted-net record was refreshed and permissions changed). + */ + useEffect(() => { + if (!bridge.isReady || loadedView === null) { + return; + } + bridge.send({ kind: "setReadonly", readonly: !userEditable }); + }, [bridge, loadedView, userEditable]); + /** * Browser-level dirty guard: warns when the user tries to close the tab, * reload, or follow an external link with unsaved changes. SPA-internal - * navigation is handled separately via the {@link AlertModal} above. + * navigation is handled separately via the {@link AlertModal} below. */ useEffect(() => { if (!isDirty) { @@ -330,120 +564,41 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { }, [isDirty]); /** - * Replace the editor state with a past revision of the active entity. - * Doesn't change `selectedNetId` — it's still the same entity, just - * pinned to an older decision time. Subsequent edits + save create a - * new top revision on the existing baseId (linear-edit model). - * - * Mutates the existing handle in place via `change()` rather than - * recreating it through `setPetriNet`. A fresh handle would force a - * full editor remount (Petrinaut keys worker providers on `handle.id`). + * Apply a stashed pending view (after the user confirmed discard). */ - const loadRevision = useCallback( - (revision: PetriNetRevision) => { - handle.change((draft) => { - for (const key of Object.keys(SDCPN_FIELDS) as (keyof SDCPN)[]) { - copySdcpnField(draft, revision.definition, key); - } + const applyPendingView = useCallback( + (target: ProcessEditorView) => { + const resolved = resolveView(target); + if (!resolved) { + return; + } + adoptResolvedView(resolved); + + // Drop the dirty flag eagerly so the modal doesn't immediately retrigger + // before the iframe's `dirtyChanged` flushes after the new load. + setIsDirty(false); + + bridge.send({ + kind: "load", + definition: resolved.definition, + title: resolved.title, + mode: resolved.mode, + savedSnapshot: resolved.savedSnapshot, + revisions: buildRevisionSummaries(revisions), }); - setTitle(revision.title); - setLoadedRevisionTime(revision.decisionTime); }, - [handle, setTitle], + [adoptResolvedView, bridge, resolveView, revisions], ); /** - * Latest `persistToGraph` callback. Stored in a ref so the Save button - * closure stays stable while still seeing the freshest reference. + * Show the skeleton until the iframe has signalled `ready` AND we've + * pushed the bootstrap `init` message (i.e. the iframe is rendering + * Petrinaut against the right SDCPN). There's a small visual gap + * between sending `init` and Petrinaut actually painting its panels; + * we accept that flash rather than introducing an extra + * "editor-painted" handshake message. */ - const persistToGraphRef = useRef(persistToGraph); - persistToGraphRef.current = persistToGraph; - - const handleSaveClick = useCallback(async () => { - const wasCreate = selectedNetId === null; - const persistedEntityId = await persistToGraphRef.current(); - if (wasCreate && persistedEntityId) { - // The `useProcessSaveAndLoad` hook has already set `selectedNetId`, - // so we update `loadedView` synchronously here too — that way once the - // URL catches up, `viewMatchesLoaded` short-circuits the reconciliation - // effect. - const entityUuid = extractEntityUuidFromEntityId(persistedEntityId); - // Suppress reconciliation until the router catches up. Without this, - // the brief window where `view` is still `"draft"` but `loadedView` - // is `"saved"` would surface the discard-changes modal. - expectedSavedUuidRef.current = entityUuid; - setLoadedView({ kind: "saved", entityId: persistedEntityId }); - void router.replace(`/processes/${entityUuid}`); - } - }, [router, selectedNetId]); - - /** - * Top-bar content injected into Petrinaut via the `slots` API: - * - `topBarStart`: a back-arrow button returning to `/processes`. - * - `topBarEnd`: - * - `VersionPicker` — shows the active server revision (vN), a `Draft` - * badge when local edits diverge from the latest revision, and a - * dropdown to browse history. Hidden entirely when there are no - * saved revisions yet (i.e. brand-new net). - * - The Save/Create button — disabled until there's something to save. - * - * Both end-slot controls are hidden when the active net is not - * user-editable; we don't surface a "save as copy" affordance from here - * today. - */ - const slots = useMemo(() => { - const backButton = ( - - - ), - }; - }, [ - handleSaveClick, - isDirty, - loadRevision, - loadedRevisionTime, - persistPending, - revisions, - router, - selectedNetId, - userEditable, - ]); + const isLoading = !bridge.isReady || loadedView === null; return ( @@ -452,7 +607,7 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { callback={() => { const target = pendingView; setPendingView(null); - applyView(target); + applyPendingView(target); }} calloutMessage="You have unsaved changes which will be discarded. Are you sure you want to switch?" confirmButtonText="Discard" @@ -472,18 +627,32 @@ export const ProcessEditor = ({ view }: { view: ProcessEditorView }) => { /> )} - - + + + {isLoading && } ); diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx index 759bbf12c55..c738729d6eb 100644 --- a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx +++ b/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/use-process-save-and-load.tsx @@ -7,7 +7,6 @@ import { useState, } from "react"; -import { isSDCPNEqual } from "@hashintel/petrinaut"; import { HashEntity } from "@local/hash-graph-sdk/entity"; import { blockProtocolDataTypes, @@ -39,45 +38,48 @@ import type { import type { SDCPN } from "@hashintel/petrinaut"; import type { PetriNetPropertiesWithMetadata } from "@local/hash-isomorphic-utils/system-types/petrinet"; -export type { PersistedNet } from "../../../processes.page/use-persisted-nets"; +/** + * Result of {@link persistDefinition}. The caller (the bridge layer in + * `process-editor.tsx`) needs both the new entity id and the freshly-saved + * snapshot to ack the iframe's pending save request, so we return both. + */ +export type PersistResult = { + entityId: EntityId; + /** + * Decision-time recorded for the just-saved revision, sourced from the + * refetched persisted-nets list. + */ + decisionTime: string; + userEditable: boolean; +}; type UseProcessSaveAndLoadParams = { - petriNet: SDCPN; selectedNetId: EntityId | null; - /** - * Replace the entire active net with a new SDCPN. Internally the consumer - * recreates the document handle, which resets undo/redo history — so this - * is intended for net-switch / load flows, not user mutations. - */ - setPetriNet: (sdcpn: SDCPN) => void; setSelectedNetId: Dispatch>; - setLoadedRevisionTime: Dispatch>; - setTitle: Dispatch>; - title: string; refetchRevisions: () => Promise; }; +/** + * Encapsulates the GraphQL persistence + persisted-nets-list reads needed by + * the process editor host. The iframe owns the live SDCPN and title; the + * host calls {@link persistDefinition} when forwarding a `requestSave` from + * the bridge. + */ export const useProcessSaveAndLoad = ({ - petriNet, selectedNetId, setSelectedNetId, - setLoadedRevisionTime, - setPetriNet, - setTitle, - title, refetchRevisions, }: UseProcessSaveAndLoadParams): { - isDirty: boolean; loadPersistedNet: (net: PersistedNet) => void; persistedNets: PersistedNet[]; persistedNetsLoading: boolean; - persistPending: boolean; /** - * Persist the active net. On success, resolves with the entity id of the - * persisted (created or updated) entity; the caller is responsible for any - * URL navigation needed after a create. + * Persist the supplied SDCPN + title to the graph. On success resolves + * with the persisted entity id, the new decision-time, and whether the + * resulting entity is editable by the current user. The caller is + * responsible for any URL navigation following a create. */ - persistToGraph: () => Promise; + persistDefinition: (petriNet: SDCPN, title: string) => Promise; setUserEditable: Dispatch>; userEditable: boolean; } => { @@ -89,8 +91,6 @@ export const useProcessSaveAndLoad = ({ const { activeWorkspaceWebId } = useActiveWorkspace(); - const [persistPending, setPersistPending] = useState(false); - const [userEditable, setUserEditable] = useState(true); const [createEntity] = useMutation< @@ -103,40 +103,23 @@ export const useProcessSaveAndLoad = ({ UpdateEntityMutationVariables >(updateEntityMutation); - const persistedNet = useMemo(() => { - return persistedNets.find((net) => net.entityId === selectedNetId); - }, [persistedNets, selectedNetId]); - - const isDirty = useMemo(() => { - if (!persistedNet) { - return true; - } - - return ( - title !== persistedNet.title || - !isSDCPNEqual(petriNet, persistedNet.definition) - ); - }, [petriNet, persistedNet, title]); - const loadPersistedNet = useCallback( (net: PersistedNet) => { setSelectedNetId(net.entityId); - setPetriNet(net.definition); - setTitle(net.title); setUserEditable(net.userEditable); - setLoadedRevisionTime(net.lastUpdated); }, - [ - setLoadedRevisionTime, - setPetriNet, - setSelectedNetId, - setTitle, - setUserEditable, - ], + [setSelectedNetId, setUserEditable], ); const refetchPersistedNets = useCallback( - async ({ updatedEntityId }: { updatedEntityId: EntityId | null }) => { + async ({ + updatedEntityId, + }: { + updatedEntityId: EntityId | null; + }): Promise<{ + decisionTime: string; + userEditable: boolean; + } | null> => { const [updatedNetsData] = await Promise.all([ refetch(), // For updates `selectedNetId` is unchanged, so Apollo won't @@ -149,35 +132,34 @@ export const useProcessSaveAndLoad = ({ updatedNetsData.data, ); - if (updatedEntityId) { - const updatedNet = transformedNets.find( - (net) => net.entityId === updatedEntityId, - ); + if (!updatedEntityId) { + return null; + } - if (updatedNet) { - setSelectedNetId(updatedNet.entityId); - setUserEditable(updatedNet.userEditable); - setLoadedRevisionTime(updatedNet.lastUpdated); - } + const updatedNet = transformedNets.find( + (net) => net.entityId === updatedEntityId, + ); + + if (!updatedNet) { + return null; } + + setSelectedNetId(updatedNet.entityId); + setUserEditable(updatedNet.userEditable); + return { + decisionTime: updatedNet.lastUpdated, + userEditable: updatedNet.userEditable, + }; }, - [ - refetch, - refetchRevisions, - setLoadedRevisionTime, - setSelectedNetId, - setUserEditable, - ], + [refetch, refetchRevisions, setSelectedNetId, setUserEditable], ); - const persistToGraph = useCallback(async (): Promise => { - if (!activeWorkspaceWebId) { - return null; - } - - setPersistPending(true); + const persistDefinition = useCallback( + async (petriNet: SDCPN, title: string): Promise => { + if (!activeWorkspaceWebId) { + throw new Error("No active workspace; cannot persist Petri net."); + } - try { let persistedEntityId = selectedNetId; if (selectedNetId) { @@ -251,43 +233,45 @@ export const useProcessSaveAndLoad = ({ throw new Error("Somehow no entityId available after persisting net"); } - await refetchPersistedNets({ updatedEntityId: persistedEntityId }); - setSelectedNetId(persistedEntityId); - setUserEditable(true); + const refetched = await refetchPersistedNets({ + updatedEntityId: persistedEntityId, + }); - return persistedEntityId; - } finally { - setPersistPending(false); - } - }, [ - activeWorkspaceWebId, - createEntity, - petriNet, - refetchPersistedNets, - selectedNetId, - setSelectedNetId, - title, - updateEntity, - ]); + if (!refetched) { + throw new Error( + "Persist appeared to succeed but the entity wasn't visible on refetch.", + ); + } + + return { + entityId: persistedEntityId, + decisionTime: refetched.decisionTime, + userEditable: refetched.userEditable, + }; + }, + [ + activeWorkspaceWebId, + createEntity, + refetchPersistedNets, + selectedNetId, + updateEntity, + ], + ); return useMemo( () => ({ - isDirty, loadPersistedNet, persistedNets, persistedNetsLoading, - persistPending, - persistToGraph, + persistDefinition, setUserEditable, userEditable, }), [ - isDirty, loadPersistedNet, - persistPending, + persistDefinition, persistedNets, persistedNetsLoading, - persistToGraph, setUserEditable, userEditable, ], diff --git a/apps/hash-frontend/src/pages/processes/[uuid]/embed.page.tsx b/apps/hash-frontend/src/pages/processes/[uuid]/embed.page.tsx new file mode 100644 index 00000000000..42352b7dc0b --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/[uuid]/embed.page.tsx @@ -0,0 +1,34 @@ +import dynamic from "next/dynamic"; + +import type { NextPageWithLayout } from "../../../shared/layout"; + +/** + * Petrinaut needs Web Workers, Canvas, Monaco Editor, and the TypeScript + * compiler — all browser-only. Dynamic-imported so this route never SSRs + * and we don't pay the Petrinaut import cost on the parent process page. + */ +const EmbedContent = dynamic( + () => import("./embed.page/embed-content").then((mod) => mod.EmbedContent), + { ssr: false }, +); + +/** + * Petrinaut embed route. Loaded into a sandboxed null-origin iframe by the + * host process editor (`/processes/[uuid].page/process-editor.tsx`); the + * route's stricter CSP allows `'unsafe-eval'` so user-provided code can be + * compiled, contained safely away from the parent HASH origin's cookies, + * storage, and APIs. + * + * The route's URL parameter (`uuid`) is unused — the host drives all net + * loads via the postMessage bridge defined in `../shared/`. + * + * `_app.page.tsx` short-circuits this pathname to a minimal provider shell + * (Emotion + MUI theme + Snackbar + ErrorBoundary), so we use a no-op + * `getLayout` here — `getPlainLayout` would call `useAuthInfo()`, which + * would throw because `AuthInfoProvider` isn't mounted on this route. + * + * @see {@link buildEmbedCspHeader} + */ +const PetrinautEmbedPage: NextPageWithLayout = () => ; + +export default PetrinautEmbedPage; diff --git a/apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-content.tsx b/apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-content.tsx new file mode 100644 index 00000000000..7fd0354bf95 --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/[uuid]/embed.page/embed-content.tsx @@ -0,0 +1,337 @@ +import "@hashintel/petrinaut/dist/main.css"; +import { Box } from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Button } from "@hashintel/ds-components"; +import { + createJsonDocHandle, + isSDCPNEqual, + Petrinaut, + type PetrinautDocHandle, + type PetrinautSlots, + type SDCPN, +} from "@hashintel/petrinaut"; + +import { setIframeErrorReporterMode } from "../../shared/iframe-error-reporter"; +import { + type HostNetMode, + nextRequestId, + type RevisionSummary, + type SavedSnapshot, +} from "../../shared/messages"; +import { useIframeBridge } from "../../shared/use-iframe-bridge"; +import { VersionPicker } from "./version-picker"; + +const noNetSwitchingError = () => { + throw new Error( + "Net switching from inside the Petrinaut iframe is not supported; " + + "the host (process-editor) drives all net loads via the bridge.", + ); +}; + +type EditorState = { + handle: PetrinautDocHandle; + title: string; + readonly: boolean; + mode: HostNetMode; + savedSnapshot: SavedSnapshot; +}; + +const computeIsDirty = ( + definition: SDCPN, + title: string, + savedSnapshot: SavedSnapshot, +): boolean => { + if (!savedSnapshot) { + return true; + } + return ( + title !== savedSnapshot.title || + !isSDCPNEqual(definition, savedSnapshot.definition) + ); +}; + +/** + * Petrinaut embed content — runs inside a sandboxed null-origin iframe. + * + * Owns the doc handle, title, panels, simulation/Monte-Carlo workers, + * Monaco, and dirty tracking. The host (`process-editor.tsx`) only handles + * persistence and routing, communicating via the postMessage bridge. + * + * This component is dynamic-imported (`ssr: false`) by `embed.page.tsx` + * because Petrinaut needs Web Workers, Canvas, Monaco, and the TypeScript + * compiler — all browser-only. + */ +export const EmbedContent = () => { + const [state, setState] = useState(null); + const [revisions, setRevisions] = useState([]); + const [pendingSaveRequestId, setPendingSaveRequestId] = useState< + string | null + >(null); + const [isDirty, setIsDirty] = useState(false); + + /** + * Latest saved snapshot kept in a ref so the per-handle `subscribe` + * listener can read it without re-attaching whenever the snapshot + * changes. + */ + const savedSnapshotRef = useRef(null); + savedSnapshotRef.current = state?.savedSnapshot ?? null; + + /** + * Same pattern for the title — read by the handle subscriber. + */ + const titleRef = useRef(""); + titleRef.current = state?.title ?? ""; + + const bridge = useIframeBridge({ + onInit: (payload) => { + const handle = createJsonDocHandle({ + initial: payload.initialDefinition, + }); + setState({ + handle, + title: payload.initialTitle, + readonly: payload.readonly, + mode: payload.mode, + savedSnapshot: payload.savedSnapshot, + }); + setRevisions(payload.revisions); + setIsDirty( + computeIsDirty( + payload.initialDefinition, + payload.initialTitle, + payload.savedSnapshot, + ), + ); + setIframeErrorReporterMode(payload.mode); + /** + * Reset any in-flight save state. A re-init means the host has + * decided to throw away whatever the iframe was doing (e.g. user + * navigated to a different net via URL while a save was pending). + */ + setPendingSaveRequestId(null); + }, + onLoad: (payload) => { + const handle = createJsonDocHandle({ initial: payload.definition }); + setState({ + handle, + title: payload.title, + readonly: + payload.mode.kind === "saved" ? !payload.mode.userEditable : false, + mode: payload.mode, + savedSnapshot: payload.savedSnapshot, + }); + setRevisions(payload.revisions); + setIsDirty( + computeIsDirty( + payload.definition, + payload.title, + payload.savedSnapshot, + ), + ); + setIframeErrorReporterMode(payload.mode); + setPendingSaveRequestId(null); + }, + onSetReadonly: (readonly) => { + setState((prev) => (prev ? { ...prev, readonly } : prev)); + }, + onRevisionsList: (incoming) => { + setRevisions(incoming); + }, + onSaveResult: (payload) => { + setPendingSaveRequestId((prev) => + prev === payload.requestId ? null : prev, + ); + if (payload.result.ok) { + const { + savedSnapshot, + mode, + revisions: updatedRevisions, + } = payload.result; + setRevisions(updatedRevisions); + setState((prev) => + prev + ? { + ...prev, + mode, + savedSnapshot, + } + : prev, + ); + setIframeErrorReporterMode(mode); + const currentDoc = state?.handle.doc(); + const currentTitle = state?.title ?? savedSnapshot.title; + if (currentDoc) { + setIsDirty(computeIsDirty(currentDoc, currentTitle, savedSnapshot)); + } + } + }, + }); + + /** + * Subscribe to handle mutations to keep `isDirty` in sync. Re-attaches + * whenever the handle is replaced (e.g. after `load` / `init`). + */ + useEffect(() => { + const handle = state?.handle; + if (!handle) { + return; + } + return handle.subscribe((event) => { + const dirty = computeIsDirty( + event.next, + titleRef.current, + savedSnapshotRef.current, + ); + setIsDirty((prev) => (prev === dirty ? prev : dirty)); + }); + }, [state?.handle]); + + /** + * Title changes always recompute dirty (it's part of the comparison) and + * always emit `titleChanged` so the host can mirror the document title. + */ + useEffect(() => { + if (!state) { + return; + } + bridge.send({ kind: "titleChanged", title: state.title }); + const currentDoc = state.handle.doc(); + if (currentDoc) { + const dirty = computeIsDirty( + currentDoc, + state.title, + state.savedSnapshot, + ); + setIsDirty((prev) => (prev === dirty ? prev : dirty)); + } + }, [bridge, state]); + + /** + * Mirror dirty state to the host. The host caches it for the discard- + * changes modal and the `beforeunload` guard. + */ + useEffect(() => { + if (!state) { + return; + } + bridge.send({ kind: "dirtyChanged", isDirty }); + }, [bridge, isDirty, state]); + + const handleSetTitle = useCallback((title: string) => { + setState((prev) => (prev ? { ...prev, title } : prev)); + }, []); + + const handleSaveClick = useCallback(() => { + if (!state || pendingSaveRequestId) { + return; + } + const definition = state.handle.doc(); + if (!definition) { + return; + } + const requestId = nextRequestId(); + setPendingSaveRequestId(requestId); + bridge.send({ + kind: "requestSave", + requestId, + definition, + title: state.title, + }); + }, [bridge, pendingSaveRequestId, state]); + + const handleNavigateBack = useCallback(() => { + bridge.send({ kind: "requestNavigateBack" }); + }, [bridge]); + + const handleLoadRevision = useCallback( + (revision: RevisionSummary) => { + bridge.send({ + kind: "requestRevision", + decisionTime: revision.decisionTime, + }); + }, + [bridge], + ); + + const persistPending = pendingSaveRequestId !== null; + + const slots = useMemo(() => { + const backButton = ( + + + ), + }; + }, [ + handleLoadRevision, + handleNavigateBack, + handleSaveClick, + isDirty, + persistPending, + revisions, + state, + ]); + + if (!state) { + /** + * Host is expected to send `init` immediately after the iframe's + * `ready`. A blank box is fine for that brief window — anything more + * elaborate (spinner, etc.) tends to flash visibly on every load. + */ + return ; + } + + return ( + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/version-picker.tsx b/apps/hash-frontend/src/pages/processes/[uuid]/embed.page/version-picker.tsx similarity index 86% rename from apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/version-picker.tsx rename to apps/hash-frontend/src/pages/processes/[uuid]/embed.page/version-picker.tsx index a86f0cc1f10..ca1fba28f97 100644 --- a/apps/hash-frontend/src/pages/processes/[uuid].page/process-editor/version-picker.tsx +++ b/apps/hash-frontend/src/pages/processes/[uuid]/embed.page/version-picker.tsx @@ -17,13 +17,12 @@ import { FontAwesomeIcon } from "@hashintel/design-system"; import { MenuItem } from "../../../../shared/ui/menu-item"; -import type { PetriNetRevision } from "./use-process-save-and-load/use-petri-net-revisions"; +import type { RevisionSummary } from "../../shared/messages"; /** - * Locale is left `undefined` so `Intl.DateTimeFormat` picks up the browser's preference - * - * The host renders client-side only (`[uuid].page.tsx` uses `dynamic` with `ssr: false`), - * so there's no SSR/hydration mismatch risk from the runtime locale. + * Locale is left `undefined` so `Intl.DateTimeFormat` picks up the browser's + * preference. The embed page renders client-side only (Petrinaut requires + * browser APIs), so there's no SSR/hydration mismatch risk. */ const dateFormatter = new Intl.DateTimeFormat(undefined, { day: "2-digit", @@ -76,33 +75,37 @@ const DraftBadge = () => ( ); -export type VersionPickerProps = { +type VersionPickerProps = { /** - * All saved revisions of the active entity, newest first. The version - * label is derived as `revisions.length - index` (so the entry at - * index 0 is "vN", the latest, and the last entry is "v1"). + * Revision summaries from the host (newest first). The version label is + * derived as `revisions.length - index` (so index 0 is "vN", the latest). */ - revisions: PetriNetRevision[]; + revisions: RevisionSummary[]; /** - * Decision-time of the revision currently loaded in the editor, or - * `null` if the active net is unsaved. + * Decision-time of the revision the editor is currently mirrored against, + * or `null` if the active net is unsaved or has no saved revisions yet. */ loadedRevisionTime: string | null; /** - * Whether the editor state diverges from the loaded revision. Used to - * surface the "Draft" badge — the linear-edit model means a save while - * dirty creates a new top revision (vN+1). + * Whether the editor state diverges from the loaded revision. Drives the + * "Draft" badge — a save while dirty creates a new top revision (vN+1). */ isDirty: boolean; - onLoadRevision: (revision: PetriNetRevision) => void; + onLoadRevision: (revision: RevisionSummary) => void; }; /** - * Top-bar control for browsing the server-side revision history of a persisted Petri net. + * Top-bar control for browsing the server-side revision history of a + * persisted Petri net. + * + * The picker itself lives inside the Petrinaut iframe (the host pushes the + * `revisions` summary list over the bridge); selecting a revision sends a + * `requestRevision` message back to the host, which fetches the full SDCPN + * and replies with `load`. * * In-memory undo/redo is handled separately by Petrinaut's - * `VersionHistoryButton` — these two histories intentionally don't share - * a surface (keystroke-level deltas vs persisted snapshots). + * `VersionHistoryButton` — these two histories intentionally don't share a + * surface (keystroke-level deltas vs persisted snapshots). */ export const VersionPicker = ({ revisions, diff --git a/apps/hash-frontend/src/pages/processes/shared/iframe-error-reporter.ts b/apps/hash-frontend/src/pages/processes/shared/iframe-error-reporter.ts new file mode 100644 index 00000000000..3d1602c6d75 --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/shared/iframe-error-reporter.ts @@ -0,0 +1,100 @@ +import type { HostNetMode, IframeToHostMessage } from "./messages"; + +type ReportSource = Extract< + IframeToHostMessage, + { kind: "reportError" } +>["source"]; + +let activeMode: HostNetMode | null = null; +let installed = false; + +const safeStringify = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch { + return Object.prototype.toString.call(value); + } +}; + +const serializeError = ( + raw: unknown, +): { name: string; message: string; stack: string | undefined } => { + if (raw instanceof Error) { + return { name: raw.name, message: raw.message, stack: raw.stack }; + } + if (typeof raw === "string") { + return { name: "Error", message: raw, stack: undefined }; + } + if (raw === null || raw === undefined) { + return { name: "Error", message: String(raw), stack: undefined }; + } + return { name: "Error", message: safeStringify(raw), stack: undefined }; +}; + +/** + * Posts a `reportError` message to the parent. No-op when the document is + * loaded outside an iframe (so directly-visiting `/processes//embed` + * doesn't recursively post errors to itself). + */ +const post = (source: ReportSource, raw: unknown) => { + if (typeof window === "undefined" || window === window.parent) { + return; + } + const message: IframeToHostMessage = { + kind: "reportError", + source, + ...serializeError(raw), + mode: activeMode, + }; + /** + * Target origin is "*" — see `use-iframe-bridge.ts` for why a stricter + * value isn't readable from a null-origin sandbox. + */ + window.parent.postMessage(message, "*"); +}; + +/** + * Idempotently wires `window.error` and `window.unhandledrejection` + * listeners that forward into the host's Sentry SDK. Intended to be + * called as early in the iframe document's lifetime as possible — see + * `instrumentation-client.ts` which calls it before any application code. + */ +export const installIframeErrorReporter = (): void => { + if (installed || typeof window === "undefined") { + return; + } + installed = true; + + window.addEventListener("error", (event) => { + /** + * Prefer `event.error` (the actual Error object) over `event.message`, + * but fall back to `message` for cross-origin script errors where + * `event.error` is `null` ("Script error."). + */ + post("window-error", event.error ?? event.message); + }); + + window.addEventListener("unhandledrejection", (event) => { + post("unhandled-rejection", event.reason); + }); +}; + +/** + * Records the active net mode so subsequent error reports include it. The + * iframe bridge calls this in response to `init` / `load` messages so the + * host can tag Sentry events with which net the user was editing when + * the error fired. + */ +export const setIframeErrorReporterMode = (mode: HostNetMode | null): void => { + activeMode = mode; +}; + +/** + * Forward an error caught by the embed app's React `ErrorBoundary`. The + * boundary's `onError` callback is the right hook for this because by the + * time `componentDidCatch` runs, the error has already been thrown out of + * React's render path and `window.error` won't fire for it. + */ +export const reportIframeReactError = (error: unknown): void => { + post("react", error); +}; diff --git a/apps/hash-frontend/src/pages/processes/shared/messages.ts b/apps/hash-frontend/src/pages/processes/shared/messages.ts new file mode 100644 index 00000000000..70a957e39ab --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/shared/messages.ts @@ -0,0 +1,206 @@ +import type { EntityId } from "@blockprotocol/type-system"; +import type { SDCPN } from "@hashintel/petrinaut"; + +/** + * Metadata about the active net surfaced by the host. Mirrors the shape the + * iframe needs to drive the version picker and to know what (if anything) it's + * currently editing on the server. + */ +export type HostNetMode = + | { kind: "draft"; seedKey: string | null } + | { kind: "saved"; entityId: EntityId; userEditable: boolean }; + +export type RevisionSummary = { + decisionTime: string; + title: string; +}; + +/** + * Snapshot the iframe should treat as the canonical "last-saved" state for + * the purposes of dirty-tracking. The iframe compares its live SDCPN against + * `definition` (and `title` against this title); when they diverge it emits + * `dirtyChanged: { isDirty: true }`. + * + * `null` means "there is no saved state to compare against" (a brand-new + * draft) — in that case the iframe treats every non-empty edit as dirty. + */ +export type SavedSnapshot = { + definition: SDCPN; + title: string; + /** Decision-time of the snapshot, used to drive the version picker. */ + decisionTime: string | null; +} | null; + +/** + * Messages sent by the host (process-editor) into the iframe. + */ +export type HostToIframeMessage = + | { + kind: "init"; + /** Initial SDCPN the iframe should load into its handle. */ + initialDefinition: SDCPN; + /** Initial title (mirrored into the editor's title state). */ + initialTitle: string; + /** Whether the editor should be read-only. */ + readonly: boolean; + mode: HostNetMode; + /** The "last-saved" snapshot at init time (null for unsaved drafts). */ + savedSnapshot: SavedSnapshot; + /** + * Initial revision list for the version picker (newest first). Empty + * for drafts and brand-new saved nets. + */ + revisions: RevisionSummary[]; + } + | { + /** + * Replace the editor's contents wholesale. Used when the user picks a + * past revision in the version picker (the host fetches the revision + * and forwards it), or when the URL navigates to a different net. + */ + kind: "load"; + definition: SDCPN; + title: string; + mode: HostNetMode; + savedSnapshot: SavedSnapshot; + /** + * Updated revision list. Included in `load` so a net-switch can swap + * content + revisions atomically without the version picker briefly + * showing the previous net's history. + */ + revisions: RevisionSummary[]; + } + | { + /** + * Update read-only state without touching the document (e.g. when the + * persisted-net record is refreshed and permissions changed). + */ + kind: "setReadonly"; + readonly: boolean; + } + | { + /** Push the latest revision list to the version picker. */ + kind: "revisionsList"; + revisions: RevisionSummary[]; + } + | { + /** + * Reply to a `requestSave`. On success carries the new entity id (the + * host has either created or updated the underlying entity) and the + * updated saved snapshot the iframe should treat as canonical. + */ + kind: "saveResult"; + requestId: string; + result: + | { + ok: true; + mode: HostNetMode; + savedSnapshot: NonNullable; + revisions: RevisionSummary[]; + } + | { ok: false; error: string }; + }; + +/** + * Messages sent by the iframe (Petrinaut + bridge) up to the host. + */ +export type IframeToHostMessage = + | { + /** + * Sent once after the iframe has mounted and its bridge is ready to + * receive messages. The host responds with `init`. + */ + kind: "ready"; + } + | { + /** + * Iframe-computed dirty flag (live SDCPN vs the `savedSnapshot` it last + * received). The host caches this for the discard-changes modal and the + * `beforeunload` guard. + */ + kind: "dirtyChanged"; + isDirty: boolean; + } + | { + /** + * Title is owned by the iframe; emitted on every change so the host + * can mirror it into the document title or into a heading rendered + * around the iframe. + */ + kind: "titleChanged"; + title: string; + } + | { + /** + * User clicked the save/create button. The host should persist + * `definition` + `title` to the graph and reply with `saveResult` — + * including on failure. The iframe waits for the matching `requestId` + * before un-disabling the save button. + */ + kind: "requestSave"; + requestId: string; + definition: SDCPN; + title: string; + } + | { + /** + * Back arrow click. The host typically navigates to `/processes`. + */ + kind: "requestNavigateBack"; + } + | { + /** + * User picked a revision in the version picker. The host looks up the + * revision in its already-fetched data and replies with `load`. + */ + kind: "requestRevision"; + decisionTime: string; + } + | { + /** + * Forwarded error from inside the iframe. The host's Sentry SDK + * captures it because the iframe's strict CSP blocks Sentry's own + * transport, and because attribution to the host's authenticated + * user is more useful than a free-standing iframe report. + * + * `name` / `message` / `stack` are extracted iframe-side from the + * thrown value so the host doesn't have to deal with non-Error + * `reason` values from `unhandledrejection` etc. + */ + kind: "reportError"; + source: "react" | "window-error" | "unhandled-rejection"; + name: string; + message: string; + stack: string | undefined; + /** + * Active net mode at the time of the error, if known. Lets the host + * tag the Sentry event with which net the user was editing. + */ + mode: HostNetMode | null; + }; + +export const isHostToIframeMessage = ( + data: unknown, +): data is HostToIframeMessage => + typeof data === "object" && + data !== null && + typeof (data as { kind?: unknown }).kind === "string"; + +export const isIframeToHostMessage = ( + data: unknown, +): data is IframeToHostMessage => + typeof data === "object" && + data !== null && + typeof (data as { kind?: unknown }).kind === "string"; + +let requestIdCounter = 0; + +/** + * Produces a process-local request id for matching `requestSave` -> `saveResult` + * round-trips. Doesn't need cross-tab uniqueness (each iframe has its own + * counter and the matching is done within a single host<->iframe pair). + */ +export const nextRequestId = (): string => { + requestIdCounter += 1; + return `req-${Date.now()}-${requestIdCounter}`; +}; diff --git a/apps/hash-frontend/src/pages/processes/shared/use-host-bridge.ts b/apps/hash-frontend/src/pages/processes/shared/use-host-bridge.ts new file mode 100644 index 00000000000..7602a996019 --- /dev/null +++ b/apps/hash-frontend/src/pages/processes/shared/use-host-bridge.ts @@ -0,0 +1,142 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { + type HostToIframeMessage, + type IframeToHostMessage, + isIframeToHostMessage, +} from "./messages"; + +type HostBridgeHandlers = { + onReady?: () => void; + onDirtyChanged?: (isDirty: boolean) => void; + onTitleChanged?: (title: string) => void; + onRequestSave?: ( + payload: Extract, + ) => void; + onRequestNavigateBack?: () => void; + onRequestRevision?: (decisionTime: string) => void; + onReportError?: ( + payload: Extract, + ) => void; +}; + +type HostBridge = { + /** + * Whether the iframe has signalled `ready`. The host should not call `send` + * until this is `true` (sending earlier would race the iframe's mount and + * the message would be dropped). + */ + isReady: boolean; + /** Send a message into the iframe. No-op if the iframe isn't mounted. */ + send: (message: HostToIframeMessage) => void; +}; + +/** + * Host-side hook that owns the postMessage channel to a Petrinaut iframe. + * + * - Listens for messages on `window` and dispatches them to the matching + * handler. Messages from any source other than `iframeRef.current.contentWindow` + * are dropped, so a sibling iframe or an unrelated window can't drive the + * bridge. + * - Tracks whether the iframe has signalled `ready`. Until then, `send()` is + * a no-op (and the host typically waits to push `init`). + * + * The hook intentionally treats `handlers` as a "snapshot" — it stores the + * latest handlers in a ref and re-reads them inside the message listener, so + * the listener doesn't need to be re-attached when handler identities change. + */ +export const useHostBridge = ({ + iframeRef, + handlers, +}: { + iframeRef: RefObject; + handlers: HostBridgeHandlers; +}): HostBridge => { + const [isReady, setIsReady] = useState(false); + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const iframeWindow = iframeRef.current?.contentWindow; + if (!iframeWindow || event.source !== iframeWindow) { + return; + } + + const data = event.data as unknown; + if (!isIframeToHostMessage(data)) { + return; + } + + const current = handlersRef.current; + switch (data.kind) { + case "ready": + setIsReady(true); + current.onReady?.(); + break; + case "dirtyChanged": + current.onDirtyChanged?.(data.isDirty); + break; + case "titleChanged": + current.onTitleChanged?.(data.title); + break; + case "requestSave": + current.onRequestSave?.(data); + break; + case "requestNavigateBack": + current.onRequestNavigateBack?.(); + break; + case "requestRevision": + current.onRequestRevision?.(data.decisionTime); + break; + case "reportError": + current.onReportError?.(data); + break; + } + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [iframeRef]); + + /** + * Reset readiness whenever the iframe element is swapped out (e.g. host + * navigates between draft and saved view by re-mounting the iframe). The + * `iframeRef.current` identity isn't reactive on its own, so consumers + * remount the `