From f435dedfe497001abf856eefe89134c2471868cd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 01:45:32 +0100 Subject: [PATCH 1/9] H-5839: Extract Monaco setup into dedicated module with async loading Replace the monolithic useMonacoGlobalTypings hook with a monaco/ module that loads Monaco and @monaco-editor/react asynchronously, disables all built-in TS workers, and provides a custom completion provider. CodeEditor uses React 19 Suspense with use() to show a loading placeholder until Monaco is ready. Co-Authored-By: Claude Opus 4.6 --- .../src/hooks/use-monaco-global-typings.ts | 380 ------------------ .../{components => monaco}/code-editor.tsx | 67 +-- .../petrinaut/src/monaco/context.ts | 12 + .../petrinaut/src/monaco/provider.tsx | 108 +++++ libs/@hashintel/petrinaut/src/petrinaut.tsx | 36 +- .../differential-equation-properties.tsx | 4 +- .../PropertiesPanel/place-properties.tsx | 46 +-- .../PropertiesPanel/transition-properties.tsx | 11 +- 8 files changed, 194 insertions(+), 470 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts rename libs/@hashintel/petrinaut/src/{components => monaco}/code-editor.tsx (63%) create mode 100644 libs/@hashintel/petrinaut/src/monaco/context.ts create mode 100644 libs/@hashintel/petrinaut/src/monaco/provider.tsx diff --git a/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts b/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts deleted file mode 100644 index 9dbfe496bde..00000000000 --- a/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { loader } from "@monaco-editor/react"; -import type * as Monaco from "monaco-editor"; -import { use, useEffect, useState } from "react"; - -import type { - Color, - DifferentialEquation, - Parameter, - Place, - Transition, -} from "../core/types/sdcpn"; -import { EditorContext } from "../state/editor-context"; -import { SDCPNContext } from "../state/sdcpn-context"; - -interface ReactTypeDefinitions { - react: string; - reactJsxRuntime: string; - reactDom: string; -} - -/** - * Fetch React type definitions from unpkg CDN - */ -async function fetchReactTypes(): Promise { - const [react, reactJsxRuntime, reactDom] = await Promise.all([ - fetch("https://unpkg.com/@types/react@18/index.d.ts").then((response) => - response.text(), - ), - fetch("https://unpkg.com/@types/react@18/jsx-runtime.d.ts").then( - (response) => response.text(), - ), - fetch("https://unpkg.com/@types/react-dom@18/index.d.ts").then((response) => - response.text(), - ), - ]); - - return { react, reactJsxRuntime, reactDom }; -} - -/** - * Convert a transition to a TypeScript definition string - */ -function transitionToTsDefinitionString( - transition: Transition, - placeIdToNameMap: Map, - placeIdToTypeMap: Map, -): string { - const input = - transition.inputArcs.length === 0 - ? "never" - : ` - {${transition.inputArcs - // Only include arcs whose places have defined types - .filter((arc) => placeIdToTypeMap.get(arc.placeId)) - .map((arc) => { - const placeTokenType = `SDCPNPlaces['${arc.placeId}']['type']['object']`; - return `"${placeIdToNameMap.get(arc.placeId)!}": [${Array.from({ length: arc.weight }).fill(placeTokenType).join(", ")}]`; - }) - .join(", ")} - }`; - - const output = - transition.outputArcs.length === 0 - ? "never" - : `{ - ${transition.outputArcs - // Only include arcs whose places have defined types - .filter((arc) => placeIdToTypeMap.get(arc.placeId)) - .map((arc) => { - const placeTokenType = `SDCPNPlaces['${arc.placeId}']['type']['object']`; - return `"${placeIdToNameMap.get(arc.placeId)!}": [${Array.from({ length: arc.weight }).fill(placeTokenType).join(", ")}]`; - }) - .join(", ")} - }`; - - return `{ - name: "${transition.name}"; - lambdaType: "${transition.lambdaType}"; - lambdaInputFn: (input: ${input}, parameters: SDCPNParametersValues) => ${transition.lambdaType === "predicate" ? "boolean" : "number"}; - transitionKernelFn: (input: ${input}, parameters: SDCPNParametersValues) => ${output}; - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN types - */ -function generateTypesDefinition(types: Color[]): string { - return `declare interface SDCPNTypes { - ${types - .map( - (type) => `"${type.id}": { - tuple: [${type.elements.map((el) => `${el.name}: ${el.type === "boolean" ? "boolean" : "number"}`).join(", ")}]; - object: { - ${type.elements - .map( - (el) => - `${el.name}: ${el.type === "boolean" ? "boolean" : "number"};`, - ) - .join("\n")} - }; - dynamicsFn: (input: SDCPNTypes["${type.id}"]["object"][], parameters: SDCPNParametersValues) => SDCPNTypes["${type.id}"]["object"][]; - }`, - ) - .join("\n")} - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN places - */ -function generatePlacesDefinition(places: Place[]): string { - return `declare interface SDCPNPlaces { - ${places - .map( - (place) => `"${place.id}": { - name: ${JSON.stringify(place.name)}; - type: ${place.colorId ? `SDCPNTypes["${place.colorId}"]` : "null"}; - dynamicsEnabled: ${place.dynamicsEnabled ? "true" : "false"}; - };`, - ) - .join("\n")}}`; -} - -/** - * Generate TypeScript type definitions for SDCPN transitions - */ -function generateTransitionsDefinition( - transitions: Transition[], - placeIdToNameMap: Map, - placeIdToTypeMap: Map, -): string { - return `declare interface SDCPNTransitions { - ${transitions - .map( - (transition) => - `"${transition.id}": ${transitionToTsDefinitionString(transition, placeIdToNameMap, placeIdToTypeMap)};`, - ) - .join("\n")} - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN differential equations - */ -function generateDifferentialEquationsDefinition( - differentialEquations: DifferentialEquation[], -): string { - return `declare interface SDCPNDifferentialEquations { - ${differentialEquations - .map( - (diffEq) => `"${diffEq.id}": { - name: ${JSON.stringify(diffEq.name)}; - typeId: "${diffEq.colorId}"; - type: SDCPNTypes["${diffEq.colorId}"]; - };`, - ) - .join("\n")} - }`; -} - -function generateParametersDefinition(parameters: Parameter[]): string { - return `{${parameters - .map( - (param) => - `"${param.variableName}": ${param.type === "boolean" ? "boolean" : "number"}`, - ) - .join(", ")}}`; -} - -/** - * Generate complete SDCPN type definitions - */ -function generateSDCPNTypings( - types: Color[], - places: Place[], - transitions: Transition[], - differentialEquations: DifferentialEquation[], - parameters: Parameter[], - currentlySelectedItemId?: string, -): string { - // Generate a map from place IDs to names for easier reference - const placeIdToNameMap = new Map( - places.map((place) => [place.id, place.name]), - ); - const typeIdToTypeMap = new Map(types.map((type) => [type.id, type])); - const placeIdToTypeMap = new Map( - places.map((place) => [ - place.id, - place.colorId ? typeIdToTypeMap.get(place.colorId) : undefined, - ]), - ); - - const parametersDefinition = generateParametersDefinition(parameters); - const globalTypesDefinition = generateTypesDefinition(types); - const placesDefinition = generatePlacesDefinition(places); - const transitionsDefinition = generateTransitionsDefinition( - transitions, - placeIdToNameMap, - placeIdToTypeMap, - ); - const differentialEquationsDefinition = - generateDifferentialEquationsDefinition(differentialEquations); - - return ` -declare type SDCPNParametersValues = ${parametersDefinition}; - -${globalTypesDefinition} - -${placesDefinition} - -${transitionsDefinition} - -${differentialEquationsDefinition} - -// Define Lambda and TransitionKernel functions - -declare type SDCPNTransitionID = keyof SDCPNTransitions; - -${ - currentlySelectedItemId - ? `type __SelectedTransitionID = "${currentlySelectedItemId}"` - : `type __SelectedTransitionID = SDCPNTransitionID` -}; - -declare function Lambda(fn: SDCPNTransitions[TransitionId]['lambdaInputFn']): void; - -declare function TransitionKernel(fn: SDCPNTransitions[TransitionId]['transitionKernelFn']): void; - - -// Define Dynamics function - -type SDCPNDiffEqID = keyof SDCPNDifferentialEquations; - -${ - currentlySelectedItemId - ? `type __SelectedDiffEqID = "${currentlySelectedItemId}"` - : `type __SelectedDiffEqID = SDCPNDiffEqID` -}; - -declare function Dynamics(fn: SDCPNDifferentialEquations[DiffEqId]['type']['dynamicsFn']): void; - - -// Define Visualizer function - -type SDCPNPlaceID = keyof SDCPNPlaces; - -${ - currentlySelectedItemId - ? `type __SelectedPlaceID = "${currentlySelectedItemId}"` - : `type __SelectedPlaceID = SDCPNPlaceID` -}; - -declare function Visualization(fn: (props: { tokens: SDCPNPlaces[PlaceId]['type']['object'][], parameters: SDCPNParametersValues }) => React.JSX.Element): void; - - `.trim(); -} - -/** - * Configure Monaco TypeScript compiler options - */ -function configureMonacoCompilerOptions(monaco: typeof Monaco): void { - const ts = monaco.typescript; - - ts.typescriptDefaults.setCompilerOptions({ - target: ts.ScriptTarget.ES2020, - allowNonTsExtensions: true, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - module: ts.ModuleKind.ESNext, - noEmit: true, - esModuleInterop: true, - jsx: ts.JsxEmit.ReactJSX, - allowJs: false, - checkJs: false, - typeRoots: ["node_modules/@types"], - }); - - ts.javascriptDefaults.setCompilerOptions({ - target: ts.ScriptTarget.ES2020, - allowNonTsExtensions: true, - noEmit: true, - allowJs: true, - checkJs: false, - }); - - ts.typescriptDefaults.setDiagnosticsOptions({ - noSemanticValidation: false, - noSyntaxValidation: false, - }); -} - -/** - * Global hook to update Monaco's TypeScript context with SDCPN-derived typings. - * Should be called once at the app level to avoid race conditions. - */ -export function useMonacoGlobalTypings() { - const { - petriNetDefinition: { - types, - transitions, - parameters, - places, - differentialEquations, - }, - } = use(SDCPNContext); - - const { selectedResourceId: currentlySelectedItemId } = use(EditorContext); - - const [reactTypes, setReactTypes] = useState( - null, - ); - - // Configure Monaco and load React types once at startup - useEffect(() => { - void loader.init().then((monaco: typeof Monaco) => { - // Configure compiler options - configureMonacoCompilerOptions(monaco); - - // Fetch and set React types once - void fetchReactTypes().then((rTypes) => { - setReactTypes(rTypes); - - // Set React types as base extra libs - this is done only once - monaco.typescript.typescriptDefaults.setExtraLibs([ - { - content: rTypes.react, - filePath: "inmemory://sdcpn/node_modules/@types/react/index.d.ts", - }, - { - content: rTypes.reactJsxRuntime, - filePath: - "inmemory://sdcpn/node_modules/@types/react/jsx-runtime.d.ts", - }, - { - content: rTypes.reactDom, - filePath: - "inmemory://sdcpn/node_modules/@types/react-dom/index.d.ts", - }, - ]); - }); - }); - }, []); // Empty deps - run only once at startup - - // Update SDCPN typings whenever the model changes - useEffect(() => { - if (!reactTypes) { - return; // Wait for React types to load first - } - - void loader.init().then((monaco: typeof Monaco) => { - const sdcpnTypings = generateSDCPNTypings( - types, - places, - transitions, - differentialEquations, - parameters, - currentlySelectedItemId ?? undefined, - ); - - // Create or update SDCPN typings model - const sdcpnTypingsUri = monaco.Uri.parse( - "inmemory://sdcpn/sdcpn-globals.d.ts", - ); - const existingModel = monaco.editor.getModel(sdcpnTypingsUri); - - if (existingModel) { - existingModel.setValue(sdcpnTypings); - } else { - monaco.editor.createModel(sdcpnTypings, "typescript", sdcpnTypingsUri); - } - }); - }, [ - reactTypes, - types, - parameters, - places, - transitions, - differentialEquations, - currentlySelectedItemId, - ]); -} diff --git a/libs/@hashintel/petrinaut/src/components/code-editor.tsx b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx similarity index 63% rename from libs/@hashintel/petrinaut/src/components/code-editor.tsx rename to libs/@hashintel/petrinaut/src/monaco/code-editor.tsx index 2c0a62d349b..8ccef730cb4 100644 --- a/libs/@hashintel/petrinaut/src/components/code-editor.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx @@ -1,10 +1,10 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { EditorProps, Monaco } from "@monaco-editor/react"; -import MonacoEditor from "@monaco-editor/react"; import type { editor } from "monaco-editor"; -import { useCallback, useRef } from "react"; +import { Suspense, use, useCallback, useRef } from "react"; -import { Tooltip } from "./tooltip"; +import { Tooltip } from "../components/tooltip"; +import { MonacoContext } from "./context"; const containerStyle = cva({ base: { @@ -24,31 +24,34 @@ const containerStyle = cva({ }, }); +const loadingStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "2", + height: "full", + color: "fg.muted", + bg: "bg.subtle", + fontSize: "base", +}); + type CodeEditorProps = Omit & { tooltip?: string; }; -/** - * Code editor component that wraps Monaco Editor. - * - * @param tooltip - Optional tooltip to show when hovering over the editor. - * In read-only mode, the tooltip also appears when attempting to edit. - */ -export const CodeEditor: React.FC = ({ - tooltip, +const CodeEditorInner: React.FC = ({ options, - height, onMount, ...props }) => { - const isReadOnly = options?.readOnly === true; + const { Editor } = use(use(MonacoContext)); + const editorRef = useRef(null); const handleMount = useCallback( - (editorInstance: editor.IStandaloneCodeEditor, monaco: Monaco) => { + (editorInstance: editor.IStandaloneCodeEditor, monacoInstance: Monaco) => { editorRef.current = editorInstance; - // Call the original onMount if provided - onMount?.(editorInstance, monaco); + onMount?.(editorInstance, monacoInstance); }, [onMount], ); @@ -67,19 +70,35 @@ export const CodeEditor: React.FC = ({ ...options, }; + return ( + + ); +}; + +export const CodeEditor: React.FC = ({ + tooltip, + options, + height, + ...props +}) => { + const isReadOnly = options?.readOnly === true; + const editorElement = (
- + Loading editor...
} + > + + ); - // Regular tooltip for non-read-only mode (if tooltip is provided) if (tooltip) { return ( ; +}; + +export const MonacoContext = createContext>( + null as never, +); diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx new file mode 100644 index 00000000000..b09b0644aea --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -0,0 +1,108 @@ +import type * as Monaco from "monaco-editor"; + +import type { MonacoContextValue } from "./context"; +import { MonacoContext } from "./context"; + +interface LanguageDefaults { + setModeConfiguration(config: Record): void; +} + +interface TypeScriptNamespace { + typescriptDefaults: LanguageDefaults; + javascriptDefaults: LanguageDefaults; +} + +/** + * Disable all built-in TypeScript language worker features. + * Syntax highlighting (Monarch tokenizer) is retained since it runs client-side. + */ +function disableBuiltInTypeScriptFeatures(monaco: typeof Monaco) { + // The `typescript` namespace is marked deprecated in newer type definitions + // but the runtime API still exists and is the only way to control the TS worker. + const ts = monaco.languages.typescript as unknown as TypeScriptNamespace; + + const modeConfiguration: Record = { + completionItems: false, + hovers: false, + documentSymbols: false, + definitions: false, + references: false, + documentHighlights: false, + rename: false, + diagnostics: false, + documentRangeFormattingEdits: false, + signatureHelp: false, + onTypeFormattingEdits: false, + codeActions: false, + inlayHints: false, + }; + + ts.typescriptDefaults.setModeConfiguration(modeConfiguration); + ts.javascriptDefaults.setModeConfiguration(modeConfiguration); +} + +function registerCompletionProvider(monaco: typeof Monaco) { + monaco.languages.registerCompletionItemProvider("typescript", { + provideCompletionItems(model, position) { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + // eslint-disable-next-line no-console + console.log("Completion requested", { + position: { line: position.lineNumber, column: position.column }, + word: word.word, + range, + }); + + return { + suggestions: [ + { + label: "transition", + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: "transition", + range, + }, + ], + }; + }, + }); +} + +async function initMonaco(): Promise { + await new Promise((resolve) => setTimeout(resolve, 4000)); + + // Disable all workers — no worker files will be shipped or loaded. + (globalThis as Record).MonacoEnvironment = { + getWorker: undefined, + }; + + const [monaco, monacoReact] = await Promise.all([ + import("monaco-editor") as Promise, + import("@monaco-editor/react"), + ]); + + // Use local Monaco instance — no CDN fetch. + monacoReact.loader.config({ monaco }); + + disableBuiltInTypeScriptFeatures(monaco); + registerCompletionProvider(monaco); + return { monaco, Editor: monacoReact.default }; +} + +export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // Stable promise reference — created once, never changes. + const monacoPromise = initMonaco(); + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index c56d600da35..ad1d83d5008 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -11,7 +11,7 @@ import type { SDCPN, Transition, } from "./core/types/sdcpn"; -import { useMonacoGlobalTypings } from "./hooks/use-monaco-global-typings"; +import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; @@ -33,15 +33,6 @@ export type { Transition, }; -/** - * Internal component to initialize Monaco global typings. - * Must be inside SDCPNProvider to access the store. - */ -const MonacoSetup: React.FC = () => { - useMonacoGlobalTypings(); - return null; -}; - export type PetrinautProps = { /** * Nets other than this one which are available for selection, e.g. to switch to or to link from a transition. @@ -107,18 +98,19 @@ export const Petrinaut = ({ return ( - - - - - - - - - - + + + + + + + + + + + ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx index d2c5db0bcfb..330dbc1e7c4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx @@ -5,7 +5,6 @@ import { useState } from "react"; import { TbDotsVertical, TbSparkles } from "react-icons/tb"; import { Button } from "../../../../components/button"; -import { CodeEditor } from "../../../../components/code-editor"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; import { Tooltip } from "../../../../components/tooltip"; @@ -19,6 +18,7 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -476,6 +476,7 @@ export const DifferentialEquationProperties: React.FC< )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx index c80ae9f7119..7758dde9c67 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx @@ -1,6 +1,5 @@ /* eslint-disable id-length */ import { css } from "@hashintel/ds-helpers/css"; -import MonacoEditor from "@monaco-editor/react"; import { use, useEffect, useMemo, useRef, useState } from "react"; import { TbArrowRight, @@ -28,6 +27,7 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../playback/context"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; @@ -142,12 +142,6 @@ const codeHeaderLabelStyle = css({ fontSize: "[12px]", }); -const editorBorderStyle = css({ - border: "[1px solid rgba(0, 0, 0, 0.1)]", - borderRadius: "[4px]", - overflow: "hidden", -}); - const aiMenuItemStyle = css({ display: "flex", alignItems: "center", @@ -549,33 +543,17 @@ export const PlaceProperties: React.FC = ({ ]} /> -
- { - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = value ?? ""; - }); - }} - theme="vs-light" - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 12, - lineNumbers: "off", - folding: true, - glyphMargin: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 3, - padding: { top: 8, bottom: 8 }, - fixedOverflowWidgets: true, - }} - /> -
+ { + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = value ?? ""; + }); + }} + /> )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx index 22201b754ee..f3bd01ef93e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx @@ -19,7 +19,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbDotsVertical, TbSparkles, TbTrash } from "react-icons/tb"; -import { CodeEditor } from "../../../../components/code-editor"; import { IconButton } from "../../../../components/icon-button"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; @@ -31,6 +30,7 @@ import { generateDefaultTransitionKernelCode, } from "../../../../core/default-codes"; import type { Color, Place, Transition } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; @@ -480,12 +480,12 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) .join("-")}`} language="typescript" value={transition.lambdaCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/lambda.ts`} height={340} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { @@ -583,14 +583,9 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) - .join("-")}-${transition.outputArcs - .map((a) => `${a.placeId}:${a.weight}`) - .join("-")}`} + path={`inmemory://sdcpn/transitions/${transition.id}/transition-kernel.ts`} language="typescript" value={transition.transitionKernelCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/transition-kernel.ts`} height={400} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { From 98d68ecd5009cd137c82ea5cb21287b51613c920 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 14:21:33 +0100 Subject: [PATCH 2/9] H-5839: Consolidate checker into self-contained module Move core/checker/ to checker/lib/ and state/checker-context + checker-provider into checker/context and checker/provider, mirroring the monaco/ module structure. Add rollup-plugin-visualizer and externalize @monaco-editor/react from the bundle. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 1 + .../checker-context.ts => checker/context.ts} | 2 +- .../checker => checker/lib}/checker.test.ts | 0 .../{core/checker => checker/lib}/checker.ts | 0 .../lib}/create-language-service-host.ts | 0 .../lib}/create-sdcpn-language-service.ts | 0 .../checker => checker/lib}/file-paths.ts | 0 .../lib}/helper/create-sdcpn.ts | 0 .../provider.tsx} | 6 ++--- libs/@hashintel/petrinaut/src/petrinaut.tsx | 10 +++---- .../components/BottomBar/bottom-bar.tsx | 2 +- .../BottomBar/diagnostics-indicator.tsx | 2 +- .../src/views/Editor/subviews/diagnostics.tsx | 2 +- yarn.lock | 27 +++++++++++++++++-- 14 files changed, 38 insertions(+), 14 deletions(-) rename libs/@hashintel/petrinaut/src/{state/checker-context.ts => checker/context.ts} (89%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/checker.test.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/checker.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/create-language-service-host.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/create-sdcpn-language-service.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/file-paths.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/helper/create-sdcpn.ts (100%) rename libs/@hashintel/petrinaut/src/{state/checker-provider.tsx => checker/provider.tsx} (77%) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index f7d5a4ce172..ab06edcb60d 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -77,6 +77,7 @@ "jsdom": "24.1.3", "react": "19.2.3", "react-dom": "19.2.3", + "rollup-plugin-visualizer": "6.0.5", "vite": "8.0.0-beta.14", "vite-plugin-dts": "4.5.4", "vitest": "4.0.18" diff --git a/libs/@hashintel/petrinaut/src/state/checker-context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts similarity index 89% rename from libs/@hashintel/petrinaut/src/state/checker-context.ts rename to libs/@hashintel/petrinaut/src/checker/context.ts index 1ebf40f6f67..e7aa5988812 100644 --- a/libs/@hashintel/petrinaut/src/state/checker-context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; -import type { SDCPNCheckResult } from "../core/checker/checker"; +import type { SDCPNCheckResult } from "./lib/checker"; export type CheckResult = SDCPNCheckResult; diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/checker.test.ts rename to libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/checker.ts rename to libs/@hashintel/petrinaut/src/checker/lib/checker.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts rename to libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts rename to libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/file-paths.ts b/libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/file-paths.ts rename to libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts rename to libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts diff --git a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx similarity index 77% rename from libs/@hashintel/petrinaut/src/state/checker-provider.tsx rename to libs/@hashintel/petrinaut/src/checker/provider.tsx index e4ba7b2c337..97b5a01ab11 100644 --- a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,8 +1,8 @@ import { use } from "react"; -import { checkSDCPN } from "../core/checker/checker"; -import { CheckerContext } from "./checker-context"; -import { SDCPNContext } from "./sdcpn-context"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { checkSDCPN } from "./lib/checker"; +import { CheckerContext } from "./context"; export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index ad1d83d5008..022f4f91816 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -15,7 +15,7 @@ import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; -import { CheckerProvider } from "./state/checker-provider"; +import { CheckerProvider } from "./checker/provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; import { EditorView } from "./views/Editor/editor-view"; @@ -98,8 +98,8 @@ export const Petrinaut = ({ return ( - - + + @@ -109,8 +109,8 @@ export const Petrinaut = ({ - - + + ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx index 0fca9f54bce..e82175b8e04 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx @@ -3,7 +3,7 @@ import { refractive } from "@hashintel/refractive"; import { use, useCallback, useEffect } from "react"; import { FaChevronDown, FaChevronUp } from "react-icons/fa6"; -import { CheckerContext } from "../../../../state/checker-context"; +import { CheckerContext } from "../../../../checker/context"; import { EditorContext, type EditorState, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx index 145fb52b49e..648a2b0ffa4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx @@ -2,7 +2,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaCheck, FaXmark } from "react-icons/fa6"; -import { CheckerContext } from "../../../../state/checker-context"; +import { CheckerContext } from "../../../../checker/context"; import { ToolbarButton } from "./toolbar-button"; const iconContainerStyle = cva({ diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index b966ef21413..dafd2c85cfe 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -4,7 +4,7 @@ import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; import ts from "typescript"; import type { SubView } from "../../../components/sub-view/types"; -import { CheckerContext } from "../../../state/checker-context"; +import { CheckerContext } from "../../../checker/context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; diff --git a/yarn.lock b/yarn.lock index 5fcbcd33d50..731c67df3da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7801,6 +7801,7 @@ __metadata: react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" reactflow: "npm:11.11.4" + rollup-plugin-visualizer: "npm:6.0.5" typescript: "npm:5.9.3" uuid: "npm:13.0.0" vite: "npm:8.0.0-beta.14" @@ -36690,7 +36691,7 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.4, open@npm:^8.4.2": +"open@npm:^8.0.0, open@npm:^8.0.4, open@npm:^8.4.2": version: 8.4.2 resolution: "open@npm:8.4.2" dependencies: @@ -40597,6 +40598,28 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-visualizer@npm:6.0.5": + version: 6.0.5 + resolution: "rollup-plugin-visualizer@npm:6.0.5" + dependencies: + open: "npm:^8.0.0" + picomatch: "npm:^4.0.2" + source-map: "npm:^0.7.4" + yargs: "npm:^17.5.1" + peerDependencies: + rolldown: 1.x || ^1.0.0-beta + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + bin: + rollup-plugin-visualizer: dist/bin/cli.js + checksum: 10c0/3824626e97d5033fbb3aa1bbe93c8c17a8569bc47e33c941bde6b90404f2cae70b26fec1b623bd393c3e076338014196c91726ed2c96218edc67e1f21676f7ef + languageName: node + linkType: hard + "rollup@npm:4.57.1, rollup@npm:^4.34.9, rollup@npm:^4.35.0, rollup@npm:^4.43.0": version: 4.57.1 resolution: "rollup@npm:4.57.1" @@ -46363,7 +46386,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From cd9984f82c1da8e6e17970b6a90f8d5372c16a75 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 14:38:09 +0100 Subject: [PATCH 3/9] H-5839: Fix import paths after checker move and remove debug delay Update relative imports in checker/lib/ to reach core/types/sdcpn at the new depth. Sort imports per linter rules. Remove the 4s debug setTimeout from Monaco init. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/src/checker/lib/checker.ts | 2 +- .../petrinaut/src/checker/lib/create-sdcpn-language-service.ts | 2 +- .../@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts | 2 +- libs/@hashintel/petrinaut/src/checker/provider.tsx | 2 +- libs/@hashintel/petrinaut/src/monaco/provider.tsx | 2 -- libs/@hashintel/petrinaut/src/petrinaut.tsx | 2 +- .../petrinaut/src/views/Editor/subviews/diagnostics.tsx | 2 +- 7 files changed, 6 insertions(+), 8 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts index 33d170423ae..349b3aeddf1 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts @@ -1,6 +1,6 @@ import type ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 87d16b0274e..33a05d29a34 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -1,6 +1,6 @@ import ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { createLanguageServiceHost, type VirtualFile, diff --git a/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts index eec76175ef0..644c469521d 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts @@ -5,7 +5,7 @@ import type { Place, SDCPN, Transition, -} from "../../types/sdcpn"; +} from "../../../core/types/sdcpn"; type PartialColor = Omit, "elements"> & { elements?: Array>; diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index 97b5a01ab11..c39d4d24807 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,8 +1,8 @@ import { use } from "react"; import { SDCPNContext } from "../state/sdcpn-context"; -import { checkSDCPN } from "./lib/checker"; import { CheckerContext } from "./context"; +import { checkSDCPN } from "./lib/checker"; export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index b09b0644aea..7922ec5fce5 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -74,8 +74,6 @@ function registerCompletionProvider(monaco: typeof Monaco) { } async function initMonaco(): Promise { - await new Promise((resolve) => setTimeout(resolve, 4000)); - // Disable all workers — no worker files will be shipped or loaded. (globalThis as Record).MonacoEnvironment = { getWorker: undefined, diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index 022f4f91816..df237136b33 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -1,6 +1,7 @@ import "reactflow/dist/style.css"; import "./index.css"; +import { CheckerProvider } from "./checker/provider"; import type { Color, DifferentialEquation, @@ -15,7 +16,6 @@ import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; -import { CheckerProvider } from "./checker/provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; import { EditorView } from "./views/Editor/editor-view"; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index dafd2c85cfe..f2e3f8bd2f1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -3,8 +3,8 @@ import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; import ts from "typescript"; -import type { SubView } from "../../../components/sub-view/types"; import { CheckerContext } from "../../../checker/context"; +import type { SubView } from "../../../components/sub-view/types"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; From 02142b2ff28807101811ef5e901f6cca72eda9a6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 15:54:29 +0100 Subject: [PATCH 4/9] H-5839: Move checker to WebWorker with JSON-RPC protocol Run TypeScript validation off the main thread to keep the UI responsive. The checker worker communicates via JSON-RPC 2.0 over postMessage, with diagnostics serialized (messageText flattened, ts.SourceFile stripped) before crossing the worker boundary. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/context.ts | 10 +-- .../petrinaut/src/checker/provider.tsx | 27 +++++- .../src/checker/worker/checker.worker.ts | 62 +++++++++++++ .../petrinaut/src/checker/worker/protocol.ts | 63 ++++++++++++++ .../src/checker/worker/use-checker-worker.ts | 86 +++++++++++++++++++ .../src/views/Editor/subviews/diagnostics.tsx | 22 +---- 6 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/protocol.ts create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts index e7aa5988812..c312b234f96 100644 --- a/libs/@hashintel/petrinaut/src/checker/context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,13 +1,11 @@ import { createContext } from "react"; -import type { SDCPNCheckResult } from "./lib/checker"; - -export type CheckResult = SDCPNCheckResult; +import type { CheckerResult } from "./worker/protocol"; export interface CheckerContextValue { - /** The result of the last SDCPN check */ - checkResult: SDCPNCheckResult; - /** Total count of all diagnostics across all items */ + /** Result of the last SDCPN validation run. */ + checkResult: CheckerResult; + /** Total number of diagnostics across all items. */ totalDiagnosticsCount: number; } diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index c39d4d24807..4e5388cb950 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,15 +1,36 @@ -import { use } from "react"; +import { use, useEffect, useState } from "react"; import { SDCPNContext } from "../state/sdcpn-context"; import { CheckerContext } from "./context"; -import { checkSDCPN } from "./lib/checker"; +import type { CheckerResult } from "./worker/protocol"; +import { useCheckerWorker } from "./worker/use-checker-worker"; + +const EMPTY_RESULT: CheckerResult = { + isValid: true, + itemDiagnostics: [], +}; export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { petriNetDefinition } = use(SDCPNContext); + const { checkSDCPN } = useCheckerWorker(); + + const [checkResult, setCheckerResult] = useState(EMPTY_RESULT); + + useEffect(() => { + let cancelled = false; + + void checkSDCPN(petriNetDefinition).then((result) => { + if (!cancelled) { + setCheckerResult(result); + } + }); - const checkResult = checkSDCPN(petriNetDefinition); + return () => { + cancelled = true; + }; + }, [petriNetDefinition, checkSDCPN]); const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( (sum, item) => sum + item.diagnostics.length, diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts new file mode 100644 index 00000000000..bbc0f96ad6c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-restricted-globals */ +/** + * Checker WebWorker — runs TypeScript validation off the main thread. + * + * Receives JSON-RPC requests via `postMessage`, delegates to `checkSDCPN`, + * serializes the diagnostics (flatten messageText, strip non-cloneable fields), + * and posts the result back. + */ +import ts from "typescript"; + +import { checkSDCPN } from "../lib/checker"; +import type { + CheckerDiagnostic, + CheckerItemDiagnostics, + CheckerResult, + JsonRpcRequest, + JsonRpcResponse, +} from "./protocol"; + +/** Strip `ts.SourceFile` and flatten `DiagnosticMessageChain` for structured clone. */ +function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { + return { + category: diag.category, + code: diag.code, + messageText: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), + start: diag.start, + length: diag.length, + }; +} + +self.onmessage = ({ data: { id, params } }: MessageEvent) => { + try { + const raw = checkSDCPN(params.sdcpn); + + const result: CheckerResult = { + isValid: raw.isValid, + itemDiagnostics: raw.itemDiagnostics.map( + (item): CheckerItemDiagnostics => ({ + itemId: item.itemId, + itemType: item.itemType, + filePath: item.filePath, + diagnostics: item.diagnostics.map(serializeDiagnostic), + }), + ), + }; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + } catch (err) { + self.postMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + } satisfies JsonRpcResponse); + } +}; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts new file mode 100644 index 00000000000..fc63218f0a3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -0,0 +1,63 @@ +/** + * JSON-RPC 2.0 protocol types for the checker WebWorker. + * + * These types define the contract between the main thread and the worker. + * Diagnostic types are serializable (no `ts.SourceFile` references) so they + * can cross the postMessage boundary via structured clone. + */ +import type { SDCPN } from "../../core/types/sdcpn"; + +// --------------------------------------------------------------------------- +// Diagnostics — serializable variants of ts.Diagnostic +// --------------------------------------------------------------------------- + +/** A single TypeScript diagnostic, safe for structured clone. */ +export type CheckerDiagnostic = { + /** @see ts.DiagnosticCategory */ + category: number; + /** TypeScript error code (e.g. 2322 for type mismatch). */ + code: number; + /** Human-readable message, pre-flattened from `ts.DiagnosticMessageChain`. */ + messageText: string; + /** Character offset in user code where the error starts. */ + start: number | undefined; + /** Length of the error span in characters. */ + length: number | undefined; +}; + +/** Diagnostics grouped by SDCPN item (one transition function or differential equation). */ +export type CheckerItemDiagnostics = { + /** ID of the transition or differential equation. */ + itemId: string; + /** Which piece of code was checked. */ + itemType: "transition-lambda" | "transition-kernel" | "differential-equation"; + /** Path in the virtual file system used by the TS LanguageService. */ + filePath: string; + /** All diagnostics found in this item's code. */ + diagnostics: CheckerDiagnostic[]; +}; + +/** Result of validating an entire SDCPN model. */ +export type CheckerResult = { + /** `true` when every item compiles without errors. */ + isValid: boolean; + /** Per-item diagnostics (empty when valid). */ + itemDiagnostics: CheckerItemDiagnostics[]; +}; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 +// --------------------------------------------------------------------------- + +/** A JSON-RPC request sent from the main thread to the worker. */ +export type JsonRpcRequest = { + jsonrpc: "2.0"; + id: number; + method: "checkSDCPN"; + params: { sdcpn: SDCPN }; +}; + +/** A JSON-RPC response sent from the worker back to the main thread. */ +export type JsonRpcResponse = + | { jsonrpc: "2.0"; id: number; result: Result } + | { jsonrpc: "2.0"; id: number; error: { code: number; message: string } }; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts new file mode 100644 index 00000000000..e5627c34cd6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "react"; + +import type { SDCPN } from "../../core/types/sdcpn"; +import type { + CheckerResult, + JsonRpcRequest, + JsonRpcResponse, +} from "./protocol"; + +type Pending = { + resolve: (result: CheckerResult) => void; + reject: (error: Error) => void; +}; + +/** Methods exposed by the checker WebWorker. */ +export type CheckerWorkerApi = { + /** Validate all user code in an SDCPN model. Runs off the main thread. */ + checkSDCPN: (sdcpn: SDCPN) => Promise; +}; + +/** + * Spawn a checker WebWorker and return a Promise-based API to interact with it. + * The worker is created on mount and terminated on unmount. + */ +export function useCheckerWorker(): CheckerWorkerApi { + const workerRef = useRef(null); + const pendingRef = useRef(new Map()); + const nextId = useRef(0); + + useEffect(() => { + const worker = new Worker(new URL("./checker.worker.ts", import.meta.url), { + type: "module", + }); + + worker.onmessage = ( + event: MessageEvent>, + ) => { + const response = event.data; + const pending = pendingRef.current.get(response.id); + if (!pending) { + return; + } + pendingRef.current.delete(response.id); + + if ("error" in response) { + pending.reject(new Error(response.error.message)); + } else { + pending.resolve(response.result); + } + }; + + workerRef.current = worker; + const pending = pendingRef.current; + + return () => { + worker.terminate(); + workerRef.current = null; + for (const entry of pending.values()) { + entry.reject(new Error("Worker terminated")); + } + pending.clear(); + }; + }, []); + + const checkSDCPN = (sdcpn: SDCPN): Promise => { + const worker = workerRef.current; + if (!worker) { + return Promise.reject(new Error("Worker not initialized")); + } + + const id = nextId.current++; + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method: "checkSDCPN", + params: { sdcpn }, + }; + + return new Promise((resolve, reject) => { + pendingRef.current.set(id, { resolve, reject }); + worker.postMessage(request); + }); + }; + + return { checkSDCPN }; +} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index f2e3f8bd2f1..0eac7e15e5c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -1,9 +1,9 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; -import ts from "typescript"; import { CheckerContext } from "../../../checker/context"; +import type { CheckerDiagnostic } from "../../../checker/worker/protocol"; import type { SubView } from "../../../components/sub-view/types"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; @@ -94,22 +94,6 @@ const positionStyle = css({ marginLeft: "[8px]", }); -// --- Helpers --- - -/** - * Formats a TypeScript diagnostic message to a readable string - */ -function formatDiagnosticMessage( - messageText: string | ts.DiagnosticMessageChain, -): string { - if (typeof messageText === "string") { - return messageText; - } - return ts.flattenDiagnosticMessageText(messageText, "\n"); -} - -// --- Types --- - type EntityType = "transition" | "differential-equation"; interface GroupedDiagnostics { @@ -119,7 +103,7 @@ interface GroupedDiagnostics { errorCount: number; items: Array<{ subType: "lambda" | "kernel" | null; - diagnostics: ts.Diagnostic[]; + diagnostics: CheckerDiagnostic[]; }>; } @@ -269,7 +253,7 @@ const DiagnosticsContent: React.FC = () => { className={diagnosticButtonStyle} > - {formatDiagnosticMessage(diagnostic.messageText)} + {diagnostic.messageText} {diagnostic.start !== undefined && ( (pos: {diagnostic.start}) From 81455c6a6016de9c19647074a71ab4ac1c4cfa71 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 17:10:00 +0100 Subject: [PATCH 5/9] H-5839: Bundle TypeScript into worker and remove CDN/polyfill workarounds TypeScript now runs exclusively in the checker WebWorker, so: - Move `typescript` from main bundle externals into worker bundle - Add process/isNodeLikeSystem replacements to eliminate Node.js code paths - Remove Node.js polyfill fallbacks from next.config.js (fs, path, os, etc.) - Remove CDN CSP exceptions since Monaco is loaded locally Co-Authored-By: Claude Opus 4.6 --- apps/hash-frontend/next.config.js | 14 -------------- apps/hash-frontend/src/lib/csp.ts | 13 +------------ libs/@hashintel/petrinaut/vite.config.ts | 12 +++++++++++- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 606f4cd6a5f..d6d6bef9f1d 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -219,20 +219,6 @@ export default withSentryConfig( // eslint-disable-next-line no-param-reassign webpackConfig.resolve.alias.canvas = false; - if (!isServer) { - // Stub Node.js built-ins for browser — needed by `typescript` (used by - // @hashintel/petrinaut's in-browser language service) - // eslint-disable-next-line no-param-reassign - webpackConfig.resolve.fallback = { - ...webpackConfig.resolve.fallback, - module: false, - fs: false, - path: false, - os: false, - perf_hooks: false, - }; - } - webpackConfig.plugins.push( new DefinePlugin({ __SENTRY_DEBUG__: false, diff --git a/apps/hash-frontend/src/lib/csp.ts b/apps/hash-frontend/src/lib/csp.ts index 171adfb0a5b..4b327029a49 100644 --- a/apps/hash-frontend/src/lib/csp.ts +++ b/apps/hash-frontend/src/lib/csp.ts @@ -24,9 +24,6 @@ export const buildCspHeader = (nonce: string): string => { "https://apis.google.com", // Vercel toolbar / live preview widget "https://vercel.live", - // @todo FE-488 will make this unnecessary - // Monaco Editor loaded from CDN by @monaco-editor/react (used by petrinaut) - "https://cdn.jsdelivr.net", ], "style-src": [ @@ -34,9 +31,6 @@ export const buildCspHeader = (nonce: string): string => { // Required for Emotion/MUI CSS-in-JS inline style injection. // @todo Use nonce-based approach via Emotion's cache `nonce` option. "'unsafe-inline'", - // @todo FE-488 will make this unnecessary - // Monaco Editor stylesheet loaded from CDN by @monaco-editor/react (used by petrinaut) - "https://cdn.jsdelivr.net", ], "img-src": [ @@ -51,12 +45,7 @@ export const buildCspHeader = (nonce: string): string => { ...(process.env.NODE_ENV === "development" ? ["http:"] : []), ], - "font-src": [ - "'self'", - // @todo FE-488 will make this unnecessary - // Monaco Editor CSS embeds the Codicon icon font as an inline base64 data URI - "data:", - ], + "font-src": ["'self'"], "connect-src": [ "'self'", diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index 744ed2004b9..d5b64395b11 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -21,7 +21,6 @@ export default defineConfig({ "react", "react-dom", "reactflow", - "typescript", "monaco-editor", "@babel/standalone", ], @@ -48,7 +47,17 @@ export default defineConfig({ // This causes crashes in Web Workers, since `window` is not defined there. // To prevent this, we do this resolution on our side. "typeof window": '"undefined"', + // TypeScript's internals reference process, process.versions.pnp, etc. + "typeof process": "'undefined'", + "typeof process.versions.pnp": "'undefined'", }), + // Separate replacePlugin for call-expression replacements: + // 1. Empty end delimiter because \b can't match after `)` (non-word → non-word). + // 2. Negative lookbehind skips the function definition (`function isNodeLikeSystem`). + replacePlugin( + { "isNodeLikeSystem()": "false" }, + { delimiters: ["(? Date: Thu, 19 Feb 2026 17:32:52 +0100 Subject: [PATCH 6/9] H-5839: Remove @dnd-kit and feature flags Arc reordering was behind a permanently-off feature flag, so all dnd-kit usage was dead code. Strip the DndContext/SortableContext wrappers, simplify SortableArcItem to a plain ArcItem, remove @dnd-kit dependencies, and delete the now-empty feature-flags module. Co-Authored-By: Claude Opus 4.6 --- .../@hashintel/petrinaut/src/feature-flags.ts | 3 - .../PropertiesPanel/sortable-arc-item.tsx | 64 +----- .../PropertiesPanel/transition-properties.tsx | 186 +++++------------- 3 files changed, 51 insertions(+), 202 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/feature-flags.ts diff --git a/libs/@hashintel/petrinaut/src/feature-flags.ts b/libs/@hashintel/petrinaut/src/feature-flags.ts deleted file mode 100644 index 5d4bcdf431d..00000000000 --- a/libs/@hashintel/petrinaut/src/feature-flags.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const FEATURE_FLAGS = { - REORDER_TRANSITION_ARCS: false, -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx index d68cb37ac3c..bbfa37c91ce 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx @@ -1,12 +1,8 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { css, cva } from "@hashintel/ds-helpers/css"; -import { MdDragIndicator } from "react-icons/md"; +import { css } from "@hashintel/ds-helpers/css"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../components/icon-button"; import { NumberInput } from "../../../../components/number-input"; -import { FEATURE_FLAGS } from "../../../../feature-flags"; const containerStyle = css({ display: "flex", @@ -17,28 +13,6 @@ const containerStyle = css({ borderBottom: "[1px solid rgba(0, 0, 0, 0.06)]", }); -const dragHandleStyle = cva({ - base: { - display: "flex", - alignItems: "center", - flexShrink: 0, - }, - variants: { - isDisabled: { - true: { - cursor: "default", - color: "[#ccc]", - pointerEvents: "none", - }, - false: { - cursor: "grab", - color: "[#999]", - pointerEvents: "auto", - }, - }, - }, -}); - const placeNameStyle = css({ flex: "[1]", fontSize: "[14px]", @@ -68,22 +42,16 @@ const weightInputStyle = css({ padding: "[4px 8px]", }); -/** - * SortableArcItem - A draggable arc item that displays place name and weight - */ -interface SortableArcItemProps { - id: string; +interface ArcItemProps { placeName: string; weight: number; disabled?: boolean; - /** Tooltip to show when disabled (e.g., for read-only mode) */ tooltip?: string; onWeightChange: (weight: number) => void; onDelete?: () => void; } -export const SortableArcItem: React.FC = ({ - id, +export const ArcItem: React.FC = ({ placeName, weight, disabled = false, @@ -91,32 +59,8 @@ export const SortableArcItem: React.FC = ({ onWeightChange, onDelete, }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id, disabled }); - - const transformStyle = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - return ( -
- {FEATURE_FLAGS.REORDER_TRANSITION_ARCS && ( -
- -
- )} +
{placeName}
weight diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx index f3bd01ef93e..73a4b2a1e3e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx @@ -1,20 +1,5 @@ /* eslint-disable id-length */ /* eslint-disable curly */ -import type { DragEndEvent } from "@dnd-kit/core"; -import { - closestCenter, - DndContext, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbDotsVertical, TbSparkles, TbTrash } from "react-icons/tb"; @@ -34,7 +19,7 @@ import { CodeEditor } from "../../../../monaco/code-editor"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; -import { SortableArcItem } from "./sortable-arc-item"; +import { ArcItem } from "./sortable-arc-item"; const containerStyle = css({ display: "flex", @@ -182,55 +167,6 @@ export const TransitionProperties: React.FC = ({ const isReadOnly = useIsReadOnly(); const { globalMode } = use(EditorContext); - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleInputArcDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = transition.inputArcs.findIndex( - (arc) => arc.placeId === active.id, - ); - const newIndex = transition.inputArcs.findIndex( - (arc) => arc.placeId === over.id, - ); - - updateTransition(transition.id, (existingTransition) => { - existingTransition.inputArcs = arrayMove( - existingTransition.inputArcs, - oldIndex, - newIndex, - ); - }); - } - }; - - const handleOutputArcDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = transition.outputArcs.findIndex( - (arc) => arc.placeId === active.id, - ); - const newIndex = transition.outputArcs.findIndex( - (arc) => arc.placeId === over.id, - ); - - updateTransition(transition.id, (existingTransition) => { - existingTransition.outputArcs = arrayMove( - existingTransition.outputArcs, - oldIndex, - newIndex, - ); - }); - } - }; - const handleDeleteInputArc = (placeId: string) => { updateTransition(transition.id, (existingTransition) => { const index = existingTransition.inputArcs.findIndex( @@ -308,43 +244,29 @@ export const TransitionProperties: React.FC = ({
) : (
- - arc.placeId)} - strategy={verticalListSortingStrategy} - > - {transition.inputArcs.map((arc) => { - const place = places.find( - (placeItem) => placeItem.id === arc.placeId, - ); - return ( - { - onArcWeightUpdate( - transition.id, - "input", - arc.placeId, - weight, - ); - }} - onDelete={() => handleDeleteInputArc(arc.placeId)} - /> - ); - })} - - + {transition.inputArcs.map((arc) => { + const place = places.find( + (placeItem) => placeItem.id === arc.placeId, + ); + return ( + { + onArcWeightUpdate( + transition.id, + "input", + arc.placeId, + weight, + ); + }} + onDelete={() => handleDeleteInputArc(arc.placeId)} + /> + ); + })}
)}
@@ -357,43 +279,29 @@ export const TransitionProperties: React.FC = ({
) : (
- - arc.placeId)} - strategy={verticalListSortingStrategy} - > - {transition.outputArcs.map((arc) => { - const place = places.find( - (placeItem) => placeItem.id === arc.placeId, - ); - return ( - { - onArcWeightUpdate( - transition.id, - "output", - arc.placeId, - weight, - ); - }} - onDelete={() => handleDeleteOutputArc(arc.placeId)} - /> - ); - })} - - + {transition.outputArcs.map((arc) => { + const place = places.find( + (placeItem) => placeItem.id === arc.placeId, + ); + return ( + { + onArcWeightUpdate( + transition.id, + "output", + arc.placeId, + weight, + ); + }} + onDelete={() => handleDeleteOutputArc(arc.placeId)} + /> + ); + })}
)} From 27b084b8f42359eae4022288d6a9fcd3c5184085 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 17:43:19 +0100 Subject: [PATCH 7/9] H-5839: Remove rollup-plugin-visualizer dependency Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 1 - yarn.lock | 27 ++------------------------ 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index ab06edcb60d..f7d5a4ce172 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -77,7 +77,6 @@ "jsdom": "24.1.3", "react": "19.2.3", "react-dom": "19.2.3", - "rollup-plugin-visualizer": "6.0.5", "vite": "8.0.0-beta.14", "vite-plugin-dts": "4.5.4", "vitest": "4.0.18" diff --git a/yarn.lock b/yarn.lock index 731c67df3da..5fcbcd33d50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7801,7 +7801,6 @@ __metadata: react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" reactflow: "npm:11.11.4" - rollup-plugin-visualizer: "npm:6.0.5" typescript: "npm:5.9.3" uuid: "npm:13.0.0" vite: "npm:8.0.0-beta.14" @@ -36691,7 +36690,7 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.0, open@npm:^8.0.4, open@npm:^8.4.2": +"open@npm:^8.0.4, open@npm:^8.4.2": version: 8.4.2 resolution: "open@npm:8.4.2" dependencies: @@ -40598,28 +40597,6 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-visualizer@npm:6.0.5": - version: 6.0.5 - resolution: "rollup-plugin-visualizer@npm:6.0.5" - dependencies: - open: "npm:^8.0.0" - picomatch: "npm:^4.0.2" - source-map: "npm:^0.7.4" - yargs: "npm:^17.5.1" - peerDependencies: - rolldown: 1.x || ^1.0.0-beta - rollup: 2.x || 3.x || 4.x - peerDependenciesMeta: - rolldown: - optional: true - rollup: - optional: true - bin: - rollup-plugin-visualizer: dist/bin/cli.js - checksum: 10c0/3824626e97d5033fbb3aa1bbe93c8c17a8569bc47e33c941bde6b90404f2cae70b26fec1b623bd393c3e076338014196c91726ed2c96218edc67e1f21676f7ef - languageName: node - linkType: hard - "rollup@npm:4.57.1, rollup@npm:^4.34.9, rollup@npm:^4.35.0, rollup@npm:^4.43.0": version: 4.57.1 resolution: "rollup@npm:4.57.1" @@ -46386,7 +46363,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From 97da8e7be3710c0bcfc31b70d51376a0652f990e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 18:49:09 +0100 Subject: [PATCH 8/9] H-5839: Wire checker diagnostics into Monaco editors as inline markers Bridge CheckerContext diagnostics to Monaco model markers so CodeEditor instances show squiggly underlines for type errors. Centralizes editor model paths in a single getEditorPath function used by both CodeEditor path props and the new DiagnosticsSync component. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/monaco/diagnostics-sync.tsx | 108 ++++++++++++++++++ .../petrinaut/src/monaco/editor-paths.ts | 16 +++ .../petrinaut/src/monaco/provider.tsx | 2 + .../differential-equation-properties.tsx | 3 +- .../PropertiesPanel/transition-properties.tsx | 5 +- 5 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx create mode 100644 libs/@hashintel/petrinaut/src/monaco/editor-paths.ts diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx new file mode 100644 index 00000000000..9bd907d02d5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -0,0 +1,108 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect, useRef } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerDiagnostic } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { getEditorPath } from "./editor-paths"; + +const OWNER = "checker"; + +/** Convert ts.DiagnosticCategory number to Monaco MarkerSeverity. */ +function toMarkerSeverity( + category: number, + monaco: typeof Monaco, +): Monaco.MarkerSeverity { + switch (category) { + case 0: + return monaco.MarkerSeverity.Warning; + case 1: + return monaco.MarkerSeverity.Error; + case 2: + return monaco.MarkerSeverity.Hint; + case 3: + return monaco.MarkerSeverity.Info; + default: + return monaco.MarkerSeverity.Error; + } +} + +/** Convert CheckerDiagnostic[] to IMarkerData[] using the model for offset→position. */ +function diagnosticsToMarkers( + model: Monaco.editor.ITextModel, + diagnostics: CheckerDiagnostic[], + monaco: typeof Monaco, +): Monaco.editor.IMarkerData[] { + return diagnostics.map((diag) => { + const start = model.getPositionAt(diag.start ?? 0); + const end = model.getPositionAt((diag.start ?? 0) + (diag.length ?? 0)); + return { + severity: toMarkerSeverity(diag.category, monaco), + message: diag.messageText, + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + code: String(diag.code), + }; + }); +} + +const DiagnosticsSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { checkResult } = use(CheckerContext); + const prevPathsRef = useRef>(new Set()); + + useEffect(() => { + const currentPaths = new Set(); + + for (const item of checkResult.itemDiagnostics) { + const path = getEditorPath(item.itemType, item.itemId); + const uri = monaco.Uri.parse(path); + const model = monaco.editor.getModel(uri); + if (model) { + const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + currentPaths.add(path); + } + + // Clear markers from models that no longer have diagnostics + for (const path of prevPathsRef.current) { + if (!currentPaths.has(path)) { + const uri = monaco.Uri.parse(path); + const model = monaco.editor.getModel(uri); + if (model) { + monaco.editor.setModelMarkers(model, OWNER, []); + } + } + } + + prevPathsRef.current = currentPaths; + + // Handle models created after diagnostics arrived + const disposable = monaco.editor.onDidCreateModel((model) => { + const modelUri = model.uri.toString(); + const item = checkResult.itemDiagnostics.find( + (i) => + monaco.Uri.parse(getEditorPath(i.itemType, i.itemId)).toString() === + modelUri, + ); + if (item) { + const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + }); + + return () => disposable.dispose(); + }, [checkResult, monaco]); + + return null; +}; + +/** Renders nothing visible — syncs CheckerContext diagnostics to Monaco model markers. */ +export const DiagnosticsSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts new file mode 100644 index 00000000000..cef45bd237d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -0,0 +1,16 @@ +import type { CheckerItemDiagnostics } from "../checker/worker/protocol"; + +/** Generates the Monaco model path for a given SDCPN item. */ +export function getEditorPath( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, +): string { + switch (itemType) { + case "transition-lambda": + return `inmemory://sdcpn/transitions/${itemId}/lambda.ts`; + case "transition-kernel": + return `inmemory://sdcpn/transitions/${itemId}/kernel.ts`; + case "differential-equation": + return `inmemory://sdcpn/differential-equations/${itemId}.ts`; + } +} diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index 7922ec5fce5..ce18102f437 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -2,6 +2,7 @@ import type * as Monaco from "monaco-editor"; import type { MonacoContextValue } from "./context"; import { MonacoContext } from "./context"; +import { DiagnosticsSync } from "./diagnostics-sync"; interface LanguageDefaults { setModeConfiguration(config: Record): void; @@ -100,6 +101,7 @@ export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ return ( + {children} ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx index 330dbc1e7c4..6556c3f2712 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx @@ -19,6 +19,7 @@ import type { Place, } from "../../../../core/types/sdcpn"; import { CodeEditor } from "../../../../monaco/code-editor"; +import { getEditorPath } from "../../../../monaco/editor-paths"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -476,7 +477,7 @@ export const DifferentialEquationProperties: React.FC< )} = ({ )} `${a.placeId}:${a.weight}`) .join("-")}`} @@ -491,7 +492,7 @@ export const TransitionProperties: React.FC = ({ )} Date: Fri, 20 Feb 2026 02:43:49 +0100 Subject: [PATCH 9/9] H-5839: Add completions, hover, and signature help providers to Monaco editors Wire TypeScript LanguageService features through the checker WebWorker to Monaco editors via JSON-RPC. The LanguageServiceHost is now mutable (per-file version tracking) so completions reflect current editor state. - Completions: registerCompletionItemProvider with ScriptElementKind mapping - Hover: registerHoverProvider backed by getQuickInfoAtPosition - Signature help: registerSignatureHelpProvider backed by getSignatureHelpItems - All three adjust offsets to account for injected declaration prefixes - Add tests for completions and updateFileContent in the language service Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/context.ts | 29 +- .../petrinaut/src/checker/lib/checker.ts | 13 +- .../lib/create-language-service-host.ts | 23 +- .../lib/create-sdcpn-language-service.test.ts | 311 ++++++++++++++++++ .../lib/create-sdcpn-language-service.ts | 37 ++- .../petrinaut/src/checker/provider.tsx | 10 +- .../src/checker/worker/checker.worker.ts | 214 ++++++++++-- .../petrinaut/src/checker/worker/protocol.ts | 101 +++++- .../src/checker/worker/use-checker-worker.ts | 121 +++++-- .../petrinaut/src/monaco/completion-sync.tsx | 127 +++++++ .../petrinaut/src/monaco/editor-paths.ts | 30 ++ .../petrinaut/src/monaco/hover-sync.tsx | 58 ++++ .../petrinaut/src/monaco/provider.tsx | 39 +-- .../src/monaco/signature-help-sync.tsx | 73 ++++ 14 files changed, 1093 insertions(+), 93 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts create mode 100644 libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx create mode 100644 libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx create mode 100644 libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts index c312b234f96..d95dea43201 100644 --- a/libs/@hashintel/petrinaut/src/checker/context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,12 +1,36 @@ import { createContext } from "react"; -import type { CheckerResult } from "./worker/protocol"; +import type { + CheckerCompletionResult, + CheckerItemDiagnostics, + CheckerQuickInfoResult, + CheckerResult, + CheckerSignatureHelpResult, +} from "./worker/protocol"; export interface CheckerContextValue { /** Result of the last SDCPN validation run. */ checkResult: CheckerResult; /** Total number of diagnostics across all items. */ totalDiagnosticsCount: number; + /** Request completions at a position within an SDCPN item. */ + getCompletions: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request quick info (hover) at a position within an SDCPN item. */ + getQuickInfo: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request signature help at a position within an SDCPN item. */ + getSignatureHelp: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; } const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { @@ -15,6 +39,9 @@ const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { itemDiagnostics: [], }, totalDiagnosticsCount: 0, + getCompletions: () => Promise.resolve({ items: [] }), + getQuickInfo: () => Promise.resolve(null), + getSignatureHelp: () => Promise.resolve(null), }; export const CheckerContext = createContext( diff --git a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts index 349b3aeddf1..61a5d27799a 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts @@ -1,7 +1,10 @@ import type ts from "typescript"; import type { SDCPN } from "../../core/types/sdcpn"; -import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import { + createSDCPNLanguageService, + type SDCPNLanguageService, +} from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; export type SDCPNDiagnostic = { @@ -29,8 +32,12 @@ export type SDCPNCheckResult = { * @param sdcpn - The SDCPN to check * @returns A result object indicating validity and any diagnostics */ -export function checkSDCPN(sdcpn: SDCPN): SDCPNCheckResult { - const languageService = createSDCPNLanguageService(sdcpn); +export function checkSDCPN( + sdcpn: SDCPN, + existingLanguageService?: SDCPNLanguageService, +): SDCPNCheckResult { + const languageService = + existingLanguageService ?? createSDCPNLanguageService(sdcpn); const itemDiagnostics: SDCPNDiagnostic[] = []; // Check all differential equations diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts index 3e85556c015..4e22135e8ce 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts @@ -42,9 +42,12 @@ export type VirtualFile = { * * Creates a TypeScript LanguageServiceHost for virtual SDCPN files */ -export function createLanguageServiceHost( - files: Map, -): ts.LanguageServiceHost { +export function createLanguageServiceHost(files: Map): { + host: ts.LanguageServiceHost; + updateFileContent: (fileName: string, content: string) => void; +} { + const versions = new Map(); + const getFileContent = (fileName: string): string | undefined => { const entry = files.get(fileName); if (entry) { @@ -62,10 +65,18 @@ export function createLanguageServiceHost( return undefined; }; - return { + const updateFileContent = (fileName: string, content: string) => { + const entry = files.get(fileName); + if (entry) { + entry.content = content; + versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + } + }; + + const host: ts.LanguageServiceHost = { getScriptFileNames: () => [...files.keys()], getCompilationSettings: () => COMPILER_OPTIONS, - getScriptVersion: () => "0", + getScriptVersion: (fileName) => String(versions.get(fileName) ?? 0), getCurrentDirectory: () => "/", getDefaultLibFileName: () => "/lib.es2015.core.d.ts", @@ -82,4 +93,6 @@ export function createLanguageServiceHost( return getFileContent(path); }, }; + + return { host, updateFileContent }; } diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts new file mode 100644 index 00000000000..791b66f2c23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; + +import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import { getItemFilePath } from "./file-paths"; +import { createSDCPN } from "./helper/create-sdcpn"; + +/** Cursor marker used in test code strings to indicate the completion position. */ +const CURSOR = "∫"; + +/** + * Find the offset of a cursor marker in user code. + * Returns the offset (without the marker) and the clean code. + */ +function parseCursor(codeWithCursor: string): { + offset: number; + code: string; +} { + const offset = codeWithCursor.indexOf(CURSOR); + if (offset === -1) { + throw new Error(`No cursor marker \`${CURSOR}\` found in code`); + } + const code = + codeWithCursor.slice(0, offset) + codeWithCursor.slice(offset + 1); + return { offset, code }; +} + +/** Extract just the completion names from a position. */ +function getCompletionNames( + sdcpnOptions: Parameters[0], + codeWithCursor: string, + target: + | { type: "transition-lambda"; transitionId?: string } + | { type: "transition-kernel"; transitionId?: string } + | { type: "differential-equation"; deId?: string }, +): string[] { + const { offset, code } = parseCursor(codeWithCursor); + + // Patch the SDCPN to inject the clean code at the right item + const patched = { ...sdcpnOptions }; + if ( + target.type === "transition-lambda" || + target.type === "transition-kernel" + ) { + const transitionId = target.transitionId ?? "t1"; + patched.transitions = (patched.transitions ?? []).map((tr) => + (tr.id ?? "t1") === transitionId + ? { + ...tr, + ...(target.type === "transition-lambda" + ? { lambdaCode: code } + : { transitionKernelCode: code }), + } + : tr, + ); + } else { + const deId = target.deId ?? "de_1"; + patched.differentialEquations = (patched.differentialEquations ?? []).map( + (de, index) => + (de.id ?? `de_${index + 1}`) === deId ? { ...de, code } : de, + ); + } + + const sdcpn = createSDCPN(patched); + const ls = createSDCPNLanguageService(sdcpn); + + let filePath: string; + if (target.type === "transition-lambda") { + filePath = getItemFilePath("transition-lambda-code", { + transitionId: target.transitionId ?? "t1", + }); + } else if (target.type === "transition-kernel") { + filePath = getItemFilePath("transition-kernel-code", { + transitionId: target.transitionId ?? "t1", + }); + } else { + filePath = getItemFilePath("differential-equation-code", { + id: target.deId ?? "de_1", + }); + } + + const completions = ls.getCompletionsAtPosition(filePath, offset, undefined); + return (completions?.entries ?? []).map((entry) => entry.name); +} + +describe("createSDCPNLanguageService completions", () => { + const baseSdcpn = { + types: [{ id: "color1", elements: [{ name: "x", type: "real" as const }] }], + places: [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ], + parameters: [ + { id: "p1", variableName: "alpha", type: "real" as const }, + { id: "p2", variableName: "enabled", type: "boolean" as const }, + ], + transitions: [ + { + id: "t1", + lambdaType: "predicate" as const, + inputArcs: [{ placeId: "place1", weight: 1 }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: "", + transitionKernelCode: "", + }, + ], + }; + + describe("member access completions (dot completions)", () => { + it("returns Number methods after `a.` where a is a number", () => { + const names = getCompletionNames( + baseSdcpn, + `const a = 42;\na.${CURSOR}`, + { + type: "transition-lambda", + }, + ); + + expect(names).toContain("toFixed"); + expect(names).toContain("toString"); + expect(names).toContain("valueOf"); + // Should NOT contain global scope items + expect(names).not.toContain("Array"); + expect(names).not.toContain("Lambda"); + }); + + it("returns parameter names after `parameters.`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n return parameters.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("alpha"); + expect(names).toContain("enabled"); + // Should NOT contain globals + expect(names).not.toContain("Array"); + }); + + it("returns place names after `input.`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n return input.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("Source"); + // Should NOT contain unrelated globals + expect(names).not.toContain("Array"); + }); + + it("returns token properties after `input.Source[0].`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n const token = input.Source[0];\n return token.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("x"); + }); + + it("returns String methods after string expression", () => { + const names = getCompletionNames( + baseSdcpn, + `const s = "hello";\ns.${CURSOR}`, + { + type: "transition-lambda", + }, + ); + + expect(names).toContain("charAt"); + expect(names).toContain("indexOf"); + expect(names).not.toContain("Array"); + }); + }); + + describe("top-level completions (no dot)", () => { + it("returns globals and declared identifiers at top level", () => { + const names = getCompletionNames(baseSdcpn, `const a = 42;\n${CURSOR}`, { + type: "transition-lambda", + }); + + // Should include user-defined and prefix-declared identifiers + expect(names).toContain("a"); + expect(names).toContain("Lambda"); + // Should include globals + expect(names).toContain("Array"); + }); + }); + + describe("updateFileContent", () => { + it("returns completions for updated code, not original code", () => { + // Start with number code + const sdcpn = createSDCPN({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "const a = 42;\na.", + }, + ], + }); + + const ls = createSDCPNLanguageService(sdcpn); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // Update to string code + const { offset, code } = parseCursor(`const s = "hello";\ns.${CURSOR}`); + ls.updateFileContent(filePath, code); + + const completions = ls.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + const names = (completions?.entries ?? []).map((entry) => entry.name); + + // Should have String methods + expect(names).toContain("charAt"); + expect(names).toContain("indexOf"); + // Should NOT have Number methods + expect(names).not.toContain("toFixed"); + }); + + it("reflects new content after multiple updates", () => { + const sdcpn = createSDCPN({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "", + }, + ], + }); + + const ls = createSDCPNLanguageService(sdcpn); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // First update: number + const first = parseCursor(`const a = 42;\na.${CURSOR}`); + ls.updateFileContent(filePath, first.code); + const firstCompletions = ls.getCompletionsAtPosition( + filePath, + first.offset, + undefined, + ); + const firstNames = (firstCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(firstNames).toContain("toFixed"); + + // Second update: string + const second = parseCursor(`const s = "hello";\ns.${CURSOR}`); + ls.updateFileContent(filePath, second.code); + const secondCompletions = ls.getCompletionsAtPosition( + filePath, + second.offset, + undefined, + ); + const secondNames = (secondCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(secondNames).toContain("charAt"); + expect(secondNames).not.toContain("toFixed"); + }); + }); + + describe("differential equation completions", () => { + const deSdcpn = { + types: [ + { + id: "color1", + elements: [{ name: "velocity", type: "real" as const }], + }, + ], + parameters: [ + { id: "p1", variableName: "gravity", type: "real" as const }, + ], + differentialEquations: [ + { + id: "de1", + colorId: "color1", + code: "", + }, + ], + }; + + it("returns token properties after `tokens[0].`", () => { + const names = getCompletionNames( + deSdcpn, + `export default Dynamics((tokens, parameters) => {\n const t = tokens[0];\n return t.${CURSOR};\n});`, + { type: "differential-equation", deId: "de1" }, + ); + + expect(names).toContain("velocity"); + }); + + it("returns parameter names after `parameters.`", () => { + const names = getCompletionNames( + deSdcpn, + `export default Dynamics((tokens, parameters) => {\n return parameters.${CURSOR};\n});`, + { type: "differential-equation", deId: "de1" }, + ); + + expect(names).toContain("gravity"); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 33a05d29a34..02064505ca5 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -7,7 +7,9 @@ import { } from "./create-language-service-host"; import { getItemFilePath } from "./file-paths"; -export type SDCPNLanguageService = ts.LanguageService; +export type SDCPNLanguageService = ts.LanguageService & { + updateFileContent: (fileName: string, content: string) => void; +}; /** * Sanitizes a color ID to be a valid TypeScript identifier. @@ -250,13 +252,15 @@ function adjustDiagnostics( */ export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { const files = generateVirtualFiles(sdcpn); - const host = createLanguageServiceHost(files); + const { host, updateFileContent } = createLanguageServiceHost(files); const baseService = ts.createLanguageService(host); // Proxy service to adjust positions for injected prefixes return { ...baseService, + updateFileContent, + getSemanticDiagnostics(fileName) { const entry = files.get(fileName); const prefixLength = entry?.prefix?.length ?? 0; @@ -280,5 +284,34 @@ export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { options, ); }, + + getQuickInfoAtPosition(fileName, position) { + const entry = files.get(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + const info = baseService.getQuickInfoAtPosition( + fileName, + position + prefixLength, + ); + if (!info) { + return undefined; + } + return { + ...info, + textSpan: { + start: info.textSpan.start - prefixLength, + length: info.textSpan.length, + }, + }; + }, + + getSignatureHelpItems(fileName, position, options) { + const entry = files.get(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + return baseService.getSignatureHelpItems( + fileName, + position + prefixLength, + options, + ); + }, }; } diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index 4e5388cb950..04ef268eef0 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -14,14 +14,15 @@ export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { petriNetDefinition } = use(SDCPNContext); - const { checkSDCPN } = useCheckerWorker(); + const { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp } = + useCheckerWorker(); const [checkResult, setCheckerResult] = useState(EMPTY_RESULT); useEffect(() => { let cancelled = false; - void checkSDCPN(petriNetDefinition).then((result) => { + void setSDCPN(petriNetDefinition).then((result) => { if (!cancelled) { setCheckerResult(result); } @@ -30,7 +31,7 @@ export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ return () => { cancelled = true; }; - }, [petriNetDefinition, checkSDCPN]); + }, [petriNetDefinition, setSDCPN]); const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( (sum, item) => sum + item.diagnostics.length, @@ -42,6 +43,9 @@ export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ value={{ checkResult, totalDiagnosticsCount, + getCompletions, + getQuickInfo, + getSignatureHelp, }} > {children} diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts index bbc0f96ad6c..caa4b826ca7 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -5,18 +5,34 @@ * Receives JSON-RPC requests via `postMessage`, delegates to `checkSDCPN`, * serializes the diagnostics (flatten messageText, strip non-cloneable fields), * and posts the result back. + * + * The LanguageService is persisted between calls so that `getCompletions` + * can reuse it without re-sending the full SDCPN model. */ import ts from "typescript"; import { checkSDCPN } from "../lib/checker"; +import { + createSDCPNLanguageService, + type SDCPNLanguageService, +} from "../lib/create-sdcpn-language-service"; +import { getItemFilePath } from "../lib/file-paths"; import type { + CheckerCompletionItem, + CheckerCompletionResult, CheckerDiagnostic, CheckerItemDiagnostics, + CheckerQuickInfoResult, CheckerResult, + CheckerSignatureHelpResult, + CheckerSignatureInfo, JsonRpcRequest, JsonRpcResponse, } from "./protocol"; +/** Persisted LanguageService — created on `setSDCPN`, reused by `getCompletions`. */ +let languageService: SDCPNLanguageService | null = null; + /** Strip `ts.SourceFile` and flatten `DiagnosticMessageChain` for structured clone. */ function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { return { @@ -28,27 +44,187 @@ function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { }; } -self.onmessage = ({ data: { id, params } }: MessageEvent) => { +/** Map (itemType, itemId) → checker virtual file path. */ +function getCheckerFilePath( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, +): string { + switch (itemType) { + case "transition-lambda": + return getItemFilePath("transition-lambda-code", { + transitionId: itemId, + }); + case "transition-kernel": + return getItemFilePath("transition-kernel-code", { + transitionId: itemId, + }); + case "differential-equation": + return getItemFilePath("differential-equation-code", { id: itemId }); + } +} + +self.onmessage = async ({ data }: MessageEvent) => { + const { id, method } = data; + try { - const raw = checkSDCPN(params.sdcpn); - - const result: CheckerResult = { - isValid: raw.isValid, - itemDiagnostics: raw.itemDiagnostics.map( - (item): CheckerItemDiagnostics => ({ - itemId: item.itemId, - itemType: item.itemType, - filePath: item.filePath, - diagnostics: item.diagnostics.map(serializeDiagnostic), - }), - ), - }; + switch (method) { + case "setSDCPN": { + const { sdcpn } = data.params; - self.postMessage({ - jsonrpc: "2.0", - id, - result, - } satisfies JsonRpcResponse); + languageService = createSDCPNLanguageService(sdcpn); + const raw = checkSDCPN(sdcpn, languageService); + + const result: CheckerResult = { + isValid: raw.isValid, + itemDiagnostics: raw.itemDiagnostics.map( + (item): CheckerItemDiagnostics => ({ + itemId: item.itemId, + itemType: item.itemType, + filePath: item.filePath, + diagnostics: item.diagnostics.map(serializeDiagnostic), + }), + ), + }; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + + case "getCompletions": { + const { itemType, itemId, offset } = data.params; + + // Wait before requesting completions, to be sure the file content is updated + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: { items: [] }, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const completions = languageService.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + + const items: CheckerCompletionItem[] = (completions?.entries ?? []).map( + (entry) => ({ + name: entry.name, + kind: entry.kind, + sortText: entry.sortText, + insertText: entry.insertText, + }), + ); + + self.postMessage({ + jsonrpc: "2.0", + id, + result: { items }, + } satisfies JsonRpcResponse); + break; + } + + case "getQuickInfo": { + const { itemType, itemId, offset } = data.params; + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: null, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const info = languageService.getQuickInfoAtPosition(filePath, offset); + + const result: CheckerQuickInfoResult = info + ? { + displayParts: ts.displayPartsToString(info.displayParts), + documentation: ts.displayPartsToString(info.documentation), + start: info.textSpan.start, + length: info.textSpan.length, + } + : null; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + + case "getSignatureHelp": { + const { itemType, itemId, offset } = data.params; + + // Wait a bit before requesting completions, to be sure the file content is updated + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: null, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const help = languageService.getSignatureHelpItems( + filePath, + offset, + undefined, + ); + + const result: CheckerSignatureHelpResult = help + ? { + activeSignature: help.selectedItemIndex, + activeParameter: help.argumentIndex, + signatures: help.items.map( + (item): CheckerSignatureInfo => ({ + label: [ + ...item.prefixDisplayParts, + ...item.parameters.flatMap((param, idx) => [ + ...(idx > 0 ? item.separatorDisplayParts : []), + ...param.displayParts, + ]), + ...item.suffixDisplayParts, + ] + .map((part) => part.text) + .join(""), + documentation: ts.displayPartsToString(item.documentation), + parameters: item.parameters.map((param) => ({ + label: ts.displayPartsToString(param.displayParts), + documentation: ts.displayPartsToString(param.documentation), + })), + }), + ), + } + : null; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + } } catch (err) { self.postMessage({ jsonrpc: "2.0", diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts index fc63218f0a3..7e52045904c 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -45,17 +45,106 @@ export type CheckerResult = { itemDiagnostics: CheckerItemDiagnostics[]; }; +// --------------------------------------------------------------------------- +// Completions — serializable variants of ts.CompletionEntry +// --------------------------------------------------------------------------- + +/** A single completion suggestion, safe for structured clone. */ +export type CheckerCompletionItem = { + name: string; + /** @see ts.ScriptElementKind */ + kind: string; + sortText: string; + insertText?: string; +}; + +/** Result of requesting completions at a position. */ +export type CheckerCompletionResult = { + items: CheckerCompletionItem[]; +}; + +// --------------------------------------------------------------------------- +// Quick Info (hover) — serializable variant of ts.QuickInfo +// --------------------------------------------------------------------------- + +/** Result of requesting quick info (hover) at a position. */ +export type CheckerQuickInfoResult = { + /** Type/signature display string. */ + displayParts: string; + /** JSDoc documentation string. */ + documentation: string; + /** Offset in user code where the hovered symbol starts. */ + start: number; + /** Length of the hovered symbol span. */ + length: number; +} | null; + +// --------------------------------------------------------------------------- +// Signature Help — serializable variant of ts.SignatureHelpItems +// --------------------------------------------------------------------------- + +/** A single parameter in a signature. */ +export type CheckerSignatureParameter = { + label: string; + documentation: string; +}; + +/** A single signature (overload). */ +export type CheckerSignatureInfo = { + label: string; + documentation: string; + parameters: CheckerSignatureParameter[]; +}; + +/** Result of requesting signature help at a position. */ +export type CheckerSignatureHelpResult = { + signatures: CheckerSignatureInfo[]; + activeSignature: number; + activeParameter: number; +} | null; + // --------------------------------------------------------------------------- // JSON-RPC 2.0 // --------------------------------------------------------------------------- /** A JSON-RPC request sent from the main thread to the worker. */ -export type JsonRpcRequest = { - jsonrpc: "2.0"; - id: number; - method: "checkSDCPN"; - params: { sdcpn: SDCPN }; -}; +export type JsonRpcRequest = + | { + jsonrpc: "2.0"; + id: number; + method: "setSDCPN"; + params: { sdcpn: SDCPN }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getCompletions"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getQuickInfo"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getSignatureHelp"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + }; /** A JSON-RPC response sent from the worker back to the main thread. */ export type JsonRpcResponse = diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts index e5627c34cd6..82d63cbca62 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts @@ -1,21 +1,43 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { SDCPN } from "../../core/types/sdcpn"; import type { + CheckerCompletionResult, + CheckerItemDiagnostics, + CheckerQuickInfoResult, CheckerResult, + CheckerSignatureHelpResult, JsonRpcRequest, JsonRpcResponse, } from "./protocol"; type Pending = { - resolve: (result: CheckerResult) => void; + resolve: (result: never) => void; reject: (error: Error) => void; }; /** Methods exposed by the checker WebWorker. */ export type CheckerWorkerApi = { - /** Validate all user code in an SDCPN model. Runs off the main thread. */ - checkSDCPN: (sdcpn: SDCPN) => Promise; + /** Send an SDCPN model to the worker. Persists the LanguageService and returns diagnostics. */ + setSDCPN: (sdcpn: SDCPN) => Promise; + /** Request completions at a position within an SDCPN item. */ + getCompletions: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request quick info (hover) at a position within an SDCPN item. */ + getQuickInfo: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request signature help at a position within an SDCPN item. */ + getSignatureHelp: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; }; /** @@ -32,9 +54,7 @@ export function useCheckerWorker(): CheckerWorkerApi { type: "module", }); - worker.onmessage = ( - event: MessageEvent>, - ) => { + worker.onmessage = (event: MessageEvent) => { const response = event.data; const pending = pendingRef.current.get(response.id); if (!pending) { @@ -45,7 +65,7 @@ export function useCheckerWorker(): CheckerWorkerApi { if ("error" in response) { pending.reject(new Error(response.error.message)); } else { - pending.resolve(response.result); + pending.resolve(response.result as never); } }; @@ -62,25 +82,84 @@ export function useCheckerWorker(): CheckerWorkerApi { }; }, []); - const checkSDCPN = (sdcpn: SDCPN): Promise => { + const sendRequest = useCallback((request: JsonRpcRequest): Promise => { const worker = workerRef.current; if (!worker) { return Promise.reject(new Error("Worker not initialized")); } - const id = nextId.current++; - const request: JsonRpcRequest = { - jsonrpc: "2.0", - id, - method: "checkSDCPN", - params: { sdcpn }, - }; - - return new Promise((resolve, reject) => { - pendingRef.current.set(id, { resolve, reject }); + return new Promise((resolve, reject) => { + pendingRef.current.set(request.id, { + resolve: resolve as (result: never) => void, + reject, + }); worker.postMessage(request); }); - }; + }, []); + + const setSDCPN = useCallback( + (sdcpn: SDCPN): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "setSDCPN", + params: { sdcpn }, + }); + }, + [sendRequest], + ); + + const getCompletions = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getCompletions", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + const getQuickInfo = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getQuickInfo", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + const getSignatureHelp = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getSignatureHelp", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); - return { checkSDCPN }; + return { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp }; } diff --git a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx new file mode 100644 index 00000000000..9a9cbbe0868 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx @@ -0,0 +1,127 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerCompletionItem } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +/** + * Map TypeScript `ScriptElementKind` strings to Monaco `CompletionItemKind`. + * @see https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts + */ +function toCompletionItemKind( + kind: string, + monaco: typeof Monaco, +): Monaco.languages.CompletionItemKind { + switch (kind) { + case "method": + case "construct": + return monaco.languages.CompletionItemKind.Method; + case "function": + case "local function": + return monaco.languages.CompletionItemKind.Function; + case "constructor": + return monaco.languages.CompletionItemKind.Constructor; + case "property": + case "getter": + case "setter": + return monaco.languages.CompletionItemKind.Property; + case "parameter": + case "var": + case "local var": + case "let": + return monaco.languages.CompletionItemKind.Variable; + case "const": + return monaco.languages.CompletionItemKind.Variable; + case "class": + return monaco.languages.CompletionItemKind.Class; + case "interface": + return monaco.languages.CompletionItemKind.Interface; + case "type": + case "type parameter": + case "primitive type": + case "alias": + return monaco.languages.CompletionItemKind.TypeParameter; + case "enum": + return monaco.languages.CompletionItemKind.Enum; + case "enum member": + return monaco.languages.CompletionItemKind.EnumMember; + case "module": + case "external module name": + return monaco.languages.CompletionItemKind.Module; + case "keyword": + return monaco.languages.CompletionItemKind.Keyword; + case "string": + return monaco.languages.CompletionItemKind.Value; + default: + return monaco.languages.CompletionItemKind.Text; + } +} + +function toMonacoCompletion( + entry: CheckerCompletionItem, + range: Monaco.IRange, + monaco: typeof Monaco, +): Monaco.languages.CompletionItem { + return { + label: entry.name, + kind: toCompletionItemKind(entry.kind, monaco), + insertText: entry.insertText ?? entry.name, + sortText: entry.sortText, + range, + }; +} + +const CompletionSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getCompletions } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerCompletionItemProvider( + "typescript", + { + triggerCharacters: ["."], + + async provideCompletionItems(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return { suggestions: [] }; + } + + const offset = model.getOffsetAt(position); + const result = await getCompletions( + parsed.itemType, + parsed.itemId, + offset, + ); + + const word = model.getWordUntilPosition(position); + const range: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: result.items.map((item) => + toMonacoCompletion(item, range, monaco), + ), + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, getCompletions]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco CompletionItemProvider backed by the checker worker. */ +export const CompletionSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts index cef45bd237d..f6f167e5ef6 100644 --- a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -14,3 +14,33 @@ export function getEditorPath( return `inmemory://sdcpn/differential-equations/${itemId}.ts`; } } + +const TRANSITION_LAMBDA_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/lambda\.ts$/; +const TRANSITION_KERNEL_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/kernel\.ts$/; +const DIFFERENTIAL_EQUATION_RE = + /^inmemory:\/\/sdcpn\/differential-equations\/([^/]+)\.ts$/; + +/** Extract `(itemType, itemId)` from a Monaco model URI string. */ +export function parseEditorPath(uri: string): { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; +} | null { + let match = TRANSITION_LAMBDA_RE.exec(uri); + if (match) { + return { itemType: "transition-lambda", itemId: match[1]! }; + } + + match = TRANSITION_KERNEL_RE.exec(uri); + if (match) { + return { itemType: "transition-kernel", itemId: match[1]! }; + } + + match = DIFFERENTIAL_EQUATION_RE.exec(uri); + if (match) { + return { itemType: "differential-equation", itemId: match[1]! }; + } + + return null; +} diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx new file mode 100644 index 00000000000..06b3bd14d87 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -0,0 +1,58 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +const HoverSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getQuickInfo } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerHoverProvider("typescript", { + async provideHover(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return null; + } + + const offset = model.getOffsetAt(position); + const info = await getQuickInfo(parsed.itemType, parsed.itemId, offset); + + if (!info) { + return null; + } + + const startPos = model.getPositionAt(info.start); + const endPos = model.getPositionAt(info.start + info.length); + const range: Monaco.IRange = { + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }; + + const contents: Monaco.IMarkdownString[] = [ + { value: `\`\`\`typescript\n${info.displayParts}\n\`\`\`` }, + ]; + if (info.documentation) { + contents.push({ value: info.documentation }); + } + + return { range, contents }; + }, + }); + + return () => disposable.dispose(); + }, [monaco, getQuickInfo]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco HoverProvider backed by the checker worker. */ +export const HoverSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index ce18102f437..6488438e526 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -1,8 +1,11 @@ import type * as Monaco from "monaco-editor"; +import { CompletionSync } from "./completion-sync"; import type { MonacoContextValue } from "./context"; import { MonacoContext } from "./context"; import { DiagnosticsSync } from "./diagnostics-sync"; +import { HoverSync } from "./hover-sync"; +import { SignatureHelpSync } from "./signature-help-sync"; interface LanguageDefaults { setModeConfiguration(config: Record): void; @@ -42,38 +45,6 @@ function disableBuiltInTypeScriptFeatures(monaco: typeof Monaco) { ts.javascriptDefaults.setModeConfiguration(modeConfiguration); } -function registerCompletionProvider(monaco: typeof Monaco) { - monaco.languages.registerCompletionItemProvider("typescript", { - provideCompletionItems(model, position) { - const word = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }; - - // eslint-disable-next-line no-console - console.log("Completion requested", { - position: { line: position.lineNumber, column: position.column }, - word: word.word, - range, - }); - - return { - suggestions: [ - { - label: "transition", - kind: monaco.languages.CompletionItemKind.Keyword, - insertText: "transition", - range, - }, - ], - }; - }, - }); -} - async function initMonaco(): Promise { // Disable all workers — no worker files will be shipped or loaded. (globalThis as Record).MonacoEnvironment = { @@ -89,7 +60,6 @@ async function initMonaco(): Promise { monacoReact.loader.config({ monaco }); disableBuiltInTypeScriptFeatures(monaco); - registerCompletionProvider(monaco); return { monaco, Editor: monacoReact.default }; } @@ -102,6 +72,9 @@ export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ return ( + + + {children} ); diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx new file mode 100644 index 00000000000..54f71f59f8b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -0,0 +1,73 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerSignatureHelpResult } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +function toMonacoSignatureHelp( + result: NonNullable, +): Monaco.languages.SignatureHelp { + return { + activeSignature: result.activeSignature, + activeParameter: result.activeParameter, + signatures: result.signatures.map((sig) => ({ + label: sig.label, + documentation: sig.documentation || undefined, + parameters: sig.parameters.map((param) => ({ + label: param.label, + documentation: param.documentation || undefined, + })), + })), + }; +} + +const SignatureHelpSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getSignatureHelp } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerSignatureHelpProvider( + "typescript", + { + signatureHelpTriggerCharacters: ["(", ","], + signatureHelpRetriggerCharacters: [","], + + async provideSignatureHelp(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return null; + } + + const offset = model.getOffsetAt(position); + const result = await getSignatureHelp( + parsed.itemType, + parsed.itemId, + offset, + ); + + if (!result) { + return null; + } + + return { + value: toMonacoSignatureHelp(result), + dispose() {}, + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, getSignatureHelp]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco SignatureHelpProvider backed by the checker worker. */ +export const SignatureHelpSync: React.FC = () => ( + + + +);