From 2b4739f20efb47108831eabc23e4526cb35144b6 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 15 May 2026 19:08:15 +0100 Subject: [PATCH 01/21] wip petrinaut ai assistant UI --- apps/petrinaut-website/package.json | 73 +- apps/petrinaut-website/src/main/app.tsx | 433 +++--- apps/petrinaut-website/tsconfig.json | 2 +- apps/petrinaut-website/vite.config.ts | 167 ++- libs/@hashintel/petrinaut/package.json | 211 +-- .../src/react/mutation-provider.test.tsx | 3 + .../src/react/state/editor-context.ts | 19 + .../src/react/state/editor-provider.tsx | 9 + .../src/ui/components/ai-assistant-icon.tsx | 53 + libs/@hashintel/petrinaut/src/ui/index.ts | 8 +- .../src/ui/petrinaut-story-provider.tsx | 261 ++-- .../petrinaut/src/ui/petrinaut.stories.tsx | 110 ++ .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 16 + .../components/BottomBar/bottom-bar.tsx | 23 + .../src/ui/views/Editor/editor-view.tsx | 951 +++++++----- .../AiAssistant/assistant-panel.stories.tsx | 307 ++++ .../AiAssistant/assistant-surface.test.tsx | 593 ++++++++ .../panels/AiAssistant/assistant-surface.tsx | 1270 +++++++++++++++++ .../views/Editor/panels/AiAssistant/panel.tsx | 334 +++++ .../AiAssistant/storybook-ai-transport.ts | 95 ++ .../panels/AiAssistant/tool-summaries.test.ts | 82 ++ .../panels/AiAssistant/tool-summaries.ts | 493 +++++++ .../views/Editor/panels/AiAssistant/types.ts | 31 + .../panels/SimulateView/simulate-view.tsx | 114 +- package.json | 229 ++- yarn.lock | 154 +- 26 files changed, 5029 insertions(+), 1012 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index d50edb48de3..b8613bb977b 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -1,37 +1,40 @@ { - "name": "@apps/petrinaut-website", - "version": "0.0.0-private", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit" - }, - "dependencies": { - "@hashintel/ds-components": "workspace:*", - "@hashintel/ds-helpers": "workspace:*", - "@hashintel/petrinaut": "workspace:*", - "@hashintel/petrinaut-core": "workspace:*", - "@mantine/hooks": "8.3.5", - "@pandacss/dev": "1.11.1", - "@sentry/react": "10.22.0", - "immer": "10.1.3", - "react": "19.2.6", - "react-dom": "19.2.6", - "react-icons": "5.5.0" - }, - "devDependencies": { - "@rolldown/plugin-babel": "0.2.1", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260511.1", - "@vitejs/plugin-react": "6.0.1", - "babel-plugin-react-compiler": "1.0.0", - "oxlint": "1.63.0", - "oxlint-tsgolint": "0.22.1", - "vite": "8.0.12" - } + "name": "@apps/petrinaut-website", + "version": "0.0.0-private", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit" + }, + "dependencies": { + "@ai-sdk/openai": "3.0.63", + "@hashintel/ds-components": "workspace:*", + "@hashintel/ds-helpers": "workspace:*", + "@hashintel/petrinaut": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", + "@mantine/hooks": "8.3.5", + "@pandacss/dev": "1.11.1", + "@sentry/react": "10.22.0", + "ai": "6.0.182", + "immer": "10.1.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-icons": "5.5.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@rolldown/plugin-babel": "0.2.1", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "@vitejs/plugin-react": "6.0.1", + "babel-plugin-react-compiler": "1.0.0", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "vite": "8.0.12" + } } diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index b8de524bdad..ba54cd78d12 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -1,41 +1,47 @@ import { produce } from "immer"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createJsonDocHandle } from "@hashintel/petrinaut-core"; -import { Petrinaut } from "@hashintel/petrinaut/ui"; +import { + DefaultChatTransport, + Petrinaut, + type PetrinautAiChatTransport, + type PetrinautAiMessage, +} from "@hashintel/petrinaut/ui"; import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; +import { useLocalStorageAiMessages } from "./app/use-local-storage-ai-messages"; import { - type SDCPNInLocalStorage, - useLocalStorageSDCPNs, + type SDCPNInLocalStorage, + useLocalStorageSDCPNs, } from "./app/use-local-storage-sdcpns"; import type { - MinimalNetMetadata, - PetrinautDocHandle, - SDCPN, + MinimalNetMetadata, + PetrinautDocHandle, + SDCPN, } from "@hashintel/petrinaut-core"; const isEmptySDCPN = (sdcpn: SDCPN) => - sdcpn.places.length === 0 && - sdcpn.transitions.length === 0 && - sdcpn.types.length === 0 && - sdcpn.parameters.length === 0 && - sdcpn.differentialEquations.length === 0; + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], }; const createDefaultStoredSDCPN = (): SDCPNInLocalStorage => ({ - id: "net-1", - title: "New Process", - sdcpn: emptySDCPN, - lastUpdated: new Date(0).toISOString(), + id: "net-1", + title: "New Process", + sdcpn: emptySDCPN, + lastUpdated: new Date(0).toISOString(), }); /** @@ -43,43 +49,48 @@ const createDefaultStoredSDCPN = (): SDCPNInLocalStorage => ({ * id and last-updated timestamp in sync. */ const createLocalStorageNetRecord = (params: { - petriNetDefinition: SDCPN; - title: string; + petriNetDefinition: SDCPN; + title: string; }): SDCPNInLocalStorage => { - const now = new Date(); - - return { - id: `net-${now.getTime()}`, - title: params.title, - sdcpn: params.petriNetDefinition, - lastUpdated: now.toISOString(), - }; + const now = new Date(); + + return { + id: `net-${now.getTime()}`, + title: params.title, + sdcpn: params.petriNetDefinition, + lastUpdated: now.toISOString(), + }; }; const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => - createJsonDocHandle({ id: net.id, initial: net.sdcpn }); + createJsonDocHandle({ id: net.id, initial: net.sdcpn }); + +const petrinautAiChatTransport: PetrinautAiChatTransport = + new DefaultChatTransport({ + api: "/api/chat", + }); const getStoredSDCPNsForDisplay = ( - storedSDCPNs: Record, + storedSDCPNs: Record, ): Record => { - if (Object.values(storedSDCPNs).length > 0) { - return storedSDCPNs; - } + if (Object.values(storedSDCPNs).length > 0) { + return storedSDCPNs; + } - const defaultStoredSDCPN = createDefaultStoredSDCPN(); - return { [defaultStoredSDCPN.id]: defaultStoredSDCPN }; + const defaultStoredSDCPN = createDefaultStoredSDCPN(); + return { [defaultStoredSDCPN.id]: defaultStoredSDCPN }; }; type ActiveHandle = { - handle: PetrinautDocHandle; - netId: string; - fallbackNet: SDCPNInLocalStorage; + handle: PetrinautDocHandle; + netId: string; + fallbackNet: SDCPNInLocalStorage; }; const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ - handle: createHandle(net), - netId: net.id, - fallbackNet: net, + handle: createHandle(net), + netId: net.id, + fallbackNet: net, }); /** @@ -91,155 +102,187 @@ const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ * for background nets. */ export const DevApp = () => { - const sentryFeedbackAction = useSentryFeedbackAction(); - const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); - const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); - const firstNet = Object.values(storedSDCPNsForDisplay)[0] ?? null; - - // The net currently selected in the UI. - const [currentNetId, setCurrentNetId] = useState( - () => firstNet?.id ?? null, - ); - - // Metadata and persisted SDCPN snapshot for the selected net. - const currentNet = currentNetId - ? (storedSDCPNsForDisplay[currentNetId] ?? null) - : null; - - // Live editable document handle for the selected net only. - const [activeHandle, setActiveHandle] = useState(() => - firstNet ? createActiveHandle(firstNet) : null, - ); - - useEffect(() => { - if (!activeHandle) { - return; - } - - const { fallbackNet, handle, netId } = activeHandle; - - return handle.subscribe((event) => { - const lastUpdated = new Date().toISOString(); - - setStoredSDCPNs((prev) => { - const stored = prev[netId] ?? fallbackNet; - - return produce(prev, (draft) => { - draft[netId] = { - ...stored, - sdcpn: event.next, - lastUpdated, - }; - }); - }); - }); - }, [activeHandle, setStoredSDCPNs]); - - const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) - .map((net) => ({ - netId: net.id, - title: net.title, - lastUpdated: net.lastUpdated, - })) - .sort( - (a, b) => - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), - ); - - const createNewNet = (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const newNet = createLocalStorageNetRecord(params); - const previousNet = - currentNetId && currentNetId !== newNet.id ? currentNet : null; - const previousNetIdToRemove = previousNet !== null ? currentNetId : null; - - setStoredSDCPNs((prev) => { - const next = { ...prev, [newNet.id]: newNet }; - - // Remove the previous net if it was empty and unmodified - if ( - previousNetIdToRemove && - previousNet && - isEmptySDCPN(prev[previousNetIdToRemove]?.sdcpn ?? previousNet.sdcpn) - ) { - delete next[previousNetIdToRemove]; - } - - return next; - }); - setActiveHandle(createActiveHandle(newNet)); - setCurrentNetId(newNet.id); - }; - - const loadPetriNet = (petriNetId: string) => { - const netToLoad = storedSDCPNsForDisplay[petriNetId]; - if (!netToLoad) { - return; - } - - // Remove the current net if it was empty and unmodified - if (currentNetId && currentNetId !== petriNetId) { - const previousNetIdToRemove = - currentNet && isEmptySDCPN(currentNet.sdcpn) ? currentNetId : null; - - setStoredSDCPNs((prev) => { - const prevNet = previousNetIdToRemove - ? prev[previousNetIdToRemove] - : null; - - if (previousNetIdToRemove && prevNet && isEmptySDCPN(prevNet.sdcpn)) { - const next = { ...prev }; - delete next[previousNetIdToRemove]; - return next; - } - return prev; - }); - } - setActiveHandle(createActiveHandle(netToLoad)); - setCurrentNetId(petriNetId); - }; - - const setTitle = (title: string) => { - if (!currentNetId || !currentNet) { - return; - } - - const lastUpdated = new Date().toISOString(); - - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - draft[currentNetId] = { - ...(draft[currentNetId] ?? currentNet), - title, - lastUpdated, - }; - }), - ); - }; - - if (!currentNet) { - return null; - } - - if (!activeHandle || activeHandle.netId !== currentNet.id) { - return null; - } - - return ( -
- -
- ); + const sentryFeedbackAction = useSentryFeedbackAction(); + const { aiMessagesByNetId, setAiMessagesByNetId } = + useLocalStorageAiMessages(); + const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); + const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); + const firstNet = Object.values(storedSDCPNsForDisplay)[0] ?? null; + + // The net currently selected in the UI. + const [currentNetId, setCurrentNetId] = useState( + () => firstNet?.id ?? null, + ); + + // Metadata and persisted SDCPN snapshot for the selected net. + const currentNet = currentNetId + ? (storedSDCPNsForDisplay[currentNetId] ?? null) + : null; + + // Live editable document handle for the selected net only. + const [activeHandle, setActiveHandle] = useState(() => + firstNet ? createActiveHandle(firstNet) : null, + ); + + useEffect(() => { + if (!activeHandle) { + return; + } + + const { fallbackNet, handle, netId } = activeHandle; + + return handle.subscribe((event) => { + const lastUpdated = new Date().toISOString(); + + setStoredSDCPNs((prev) => { + const stored = prev[netId] ?? fallbackNet; + + return produce(prev, (draft) => { + draft[netId] = { + ...stored, + sdcpn: event.next, + lastUpdated, + }; + }); + }); + }); + }, [activeHandle, setStoredSDCPNs]); + + const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) + .map((net) => ({ + netId: net.id, + title: net.title, + lastUpdated: net.lastUpdated, + })) + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const newNet = createLocalStorageNetRecord(params); + const previousNet = + currentNetId && currentNetId !== newNet.id ? currentNet : null; + const previousNetIdToRemove = previousNet !== null ? currentNetId : null; + + setStoredSDCPNs((prev) => { + const next = { ...prev, [newNet.id]: newNet }; + + // Remove the previous net if it was empty and unmodified + if ( + previousNetIdToRemove && + previousNet && + isEmptySDCPN(prev[previousNetIdToRemove]?.sdcpn ?? previousNet.sdcpn) + ) { + delete next[previousNetIdToRemove]; + } + + return next; + }); + setActiveHandle(createActiveHandle(newNet)); + setCurrentNetId(newNet.id); + }; + + const loadPetriNet = (petriNetId: string) => { + const netToLoad = storedSDCPNsForDisplay[petriNetId]; + if (!netToLoad) { + return; + } + + // Remove the current net if it was empty and unmodified + if (currentNetId && currentNetId !== petriNetId) { + const previousNetIdToRemove = + currentNet && isEmptySDCPN(currentNet.sdcpn) ? currentNetId : null; + + setStoredSDCPNs((prev) => { + const prevNet = previousNetIdToRemove + ? prev[previousNetIdToRemove] + : null; + + if (previousNetIdToRemove && prevNet && isEmptySDCPN(prevNet.sdcpn)) { + const next = { ...prev }; + delete next[previousNetIdToRemove]; + return next; + } + return prev; + }); + } + setActiveHandle(createActiveHandle(netToLoad)); + setCurrentNetId(petriNetId); + }; + + const setTitle = (title: string) => { + if (!currentNetId || !currentNet) { + return; + } + + const lastUpdated = new Date().toISOString(); + + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + draft[currentNetId] = { + ...(draft[currentNetId] ?? currentNet), + title, + lastUpdated, + }; + }), + ); + }; + + const aiAssistant = useMemo( + () => ({ + transport: petrinautAiChatTransport, + messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, + onMessages: (messages: PetrinautAiMessage[]) => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => ({ + ...prev, + [currentNetId]: messages, + })); + }, + onClearMessages: () => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => { + const next = { ...prev }; + delete next[currentNetId]; + return next; + }); + }, + }), + [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], + ); + + if (!currentNet) { + return null; + } + + if (!activeHandle || activeHandle.netId !== currentNet.id) { + return null; + } + + return ( +
+ +
+ ); }; diff --git a/apps/petrinaut-website/tsconfig.json b/apps/petrinaut-website/tsconfig.json index a8ddecb6f7e..32ce2125854 100644 --- a/apps/petrinaut-website/tsconfig.json +++ b/apps/petrinaut-website/tsconfig.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "isolatedModules": true }, - "include": ["src"] + "include": ["api", "src"] } diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index be2c57d3852..acf2ef546b9 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -1,9 +1,170 @@ +import type { + IncomingHttpHeaders, + IncomingMessage, + ServerResponse, +} from "node:http"; +import { fileURLToPath } from "node:url"; + import babel from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv, type Plugin } from "vite"; + +type DevApiHandler = (request: Request) => Promise; + +const appRoot = fileURLToPath(new URL(".", import.meta.url)); + +const loadServerEnv = (mode: string) => { + const env = loadEnv(mode, appRoot, ""); + + for (const [key, value] of Object.entries(env)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +}; + +const readRequestBody = async ( + request: IncomingMessage, +): Promise => { + if (request.method === "GET" || request.method === "HEAD") { + return undefined; + } + + const chunks: Uint8Array[] = []; + const encoder = new TextEncoder(); + + await new Promise((resolve, reject) => { + request.on("data", (chunk: string | Uint8Array) => { + chunks.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk); + }); + request.on("end", resolve); + request.on("error", reject); + }); + + if (chunks.length === 0) { + return undefined; + } + + const byteLength = chunks.reduce( + (total, chunk) => total + chunk.byteLength, + 0, + ); + const body = new Uint8Array(byteLength); + let offset = 0; + + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + return body; +}; + +const headersFromIncomingMessage = (headers: IncomingHttpHeaders): Headers => { + const result = new Headers(); + + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const headerValue of value) { + result.append(key, headerValue); + } + } else if (value !== undefined) { + result.set(key, value); + } + } + + return result; +}; + +const isDevApiModule = ( + value: unknown, +): value is { default: DevApiHandler } => { + const maybeModule = value as { default?: unknown }; + + return typeof maybeModule.default === "function"; +}; + +const writeResponse = async ( + response: Response, + serverResponse: ServerResponse, +) => { + const nodeResponse = serverResponse; + nodeResponse.statusCode = response.status; + nodeResponse.statusMessage = response.statusText; + + response.headers.forEach((value, key) => { + nodeResponse.setHeader(key, value); + }); + + if (!response.body) { + nodeResponse.end(); + return; + } + + const reader = response.body.getReader(); + + try { + let result = await reader.read(); + + while (!result.done) { + nodeResponse.write(result.value); + result = await reader.read(); + } + + nodeResponse.end(); + } finally { + reader.releaseLock(); + } +}; + +const petrinautApiDevPlugin = (): Plugin => ({ + name: "petrinaut-api-dev", + apply: "serve", + configureServer(server) { + server.middlewares.use("/api/chat", (request, response) => { + void (async () => { + try { + const apiModule = await server.ssrLoadModule("/api/chat.ts"); + if (!isDevApiModule(apiModule)) { + throw new Error( + "Expected /api/chat.ts to export a default handler.", + ); + } + + const url = new URL( + request.url ?? "", + `${server.config.server.https ? "https" : "http"}://${ + request.headers.host ?? "localhost" + }`, + ); + + await writeResponse( + await apiModule.default( + new Request(url, { + body: await readRequestBody(request), + headers: headersFromIncomingMessage(request.headers), + method: request.method, + }), + ), + response, + ); + } catch (error) { + server.ssrFixStacktrace(error as Error); + const nodeResponse = response; + nodeResponse.statusCode = 500; + nodeResponse.end( + error instanceof Error ? error.message : "API error", + ); + } + })(); + }); + }, +}); /** Petrinaut website dev server and production build config. */ -export default defineConfig(() => { +export default defineConfig(({ mode }) => { + loadServerEnv(mode); + const environment = process.env.VITE_VERCEL_ENV ?? "development"; const sentryDsn: string | undefined = process.env.SENTRY_DSN; @@ -19,6 +180,7 @@ export default defineConfig(() => { }, plugins: [ + petrinautApiDevPlugin(), react(), babel({ presets: [ @@ -30,7 +192,6 @@ export default defineConfig(() => { // "Cannot access refs during render". Opt that package out. sources: (filename: string) => !filename.includes("@hashintel/ds-components"), - // @ts-expect-error - panicThreshold is accepted at runtime panicThreshold: "critical_errors", }), ], diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 88b478ec7d7..956800d0683 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -1,106 +1,109 @@ { - "name": "@hashintel/petrinaut", - "version": "0.0.14", - "description": "A visual editor for Petri nets", - "license": "(MIT OR Apache-2.0)", - "repository": { - "type": "git", - "url": "git+https://github.com/hashintel/hash.git", - "directory": "libs/@hashintel/petrinaut" - }, - "style": "dist/main.css", - "files": [ - "dist", - "CHANGELOG.md", - "LICENSE", - "LICENSE-APACHE.md", - "LICENSE-MIT.md", - "README.md", - "package.json" - ], - "type": "module", - "sideEffects": [ - "*.css" - ], - "main": "dist/main.js", - "types": "dist/main.d.d.ts", - "exports": { - ".": { - "types": "./dist/main.d.d.ts", - "default": "./dist/main.js" - }, - "./react": { - "types": "./dist/react.d.d.ts", - "default": "./dist/react.js" - }, - "./ui": { - "types": "./dist/ui.d.d.ts", - "default": "./dist/ui.js" - }, - "./styles.css": "./dist/main.css", - "./dist/main.css": "./dist/main.css", - "./package.json": "./package.json" - }, - "scripts": { - "build": "vite build", - "dev": "storybook dev", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit", - "prepublishOnly": "turbo run build", - "test:unit": "vitest" - }, - "dependencies": { - "@ark-ui/react": "5.26.2", - "@babel/standalone": "7.28.5", - "@fontsource-variable/inter": "5.2.8", - "@fontsource-variable/inter-tight": "5.2.7", - "@fontsource-variable/jetbrains-mono": "5.2.8", - "@hashintel/ds-components": "workspace:^", - "@hashintel/ds-helpers": "workspace:^", - "@hashintel/petrinaut-core": "workspace:^", - "@hashintel/refractive": "workspace:^", - "@monaco-editor/react": "4.8.0-rc.3", - "@tanstack/react-form": "1.29.0", - "@xyflow/react": "12.10.1", - "elkjs": "0.11.0", - "fuzzysort": "3.1.0", - "lodash-es": "4.18.1", - "monaco-editor": "0.55.1", - "react-resizable-panels": "4.6.5", - "uplot": "1.6.32", - "use-sync-external-store": "1.6.0", - "uuid": "14.0.0" - }, - "devDependencies": { - "@hashintel/ds-helpers": "workspace:*", - "@pandacss/dev": "1.11.1", - "@rolldown/plugin-babel": "0.2.1", - "@storybook/react-vite": "10.2.19", - "@testing-library/dom": "10.4.1", - "@testing-library/react": "16.3.2", - "@types/babel__standalone": "7.1.9", - "@types/lodash-es": "4.17.12", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260511.1", - "@vitejs/plugin-react": "6.0.1", - "babel-plugin-react-compiler": "1.0.0", - "jsdom": "24.1.3", - "oxlint": "1.63.0", - "oxlint-tsgolint": "0.22.1", - "react": "19.2.6", - "react-dom": "19.2.6", - "rolldown": "1.0.0", - "rolldown-plugin-dts": "0.25.0", - "storybook": "10.3.6", - "vite": "8.0.12", - "vitest": "4.1.5" - }, - "peerDependencies": { - "@hashintel/ds-components": "workspace:^", - "@hashintel/ds-helpers": "workspace:^", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } + "name": "@hashintel/petrinaut", + "version": "0.0.14", + "description": "A visual editor for Petri nets", + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "git+https://github.com/hashintel/hash.git", + "directory": "libs/@hashintel/petrinaut" + }, + "style": "dist/main.css", + "files": [ + "dist", + "CHANGELOG.md", + "LICENSE", + "LICENSE-APACHE.md", + "LICENSE-MIT.md", + "README.md", + "package.json" + ], + "type": "module", + "sideEffects": [ + "*.css" + ], + "main": "dist/main.js", + "types": "dist/main.d.d.ts", + "exports": { + ".": { + "types": "./dist/main.d.d.ts", + "default": "./dist/main.js" + }, + "./react": { + "types": "./dist/react.d.d.ts", + "default": "./dist/react.js" + }, + "./ui": { + "types": "./dist/ui.d.d.ts", + "default": "./dist/ui.js" + }, + "./styles.css": "./dist/main.css", + "./dist/main.css": "./dist/main.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "vite build", + "dev": "storybook dev", + "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", + "prepublishOnly": "turbo run build", + "test:unit": "vitest" + }, + "dependencies": { + "@ai-sdk/react": "3.0.184", + "@ark-ui/react": "5.26.2", + "@babel/standalone": "7.28.5", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/inter-tight": "5.2.7", + "@fontsource-variable/jetbrains-mono": "5.2.8", + "@hashintel/ds-components": "workspace:^", + "@hashintel/ds-helpers": "workspace:^", + "@hashintel/petrinaut-core": "workspace:^", + "@hashintel/refractive": "workspace:^", + "@monaco-editor/react": "4.8.0-rc.3", + "@tanstack/react-form": "1.29.0", + "@xyflow/react": "12.10.1", + "ai": "6.0.182", + "elkjs": "0.11.0", + "fuzzysort": "3.1.0", + "lodash-es": "4.18.1", + "monaco-editor": "0.55.1", + "react-markdown": "10.1.0", + "react-resizable-panels": "4.6.5", + "uplot": "1.6.32", + "use-sync-external-store": "1.6.0", + "uuid": "14.0.0" + }, + "devDependencies": { + "@hashintel/ds-helpers": "workspace:*", + "@pandacss/dev": "1.11.1", + "@rolldown/plugin-babel": "0.2.1", + "@storybook/react-vite": "10.2.19", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@types/babel__standalone": "7.1.9", + "@types/lodash-es": "4.17.12", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "@vitejs/plugin-react": "6.0.1", + "babel-plugin-react-compiler": "1.0.0", + "jsdom": "24.1.3", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "react": "19.2.6", + "react-dom": "19.2.6", + "rolldown": "1.0.0", + "rolldown-plugin-dts": "0.25.0", + "storybook": "10.3.6", + "vite": "8.0.12", + "vitest": "4.1.5" + }, + "peerDependencies": { + "@hashintel/ds-components": "workspace:^", + "@hashintel/ds-helpers": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } } diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx index a81a1fad9cf..8cd784c2e8d 100644 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx @@ -107,7 +107,10 @@ const DEFAULT_EDITOR: EditorContextValue = { setTimelineChartType: () => {}, setTimelineView: () => {}, setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, searchInputRef: { current: null }, triggerPanelAnimation: () => {}, __reinitialize: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 42596a818b7..e475cc16bd5 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -25,6 +25,15 @@ export type TimelineChartType = "run" | "stacked"; export type SimulateViewMode = "scenarios" | "metrics" | "experiments"; +export type SimulateDrawerState = + | { type: "closed" } + | { type: "view-scenario"; scenarioId: string } + | { type: "create-scenario" } + | { type: "view-metric"; metricId: string } + | { type: "create-metric" } + | { type: "view-experiment"; experimentId: string } + | { type: "create-experiment" }; + /** * What is rendered on the simulation timeline chart. * @@ -74,8 +83,10 @@ export type EditorState = { * button in the timeline header) can switch it. */ simulateViewMode: SimulateViewMode; + simulateDrawer: SimulateDrawerState; isPanelAnimating: boolean; isSearchOpen: boolean; + isAiAssistantOpen: boolean; }; /** @@ -123,7 +134,10 @@ export type EditorActions = { setTimelineChartType: (chartType: TimelineChartType) => void; setTimelineView: (view: TimelineView) => void; setSimulateViewMode: (mode: SimulateViewMode) => void; + setSimulateDrawer: (drawer: SimulateDrawerState) => void; setSearchOpen: (isOpen: boolean) => void; + setAiAssistantOpen: (isOpen: boolean) => void; + toggleAiAssistant: () => void; triggerPanelAnimation: () => void; __reinitialize: () => void; }; @@ -152,8 +166,10 @@ export const initialEditorState: EditorState = { timelineChartType: "run", timelineView: { kind: "per-place" }, simulateViewMode: "scenarios", + simulateDrawer: { type: "closed" }, isPanelAnimating: false, isSearchOpen: false, + isAiAssistantOpen: false, }; const DEFAULT_CONTEXT_VALUE: EditorContextValue = { @@ -188,7 +204,10 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { setTimelineChartType: () => {}, setTimelineView: () => {}, setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, searchInputRef: createRef(), triggerPanelAnimation: () => {}, __reinitialize: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index cbf16e5daed..896d8b70659 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -218,6 +218,8 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, timelineView: view })), setSimulateViewMode: (mode) => setState((prev) => ({ ...prev, simulateViewMode: mode })), + setSimulateDrawer: (drawer) => + setState((prev) => ({ ...prev, simulateDrawer: drawer })), setSearchOpen: (isOpen) => { scheduleAnimationEnd(); setState((prev) => { @@ -234,6 +236,13 @@ export const EditorProvider: React.FC = ({ children }) => { }; }); }, + setAiAssistantOpen: (isOpen) => + setState((prev) => ({ ...prev, isAiAssistantOpen: isOpen })), + toggleAiAssistant: () => + setState((prev) => ({ + ...prev, + isAiAssistantOpen: !prev.isAiAssistantOpen, + })), triggerPanelAnimation: () => { scheduleAnimationEnd(); setState((prev) => ({ ...prev, ...animationPatch() })); diff --git a/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx b/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx new file mode 100644 index 00000000000..bf16420cb48 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx @@ -0,0 +1,53 @@ +import type { SVGProps } from "react"; + +type AiAssistantIconProps = SVGProps & { + size?: number | string; + title?: string; +}; + +export const AiAssistantIcon = ({ + size = 15, + title, + ...props +}: AiAssistantIconProps) => ( + + {title && {title}} + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index 2855e2909e7..b0c349c8b45 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -5,7 +5,13 @@ // `` (`/react`). export { Petrinaut } from "./petrinaut"; -export type { PetrinautProps } from "./petrinaut"; +export type { PetrinautAiMessage } from "./views/Editor/panels/AiAssistant/types"; +export type { + PetrinautAiAssistant, + PetrinautAiChatTransport, + PetrinautProps, +} from "./petrinaut"; +export { DefaultChatTransport } from "ai"; // SDCPN value-equality check exposed for consumers that need to detect // no-op changes outside the handle (e.g. memoising Storybook stories). diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx index ba9f237c59e..db0132de671 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx @@ -1,27 +1,27 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import { - createJsonDocHandle, - type PetrinautDocHandle, - type MinimalNetMetadata, - type SDCPN, + createJsonDocHandle, + type PetrinautDocHandle, + type MinimalNetMetadata, + type SDCPN, } from "@hashintel/petrinaut-core"; -import { Petrinaut } from "./petrinaut"; +import { Petrinaut, type PetrinautAiAssistant } from "./petrinaut"; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], }; type StoredNet = { - id: string; - title: string; - /** Latest snapshot — kept in sync with handle.doc() via subscribe. */ - sdcpn: SDCPN; + id: string; + title: string; + /** Latest snapshot — kept in sync with handle.doc() via subscribe. */ + sdcpn: SDCPN; }; type HandlesByNetId = Record; @@ -34,121 +34,124 @@ type HandlesByNetId = Record; * history survives switching between nets. */ export const PetrinautStoryProvider = ({ - initialTitle = "New Process", - initialDefinition = emptySDCPN, - hideNetManagementControls = false, - readonly = false, - children, + aiAssistant, + initialTitle = "New Process", + initialDefinition = emptySDCPN, + hideNetManagementControls = false, + readonly = false, + children, }: { - initialTitle?: string; - initialDefinition?: SDCPN; - hideNetManagementControls?: boolean; - readonly?: boolean; - children?: ReactNode; + aiAssistant?: PetrinautAiAssistant; + initialTitle?: string; + initialDefinition?: SDCPN; + hideNetManagementControls?: boolean; + readonly?: boolean; + children?: ReactNode; }) => { - const [nets, setNets] = useState>(() => { - const id = "net-1"; - return { - [id]: { id, title: initialTitle, sdcpn: initialDefinition }, - }; - }); - const [currentNetId, setCurrentNetId] = useState("net-1"); - const [handlesByNetId, setHandlesByNetId] = useState(() => ({ - "net-1": createJsonDocHandle({ id: "net-1", initial: initialDefinition }), - })); - - // Track which handles have an active subscription so adding a new net only - // wires up the new handle instead of tearing down every existing one. - const unsubscribersRef = useRef void>>(new Map()); - - useEffect(() => { - for (const handle of Object.values(handlesByNetId)) { - if (unsubscribersRef.current.has(handle.id)) { - continue; - } - const off = handle.subscribe((event) => { - setNets((prev) => { - const stored = prev[handle.id]; - if (!stored) { - return prev; - } - return { ...prev, [handle.id]: { ...stored, sdcpn: event.next } }; - }); - }); - unsubscribersRef.current.set(handle.id, off); - } - }, [handlesByNetId]); - - useEffect( - () => () => { - for (const off of unsubscribersRef.current.values()) { - off(); - } - unsubscribersRef.current.clear(); - }, - [], - ); - - const existingNets: MinimalNetMetadata[] = Object.values(nets).map((net) => ({ - netId: net.id, - title: net.title, - lastUpdated: new Date().toISOString(), - })); - - const createNewNet = (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const id = `net-${Date.now()}`; - const handle = createJsonDocHandle({ - id, - initial: params.petriNetDefinition, - }); - setHandlesByNetId((prev) => ({ - ...prev, - [id]: handle, - })); - setNets((prev) => ({ - ...prev, - [id]: { id, title: params.title, sdcpn: params.petriNetDefinition }, - })); - setCurrentNetId(id); - }; - - const loadPetriNet = (petriNetId: string) => { - setCurrentNetId(petriNetId); - }; - - const setTitle = (title: string) => { - setNets((prev) => { - const net = prev[currentNetId]; - if (!net) { - return prev; - } - return { ...prev, [currentNetId]: { ...net, title } }; - }); - }; - - const currentNet = nets[currentNetId]!; - const handle = handlesByNetId[currentNetId]; - - if (!handle) { - return null; - } - - return ( - <> - - {children} - - ); + const [nets, setNets] = useState>(() => { + const id = "net-1"; + return { + [id]: { id, title: initialTitle, sdcpn: initialDefinition }, + }; + }); + const [currentNetId, setCurrentNetId] = useState("net-1"); + const [handlesByNetId, setHandlesByNetId] = useState(() => ({ + "net-1": createJsonDocHandle({ id: "net-1", initial: initialDefinition }), + })); + + // Track which handles have an active subscription so adding a new net only + // wires up the new handle instead of tearing down every existing one. + const unsubscribersRef = useRef void>>(new Map()); + + useEffect(() => { + for (const handle of Object.values(handlesByNetId)) { + if (unsubscribersRef.current.has(handle.id)) { + continue; + } + const off = handle.subscribe((event) => { + setNets((prev) => { + const stored = prev[handle.id]; + if (!stored) { + return prev; + } + return { ...prev, [handle.id]: { ...stored, sdcpn: event.next } }; + }); + }); + unsubscribersRef.current.set(handle.id, off); + } + }, [handlesByNetId]); + + useEffect( + () => () => { + for (const off of unsubscribersRef.current.values()) { + off(); + } + unsubscribersRef.current.clear(); + }, + [], + ); + + const existingNets: MinimalNetMetadata[] = Object.values(nets).map((net) => ({ + netId: net.id, + title: net.title, + lastUpdated: new Date().toISOString(), + })); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const id = `net-${Date.now()}`; + const handle = createJsonDocHandle({ + id, + initial: params.petriNetDefinition, + }); + setHandlesByNetId((prev) => ({ + ...prev, + [id]: handle, + })); + setNets((prev) => ({ + ...prev, + [id]: { id, title: params.title, sdcpn: params.petriNetDefinition }, + })); + setCurrentNetId(id); + }; + + const loadPetriNet = (petriNetId: string) => { + setCurrentNetId(petriNetId); + }; + + const setTitle = (title: string) => { + setNets((prev) => { + const net = prev[currentNetId]; + if (!net) { + return prev; + } + return { ...prev, [currentNetId]: { ...net, title } }; + }); + }; + + const currentNet = nets[currentNetId]!; + const handle = handlesByNetId[currentNetId]; + + if (!handle) { + return null; + } + + return ( + <> + + {children} + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index 6c11d7d86e4..6c572d52f3b 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -1,6 +1,18 @@ import { sirModel } from "@hashintel/petrinaut-core/examples"; import { PetrinautStoryProvider } from "./petrinaut-story-provider"; +import { Petrinaut } from "../ui/petrinaut"; +import { createStorybookAiTransport } from "./views/Editor/panels/AiAssistant/storybook-ai-transport"; +import { createJsonDocHandle, type SDCPN } from "../main"; +import { useMemo, useState, useEffect } from "react"; + +const emptySDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -40,3 +52,101 @@ export const HiddenNetManagement: Story = { ), }; + +export const WithAiAssistant: Story = { + render: () => ( +
+ +
+ ), +}; + +const HandleSpikeRender = ({ + aiAssistant, + initial, + initialTitle, +}: { + aiAssistant?: { + transport: ReturnType; + }; + initial: SDCPN; + initialTitle: string; +}) => { + const handle = useMemo( + () => createJsonDocHandle({ id: "spike-net", initial }), + [initial], + ); + + const [patchLog, setPatchLog] = useState([]); + const [title, setTitle] = useState(initialTitle); + + useEffect(() => { + return handle.subscribe((event) => { + const summary = (event.patches ?? []).map( + (p) => `${p.op} /${p.path.join("/")}`, + ); + setPatchLog((prev) => [...summary, ...prev].slice(0, 12)); + }); + }, [handle]); + + return ( +
+ +
+        {`Last ${patchLog.length} patches (newest first):\n` +
+          (patchLog.length === 0 ? "(no mutations yet)" : patchLog.join("\n"))}
+      
+
+ ); +}; + +export const HandleSpike: Story = { + render: () => ( + + ), +}; + +export const HandleSpikeWithSir: Story = { + render: () => ( + + ), +}; + +export const HandleSpikeWithAi: Story = { + render: () => ( + + ), +}; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 7804ab40422..5116b19a0ed 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -18,6 +18,19 @@ import { import { PetrinautProvider } from "../react/petrinaut-provider"; import { MonacoProvider } from "./monaco/provider"; import { EditorView } from "./views/Editor/editor-view"; +import type { + PetrinautAiMessage, + PetrinautAiTransport, +} from "./views/Editor/panels/AiAssistant/types"; + +export type PetrinautAiChatTransport = PetrinautAiTransport; + +export type PetrinautAiAssistant = { + messages?: PetrinautAiMessage[]; + onClearMessages?: () => void; + onMessages?: (messages: PetrinautAiMessage[]) => void; + transport: PetrinautAiTransport; +}; import type { NetManagement } from "../react/net-management-context"; import type { ViewportAction } from "./types/viewport-action"; @@ -31,6 +44,7 @@ export type PetrinautProps = { existingNets?: MinimalNetMetadata[]; createNewNet?: (params: { petriNetDefinition: SDCPN; title: string }) => void; loadPetriNet?: (petriNetId: string) => void; + aiAssistant?: PetrinautAiAssistant; viewportActions?: ViewportAction[]; /** * Optional simulation-worker factory. Provide this when the host bundler @@ -74,6 +88,7 @@ export const Petrinaut: FunctionComponent = ({ existingNets = [], createNewNet = noop, loadPetriNet = noop, + aiAssistant, viewportActions, simulationWorkerFactory, monteCarloWorkerFactory, @@ -104,6 +119,7 @@ export const Petrinaut: FunctionComponent = ({ > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index a0fb19c34c3..b59bebe814b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -10,6 +10,7 @@ import { EditorContext, type EditorState, } from "../../../../../react/state/editor-context"; +import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; import { DiagnosticsIndicator } from "./diagnostics-indicator"; import { SimulationControls } from "./simulation-controls"; import { ToolbarButton } from "./toolbar-button"; @@ -65,11 +66,13 @@ interface BottomBarProps { onEditionModeChange: (mode: EditorEditionMode) => void; cursorMode: CursorMode; onCursorModeChange: (mode: CursorMode) => void; + hasAiAssistant: boolean; } export const BottomBar: React.FC = ({ mode, editionMode, + hasAiAssistant, onEditionModeChange, cursorMode, onCursorModeChange, @@ -79,7 +82,9 @@ export const BottomBar: React.FC = ({ setBottomPanelOpen, setActiveBottomPanelTab, bottomPanelHeight, + isAiAssistantOpen, isPanelAnimating, + toggleAiAssistant, } = use(EditorContext); const { totalDiagnosticsCount } = use(LanguageClientContext); @@ -134,6 +139,24 @@ export const BottomBar: React.FC = ({ cursorMode={cursorMode} onCursorModeChange={onCursorModeChange} /> + {hasAiAssistant && ( + <> + + + + + + )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 0ebd706c481..864df14da1c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -1,31 +1,35 @@ import { use, useRef, useState } from "react"; +import { TbSend } from "react-icons/tb"; import { css, cx } from "@hashintel/ds-helpers/css"; import { - deploymentPipelineSDCPN, - probabilisticSatellitesSDCPN, - productionMachines, - satellitesSDCPN, - sirModel, - supplyChainStochasticSDCPN, + deploymentPipelineSDCPN, + probabilisticSatellitesSDCPN, + productionMachines, + satellitesSDCPN, + sirModel, + supplyChainStochasticSDCPN, } from "@hashintel/petrinaut-core/examples"; import { ExperimentsContext } from "../../../react/experiments/context"; +import { Box } from "../../components/box"; +import { IconButton } from "../../components/icon-button"; +import { Input } from "../../components/input"; +import { Stack } from "../../components/stack"; +import { importSDCPN } from "../../file-io/import-sdcpn"; +import { exportSDCPN } from "../../file-io/export-sdcpn"; +import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { EditorContext } from "../../../react/state/editor-context"; import { MutationContext } from "../../../react/state/mutation-context"; import { PortalContainerContext } from "../../../react/state/portal-container-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; -import { Box } from "../../components/box"; -import { Stack } from "../../components/stack"; -import { exportSDCPN } from "../../file-io/export-sdcpn"; import { exportTikZ } from "../../file-io/export-tikz"; -import { importSDCPN } from "../../file-io/import-sdcpn"; -import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; +import { AiAssistantIcon } from "../../components/ai-assistant-icon"; import { - classicNodeDimensions, - compactNodeDimensions, + classicNodeDimensions, + compactNodeDimensions, } from "../SDCPN/node-dimensions"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { BottomBar } from "./components/BottomBar/bottom-bar"; @@ -35,389 +39,598 @@ import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; import { SimulateView } from "./panels/SimulateView/simulate-view"; +import { AiAssistantPanel } from "./panels/AiAssistant/panel"; import { runAutoLayout } from "./run-auto-layout"; import type { ViewportAction } from "../../types/viewport-action"; +import type { PetrinautAiAssistant } from "../../petrinaut"; +import type { SDCPN } from "@hashintel/petrinaut-core"; const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { - numeric: "auto", + numeric: "auto", }); const formatRelativeTime = (isoTimestamp: string): string => { - const diffMs = Date.now() - new Date(isoTimestamp).getTime(); - const diffSecs = Math.round(diffMs / 1_000); - const diffMins = Math.round(diffMs / 60_000); - const diffHours = Math.round(diffMs / 3_600_000); - const diffDays = Math.round(diffMs / 86_400_000); - - if (diffSecs < 60) { - return relativeTimeFormat.format(-diffSecs, "second"); - } else if (diffMins < 60) { - return relativeTimeFormat.format(-diffMins, "minute"); - } else if (diffHours < 24) { - return relativeTimeFormat.format(-diffHours, "hour"); - } else if (diffDays < 30) { - return relativeTimeFormat.format(-diffDays, "day"); - } - return new Intl.DateTimeFormat("en", { - month: "short", - day: "numeric", - }).format(new Date(isoTimestamp)); + const diffMs = Date.now() - new Date(isoTimestamp).getTime(); + const diffSecs = Math.round(diffMs / 1_000); + const diffMins = Math.round(diffMs / 60_000); + const diffHours = Math.round(diffMs / 3_600_000); + const diffDays = Math.round(diffMs / 86_400_000); + + if (diffSecs < 60) { + return relativeTimeFormat.format(-diffSecs, "second"); + } else if (diffMins < 60) { + return relativeTimeFormat.format(-diffMins, "minute"); + } else if (diffHours < 24) { + return relativeTimeFormat.format(-diffHours, "hour"); + } else if (diffDays < 30) { + return relativeTimeFormat.format(-diffDays, "day"); + } + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(new Date(isoTimestamp)); }; const rowContainerStyle = css({ - height: "full", - userSelect: "none", + height: "full", + userSelect: "none", }); const canvasContainerStyle = css({ - width: "full", - position: "relative", - flexGrow: 1, + width: "full", + position: "relative", + flexGrow: 1, }); const editorRootStyle = css({ - position: "relative", - height: "full", - overflow: "hidden", - backgroundColor: "neutral.s25", + position: "relative", + height: "full", + overflow: "hidden", + backgroundColor: "neutral.s25", }); const portalContainerStyle = css({ - position: "absolute", - top: "0", - left: "0", - width: "full", - height: "full", - zIndex: "99999", - pointerEvents: "none", + position: "absolute", + top: "0", + left: "0", + width: "full", + height: "full", + zIndex: "99999", + pointerEvents: "none", +}); + +const emptyAiHeroLayerStyle = css({ + position: "absolute", + inset: "0", + zIndex: 20, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "8", + pointerEvents: "none", +}); + +const emptyAiHeroStyle = css({ + pointerEvents: "auto", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "5", + width: "[min(560px, calc(100% - 48px))]", + padding: "[28px]", + borderRadius: "[24px]", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "blue.a30", + backgroundColor: "white.a95", + boxShadow: + "[0px 20px 60px rgba(15, 23, 42, 0.18), 0px 2px 8px rgba(15, 23, 42, 0.08), inset 0px 1px 0px rgba(255, 255, 255, 0.9)]", + textAlign: "center", + userSelect: "text", + backdropFilter: "[blur(14px)]", +}); + +const emptyAiHeroIconStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[56px]", + height: "[56px]", + borderRadius: "2xl", + backgroundColor: "blue.s20", + boxShadow: "[0px 0px 0px 8px rgba(42, 128, 200, 0.08)]", + color: "blue.s90", +}); + +const emptyAiHeroCopyStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + maxWidth: "[420px]", +}); + +const emptyAiHeroTitleStyle = css({ + margin: "0", + color: "neutral.s110", + fontFamily: "[Inter Tight, Inter, sans-serif]", + fontSize: "[24px]", + fontWeight: "semibold", + lineHeight: "[30px]", +}); + +const emptyAiHeroDescriptionStyle = css({ + margin: "0", + color: "neutral.s80", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[20px]", }); +const emptyAiHeroFormStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + padding: "1.5", + borderRadius: "[20px]", + backgroundColor: "neutral.s00", + boxShadow: + "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", +}); + +const emptyAiHeroInputStyle = css({ + flex: "[1]", + minWidth: "[0]", + height: "[48px]", + borderColor: "[transparent]", + backgroundColor: "[transparent]", + boxShadow: "[none]", + fontSize: "base", + _hover: { + borderColor: "[transparent]", + }, + _focus: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _active: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, +}); + +const isEmptySDCPN = (sdcpn: SDCPN) => + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; + +const EmptyAiHero = ({ + bottomClearance, + input, + onInputChange, + onSubmit, +}: { + bottomClearance: number; + input: string; + onInputChange: (value: string) => void; + onSubmit: (message: string) => void; +}) => { + const canSubmit = input.trim().length > 0; + + return ( +
+
{ + event.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput) { + return; + } + + onSubmit(trimmedInput); + }} + > +
+ +
+
+

+ Describe the process you want to create +

+
+
+ onInputChange(event.currentTarget.value)} + placeholder="e.g. Model an SIR outbreak with recovery" + aria-label="Describe the process you want to create" + size="lg" + /> + + + +
+
+
+ ); +}; + /** * EditorView is responsible for the overall editor UI layout and controls. * It relies on sdcpn-store and editor-store for state, and uses SDCPNView for visualization. */ export const EditorView = ({ - hideNetManagementControls, - viewportActions, + aiAssistant, + hideNetManagementControls, + viewportActions, }: { - hideNetManagementControls: boolean; - viewportActions?: ViewportAction[]; + aiAssistant?: PetrinautAiAssistant; + hideNetManagementControls: boolean; + viewportActions?: ViewportAction[]; }) => { - // Get data from sdcpn-store - const { - createNewNet, - existingNets, - loadPetriNet, - petriNetDefinition, - title, - setTitle, - } = use(SDCPNContext); - const { commitNodePositions } = use(MutationContext); - - // Get editor context - const { - globalMode: mode, - setGlobalMode, - editionMode, - setEditionMode, - cursorMode, - setCursorMode, - clearSelection, - setSimulateViewMode, - } = use(EditorContext); - const { setSelectedExperimentId } = use(ExperimentsContext); - - const { compactNodes } = use(UserSettingsContext); - const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; - - async function handleLayout() { - await runAutoLayout({ - sdcpn: petriNetDefinition, - dimensions: dims, - commitNodePositions, - }); - } - - const [importError, setImportError] = useState(null); - - // Clean up stale selections when items are deleted - useSelectionCleanup(); - - function handleCreateEmpty() { - createNewNet({ - title: "Untitled", - petriNetDefinition: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - }); - clearSelection(); - } - - function handleNew() { - handleCreateEmpty(); - } - - function handleExport() { - exportSDCPN({ petriNetDefinition, title }); - } - - function handleExportWithoutVisualInfo() { - exportSDCPN({ petriNetDefinition, title, removeVisualInfo: true }); - } - - function handleExportTikZ() { - exportTikZ({ petriNetDefinition, title }); - } - - function handleRunningExperimentClick(experimentId: string) { - setGlobalMode("simulate"); - setSimulateViewMode("experiments"); - setSelectedExperimentId(experimentId); - } - - async function handleImport() { - const result = await importSDCPN(); - if (!result) { - return; // User cancelled file picker - } - - if (!result.ok) { - setImportError(result.error); - return; - } - - const { sdcpn: loadedSDCPN, hadMissingPositions } = result; - let sdcpnToLoad = loadedSDCPN; - - // If any nodes were missing positions, run ELK layout BEFORE creating the net. - // We must do this before createNewNet because after createNewNet triggers a - // re-render, the mutatePetriNetDefinition closure would be stale. - if (hadMissingPositions) { - const positions = await calculateGraphLayout(sdcpnToLoad, dims); - - if (Object.keys(positions).length > 0) { - sdcpnToLoad = { - ...sdcpnToLoad, - places: sdcpnToLoad.places.map((place) => { - const position = positions[place.id]; - return position - ? { ...place, x: position.x, y: position.y } - : place; - }), - transitions: sdcpnToLoad.transitions.map((transition) => { - const position = positions[transition.id]; - return position - ? { ...transition, x: position.x, y: position.y } - : transition; - }), - }; - } - } - - createNewNet({ - title: loadedSDCPN.title, - petriNetDefinition: sdcpnToLoad, - }); - clearSelection(); - } - - const menuItems = [ - ...(!hideNetManagementControls - ? [ - { - id: "new", - label: "New", - onClick: handleNew, - }, - ] - : []), - ...(!hideNetManagementControls && Object.keys(existingNets).length > 0 - ? [ - { - id: "open", - label: "Open", - submenu: existingNets.map((net) => ({ - id: `open-${net.netId}`, - label: net.title, - suffix: formatRelativeTime(net.lastUpdated), - onClick: () => { - loadPetriNet(net.netId); - clearSelection(); - }, - })), - }, - ] - : []), - { - id: "export", - label: "Export", - submenu: [ - { - id: "export-json", - label: "JSON", - onClick: handleExport, - }, - { - id: "export-without-visuals", - label: "JSON without visual info", - onClick: handleExportWithoutVisualInfo, - }, - { - id: "export-tikz", - label: "TikZ", - onClick: handleExportTikZ, - }, - ], - }, - ...(!hideNetManagementControls - ? [ - { - id: "import", - label: "Import", - onClick: handleImport, - }, - ] - : []), - { - id: "layout", - label: "Layout", - onClick: handleLayout, - }, - ...(!hideNetManagementControls - ? [ - { - id: "load-example", - label: "Load example", - submenu: [ - { - id: "load-example-supply-chain-stochastic", - label: "Probabilistic Supply Chain", - onClick: () => { - createNewNet(supplyChainStochasticSDCPN); - clearSelection(); - }, - }, - { - id: "load-example-satellites", - label: "Satellites", - onClick: () => { - createNewNet(satellitesSDCPN); - clearSelection(); - }, - }, - { - id: "load-example-probabilistic-satellites", - label: "Probabilistic Satellites Launcher", - onClick: () => { - createNewNet(probabilisticSatellitesSDCPN); - clearSelection(); - }, - }, - { - id: "load-example-production-machines", - label: "Production Machines", - onClick: () => { - createNewNet(productionMachines); - clearSelection(); - }, - }, - { - id: "load-example-sir-model", - label: "SIR Model", - onClick: () => { - createNewNet(sirModel); - clearSelection(); - }, - }, - { - id: "load-example-deployment-pipeline", - label: "Deployment Pipeline", - onClick: () => { - createNewNet(deploymentPipelineSDCPN); - clearSelection(); - }, - }, - ], - }, - ] - : []), - { - id: "docs", - label: "Docs", - onClick: () => { - window.open( - "https://github.com/hashintel/hash/tree/main/libs/%40hashintel/petrinaut/docs", - "_blank", - "noopener,noreferrer", - ); - }, - }, - ]; - - const portalContainerRef = useRef(null); - - return ( - - -
- - { - if (!open) { - setImportError(null); - } - }} - errorMessage={importError ?? ""} - onCreateEmpty={handleCreateEmpty} - /> - - {/* Top Bar - always visible */} - - handleRunningExperimentClick(experiment.id) - } - /> - - - {mode === "simulate" ? ( - - ) : ( - - {/* Left Sidebar - Tools and content panels */} - - - {/* Properties Panel - Right Side */} - - - {/* SDCPN Visualization */} - - - {/* Bottom Panel - Diagnostics, Simulation Settings */} - - - - - )} - - - - ); + // Get data from sdcpn-store + const { + createNewNet, + existingNets, + loadPetriNet, + petriNetDefinition, + title, + setTitle, + } = use(SDCPNContext); + const { commitNodePositions } = use(MutationContext); + + // Get editor context + const { + globalMode: mode, + isAiAssistantOpen, + setGlobalMode, + editionMode, + setEditionMode, + cursorMode, + setCursorMode, + clearSelection, + setSimulateViewMode, + setAiAssistantOpen, + isBottomPanelOpen, + bottomPanelHeight, + } = use(EditorContext); + const { setSelectedExperimentId } = use(ExperimentsContext); + + const { compactNodes } = use(UserSettingsContext); + const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; + const [emptyAiPromptInput, setEmptyAiPromptInput] = useState(""); + const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< + string | null + >(null); + + async function handleLayout() { + await runAutoLayout({ + sdcpn: petriNetDefinition, + dimensions: dims, + commitNodePositions, + }); + } + + const [importError, setImportError] = useState(null); + + // Clean up stale selections when items are deleted + useSelectionCleanup(); + + function handleCreateEmpty() { + createNewNet({ + title: "Untitled", + petriNetDefinition: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + clearSelection(); + } + + function handleNew() { + handleCreateEmpty(); + } + + function handleExport() { + exportSDCPN({ petriNetDefinition, title }); + } + + function handleExportWithoutVisualInfo() { + exportSDCPN({ petriNetDefinition, title, removeVisualInfo: true }); + } + + function handleExportTikZ() { + exportTikZ({ petriNetDefinition, title }); + } + + function handleRunningExperimentClick(experimentId: string) { + setGlobalMode("simulate"); + setSimulateViewMode("experiments"); + setSelectedExperimentId(experimentId); + } + + async function handleImport() { + const result = await importSDCPN(); + if (!result) { + return; // User cancelled file picker + } + + if (!result.ok) { + setImportError(result.error); + return; + } + + const { sdcpn: loadedSDCPN, hadMissingPositions } = result; + let sdcpnToLoad = loadedSDCPN; + + // If any nodes were missing positions, run ELK layout BEFORE creating the net. + // We must do this before createNewNet because after createNewNet triggers a + // re-render, the mutatePetriNetDefinition closure would be stale. + if (hadMissingPositions) { + const positions = await calculateGraphLayout(sdcpnToLoad, dims); + + if (Object.keys(positions).length > 0) { + sdcpnToLoad = { + ...sdcpnToLoad, + places: sdcpnToLoad.places.map((place) => { + const position = positions[place.id]; + return position + ? { ...place, x: position.x, y: position.y } + : place; + }), + transitions: sdcpnToLoad.transitions.map((transition) => { + const position = positions[transition.id]; + return position + ? { ...transition, x: position.x, y: position.y } + : transition; + }), + }; + } + } + + createNewNet({ + title: loadedSDCPN.title, + petriNetDefinition: sdcpnToLoad, + }); + clearSelection(); + } + + const menuItems = [ + ...(!hideNetManagementControls + ? [ + { + id: "new", + label: "New", + onClick: handleNew, + }, + ] + : []), + ...(!hideNetManagementControls && Object.keys(existingNets).length > 0 + ? [ + { + id: "open", + label: "Open", + submenu: existingNets.map((net) => ({ + id: `open-${net.netId}`, + label: net.title, + suffix: formatRelativeTime(net.lastUpdated), + onClick: () => { + loadPetriNet(net.netId); + clearSelection(); + }, + })), + }, + ] + : []), + { + id: "export", + label: "Export", + submenu: [ + { + id: "export-json", + label: "JSON", + onClick: handleExport, + }, + { + id: "export-without-visuals", + label: "JSON without visual info", + onClick: handleExportWithoutVisualInfo, + }, + { + id: "export-tikz", + label: "TikZ", + onClick: handleExportTikZ, + }, + ], + }, + ...(!hideNetManagementControls + ? [ + { + id: "import", + label: "Import", + onClick: handleImport, + }, + ] + : []), + { + id: "layout", + label: "Layout", + onClick: handleLayout, + }, + ...(!hideNetManagementControls + ? [ + { + id: "load-example", + label: "Load example", + submenu: [ + { + id: "load-example-supply-chain-stochastic", + label: "Probabilistic Supply Chain", + onClick: () => { + createNewNet(supplyChainStochasticSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-satellites", + label: "Satellites", + onClick: () => { + createNewNet(satellitesSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-probabilistic-satellites", + label: "Probabilistic Satellites Launcher", + onClick: () => { + createNewNet(probabilisticSatellitesSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-production-machines", + label: "Production Machines", + onClick: () => { + createNewNet(productionMachines); + clearSelection(); + }, + }, + { + id: "load-example-sir-model", + label: "SIR Model", + onClick: () => { + createNewNet(sirModel); + clearSelection(); + }, + }, + { + id: "load-example-deployment-pipeline", + label: "Deployment Pipeline", + onClick: () => { + createNewNet(deploymentPipelineSDCPN); + clearSelection(); + }, + }, + ], + }, + ] + : []), + { + id: "docs", + label: "Docs", + onClick: () => { + window.open( + "https://github.com/hashintel/hash/tree/main/libs/%40hashintel/petrinaut/docs", + "_blank", + "noopener,noreferrer", + ); + }, + }, + ]; + + const portalContainerRef = useRef(null); + const showEmptyAiHero = + aiAssistant !== undefined && + !isAiAssistantOpen && + isEmptySDCPN(petriNetDefinition); + + return ( + + +
+ + { + if (!open) { + setImportError(null); + } + }} + errorMessage={importError ?? ""} + onCreateEmpty={handleCreateEmpty} + /> + + {/* Top Bar - always visible */} + + handleRunningExperimentClick(experiment.id) + } + /> + + + {mode === "simulate" ? ( + + ) : ( + + {/* Left Sidebar - Tools and content panels */} + + + {/* Properties Panel - Right Side */} + + + {/* SDCPN Visualization */} + + + {showEmptyAiHero && ( + { + setEmptyAiPromptInput(""); + setPendingAiAssistantMessage(message); + setAiAssistantOpen(true); + }} + /> + )} + + {/* Bottom Panel - Diagnostics, Simulation Settings */} + + + + + {aiAssistant && ( + + setPendingAiAssistantMessage(null) + } + /> + )} + + )} + + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx new file mode 100644 index 00000000000..c67a4610397 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx @@ -0,0 +1,307 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; + +import { AiAssistantSurface } from "./assistant-surface"; +import type { PetrinautAiMessage } from "./types"; + +const meta = { + title: "Editor / AI Assistant", + parameters: { + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const userMessage: PetrinautAiMessage = { + id: "user-1", + role: "user", + parts: [ + { + type: "text", + text: "Create a pharmaceutical supply chain Petri net.", + }, + ], +}; + +const followUpUserMessage: PetrinautAiMessage = { + id: "user-2", + role: "user", + parts: [ + { + type: "text", + text: "Turn this into an SIR model petri net please.", + }, + ], +}; + +const assistantMarkdownMessage: PetrinautAiMessage = { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "text", + state: "done", + text: "I created a **supply intake** structure with:\n\n- stochastic supply places\n- a delivery transition\n- a manufacturing buffer", + }, + ], +}; + +const reasoningMessage: PetrinautAiMessage = { + id: "assistant-reasoning", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "done", + text: "Identify diagram type: Petri net\n\n- Extract required places and transitions\n- Keep IDs stable\n- Add positions for immediate visual feedback", + }, + { + type: "text", + state: "done", + text: "I understand the requested model and will update the net directly.", + }, + ], +}; + +const streamingReasoningMessage: PetrinautAiMessage = { + id: "assistant-streaming-reasoning", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "streaming", + text: "I need to identify the SIR compartments and map movement between susceptible, infected, and recovered places.", + }, + ], +}; + +const singleToolCallMessage: PetrinautAiMessage = { + id: "assistant-single-tool", + role: "assistant", + parts: [ + { + type: "tool-updatePlacePosition", + state: "output-available", + toolCallId: "tool-position", + input: { + placeId: "place__plant_supply", + position: { x: 80, y: 40 }, + }, + output: { + applied: true, + title: "Moved place Plant Supply", + target: { + kind: "selection", + item: { type: "place", id: "place__plant_supply" }, + }, + }, + }, + ], +}; + +const toolCallMessage: PetrinautAiMessage = { + id: "assistant-tools", + role: "assistant", + parts: [ + { + type: "tool-addPlace", + state: "output-available", + toolCallId: "tool-1", + input: { + id: "place__plant_supply", + name: "Plant Supply", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + showAsInitialState: true, + x: 0, + y: 0, + }, + output: { + applied: true, + title: "Added place Plant Supply", + target: { + kind: "selection", + item: { type: "place", id: "place__plant_supply" }, + }, + }, + }, + { + type: "tool-addTransition", + state: "output-available", + toolCallId: "tool-2", + input: { + id: "transition__delivery", + name: "Delivery", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export const Lambda = () => true;", + transitionKernelCode: "export const TransitionKernel = () => ({});", + x: 160, + y: 0, + }, + output: { + applied: true, + title: "Added transition Delivery", + target: { + kind: "selection", + item: { type: "transition", id: "transition__delivery" }, + }, + }, + }, + ], +}; + +const renamedToolCallMessage: PetrinautAiMessage = { + id: "assistant-renamed-tool", + role: "assistant", + parts: [ + { + type: "tool-updatePlace", + state: "output-available", + toolCallId: "tool-rename", + input: { + placeId: "place__plant_supply", + update: { + name: "Warehouse Supply", + }, + }, + output: { + applied: true, + title: "Updated place Warehouse Supply", + detail: "Previous name: Plant Supply", + target: { + kind: "selection", + item: { type: "place", id: "place__plant_supply" }, + }, + }, + }, + ], +}; + +const errorMessage = new Error( + "The assistant could not reach the AI endpoint.", +); + +const Frame = ({ + error, + messages, + status = "ready", +}: { + error?: Error; + messages: PetrinautAiMessage[]; + status?: "submitted" | "streaming" | "ready" | "error"; +}) => { + const [input, setInput] = useState(""); + + return ( +
+ {}} + onInputChange={setInput} + onStop={() => {}} + onSubmit={() => setInput("")} + status={status} + /> +
+ ); +}; + +export const Empty: Story = { + render: () => , +}; + +export const StreamingMarkdown: Story = { + render: () => ( + + part.type === "text" ? { ...part, state: "streaming" } : part, + ), + }, + ]} + status="streaming" + /> + ), +}; + +export const ReasoningCollapsed: Story = { + render: () => , +}; + +export const StreamingReasoning: Story = { + render: () => ( + + ), +}; + +export const SingleCompletedToolCall: Story = { + render: () => , +}; + +export const CompletedToolCalls: Story = { + render: () => , +}; + +export const RenameDetail: Story = { + render: () => , +}; + +export const MixedConversation: Story = { + render: () => ( + + ), +}; + +export const ToolError: Story = { + render: () => ( + + part.type.startsWith("tool-") + ? { + ...part, + state: "output-error", + errorText: "Validation failed", + } + : part, + ) as PetrinautAiMessage["parts"], + }, + ]} + status="error" + /> + ), +}; + +export const NetworkError: Story = { + render: () => , +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx new file mode 100644 index 00000000000..8e768c2452b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx @@ -0,0 +1,593 @@ +/** + * @vitest-environment jsdom + */ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import type { SDCPN } from "../../../../../core/types/sdcpn"; +import { AiAssistantSurface } from "./assistant-surface"; +import type { PetrinautAiMessage } from "./types"; + +const noop = () => {}; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("AiAssistantSurface", () => { + test("renders the empty assistant state", () => { + render( + , + ); + + expect( + screen.getByText(/Ask Petrinaut AI to create a Petri net/u), + ).not.toBeNull(); + }); + + test("renders streamed markdown and collapsed reasoning", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "done", + text: "Understanding the requested model.", + }, + { + type: "text", + state: "done", + text: "**Created** a supply chain model.", + }, + ], + }, + ]; + + render( + , + ); + + expect(screen.getByText("Created")).not.toBeNull(); + expect( + screen + .getByRole("button", { name: /Reasoning/u }) + .getAttribute("aria-expanded"), + ).toBe("false"); + expect(screen.queryByTestId("reasoning-status")).toBeNull(); + expect(screen.getByLabelText(/Reasoning time/u)).not.toBeNull(); + }); + + test("calls the clear handler from the header", () => { + const onClearMessages = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Clear AI chat" })); + + expect(onClearMessages).toHaveBeenCalledOnce(); + }); + + test("scrolls to the latest chat content", async () => { + const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + const scrollIntoView = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = scrollIntoView; + window.requestAnimationFrame = (callback) => { + callback(0); + return 0; + }; + window.cancelAnimationFrame = () => {}; + + render( + , + ); + + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + + expect(scrollIntoView).toHaveBeenCalled(); + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + test("renders a spinner for empty streaming reasoning", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "streaming", + text: "", + }, + ], + }, + ]; + + render( + , + ); + + expect(screen.getByTestId("reasoning-spinner")).not.toBeNull(); + expect(screen.queryByText("Thinking...")).toBeNull(); + }); + + test("hides completed reasoning when no text was received", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "done", + text: "", + }, + ], + }, + ]; + + render( + , + ); + + expect(screen.queryByRole("button", { name: /Reasoning/u })).toBeNull(); + }); + + test("renders assistant parts in message order", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "done", + text: "Checking the current net.", + }, + { + type: "text", + state: "done", + text: "I found the current places.", + }, + ], + }, + ]; + + const { container } = render( + , + ); + const renderedText = container.textContent ?? ""; + + expect(renderedText.indexOf("Reasoning")).toBeLessThan( + renderedText.indexOf("I found the current places."), + ); + }); + + test("right-aligns user text and renders active reasoning time", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-14T12:00:00Z")); + + const messages: PetrinautAiMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Add a place please" }], + }, + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + state: "streaming", + text: "Choosing the smallest valid place update.", + }, + ], + }, + ]; + + render( + , + ); + + act(() => { + vi.advanceTimersByTime(2_000); + }); + + expect( + screen + .getByText("Add a place please") + .closest("[data-role]") + ?.getAttribute("data-role"), + ).toBe("user"); + expect(screen.getByLabelText("Reasoning time 2s")).not.toBeNull(); + + vi.useRealTimers(); + }); + + test("selects a target from a completed tool summary without a single-item chevron", () => { + const onSelectToolTarget = vi.fn(); + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-addPlace", + state: "output-available", + toolCallId: "tool-1", + input: { + id: "place__buffer", + name: "Buffer", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + output: { + applied: true, + title: "Added place Buffer", + detail: "Previous name: Queue", + target: { + kind: "selection", + item: { type: "place", id: "place__buffer" }, + }, + }, + }, + ], + }, + ]; + + render( + , + ); + + const toolButton = screen.getByRole("button", { + name: /Added place Buffer/u, + }); + + fireEvent.click(toolButton); + + expect(screen.queryByTestId("tool-item-chevron")).toBeNull(); + expect(toolButton.getAttribute("data-tone")).toBe("success"); + expect(screen.getByTestId("tool-detail").textContent).toBe( + "Previous name: Queue", + ); + expect(onSelectToolTarget).toHaveBeenCalledWith({ + kind: "selection", + item: { type: "place", id: "place__buffer" }, + }); + }); + + test("renders grouped tool rows with Figma-style tones and no item chevrons", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-addPlace", + state: "output-available", + toolCallId: "tool-1", + input: { + id: "place__buffer", + name: "Buffer", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + output: { + applied: true, + title: "Added place Buffer", + }, + }, + { + type: "tool-deleteItemsByIds", + state: "output-available", + toolCallId: "tool-2", + input: { + items: [{ type: "place", id: "place__old" }], + }, + output: { + applied: true, + title: "Deleted 1 item", + }, + }, + ], + }, + ]; + + render( + , + ); + + expect(screen.getByRole("button", { name: /2 changes/u })).not.toBeNull(); + expect(screen.queryByTestId("tool-item-chevron")).toBeNull(); + expect( + screen + .getByRole("button", { name: /Added place Buffer/u }) + .getAttribute("data-tone"), + ).toBe("success"); + expect( + screen + .getByRole("button", { name: /Deleted 1 item/u }) + .getAttribute("data-tone"), + ).toBe("danger"); + }); + + test("keeps net definition checks separate from grouped changes", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-getLatestNetDefinition", + state: "output-available", + toolCallId: "tool-net", + input: {}, + output: { + title: "HyProGen 121 - Stochastic Petri Net", + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + } as SDCPN & { title: string }, + }, + { + type: "tool-addPlace", + state: "output-available", + toolCallId: "tool-1", + input: { + id: "place__buffer", + name: "Buffer", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + output: { + applied: true, + title: "Added place Buffer", + }, + }, + { + type: "tool-deleteItemsByIds", + state: "output-available", + toolCallId: "tool-2", + input: { + items: [{ type: "place", id: "place__old" }], + }, + output: { + applied: true, + title: "Deleted 1 item", + }, + }, + ], + }, + ]; + + render( + , + ); + + expect( + screen.getByRole("button", { name: /Checked latest net definition/u }), + ).not.toBeNull(); + expect( + screen.queryByRole("button", { + name: /HyProGen 121 - Stochastic Petri Net/u, + }), + ).toBeNull(); + expect(screen.getByRole("button", { name: /2 changes/u })).not.toBeNull(); + }); + + test("labels failed tool calls as errored", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-deleteItemsByIds", + state: "output-error", + toolCallId: "tool-1", + errorText: "Validation failed", + input: { + items: [{ type: "place", id: "place__old" }], + }, + }, + ], + }, + ]; + + render( + , + ); + + expect( + screen.getByRole("button", { name: /deleteItemsByIds errored/u }), + ).not.toBeNull(); + }); + + test("expands deleted item summaries", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-deleteItemsByIds", + state: "output-available", + toolCallId: "tool-1", + input: { + items: [ + { type: "place", id: "place__old" }, + { type: "transition", id: "transition__old" }, + { type: "parameter", id: "parameter__old" }, + ], + }, + output: { + applied: true, + title: "Deleted 3 items", + items: [ + "place: Old place", + "transition: Old transition", + "parameter: old_rate", + ], + }, + }, + ], + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Deleted 3 items/u })); + + expect(screen.getByText("place: Old place")).not.toBeNull(); + expect(screen.getByText("transition: Old transition")).not.toBeNull(); + expect(screen.getByText("parameter: old_rate")).not.toBeNull(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx new file mode 100644 index 00000000000..cb7a6cab057 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx @@ -0,0 +1,1270 @@ +import { Collapsible } from "@ark-ui/react/collapsible"; +import { css, cva, cx } from "@hashintel/ds-helpers/css"; +import { + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react"; +import ReactMarkdown from "react-markdown"; +import { + TbCheck, + TbChevronUp, + TbList, + TbLoader2, + TbPlayerStopFilled, + TbSend, + TbTrash, + TbX, +} from "react-icons/tb"; + +import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; +import { IconButton } from "../../../../components/icon-button"; +import { Input } from "../../../../components/input"; +import { + getLatestNetDefinitionToolName, + petrinautAiMutationTools, +} from "../../../../../core/ai"; +import type { SelectionItem } from "../../../../../core/types/selection"; +import { + type AiToolTarget, + type AiToolSummary, + summarizePetrinautAiToolCall, +} from "./tool-summaries"; +import type { PetrinautAiMessage } from "./types"; + +type AiAssistantStatus = "submitted" | "streaming" | "ready" | "error"; + +type ToolTone = "danger" | "info" | "neutral" | "success"; + +type ToolRenderItem = { + id: string; + state: string; + summary: AiToolSummary; + tone: ToolTone; + toolName: string; +}; + +type MessagePart = PetrinautAiMessage["parts"][number]; +type TextPart = Extract; +type ReasoningMessagePart = Extract; + +type RenderableToolPart = PetrinautAiMessage["parts"][number] & { + input?: unknown; + output?: unknown; + state?: string; + toolCallId?: string; + toolName?: unknown; + type: `tool-${string}` | "dynamic-tool"; +}; + +type MessageRenderItem = + | { type: "reasoning"; key: string; part: ReasoningMessagePart } + | { type: "text"; key: string; part: TextPart } + | { type: "tools"; key: string; tools: ToolRenderItem[] }; + +export type AiAssistantSurfaceProps = { + error?: Error; + input: string; + messages: PetrinautAiMessage[]; + onClearMessages?: () => void; + onClose: () => void; + onInputChange: (value: string) => void; + onSelectToolTarget?: (target: AiToolTarget) => void; + onStop: () => void; + onSubmit: () => void; + rightOffset?: number; + status: AiAssistantStatus; +}; + +const shellStyle = css({ + position: "absolute", + top: "0", + right: "0", + bottom: "0", + width: "[420px]", + maxWidth: "[calc(100vw - 32px)]", + zIndex: 1090, + padding: "2", + pointerEvents: "auto", + transition: "[right 150ms ease-in-out]", + _before: { + content: '""', + position: "absolute", + inset: "2", + borderRadius: "[14px]", + background: + "[radial-gradient(circle at 78% 28%, rgba(52,160,250,0.22), rgba(190,230,255,0.04) 54%, transparent 80%)]", + filter: "[blur(4px)]", + pointerEvents: "none", + }, +}); + +const resizeHandleStyle = css({ + position: "absolute", + top: "2", + bottom: "2", + left: "0", + width: "[10px]", + cursor: "ew-resize", + zIndex: 1, + touchAction: "none", + _before: { + content: '""', + position: "absolute", + top: "[12px]", + bottom: "[12px]", + left: "[4px]", + width: "[2px]", + borderRadius: "full", + backgroundColor: "[transparent]", + transition: "[background-color 120ms ease-out]", + }, + _hover: { + _before: { + backgroundColor: "neutral.a40", + }, + }, +}); + +const cardStyle = css({ + position: "relative", + display: "flex", + flexDirection: "column", + height: "full", + overflow: "hidden", + backgroundColor: "neutral.s10", + borderRadius: "[12px]", + boxShadow: + "[0px 0px 0px 1px rgba(0,0,0,0.06), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.02), 0px 4px 4px -12px rgba(0,0,0,0.02)]", +}); + +const headerStyle = css({ + display: "flex", + alignItems: "center", + gap: "[1px]", + paddingX: "1", + paddingTop: "[6px]", + borderBottom: "[1px solid rgba(0,0,0,0.08)]", + flexShrink: 0, +}); + +const tabStyle = cva({ + base: { + display: "flex", + alignItems: "center", + height: "[28px]", + maxWidth: "[112px]", + paddingX: "3", + borderTopLeftRadius: "lg", + borderTopRightRadius: "lg", + fontSize: "xs", + fontWeight: "medium", + lineHeight: "[12px]", + color: "neutral.s90", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, + variants: { + active: { + true: { + backgroundColor: "neutral.s00", + boxShadow: "[0px 0px 0px 1px rgba(0,0,0,0.08)]", + color: "neutral.s100", + }, + }, + }, +}); + +const headerButtonStyle = css({ + color: "neutral.s90", + _hover: { + color: "neutral.s110", + }, +}); + +const messagesStyle = css({ + display: "flex", + flexDirection: "column", + gap: "3", + flex: "[1]", + minHeight: "[0]", + overflowY: "auto", + padding: "2", +}); + +const emptyStyle = css({ + display: "flex", + flex: "[1]", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "2", + minHeight: "[240px]", + color: "neutral.s90", + textAlign: "center", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[20px]", + padding: "[20px]", +}); + +const messageStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + gap: "2", + borderRadius: "xl", + padding: "[10px]", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[1.5]", + color: "neutral.s100", + boxShadow: + "[0px 0px 0px 1px rgba(0,0,0,0.07), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 8px 8px -6px rgba(0,0,0,0.04)]", + }, + variants: { + role: { + assistant: { + alignSelf: "stretch", + backgroundColor: "white.a95", + }, + user: { + alignSelf: "flex-end", + maxWidth: "[92%]", + backgroundColor: "neutral.s20", + textAlign: "right", + }, + }, + activity: { + active: {}, + complete: {}, + }, + }, + compoundVariants: [ + { + role: "assistant", + activity: "active", + css: { + backgroundColor: "neutral.s00", + }, + }, + { + role: "assistant", + activity: "complete", + css: { + backgroundColor: "neutral.s10", + }, + }, + ], +}); + +const markdownStyle = css({ + "& p": { + margin: "[0]", + }, + "& p + p": { + marginTop: "2", + }, + "& ul, & ol": { + marginTop: "1", + marginBottom: "1", + paddingLeft: "5", + }, + "& code": { + fontFamily: "mono", + fontSize: "xs", + backgroundColor: "neutral.s20", + borderRadius: "sm", + paddingX: "1", + }, +}); + +const reasoningGroupStyle = css({ + display: "flex", + flexDirection: "column", + gap: "3", + borderRadius: "lg", + backgroundColor: "neutral.bg.subtle", + padding: "1", +}); + +const reasoningHeaderStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + height: "8", + paddingX: "2", + border: "none", + borderRadius: "lg", + backgroundColor: "[transparent]", + color: "neutral.s90", + cursor: "pointer", + fontSize: "sm", + fontWeight: "medium", + textAlign: "left", + _hover: { + backgroundColor: "white.a60", + }, + "& svg[data-chevron]": { + transition: "[transform 150ms ease-out]", + }, + "&[data-state=closed] svg[data-chevron]": { + transform: "[rotate(180deg)]", + }, +}); + +const reasoningBodyStyle = css({ + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "lg", + backgroundColor: "neutral.s10", + boxShadow: "[0px 0px 0px 2px {colors.neutral.bg.subtle}]", + padding: "2", + color: "neutral.s90", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[1.5]", +}); + +const collapsibleContentStyle = css({ + overflow: "hidden", + animationDuration: "[200ms]", + animationTimingFunction: "ease-in-out", + "&[data-state=open]": { + animationName: "expand", + }, + "&[data-state=closed]": { + animationName: "collapse", + }, +}); + +const spinnerStyle = css({ + animation: "[spin 900ms linear infinite]", + color: "neutral.s80", +}); + +const reasoningLoadingStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + minHeight: "6", + color: "neutral.s80", +}); + +const toolListStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + borderRadius: "lg", + }, + variants: { + kind: { + group: { + backgroundColor: "[#eff9ff]", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "[#bee6ff]", + }, + single: {}, + }, + }, +}); + +const toolGroupPanelStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[0]", + overflow: "hidden", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "[rgba(0,0,0,0.13)]", + borderRadius: "lg", + backgroundColor: "white", + "& > button": { + borderRadius: "[0]", + }, + "& > div > button": { + borderRadius: "[0]", + }, + "& > button:first-child": { + borderTopLeftRadius: "md", + borderTopRightRadius: "md", + }, + "& > div:first-child > button": { + borderTopLeftRadius: "md", + borderTopRightRadius: "md", + }, + "& > button:last-child": { + borderBottomLeftRadius: "md", + borderBottomRightRadius: "md", + }, + "& > div:last-child > button": { + borderBottomLeftRadius: "md", + borderBottomRightRadius: "md", + }, + "& > * + *": { + marginTop: "[-1px]", + }, +}); + +const toolItemCollapsibleStyle = css({ + "& > button": { + borderRadius: "[0]", + }, +}); + +const toolHeaderStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + height: "8", + paddingX: "2", + fontSize: "sm", + fontWeight: "medium", + color: "[#0666c6]", + "& svg[data-chevron]": { + transition: "[transform 150ms ease-out]", + }, + "&[data-state=closed] svg[data-chevron]": { + transform: "[rotate(180deg)]", + }, +}); + +const toolHeaderIconStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[14px]", + height: "[14px]", + borderRadius: "full", + backgroundColor: "[#2a80c8]", + color: "white", + boxShadow: "[0px 0px 0px 1px white]", + flexShrink: 0, +}); + +const toolItemStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + minHeight: "8", + paddingX: "2", + paddingY: "[5px]", + borderWidth: "thin", + borderStyle: "solid", + borderRadius: "lg", + color: "neutral.s90", + fontSize: "sm", + fontWeight: "medium", + textAlign: "left", + cursor: "default", + _enabled: { + cursor: "pointer", + }, + "& svg[data-chevron]": { + transition: "[transform 150ms ease-out]", + }, + "&[data-state=closed] svg[data-chevron]": { + transform: "[rotate(180deg)]", + }, + }, + variants: { + tone: { + danger: { + backgroundColor: "red.s20", + borderColor: "red.a40", + }, + info: { + backgroundColor: "[#eff9ff]", + borderColor: "[#bee6ff]", + color: "[#0666c6]", + }, + neutral: { + backgroundColor: "neutral.s10", + borderColor: "neutral.a30", + }, + success: { + backgroundColor: "green.s20", + borderColor: "green.a40", + }, + }, + }, +}); + +const toolStatusStyle = cva({ + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[14px]", + height: "[14px]", + borderRadius: "full", + flexShrink: 0, + boxShadow: "[0px 0px 0px 1px white]", + color: "white", + }, + variants: { + tone: { + danger: { + backgroundColor: "red.s90", + }, + info: { + backgroundColor: "[#2a80c8]", + }, + neutral: { + backgroundColor: "neutral.s90", + }, + success: { + backgroundColor: "green.s90", + }, + }, + state: { + active: { + backgroundColor: "white", + borderWidth: "thin", + borderStyle: "dashed", + borderColor: "blue.s70", + color: "blue.s70", + }, + complete: {}, + error: { + backgroundColor: "red.s90", + }, + }, + }, +}); + +const toolTextStyle = css({ + display: "flex", + flex: "[1]", + flexDirection: "column", + gap: "[2px]", +}); + +const toolDetailStyle = css({ + display: "block", + color: "neutral.s80", + fontSize: "xs", + lineHeight: "[16px]", +}); + +const toolSubItemListStyle = css({ + display: "flex", + flexDirection: "column", + gap: "1", + padding: "[4px 8px 8px 30px]", + color: "neutral.s80", + fontSize: "xs", + fontWeight: "medium", + lineHeight: "[16px]", +}); + +const toolSubItemStyle = css({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const errorStyle = css({ + borderRadius: "lg", + padding: "2", + backgroundColor: "red.bg.subtle", + color: "red.s100", + fontSize: "sm", + fontWeight: "medium", +}); + +const composerWrapStyle = css({ + padding: "2", + backgroundColor: "neutral.bg.subtle", + flexShrink: 0, +}); + +const composerStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + borderRadius: "lg", + backgroundColor: "neutral.s10", + boxShadow: + "[0px 0px 0px 1px rgba(0,0,0,0.06), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.02), 0px 4px 4px -12px rgba(0,0,0,0.02)]", + padding: "1", +}); + +const inputStyle = css({ + flex: "[1]", + minWidth: "[0]", + width: "auto", + borderColor: "[transparent]", + backgroundColor: "[transparent]", + boxShadow: "[none]", + _hover: { + borderColor: "[transparent]", + }, + _focus: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _active: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _placeholder: { + color: "neutral.s70", + }, +}); + +const isToolPart = ( + part: PetrinautAiMessage["parts"][number], +): part is RenderableToolPart => + part.type === "dynamic-tool" || part.type.startsWith("tool-"); + +const getToolName = (part: RenderableToolPart) => + part.type === "dynamic-tool" && typeof part.toolName === "string" + ? part.toolName + : part.type.replace(/^tool-/, ""); + +const getAiToolTarget = (value: unknown): AiToolTarget | undefined => { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const candidate = value as { + id?: unknown; + item?: unknown; + itemId?: unknown; + kind?: unknown; + mode?: unknown; + type?: unknown; + }; + + if (candidate.kind === "selection") { + return { kind: "selection", item: candidate.item as SelectionItem }; + } + + if ( + candidate.kind === "simulateView" && + (candidate.mode === "scenarios" || candidate.mode === "metrics") + ) { + return { + kind: "simulateView", + mode: candidate.mode, + itemId: + typeof candidate.itemId === "string" ? candidate.itemId : undefined, + }; + } + + if (typeof candidate.type === "string" && typeof candidate.id === "string") { + return { + kind: "selection", + item: { + type: candidate.type as SelectionItem["type"], + id: candidate.id, + }, + }; + } + + return undefined; +}; + +const getToolSummaryFromPart = (part: RenderableToolPart): AiToolSummary => { + const toolName = getToolName(part); + if (toolName === getLatestNetDefinitionToolName) { + return { title: "Checked latest net definition" }; + } + + const output = part.output; + if (typeof output === "object" && output !== null) { + const maybeSummary = output as { + detail?: unknown; + items?: unknown; + title?: unknown; + target?: unknown; + }; + if (typeof maybeSummary.title === "string") { + return { + title: maybeSummary.title, + detail: + typeof maybeSummary.detail === "string" + ? maybeSummary.detail + : undefined, + items: Array.isArray(maybeSummary.items) + ? maybeSummary.items.filter( + (item): item is string => typeof item === "string", + ) + : undefined, + target: getAiToolTarget(maybeSummary.target), + }; + } + } + + if (!(toolName in petrinautAiMutationTools)) { + return { title: toolName }; + } + try { + return summarizePetrinautAiToolCall({ + toolName: toolName as never, + input: part.input as never, + }); + } catch { + return { title: toolName }; + } +}; + +const getToolTone = ({ + state, + summary, + toolName, +}: { + state: string; + summary: AiToolSummary; + toolName: string; +}): ToolTone => { + if (state === "output-error") { + return "danger"; + } + + if (toolName === getLatestNetDefinitionToolName) { + return "neutral"; + } + + if ( + toolName === "deleteItemsByIds" || + toolName.startsWith("remove") || + /^(Deleted|Removed)\b/u.test(summary.title) + ) { + return "danger"; + } + + return "success"; +}; + +const toToolRenderItem = ( + message: PetrinautAiMessage, + part: RenderableToolPart, +): ToolRenderItem => { + const state = part.state ?? "input-available"; + const summary = getToolSummaryFromPart(part); + const toolName = getToolName(part); + + return { + id: + typeof part.toolCallId === "string" + ? part.toolCallId + : `${message.id}-${part.type}`, + state, + summary, + tone: getToolTone({ state, summary, toolName }), + toolName, + }; +}; + +const getMessageRenderItems = ( + message: PetrinautAiMessage, +): MessageRenderItem[] => { + const items: MessageRenderItem[] = []; + let pendingTools: ToolRenderItem[] = []; + + const flushTools = () => { + if (pendingTools.length === 0) { + return; + } + + items.push({ + type: "tools", + key: `${message.id}-tools-${items.length}`, + tools: pendingTools, + }); + pendingTools = []; + }; + + message.parts.forEach((part, index) => { + if (part.type === "text") { + flushTools(); + items.push({ + type: "text", + key: `${message.id}-text-${index}`, + part, + }); + return; + } + + if (part.type === "reasoning") { + flushTools(); + items.push({ + type: "reasoning", + key: `${message.id}-reasoning-${index}`, + part, + }); + return; + } + + if (isToolPart(part)) { + const tool = toToolRenderItem(message, part); + + if (tool.toolName === getLatestNetDefinitionToolName) { + flushTools(); + pendingTools.push(tool); + flushTools(); + return; + } + + pendingTools.push(tool); + } + }); + + flushTools(); + + return items; +}; + +const isPartActive = (part: PetrinautAiMessage["parts"][number]): boolean => + "state" in part && + (part.state === "streaming" || + part.state === "input-streaming" || + part.state === "input-available"); + +const getMessagesScrollKey = (messages: PetrinautAiMessage[]): string => + messages + .map((message) => + [ + message.id, + message.parts + .map((part) => { + if (part.type === "text" || part.type === "reasoning") { + return `${part.type}:${part.state ?? ""}:${part.text}`; + } + + return "state" in part + ? `${part.type}:${part.state ?? ""}` + : part.type; + }) + .join(","), + ].join(":"), + ) + .join("|"); + +const formatElapsedTime = (elapsedMs: number): string => { + const seconds = Math.max(0, Math.floor(elapsedMs / 1_000)); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + return minutes > 0 + ? `${minutes}m ${remainingSeconds.toString().padStart(2, "0")}s` + : `${seconds}s`; +}; + +const useElapsedTime = (isRunning: boolean): string => { + const startedAt = useRef(Date.now()); + const [elapsedMs, setElapsedMs] = useState(0); + + useEffect(() => { + if (!isRunning) { + setElapsedMs((current) => current || Date.now() - startedAt.current); + return; + } + + const updateElapsed = () => setElapsedMs(Date.now() - startedAt.current); + updateElapsed(); + const intervalId = window.setInterval(updateElapsed, 1_000); + + return () => window.clearInterval(intervalId); + }, [isRunning]); + + return formatElapsedTime(elapsedMs); +}; + +const ReasoningPart = ({ + isStreaming, + text, +}: { + isStreaming: boolean; + text: string; +}) => { + const elapsedTime = useElapsedTime(isStreaming); + const renderedText = text.trim(); + const [open, setOpen] = useState(isStreaming); + + useEffect(() => { + setOpen(isStreaming); + }, [isStreaming]); + + if (!isStreaming && !renderedText) { + return null; + } + + return ( + setOpen(details.open)} + > + + + Reasoning + {elapsedTime} + + + +
+ {renderedText ? ( + {renderedText} + ) : ( +
+ +
+ )} +
+
+
+ ); +}; + +const ToolItem = ({ + onSelectToolTarget, + tool, +}: { + onSelectToolTarget?: (target: AiToolTarget) => void; + tool: ToolRenderItem; +}) => { + const complete = tool.state === "output-available"; + const errored = tool.state === "output-error"; + const target = tool.summary.target; + const children = tool.summary.items ?? []; + const expandable = children.length > 0; + const title = errored ? `${tool.toolName} errored` : tool.summary.title; + + const button = ( + + ); + + if (!expandable) { + return button; + } + + return ( + + {button} + +
+ {children.map((item, index) => ( +
+ {item} +
+ ))} +
+
+
+ ); +}; + +const ToolListContent = ({ + onSelectToolTarget, + tools, +}: { + onSelectToolTarget?: (target: AiToolTarget) => void; + tools: ToolRenderItem[]; +}) => ( + <> + {tools.map((tool) => ( + + ))} + +); + +const ToolList = ({ + onSelectToolTarget, + tools, +}: { + onSelectToolTarget?: (target: AiToolTarget) => void; + tools: ToolRenderItem[]; +}) => { + if (tools.length === 0) { + return null; + } + + if (tools.length === 1) { + return ( +
+ +
+ ); + } + + return ( + + + + + + {tools.length} changes + + + +
+ +
+
+
+ ); +}; + +export const AiAssistantSurface = ({ + error, + input, + messages, + onClearMessages, + onClose, + onInputChange, + onSelectToolTarget, + onStop, + onSubmit, + rightOffset = 0, + status, +}: AiAssistantSurfaceProps) => { + const isBusy = status === "submitted" || status === "streaming"; + const canSubmit = input.trim().length > 0 && !isBusy; + const [assistantWidth, setAssistantWidth] = useState(420); + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + const messagesScrollKey = getMessagesScrollKey(messages); + + const onResizeStart = (event: ReactPointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = assistantWidth; + const maxWidth = Math.min(window.innerWidth - 32, 720); + + const onPointerMove = (moveEvent: PointerEvent) => { + setAssistantWidth( + Math.min( + Math.max(startWidth + startX - moveEvent.clientX, 320), + maxWidth, + ), + ); + }; + + const onPointerUp = () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const scrollToEnd = () => { + messagesEndRef.current?.scrollIntoView?.({ + block: "end", + behavior: "smooth", + }); + }; + const requestFrame = + window.requestAnimationFrame ?? + ((callback: FrameRequestCallback) => window.setTimeout(callback, 0)); + const cancelFrame = + window.cancelAnimationFrame ?? + ((handle: number) => window.clearTimeout(handle)); + const frameId = requestFrame(scrollToEnd); + + return () => cancelFrame(frameId); + }, [messagesScrollKey, status]); + + return ( + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx new file mode 100644 index 00000000000..bccc6ab6235 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx @@ -0,0 +1,334 @@ +import { useChat } from "@ai-sdk/react"; +import { use, useEffect, useRef, useState } from "react"; +import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; + +import { + createPetrinautMutationAiToolCallbacks, + getLatestNetDefinitionToolName, + petrinautAiMutationToolInputSchemas, + type PetrinautAiMutationToolInput, + type PetrinautAiMutationToolName, +} from "../../../../../core/ai"; +import type { Petrinaut } from "../../../../../core/instance"; +import type { SDCPN } from "../../../../../core/types/sdcpn"; +import { PetrinautInstanceContext } from "../../../../../react/instance-context"; +import { + EditorContext, + type EditorContextValue, +} from "../../../../../react/state/editor-context"; +import { PANEL_MARGIN } from "../../../../constants/ui"; +import type { PetrinautAiAssistant } from "../../../../petrinaut"; +import { AiAssistantSurface } from "./assistant-surface"; +import { + type AiToolOutput, + type AiToolCall, + type AiToolTarget, + summarizePetrinautAiToolCall, + toPetrinautAiToolOutput, +} from "./tool-summaries"; +import type { PetrinautAiMessage } from "./types"; + +const selectTarget = ( + target: AiToolTarget, + actions: Pick< + EditorContextValue, + "selectItem" | "setGlobalMode" | "setSimulateDrawer" | "setSimulateViewMode" + >, +) => { + if (target.kind === "selection") { + actions.selectItem(target.item); + return; + } + + actions.setGlobalMode("simulate"); + actions.setSimulateViewMode(target.mode); + actions.setSimulateDrawer( + target.mode === "scenarios" + ? target.itemId + ? { type: "view-scenario", scenarioId: target.itemId } + : { type: "closed" } + : target.itemId + ? { type: "view-metric", metricId: target.itemId } + : { type: "closed" }, + ); +}; + +const isPetrinautAiMutationToolName = ( + toolName: string, +): toolName is PetrinautAiMutationToolName => + toolName in petrinautAiMutationToolInputSchemas; + +const logToolCallError = ({ + error, + input, + toolName, +}: { + error: unknown; + input: unknown; + toolName: string; +}) => { + console.error("Petrinaut AI tool call failed", { + error, + input, + toolName, + }); +}; + +const getErroredToolParts = (messages: PetrinautAiMessage[]) => + messages.flatMap((message) => + message.parts.flatMap((part) => { + if ( + !("state" in part) || + part.state !== "output-error" || + !part.type.startsWith("tool-") + ) { + return []; + } + + const toolPart = part as { + errorText?: unknown; + input?: unknown; + toolCallId?: unknown; + type: string; + }; + + return [ + { + errorText: + typeof toolPart.errorText === "string" + ? toolPart.errorText + : undefined, + input: toolPart.input, + messageId: message.id, + toolCallId: + typeof toolPart.toolCallId === "string" + ? toolPart.toolCallId + : undefined, + toolName: toolPart.type.replace(/^tool-/, ""), + }, + ]; + }), + ); + +const safelyAddToolOutput = ( + addToolOutput: ReturnType< + typeof useChat + >["addToolOutput"], + params: Parameters< + ReturnType>["addToolOutput"] + >[0], +) => { + void Promise.resolve(addToolOutput(params)).catch((error: unknown) => { + logToolCallError({ + error, + input: undefined, + toolName: String(params.tool), + }); + }); +}; + +const applyPetrinautAiTool = ({ + definition, + input, + instance, + toolName, +}: { + definition: SDCPN; + input: PetrinautAiMutationToolInput; + instance: Petrinaut; + toolName: Name; +}): AiToolOutput => { + const toolCallbacks = createPetrinautMutationAiToolCallbacks(instance); + const aiToolCall: AiToolCall = { input, toolName } as AiToolCall; + const summary = summarizePetrinautAiToolCall(aiToolCall, { definition }); + const callback = toolCallbacks[aiToolCall.toolName] as ( + input: typeof aiToolCall.input, + ) => void; + + callback(aiToolCall.input); + + return toPetrinautAiToolOutput(summary); +}; + +export const AiAssistantPanel = ({ + aiAssistant, + initialMessage, + onInitialMessageConsumed, +}: { + aiAssistant: PetrinautAiAssistant; + initialMessage?: string | null; + onInitialMessageConsumed?: () => void; +}) => { + const instance = use(PetrinautInstanceContext); + const { + hasSelection, + isAiAssistantOpen, + propertiesPanelWidth, + selectItem, + setAiAssistantOpen, + setGlobalMode, + setSimulateDrawer, + setSimulateViewMode, + } = use(EditorContext); + const [input, setInput] = useState(""); + const submittedInitialMessageRef = useRef(null); + + const { + error, + messages, + addToolOutput, + sendMessage, + setMessages, + status, + stop, + } = useChat({ + messages: aiAssistant.messages, + transport: aiAssistant.transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onFinish: ({ messages: finishedMessages }) => { + aiAssistant.onMessages?.(finishedMessages); + }, + onToolCall: async ({ toolCall }) => { + try { + if (!instance) { + throw new Error( + "Petrinaut AI cannot run without an editor instance.", + ); + } + if (toolCall.dynamic) { + throw new Error(`Unknown Petrinaut AI tool: ${toolCall.toolName}`); + } + if (toolCall.toolName === getLatestNetDefinitionToolName) { + safelyAddToolOutput(addToolOutput, { + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: instance.definition.get(), + }); + return; + } + + if (!isPetrinautAiMutationToolName(toolCall.toolName)) { + throw new Error(`Unknown Petrinaut AI tool: ${toolCall.toolName}`); + } + + const toolInput = petrinautAiMutationToolInputSchemas[ + toolCall.toolName + ].parse(toolCall.input); + const output = applyPetrinautAiTool({ + definition: instance.definition.get(), + input: toolInput, + instance, + toolName: toolCall.toolName, + }); + + safelyAddToolOutput(addToolOutput, { + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output, + }); + } catch (error) { + logToolCallError({ + error, + input: toolCall.input, + toolName: toolCall.toolName, + }); + throw error; + } + }, + }); + + useEffect(() => { + const trimmedInitialMessage = initialMessage?.trim(); + if (!trimmedInitialMessage) { + submittedInitialMessageRef.current = null; + return; + } + if (!isAiAssistantOpen || !instance) { + return; + } + if (submittedInitialMessageRef.current === trimmedInitialMessage) { + return; + } + + submittedInitialMessageRef.current = trimmedInitialMessage; + onInitialMessageConsumed?.(); + setInput(""); + void sendMessage({ text: trimmedInitialMessage }); + }, [ + initialMessage, + instance, + isAiAssistantOpen, + onInitialMessageConsumed, + sendMessage, + ]); + + useEffect(() => { + if (!error) { + return; + } + + const lastMessage = messages.at(-1); + console.error("Petrinaut AI chat failed", { + error, + lastMessage, + messageCount: messages.length, + status, + }); + }, [error, messages, status]); + + const loggedErroredToolCallsRef = useRef>(new Set()); + + useEffect(() => { + for (const toolPart of getErroredToolParts(messages)) { + const key = `${toolPart.messageId}:${toolPart.toolCallId ?? toolPart.toolName}`; + if (loggedErroredToolCallsRef.current.has(key)) { + continue; + } + + loggedErroredToolCallsRef.current.add(key); + console.error("Petrinaut AI tool call failed", toolPart); + } + }, [messages]); + + if (!isAiAssistantOpen || !instance) { + return null; + } + + return ( + { + if (status === "submitted" || status === "streaming") { + void stop(); + } + setInput(""); + setMessages([]); + aiAssistant.onMessages?.([]); + aiAssistant.onClearMessages?.(); + }} + onClose={() => setAiAssistantOpen(false)} + onInputChange={setInput} + onSelectToolTarget={(target) => + selectTarget(target, { + selectItem, + setGlobalMode, + setSimulateDrawer, + setSimulateViewMode, + }) + } + onStop={() => void stop()} + onSubmit={() => { + const trimmed = input.trim(); + if (!trimmed) { + return; + } + setInput(""); + void sendMessage({ text: trimmed }); + }} + rightOffset={hasSelection ? propertiesPanelWidth + PANEL_MARGIN : 0} + status={status} + /> + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts new file mode 100644 index 00000000000..fe9d84383e0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts @@ -0,0 +1,95 @@ +import type { ChatTransport, UIMessageChunk } from "ai"; + +import type { PetrinautAiMessage } from "./types"; + +const placeInput = { + id: "place__ai_buffer", + name: "AI Buffer", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + showAsInitialState: true, + x: 180, + y: 140, +}; + +const transitionInput = { + id: "transition__ai_dispatch", + name: "AI Dispatch", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export const Lambda = () => true;", + transitionKernelCode: "export const TransitionKernel = () => ({});", + x: 420, + y: 140, +}; + +const chunksForInitialRequest = (): UIMessageChunk[] => [ + { type: "reasoning-start", id: "reasoning-1" }, + { + type: "reasoning-delta", + id: "reasoning-1", + delta: + "Identify the requested process elements, then add a place and transition with stable IDs.", + }, + { type: "reasoning-end", id: "reasoning-1" }, + { + type: "tool-input-available", + toolCallId: "tool-add-place", + toolName: "addPlace", + input: placeInput, + }, + { + type: "tool-input-available", + toolCallId: "tool-add-transition", + toolName: "addTransition", + input: transitionInput, + }, +]; + +const chunksForFollowUpRequest = (): UIMessageChunk[] => [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: + "I added an AI Buffer place and an AI Dispatch transition. You can select the change summaries to inspect the new items.", + }, + { type: "text-end", id: "text-1" }, +]; + +const streamChunks = ( + chunks: UIMessageChunk[], +): ReadableStream => + new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + +const hasToolOutput = (messages: PetrinautAiMessage[]): boolean => + messages.some((message) => + message.parts.some( + (part) => + part.type.startsWith("tool-") && + "state" in part && + part.state === "output-available", + ), + ); + +export const createStorybookAiTransport = + (): ChatTransport => ({ + reconnectToStream: () => Promise.resolve(null), + sendMessages: ({ messages }) => + Promise.resolve( + streamChunks( + hasToolOutput(messages) + ? chunksForFollowUpRequest() + : chunksForInitialRequest(), + ), + ), + }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.test.ts new file mode 100644 index 00000000000..e7c5c0b6f5f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "vitest"; + +import type { SDCPN } from "../../../../../core/types/sdcpn"; +import { summarizePetrinautAiToolCall } from "./tool-summaries"; + +const definition: SDCPN = { + differentialEquations: [], + parameters: [], + places: [ + { + colorId: null, + differentialEquationId: null, + dynamicsEnabled: false, + id: "place__buffer", + name: "Buffer", + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition__ship", + inputArcs: [], + lambdaCode: "return true;", + lambdaType: "predicate", + name: "Ship", + outputArcs: [], + transitionKernelCode: "return tokens;", + x: 0, + y: 0, + }, + ], + types: [], +}; + +describe("summarizePetrinautAiToolCall", () => { + test("falls back to existing entity names when updates omit names", () => { + expect( + summarizePetrinautAiToolCall( + { + input: { + placeId: "place__buffer", + update: { dynamicsEnabled: true }, + }, + toolName: "updatePlace", + }, + { definition }, + ).title, + ).toBe("Updated place Buffer"); + + expect( + summarizePetrinautAiToolCall( + { + input: { + position: { x: 10, y: 20 }, + transitionId: "transition__ship", + }, + toolName: "updateTransitionPosition", + }, + { definition }, + ).title, + ).toBe("Moved transition Ship"); + }); + + test("prefers updated names while retaining previous names as detail", () => { + expect( + summarizePetrinautAiToolCall( + { + input: { + placeId: "place__buffer", + update: { name: "Queue" }, + }, + toolName: "updatePlace", + }, + { definition }, + ), + ).toMatchObject({ + detail: "Previous name: Buffer", + title: "Updated place Queue", + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.ts new file mode 100644 index 00000000000..67fc3d5a52a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.ts @@ -0,0 +1,493 @@ +import type { + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, +} from "../../../../../core/ai"; +import { generateArcId } from "../../../../../core/arc-id"; +import type { SDCPN } from "../../../../../core/types/sdcpn"; +import type { SelectionItem } from "../../../../../core/types/selection"; + +export type AiToolSummary = { + title: string; + detail?: string; + items?: string[]; + target?: AiToolTarget; +}; + +export type AiToolOutput = AiToolSummary & { applied: true }; + +export type AiToolTarget = + | { kind: "selection"; item: SelectionItem } + | { kind: "simulateView"; mode: "scenarios" | "metrics"; itemId?: string }; + +export type AiToolSummaryContext = { + definition?: SDCPN; +}; + +export const toPetrinautAiToolOutput = ( + summary: AiToolSummary, +): AiToolOutput => ({ + ...summary, + applied: true, +}); + +const prettifyToolName = (toolName: string): string => + toolName + .replace(/([A-Z])/g, " $1") + .replace(/^./, (char) => char.toUpperCase()); + +const getName = (value: unknown): string | undefined => { + if (typeof value !== "object" || value === null) { + return undefined; + } + const maybeName = (value as { name?: unknown }).name; + return typeof maybeName === "string" ? maybeName : undefined; +}; + +const findById = ( + items: Item[] | undefined, + id: string, +): Item | undefined => items?.find((item) => item.id === id); + +const entityName = ( + definition: SDCPN | undefined, + target: SelectionItem, +): string | undefined => { + switch (target.type) { + case "place": + return findById(definition?.places, target.id)?.name; + case "transition": + return findById(definition?.transitions, target.id)?.name; + case "type": + return findById(definition?.types, target.id)?.name; + case "differentialEquation": + return findById(definition?.differentialEquations, target.id)?.name; + case "parameter": + return findById(definition?.parameters, target.id)?.name; + case "arc": + return undefined; + } +}; + +const scenarioName = ( + definition: SDCPN | undefined, + scenarioId: string, +): string | undefined => findById(definition?.scenarios, scenarioId)?.name; + +const metricName = ( + definition: SDCPN | undefined, + metricId: string, +): string | undefined => findById(definition?.metrics, metricId)?.name; + +const typeElementName = ( + definition: SDCPN | undefined, + typeId: string, + elementId: string, +): string | undefined => + findById(definition?.types, typeId)?.elements.find( + (element) => element.elementId === elementId, + )?.name; + +const updatedOrExistingName = ({ + definitionName, + id, + update, +}: { + definitionName?: string; + id: string; + update?: unknown; +}): string => getName(update) ?? definitionName ?? id; + +const renameDetail = ({ + definitionName, + update, +}: { + definitionName?: string; + update?: unknown; +}): string | undefined => { + const updatedName = getName(update); + + return updatedName && definitionName && updatedName !== definitionName + ? `Previous name: ${definitionName}` + : undefined; +}; + +const targetName = ( + definition: SDCPN | undefined, + target: SelectionItem, +): string => entityName(definition, target) ?? target.id; + +const itemLabel = ( + definition: SDCPN | undefined, + item: SelectionItem, +): string => { + const typeLabel = + item.type === "differentialEquation" ? "equation" : item.type; + return `${typeLabel}: ${targetName(definition, item)}`; +}; + +const arcEndpointDetail = ( + definition: SDCPN | undefined, + input: { + placeId: string; + transitionId: string; + }, +): string => { + const place = targetName(definition, { type: "place", id: input.placeId }); + const transition = targetName(definition, { + type: "transition", + id: input.transitionId, + }); + + return `${place} <-> ${transition}`; +}; + +const arcTarget = (input: { + arcDirection: "input" | "output"; + placeId: string; + transitionId: string; +}): SelectionItem => ({ + type: "arc", + id: + input.arcDirection === "input" + ? generateArcId({ inputId: input.placeId, outputId: input.transitionId }) + : generateArcId({ inputId: input.transitionId, outputId: input.placeId }), +}); + +const selectionTarget = (item: SelectionItem): AiToolTarget => ({ + kind: "selection", + item, +}); + +export type AiToolCall = { + [Name in PetrinautAiMutationToolName]: { + toolName: Name; + input: PetrinautAiMutationToolInput; + }; +}[PetrinautAiMutationToolName]; + +export const summarizePetrinautAiToolCall = ( + { input, toolName }: AiToolCall, + context: AiToolSummaryContext = {}, +): AiToolSummary => { + const { definition } = context; + + switch (toolName) { + case "addPlace": + return { + title: `Added place ${input.name}`, + target: selectionTarget({ type: "place", id: input.id }), + }; + case "updatePlace": + return { + title: `Updated place ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "place", + id: input.placeId, + }), + id: input.placeId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "place", + id: input.placeId, + }), + update: input.update, + }), + target: selectionTarget({ type: "place", id: input.placeId }), + }; + case "updatePlacePosition": + return { + title: `Moved place ${targetName(definition, { + type: "place", + id: input.placeId, + })}`, + target: selectionTarget({ type: "place", id: input.placeId }), + }; + case "removePlace": + return { + title: `Removed place ${targetName(definition, { + type: "place", + id: input.placeId, + })}`, + }; + case "addTransition": + return { + title: `Added transition ${input.name}`, + target: selectionTarget({ type: "transition", id: input.id }), + }; + case "updateTransition": + return { + title: `Updated transition ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "transition", + id: input.transitionId, + }), + id: input.transitionId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "transition", + id: input.transitionId, + }), + update: input.update, + }), + target: selectionTarget({ type: "transition", id: input.transitionId }), + }; + case "updateTransitionPosition": + return { + title: `Moved transition ${targetName(definition, { + type: "transition", + id: input.transitionId, + })}`, + target: selectionTarget({ type: "transition", id: input.transitionId }), + }; + case "removeTransition": + return { + title: `Removed transition ${targetName(definition, { + type: "transition", + id: input.transitionId, + })}`, + }; + case "addArc": + return { + title: `Added ${input.arcDirection} arc`, + detail: arcEndpointDetail(definition, input), + target: selectionTarget(arcTarget(input)), + }; + case "removeArc": + return { + title: `Removed ${input.arcDirection} arc`, + detail: arcEndpointDetail(definition, input), + }; + case "updateArcWeight": + return { + title: "Updated arc weight", + detail: `${arcEndpointDetail(definition, input)}: ${input.weight}`, + target: selectionTarget(arcTarget(input)), + }; + case "updateArcType": + return { + title: "Updated input arc type", + detail: `${arcEndpointDetail(definition, input)}: ${input.type}`, + target: selectionTarget(arcTarget({ ...input, arcDirection: "input" })), + }; + case "updateArcPlace": + return { + title: "Updated arc endpoint", + detail: `${targetName(definition, { + type: "place", + id: input.oldPlaceId, + })} -> ${targetName(definition, { + type: "place", + id: input.newPlaceId, + })}`, + }; + case "addType": + return { + title: `Added type ${input.name}`, + target: selectionTarget({ type: "type", id: input.id }), + }; + case "updateType": + return { + title: `Updated type ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "type", + id: input.typeId, + }), + id: input.typeId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "type", + id: input.typeId, + }), + update: input.update, + }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "removeType": + return { + title: `Removed type ${targetName(definition, { + type: "type", + id: input.typeId, + })}`, + }; + case "addTypeElement": + return { + title: `Added type element ${input.element.name}`, + detail: input.typeId, + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "updateTypeElement": + return { + title: `Updated type element ${updatedOrExistingName({ + definitionName: typeElementName( + definition, + input.typeId, + input.elementId, + ), + id: input.elementId, + update: input.update, + })}`, + detail: targetName(definition, { type: "type", id: input.typeId }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "removeTypeElement": + return { + title: `Removed type element ${ + typeElementName(definition, input.typeId, input.elementId) ?? + input.elementId + }`, + detail: targetName(definition, { type: "type", id: input.typeId }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "moveTypeElement": + return { + title: `Moved type element ${ + typeElementName(definition, input.typeId, input.elementId) ?? + input.elementId + }`, + detail: targetName(definition, { type: "type", id: input.typeId }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "addDifferentialEquation": + return { + title: `Added equation ${input.name}`, + target: selectionTarget({ type: "differentialEquation", id: input.id }), + }; + case "updateDifferentialEquation": + return { + title: `Updated equation ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "differentialEquation", + id: input.equationId, + }), + id: input.equationId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "differentialEquation", + id: input.equationId, + }), + update: input.update, + }), + target: selectionTarget({ + type: "differentialEquation", + id: input.equationId, + }), + }; + case "removeDifferentialEquation": + return { + title: `Removed equation ${targetName(definition, { + type: "differentialEquation", + id: input.equationId, + })}`, + }; + case "addParameter": + return { + title: `Added parameter ${input.name}`, + target: selectionTarget({ type: "parameter", id: input.id }), + }; + case "updateParameter": + return { + title: `Updated parameter ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "parameter", + id: input.parameterId, + }), + id: input.parameterId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "parameter", + id: input.parameterId, + }), + update: input.update, + }), + target: selectionTarget({ type: "parameter", id: input.parameterId }), + }; + case "removeParameter": + return { + title: `Removed parameter ${targetName(definition, { + type: "parameter", + id: input.parameterId, + })}`, + }; + case "addScenario": + return { + title: `Added scenario ${input.name}`, + target: { kind: "simulateView", mode: "scenarios", itemId: input.id }, + }; + case "updateScenario": + return { + title: `Updated scenario ${updatedOrExistingName({ + definitionName: scenarioName(definition, input.scenarioId), + id: input.scenarioId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: scenarioName(definition, input.scenarioId), + update: input.update, + }), + target: { + kind: "simulateView", + mode: "scenarios", + itemId: input.scenarioId, + }, + }; + case "removeScenario": + return { + title: `Removed scenario ${ + scenarioName(definition, input.scenarioId) ?? input.scenarioId + }`, + }; + case "addMetric": + return { + title: `Added metric ${input.name}`, + target: { kind: "simulateView", mode: "metrics", itemId: input.id }, + }; + case "updateMetric": + return { + title: `Updated metric ${updatedOrExistingName({ + definitionName: metricName(definition, input.metricId), + id: input.metricId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: metricName(definition, input.metricId), + update: input.update, + }), + target: { + kind: "simulateView", + mode: "metrics", + itemId: input.metricId, + }, + }; + case "removeMetric": + return { + title: `Removed metric ${ + metricName(definition, input.metricId) ?? input.metricId + }`, + }; + case "deleteItemsByIds": + return { + title: `Deleted ${input.items.length} item${ + input.items.length === 1 ? "" : "s" + }`, + items: input.items.map((item) => itemLabel(definition, item)), + }; + case "commitNodePositions": + return { + title: `Moved ${input.commits.length} node${ + input.commits.length === 1 ? "" : "s" + }`, + }; + default: + return { title: prettifyToolName(toolName) }; + } +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts new file mode 100644 index 00000000000..cc3f678e172 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts @@ -0,0 +1,31 @@ +import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; + +import type { + getLatestNetDefinitionToolName, + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, + PetrinautAiToolInput, + PetrinautAiToolName, +} from "../../../../../core/ai"; +import type { SDCPN } from "../../../../../core/types/sdcpn"; +import type { AiToolOutput } from "./tool-summaries"; + +type PetrinautAiUiTools = { + [Name in PetrinautAiMutationToolName]: { + input: PetrinautAiMutationToolInput; + output: AiToolOutput; + }; +} & { + [getLatestNetDefinitionToolName]: { + input: PetrinautAiToolInput; + output: SDCPN; + }; +}; + +export type PetrinautAiMessage = UIMessage< + unknown, + UIDataTypes, + PetrinautAiUiTools +>; + +export type PetrinautAiTransport = ChatTransport; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx index 23137ed831f..0941ac89e6d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx @@ -4,8 +4,8 @@ import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { - EditorContext, - type SimulateViewMode, + EditorContext, + type SimulateViewMode, } from "../../../../../react/state/editor-context"; import { SegmentGroup } from "../../../../components/segment-group"; import { ExperimentsView } from "./experiments/experiments-view"; @@ -18,77 +18,77 @@ import type { ComponentType } from "react"; // -- Layout styles ------------------------------------------------------------- const containerStyle = css({ - display: "flex", - flexDirection: "row", - width: "full", - height: "full", - backgroundColor: "neutral.s00", + display: "flex", + flexDirection: "row", + width: "full", + height: "full", + backgroundColor: "neutral.s00", }); const sidebarStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[2px]", - padding: "[12px]", - backgroundColor: "neutral.s00", - borderRightWidth: "[1px]", - borderRightStyle: "solid", - borderRightColor: "neutral.s40", - flexShrink: 0, + display: "flex", + flexDirection: "column", + gap: "[2px]", + padding: "[12px]", + backgroundColor: "neutral.s00", + borderRightWidth: "[1px]", + borderRightStyle: "solid", + borderRightColor: "neutral.s40", + flexShrink: 0, }); // -- Mode options -------------------------------------------------------------- const modeOptions: SegmentOption[] = [ - { - value: "scenarios", - label: "Scenarios", - icon: , - hideLabel: true, - tooltip: "Scenarios", - }, - { - value: "metrics", - label: "Metrics", - icon: , - hideLabel: true, - tooltip: "Metrics", - }, - { - value: "experiments", - label: "Experiments", - icon: , - hideLabel: true, - tooltip: "Experiments", - }, + { + value: "scenarios", + label: "Scenarios", + icon: , + hideLabel: true, + tooltip: "Scenarios", + }, + { + value: "metrics", + label: "Metrics", + icon: , + hideLabel: true, + tooltip: "Metrics", + }, + { + value: "experiments", + label: "Experiments", + icon: , + hideLabel: true, + tooltip: "Experiments", + }, ]; const views = { - scenarios: ScenariosView, - metrics: MetricsView, - experiments: ExperimentsView, + scenarios: ScenariosView, + metrics: MetricsView, + experiments: ExperimentsView, } satisfies Record; // -- Component ----------------------------------------------------------------- export const SimulateView = () => { - const { simulateViewMode: mode, setSimulateViewMode: setMode } = - use(EditorContext); - const ActiveView = views[mode]; + const { simulateViewMode: mode, setSimulateViewMode: setMode } = + use(EditorContext); + const ActiveView = views[mode]; - return ( -
-
- setMode(value as SimulateViewMode)} - orientation="vertical" - size="sm" - /> -
+ return ( +
+
+ setMode(value as SimulateViewMode)} + orientation="vertical" + size="sm" + /> +
- -
- ); + +
+ ); }; diff --git a/package.json b/package.json index ed632981bb5..6aef9f5ad72 100644 --- a/package.json +++ b/package.json @@ -1,117 +1,116 @@ { - "name": "hash", - "version": "0.0.0-private", - "private": true, - "description": "HASH monorepo", - "repository": { - "type": "git", - "url": "https://github.com/hashintel/hash.git" - }, - "workspaces": { - "packages": [ - "!**/node_modules", - "!**/pkg", - ".claude/hooks", - "apps/**", - "libs/**", - "tests/**" - ] - }, - "scripts": { - "agents:skill-management": "yarn workspace @local/repo-chores exe scripts/skill-management.ts", - "agents:symlink-rules": "yarn workspace @local/repo-chores exe scripts/symlink-agent-rules.ts", - "bench": "npm-run-all --continue-on-error \"bench:*\"", - "bench:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:integration --env-mode=loose --", - "bench:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:unit --env-mode=loose --", - "changeset:publish": "yarn changeset:resolve-workspace-ranges && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn && yarn changeset publish", - "changeset:resolve-workspace-ranges": "node scripts/resolve-workspace-ranges.mjs", - "changeset:version": "yarn changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn", - "codegen": "CARGO_TERM_PROGRESS_WHEN=never turbo codegen", - "create-block": "yarn workspace @local/repo-chores exe scripts/create-block.ts", - "dev": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --filter '@apps/hash-frontend' --", - "dev:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", - "dev:backend:api": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", - "dev:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-frontend' --", - "external-services": "turbo deploy --filter '@apps/hash-external-services' --", - "external-services:offline": "turbo deploy:offline --filter '@apps/hash-external-services' --", - "external-services:prod": "turbo deploy:prod --filter '@apps/hash-external-services' --", - "external-services:test": "turbo deploy:test --filter '@apps/hash-external-services' --", - "fix": "npm-run-all --continue-on-error \"fix:*\"", - "fix:constraints": "yarn constraints --fix", - "fix:eslint": "mise run fix:eslint", - "fix:format": "oxfmt --write", - "fix:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2 --fix", - "fix:taplo": "taplo fmt", - "fix:yarn-deduplicate": "yarn dedupe --strategy highest", - "generate-ontology-type-ids": "yarn workspace @apps/hash-api generate-ontology-type-ids", - "generate-system-types": "yarn workspace @local/hash-isomorphic-utils generate-system-types", - "graph:reset-database": "yarn workspace @rust/hash-graph-http-tests reset-database", - "lint": "npm-run-all --continue-on-error \"lint:*\"", - "lint:constraints": "yarn constraints", - "lint:eslint": "CARGO_TERM_PROGRESS_WHEN=never turbo --continue=always lint:eslint --", - "lint:format": "oxfmt --check", - "lint:license-in-workspaces": "yarn workspace @local/repo-chores exe scripts/check-license-in-workspaces.ts", - "lint:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2", - "lint:skill": "yarn agents:skill-management validate", - "lint:taplo": "taplo fmt --check", - "lint:tsc": "mise run lint:tsc", - "lint:yarn-deduplicate": "yarn dedupe --strategy highest --check", - "postinstall": "CARGO_TERM_PROGRESS_WHEN=never turbo run postinstall", - "prune-node-modules": "find . -type d -name \"node_modules\" -exec rm -rf {} +", - "start": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --filter @apps/hash-api --filter @apps/hash-ai-worker-ts --filter @apps/hash-integration-worker --filter @apps/hash-frontend --env-mode=loose", - "start:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-api --env-mode=loose", - "start:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-frontend --env-mode=loose", - "start:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --env-mode=loose", - "start:test": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --env-mode=loose", - "start:test:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-api --env-mode=loose", - "start:test:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-frontend --env-mode=loose", - "start:test:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-graph --env-mode=loose", - "start:test:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", - "start:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", - "test": "npm-run-all --continue-on-error \"test:*\"", - "test:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --", - "test:playwright": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --filter @tests/hash-playwright --", - "test:stale-approvals": "bash .github/actions/dismiss-stale-approvals/self-test.sh", - "test:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:unit --env-mode=loose --" - }, - "devDependencies": { - "@changesets/changelog-github": "0.5.1", - "@changesets/cli": "patch:@changesets/cli@npm%3A2.30.0#~/.yarn/patches/@changesets-cli-npm-2.30.0-83a4e8887c.patch", - "@local/claude-hooks": "workspace:*", - "@yarnpkg/types": "^4.0.1", - "lefthook": "2.0.0", - "npm-run-all2": "8.0.4", - "oxfmt": "0.50.0" - }, - "resolutions": { - "@artilleryio/int-commons@npm:2.11.0": "patch:@artilleryio/int-commons@npm%3A2.11.0#~/.yarn/patches/@artilleryio-int-commons-npm-2.11.0-5b69c05121.patch", - "@changesets/assemble-release-plan@npm:^6.0.9": "patch:@changesets/assemble-release-plan@npm%3A6.0.9#~/.yarn/patches/@changesets-assemble-release-plan-npm-6.0.9-e01af97ef4.patch", - "@playwright/test": "1.58.2", - "@tldraw/editor@npm:2.0.0-alpha.12": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", - "@tldraw/tlschema@npm:2.0.0-alpha.12": "patch:@tldraw/tlschema@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-tlschema-npm-2.0.0-alpha.12-13bf88407b.patch", - "ai": "5.0.97", - "blockprotocol@npm:0.0.10": "patch:blockprotocol@npm%3A0.0.12#~/.yarn/patches/blockprotocol-npm-0.0.12-2558a31f0a.patch", - "canvas": "3.2.0", - "dompurify": "3.4.0", - "fast-xml-parser": "5.7.0", - "http-proxy-middleware@npm:^2.0.9": "patch:http-proxy-middleware@npm%3A3.0.5#~/.yarn/patches/http-proxy-middleware-npm-3.0.5-5c57f2e983.patch", - "jsondiffpatch": "0.7.2", - "lodash": "4.18.1", - "next/postcss": "8.5.10", - "prosemirror-model@npm:>=1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.16.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.20.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.21.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-view@npm:^1.1.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", - "prosemirror-view@npm:^1.27.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", - "qs": "6.14.2", - "react": "19.2.6", - "react-dom": "19.2.6", - "underscore": "1.13.8" - }, - "engines": { - "node": ">= v22" - }, - "packageManager": "yarn@4.12.0" + "name": "hash", + "version": "0.0.0-private", + "private": true, + "description": "HASH monorepo", + "repository": { + "type": "git", + "url": "https://github.com/hashintel/hash.git" + }, + "workspaces": { + "packages": [ + "!**/node_modules", + "!**/pkg", + ".claude/hooks", + "apps/**", + "libs/**", + "tests/**" + ] + }, + "scripts": { + "agents:skill-management": "yarn workspace @local/repo-chores exe scripts/skill-management.ts", + "agents:symlink-rules": "yarn workspace @local/repo-chores exe scripts/symlink-agent-rules.ts", + "bench": "npm-run-all --continue-on-error \"bench:*\"", + "bench:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:integration --env-mode=loose --", + "bench:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:unit --env-mode=loose --", + "changeset:publish": "yarn changeset:resolve-workspace-ranges && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn && yarn changeset publish", + "changeset:resolve-workspace-ranges": "node scripts/resolve-workspace-ranges.mjs", + "changeset:version": "yarn changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn", + "codegen": "CARGO_TERM_PROGRESS_WHEN=never turbo codegen", + "create-block": "yarn workspace @local/repo-chores exe scripts/create-block.ts", + "dev": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --filter '@apps/hash-frontend' --", + "dev:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", + "dev:backend:api": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", + "dev:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-frontend' --", + "external-services": "turbo deploy --filter '@apps/hash-external-services' --", + "external-services:offline": "turbo deploy:offline --filter '@apps/hash-external-services' --", + "external-services:prod": "turbo deploy:prod --filter '@apps/hash-external-services' --", + "external-services:test": "turbo deploy:test --filter '@apps/hash-external-services' --", + "fix": "npm-run-all --continue-on-error \"fix:*\"", + "fix:constraints": "yarn constraints --fix", + "fix:eslint": "mise run fix:eslint", + "fix:format": "oxfmt --write", + "fix:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2 --fix", + "fix:taplo": "taplo fmt", + "fix:yarn-deduplicate": "yarn dedupe --strategy highest", + "generate-ontology-type-ids": "yarn workspace @apps/hash-api generate-ontology-type-ids", + "generate-system-types": "yarn workspace @local/hash-isomorphic-utils generate-system-types", + "graph:reset-database": "yarn workspace @rust/hash-graph-http-tests reset-database", + "lint": "npm-run-all --continue-on-error \"lint:*\"", + "lint:constraints": "yarn constraints", + "lint:eslint": "CARGO_TERM_PROGRESS_WHEN=never turbo --continue=always lint:eslint --", + "lint:format": "oxfmt --check", + "lint:license-in-workspaces": "yarn workspace @local/repo-chores exe scripts/check-license-in-workspaces.ts", + "lint:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2", + "lint:skill": "yarn agents:skill-management validate", + "lint:taplo": "taplo fmt --check", + "lint:tsc": "mise run lint:tsc", + "lint:yarn-deduplicate": "yarn dedupe --strategy highest --check", + "postinstall": "CARGO_TERM_PROGRESS_WHEN=never turbo run postinstall", + "prune-node-modules": "find . -type d -name \"node_modules\" -exec rm -rf {} +", + "start": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --filter @apps/hash-api --filter @apps/hash-ai-worker-ts --filter @apps/hash-integration-worker --filter @apps/hash-frontend --env-mode=loose", + "start:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-api --env-mode=loose", + "start:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-frontend --env-mode=loose", + "start:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --env-mode=loose", + "start:test": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --env-mode=loose", + "start:test:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-api --env-mode=loose", + "start:test:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-frontend --env-mode=loose", + "start:test:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-graph --env-mode=loose", + "start:test:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", + "start:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", + "test": "npm-run-all --continue-on-error \"test:*\"", + "test:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --", + "test:playwright": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --filter @tests/hash-playwright --", + "test:stale-approvals": "bash .github/actions/dismiss-stale-approvals/self-test.sh", + "test:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:unit --env-mode=loose --" + }, + "devDependencies": { + "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "patch:@changesets/cli@npm%3A2.30.0#~/.yarn/patches/@changesets-cli-npm-2.30.0-83a4e8887c.patch", + "@local/claude-hooks": "workspace:*", + "@yarnpkg/types": "^4.0.1", + "lefthook": "2.0.0", + "npm-run-all2": "8.0.4", + "oxfmt": "0.50.0" + }, + "resolutions": { + "@artilleryio/int-commons@npm:2.11.0": "patch:@artilleryio/int-commons@npm%3A2.11.0#~/.yarn/patches/@artilleryio-int-commons-npm-2.11.0-5b69c05121.patch", + "@changesets/assemble-release-plan@npm:^6.0.9": "patch:@changesets/assemble-release-plan@npm%3A6.0.9#~/.yarn/patches/@changesets-assemble-release-plan-npm-6.0.9-e01af97ef4.patch", + "@playwright/test": "1.58.2", + "@tldraw/editor@npm:2.0.0-alpha.12": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", + "@tldraw/tlschema@npm:2.0.0-alpha.12": "patch:@tldraw/tlschema@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-tlschema-npm-2.0.0-alpha.12-13bf88407b.patch", + "blockprotocol@npm:0.0.10": "patch:blockprotocol@npm%3A0.0.12#~/.yarn/patches/blockprotocol-npm-0.0.12-2558a31f0a.patch", + "canvas": "3.2.0", + "dompurify": "3.4.0", + "fast-xml-parser": "5.7.0", + "http-proxy-middleware@npm:^2.0.9": "patch:http-proxy-middleware@npm%3A3.0.5#~/.yarn/patches/http-proxy-middleware-npm-3.0.5-5c57f2e983.patch", + "jsondiffpatch": "0.7.2", + "lodash": "4.18.1", + "next/postcss": "8.5.10", + "prosemirror-model@npm:>=1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.16.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.20.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.21.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-view@npm:^1.1.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", + "prosemirror-view@npm:^1.27.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", + "qs": "6.14.2", + "react": "19.2.6", + "react-dom": "19.2.6", + "underscore": "1.13.8" + }, + "engines": { + "node": ">= v22" + }, + "packageManager": "yarn@4.12.0" } diff --git a/yarn.lock b/yarn.lock index d0e7debb137..3e13bd022d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,67 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/gateway@npm:3.0.114": + version: 3.0.114 + resolution: "@ai-sdk/gateway@npm:3.0.114" + dependencies: + "@ai-sdk/provider": "npm:3.0.10" + "@ai-sdk/provider-utils": "npm:4.0.27" + "@vercel/oidc": "npm:3.2.0" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/8a3f426c458c4a5eb8121e0f4a9814765dda29a25dc3efb458aea05abd94ff34fedb3987ab1d97e8f457156222f4149cd116f96cc03382ce2ff215106b6da70d + languageName: node + linkType: hard + +"@ai-sdk/openai@npm:3.0.63": + version: 3.0.63 + resolution: "@ai-sdk/openai@npm:3.0.63" + dependencies: + "@ai-sdk/provider": "npm:3.0.10" + "@ai-sdk/provider-utils": "npm:4.0.27" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/b68614fa506fd98364ecab7096e34c6bc2797add919213996e8747f4afba3f15bdcae6c74096c202e7cc13fab2fd6c180e64515d76ea1b75b0bd49e3d088b069 + languageName: node + linkType: hard + +"@ai-sdk/provider-utils@npm:4.0.27": + version: 4.0.27 + resolution: "@ai-sdk/provider-utils@npm:4.0.27" + dependencies: + "@ai-sdk/provider": "npm:3.0.10" + "@standard-schema/spec": "npm:^1.1.0" + eventsource-parser: "npm:^3.0.8" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/2202dedcbe8f58883ef37425111392c6b5b5c2f85dfbbd11b13173e9d349da3d977ba610586c4b5412720e07b09f63ba0e88dbc6e21f043441a67f6d51225020 + languageName: node + linkType: hard + +"@ai-sdk/provider@npm:3.0.10": + version: 3.0.10 + resolution: "@ai-sdk/provider@npm:3.0.10" + dependencies: + json-schema: "npm:^0.4.0" + checksum: 10c0/c9b162165c3fd4684e4f7a1c041db3ad75785c841d904bac577006b7b2299d1b465557b1d32e3f59b6d8536a0ef5741b195aad86fc3e0e1d2a39f077062a83c4 + languageName: node + linkType: hard + +"@ai-sdk/react@npm:3.0.184": + version: 3.0.184 + resolution: "@ai-sdk/react@npm:3.0.184" + dependencies: + "@ai-sdk/provider-utils": "npm:4.0.27" + ai: "npm:6.0.182" + swr: "npm:^2.2.5" + throttleit: "npm:2.1.0" + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + checksum: 10c0/1d16f5a20b7a6334a09445f19056afc410b601c3da52ff3ad331f2b7da73657c9b7b7106fc64d081636522009b8d42451cec8c33dea2bf29bbe31773a9aa8229 + languageName: node + linkType: hard + "@anthropic-ai/bedrock-sdk@npm:0.26.3": version: 0.26.3 resolution: "@anthropic-ai/bedrock-sdk@npm:0.26.3" @@ -781,6 +842,7 @@ __metadata: version: 0.0.0-use.local resolution: "@apps/petrinaut-website@workspace:apps/petrinaut-website" dependencies: + "@ai-sdk/openai": "npm:3.0.63" "@hashintel/ds-components": "workspace:*" "@hashintel/ds-helpers": "workspace:*" "@hashintel/petrinaut": "workspace:*" @@ -793,6 +855,7 @@ __metadata: "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" "@vitejs/plugin-react": "npm:6.0.1" + ai: "npm:6.0.182" babel-plugin-react-compiler: "npm:1.0.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" @@ -801,6 +864,7 @@ __metadata: react-dom: "npm:19.2.6" react-icons: "npm:5.5.0" vite: "npm:8.0.12" + zod: "npm:4.4.3" languageName: unknown linkType: soft @@ -3610,7 +3674,18 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.25.6, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.3, @babel/parser@npm:^7.26.7, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.25.6, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.3, @babel/parser@npm:^7.26.7, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.2 + resolution: "@babel/parser@npm:7.29.2" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.28.5": version: 7.29.3 resolution: "@babel/parser@npm:7.29.3" dependencies: @@ -7676,6 +7751,7 @@ __metadata: version: 0.0.0-use.local resolution: "@hashintel/petrinaut@workspace:libs/@hashintel/petrinaut" dependencies: + "@ai-sdk/react": "npm:3.0.184" "@ark-ui/react": "npm:5.26.2" "@babel/standalone": "npm:7.28.5" "@fontsource-variable/inter": "npm:5.2.8" @@ -7699,6 +7775,7 @@ __metadata: "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" "@vitejs/plugin-react": "npm:6.0.1" "@xyflow/react": "npm:12.10.1" + ai: "npm:6.0.182" babel-plugin-react-compiler: "npm:1.0.0" elkjs: "npm:0.11.0" fuzzysort: "npm:3.1.0" @@ -7709,6 +7786,7 @@ __metadata: oxlint-tsgolint: "npm:0.22.1" react: "npm:19.2.6" react-dom: "npm:19.2.6" + react-markdown: "npm:10.1.0" react-resizable-panels: "npm:4.6.5" rolldown: "npm:1.0.0" rolldown-plugin-dts: "npm:0.25.0" @@ -20635,6 +20713,13 @@ __metadata: languageName: node linkType: hard +"@vercel/oidc@npm:3.2.0": + version: 3.2.0 + resolution: "@vercel/oidc@npm:3.2.0" + checksum: 10c0/98318d3236f58c296616c8c2e1655b268c7bf58525bcd985adac7af6d900e05fc610f6f03ce2ff4bdcd3df7885a40c0ca44fdc761f122dcfe15a78c2756b0243 + languageName: node + linkType: hard + "@vitejs/plugin-react-swc@npm:^3.7.2": version: 3.11.0 resolution: "@vitejs/plugin-react-swc@npm:3.11.0" @@ -22449,6 +22534,20 @@ __metadata: languageName: node linkType: hard +"ai@npm:6.0.182": + version: 6.0.182 + resolution: "ai@npm:6.0.182" + dependencies: + "@ai-sdk/gateway": "npm:3.0.114" + "@ai-sdk/provider": "npm:3.0.10" + "@ai-sdk/provider-utils": "npm:4.0.27" + "@opentelemetry/api": "npm:^1.9.0" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/6c4e19a9ddc6b7effb1e91feee9f3c4c0217bfacd7bf0a681aa392782720f8f25bacff2cf7ef6d121feca057f667276f2fd59c8759f12d7f1ef254ec39c76e4f + languageName: node + linkType: hard + "ajv-draft-04@npm:~1.0.0": version: 1.0.0 resolution: "ajv-draft-04@npm:1.0.0" @@ -28705,6 +28804,13 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.8": + version: 3.0.8 + resolution: "eventsource-parser@npm:3.0.8" + checksum: 10c0/3a73eee85311f33b12fa558381a477c1bdcf8c024a429a9d48f87b043e328c26d24ed280fd7ca92e2fdd4c8c37f749b758420c1533778aaca2beabf895024efa + languageName: node + linkType: hard + "eventsource@npm:^2.0.2": version: 2.0.2 resolution: "eventsource@npm:2.0.2" @@ -30031,7 +30137,16 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.13.0, get-tsconfig@npm:^4.7.5": +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5": + version: 4.13.6 + resolution: "get-tsconfig@npm:4.13.6" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/bab6937302f542f97217cbe7cbbdfa7e85a56a377bc7a73e69224c1f0b7c9ae8365918e55752ae8648265903f506c1705f63c0de1d4bab1ec2830fef3e539a1a + languageName: node + linkType: hard + +"get-tsconfig@npm:^4.13.0": version: 4.14.0 resolution: "get-tsconfig@npm:4.14.0" dependencies: @@ -33481,6 +33596,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:^0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -43597,6 +43719,18 @@ __metadata: languageName: node linkType: hard +"swr@npm:^2.2.5": + version: 2.4.1 + resolution: "swr@npm:2.4.1" + dependencies: + dequal: "npm:^2.0.3" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/34d61fb4653ac8875ad24e7c6da37e210b0e90fce0815dc59f013b7554a0bd267e79aac0f8ae5fbf04992e2a1815ee3da581b0dab3ed6ac4c2ce0e82b351320f + languageName: node + linkType: hard + "sylvester@npm:>= 0.0.8": version: 0.0.21 resolution: "sylvester@npm:0.0.21" @@ -43937,6 +44071,13 @@ __metadata: languageName: node linkType: hard +"throttleit@npm:2.1.0": + version: 2.1.0 + resolution: "throttleit@npm:2.1.0" + checksum: 10c0/1696ae849522cea6ba4f4f3beac1f6655d335e51b42d99215e196a718adced0069e48deaaf77f7e89f526ab31de5b5c91016027da182438e6f9280be2f3d5265 + languageName: node + linkType: hard + "through2@npm:^3.0.1": version: 3.0.2 resolution: "through2@npm:3.0.2" @@ -47259,13 +47400,20 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5": +"zod@npm:4.4.3, zod@npm:^4.0.0": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 languageName: node linkType: hard +"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.1.5": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 + languageName: node + linkType: hard + "zrender@npm:5.6.1": version: 5.6.1 resolution: "zrender@npm:5.6.1" From bd1331721423c12f9108c5c7473989c66513d49a Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 15 May 2026 19:08:39 +0100 Subject: [PATCH 02/21] track files --- apps/petrinaut-website/.env.example | 1 + apps/petrinaut-website/api/chat.ts | 291 ++++++++++++++++++ .../main/app/use-local-storage-ai-messages.ts | 17 + 3 files changed, 309 insertions(+) create mode 100644 apps/petrinaut-website/.env.example create mode 100644 apps/petrinaut-website/api/chat.ts create mode 100644 apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts diff --git a/apps/petrinaut-website/.env.example b/apps/petrinaut-website/.env.example new file mode 100644 index 00000000000..dfb9d72a1b6 --- /dev/null +++ b/apps/petrinaut-website/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=sk-xxxx diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts new file mode 100644 index 00000000000..2be705da482 --- /dev/null +++ b/apps/petrinaut-website/api/chat.ts @@ -0,0 +1,291 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { + convertToModelMessages, + createProviderRegistry, + safeValidateUIMessages, + streamText, + type ToolSet, + type UIMessage, +} from "ai"; +import { petrinautAiTools, petrinautAiPrompt } from "@hashintel/petrinaut/core"; +import { z } from "zod"; + +declare const process: { + env: Record; +}; + +const DEFAULT_MODEL = "gpt-5.5-2026-04-23"; +const MAX_REQUEST_BYTES = 1024 * 1024; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_REQUESTS = 20; + +const requestSchema = z.object({ + id: z.string().optional(), + messages: z.unknown(), +}); + +const petrinautAiValidationTools = Object.fromEntries( + Object.entries(petrinautAiTools).map(([toolName, aiTool]) => [ + toolName, + { + description: aiTool.description, + inputSchema: aiTool.inputSchema, + outputSchema: z.unknown(), + }, + ]), +) satisfies ToolSet; + +const rateLimitBuckets = new Map(); + +const getAllowedOrigins = (): Set => { + const configured = process.env.PETRINAUT_AI_ALLOWED_ORIGINS; + const origins = new Set( + configured + ?.split(",") + .map((origin) => origin.trim()) + .filter(Boolean) ?? [], + ); + + const vercelUrl = process.env.VERCEL_URL; + if (vercelUrl) { + origins.add(`https://${vercelUrl}`); + } + + return origins; +}; + +const mergeHeaders = (...headers: (HeadersInit | undefined)[]): Headers => { + const merged = new Headers(); + for (const headerSet of headers) { + if (!headerSet) { + continue; + } + new Headers(headerSet).forEach((value, key) => { + merged.set(key, value); + }); + } + return merged; +}; + +const jsonResponse = (body: unknown, init: ResponseInit = {}) => + new Response(JSON.stringify(body), { + ...init, + headers: mergeHeaders({ "content-type": "application/json" }, init.headers), + }); + +const logChatFailure = ( + reason: string, + context: Record = {}, +) => { + console.error(`[Petrinaut AI] ${reason}`, context); +}; + +const validationErrorBody = ( + error: unknown, +): { error: string; detail?: string } => + process.env.VERCEL_ENV === "production" || !(error instanceof Error) + ? { error: "Invalid chat messages" } + : { error: "Invalid chat messages", detail: error.message }; + +const corsHeaders = (request: Request): HeadersInit => { + const origin = request.headers.get("origin"); + return origin && + (process.env.VERCEL_ENV !== "production" || getAllowedOrigins().has(origin)) + ? { "access-control-allow-origin": origin, vary: "Origin" } + : { vary: "Origin" }; +}; + +const isAllowedOrigin = (request: Request): boolean => { + if (process.env.VERCEL_ENV !== "production") { + return true; + } + + const origin = request.headers.get("origin"); + return origin !== null && getAllowedOrigins().has(origin); +}; + +const getClientKey = (request: Request): string => + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + request.headers.get("user-agent") ?? + "unknown"; + +const checkRateLimit = (request: Request): boolean => { + const key = getClientKey(request); + const now = Date.now(); + const current = rateLimitBuckets.get(key); + + if (!current || current.resetAt <= now) { + rateLimitBuckets.set(key, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + return true; + } + + if (current.count >= RATE_LIMIT_MAX_REQUESTS) { + return false; + } + + current.count += 1; + return true; +}; + +export default async function handler(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: mergeHeaders(corsHeaders(request), { + "access-control-allow-headers": "content-type", + "access-control-allow-methods": "POST, OPTIONS", + }), + }); + } + + if (request.method !== "POST") { + logChatFailure("Rejected unsupported method", { + method: request.method, + }); + return jsonResponse( + { error: "Method not allowed" }, + { + headers: corsHeaders(request), + status: 405, + }, + ); + } + + if (!isAllowedOrigin(request)) { + logChatFailure("Rejected disallowed origin", { + origin: request.headers.get("origin"), + }); + return jsonResponse({ error: "Origin not allowed" }, { status: 403 }); + } + + if (!checkRateLimit(request)) { + logChatFailure("Rejected rate-limited request", { + clientKey: getClientKey(request), + }); + return jsonResponse( + { error: "Rate limit exceeded" }, + { + headers: corsHeaders(request), + status: 429, + }, + ); + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + logChatFailure("Missing OpenAI API key"); + return jsonResponse( + { error: "OPENAI_API_KEY is not configured" }, + { + headers: corsHeaders(request), + status: 500, + }, + ); + } + + const contentLength = Number(request.headers.get("content-length") ?? "0"); + if (contentLength > MAX_REQUEST_BYTES) { + logChatFailure("Rejected oversized request by content-length", { + contentLength, + maxRequestBytes: MAX_REQUEST_BYTES, + }); + return jsonResponse( + { error: "Request too large" }, + { + headers: corsHeaders(request), + status: 413, + }, + ); + } + + const rawBody = await request.text(); + const rawBodyBytes = new TextEncoder().encode(rawBody).byteLength; + if (rawBodyBytes > MAX_REQUEST_BYTES) { + logChatFailure("Rejected oversized request body", { + maxRequestBytes: MAX_REQUEST_BYTES, + rawBodyBytes, + }); + return jsonResponse( + { error: "Request too large" }, + { + headers: corsHeaders(request), + status: 413, + }, + ); + } + + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch (error) { + logChatFailure("Rejected invalid JSON", { error }); + return jsonResponse( + { error: "Invalid JSON" }, + { + headers: corsHeaders(request), + status: 400, + }, + ); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + logChatFailure("Rejected invalid chat request", { + error: parsed.error, + }); + return jsonResponse( + { error: "Invalid chat request" }, + { + headers: corsHeaders(request), + status: 400, + }, + ); + } + + const validatedMessages = await safeValidateUIMessages({ + messages: parsed.data.messages, + tools: petrinautAiValidationTools, + }); + + if (!validatedMessages.success) { + logChatFailure("Rejected invalid chat messages", { + error: validatedMessages.error, + }); + return jsonResponse(validationErrorBody(validatedMessages.error), { + headers: corsHeaders(request), + status: 400, + }); + } + + const openai = createOpenAI({ apiKey }); + const registry = createProviderRegistry({ openai }); + const modelId = process.env.PETRINAUT_AI_MODEL ?? DEFAULT_MODEL; + + const result = streamText({ + model: registry.languageModel(`openai:${modelId}`), + system: petrinautAiPrompt, + messages: await convertToModelMessages(validatedMessages.data, { + tools: petrinautAiTools, + }), + tools: petrinautAiTools, + providerOptions: { + openai: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + onError: ({ error }) => { + logChatFailure("AI stream error", { error }); + }, + }); + + return result.toUIMessageStreamResponse({ + headers: corsHeaders(request), + sendReasoning: true, + }); +} diff --git a/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts b/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts new file mode 100644 index 00000000000..ac353b30ec4 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts @@ -0,0 +1,17 @@ +import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; +import { useLocalStorage } from "@mantine/hooks"; + +const rootLocalStorageKey = "petrinaut-ai-messages"; + +type AiMessagesByNetId = Record; + +export const useLocalStorageAiMessages = () => { + const [aiMessagesByNetId, setAiMessagesByNetId] = + useLocalStorage({ + key: rootLocalStorageKey, + defaultValue: {}, + getInitialValueInEffect: false, + }); + + return { aiMessagesByNetId, setAiMessagesByNetId }; +}; From 12ce39ee69872267dfe9b4f9541ac1cae6789738 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Tue, 19 May 2026 19:17:30 +0100 Subject: [PATCH 03/21] add diagnostics feedback to petrinaut AI. file organization --- libs/@hashintel/petrinaut-core/src/ai.ts | 13 + libs/@hashintel/petrinaut-core/src/index.ts | 1 + .../petrinaut/src/ui/components/popover.tsx | 155 ++-- libs/@hashintel/petrinaut/src/ui/index.ts | 2 +- .../petrinaut/src/ui/petrinaut.stories.tsx | 2 +- .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 2 +- .../BottomBar/playback-settings-menu.tsx | 854 +++++++++++------- .../src/ui/views/Editor/editor-view.tsx | 16 +- .../panel.tsx => ai-assistant-panel.tsx} | 154 +++- .../ai-assistant-surface.stories.tsx} | 2 +- .../ai-assistant-surface.test.tsx} | 2 +- .../ai-assistant-surface.tsx} | 148 ++- ...ate-diagnostics-aware-ai-transport.test.ts | 107 +++ .../create-diagnostics-aware-ai-transport.ts | 78 ++ .../format-diagnostics-for-ai.test.ts | 124 +++ .../format-diagnostics-for-ai.ts | 97 ++ .../tool-summaries.test.ts | 0 .../tool-summaries.ts | 0 .../types.ts | 5 + ...rt.ts => create-storybook-ai-transport.ts} | 2 +- 20 files changed, 1295 insertions(+), 469 deletions(-) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant/panel.tsx => ai-assistant-panel.tsx} (62%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant/assistant-panel.stories.tsx => ai-assistant-panel/ai-assistant-surface.stories.tsx} (99%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant/assistant-surface.test.tsx => ai-assistant-panel/ai-assistant-surface.test.tsx} (99%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant/assistant-surface.tsx => ai-assistant-panel/ai-assistant-surface.tsx} (91%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant => ai-assistant-panel}/tool-summaries.test.ts (100%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant => ai-assistant-panel}/tool-summaries.ts (100%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant => ai-assistant-panel}/types.ts (82%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/{AiAssistant/storybook-ai-transport.ts => create-storybook-ai-transport.ts} (97%) diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 2e8731fbdd9..a5dedc9b3ae 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -69,14 +69,22 @@ function createToolBundle>( } export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; +export const getNetCompilationErrorsToolName = "getNetCompilationErrors"; const getLatestNetDefinitionToolInputSchema = z .strictObject({}) .describe("Get the latest complete Petrinaut SDCPN net definition."); +const getNetCompilationErrorsToolInputSchema = z + .strictObject({}) + .describe( + "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", + ); + export const petrinautAiToolInputSchemas = { ...mutationActionInputSchemas, [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, + [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, }; export const petrinautAiMutationTools = createToolBundle( @@ -89,6 +97,10 @@ export const petrinautAiTools = { description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), inputSchema: getLatestNetDefinitionToolInputSchema, }, + [getNetCompilationErrorsToolName]: { + description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), + inputSchema: getNetCompilationErrorsToolInputSchema, + }, } satisfies PetrinautAiTools; export type PetrinautAiToolName = keyof typeof petrinautAiTools; @@ -112,6 +124,7 @@ export const petrinautAiPrompt = `You are an expert assistant for building Stoch Use the provided tools to directly modify the current net. The tools use Petrinaut's raw mutation interfaces, so include stable IDs, full entity objects where required, and canvas positions for places and transitions. You can check the latest complete net definition at any point using the ${getLatestNetDefinitionToolName} tool. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types. +You can check current TypeScript compilation diagnostics at any point using the ${getNetCompilationErrorsToolName} tool. When the user's intent, requirements, constraints, or preferred modelling process are ambiguous, ask a concise follow-up question before making changes. If the request is clear, proceed with small, purposeful tool calls. diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 3c633c68fd3..67553c6b76a 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -29,6 +29,7 @@ export { createPetrinautMutationAiToolCallbacks, differentialEquationSchema, getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, metricSchema, parameterSchema, petrinautAiMutationTools, diff --git a/libs/@hashintel/petrinaut/src/ui/components/popover.tsx b/libs/@hashintel/petrinaut/src/ui/components/popover.tsx index 64b77659959..6e7a0d4b3c4 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/popover.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/popover.tsx @@ -6,63 +6,63 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { usePortalContainerRef } from "../../react/state/portal-container-context"; import { Button } from "./button"; -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; // -- Styles ------------------------------------------------------------------ const contentStyle = css({ - backgroundColor: "neutral.s25", - borderRadius: "xl", - boxShadow: "[0px 0px 0px 1px rgba(0, 0, 0, 0.08)]", - overflow: "hidden", - zIndex: "dropdown", - transformOrigin: "var(--transform-origin)", - userSelect: "none", - '&[data-state="open"]': { - animation: "popover-in 150ms ease-out", - }, - '&[data-state="closed"]': { - animation: "popover-out 100ms ease-in", - }, + backgroundColor: "neutral.s25", + borderRadius: "xl", + boxShadow: "[0px 0px 0px 1px rgba(0, 0, 0, 0.08)]", + overflow: "hidden", + zIndex: "dropdown", + transformOrigin: "var(--transform-origin)", + userSelect: "none", + '&[data-state="open"]': { + animation: "popover-in 150ms ease-out", + }, + '&[data-state="closed"]': { + animation: "popover-out 100ms ease-in", + }, }); const headerStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingX: "3", - paddingY: "2", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingX: "3", + paddingY: "2", }); const titleStyle = css({ - fontSize: "xs", - fontWeight: "medium", - color: "neutral.s100", - textTransform: "uppercase", - letterSpacing: "[0.48px]", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s100", + textTransform: "uppercase", + letterSpacing: "[0.48px]", }); const sectionStyle = css({ - paddingX: "1", - paddingBottom: "1", + paddingX: "1", + paddingBottom: "1", }); const sectionCardStyle = css({ - backgroundColor: "neutral.s00", - borderRadius: "lg", - boxShadow: - "[0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 1px -0.5px rgba(0, 0, 0, 0.04), 0px 4px 4px -12px rgba(0, 0, 0, 0.02), 0px 12px 12px -6px rgba(0, 0, 0, 0.02)]", - overflow: "hidden", - padding: "1", + backgroundColor: "neutral.s00", + borderRadius: "lg", + boxShadow: + "[0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 1px -0.5px rgba(0, 0, 0, 0.04), 0px 4px 4px -12px rgba(0, 0, 0, 0.02), 0px 12px 12px -6px rgba(0, 0, 0, 0.02)]", + overflow: "hidden", + padding: "1", }); const sectionLabelStyle = css({ - fontSize: "xs", - fontWeight: "medium", - color: "neutral.s100", - paddingX: "2", - paddingTop: "2", - paddingBottom: "1.5", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s100", + paddingX: "2", + paddingTop: "2", + paddingBottom: "1.5", }); // -- Subcomponents ----------------------------------------------------------- @@ -72,63 +72,64 @@ const sectionLabelStyle = css({ * Wraps Portal + Positioner + Content from Ark UI. */ const Content = ({ - children, - className, -}: { - children: ReactNode; - className?: string; + children, + className, + ...props +}: HTMLAttributes & { + children: ReactNode; + className?: string; }) => { - const portalContainerRef = usePortalContainerRef(); - - return ( - - - - {children} - - - - ); + const portalContainerRef = usePortalContainerRef(); + + return ( + + + + {children} + + + + ); }; /** * Popover header with a title and close button. */ const Header = ({ children }: { children: ReactNode }) => ( -
- {children} - -
+
+ {children} + +
); /** * Padded section wrapper inside popover content. */ const Section = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); /** * White card with subtle shadow, used to group related items inside a Section. */ const SectionCard = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); /** * Label for a section card. */ const SectionLabel = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); // -- Compound export --------------------------------------------------------- @@ -136,11 +137,11 @@ const SectionLabel = ({ children }: { children: ReactNode }) => ( export type PopoverRootProps = ComponentProps; export const Popover = { - Root: ArkPopover.Root, - Trigger: ArkPopover.Trigger, - Content, - Header, - Section, - SectionCard, - SectionLabel, + Root: ArkPopover.Root, + Trigger: ArkPopover.Trigger, + Content, + Header, + Section, + SectionCard, + SectionLabel, }; diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index b0c349c8b45..b54d29297fe 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -5,7 +5,7 @@ // `` (`/react`). export { Petrinaut } from "./petrinaut"; -export type { PetrinautAiMessage } from "./views/Editor/panels/AiAssistant/types"; +export type { PetrinautAiMessage } from "./views/Editor/panels/ai-assistant-panel"; export type { PetrinautAiAssistant, PetrinautAiChatTransport, diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index 6c572d52f3b..32194a64dce 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -2,7 +2,7 @@ import { sirModel } from "@hashintel/petrinaut-core/examples"; import { PetrinautStoryProvider } from "./petrinaut-story-provider"; import { Petrinaut } from "../ui/petrinaut"; -import { createStorybookAiTransport } from "./views/Editor/panels/AiAssistant/storybook-ai-transport"; +import { createStorybookAiTransport } from "./views/Editor/panels/create-storybook-ai-transport"; import { createJsonDocHandle, type SDCPN } from "../main"; import { useMemo, useState, useEffect } from "react"; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 5116b19a0ed..13a027fb6d2 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -21,7 +21,7 @@ import { EditorView } from "./views/Editor/editor-view"; import type { PetrinautAiMessage, PetrinautAiTransport, -} from "./views/Editor/panels/AiAssistant/types"; +} from "./views/Editor/panels/ai-assistant-panel"; export type PetrinautAiChatTransport = PetrinautAiTransport; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index 751a4760e8f..702a6f33cc0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -1,13 +1,13 @@ -import { use } from "react"; +import { use, useEffect } from "react"; import { Icon } from "@hashintel/ds-components"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; import { - formatPlaybackSpeed, - PLAYBACK_SPEEDS, - PlaybackContext, - type PlaybackSpeed, + formatPlaybackSpeed, + PLAYBACK_SPEEDS, + PlaybackContext, + type PlaybackSpeed, } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; import { Button } from "../../../../components/button"; @@ -16,367 +16,565 @@ import { Popover } from "../../../../components/popover"; import { ToolbarButton } from "./toolbar-button"; const contentWidthStyle = css({ - width: "[280px]", + width: "[280px]", }); +const logPlaybackControlsDebug = ({ + hypothesisId, + location, + message, + data, +}: { + hypothesisId: string; + location: string; + message: string; + data: Record; +}) => { + // #region agent log + fetch("http://127.0.0.1:7370/ingest/051d6616-30f1-4a11-bffa-57e53758d60f", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "699661", + }, + body: JSON.stringify({ + sessionId: "699661", + runId: "initial", + hypothesisId, + location, + message, + data, + timestamp: Date.now(), + }), + }).catch(() => {}); + // #endregion +}; + const menuItemStyle = cva({ - base: { - display: "flex !important", - alignItems: "center", - gap: "2", - width: "[100%]", - minWidth: "[130px]", - height: "[28px]", - paddingX: "2", - borderRadius: "lg", - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", - backgroundColor: "[transparent]", - border: "none", - cursor: "pointer", - textAlign: "left", - _hover: { - backgroundColor: "neutral.s10", - }, - }, - variants: { - selected: { - true: { - backgroundColor: "blue.s20", - _hover: { - backgroundColor: "blue.s20", - }, - }, - }, - disabled: { - true: { - opacity: "[0.4]", - cursor: "not-allowed", - _hover: { - backgroundColor: "[transparent]", - }, - }, - }, - }, + base: { + display: "flex !important", + alignItems: "center", + gap: "2", + width: "[100%]", + minWidth: "[130px]", + height: "[28px]", + paddingX: "2", + borderRadius: "lg", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", + backgroundColor: "[transparent]", + border: "none", + cursor: "pointer", + textAlign: "left", + _hover: { + backgroundColor: "neutral.s10", + }, + }, + variants: { + selected: { + true: { + backgroundColor: "blue.s20", + _hover: { + backgroundColor: "blue.s20", + }, + }, + }, + disabled: { + true: { + opacity: "[0.4]", + cursor: "not-allowed", + _hover: { + backgroundColor: "[transparent]", + }, + }, + }, + }, }); const menuItemIconStyle = css({ - fontSize: "sm", - color: "neutral.s100", - flexShrink: 0, + fontSize: "sm", + color: "neutral.s100", + flexShrink: 0, }); const menuItemTextStyle = css({ - flex: "[1]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + flex: "[1]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); const checkIconStyle = css({ - color: "blue.s50", + color: "blue.s50", }); const speedGridStyle = css({ - display: "grid", - gridTemplateColumns: "repeat(4, 1fr)", - paddingX: "2", - paddingBottom: "1", + display: "grid", + gridTemplateColumns: "repeat(4, 1fr)", + paddingX: "2", + paddingBottom: "1", }); const speedButtonStyle = cva({ - base: { - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "2", - minWidth: "0", - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", - backgroundColor: "[transparent]", - border: "none", - borderRadius: "lg", - cursor: "pointer", - _hover: { - backgroundColor: "neutral.s10", - }, - }, - variants: { - selected: { - true: { - backgroundColor: "blue.s20", - _hover: { - backgroundColor: "blue.s20", - }, - }, - }, - }, + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "2", + minWidth: "0", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", + backgroundColor: "[transparent]", + border: "none", + borderRadius: "lg", + cursor: "pointer", + _hover: { + backgroundColor: "neutral.s10", + }, + }, + variants: { + selected: { + true: { + backgroundColor: "blue.s20", + _hover: { + backgroundColor: "blue.s20", + }, + }, + }, + }, }); const popoverDividerStyle = css({ - height: "[1px]", - backgroundColor: "[transparent]", - marginTop: "1", + height: "[1px]", + backgroundColor: "[transparent]", + marginTop: "1", }); const maxTimeInputStyle = css({ - width: "[60px]", - textAlign: "right", - flexShrink: 0, - fontVariantNumeric: "tabular-nums", + width: "[60px]", + textAlign: "right", + flexShrink: 0, + fontVariantNumeric: "tabular-nums", }); // Split speeds into two rows of 4 const speedRows: PlaybackSpeed[][] = [ - PLAYBACK_SPEEDS.slice(0, 4), - PLAYBACK_SPEEDS.slice(4), + PLAYBACK_SPEEDS.slice(0, 4), + PLAYBACK_SPEEDS.slice(4), ]; export const PlaybackSettingsMenu = () => { - const { - state: simulationState, - maxTime, - setMaxTime, - } = use(SimulationContext); + const { + state: simulationState, + maxTime, + setMaxTime, + } = use(SimulationContext); + + const { + playbackSpeed, + playMode, + isViewOnlyAvailable, + isComputeAvailable, + setPlaybackSpeed, + setPlayMode, + } = use(PlaybackContext); + + const hasSimulation = simulationState !== "NotRun"; + + // Derive stopping condition from maxTime + const stoppingCondition: "indefinitely" | "fixed" = + maxTime === null ? "indefinitely" : "fixed"; + + useEffect(() => { + logPlaybackControlsDebug({ + hypothesisId: "C,E", + location: "playback-settings-menu.tsx:state-effect", + message: "Playback settings state observed", + data: { + simulationState, + maxTime, + stoppingCondition, + playbackSpeed, + playMode, + isViewOnlyAvailable, + isComputeAvailable, + }, + }); + }, [ + isComputeAvailable, + isViewOnlyAvailable, + maxTime, + playbackSpeed, + playMode, + simulationState, + stoppingCondition, + ]); + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Element | null; + const elementAtPoint = document.elementFromPoint( + event.clientX, + event.clientY, + ); + const popoverPositioner = document.querySelector( + '[data-scope="popover"][data-part="positioner"]', + ); + const popoverContent = document.querySelector( + '[data-scope="popover"][data-part="content"]', + ); + + logPlaybackControlsDebug({ + hypothesisId: "A,B,D", + location: "playback-settings-menu.tsx:document-pointerdown", + message: "Document pointerdown captured", + data: { + clientX: event.clientX, + clientY: event.clientY, + targetTag: target?.tagName, + targetText: target?.textContent?.trim().slice(0, 80), + targetAriaLabel: target?.getAttribute("aria-label"), + elementAtPointTag: elementAtPoint?.tagName, + elementAtPointText: elementAtPoint?.textContent?.trim().slice(0, 80), + elementAtPointAriaLabel: elementAtPoint?.getAttribute("aria-label"), + popoverPositionerPointerEvents: popoverPositioner + ? getComputedStyle(popoverPositioner).pointerEvents + : null, + popoverContentPointerEvents: popoverContent + ? getComputedStyle(popoverContent).pointerEvents + : null, + isInsidePopoverContent: + !!popoverContent && !!target && popoverContent.contains(target), + }, + }); + }; + + document.addEventListener("pointerdown", handlePointerDown, { + capture: true, + }); - const { - playbackSpeed, - playMode, - isViewOnlyAvailable, - isComputeAvailable, - setPlaybackSpeed, - setPlayMode, - } = use(PlaybackContext); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, { + capture: true, + }); + }; + }, []); - const hasSimulation = simulationState !== "NotRun"; + const handleStoppingConditionChange = ( + condition: "indefinitely" | "fixed", + ) => { + logPlaybackControlsDebug({ + hypothesisId: "C,E", + location: "playback-settings-menu.tsx:handleStoppingConditionChange", + message: "Stopping condition click handler invoked", + data: { + requestedCondition: condition, + hasSimulation, + currentMaxTime: maxTime, + }, + }); - // Derive stopping condition from maxTime - const stoppingCondition: "indefinitely" | "fixed" = - maxTime === null ? "indefinitely" : "fixed"; + if (condition === "indefinitely") { + setMaxTime(null); + } else { + // Set default of 10 seconds when switching to fixed time + setMaxTime(10); + } + }; - const handleStoppingConditionChange = ( - condition: "indefinitely" | "fixed", - ) => { - if (condition === "indefinitely") { - setMaxTime(null); - } else { - // Set default of 10 seconds when switching to fixed time - setMaxTime(10); - } - }; + return ( + { + logPlaybackControlsDebug({ + hypothesisId: "A,D", + location: "playback-settings-menu.tsx:popover-open-change", + message: "Playback settings popover open state changed", + data: { open: details.open }, + }); + }} + > + + + + + + + - return ( - - - - - - - - + { + const target = event.target as Element | null; - - Playback Controls + logPlaybackControlsDebug({ + hypothesisId: "A,B", + location: "playback-settings-menu.tsx:content-pointerdown", + message: "Popover content pointerdown captured", + data: { + targetTag: target?.tagName, + targetText: target?.textContent?.trim().slice(0, 80), + targetAriaLabel: target?.getAttribute("aria-label"), + }, + }); + }} + > + Playback Controls - {/* When pressing play section */} - - - When pressing play - - - -
- - + {/* When pressing play section */} + + + When pressing play + + + +
+ + - {/* Playback speed section */} - - - Playback speed - {speedRows.map((row) => ( -
- {row.map((speed) => ( - - ))} -
- ))} -
- - + {/* Playback speed section */} + + + Playback speed + {speedRows.map((row) => ( +
+ {row.map((speed) => ( + + ))} +
+ ))} +
+ + - {/* Stopping conditions section */} - - - Stopping conditions - - -
- - - - - ); + {/* Stopping conditions section */} + + + Stopping conditions + + +
+ + + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 864df14da1c..53d2f50dd0b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -1,5 +1,4 @@ import { use, useRef, useState } from "react"; -import { TbSend } from "react-icons/tb"; import { css, cx } from "@hashintel/ds-helpers/css"; import { @@ -13,7 +12,7 @@ import { import { ExperimentsContext } from "../../../react/experiments/context"; import { Box } from "../../components/box"; -import { IconButton } from "../../components/icon-button"; +import { Button } from "../../components/button"; import { Input } from "../../components/input"; import { Stack } from "../../components/stack"; import { importSDCPN } from "../../file-io/import-sdcpn"; @@ -39,7 +38,7 @@ import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; import { SimulateView } from "./panels/SimulateView/simulate-view"; -import { AiAssistantPanel } from "./panels/AiAssistant/panel"; +import { AiAssistantPanel } from "./panels/ai-assistant-panel"; import { runAutoLayout } from "./run-auto-layout"; import type { ViewportAction } from "../../types/viewport-action"; @@ -252,16 +251,17 @@ const EmptyAiHero = ({ aria-label="Describe the process you want to create" size="lg" /> - - - + iconName="arrowUp" + tooltip="Send first AI assistant message" + tooltipDisplay="inline" + />
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx similarity index 62% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index bccc6ab6235..043768a1fce 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -5,28 +5,38 @@ import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { createPetrinautMutationAiToolCallbacks, getLatestNetDefinitionToolName, - petrinautAiMutationToolInputSchemas, + getNetCompilationErrorsToolName, + mutationActionInputSchemas as petrinautAiMutationToolInputSchemas, type PetrinautAiMutationToolInput, type PetrinautAiMutationToolName, -} from "../../../../../core/ai"; -import type { Petrinaut } from "../../../../../core/instance"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; -import { PetrinautInstanceContext } from "../../../../../react/instance-context"; +} from "../../../../core/ai"; +import type { Petrinaut } from "../../../../core/instance"; +import type { SDCPN } from "../../../../core/types/sdcpn"; +import { PetrinautInstanceContext } from "../../../../react/instance-context"; +import { LanguageClientContext } from "../../../../react/lsp/context"; import { EditorContext, type EditorContextValue, -} from "../../../../../react/state/editor-context"; -import { PANEL_MARGIN } from "../../../../constants/ui"; -import type { PetrinautAiAssistant } from "../../../../petrinaut"; -import { AiAssistantSurface } from "./assistant-surface"; +} from "../../../../react/state/editor-context"; +import { SDCPNContext } from "../../../../react/state/sdcpn-context"; +import { PANEL_MARGIN } from "../../../constants/ui"; +import type { PetrinautAiAssistant } from "../../../petrinaut"; +import { AiAssistantSurface } from "./ai-assistant-panel/ai-assistant-surface"; +import { createDiagnosticsAwareAiTransport } from "./ai-assistant-panel/create-diagnostics-aware-ai-transport"; +import { formatDiagnosticsForAi } from "./ai-assistant-panel/format-diagnostics-for-ai"; import { type AiToolOutput, type AiToolCall, type AiToolTarget, summarizePetrinautAiToolCall, toPetrinautAiToolOutput, -} from "./tool-summaries"; -import type { PetrinautAiMessage } from "./types"; +} from "./ai-assistant-panel/tool-summaries"; +import type { PetrinautAiMessage } from "./ai-assistant-panel/types"; + +export type { + PetrinautAiMessage, + PetrinautAiTransport, +} from "./ai-assistant-panel/types"; const selectTarget = ( target: AiToolTarget, @@ -127,6 +137,41 @@ const safelyAddToolOutput = ( }); }; +const waitForDiagnosticsRefresh = async ({ + consumePendingMutationDiagnosticsVersion, + diagnosticsVersionRef, +}: { + consumePendingMutationDiagnosticsVersion: () => number | null; + diagnosticsVersionRef: { current: number }; +}) => { + const pendingVersion = consumePendingMutationDiagnosticsVersion(); + + if ( + pendingVersion === null || + diagnosticsVersionRef.current > pendingVersion + ) { + return; + } + + await new Promise((resolve) => { + const timeoutAt = Date.now() + 1_000; + + const check = () => { + if ( + diagnosticsVersionRef.current > pendingVersion || + Date.now() >= timeoutAt + ) { + resolve(); + return; + } + + setTimeout(check, 25); + }; + + check(); + }); +}; + const applyPetrinautAiTool = ({ definition, input, @@ -160,6 +205,7 @@ export const AiAssistantPanel = ({ onInitialMessageConsumed?: () => void; }) => { const instance = use(PetrinautInstanceContext); + const { diagnosticsByUri } = use(LanguageClientContext); const { hasSelection, isAiAssistantOpen, @@ -170,8 +216,67 @@ export const AiAssistantPanel = ({ setSimulateDrawer, setSimulateViewMode, } = use(EditorContext); + const { petriNetDefinition } = use(SDCPNContext); const [input, setInput] = useState(""); const submittedInitialMessageRef = useRef(null); + const diagnosticsContextRef = useRef("No current TypeScript diagnostics."); + const diagnosticsVersionRef = useRef(0); + const pendingMutationDiagnosticsVersionRef = useRef(null); + + useEffect(() => { + diagnosticsVersionRef.current += 1; + }, [diagnosticsByUri]); + + useEffect(() => { + diagnosticsContextRef.current = formatDiagnosticsForAi({ + definition: petriNetDefinition, + diagnosticsByUri, + }); + }, [diagnosticsByUri, petriNetDefinition]); + + const [diagnosticsTransportState, setDiagnosticsTransportState] = useState( + () => ({ + source: aiAssistant.transport, + transport: createDiagnosticsAwareAiTransport({ + getDiagnosticsContext: () => diagnosticsContextRef.current, + transport: aiAssistant.transport, + waitForDiagnosticsRefresh: () => + waitForDiagnosticsRefresh({ + consumePendingMutationDiagnosticsVersion: () => { + const pendingVersion = + pendingMutationDiagnosticsVersionRef.current; + pendingMutationDiagnosticsVersionRef.current = null; + return pendingVersion; + }, + diagnosticsVersionRef, + }), + }), + }), + ); + + useEffect(() => { + if (diagnosticsTransportState.source === aiAssistant.transport) { + return; + } + + setDiagnosticsTransportState({ + source: aiAssistant.transport, + transport: createDiagnosticsAwareAiTransport({ + getDiagnosticsContext: () => diagnosticsContextRef.current, + transport: aiAssistant.transport, + waitForDiagnosticsRefresh: () => + waitForDiagnosticsRefresh({ + consumePendingMutationDiagnosticsVersion: () => { + const pendingVersion = + pendingMutationDiagnosticsVersionRef.current; + pendingMutationDiagnosticsVersionRef.current = null; + return pendingVersion; + }, + diagnosticsVersionRef, + }), + }), + }); + }, [aiAssistant.transport, diagnosticsTransportState.source]); const { error, @@ -183,7 +288,7 @@ export const AiAssistantPanel = ({ stop, } = useChat({ messages: aiAssistant.messages, - transport: aiAssistant.transport, + transport: diagnosticsTransportState.transport, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, onFinish: ({ messages: finishedMessages }) => { aiAssistant.onMessages?.(finishedMessages); @@ -206,6 +311,23 @@ export const AiAssistantPanel = ({ }); return; } + if (toolCall.toolName === getNetCompilationErrorsToolName) { + await waitForDiagnosticsRefresh({ + consumePendingMutationDiagnosticsVersion: () => { + const pendingVersion = + pendingMutationDiagnosticsVersionRef.current; + pendingMutationDiagnosticsVersionRef.current = null; + return pendingVersion; + }, + diagnosticsVersionRef, + }); + safelyAddToolOutput(addToolOutput, { + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: diagnosticsContextRef.current, + }); + return; + } if (!isPetrinautAiMutationToolName(toolCall.toolName)) { throw new Error(`Unknown Petrinaut AI tool: ${toolCall.toolName}`); @@ -214,6 +336,8 @@ export const AiAssistantPanel = ({ const toolInput = petrinautAiMutationToolInputSchemas[ toolCall.toolName ].parse(toolCall.input); + pendingMutationDiagnosticsVersionRef.current = + diagnosticsVersionRef.current; const output = applyPetrinautAiTool({ definition: instance.definition.get(), input: toolInput, @@ -226,13 +350,13 @@ export const AiAssistantPanel = ({ toolCallId: toolCall.toolCallId, output, }); - } catch (error) { + } catch (toolError) { logToolCallError({ - error, + error: toolError, input: toolCall.input, toolName: toolCall.toolName, }); - throw error; + throw toolError; } }, }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx similarity index 99% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx index c67a4610397..5c3cb71658f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-panel.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { AiAssistantSurface } from "./assistant-surface"; +import { AiAssistantSurface } from "./ai-assistant-surface"; import type { PetrinautAiMessage } from "./types"; const meta = { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx similarity index 99% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx index 8e768c2452b..85f3b7f4c30 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx @@ -11,7 +11,7 @@ import { import { afterEach, describe, expect, test, vi } from "vitest"; import type { SDCPN } from "../../../../../core/types/sdcpn"; -import { AiAssistantSurface } from "./assistant-surface"; +import { AiAssistantSurface } from "./ai-assistant-surface"; import type { PetrinautAiMessage } from "./types"; const noop = () => {}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx similarity index 91% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx index cb7a6cab057..f042f9e0f77 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/assistant-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx @@ -1,5 +1,5 @@ import { Collapsible } from "@ark-ui/react/collapsible"; -import { css, cva, cx } from "@hashintel/ds-helpers/css"; +import { css, cva } from "@hashintel/ds-helpers/css"; import { type PointerEvent as ReactPointerEvent, useEffect, @@ -7,22 +7,14 @@ import { useState, } from "react"; import ReactMarkdown from "react-markdown"; -import { - TbCheck, - TbChevronUp, - TbList, - TbLoader2, - TbPlayerStopFilled, - TbSend, - TbTrash, - TbX, -} from "react-icons/tb"; +import { TbCheck, TbChevronUp, TbList, TbLoader2, TbX } from "react-icons/tb"; import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; -import { IconButton } from "../../../../components/icon-button"; +import { Button } from "../../../../components/button"; import { Input } from "../../../../components/input"; import { getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, petrinautAiMutationTools, } from "../../../../../core/ai"; import type { SelectionItem } from "../../../../../core/types/selection"; @@ -261,24 +253,96 @@ const messageStyle = cva({ }); const markdownStyle = css({ + overflowWrap: "anywhere", + wordBreak: "break-word", + "& > :first-child": { + marginTop: "[0]", + }, + "& > :last-child": { + marginBottom: "[0]", + }, "& p": { - margin: "[0]", + marginY: "2", + }, + "& h1, & h2, & h3, & h4, & h5, & h6": { + marginTop: "3", + marginBottom: "1", + fontWeight: "semibold", + lineHeight: "[1.25]", + color: "neutral.s110", + }, + "& h1": { + fontSize: "[15px]", + }, + "& h2, & h3": { + fontSize: "sm", }, - "& p + p": { - marginTop: "2", + "& h4, & h5, & h6": { + fontSize: "xs", }, "& ul, & ol": { - marginTop: "1", - marginBottom: "1", + marginY: "2", paddingLeft: "5", }, - "& code": { + "& ul": { + listStyleType: "disc", + }, + "& ol": { + listStyleType: "decimal", + }, + "& li": { + marginY: "1", + }, + "& li > p": { + marginY: "1", + }, + "& a": { + color: "blue.s90", + textDecorationLine: "underline", + textUnderlineOffset: "[2px]", + }, + "& blockquote": { + marginY: "2", + marginX: "[0]", + borderLeftWidth: "[3px]", + borderLeftStyle: "solid", + borderLeftColor: "neutral.a40", + paddingLeft: "3", + color: "neutral.s90", + }, + "& pre": { + marginY: "2", + overflowX: "auto", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "md", + backgroundColor: "neutral.s20", + padding: "2", + }, + "& :not(pre) > code": { fontFamily: "mono", fontSize: "xs", backgroundColor: "neutral.s20", borderRadius: "sm", paddingX: "1", }, + "& pre code": { + display: "block", + minWidth: "[max-content]", + backgroundColor: "[transparent]", + padding: "[0]", + fontFamily: "mono", + fontSize: "xs", + lineHeight: "[1.5]", + }, + "& hr": { + marginY: "3", + borderWidth: "[0]", + borderTopWidth: "thin", + borderTopStyle: "solid", + borderTopColor: "neutral.a30", + }, }); const reasoningGroupStyle = css({ @@ -678,6 +742,9 @@ const getToolSummaryFromPart = (part: RenderableToolPart): AiToolSummary => { if (toolName === getLatestNetDefinitionToolName) { return { title: "Checked latest net definition" }; } + if (toolName === getNetCompilationErrorsToolName) { + return { title: "Checked net compilation errors" }; + } const output = part.output; if (typeof output === "object" && output !== null) { @@ -730,7 +797,10 @@ const getToolTone = ({ return "danger"; } - if (toolName === getLatestNetDefinitionToolName) { + if ( + toolName === getLatestNetDefinitionToolName || + toolName === getNetCompilationErrorsToolName + ) { return "neutral"; } @@ -808,7 +878,10 @@ const getMessageRenderItems = ( if (isToolPart(part)) { const tool = toToolRenderItem(message, part); - if (tool.toolName === getLatestNetDefinitionToolName) { + if ( + tool.toolName === getLatestNetDefinitionToolName || + tool.toolName === getNetCompilationErrorsToolName + ) { flushTools(); pendingTools.push(tool); flushTools(); @@ -914,7 +987,9 @@ const ReasoningPart = ({
{renderedText ? ( - {renderedText} +
+ {renderedText} +
) : (
Petrinaut AI
- - - - +
@@ -1247,11 +1324,11 @@ export const AiAssistantSurface = ({ } aria-label="Message Petrinaut AI" /> - { @@ -1259,9 +1336,10 @@ export const AiAssistantSurface = ({ onStop(); } }} - > - {isBusy ? : } - + iconName={isBusy ? "stopFilled" : "arrowUp"} + tooltip={isBusy ? "Stop AI response" : "Send message"} + tooltipDisplay="inline" + />
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts new file mode 100644 index 00000000000..b6d64dd350c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts @@ -0,0 +1,107 @@ +import type { UIMessageChunk } from "ai"; +import { describe, expect, test, vi } from "vitest"; + +import { createDiagnosticsAwareAiTransport } from "./create-diagnostics-aware-ai-transport"; +import type { PetrinautAiMessage, PetrinautAiTransport } from "./types"; + +const emptyStream = (): ReadableStream => + new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + +const createFakeTransport = () => { + const sendMessages = vi.fn(() => + Promise.resolve(emptyStream()), + ); + + return { + sendMessages, + transport: { + reconnectToStream: () => Promise.resolve(null), + sendMessages, + } satisfies PetrinautAiTransport, + }; +}; + +const sendOptions = (messages: PetrinautAiMessage[]) => + ({ + abortSignal: undefined, + chatId: "chat-1", + messageId: undefined, + messages, + trigger: "submit-message", + }) satisfies Parameters[0]; + +describe("createDiagnosticsAwareAiTransport", () => { + test("adds transient diagnostics context to completed tool-result sends", async () => { + const { sendMessages, transport } = createFakeTransport(); + const waitForDiagnosticsRefresh = vi.fn(() => Promise.resolve()); + const wrapped = createDiagnosticsAwareAiTransport({ + getDiagnosticsContext: () => + "Current TypeScript diagnostics (1 issue):\n- Transition: Infect lambda: error TS2304 at Ln 1, Col 1: Cannot find name 'x'.", + transport, + waitForDiagnosticsRefresh, + }); + + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-updateTransition", + state: "output-available", + toolCallId: "tool-1", + input: {}, + output: { applied: true, title: "Updated transition Infect" }, + }, + ], + } as PetrinautAiMessage, + ]; + + await wrapped.sendMessages(sendOptions(messages)); + + expect(waitForDiagnosticsRefresh).toHaveBeenCalledOnce(); + expect(sendMessages).toHaveBeenCalledOnce(); + + const sentMessages = sendMessages.mock.calls[0]![0].messages; + expect(sentMessages).toHaveLength(2); + expect(sentMessages[0]).toBe(messages[0]); + expect(sentMessages[1]?.id).toBe("petrinaut-diagnostics-context"); + expect(sentMessages[1]?.role).toBe("user"); + + const diagnosticsPart = sentMessages[1]?.parts[0]; + expect(diagnosticsPart?.type).toBe("text"); + if (diagnosticsPart?.type !== "text") { + throw new Error("Expected diagnostics context to be a text part."); + } + expect(diagnosticsPart.text).toContain( + "Petrinaut diagnostics context only", + ); + expect(diagnosticsPart.text).toContain("Current TypeScript diagnostics"); + }); + + test("delegates ordinary user-message sends unchanged", async () => { + const { sendMessages, transport } = createFakeTransport(); + const waitForDiagnosticsRefresh = vi.fn(() => Promise.resolve()); + const wrapped = createDiagnosticsAwareAiTransport({ + getDiagnosticsContext: () => "No current TypeScript diagnostics.", + transport, + waitForDiagnosticsRefresh, + }); + const messages: PetrinautAiMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Explain this net." }], + }, + ]; + + await wrapped.sendMessages(sendOptions(messages)); + + expect(waitForDiagnosticsRefresh).not.toHaveBeenCalled(); + expect(sendMessages.mock.calls[0]![0].messages).toBe(messages); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts new file mode 100644 index 00000000000..0f6f88e9a6c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts @@ -0,0 +1,78 @@ +import type { ChatTransport } from "ai"; + +import type { PetrinautAiMessage, PetrinautAiTransport } from "./types"; + +const diagnosticsContextMessageId = "petrinaut-diagnostics-context"; + +const createDiagnosticsContextMessage = ( + diagnosticsContext: string, +): PetrinautAiMessage => + ({ + id: diagnosticsContextMessageId, + role: "user", + parts: [ + { + type: "text", + text: [ + "Petrinaut diagnostics context only; this is not a user request.", + "The following TypeScript diagnostics reflect the current Petrinaut model after client-side tool execution.", + "Use them to decide whether more tool calls are needed before replying to the user.", + "", + diagnosticsContext, + ].join("\n"), + }, + ], + }) as PetrinautAiMessage; + +const lastMessageIsCompleteToolResultMessage = ( + messages: PetrinautAiMessage[], +) => { + const lastMessage = messages.at(-1); + if (!lastMessage || lastMessage.role !== "assistant") { + return false; + } + + const toolParts = lastMessage.parts.filter((part) => + part.type.startsWith("tool-"), + ); + + return ( + toolParts.length > 0 && + toolParts.every( + (part) => + "state" in part && + (part.state === "output-available" || part.state === "output-error"), + ) + ); +}; + +export const createDiagnosticsAwareAiTransport = ({ + getDiagnosticsContext, + transport, + waitForDiagnosticsRefresh, +}: { + getDiagnosticsContext: () => string; + transport: PetrinautAiTransport; + waitForDiagnosticsRefresh: () => Promise; +}): PetrinautAiTransport => { + const wrappedTransport: ChatTransport = { + reconnectToStream: (options) => transport.reconnectToStream(options), + sendMessages: async (options) => { + if (!lastMessageIsCompleteToolResultMessage(options.messages)) { + return transport.sendMessages(options); + } + + await waitForDiagnosticsRefresh(); + + return transport.sendMessages({ + ...options, + messages: [ + ...options.messages, + createDiagnosticsContextMessage(getDiagnosticsContext()), + ], + }); + }, + }; + + return wrappedTransport; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts new file mode 100644 index 00000000000..a51db98e4ab --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts @@ -0,0 +1,124 @@ +import { + type Diagnostic, + DiagnosticSeverity, +} from "vscode-languageserver-types"; +import { describe, expect, test } from "vitest"; + +import type { SDCPN } from "../../../../../core/types/sdcpn"; +import { formatDiagnosticsForAi } from "./format-diagnostics-for-ai"; + +const definition: SDCPN = { + places: [], + transitions: [ + { + id: "transition__infect", + name: "Infect", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [ + { + id: "de__viral_load", + name: "Viral load", + colorId: null, + code: "", + }, + ], + parameters: [], +}; + +const diagnostic = ( + message: string, + overrides: Partial = {}, +): Diagnostic => ({ + message, + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 8 }, + }, + severity: DiagnosticSeverity.Error, + source: "ts", + ...overrides, +}); + +describe("formatDiagnosticsForAi", () => { + test("reports an empty diagnostics state", () => { + expect( + formatDiagnosticsForAi({ + definition, + diagnosticsByUri: new Map(), + }), + ).toBe("No current TypeScript diagnostics."); + }); + + test("formats transition and differential-equation diagnostics", () => { + const diagnosticsByUri = new Map([ + [ + "inmemory://sdcpn/transitions/transition__infect/lambda.ts", + [ + diagnostic("Cannot find name 'infected'.", { + code: 2304, + range: { + start: { line: 3, character: 10 }, + end: { line: 3, character: 18 }, + }, + }), + ], + ], + [ + "inmemory://sdcpn/transitions/transition__infect/kernel.ts", + [ + diagnostic("Type 'string' is not assignable to type 'number'.", { + code: 2322, + severity: DiagnosticSeverity.Warning, + }), + ], + ], + [ + "inmemory://sdcpn/differential-equations/de__viral_load.ts", + [ + diagnostic("Declaration or statement expected.", { + code: 1128, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + }), + ], + ], + ]); + + expect(formatDiagnosticsForAi({ definition, diagnosticsByUri })).toBe( + [ + "Current TypeScript diagnostics (3 issues):", + "- Transition: Infect lambda: error TS2304 at Ln 4, Col 11: Cannot find name 'infected'.", + "- Transition: Infect kernel: warning TS2322 at Ln 2, Col 3: Type 'string' is not assignable to type 'number'.", + "- Differential Equation: Viral load: error TS1128 at Ln 1, Col 1: Declaration or statement expected.", + ].join("\n"), + ); + }); + + test("limits long diagnostics lists", () => { + const diagnosticsByUri = new Map([ + [ + "inmemory://sdcpn/transitions/transition__infect/lambda.ts", + [diagnostic("First"), diagnostic("Second"), diagnostic("Third")], + ], + ]); + + expect( + formatDiagnosticsForAi({ + definition, + diagnosticsByUri, + maxDiagnostics: 2, + }), + ).toContain("... 1 additional diagnostic omitted."); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts new file mode 100644 index 00000000000..e89b5f02b1b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts @@ -0,0 +1,97 @@ +import type { Diagnostic, DocumentUri } from "vscode-languageserver-types"; +import { DiagnosticSeverity } from "vscode-languageserver-types"; + +import { parseDocumentUri } from "../../../../../core/lsp/lib/document-uris"; +import type { SDCPN } from "../../../../../core/types/sdcpn"; + +const DEFAULT_MAX_DIAGNOSTICS = 25; + +const diagnosticSeverityLabel = ( + severity: DiagnosticSeverity | undefined, +): string => { + switch (severity) { + case DiagnosticSeverity.Error: + return "error"; + case DiagnosticSeverity.Warning: + return "warning"; + case DiagnosticSeverity.Information: + return "info"; + case DiagnosticSeverity.Hint: + return "hint"; + default: + return "error"; + } +}; + +const getDiagnosticEntityLabel = (uri: DocumentUri, definition: SDCPN) => { + const parsed = parseDocumentUri(uri); + if (!parsed) { + return `Document: ${uri}`; + } + + if (parsed.itemType === "differential-equation") { + const differentialEquation = definition.differentialEquations.find( + (item) => item.id === parsed.itemId, + ); + + return `Differential Equation: ${differentialEquation?.name ?? parsed.itemId}`; + } + + const transition = definition.transitions.find( + (item) => item.id === parsed.itemId, + ); + const transitionName = transition?.name ?? parsed.itemId; + const transitionPart = + parsed.itemType === "transition-lambda" ? "lambda" : "kernel"; + + return `Transition: ${transitionName} ${transitionPart}`; +}; + +export const formatDiagnosticsForAi = ({ + definition, + diagnosticsByUri, + maxDiagnostics = DEFAULT_MAX_DIAGNOSTICS, +}: { + definition: SDCPN; + diagnosticsByUri: Map; + maxDiagnostics?: number; +}): string => { + const diagnostics = Array.from(diagnosticsByUri.entries()).flatMap( + ([uri, uriDiagnostics]) => + uriDiagnostics.map((diagnostic) => ({ diagnostic, uri })), + ); + + if (diagnostics.length === 0) { + return "No errors detected in your model – everything compiles!"; + } + + const shownDiagnostics = diagnostics.slice(0, maxDiagnostics); + const lines = [ + `Current TypeScript diagnostics (${diagnostics.length} issue${ + diagnostics.length === 1 ? "" : "s" + }):`, + ]; + + for (const { diagnostic, uri } of shownDiagnostics) { + const code = diagnostic.code == null ? "" : ` TS${diagnostic.code}`; + const line = diagnostic.range.start.line + 1; + const column = diagnostic.range.start.character + 1; + + lines.push( + `- ${getDiagnosticEntityLabel(uri, definition)}: ${diagnosticSeverityLabel( + diagnostic.severity, + )}${code} at Ln ${line}, Col ${column}: ${diagnostic.message}`, + ); + } + + const omittedDiagnosticsCount = diagnostics.length - shownDiagnostics.length; + if (omittedDiagnosticsCount > 0) { + lines.push( + `... ${omittedDiagnosticsCount} additional diagnostic${ + omittedDiagnosticsCount === 1 ? "" : "s" + } omitted.`, + ); + } + + return lines.join("\n"); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.test.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/tool-summaries.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts similarity index 82% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts index cc3f678e172..d1aee0b546c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -2,6 +2,7 @@ import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; import type { getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, PetrinautAiMutationToolInput, PetrinautAiMutationToolName, PetrinautAiToolInput, @@ -20,6 +21,10 @@ type PetrinautAiUiTools = { input: PetrinautAiToolInput; output: SDCPN; }; + [getNetCompilationErrorsToolName]: { + input: PetrinautAiToolInput; + output: string; + }; }; export type PetrinautAiMessage = UIMessage< diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts similarity index 97% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts index fe9d84383e0..4bb573943bc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/AiAssistant/storybook-ai-transport.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts @@ -1,6 +1,6 @@ import type { ChatTransport, UIMessageChunk } from "ai"; -import type { PetrinautAiMessage } from "./types"; +import type { PetrinautAiMessage } from "./ai-assistant-panel"; const placeInput = { id: "place__ai_buffer", From eae70aa6427a29db87dd8d20e9d0c5be1dcad07e Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 21 May 2026 17:53:21 +0100 Subject: [PATCH 04/21] simplify petrinaut-website API handling --- apps/mcp/linear/package.json | 2 +- apps/mcp/notion/package.json | 2 +- apps/petrinaut-website/README.md | 92 +++ apps/petrinaut-website/api/chat.ts | 212 ++----- apps/petrinaut-website/package.json | 14 +- apps/petrinaut-website/vercel.json | 7 +- apps/petrinaut-website/vite.config.ts | 160 +---- libs/@hashintel/ds-components/package.json | 2 +- libs/@local/repo-chores/node/package.json | 2 +- yarn.lock | 672 ++++++++++++++++++++- 10 files changed, 842 insertions(+), 323 deletions(-) create mode 100644 apps/petrinaut-website/README.md diff --git a/apps/mcp/linear/package.json b/apps/mcp/linear/package.json index f4d219a8a4d..1f4dc74a2e3 100644 --- a/apps/mcp/linear/package.json +++ b/apps/mcp/linear/package.json @@ -23,7 +23,7 @@ "@local/tsconfig": "workspace:*", "@modelcontextprotocol/sdk": "1.26.0", "dotenv-flow": "3.3.0", - "zod": "4.1.12", + "zod": "4.4.3", "zod-to-json-schema": "3.24.6" }, "devDependencies": { diff --git a/apps/mcp/notion/package.json b/apps/mcp/notion/package.json index dbb5256ab0f..20c2aa01313 100644 --- a/apps/mcp/notion/package.json +++ b/apps/mcp/notion/package.json @@ -23,7 +23,7 @@ "@notionhq/client": "5.3.0", "dotenv-flow": "3.3.0", "notion-to-md": "3.1.9", - "zod": "4.1.12", + "zod": "4.4.3", "zod-to-json-schema": "3.24.6" }, "devDependencies": { diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md new file mode 100644 index 00000000000..71913888b82 --- /dev/null +++ b/apps/petrinaut-website/README.md @@ -0,0 +1,92 @@ +# Petrinaut Website + +A website for demoing Petrinaut (libs/@hashintel/petrinaut). + +A SPA plus a single API function that proxies AI requests to OpenAI. + +## Quickstart + +```sh +cp .env.example .env.local +# add your OPENAI_API_KEY to .env.local, if you want to use the chat feature + +turbo run dev +``` + +The dev server runs at [http://localhost:5173](http://localhost:5173). A plugin in `vite.config.ts` loads the API function. + +In production, the function in the `api` folder is automatically deployed as a Vercel Serverless Function. + +## Environment variables + +| Name | Required | Used by | Notes | +| ------------------- | ------------------ | --------------- | ---------------------------------------------------- | +| `OPENAI_API_KEY` | for chat to work | `api/chat.ts` | OpenAI key the function uses to call `streamText`. | +| `PETRINAUT_AI_MODEL`| no | `api/chat.ts` | Overrides the default OpenAI model id. | +| `SENTRY_DSN` | no | `vite.config.ts`| Wired into the bundle via `__SENTRY_DSN__` at build time. | + +Local values live in `.env.local`; Vite's `loadEnv` (see [`vite.config.ts`](vite.config.ts)) copies them into `process.env` for both the dev server and the chat function. In production, set these in the Vercel project settings. + +## Testing the API against the built output + +A plain `yarn build && yarn vite preview` only serves the static `dist/` assets - `/api/chat` will 404 because the dev plugin is not loaded by `vite preview`. Use one of the options below to exercise the production code path locally. + +### Option A: `vercel dev` (recommended) + +Closest to the real Vercel runtime. It builds the site, bundles the function, and serves both from a single port using the actual Node runtime + routing layer. + +Requires linking to a Vercel project. If you don't have access, go for Option B (or just use `turbo run dev` instead). + +```sh +cd apps/petrinaut-website + +# first-time setup +npx vercel link +npx vercel env pull # writes the project's env vars into .env.local + +npx vercel dev # builds + serves on http://localhost:3000 +``` + +Notes: + +- `vercel dev` runs the commands in [`vercel.json`](vercel.json), including [`vercel-build.sh`](vercel-build.sh). That script deletes the repo-root `.env` to work around mise picking it up - so do not keep anything you cannot regenerate there before running this locally. +- `vercel dev` does not read your existing `dist/`; it rebuilds. If you specifically need to inspect the artifact you already produced, use option B. + +### Option B: `vite preview` + a sibling Node API server + +Useful when you want to serve the literal `dist/` artifact you just built and avoid the Vercel CLI. It is two processes, glued together by `preview.proxy`. + +1. Add a proxy entry to `vite.config.ts` (only needed while you are testing this flow): + + ```ts + preview: { + proxy: { "/api": "http://localhost:3001" }, + }, + ``` + +2. Create a throwaway `scripts/preview-api.mjs` that mounts the same handler with `createServerAdapter`: + + ```js + import { createServer } from "node:http"; + import { createServerAdapter } from "@whatwg-node/server"; + import handler from "../api/chat.ts"; + + createServer(createServerAdapter(handler)).listen(3001, () => { + console.log("preview API listening on http://localhost:3001"); + }); + ``` + +3. Run them side by side (Node 22.6+ can execute the TypeScript entry directly with `--experimental-strip-types`): + + ```sh + yarn build + yarn vite preview # :4173 + node --experimental-strip-types scripts/preview-api.mjs # :3001 + ``` + +`/api/chat` requests against `:4173` will be proxied to the local API server, which loads the same handler the deployed function uses. + +## Known caveats + +- **In-memory rate limiting.** [`api/chat.ts`](api/chat.ts) keys rate-limit buckets by the client IP that Vercel's edge writes into `x-forwarded-for` (which Vercel actively prevents the caller from spoofing - see the [request headers docs](https://vercel.com/docs/edge-network/headers/request-headers)). The bucket map lives in module scope, so it resets on cold start and is not shared between concurrent function instances. +- **`vercel-build.sh` deletes the repo-root `.env`.** This is intentional (mise picks it up otherwise), but worth knowing if you run `vercel dev` locally and keep secrets there. diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts index 2be705da482..8e1d2386a3a 100644 --- a/apps/petrinaut-website/api/chat.ts +++ b/apps/petrinaut-website/api/chat.ts @@ -15,9 +15,9 @@ declare const process: { }; const DEFAULT_MODEL = "gpt-5.5-2026-04-23"; -const MAX_REQUEST_BYTES = 1024 * 1024; const RATE_LIMIT_WINDOW_MS = 60_000; const RATE_LIMIT_MAX_REQUESTS = 20; +const RATE_LIMIT_MAX_TRACKED_CLIENTS = 10_000; const requestSchema = z.object({ id: z.string().optional(), @@ -37,42 +37,12 @@ const petrinautAiValidationTools = Object.fromEntries( const rateLimitBuckets = new Map(); -const getAllowedOrigins = (): Set => { - const configured = process.env.PETRINAUT_AI_ALLOWED_ORIGINS; - const origins = new Set( - configured - ?.split(",") - .map((origin) => origin.trim()) - .filter(Boolean) ?? [], - ); - - const vercelUrl = process.env.VERCEL_URL; - if (vercelUrl) { - origins.add(`https://${vercelUrl}`); - } - - return origins; +const jsonResponse = (body: unknown, init: ResponseInit = {}) => { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json"); + return new Response(JSON.stringify(body), { ...init, headers }); }; -const mergeHeaders = (...headers: (HeadersInit | undefined)[]): Headers => { - const merged = new Headers(); - for (const headerSet of headers) { - if (!headerSet) { - continue; - } - new Headers(headerSet).forEach((value, key) => { - merged.set(key, value); - }); - } - return merged; -}; - -const jsonResponse = (body: unknown, init: ResponseInit = {}) => - new Response(JSON.stringify(body), { - ...init, - headers: mergeHeaders({ "content-type": "application/json" }, init.headers), - }); - const logChatFailure = ( reason: string, context: Record = {}, @@ -87,36 +57,43 @@ const validationErrorBody = ( ? { error: "Invalid chat messages" } : { error: "Invalid chat messages", detail: error.message }; -const corsHeaders = (request: Request): HeadersInit => { - const origin = request.headers.get("origin"); - return origin && - (process.env.VERCEL_ENV !== "production" || getAllowedOrigins().has(origin)) - ? { "access-control-allow-origin": origin, vary: "Origin" } - : { vary: "Origin" }; -}; - -const isAllowedOrigin = (request: Request): boolean => { - if (process.env.VERCEL_ENV !== "production") { - return true; +/** + * Resolve the public client IP for rate-limiting. + * + * Vercel's edge overwrites `x-forwarded-for` with the real client IP and + * refuses to forward externally-set values, so the header cannot be spoofed + * by the caller. `x-vercel-forwarded-for` carries the same value but is also + * immune to a custom proxy placed in front of Vercel. + * + * See https://vercel.com/docs/edge-network/headers/request-headers + */ +const resolveClientIp = (request: Request): string | null => { + const forwardedFor = request.headers.get("x-forwarded-for"); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) { + return first; + } } - - const origin = request.headers.get("origin"); - return origin !== null && getAllowedOrigins().has(origin); + return request.headers.get("x-vercel-forwarded-for"); }; -const getClientKey = (request: Request): string => - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - request.headers.get("x-real-ip") ?? - request.headers.get("user-agent") ?? - "unknown"; - -const checkRateLimit = (request: Request): boolean => { - const key = getClientKey(request); +const checkRateLimit = (clientIp: string): boolean => { const now = Date.now(); - const current = rateLimitBuckets.get(key); + const current = rateLimitBuckets.get(clientIp); if (!current || current.resetAt <= now) { - rateLimitBuckets.set(key, { + // The bucket map only grows; on a warm function instance with many unique + // clients it would accumulate indefinitely. When we cross the cap, drop + // every expired bucket in one sweep before inserting the new one. + if (rateLimitBuckets.size >= RATE_LIMIT_MAX_TRACKED_CLIENTS) { + for (const [key, bucket] of rateLimitBuckets) { + if (bucket.resetAt <= now) { + rateLimitBuckets.delete(key); + } + } + } + rateLimitBuckets.set(clientIp, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS, }); @@ -131,119 +108,58 @@ const checkRateLimit = (request: Request): boolean => { return true; }; +/** + * API endpoint to proxy requests for AI assistance to OpenAI. + */ export default async function handler(request: Request): Promise { if (request.method === "OPTIONS") { - return new Response(null, { - status: 204, - headers: mergeHeaders(corsHeaders(request), { - "access-control-allow-headers": "content-type", - "access-control-allow-methods": "POST, OPTIONS", - }), - }); + // We'll always serve this same-origin so we don't need any CORS config + return new Response(null, { status: 204 }); } if (request.method !== "POST") { - logChatFailure("Rejected unsupported method", { - method: request.method, - }); - return jsonResponse( - { error: "Method not allowed" }, - { - headers: corsHeaders(request), - status: 405, - }, - ); - } - - if (!isAllowedOrigin(request)) { - logChatFailure("Rejected disallowed origin", { - origin: request.headers.get("origin"), - }); - return jsonResponse({ error: "Origin not allowed" }, { status: 403 }); + logChatFailure("Rejected unsupported method", { method: request.method }); + return jsonResponse({ error: "Method not allowed" }, { status: 405 }); } - if (!checkRateLimit(request)) { - logChatFailure("Rejected rate-limited request", { - clientKey: getClientKey(request), - }); + const clientIp = resolveClientIp(request); + if (process.env.VERCEL_ENV === "production" && !clientIp) { + // Vercel's edge always sets x-forwarded-for in production. If it isn't + // present, the request reached us through an unexpected path and we have + // no way to rate-limit it - reject conservatively rather than fail open. + logChatFailure("Rejected production request with no resolvable client IP"); return jsonResponse( - { error: "Rate limit exceeded" }, - { - headers: corsHeaders(request), - status: 429, - }, + { error: "Could not determine client IP" }, + { status: 400 }, ); } + if (clientIp && !checkRateLimit(clientIp)) { + logChatFailure("Rejected rate-limited request", { clientIp }); + return jsonResponse({ error: "Rate limit exceeded" }, { status: 429 }); + } + const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { logChatFailure("Missing OpenAI API key"); return jsonResponse( { error: "OPENAI_API_KEY is not configured" }, - { - headers: corsHeaders(request), - status: 500, - }, - ); - } - - const contentLength = Number(request.headers.get("content-length") ?? "0"); - if (contentLength > MAX_REQUEST_BYTES) { - logChatFailure("Rejected oversized request by content-length", { - contentLength, - maxRequestBytes: MAX_REQUEST_BYTES, - }); - return jsonResponse( - { error: "Request too large" }, - { - headers: corsHeaders(request), - status: 413, - }, - ); - } - - const rawBody = await request.text(); - const rawBodyBytes = new TextEncoder().encode(rawBody).byteLength; - if (rawBodyBytes > MAX_REQUEST_BYTES) { - logChatFailure("Rejected oversized request body", { - maxRequestBytes: MAX_REQUEST_BYTES, - rawBodyBytes, - }); - return jsonResponse( - { error: "Request too large" }, - { - headers: corsHeaders(request), - status: 413, - }, + { status: 500 }, ); } let body: unknown; try { - body = JSON.parse(rawBody); + body = await request.json(); } catch (error) { logChatFailure("Rejected invalid JSON", { error }); - return jsonResponse( - { error: "Invalid JSON" }, - { - headers: corsHeaders(request), - status: 400, - }, - ); + return jsonResponse({ error: "Invalid JSON" }, { status: 400 }); } const parsed = requestSchema.safeParse(body); if (!parsed.success) { - logChatFailure("Rejected invalid chat request", { - error: parsed.error, - }); - return jsonResponse( - { error: "Invalid chat request" }, - { - headers: corsHeaders(request), - status: 400, - }, - ); + logChatFailure("Rejected invalid chat request", { error: parsed.error }); + return jsonResponse({ error: "Invalid chat request" }, { status: 400 }); } const validatedMessages = await safeValidateUIMessages({ @@ -256,7 +172,6 @@ export default async function handler(request: Request): Promise { error: validatedMessages.error, }); return jsonResponse(validationErrorBody(validatedMessages.error), { - headers: corsHeaders(request), status: 400, }); } @@ -284,8 +199,5 @@ export default async function handler(request: Request): Promise { }, }); - return result.toUIMessageStreamResponse({ - headers: corsHeaders(request), - sendReasoning: true, - }); + return result.toUIMessageStreamResponse({ sendReasoning: true }); } diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index b8613bb977b..f6c9cbc6c9c 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -6,32 +6,32 @@ "scripts": { "build": "vite build", "dev": "vite", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit" + "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error ." }, "dependencies": { "@ai-sdk/openai": "3.0.63", "@hashintel/ds-components": "workspace:*", "@hashintel/ds-helpers": "workspace:*", "@hashintel/petrinaut": "workspace:*", - "@hashintel/petrinaut-core": "workspace:*", "@mantine/hooks": "8.3.5", - "@pandacss/dev": "1.11.1", + "@pandacss/dev": "1.4.3", "@sentry/react": "10.22.0", "ai": "6.0.182", "immer": "10.1.3", - "react": "19.2.6", - "react-dom": "19.2.6", + "react": "19.2.3", + "react-dom": "19.2.3", "react-icons": "5.5.0", "zod": "4.4.3" }, "devDependencies": { "@rolldown/plugin-babel": "0.2.1", - "@types/react": "19.2.14", + "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260511.1", "@vitejs/plugin-react": "6.0.1", + "@whatwg-node/server": "0.10.18", "babel-plugin-react-compiler": "1.0.0", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", diff --git a/apps/petrinaut-website/vercel.json b/apps/petrinaut-website/vercel.json index b32d74b2e53..ea65bdf6b90 100644 --- a/apps/petrinaut-website/vercel.json +++ b/apps/petrinaut-website/vercel.json @@ -5,5 +5,10 @@ }, "buildCommand": "./vercel-build.sh", "installCommand": "./vercel-install.sh", - "outputDirectory": "./dist" + "outputDirectory": "./dist", + "functions": { + "api/chat.ts": { + "maxDuration": 60 + } + } } diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index acf2ef546b9..58a62d569c1 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -1,16 +1,11 @@ -import type { - IncomingHttpHeaders, - IncomingMessage, - ServerResponse, -} from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { fileURLToPath } from "node:url"; import babel from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import { createServerAdapter } from "@whatwg-node/server"; import { defineConfig, loadEnv, type Plugin } from "vite"; -type DevApiHandler = (request: Request) => Promise; - const appRoot = fileURLToPath(new URL(".", import.meta.url)); const loadServerEnv = (mode: string) => { @@ -23,141 +18,36 @@ const loadServerEnv = (mode: string) => { } }; -const readRequestBody = async ( - request: IncomingMessage, -): Promise => { - if (request.method === "GET" || request.method === "HEAD") { - return undefined; - } - - const chunks: Uint8Array[] = []; - const encoder = new TextEncoder(); - - await new Promise((resolve, reject) => { - request.on("data", (chunk: string | Uint8Array) => { - chunks.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk); - }); - request.on("end", resolve); - request.on("error", reject); - }); - - if (chunks.length === 0) { - return undefined; - } - - const byteLength = chunks.reduce( - (total, chunk) => total + chunk.byteLength, - 0, - ); - const body = new Uint8Array(byteLength); - let offset = 0; - - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - - return body; -}; - -const headersFromIncomingMessage = (headers: IncomingHttpHeaders): Headers => { - const result = new Headers(); - - for (const [key, value] of Object.entries(headers)) { - if (Array.isArray(value)) { - for (const headerValue of value) { - result.append(key, headerValue); - } - } else if (value !== undefined) { - result.set(key, value); - } - } - - return result; -}; - -const isDevApiModule = ( - value: unknown, -): value is { default: DevApiHandler } => { - const maybeModule = value as { default?: unknown }; - - return typeof maybeModule.default === "function"; -}; - -const writeResponse = async ( - response: Response, - serverResponse: ServerResponse, -) => { - const nodeResponse = serverResponse; - nodeResponse.statusCode = response.status; - nodeResponse.statusMessage = response.statusText; - - response.headers.forEach((value, key) => { - nodeResponse.setHeader(key, value); - }); - - if (!response.body) { - nodeResponse.end(); - return; - } - - const reader = response.body.getReader(); - - try { - let result = await reader.read(); - - while (!result.done) { - nodeResponse.write(result.value); - result = await reader.read(); - } - - nodeResponse.end(); - } finally { - reader.releaseLock(); - } -}; - +// Mounts `api/chat.ts` during `vite dev` so the front-end can hit `/api/chat` +// on the same origin. `@whatwg-node/server` handles the Node <-> Fetch +// translation (streaming, abort signals, header semantics) that Vercel's +// production runtime performs for the deployed function. const petrinautApiDevPlugin = (): Plugin => ({ name: "petrinaut-api-dev", apply: "serve", configureServer(server) { - server.middlewares.use("/api/chat", (request, response) => { - void (async () => { - try { - const apiModule = await server.ssrLoadModule("/api/chat.ts"); - if (!isDevApiModule(apiModule)) { - throw new Error( - "Expected /api/chat.ts to export a default handler.", - ); - } + const adapter = createServerAdapter(async (request) => { + const apiModule = await server.ssrLoadModule("/api/chat.ts"); + const handler = (apiModule as { default?: unknown }).default; - const url = new URL( - request.url ?? "", - `${server.config.server.https ? "https" : "http"}://${ - request.headers.host ?? "localhost" - }`, - ); + if (typeof handler !== "function") { + throw new Error("Expected /api/chat.ts to export a default handler."); + } - await writeResponse( - await apiModule.default( - new Request(url, { - body: await readRequestBody(request), - headers: headersFromIncomingMessage(request.headers), - method: request.method, - }), - ), - response, - ); - } catch (error) { - server.ssrFixStacktrace(error as Error); - const nodeResponse = response; - nodeResponse.statusCode = 500; - nodeResponse.end( - error instanceof Error ? error.message : "API error", - ); - } - })(); + try { + return await (handler as (req: Request) => Promise)(request); + } catch (error) { + server.ssrFixStacktrace(error as Error); + throw error; + } }); + + server.middlewares.use( + "/api/chat", + (request: IncomingMessage, response: ServerResponse) => { + void adapter(request, response); + }, + ); }, }); diff --git a/libs/@hashintel/ds-components/package.json b/libs/@hashintel/ds-components/package.json index 5f30a37b4b3..9566e000bf4 100644 --- a/libs/@hashintel/ds-components/package.json +++ b/libs/@hashintel/ds-components/package.json @@ -115,7 +115,7 @@ "vite-plugin-svgr": "5.2.0", "vitest": "^4.0.16", "vitest-browser-react": "^2.0.2", - "zod": "4.1.12" + "zod": "4.4.3" }, "peerDependencies": { "@ark-ui/react": "^5.26.2", diff --git a/libs/@local/repo-chores/node/package.json b/libs/@local/repo-chores/node/package.json index 8e07f0343ea..0a326128339 100644 --- a/libs/@local/repo-chores/node/package.json +++ b/libs/@local/repo-chores/node/package.json @@ -28,7 +28,7 @@ "regex-parser": "2.3.1", "tsx": "4.20.6", "typescript": "5.9.3", - "zod": "4.1.12", + "zod": "4.4.3", "zod-to-json-schema": "3.24.6" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 3e13bd022d4..59b443f3b03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -806,7 +806,7 @@ __metadata: shx: "npm:0.4.0" tsx: "npm:4.20.6" typescript: "npm:5.9.3" - zod: "npm:4.1.12" + zod: "npm:4.4.3" zod-to-json-schema: "npm:3.24.6" bin: linear-mcp: ./dist/index.js @@ -831,7 +831,7 @@ __metadata: shx: "npm:0.4.0" tsx: "npm:4.20.6" typescript: "npm:5.9.3" - zod: "npm:4.1.12" + zod: "npm:4.4.3" zod-to-json-schema: "npm:3.24.6" bin: notion-mcp: ./dist/index.js @@ -846,22 +846,22 @@ __metadata: "@hashintel/ds-components": "workspace:*" "@hashintel/ds-helpers": "workspace:*" "@hashintel/petrinaut": "workspace:*" - "@hashintel/petrinaut-core": "workspace:*" "@mantine/hooks": "npm:8.3.5" - "@pandacss/dev": "npm:1.11.1" + "@pandacss/dev": "npm:1.4.3" "@rolldown/plugin-babel": "npm:0.2.1" "@sentry/react": "npm:10.22.0" - "@types/react": "npm:19.2.14" + "@types/react": "npm:19.2.7" "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" "@vitejs/plugin-react": "npm:6.0.1" + "@whatwg-node/server": "npm:0.10.18" ai: "npm:6.0.182" babel-plugin-react-compiler: "npm:1.0.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1" - react: "npm:19.2.6" - react-dom: "npm:19.2.6" + react: "npm:19.2.3" + react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" vite: "npm:8.0.12" zod: "npm:4.4.3" @@ -5443,6 +5443,16 @@ __metadata: languageName: node linkType: hard +"@clack/core@npm:0.4.1": + version: 0.4.1 + resolution: "@clack/core@npm:0.4.1" + dependencies: + picocolors: "npm:^1.0.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/60c59e2d0017ce81567566c4f2a288f1738ef6356e25d9c56055be68aa449f75b6a7271b32c335213eb3a7af4b02db90de88c6999c432f09ca00c3d8004ea95b + languageName: node + linkType: hard + "@clack/core@npm:0.5.0": version: 0.5.0 resolution: "@clack/core@npm:0.5.0" @@ -5464,6 +5474,17 @@ __metadata: languageName: node linkType: hard +"@clack/prompts@npm:0.9.1": + version: 0.9.1 + resolution: "@clack/prompts@npm:0.9.1" + dependencies: + "@clack/core": "npm:0.4.1" + picocolors: "npm:^1.0.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/6cda9f56963dcbbfca4d9a64c82cf57e7f00dd563cd9e9ad28973b10ac761723fc21453254effbf08d5862efd57bad41d48008316c345202b74035ae905329cf + languageName: node + linkType: hard + "@cloudamqp/amqp-client@npm:^2.1.1": version: 2.1.1 resolution: "@cloudamqp/amqp-client@npm:2.1.1" @@ -7710,7 +7731,7 @@ __metadata: vite-plugin-svgr: "npm:5.2.0" vitest: "npm:^4.0.16" vitest-browser-react: "npm:^2.0.2" - zod: "npm:4.1.12" + zod: "npm:4.4.3" peerDependencies: "@ark-ui/react": ^5.26.2 "@hashintel/ds-helpers": "workspace:^" @@ -7725,7 +7746,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/petrinaut-core@workspace:*, @hashintel/petrinaut-core@workspace:^, @hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core": +"@hashintel/petrinaut-core@workspace:^, @hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core": version: 0.0.0-use.local resolution: "@hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core" dependencies: @@ -9486,7 +9507,7 @@ __metadata: regex-parser: "npm:2.3.1" tsx: "npm:4.20.6" typescript: "npm:5.9.3" - zod: "npm:4.1.12" + zod: "npm:4.4.3" zod-to-json-schema: "npm:3.24.6" languageName: unknown linkType: soft @@ -12613,6 +12634,24 @@ __metadata: languageName: node linkType: hard +"@pandacss/config@npm:1.4.3, @pandacss/config@npm:^1.4.3": + version: 1.4.3 + resolution: "@pandacss/config@npm:1.4.3" + dependencies: + "@pandacss/logger": "npm:1.4.3" + "@pandacss/preset-base": "npm:1.4.3" + "@pandacss/preset-panda": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + bundle-n-require: "npm:1.1.2" + escalade: "npm:3.1.2" + merge-anything: "npm:5.1.7" + microdiff: "npm:1.5.0" + typescript: "npm:5.8.3" + checksum: 10c0/eda86c6a6422e3c892ecf35cf5c1073c9011c34b5070566d7b1505081dc5913f9c8c0918bad2ec2f5bf5424906825a9a3b5e52a45a5575d4b7da7fb78b58272a + languageName: node + linkType: hard + "@pandacss/core@npm:1.11.1, @pandacss/core@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/core@npm:1.11.1" @@ -12638,6 +12677,34 @@ __metadata: languageName: node linkType: hard +"@pandacss/core@npm:1.4.3, @pandacss/core@npm:^1.4.3": + version: 1.4.3 + resolution: "@pandacss/core@npm:1.4.3" + dependencies: + "@csstools/postcss-cascade-layers": "npm:5.0.2" + "@pandacss/is-valid-prop": "npm:^1.4.3" + "@pandacss/logger": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/token-dictionary": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + browserslist: "npm:4.24.4" + hookable: "npm:5.5.3" + lightningcss: "npm:1.25.1" + lodash.merge: "npm:4.6.2" + outdent: "npm:0.8.0" + postcss: "npm:8.5.6" + postcss-discard-duplicates: "npm:7.0.2" + postcss-discard-empty: "npm:7.0.1" + postcss-merge-rules: "npm:7.0.6" + postcss-minify-selectors: "npm:7.0.5" + postcss-nested: "npm:7.0.2" + postcss-normalize-whitespace: "npm:7.0.1" + postcss-selector-parser: "npm:7.1.0" + ts-pattern: "npm:5.8.0" + checksum: 10c0/17974a2f420c7845216d9010e6785d1687e73246705c252ed72ecf2df703fe90cde4a92a4096d2f13e8f3080115e9c6bb09cb5163b0cd06a515243ebfb4ef6c2 + languageName: node + linkType: hard + "@pandacss/dev@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/dev@npm:1.11.1" @@ -12661,6 +12728,28 @@ __metadata: languageName: node linkType: hard +"@pandacss/dev@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/dev@npm:1.4.3" + dependencies: + "@clack/prompts": "npm:0.9.1" + "@pandacss/config": "npm:1.4.3" + "@pandacss/logger": "npm:1.4.3" + "@pandacss/node": "npm:1.4.3" + "@pandacss/postcss": "npm:1.4.3" + "@pandacss/preset-base": "npm:1.4.3" + "@pandacss/preset-panda": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/token-dictionary": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + cac: "npm:6.7.14" + bin: + panda: bin.js + pandacss: bin.js + checksum: 10c0/ff14d316e4a51f74fc9121b33951363e0b2fc1334adc79962517687313da69acb89fc4dd27e17819151290fced62669c9f9aa1339626c210dbb781738ddb479f + languageName: node + linkType: hard + "@pandacss/extractor@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/extractor@npm:1.11.1" @@ -12672,6 +12761,17 @@ __metadata: languageName: node linkType: hard +"@pandacss/extractor@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/extractor@npm:1.4.3" + dependencies: + "@pandacss/shared": "npm:1.4.3" + ts-evaluator: "npm:1.2.0" + ts-morph: "npm:26.0.0" + checksum: 10c0/3a422e9418db4a1190a2ddc53ca52460a697501e0253bfb1f54812ba7d585cc87c39ac1e9ccb878416be1b16c8910f4df60820ad55859c7f38efae767c0be4cd + languageName: node + linkType: hard + "@pandacss/generator@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/generator@npm:1.11.1" @@ -12691,6 +12791,25 @@ __metadata: languageName: node linkType: hard +"@pandacss/generator@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/generator@npm:1.4.3" + dependencies: + "@pandacss/core": "npm:1.4.3" + "@pandacss/is-valid-prop": "npm:^1.4.3" + "@pandacss/logger": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/token-dictionary": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + javascript-stringify: "npm:2.1.0" + outdent: "npm: ^0.8.0" + pluralize: "npm:8.0.0" + postcss: "npm:8.5.6" + ts-pattern: "npm:5.8.0" + checksum: 10c0/6913f82d2c937de0cb63b34d11aeba56fb0c3d181ace20ac4982db6cf740753e9d5af07fb80b29677c849da56987085904ea42ba973a4abdaf8c24d363389eef + languageName: node + linkType: hard + "@pandacss/is-valid-prop@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/is-valid-prop@npm:1.11.1" @@ -12698,6 +12817,13 @@ __metadata: languageName: node linkType: hard +"@pandacss/is-valid-prop@npm:^1.4.3": + version: 1.4.3 + resolution: "@pandacss/is-valid-prop@npm:1.4.3" + checksum: 10c0/e5f3cd692b517cb212b93602e2f9d6861191f3c075d34c2a9f3414b1b56beeb5f0413d582c1b9f87a70ba246fa0eaac4dbcfa2640901e4144bd1aae79ac2b3fb + languageName: node + linkType: hard + "@pandacss/logger@npm:1.11.1, @pandacss/logger@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/logger@npm:1.11.1" @@ -12708,6 +12834,16 @@ __metadata: languageName: node linkType: hard +"@pandacss/logger@npm:1.4.3, @pandacss/logger@npm:^1.4.3": + version: 1.4.3 + resolution: "@pandacss/logger@npm:1.4.3" + dependencies: + "@pandacss/types": "npm:1.4.3" + kleur: "npm:4.1.5" + checksum: 10c0/79b288232b9dcdfc816615eb209767d8af6738800ebb7ee56a37f4bfbeb5af659bb10b25a1281979653b3dda0eb0f5c4eb3ec41a0bada8f4297a3c4a104cf815 + languageName: node + linkType: hard + "@pandacss/mcp@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/mcp@npm:1.11.1" @@ -12763,6 +12899,42 @@ __metadata: languageName: node linkType: hard +"@pandacss/node@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/node@npm:1.4.3" + dependencies: + "@pandacss/config": "npm:1.4.3" + "@pandacss/core": "npm:1.4.3" + "@pandacss/generator": "npm:1.4.3" + "@pandacss/logger": "npm:1.4.3" + "@pandacss/parser": "npm:1.4.3" + "@pandacss/reporter": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/token-dictionary": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + browserslist: "npm:4.24.4" + chokidar: "npm:4.0.3" + fast-glob: "npm:3.3.3" + fs-extra: "npm:11.2.0" + glob-parent: "npm:6.0.2" + is-glob: "npm:4.0.3" + lodash.merge: "npm:4.6.2" + look-it-up: "npm:2.1.0" + outdent: "npm: ^0.8.0" + package-manager-detector: "npm:0.1.0" + perfect-debounce: "npm:1.0.0" + picomatch: "npm:4.0.3" + pkg-types: "npm:2.3.0" + pluralize: "npm:8.0.0" + postcss: "npm:8.5.6" + prettier: "npm:3.2.5" + ts-morph: "npm:26.0.0" + ts-pattern: "npm:5.8.0" + tsconfck: "npm:3.1.6" + checksum: 10c0/8c338edf210caef97e0939b86ecc4a78fc80c69ff833392d16ed4b687134021f814d36a358054f3c74fb76ae378481e3e13957c7d622e980f35a4fc5a66ddf84 + languageName: node + linkType: hard + "@pandacss/parser@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/parser@npm:1.11.1" @@ -12779,6 +12951,24 @@ __metadata: languageName: node linkType: hard +"@pandacss/parser@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/parser@npm:1.4.3" + dependencies: + "@pandacss/config": "npm:^1.4.3" + "@pandacss/core": "npm:^1.4.3" + "@pandacss/extractor": "npm:1.4.3" + "@pandacss/logger": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + "@vue/compiler-sfc": "npm:3.5.22" + magic-string: "npm:0.30.19" + ts-morph: "npm:26.0.0" + ts-pattern: "npm:5.8.0" + checksum: 10c0/7af08a9a5a340c4ec6b77aad11da0c3b34969c8e05fe9ad40482b8b2c288ab86ccd40e896acc713916c98e16287aa10910d68a84222b9de9f22b6cb5a5e0d95f + languageName: node + linkType: hard + "@pandacss/plugin-lightningcss@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/plugin-lightningcss@npm:1.11.1" @@ -12822,6 +13012,16 @@ __metadata: languageName: node linkType: hard +"@pandacss/postcss@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/postcss@npm:1.4.3" + dependencies: + "@pandacss/node": "npm:1.4.3" + postcss: "npm:8.5.6" + checksum: 10c0/d4029fe47540d78c130443ac8432827586a4215f94863f831ce5932a82cf647d3255f99187aa27c58ca60fc9c4954f7619fcda636fcb6873246e17feaf67f81a + languageName: node + linkType: hard + "@pandacss/preset-base@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/preset-base@npm:1.11.1" @@ -12831,6 +13031,15 @@ __metadata: languageName: node linkType: hard +"@pandacss/preset-base@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/preset-base@npm:1.4.3" + dependencies: + "@pandacss/types": "npm:1.4.3" + checksum: 10c0/3771690de58550ae96cb571260db6676990a6c966d892253f659ef0181db63b48fd9586637a456a1989e490af2106d86801625250804e3743e0ba3db96ffd03d + languageName: node + linkType: hard + "@pandacss/preset-panda@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/preset-panda@npm:1.11.1" @@ -12840,6 +13049,15 @@ __metadata: languageName: node linkType: hard +"@pandacss/preset-panda@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/preset-panda@npm:1.4.3" + dependencies: + "@pandacss/types": "npm:1.4.3" + checksum: 10c0/89d231dd26d4f880a29517f2672c7c34214d67b265c2eda4d53f87f57faabee3e2518bdda9d371f94db34918658e442666847fd1cf84ecdf9ca8fab0d261ae58 + languageName: node + linkType: hard + "@pandacss/reporter@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/reporter@npm:1.11.1" @@ -12855,6 +13073,21 @@ __metadata: languageName: node linkType: hard +"@pandacss/reporter@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/reporter@npm:1.4.3" + dependencies: + "@pandacss/core": "npm:1.4.3" + "@pandacss/generator": "npm:1.4.3" + "@pandacss/logger": "npm:1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + table: "npm:6.9.0" + wordwrapjs: "npm:5.1.0" + checksum: 10c0/2feed289228cfc192ac61883c0fcf1a71306e094c18131fd331601823c6db7a1caf25e419e21f9f5f9cbfe05b83f39fbd36f68815ee8a905a0eb5f2f67a6c7ba + languageName: node + linkType: hard + "@pandacss/shared@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/shared@npm:1.11.1" @@ -12862,6 +13095,13 @@ __metadata: languageName: node linkType: hard +"@pandacss/shared@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/shared@npm:1.4.3" + checksum: 10c0/c51de8018bc401f606998598719330d556f948ff0ef34a7ca524137222123193417b48902f1a9fcb1e08709a08a626747bd70ddb7603463bf3abd8f560d458fe + languageName: node + linkType: hard + "@pandacss/token-dictionary@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/token-dictionary@npm:1.11.1" @@ -12875,6 +13115,18 @@ __metadata: languageName: node linkType: hard +"@pandacss/token-dictionary@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/token-dictionary@npm:1.4.3" + dependencies: + "@pandacss/logger": "npm:^1.4.3" + "@pandacss/shared": "npm:1.4.3" + "@pandacss/types": "npm:1.4.3" + ts-pattern: "npm:5.8.0" + checksum: 10c0/cd0654687ebe75f5bdbcfae9cf9a17b203d407bef59c03f0de92c258d55c422669e9497767118f10f94a23a38b859a7c104301fda66151d00c491a13beba6542 + languageName: node + linkType: hard + "@pandacss/types@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/types@npm:1.11.1" @@ -12882,6 +13134,13 @@ __metadata: languageName: node linkType: hard +"@pandacss/types@npm:1.4.3": + version: 1.4.3 + resolution: "@pandacss/types@npm:1.4.3" + checksum: 10c0/8e83b39560dfb31ccdafddf19b89f75355f92f1e0dc2500c2a1cb37ca05fb9dabf91de13f97b46f89bdf25c49237e1422fae1ad702a179d08ab5d4fdeef127c3 + languageName: node + linkType: hard + "@parcel/watcher-android-arm64@npm:2.5.1": version: 2.5.1 resolution: "@parcel/watcher-android-arm64@npm:2.5.1" @@ -18610,6 +18869,17 @@ __metadata: languageName: node linkType: hard +"@ts-morph/common@npm:~0.27.0": + version: 0.27.0 + resolution: "@ts-morph/common@npm:0.27.0" + dependencies: + fast-glob: "npm:^3.3.3" + minimatch: "npm:^10.0.1" + path-browserify: "npm:^1.0.1" + checksum: 10c0/3daa267bd78114ff504eb064c5215da6e46589e775b781ec0da4998d999b0d7130eff287e70d6e13e0a0a897ea16e9387f4cd885b4b9d6d628f318cecb81d473 + languageName: node + linkType: hard + "@ts-morph/common@npm:~0.29.0": version: 0.29.0 resolution: "@ts-morph/common@npm:0.29.0" @@ -19798,6 +20068,15 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:19.2.7": + version: 19.2.7 + resolution: "@types/react@npm:19.2.7" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/a7b75f1f9fcb34badd6f84098be5e35a0aeca614bc91f93d2698664c0b2ba5ad128422bd470ada598238cebe4f9e604a752aead7dc6f5a92261d0c7f9b27cfd1 + languageName: node + linkType: hard + "@types/readable-stream@npm:^4.0.0, @types/readable-stream@npm:^4.0.5": version: 4.0.18 resolution: "@types/readable-stream@npm:4.0.18" @@ -21091,6 +21370,19 @@ __metadata: languageName: node linkType: hard +"@vue/compiler-core@npm:3.5.22": + version: 3.5.22 + resolution: "@vue/compiler-core@npm:3.5.22" + dependencies: + "@babel/parser": "npm:^7.28.4" + "@vue/shared": "npm:3.5.22" + entities: "npm:^4.5.0" + estree-walker: "npm:^2.0.2" + source-map-js: "npm:^1.2.1" + checksum: 10c0/7575fdef8d2b69aa9a7f55ba237abe0ab86a855dba1048dc32b32e2e5212a66410f922603b1191a8fbbf6e0caee7efab0cea705516304eeb1108d3819a10b092 + languageName: node + linkType: hard + "@vue/compiler-core@npm:3.5.25": version: 3.5.25 resolution: "@vue/compiler-core@npm:3.5.25" @@ -21104,6 +21396,16 @@ __metadata: languageName: node linkType: hard +"@vue/compiler-dom@npm:3.5.22": + version: 3.5.22 + resolution: "@vue/compiler-dom@npm:3.5.22" + dependencies: + "@vue/compiler-core": "npm:3.5.22" + "@vue/shared": "npm:3.5.22" + checksum: 10c0/f853e7533a6e2f51321b5ce258c6ed2bdac8a294e833a61e87b00d3fdd36cd39e1045c03027c31d85f518422062e50085f1358a37d104ccf0866bc174a5c7b9a + languageName: node + linkType: hard + "@vue/compiler-dom@npm:3.5.25, @vue/compiler-dom@npm:^3.5.0": version: 3.5.25 resolution: "@vue/compiler-dom@npm:3.5.25" @@ -21114,6 +21416,23 @@ __metadata: languageName: node linkType: hard +"@vue/compiler-sfc@npm:3.5.22": + version: 3.5.22 + resolution: "@vue/compiler-sfc@npm:3.5.22" + dependencies: + "@babel/parser": "npm:^7.28.4" + "@vue/compiler-core": "npm:3.5.22" + "@vue/compiler-dom": "npm:3.5.22" + "@vue/compiler-ssr": "npm:3.5.22" + "@vue/shared": "npm:3.5.22" + estree-walker: "npm:^2.0.2" + magic-string: "npm:^0.30.19" + postcss: "npm:^8.5.6" + source-map-js: "npm:^1.2.1" + checksum: 10c0/662838a31f69cf6eedfcb5dc9f7f67a67ec6761645f2f09e6d2b5a4833c0e08a11fb960665d16519599e865e9a883490116e984132f8f7bb5d8ba07fca062ca5 + languageName: node + linkType: hard + "@vue/compiler-sfc@npm:3.5.25, @vue/compiler-sfc@npm:^3.5.13": version: 3.5.25 resolution: "@vue/compiler-sfc@npm:3.5.25" @@ -21131,6 +21450,16 @@ __metadata: languageName: node linkType: hard +"@vue/compiler-ssr@npm:3.5.22": + version: 3.5.22 + resolution: "@vue/compiler-ssr@npm:3.5.22" + dependencies: + "@vue/compiler-dom": "npm:3.5.22" + "@vue/shared": "npm:3.5.22" + checksum: 10c0/d27721b96784d078e410d978ed5e7c0a2fca10b8a8087d7cfc832baedf79de8b3d34d05def3e54d7baaca0f7583c7261628dca482ba4e8b3c908302e44a53b2f + languageName: node + linkType: hard + "@vue/compiler-ssr@npm:3.5.25": version: 3.5.25 resolution: "@vue/compiler-ssr@npm:3.5.25" @@ -21172,6 +21501,13 @@ __metadata: languageName: node linkType: hard +"@vue/shared@npm:3.5.22": + version: 3.5.22 + resolution: "@vue/shared@npm:3.5.22" + checksum: 10c0/5866eab1dd6caa949f4ae2da2a7bac69612b35e316a298785279fb4de101bfe89a3572db56448aa35023b01d069b80a664be4fe22847ce5e5fbc1990e5970ec5 + languageName: node + linkType: hard + "@vue/shared@npm:3.5.25, @vue/shared@npm:^3.5.0": version: 3.5.25 resolution: "@vue/shared@npm:3.5.25" @@ -21422,6 +21758,19 @@ __metadata: languageName: node linkType: hard +"@whatwg-node/server@npm:0.10.18": + version: 0.10.18 + resolution: "@whatwg-node/server@npm:0.10.18" + dependencies: + "@envelop/instrumentation": "npm:^1.0.0" + "@whatwg-node/disposablestack": "npm:^0.0.6" + "@whatwg-node/fetch": "npm:^0.10.13" + "@whatwg-node/promise-helpers": "npm:^1.3.2" + tslib: "npm:^2.6.3" + checksum: 10c0/794c4776c8cb432d2f607f5a36392bec59649bbee6aa116b52866555e4852f00378e92d80702c1aa67cf32abe147b3eb96a999b7352237d64126b6e7e33acc6c + languageName: node + linkType: hard + "@wry/caches@npm:^1.0.0": version: 1.0.1 resolution: "@wry/caches@npm:1.0.1" @@ -24001,7 +24350,21 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:4.28.1, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1": +"browserslist@npm:4.24.4": + version: 4.24.4 + resolution: "browserslist@npm:4.24.4" + dependencies: + caniuse-lite: "npm:^1.0.30001688" + electron-to-chromium: "npm:^1.5.73" + node-releases: "npm:^2.0.19" + update-browserslist-db: "npm:^1.1.1" + bin: + browserslist: cli.js + checksum: 10c0/db7ebc1733cf471e0b490b4f47e3e2ea2947ce417192c9246644e92c667dd56a71406cc58f62ca7587caf828364892e9952904a02b7aead752bc65b62a37cfe9 + languageName: node + linkType: hard + +"browserslist@npm:4.28.1, browserslist@npm:^4.0.0, browserslist@npm:^4.24.0, browserslist@npm:^4.25.1, browserslist@npm:^4.28.1": version: 4.28.1 resolution: "browserslist@npm:4.28.1" dependencies: @@ -24354,7 +24717,19 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001759": +"caniuse-api@npm:^3.0.0": + version: 3.0.0 + resolution: "caniuse-api@npm:3.0.0" + dependencies: + browserslist: "npm:^4.0.0" + caniuse-lite: "npm:^1.0.0" + lodash.memoize: "npm:^4.1.2" + lodash.uniq: "npm:^4.5.0" + checksum: 10c0/60f9e85a3331e6d761b1b03eec71ca38ef7d74146bece34694853033292156b815696573ed734b65583acf493e88163618eda915c6c826d46a024c71a9572b4c + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001688, caniuse-lite@npm:^1.0.30001759": version: 1.0.30001768 resolution: "caniuse-lite@npm:1.0.30001768" checksum: 10c0/16808cb39f9563098deab6d45bcd0642a79fc5ace8dbcea8106b008b179820353e3ec089ed7e54f1f3c8bb84f2c2835b451f308212d8f36c2b7942f879e91955 @@ -25782,6 +26157,15 @@ __metadata: languageName: node linkType: hard +"cssnano-utils@npm:^5.0.1": + version: 5.0.1 + resolution: "cssnano-utils@npm:5.0.1" + peerDependencies: + postcss: ^8.4.32 + checksum: 10c0/e416e58587ccec4d904093a2834c66c44651578a58960019884add376d4f151c5b809674108088140dd57b0787cb7132a083d40ae33a72bf986d03c4b7b7c5f4 + languageName: node + linkType: hard + "csso@npm:^5.0.5": version: 5.0.5 resolution: "csso@npm:5.0.5" @@ -26962,7 +27346,7 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.263": +"electron-to-chromium@npm:^1.5.263, electron-to-chromium@npm:^1.5.73": version: 1.5.286 resolution: "electron-to-chromium@npm:1.5.286" checksum: 10c0/5384510f9682d7e46f98fa48b874c3901d9639de96e9e387afce1fe010fbac31376df0534524edc15f66e9902bfacee54037a5e598004e9c6a617884e379926d @@ -27660,6 +28044,13 @@ __metadata: languageName: node linkType: hard +"escalade@npm:3.1.2": + version: 3.1.2 + resolution: "escalade@npm:3.1.2" + checksum: 10c0/6b4adafecd0682f3aa1cd1106b8fff30e492c7015b178bc81b2d2f75106dabea6c6d6e8508fc491bd58e597c74abb0e8e2368f943ecb9393d4162e3c2f3cf287 + languageName: node + linkType: hard + "escalade@npm:3.2.0, escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -28797,14 +29188,7 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0": - version: 3.0.6 - resolution: "eventsource-parser@npm:3.0.6" - checksum: 10c0/70b8ccec7dac767ef2eca43f355e0979e70415701691382a042a2df8d6a68da6c2fca35363669821f3da876d29c02abe9b232964637c1b6635c940df05ada78a - languageName: node - linkType: hard - -"eventsource-parser@npm:^3.0.8": +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.8": version: 3.0.8 resolution: "eventsource-parser@npm:3.0.8" checksum: 10c0/3a73eee85311f33b12fa558381a477c1bdcf8c024a429a9d48f87b043e328c26d24ed280fd7ca92e2fdd4c8c37f749b758420c1533778aaca2beabf895024efa @@ -29759,6 +30143,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:11.2.0": + version: 11.2.0 + resolution: "fs-extra@npm:11.2.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398 + languageName: node + linkType: hard + "fs-extra@npm:11.3.2": version: 11.3.2 resolution: "fs-extra@npm:11.3.2" @@ -31170,6 +31565,13 @@ __metadata: languageName: node linkType: hard +"hookable@npm:5.5.3": + version: 5.5.3 + resolution: "hookable@npm:5.5.3" + checksum: 10c0/275f4cc84d27f8d48c5a5cd5685b6c0fea9291be9deea5bff0cfa72856ed566abde1dcd8cb1da0f9a70b4da3d7ec0d60dc3554c4edbba647058cc38816eced3d + languageName: node + linkType: hard + "hookified@npm:^1.12.1": version: 1.12.2 resolution: "hookified@npm:1.12.2" @@ -32817,6 +33219,13 @@ __metadata: languageName: node linkType: hard +"is-what@npm:^4.1.8": + version: 4.1.16 + resolution: "is-what@npm:4.1.16" + checksum: 10c0/611f1947776826dcf85b57cfb7bd3b3ea6f4b94a9c2f551d4a53f653cf0cb9d1e6518846648256d46ee6c91d114b6d09d2ac8a07306f7430c5900f87466aae5b + languageName: node + linkType: hard + "is-windows@npm:^1.0.0, is-windows@npm:^1.0.1": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -34144,6 +34553,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-darwin-arm64@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-darwin-arm64@npm:1.25.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-darwin-arm64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-darwin-arm64@npm:1.31.1" @@ -34158,6 +34574,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-darwin-x64@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-darwin-x64@npm:1.25.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "lightningcss-darwin-x64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-darwin-x64@npm:1.31.1" @@ -34172,6 +34595,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-freebsd-x64@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-freebsd-x64@npm:1.25.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "lightningcss-freebsd-x64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-freebsd-x64@npm:1.31.1" @@ -34186,6 +34616,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm-gnueabihf@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.25.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "lightningcss-linux-arm-gnueabihf@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-arm-gnueabihf@npm:1.31.1" @@ -34200,6 +34637,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm64-gnu@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-linux-arm64-gnu@npm:1.25.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "lightningcss-linux-arm64-gnu@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-arm64-gnu@npm:1.31.1" @@ -34214,6 +34658,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm64-musl@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-linux-arm64-musl@npm:1.25.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "lightningcss-linux-arm64-musl@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-arm64-musl@npm:1.31.1" @@ -34228,6 +34679,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-x64-gnu@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-linux-x64-gnu@npm:1.25.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "lightningcss-linux-x64-gnu@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-x64-gnu@npm:1.31.1" @@ -34242,6 +34700,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-x64-musl@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-linux-x64-musl@npm:1.25.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "lightningcss-linux-x64-musl@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-x64-musl@npm:1.31.1" @@ -34270,6 +34735,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-win32-x64-msvc@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss-win32-x64-msvc@npm:1.25.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "lightningcss-win32-x64-msvc@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-win32-x64-msvc@npm:1.31.1" @@ -34284,6 +34756,43 @@ __metadata: languageName: node linkType: hard +"lightningcss@npm:1.25.1": + version: 1.25.1 + resolution: "lightningcss@npm:1.25.1" + dependencies: + detect-libc: "npm:^1.0.3" + lightningcss-darwin-arm64: "npm:1.25.1" + lightningcss-darwin-x64: "npm:1.25.1" + lightningcss-freebsd-x64: "npm:1.25.1" + lightningcss-linux-arm-gnueabihf: "npm:1.25.1" + lightningcss-linux-arm64-gnu: "npm:1.25.1" + lightningcss-linux-arm64-musl: "npm:1.25.1" + lightningcss-linux-x64-gnu: "npm:1.25.1" + lightningcss-linux-x64-musl: "npm:1.25.1" + lightningcss-win32-x64-msvc: "npm:1.25.1" + dependenciesMeta: + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/143a412dfd3393804c9dedac4294d7d54752dd589eb9ba43e3548bd6b0f9d73765b2b4cc0c62fae767c96d5d532a64d7fdfabd8b299caf733160a751cbb28297 + languageName: node + linkType: hard + "lightningcss@npm:1.31.1": version: 1.31.1 resolution: "lightningcss@npm:1.31.1" @@ -34629,7 +35138,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:4.1.2": +"lodash.memoize@npm:4.1.2, lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10c0/c8713e51eccc650422716a14cece1809cfe34bc5ab5e242b7f8b4e2241c2483697b971a604252807689b9dd69bfe3a98852e19a5b89d506b000b4187a1285df8 @@ -34960,7 +35469,16 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.21, magic-string@npm:^0.30.0, magic-string@npm:^0.30.17, magic-string@npm:^0.30.21, magic-string@npm:^0.30.3": +"magic-string@npm:0.30.19": + version: 0.30.19 + resolution: "magic-string@npm:0.30.19" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/db23fd2e2ee98a1aeb88a4cdb2353137fcf05819b883c856dd79e4c7dfb25151e2a5a4d5dbd88add5e30ed8ae5c51bcf4accbc6becb75249d924ec7b4fbcae27 + languageName: node + linkType: hard + +"magic-string@npm:0.30.21, magic-string@npm:^0.30.0, magic-string@npm:^0.30.17, magic-string@npm:^0.30.19, magic-string@npm:^0.30.21, magic-string@npm:^0.30.3": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -35474,6 +35992,15 @@ __metadata: languageName: node linkType: hard +"merge-anything@npm:5.1.7": + version: 5.1.7 + resolution: "merge-anything@npm:5.1.7" + dependencies: + is-what: "npm:^4.1.8" + checksum: 10c0/1820c8dfa5da65de1829b5e9adb65d1685ec4bc5d358927cacd20a9917eff9448f383f937695f4dbd2162b152faf41ce24187a931621839ee8a8b3c306a65136 + languageName: node + linkType: hard + "merge-descriptors@npm:1.0.3": version: 1.0.3 resolution: "merge-descriptors@npm:1.0.3" @@ -37093,7 +37620,7 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.27": +"node-releases@npm:^2.0.19, node-releases@npm:^2.0.27": version: 2.0.27 resolution: "node-releases@npm:2.0.27" checksum: 10c0/f1e6583b7833ea81880627748d28a3a7ff5703d5409328c216ae57befbced10ce2c991bea86434e8ec39003bd017f70481e2e5f8c1f7e0a7663241f81d6e00e2 @@ -38132,6 +38659,13 @@ __metadata: languageName: node linkType: hard +"package-manager-detector@npm:0.1.0": + version: 0.1.0 + resolution: "package-manager-detector@npm:0.1.0" + checksum: 10c0/7d461515a8be38dbe13ac3d19d7774ab95d6795b177c890998511cda87e090ab1e65ee56dd2a66890802cbc2732c0c06c9402a7a1cdb64f7b615d9488b3ddb9c + languageName: node + linkType: hard + "package-manager-detector@npm:1.6.0, package-manager-detector@npm:^1.6.0": version: 1.6.0 resolution: "package-manager-detector@npm:1.6.0" @@ -38745,6 +39279,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + "picomatch@npm:4.0.4, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": version: 4.0.4 resolution: "picomatch@npm:4.0.4" @@ -39018,6 +39559,20 @@ __metadata: languageName: node linkType: hard +"postcss-merge-rules@npm:7.0.6": + version: 7.0.6 + resolution: "postcss-merge-rules@npm:7.0.6" + dependencies: + browserslist: "npm:^4.25.1" + caniuse-api: "npm:^3.0.0" + cssnano-utils: "npm:^5.0.1" + postcss-selector-parser: "npm:^7.1.0" + peerDependencies: + postcss: ^8.4.32 + checksum: 10c0/1708d2e862825f79077aff1f7d82ff815c015929f0fb5bb3fb58dbc83f9bc79ef9aa40ef585afbe2dcb2563ea3516f21332be926e746189649459eb9399cc95e + languageName: node + linkType: hard + "postcss-minify-selectors@npm:7.0.5": version: 7.0.5 resolution: "postcss-minify-selectors@npm:7.0.5" @@ -39096,6 +39651,16 @@ __metadata: languageName: node linkType: hard +"postcss-selector-parser@npm:7.1.0": + version: 7.1.0 + resolution: "postcss-selector-parser@npm:7.1.0" + dependencies: + cssesc: "npm:^3.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 10c0/0fef257cfd1c0fe93c18a3f8a6e739b4438b527054fd77e9a62730a89b2d0ded1b59314a7e4aaa55bc256204f40830fecd2eb50f20f8cb7ab3a10b52aa06c8aa + languageName: node + linkType: hard + "postcss-selector-parser@npm:7.1.1, postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.0": version: 7.1.1 resolution: "postcss-selector-parser@npm:7.1.1" @@ -39148,6 +39713,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:8.5.6": + version: 8.5.6 + resolution: "postcss@npm:8.5.6" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024 + languageName: node + linkType: hard + "postgres-array@npm:^3.0.1": version: 3.0.2 resolution: "postgres-array@npm:3.0.2" @@ -44536,6 +45112,16 @@ __metadata: languageName: node linkType: hard +"ts-morph@npm:26.0.0": + version: 26.0.0 + resolution: "ts-morph@npm:26.0.0" + dependencies: + "@ts-morph/common": "npm:~0.27.0" + code-block-writer: "npm:^13.0.3" + checksum: 10c0/c6880d90a1eefe0ce6555bf8c11cc104b1f36f84bd36a37a82b9ae0b974f51fe6b1bc91bb0ec42550158dc1c812329d6433e1237cba64f1ef515c129b321dd5d + languageName: node + linkType: hard + "ts-morph@npm:28.0.0": version: 28.0.0 resolution: "ts-morph@npm:28.0.0" @@ -44546,6 +45132,13 @@ __metadata: languageName: node linkType: hard +"ts-pattern@npm:5.8.0": + version: 5.8.0 + resolution: "ts-pattern@npm:5.8.0" + checksum: 10c0/0e41006a8de7490c7edbba36c095550cd4b0e334247f9e76cddbdaadea4bcdc479763fb403a787db19bb83480c02fe6ea0e9799ceaaba0573acbe31e341ab947 + languageName: node + linkType: hard + "ts-pattern@npm:5.9.0, ts-pattern@npm:^5.5.0": version: 5.9.0 resolution: "ts-pattern@npm:5.9.0" @@ -44553,7 +45146,7 @@ __metadata: languageName: node linkType: hard -"tsconfck@npm:^3.0.3": +"tsconfck@npm:3.1.6, tsconfck@npm:^3.0.3": version: 3.1.6 resolution: "tsconfck@npm:3.1.6" peerDependencies: @@ -44919,6 +45512,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.8.3": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 + languageName: node + linkType: hard + "typescript@npm:5.9.3, typescript@npm:^5.7.3, typescript@npm:^5.8.3": version: 5.9.3 resolution: "typescript@npm:5.9.3" @@ -44979,6 +45582,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A5.8.3#optional!builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A5.9.3#optional!builtin, typescript@patch:typescript@npm%3A^5.7.3#optional!builtin, typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" @@ -45497,7 +46110,7 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.2.0": +"update-browserslist-db@npm:^1.1.1, update-browserslist-db@npm:^1.2.0": version: 1.2.3 resolution: "update-browserslist-db@npm:1.2.3" dependencies: @@ -46842,6 +47455,13 @@ __metadata: languageName: node linkType: hard +"wordwrapjs@npm:5.1.0": + version: 5.1.0 + resolution: "wordwrapjs@npm:5.1.0" + checksum: 10c0/e147162f139eb8c05257729fde586f5422a2d242aa8f027b5fa5adead1b571b455d0690a15c73aeaa31c93ba96864caa06d84ebdb2c32a0890602ab86a7568d1 + languageName: node + linkType: hard + "wordwrapjs@npm:5.1.1": version: 5.1.1 resolution: "wordwrapjs@npm:5.1.1" From dcd7339e9c2cb5ab482fab4fdc140d9a9e4b815a Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 21 May 2026 18:27:23 +0100 Subject: [PATCH 05/21] clean up debug logs. petrinaut website opens most recently modified --- .gitignore | 1 + apps/petrinaut-website/README.md | 4 +- apps/petrinaut-website/src/main/app.tsx | 14 +- .../petrinaut/src/react/playback/context.ts | 2 +- .../BottomBar/playback-settings-menu.tsx | 220 +----------------- 5 files changed, 25 insertions(+), 216 deletions(-) diff --git a/.gitignore b/.gitignore index 455d2d6f023..69c0f3812ca 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,4 @@ out/ # Jujutsu .jj/ +.vercel diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 71913888b82..85c2e955495 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -40,9 +40,7 @@ Requires linking to a Vercel project. If you don't have access, go for Option B ```sh cd apps/petrinaut-website -# first-time setup -npx vercel link -npx vercel env pull # writes the project's env vars into .env.local +npx vercel link # first-time setup npx vercel dev # builds + serves on http://localhost:3000 ``` diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index ba54cd78d12..438a4548477 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -107,11 +107,17 @@ export const DevApp = () => { useLocalStorageAiMessages(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); - const firstNet = Object.values(storedSDCPNsForDisplay)[0] ?? null; + + // Pick the most recently modified net + const mostRecentlyModifiedNet = + Object.values(storedSDCPNsForDisplay).sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + )[0] ?? null; // The net currently selected in the UI. const [currentNetId, setCurrentNetId] = useState( - () => firstNet?.id ?? null, + () => mostRecentlyModifiedNet?.id ?? null, ); // Metadata and persisted SDCPN snapshot for the selected net. @@ -121,7 +127,9 @@ export const DevApp = () => { // Live editable document handle for the selected net only. const [activeHandle, setActiveHandle] = useState(() => - firstNet ? createActiveHandle(firstNet) : null, + mostRecentlyModifiedNet + ? createActiveHandle(mostRecentlyModifiedNet) + : null, ); useEffect(() => { diff --git a/libs/@hashintel/petrinaut/src/react/playback/context.ts b/libs/@hashintel/petrinaut/src/react/playback/context.ts index 57793d69013..249aabc975b 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/context.ts +++ b/libs/@hashintel/petrinaut/src/react/playback/context.ts @@ -126,7 +126,7 @@ const DEFAULT_CONTEXT_VALUE: PlaybackContextValue = { currentFrameIndex: 0, totalFrames: 0, playbackSpeed: 1, - playMode: "computeMax", + playMode: "computeBuffer", isViewOnlyAvailable: false, isComputeAvailable: true, setCurrentViewedFrame: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index 702a6f33cc0..48b6d35851f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -19,37 +19,6 @@ const contentWidthStyle = css({ width: "[280px]", }); -const logPlaybackControlsDebug = ({ - hypothesisId, - location, - message, - data, -}: { - hypothesisId: string; - location: string; - message: string; - data: Record; -}) => { - // #region agent log - fetch("http://127.0.0.1:7370/ingest/051d6616-30f1-4a11-bffa-57e53758d60f", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Debug-Session-Id": "699661", - }, - body: JSON.stringify({ - sessionId: "699661", - runId: "initial", - hypothesisId, - location, - message, - data, - timestamp: Date.now(), - }), - }).catch(() => {}); - // #endregion -}; - const menuItemStyle = cva({ base: { display: "flex !important", @@ -187,95 +156,9 @@ export const PlaybackSettingsMenu = () => { const stoppingCondition: "indefinitely" | "fixed" = maxTime === null ? "indefinitely" : "fixed"; - useEffect(() => { - logPlaybackControlsDebug({ - hypothesisId: "C,E", - location: "playback-settings-menu.tsx:state-effect", - message: "Playback settings state observed", - data: { - simulationState, - maxTime, - stoppingCondition, - playbackSpeed, - playMode, - isViewOnlyAvailable, - isComputeAvailable, - }, - }); - }, [ - isComputeAvailable, - isViewOnlyAvailable, - maxTime, - playbackSpeed, - playMode, - simulationState, - stoppingCondition, - ]); - - useEffect(() => { - const handlePointerDown = (event: PointerEvent) => { - const target = event.target as Element | null; - const elementAtPoint = document.elementFromPoint( - event.clientX, - event.clientY, - ); - const popoverPositioner = document.querySelector( - '[data-scope="popover"][data-part="positioner"]', - ); - const popoverContent = document.querySelector( - '[data-scope="popover"][data-part="content"]', - ); - - logPlaybackControlsDebug({ - hypothesisId: "A,B,D", - location: "playback-settings-menu.tsx:document-pointerdown", - message: "Document pointerdown captured", - data: { - clientX: event.clientX, - clientY: event.clientY, - targetTag: target?.tagName, - targetText: target?.textContent?.trim().slice(0, 80), - targetAriaLabel: target?.getAttribute("aria-label"), - elementAtPointTag: elementAtPoint?.tagName, - elementAtPointText: elementAtPoint?.textContent?.trim().slice(0, 80), - elementAtPointAriaLabel: elementAtPoint?.getAttribute("aria-label"), - popoverPositionerPointerEvents: popoverPositioner - ? getComputedStyle(popoverPositioner).pointerEvents - : null, - popoverContentPointerEvents: popoverContent - ? getComputedStyle(popoverContent).pointerEvents - : null, - isInsidePopoverContent: - !!popoverContent && !!target && popoverContent.contains(target), - }, - }); - }; - - document.addEventListener("pointerdown", handlePointerDown, { - capture: true, - }); - - return () => { - document.removeEventListener("pointerdown", handlePointerDown, { - capture: true, - }); - }; - }, []); - const handleStoppingConditionChange = ( condition: "indefinitely" | "fixed", ) => { - logPlaybackControlsDebug({ - hypothesisId: "C,E", - location: "playback-settings-menu.tsx:handleStoppingConditionChange", - message: "Stopping condition click handler invoked", - data: { - requestedCondition: condition, - hasSimulation, - currentMaxTime: maxTime, - }, - }); - if (condition === "indefinitely") { setMaxTime(null); } else { @@ -289,14 +172,6 @@ export const PlaybackSettingsMenu = () => { positioning={{ placement: "top", gutter: 8 }} lazyMount unmountOnExit - onOpenChange={(details) => { - logPlaybackControlsDebug({ - hypothesisId: "A,D", - location: "playback-settings-menu.tsx:popover-open-change", - message: "Playback settings popover open state changed", - data: { open: details.open }, - }); - }} > @@ -309,23 +184,7 @@ export const PlaybackSettingsMenu = () => { - { - const target = event.target as Element | null; - - logPlaybackControlsDebug({ - hypothesisId: "A,B", - location: "playback-settings-menu.tsx:content-pointerdown", - message: "Popover content pointerdown captured", - data: { - targetTag: target?.tagName, - targetText: target?.textContent?.trim().slice(0, 80), - targetAriaLabel: target?.getAttribute("aria-label"), - }, - }); - }} - > + Playback Controls {/* When pressing play section */} @@ -339,18 +198,7 @@ export const PlaybackSettingsMenu = () => { selected: playMode === "viewOnly", disabled: !isViewOnlyAvailable, })} - onClick={() => { - logPlaybackControlsDebug({ - hypothesisId: "B,C,E", - location: "playback-settings-menu.tsx:view-only-click", - message: "View-only play mode click handler invoked", - data: { - isViewOnlyAvailable, - currentPlayMode: playMode, - }, - }); - isViewOnlyAvailable && setPlayMode("viewOnly"); - }} + onClick={() => isViewOnlyAvailable && setPlayMode("viewOnly")} aria-disabled={!isViewOnlyAvailable} tooltip={ !isViewOnlyAvailable @@ -373,18 +221,7 @@ export const PlaybackSettingsMenu = () => { selected: playMode === "computeBuffer", disabled: !isComputeAvailable, })} - onClick={() => { - logPlaybackControlsDebug({ - hypothesisId: "B,C,E", - location: "playback-settings-menu.tsx:compute-buffer-click", - message: "Compute-buffer play mode click handler invoked", - data: { - isComputeAvailable, - currentPlayMode: playMode, - }, - }); - isComputeAvailable && setPlayMode("computeBuffer"); - }} + onClick={() => isComputeAvailable && setPlayMode("computeBuffer")} aria-disabled={!isComputeAvailable} tooltip={ !isComputeAvailable @@ -405,18 +242,7 @@ export const PlaybackSettingsMenu = () => { selected: playMode === "computeMax", disabled: !isComputeAvailable, })} - onClick={() => { - logPlaybackControlsDebug({ - hypothesisId: "B,C,E", - location: "playback-settings-menu.tsx:compute-max-click", - message: "Compute-max play mode click handler invoked", - data: { - isComputeAvailable, - currentPlayMode: playMode, - }, - }); - isComputeAvailable && setPlayMode("computeMax"); - }} + onClick={() => isComputeAvailable && setPlayMode("computeMax")} aria-disabled={!isComputeAvailable} tooltip={ !isComputeAvailable @@ -452,18 +278,7 @@ export const PlaybackSettingsMenu = () => { className={speedButtonStyle({ selected: speed === playbackSpeed, })} - onClick={() => { - logPlaybackControlsDebug({ - hypothesisId: "B,E", - location: "playback-settings-menu.tsx:speed-click", - message: "Playback speed click handler invoked", - data: { - requestedSpeed: speed, - currentPlaybackSpeed: playbackSpeed, - }, - }); - setPlaybackSpeed(speed); - }} + onClick={() => setPlaybackSpeed(speed)} > {formatPlaybackSpeed(speed)} @@ -485,16 +300,9 @@ export const PlaybackSettingsMenu = () => { selected: stoppingCondition === "indefinitely", disabled: hasSimulation, })} - onClick={() => { - logPlaybackControlsDebug({ - hypothesisId: "B,C,E", - location: "playback-settings-menu.tsx:indefinite-click", - message: - "Indefinite stopping condition click handler invoked", - data: { hasSimulation, stoppingCondition }, - }); - !hasSimulation && handleStoppingConditionChange("indefinitely"); - }} + onClick={() => + !hasSimulation && handleStoppingConditionChange("indefinitely") + } aria-disabled={hasSimulation} tooltip={ hasSimulation @@ -515,15 +323,9 @@ export const PlaybackSettingsMenu = () => { selected: stoppingCondition === "fixed", disabled: hasSimulation, })} - onClick={() => { - logPlaybackControlsDebug({ - hypothesisId: "B,C,E", - location: "playback-settings-menu.tsx:fixed-click", - message: "Fixed stopping condition click handler invoked", - data: { hasSimulation, stoppingCondition }, - }); - !hasSimulation && handleStoppingConditionChange("fixed"); - }} + onClick={() => + !hasSimulation && handleStoppingConditionChange("fixed") + } aria-disabled={hasSimulation} tooltip={ hasSimulation From 13936c6af2773bf5d4968409635a46c8f6b8d8d7 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 22 May 2026 14:57:23 +0100 Subject: [PATCH 06/21] improve action schemas. remove mutation context. ai prompting improvemetns --- .../petrinaut-core/src/action-schemas.ts | 11 +- .../petrinaut-core/src/actions.test.ts | 73 +- libs/@hashintel/petrinaut-core/src/actions.ts | 52 + libs/@hashintel/petrinaut-core/src/ai.test.ts | 60 +- libs/@hashintel/petrinaut-core/src/ai.ts | 206 +++- .../petrinaut-core/src/command-schemas.ts | 52 + .../petrinaut-core/src/commands.test.ts | 147 +++ .../@hashintel/petrinaut-core/src/commands.ts | 162 +++ .../petrinaut-core/src/handle.test.ts | 42 +- libs/@hashintel/petrinaut-core/src/index.ts | 329 ++--- .../@hashintel/petrinaut-core/src/instance.ts | 211 ++-- .../src/layout/calculate-graph-layout.ts | 122 ++ .../petrinaut-core/src/layout/dimensions.ts | 15 + .../petrinaut-core/src/layout/index.ts | 6 + .../src/schemas/entity-schemas.ts | 438 ++++--- .../src/schemas/metric-schema.ts | 9 +- .../src/schemas/scenario-schema.ts | 37 +- .../src/validation/variable-name.ts | 2 +- libs/@hashintel/petrinaut/src/main.ts | 84 +- .../petrinaut/src/react/hooks/index.ts | 14 +- .../petrinaut/src/react/hooks/use-document.ts | 11 - .../hooks/use-petrinaut-commands.test.tsx | 268 ++++ .../src/react/hooks/use-petrinaut-commands.ts | 40 + .../hooks/use-petrinaut-mutations.test.tsx | 367 ++++++ .../react/hooks/use-petrinaut-mutations.ts | 97 ++ .../src/react/mutation-provider.test.tsx | 637 ---------- .../petrinaut/src/react/mutation-provider.tsx | 219 ---- .../src/react/petrinaut-provider.tsx | 5 +- .../src/react/state/mutation-context.ts | 46 - .../src/react/state/use-is-read-only.ts | 23 +- .../src/react/state/use-read-only-reason.ts | 59 + .../src/react/use-petrinaut-instance.ts | 26 +- .../petrinaut/src/ui/clipboard/clipboard.ts | 70 +- .../src/ui/lib/calculate-graph-layout.ts | 127 -- .../BottomBar/use-keyboard-shortcuts.ts | 370 +++--- .../src/ui/views/Editor/editor-view.tsx | 1094 ++++++++--------- .../subviews/differential-equations-list.tsx | 6 +- .../LeftSideBar/subviews/entities-tree.tsx | 4 +- .../LeftSideBar/subviews/parameters-list.tsx | 6 +- .../LeftSideBar/subviews/types-list.tsx | 6 +- .../PropertiesPanel/arc-properties/main.tsx | 14 +- .../context.tsx | 4 +- .../differential-equation-properties/main.tsx | 4 +- .../PropertiesPanel/multi-selection-panel.tsx | 6 +- .../Editor/panels/PropertiesPanel/panel.tsx | 4 +- .../parameter-properties/context.tsx | 4 +- .../parameter-properties/main.tsx | 4 +- .../place-properties/context.tsx | 6 +- .../PropertiesPanel/place-properties/main.tsx | 4 +- .../place-properties/subviews/main.tsx | 4 +- .../properties-panel.stories.tsx | 34 +- .../transition-properties/context.tsx | 18 +- .../transition-properties/main.tsx | 10 +- .../transition-properties/subviews/main.tsx | 6 +- .../type-properties/context.tsx | 12 +- .../PropertiesPanel/type-properties/main.tsx | 12 +- .../metrics/create-metric-drawer.tsx | 4 +- .../metrics/view-metric-drawer.tsx | 4 +- .../scenarios/create-scenario-drawer.tsx | 4 +- .../scenarios/view-scenario-drawer.tsx | 4 +- .../Editor/panels/ai-assistant-panel.tsx | 164 ++- .../ai-assistant-surface.stories.tsx | 59 + .../ai-assistant-surface.tsx | 64 + .../apply-auto-layout-widget.test.tsx | 83 ++ .../apply-auto-layout-widget.tsx | 114 ++ .../interactive-tools/registry.ts | 35 + .../interactive-tools/types.ts | 41 + .../ai-assistant-panel/tool-summaries.ts | 60 +- .../Editor/panels/ai-assistant-panel/types.ts | 8 +- .../src/ui/views/Editor/run-auto-layout.ts | 66 - .../SDCPN/hooks/use-apply-node-changes.ts | 4 +- .../src/ui/views/SDCPN/sdcpn-view.tsx | 4 +- 72 files changed, 3719 insertions(+), 2698 deletions(-) create mode 100644 libs/@hashintel/petrinaut-core/src/command-schemas.ts create mode 100644 libs/@hashintel/petrinaut-core/src/commands.test.ts create mode 100644 libs/@hashintel/petrinaut-core/src/commands.ts create mode 100644 libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts create mode 100644 libs/@hashintel/petrinaut-core/src/layout/dimensions.ts create mode 100644 libs/@hashintel/petrinaut-core/src/layout/index.ts create mode 100644 libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts create mode 100644 libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts delete mode 100644 libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx delete mode 100644 libs/@hashintel/petrinaut/src/react/mutation-provider.tsx delete mode 100644 libs/@hashintel/petrinaut/src/react/state/mutation-context.ts create mode 100644 libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts index d2417d0f505..6a940617822 100644 --- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts @@ -278,8 +278,12 @@ export const mutationActionInputSchemas = { .strictObject({ parameterId: idSchema }) .meta({ description: "Remove a net-level parameter." }), addScenario: simulationScenarioSchema.meta({ - description: - "Add a simulation scenario. Include scenarioParameters for key user-tunable assumptions, parameterOverrides keyed by existing net-level parameter IDs, and initialState with per-place content keyed by existing place IDs unless advanced code is required. Omit parameterOverrides or use {} when no net-level parameters need overriding.", + description: [ + "Add a simulation scenario.", + "Include `scenarioParameters` for key user-tunable assumptions (reference them in expressions as `scenario.`).", + "`parameterOverrides` keys MUST be existing net-level parameter IDs; omit the field entirely when nothing is overridden.", + "`initialState.content` keys are place IDs when `type` is `per_place`, but place NAMES when `type` is `code` (note the asymmetry).", + ].join(" "), }), updateScenario: z .strictObject({ @@ -291,7 +295,8 @@ export const mutationActionInputSchemas = { .strictObject({ scenarioId: idSchema }) .meta({ description: "Remove a simulation scenario." }), addMetric: simulationMetricSchema.meta({ - description: "Add a simulation metric.", + description: + "Add a simulation metric (a time-series scalar plotted in the simulate view). Note: metric `code` is a plain function body with `state` in scope — do NOT wrap it in `export default Metric(...)` or any other module syntax.", }), updateMetric: z .strictObject({ diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index eb401f559ce..21f71f2651d 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -39,7 +39,7 @@ describe("Petrinaut core actions", () => { test("adds and updates places", () => { const instance = createInstance(); - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "Queue", colorId: null, @@ -48,13 +48,13 @@ describe("Petrinaut core actions", () => { x: 0, y: 0, }); - instance.updatePlace({ + instance.mutations.updatePlace({ placeId: "place-1", update: { name: "UpdatedQueue", }, }); - instance.updatePlacePosition({ + instance.mutations.updatePlacePosition({ placeId: "place-1", position: { x: 12, y: 24 }, }); @@ -110,7 +110,7 @@ describe("Petrinaut core actions", () => { ], }); - instance.removePlace({ placeId: "place-1" }); + instance.mutations.removePlace({ placeId: "place-1" }); const definition = instance.definition.get(); expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); @@ -138,13 +138,13 @@ describe("Petrinaut core actions", () => { ], }); - instance.updateArcPlace({ + instance.mutations.updateArcPlace({ transitionId: "transition-1", arcDirection: "input", oldPlaceId: "place-1", newPlaceId: "place-3", }); - instance.updateArcPlace({ + instance.mutations.updateArcPlace({ transitionId: "transition-1", arcDirection: "output", oldPlaceId: "place-2", @@ -174,21 +174,21 @@ describe("Petrinaut core actions", () => { ], }); - instance.addTypeElement({ + instance.mutations.addTypeElement({ typeId: "type-1", element: { elementId: "element-3", name: "Charge", type: "integer" }, }); - instance.updateTypeElement({ + instance.mutations.updateTypeElement({ typeId: "type-1", elementId: "element-1", update: { name: "MassKg" }, }); - instance.moveTypeElement({ + instance.mutations.moveTypeElement({ typeId: "type-1", elementId: "element-3", toIndex: 1, }); - instance.removeTypeElement({ + instance.mutations.removeTypeElement({ typeId: "type-1", elementId: "element-2", }); @@ -232,7 +232,7 @@ describe("Petrinaut core actions", () => { ], }); - instance.deleteItemsByIds({ + instance.mutations.deleteItemsByIds({ items: [ { type: "type", id: "type-1" }, { type: "differentialEquation", id: "equation-1" }, @@ -252,7 +252,7 @@ describe("Petrinaut core actions", () => { readonly: true, }); - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "Queue", colorId: null, @@ -269,7 +269,7 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - instance.addPlace({ + instance.mutations.addPlace({ id: "", name: "Queue", colorId: null, @@ -286,7 +286,7 @@ describe("Petrinaut core actions", () => { test("validates callback-updated entities", () => { const instance = createInstance(); - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "Queue", colorId: null, @@ -297,7 +297,7 @@ describe("Petrinaut core actions", () => { }); expect(() => - instance.updatePlace({ + instance.mutations.updatePlace({ placeId: "place-1", update: { name: "", @@ -310,62 +310,65 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - callActionWithUnknownInput(instance.updatePlace, { + callActionWithUnknownInput(instance.mutations.updatePlace, { placeId: "place-1", update: { id: "place-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updatePlace, { + callActionWithUnknownInput(instance.mutations.updatePlace, { placeId: "place-1", update: { x: 10 }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateTransition, { + callActionWithUnknownInput(instance.mutations.updateTransition, { transitionId: "transition-1", update: { inputArcs: [] }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateTransition, { + callActionWithUnknownInput(instance.mutations.updateTransition, { transitionId: "transition-1", update: { y: 10 }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateType, { + callActionWithUnknownInput(instance.mutations.updateType, { typeId: "type-1", update: { elements: [] }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateTypeElement, { + callActionWithUnknownInput(instance.mutations.updateTypeElement, { typeId: "type-1", elementId: "element-1", update: { elementId: "element-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateDifferentialEquation, { - equationId: "equation-1", - update: { id: "equation-2" }, - }), + callActionWithUnknownInput( + instance.mutations.updateDifferentialEquation, + { + equationId: "equation-1", + update: { id: "equation-2" }, + }, + ), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateParameter, { + callActionWithUnknownInput(instance.mutations.updateParameter, { parameterId: "parameter-1", update: { id: "parameter-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateScenario, { + callActionWithUnknownInput(instance.mutations.updateScenario, { scenarioId: "scenario-1", update: { id: "scenario-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateMetric, { + callActionWithUnknownInput(instance.mutations.updateMetric, { metricId: "metric-1", update: { id: "metric-2" }, }), @@ -400,7 +403,7 @@ describe("Petrinaut core actions", () => { }); expect(() => - instance.updateArcPlace({ + instance.mutations.updateArcPlace({ transitionId: "transition-1", arcDirection: "input", oldPlaceId: "place-1", @@ -408,13 +411,13 @@ describe("Petrinaut core actions", () => { }), ).toThrow(); expect(() => - instance.addTypeElement({ + instance.mutations.addTypeElement({ typeId: "type-1", element: { elementId: "element-2", name: "", type: "real" }, }), ).toThrow(); expect(() => - instance.moveTypeElement({ + instance.mutations.moveTypeElement({ typeId: "type-1", elementId: "element-1", toIndex: -1, @@ -433,7 +436,7 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "invalid place name", colorId: null, @@ -445,7 +448,7 @@ describe("Petrinaut core actions", () => { ).toThrow(); expect(() => - instance.addTransition({ + instance.mutations.addTransition({ id: "transition-1", name: "Display Name", inputArcs: [], @@ -463,7 +466,7 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - instance.addScenario({ + instance.mutations.addScenario({ id: "scenario-1", name: "Scenario", scenarioParameters: [ @@ -476,7 +479,7 @@ describe("Petrinaut core actions", () => { ).toThrow(); expect(() => - instance.addScenario({ + instance.mutations.addScenario({ id: "scenario-1", name: "Scenario", scenarioParameters: [ diff --git a/libs/@hashintel/petrinaut-core/src/actions.ts b/libs/@hashintel/petrinaut-core/src/actions.ts index 915aca4a330..783fc4a448c 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.ts @@ -19,6 +19,43 @@ export type MutationHelperFunctions = { ) => void; }; +/** + * Validate that a single place's reference to a differential equation is + * consistent: the equation must exist, the place must have a colour, and the + * equation's `colorId` must match the place's `colorId`. Throws a descriptive + * error when the invariant is violated; otherwise no-ops. + * + * Mirrors the runtime invariant enforced by + * `core/simulation/engine/build-simulation.ts`, but raises at mutation time so + * AI callers see the failure immediately instead of at simulation build. + */ +function assertPlaceDynamicsReferences( + place: SDCPN["places"][number], + equations: SDCPN["differentialEquations"], +): void { + if (place.differentialEquationId === null) { + return; + } + const equation = equations.find( + (eq) => eq.id === place.differentialEquationId, + ); + if (!equation) { + throw new Error( + `Place \`${place.name}\` references differential equation ID \`${place.differentialEquationId}\` which does not exist.`, + ); + } + if (place.colorId === null) { + throw new Error( + `Place \`${place.name}\` has a differential equation but no \`colorId\`. Set the place's \`colorId\` to match the equation's \`colorId\` (\`${String(equation.colorId)}\`).`, + ); + } + if (equation.colorId !== null && equation.colorId !== place.colorId) { + throw new Error( + `Place \`${place.name}\` (colorId \`${place.colorId}\`) references differential equation \`${equation.name}\` (colorId \`${equation.colorId}\`); the equation's \`colorId\` must match the place's \`colorId\`.`, + ); + } +} + export function createPetrinautActions( mutate: (fn: (sdcpn: SDCPN) => void) => void, ): MutationHelperFunctions { @@ -26,6 +63,7 @@ export function createPetrinautActions( addPlace(place) { const parsedPlace = placeSchema.parse(place); mutate((sdcpn) => { + assertPlaceDynamicsReferences(parsedPlace, sdcpn.differentialEquations); sdcpn.places.push(parsedPlace); }); }, @@ -36,6 +74,7 @@ export function createPetrinautActions( if (place.id === parsed.placeId) { Object.assign(place, parsed.update); placeSchema.parse(place); + assertPlaceDynamicsReferences(place, sdcpn.differentialEquations); break; } } @@ -327,6 +366,11 @@ export function createPetrinautActions( const parsedEquation = differentialEquationSchema.parse(equation); mutate((sdcpn) => { sdcpn.differentialEquations.push(parsedEquation); + for (const place of sdcpn.places) { + if (place.differentialEquationId === parsedEquation.id) { + assertPlaceDynamicsReferences(place, sdcpn.differentialEquations); + } + } }); }, updateDifferentialEquation(input) { @@ -337,6 +381,14 @@ export function createPetrinautActions( if (equation.id === parsed.equationId) { Object.assign(equation, parsed.update); differentialEquationSchema.parse(equation); + for (const place of sdcpn.places) { + if (place.differentialEquationId === equation.id) { + assertPlaceDynamicsReferences( + place, + sdcpn.differentialEquations, + ); + } + } break; } } diff --git a/libs/@hashintel/petrinaut-core/src/ai.test.ts b/libs/@hashintel/petrinaut-core/src/ai.test.ts index 881e3669f52..4f18870df0c 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.test.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.test.ts @@ -1,13 +1,27 @@ import { describe, expect, test } from "vitest"; import { - createPetrinautMutationAiToolCallbacks, + aiCommandActionInputSchemas, + createPetrinautAiWritableCallbacks, petrinautAiToolInputSchemas, petrinautAiTools, } from "./ai"; import { createJsonDocHandle } from "./handle"; import { createPetrinaut } from "./instance"; +const createInstance = () => + createPetrinaut({ + document: createJsonDocHandle({ + initial: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }), + }); + describe("Petrinaut AI core exports", () => { test("tool metadata stays aligned with input schemas and has no execute", () => { expect(Object.keys(petrinautAiTools).sort()).toEqual( @@ -22,19 +36,16 @@ describe("Petrinaut AI core exports", () => { } }); + test("AI command schemas are exposed as tools", () => { + for (const name of Object.keys(aiCommandActionInputSchemas)) { + expect(petrinautAiTools).toHaveProperty(name); + } + expect(petrinautAiTools).toHaveProperty("applyAutoLayout"); + }); + test("callback map applies tool inputs to a Petrinaut instance", () => { - const instance = createPetrinaut({ - document: createJsonDocHandle({ - initial: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - }), - }); - const callbacks = createPetrinautMutationAiToolCallbacks(instance); + const instance = createInstance(); + const callbacks = createPetrinautAiWritableCallbacks(instance); callbacks.addPlace({ id: "place-1", @@ -54,18 +65,8 @@ describe("Petrinaut AI core exports", () => { }); test("callback map validates tool inputs before applying them", () => { - const instance = createPetrinaut({ - document: createJsonDocHandle({ - initial: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - }), - }); - const callbacks = createPetrinautMutationAiToolCallbacks(instance); + const instance = createInstance(); + const callbacks = createPetrinautAiWritableCallbacks(instance); expect(() => callbacks.addPlace({ @@ -81,4 +82,13 @@ describe("Petrinaut AI core exports", () => { expect(instance.definition.get().places).toEqual([]); }); + + test("AI writable callbacks include applyAutoLayout from commands", async () => { + const instance = createInstance(); + const callbacks = createPetrinautAiWritableCallbacks(instance); + + expect(typeof callbacks.applyAutoLayout).toBe("function"); + const result = await callbacks.applyAutoLayout(); + expect(result.commitCount).toBe(0); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index a5dedc9b3ae..b22c643604f 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -1,123 +1,168 @@ import { z } from "zod"; import { - mutationActionInputSchemas, - type MutationActionName, + mutationActionInputSchemas, + type MutationActionName, } from "./action-schemas"; -import { probabilisticSatellitesSDCPN } from "./examples/satellites-launcher"; +import { + aiCommandActionInputSchemas, + type AiCommandActionName, +} from "./command-schemas"; import { typedKeys } from "./lib/typed-entries"; import type { Petrinaut } from "./instance"; +import { probabilisticSatellitesSDCPN } from "./examples"; export { - colorSchema, - differentialEquationSchema, - metricSchema, - parameterSchema, - mutationActionInputSchemas, - placeSchema, - scenarioSchema, - transitionSchema, + colorSchema, + differentialEquationSchema, + metricSchema, + parameterSchema, + mutationActionInputSchemas, + placeSchema, + scenarioSchema, + transitionSchema, } from "./action-schemas"; export type { - MutationActionInput as PetrinautAiMutationToolInput, - MutationActionName as PetrinautAiMutationToolName, + MutationActionInput as PetrinautAiMutationToolInput, + MutationActionName as PetrinautAiMutationToolName, } from "./action-schemas"; +export { aiCommandActionInputSchemas } from "./command-schemas"; +export type { + AiCommandActionInput as PetrinautAiCommandToolInput, + AiCommandActionName as PetrinautAiCommandToolName, +} from "./command-schemas"; export type PetrinautAiTool = { - description: string; - inputSchema: InputSchema; + description: string; + inputSchema: InputSchema; }; export type PetrinautAiTools = { - [Name in keyof typeof petrinautAiToolInputSchemas]: PetrinautAiTool< - (typeof petrinautAiToolInputSchemas)[Name] - >; + [Name in keyof typeof petrinautAiToolInputSchemas]: PetrinautAiTool< + (typeof petrinautAiToolInputSchemas)[Name] + >; }; const getSchemaDescription = (schema: z.ZodType): string => { - if (!schema.description) { - throw new Error("Petrinaut AI tool schemas must have descriptions"); - } - return schema.description; + if (!schema.description) { + throw new Error("Petrinaut AI tool schemas must have descriptions"); + } + return schema.description; }; function createToolBundle>( - schemas: InputSchemas, + schemas: InputSchemas, ): { - [Name in keyof InputSchemas]: PetrinautAiTool; + [Name in keyof InputSchemas]: PetrinautAiTool; } { - const tools = {} as { - [Name in keyof InputSchemas]: PetrinautAiTool; - }; - - const setTool = ( - name: Name, - inputSchema: InputSchemas[Name], - ) => { - tools[name] = { - description: getSchemaDescription(inputSchema), - inputSchema, - }; - }; - - for (const name of typedKeys(schemas)) { - setTool(name, schemas[name]); - } - - return tools; + const tools = {} as { + [Name in keyof InputSchemas]: PetrinautAiTool; + }; + + const setTool = ( + name: Name, + inputSchema: InputSchemas[Name], + ) => { + tools[name] = { + description: getSchemaDescription(inputSchema), + inputSchema, + }; + }; + + for (const name of typedKeys(schemas)) { + setTool(name, schemas[name]); + } + + return tools; } export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; export const getNetCompilationErrorsToolName = "getNetCompilationErrors"; const getLatestNetDefinitionToolInputSchema = z - .strictObject({}) - .describe("Get the latest complete Petrinaut SDCPN net definition."); + .strictObject({}) + .describe("Get the latest complete Petrinaut SDCPN net definition."); const getNetCompilationErrorsToolInputSchema = z - .strictObject({}) - .describe( - "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", - ); + .strictObject({}) + .describe( + "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", + ); export const petrinautAiToolInputSchemas = { - ...mutationActionInputSchemas, - [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, - [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, + ...mutationActionInputSchemas, + ...aiCommandActionInputSchemas, + [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, + [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, }; export const petrinautAiMutationTools = createToolBundle( - mutationActionInputSchemas, + mutationActionInputSchemas, +); + +export const petrinautAiCommandTools = createToolBundle( + aiCommandActionInputSchemas, ); export const petrinautAiTools = { - ...petrinautAiMutationTools, - [getLatestNetDefinitionToolName]: { - description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), - inputSchema: getLatestNetDefinitionToolInputSchema, - }, - [getNetCompilationErrorsToolName]: { - description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), - inputSchema: getNetCompilationErrorsToolInputSchema, - }, + ...petrinautAiMutationTools, + ...petrinautAiCommandTools, + [getLatestNetDefinitionToolName]: { + description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), + inputSchema: getLatestNetDefinitionToolInputSchema, + }, + [getNetCompilationErrorsToolName]: { + description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), + inputSchema: getNetCompilationErrorsToolInputSchema, + }, } satisfies PetrinautAiTools; export type PetrinautAiToolName = keyof typeof petrinautAiTools; export type PetrinautAiToolInput = z.input< - (typeof petrinautAiTools)[Name]["inputSchema"] + (typeof petrinautAiTools)[Name]["inputSchema"] >; +/** + * @deprecated Use {@link PetrinautAiWritableCallbacks}. + */ export type PetrinautMutationAiToolCallbacks = Pick< - Petrinaut, - MutationActionName + Petrinaut["mutations"], + MutationActionName >; +/** + * Writable tool callbacks exposed to the AI: every mutation, plus the subset + * of commands registered in {@link aiCommandActionInputSchemas}. Read-only + * tools (e.g. `getLatestNetDefinition`) are handled by the dispatcher + * separately and are not part of this bundle. + */ +export type PetrinautAiWritableCallbacks = Pick< + Petrinaut["mutations"], + MutationActionName +> & + Pick; + +/** + * @deprecated Use {@link createPetrinautAiWritableCallbacks}. + */ export function createPetrinautMutationAiToolCallbacks( - instance: Petrinaut, + instance: Petrinaut, ): PetrinautMutationAiToolCallbacks { - return instance; + return instance.mutations; +} + +export function createPetrinautAiWritableCallbacks( + instance: Petrinaut, +): PetrinautAiWritableCallbacks { + const writable: PetrinautAiWritableCallbacks = { + ...instance.mutations, + } as PetrinautAiWritableCallbacks; + for (const name of typedKeys(aiCommandActionInputSchemas)) { + (writable as Record)[name] = instance.commands[name]; + } + return writable; } export const petrinautAiPrompt = `You are an expert assistant for building Stochastic Dynamic Coloured Petri Nets (SDCPNs) in Petrinaut. @@ -126,7 +171,15 @@ Use the provided tools to directly modify the current net. The tools use Petrina You can check the latest complete net definition at any point using the ${getLatestNetDefinitionToolName} tool. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types. You can check current TypeScript compilation diagnostics at any point using the ${getNetCompilationErrorsToolName} tool. -When the user's intent, requirements, constraints, or preferred modelling process are ambiguous, ask a concise follow-up question before making changes. If the request is clear, proceed with small, purposeful tool calls. +Interview first, build second. Before creating a new net (or adding a substantial new subsystem to an existing one), do NOT jump straight to tool calls. Run a brief, focused interview to establish: + +1. Process structure & timing — the key states/places, the events/transitions between them, capacity or routing constraints, and the typical rates/durations (e.g. arrival rate, mean service time, lifetime, retry interval). Flag where stochastic vs. predicate vs. continuous dynamics seem to fit. +2. Observables & metrics — what the user wants to measure once the model runs (throughput, utilisation, latency, queue length, conversion rate, stockouts, infection fraction, …). Each becomes a \`metric\`. +3. Scenarios — the what-if conditions they want to compare (baseline vs. surge, policy A vs. B, parameter sweeps). Each becomes a \`scenario\`, ideally driven by scenario parameters so they can be tweaked between runs. + +Keep it tight: ask 2–4 grouped questions per turn, not a long form. Restate what you already understand so the user only has to fill gaps. If the request is already concrete and well-scoped (e.g. "fix this lambda", "add an arc from X to Y", "rename this place"), skip the interview and act. + +Escape hatch. Every time you ask questions, explicitly tell the user they can say "make it up", "use sensible defaults", or similar, and you will pick plausible values (with a one-line justification for each major choice) and proceed. Do the same automatically if they reply tersely, with "you decide", or otherwise signal they don't want to specify details. When creating or revising a net: - Prefer small, meaningful mutations rather than replacing unrelated content. @@ -137,8 +190,25 @@ When creating or revising a net: - Use predicate transition lambdas for boolean firing conditions. - Use transition kernels to transform or generate coloured tokens, including stochastic distributions. - Use differential equations only for places whose coloured tokens have continuous dynamics. +- Suggest place visualisations. Once the structure is agreed, proactively propose 1–2 vivid, domain-specific \`visualizerCode\` ideas (e.g. a queue as a stacked bar, satellites as orbit dots, infected population as a heat-dot grid, machines as a row of state-coloured rectangles, inventory as a shelf of boxes) and offer to add them. Default to compact, single-glance SVGs sized for a place node, following the visualizer rules in the code-surface cheatsheet below. - Keep executable code self-contained and readable. +Validate every code-writing change. After any tool call that writes code — lambda, transition kernel, dynamics, visualizer, metric, or scenario code-mode initial state — call ${getNetCompilationErrorsToolName} before continuing and fix any reported diagnostics before relying on the new code. Do not assume a code edit is correct just because the tool call succeeded; mutations only validate the schema, not the runtime contract. + +Place names are part of the code surface: lambdas/kernels read \`input.PlaceName\`, metrics read \`state.places.PlaceName.count\`, and scenario code-mode initial state keys are place names. Renaming a place via \`updatePlace\` requires updating every dependent lambda, kernel, dynamics, metric, visualizer, and scenario in the same batch — otherwise you will silently break references. + +Code-surface cheatsheet (exact shapes expected by the runtime): +- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. \`input.PlaceName\` is a tuple sized to the input arc weight; tokens are \`{ : number }\`. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. +- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` for stochastic attributes; chained \`.map(fn)\` on the same distribution shares one draw. Always required (use \`() => ({})\` when no coloured outputs). +- Differential equation (\`differentialEquation.code\`): \`export default Dynamics((tokens, parameters) => …)\`. \`tokens\` is THIS place's tokens only. Return an array of the same length whose entries are \`{ : derivative }\` (i.e. dx/dt, not the new value). The equation's \`colorId\` MUST match every referencing place's \`colorId\`. +- Place visualizer (\`place.visualizerCode\`): \`export default Visualization(({ tokens, parameters }) => )\`. Classic React runtime — do NOT import React, do NOT use \`<>…\` fragments, do NOT use hooks. Convention: return a sized \`\`. +- Metric (\`metric.code\`): a plain function body — NOT a module, no \`export default\`, no wrapper. The only variable in scope is \`state\`. Must \`return\` a finite number. Example: \`return state.places.Infected.count / (state.places.Susceptible.count + state.places.Infected.count + state.places.Recovered.count);\`. \`parameters\` and \`scenario\` are NOT available inside metrics. +- Scenario per_place initial state: \`content\` keys are place IDs; uncoloured values are expressions with \`parameters\` and \`scenario\` in scope; coloured values are \`number[][]\` rows in colour element order. +- Scenario code-mode initial state: function body returning \`{ PlaceName: tokens }\` keyed by NAME (asymmetric with per_place IDs); unknown names are silently dropped. +- Parameter access in any code surface: use \`parameters.\` where \`\` is the parameter's lower_snake_case \`variableName\` value (e.g. \`parameters.crash_threshold\`, never \`parameters.crashThreshold\`). + +Auto-layout policy. Once you've finished adding or restructuring places and transitions, call \`applyAutoLayout\` so the canvas isn't littered with overlapping nodes at the origin. Pass \`askUserFirst: false\` ONLY when the net was empty at the start of the conversation and you built it from scratch. If user-arranged content existed beforehand — even if you only added a few nodes to it — pass \`askUserFirst: true\` and the user will be shown a Yes/No prompt. If they decline, leave the layout alone and continue without retrying unless they ask. + After calling tools, do not merely summarize the added or updated items, because the user can already see those changes in the UI. Final text should add extra value: explain important modelling choices, assumptions, how the pieces work together, and useful next checks or questions. Here is a compact example Petrinaut document demonstrating coloured tokens, stochastic and predicate transitions, transition kernels with distributions, continuous dynamics, parameters, visualizer code, and scenarios: diff --git a/libs/@hashintel/petrinaut-core/src/command-schemas.ts b/libs/@hashintel/petrinaut-core/src/command-schemas.ts new file mode 100644 index 00000000000..4cb409ce22e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/command-schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +import { clipboardPayloadSchema } from "./clipboard/types"; + +/** + * AI-callable commands. Keys MUST also appear in + * {@link commandActionInputSchemas}. A command must be added here (with a + * full `meta({ description })`) to be exposed to the AI tool bundle. + */ +export const aiCommandActionInputSchemas = { + applyAutoLayout: z + .strictObject({ + askUserFirst: z.boolean().meta({ + description: [ + "Pass `true` to confirm with the user via a Yes/No prompt before applying.", + "Pass `false` to apply immediately.", + "Use `false` ONLY when you just built this net from scratch in the current conversation (no user-arranged content existed beforehand).", + "Otherwise pass `true` so the user can decline — auto-layout will reposition every node.", + ].join(" "), + }), + }) + .meta({ + description: [ + "Reposition every place and transition using an ELK layered layout.", + "Use immediately after creating a net from scratch.", + "For nets that already contained user-positioned nodes, pass `askUserFirst: true`", + "so the user can confirm before running.", + ].join(" "), + }), +} as const; + +/** + * All commands the host can invoke on the instance. Includes AI-callable + * commands plus host-only ones (e.g. clipboard paste) that are intentionally + * absent from the AI tool surface. + */ +export const commandActionInputSchemas = { + ...aiCommandActionInputSchemas, + applyClipboardPaste: z.strictObject({ + payload: clipboardPayloadSchema, + }), +} as const; + +export type CommandActionName = keyof typeof commandActionInputSchemas; +export type AiCommandActionName = keyof typeof aiCommandActionInputSchemas; + +export type CommandActionInput = z.infer< + (typeof commandActionInputSchemas)[Name] +>; +export type AiCommandActionInput = z.infer< + (typeof aiCommandActionInputSchemas)[Name] +>; diff --git a/libs/@hashintel/petrinaut-core/src/commands.test.ts b/libs/@hashintel/petrinaut-core/src/commands.test.ts new file mode 100644 index 00000000000..03f6a4db550 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/commands.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; + +import { + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, +} from "./clipboard/types"; +import { createJsonDocHandle } from "./handle"; +import { createPetrinaut } from "./instance"; +import type { SDCPN } from "./types/sdcpn"; + +const emptySDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const createInstance = (initial: SDCPN = emptySDCPN) => + createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(initial) }), + }); + +const buildClipboardPayload = ( + data: Partial = {}, +): ClipboardPayload => ({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + ...data, + }, +}); + +describe("applyClipboardPaste", () => { + test("returns new IDs for pasted places", () => { + const instance = createInstance(); + + const payload = buildClipboardPayload({ + places: [ + { + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + const { newItemIds } = instance.commands.applyClipboardPaste({ payload }); + const pastedPlace = newItemIds.find((item) => item.type === "place"); + + expect(pastedPlace).toBeDefined(); + expect(instance.definition.get().places).toHaveLength(1); + expect(instance.definition.get().places[0]!.id).toBe(pastedPlace!.id); + }); + + test("throws when the payload fails schema validation", () => { + const instance = createInstance(); + + expect(() => + instance.commands.applyClipboardPaste({ + payload: { + format: "not-petrinaut", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + } as unknown as ClipboardPayload, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); +}); + +describe("applyAutoLayout", () => { + test("no-ops for an empty net", async () => { + const instance = createInstance(); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBe(0); + }); + + test("repositions places when they have non-zero deltas", async () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBeGreaterThan(0); + const places = instance.definition.get().places; + expect(places.map((place) => place.id).sort()).toEqual([ + "place-1", + "place-2", + ]); + expect(places.some((place) => place.x !== 0 || place.y !== 0)).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/commands.ts b/libs/@hashintel/petrinaut-core/src/commands.ts new file mode 100644 index 00000000000..3563148b6b1 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/commands.ts @@ -0,0 +1,162 @@ +import { + colorSchema, + differentialEquationSchema, + parameterSchema, + placeSchema, + transitionSchema, +} from "./action-schemas"; +import { commandActionInputSchemas } from "./command-schemas"; +import { pastePayloadIntoSDCPN } from "./clipboard/paste"; +import type { ClipboardPayload } from "./clipboard/types"; +import { calculateGraphLayout } from "./layout/calculate-graph-layout"; +import { layoutNodeDimensions } from "./layout/dimensions"; +import type { SDCPN } from "./types/sdcpn"; + +export type ApplyClipboardPasteResult = { + newItemIds: Array<{ type: string; id: string }>; +}; + +export type ApplyAutoLayoutResult = { + /** Number of place/transition positions actually changed by the run. */ + commitCount: number; +}; + +/** + * Composite operations the host can invoke. These wrap multiple atomic + * mutations in a single `mutate(...)` so they produce one Automerge change + * (and one undo entry). + * + * Commands are intentionally NOT part of `instance.mutations` — the AI tool + * bundle is derived from `mutationActionInputSchemas` and never auto-exposes + * commands. AI-callable commands must be added explicitly to + * `aiCommandActionInputSchemas` and routed through the AI dispatcher. + */ +export type CommandHelperFunctions = { + /** + * Paste a clipboard payload into the document, generating fresh IDs for + * each item and deduplicating names. Returns the IDs of the newly created + * items so the caller can update selection. + */ + applyClipboardPaste: (input: { + payload: ClipboardPayload; + }) => ApplyClipboardPasteResult; + + /** + * Reposition every place and transition using the ELK layered layout. Runs + * unconditionally — the caller is responsible for confirming with the user + * when applicable (e.g. the AI dispatcher prompts via an interactive chat + * widget when `askUserFirst: true`). + */ + applyAutoLayout: () => Promise; +}; + +const validateNewlyPastedItems = ( + sdcpn: SDCPN, + newItemIds: Array<{ type: string; id: string }>, +): void => { + const idsByType = new Map>(); + for (const item of newItemIds) { + let bucket = idsByType.get(item.type); + if (!bucket) { + bucket = new Set(); + idsByType.set(item.type, bucket); + } + bucket.add(item.id); + } + + const placeIds = idsByType.get("place"); + if (placeIds) { + for (const place of sdcpn.places) { + if (placeIds.has(place.id)) { + placeSchema.parse(place); + } + } + } + + const transitionIds = idsByType.get("transition"); + if (transitionIds) { + for (const transition of sdcpn.transitions) { + if (transitionIds.has(transition.id)) { + transitionSchema.parse(transition); + } + } + } + + const typeIds = idsByType.get("type"); + if (typeIds) { + for (const type of sdcpn.types) { + if (typeIds.has(type.id)) { + colorSchema.parse(type); + } + } + } + + const equationIds = idsByType.get("differentialEquation"); + if (equationIds) { + for (const equation of sdcpn.differentialEquations) { + if (equationIds.has(equation.id)) { + differentialEquationSchema.parse(equation); + } + } + } + + const parameterIds = idsByType.get("parameter"); + if (parameterIds) { + for (const parameter of sdcpn.parameters) { + if (parameterIds.has(parameter.id)) { + parameterSchema.parse(parameter); + } + } + } +}; + +export function createPetrinautCommands( + mutate: (fn: (sdcpn: SDCPN) => void) => void, + read: () => SDCPN, +): CommandHelperFunctions { + return { + applyClipboardPaste(input) { + const { payload } = + commandActionInputSchemas.applyClipboardPaste.parse(input); + let newItemIds: Array<{ type: string; id: string }> = []; + mutate((sdcpn) => { + const result = pastePayloadIntoSDCPN(sdcpn, payload); + newItemIds = result.newItemIds; + validateNewlyPastedItems(sdcpn, newItemIds); + }); + return { newItemIds }; + }, + + async applyAutoLayout() { + const sdcpn = read(); + + if (sdcpn.places.length === 0 && sdcpn.transitions.length === 0) { + return { commitCount: 0 }; + } + + const positions = await calculateGraphLayout(sdcpn, layoutNodeDimensions); + + let commitCount = 0; + mutate((draft) => { + for (const place of draft.places) { + const next = positions[place.id]; + if (next && (place.x !== next.x || place.y !== next.y)) { + place.x = next.x; + place.y = next.y; + commitCount += 1; + } + } + for (const transition of draft.transitions) { + const next = positions[transition.id]; + if (next && (transition.x !== next.x || transition.y !== next.y)) { + transition.x = next.x; + transition.y = next.y; + commitCount += 1; + } + } + }); + + return { commitCount }; + }, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/handle.test.ts b/libs/@hashintel/petrinaut-core/src/handle.test.ts index fcba1f12921..24b5c4755c5 100644 --- a/libs/@hashintel/petrinaut-core/src/handle.test.ts +++ b/libs/@hashintel/petrinaut-core/src/handle.test.ts @@ -68,14 +68,12 @@ describe("createPetrinaut", () => { const seen: SDCPN[] = []; const off = instance.definition.subscribe((value) => seen.push(value)); - instance.mutate((draft) => { - draft.types.push({ - id: "t1", - name: "Color 1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [], - }); + instance.mutations.addType({ + id: "t1", + name: "Color 1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], }); expect(instance.definition.get().types).toHaveLength(1); @@ -92,14 +90,12 @@ describe("createPetrinaut", () => { const seenPatches: number[] = []; instance.patches.subscribe((patches) => seenPatches.push(patches.length)); - instance.mutate((draft) => { - draft.types.push({ - id: "t1", - name: "Color 1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [], - }); + instance.mutations.addType({ + id: "t1", + name: "Color 1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], }); expect(seenPatches.length).toBeGreaterThan(0); @@ -110,14 +106,12 @@ describe("createPetrinaut", () => { const handle = createJsonDocHandle({ initial: empty() }); const instance = createPetrinaut({ document: handle, readonly: true }); - instance.mutate((draft) => { - draft.types.push({ - id: "t1", - name: "Color 1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [], - }); + instance.mutations.addType({ + id: "t1", + name: "Color 1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], }); expect(instance.definition.get().types).toHaveLength(0); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 67553c6b76a..421b974a749 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -5,141 +5,174 @@ // --- Document --- export { - createJsonDocHandle, - type CreateJsonDocHandleOptions, - type DocChangeEvent, - type DocHandleState, - type DocumentId, - type HistoryEntry, - type PetrinautDocHandle, - type PetrinautHistory, - type PetrinautPatch, - type ReadableStore, + createJsonDocHandle, + type CreateJsonDocHandleOptions, + type DocChangeEvent, + type DocHandleState, + type DocumentId, + type HistoryEntry, + type PetrinautDocHandle, + type PetrinautHistory, + type PetrinautPatch, + type ReadableStore, } from "./handle"; // --- Instance --- export { createPetrinaut } from "./instance"; -export type { CreatePetrinautConfig, EventStream, Petrinaut } from "./instance"; +export type { + CreatePetrinautConfig, + EventStream, + Petrinaut, + PetrinautCommands, + PetrinautMutations, +} from "./instance"; export { createPetrinautActions } from "./actions"; export type { MutationHelperFunctions } from "./actions"; +export { createPetrinautCommands } from "./commands"; +export type { + ApplyAutoLayoutResult, + ApplyClipboardPasteResult, + CommandHelperFunctions, +} from "./commands"; +export { + aiCommandActionInputSchemas, + commandActionInputSchemas, +} from "./command-schemas"; +export type { + AiCommandActionInput, + AiCommandActionName, + CommandActionInput, + CommandActionName, +} from "./command-schemas"; +export { + calculateGraphLayout, + layoutNodeDimensions, + type LayoutDimensions, + type NodePosition, +} from "./layout"; // --- AI --- export { - colorSchema, - createPetrinautMutationAiToolCallbacks, - differentialEquationSchema, - getLatestNetDefinitionToolName, - getNetCompilationErrorsToolName, - metricSchema, - parameterSchema, - petrinautAiMutationTools, - petrinautAiPrompt, - petrinautAiTools, - placeSchema, - scenarioSchema, - transitionSchema, + colorSchema, + createPetrinautAiWritableCallbacks, + createPetrinautMutationAiToolCallbacks, + differentialEquationSchema, + getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, + metricSchema, + parameterSchema, + petrinautAiCommandTools, + petrinautAiMutationTools, + petrinautAiPrompt, + petrinautAiTools, + placeSchema, + scenarioSchema, + transitionSchema, } from "./ai"; export type { - PetrinautAiTool, - PetrinautMutationAiToolCallbacks, - PetrinautAiToolInput, - PetrinautAiMutationToolInput, - PetrinautAiMutationToolName, - PetrinautAiToolName, - PetrinautAiTools, + PetrinautAiCommandToolInput, + PetrinautAiCommandToolName, + PetrinautAiTool, + PetrinautAiWritableCallbacks, + PetrinautMutationAiToolCallbacks, + PetrinautAiToolInput, + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, + PetrinautAiToolName, + PetrinautAiTools, } from "./ai"; // --- Simulation --- export { - createMonteCarloExperiment, - createMonteCarloSimulator, - createPlaceTokenCountDistributionMetric, - createSimulation, - createWorkerTransport, + createMonteCarloExperiment, + createMonteCarloSimulator, + createPlaceTokenCountDistributionMetric, + createSimulation, + createWorkerTransport, } from "./simulation"; export type { - BackpressureConfig, - CreateMonteCarloExperimentConfig, - CreateSimulationConfig, - Simulation, - SimulationCompleteEvent, - SimulationConfig, - SimulationErrorEvent, - SimulationEvent, - SimulationFrameReader, - SimulationFrameState, - SimulationFrameSummary, - SimulationPlaceTokenValues, - SimulationState, - SimulationTransport, - WorkerFactory, - InitialMarking, - InitialPlaceMarking, - MonteCarloAdvanceResult, - MonteCarloActiveRunPlaceCountsVisitor, - MonteCarloExperiment, - MonteCarloExperimentDistributions, - MonteCarloExperimentEvent, - MonteCarloExperimentState, - MonteCarloFrameMetric, - MonteCarloFrameMetricContext, - MonteCarloRunConfig, - MonteCarloRunSnapshot, - MonteCarloRunStatus, - MonteCarloRunSummary, - MonteCarloRunUntilCompleteOptions, - MonteCarloSimulator, - MonteCarloSimulatorConfig, - PlaceTokenCountDistributionBin, - PlaceTokenCountDistributionFrame, - PlaceTokenCountDistributionMetric, - PlaceTokenCountDistributionPlace, - MonteCarloWorkerProgress, + BackpressureConfig, + CreateMonteCarloExperimentConfig, + CreateSimulationConfig, + Simulation, + SimulationCompleteEvent, + SimulationConfig, + SimulationErrorEvent, + SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameSummary, + SimulationPlaceTokenValues, + SimulationState, + SimulationTransport, + WorkerFactory, + InitialMarking, + InitialPlaceMarking, + MonteCarloAdvanceResult, + MonteCarloActiveRunPlaceCountsVisitor, + MonteCarloExperiment, + MonteCarloExperimentDistributions, + MonteCarloExperimentEvent, + MonteCarloExperimentState, + MonteCarloFrameMetric, + MonteCarloFrameMetricContext, + MonteCarloRunConfig, + MonteCarloRunSnapshot, + MonteCarloRunStatus, + MonteCarloRunSummary, + MonteCarloRunUntilCompleteOptions, + MonteCarloSimulator, + MonteCarloSimulatorConfig, + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, + PlaceTokenCountDistributionPlace, + MonteCarloWorkerProgress, } from "./simulation"; // --- LSP --- export { - CompletionItemKind, - createLanguageClient, - createWorkerLspTransport, - DiagnosticSeverity, - MarkupKind, - Position, - Range, + CompletionItemKind, + createLanguageClient, + createWorkerLspTransport, + DiagnosticSeverity, + MarkupKind, + Position, + Range, } from "./lsp"; export type { - CompletionItem, - CompletionList, - CreateLanguageClientConfig, - Diagnostic, - DiagnosticsSnapshot, - DocumentUri, - Hover, - LanguageClient, - LspTransport, - LspWorkerFactory, - MarkupContent, - SignatureHelp, - TextDocumentIdentifier, + CompletionItem, + CompletionList, + CreateLanguageClientConfig, + Diagnostic, + DiagnosticsSnapshot, + DocumentUri, + Hover, + LanguageClient, + LspTransport, + LspWorkerFactory, + MarkupContent, + SignatureHelp, + TextDocumentIdentifier, } from "./lsp"; // --- Playback --- export { - createPlayback, - formatPlaybackSpeed, - getPlayModeBackpressure, - PLAYBACK_SPEEDS, + createPlayback, + formatPlaybackSpeed, + getPlayModeBackpressure, + PLAYBACK_SPEEDS, } from "./playback"; export type { - Playback, - ComputePlayMode, - PlaybackSnapshot, - PlaybackSpeed, - PlaybackState, - PlayMode, - PlayModeBackpressure, - TickInput, - TickResult, + Playback, + ComputePlayMode, + PlaybackSnapshot, + PlaybackSpeed, + PlaybackState, + PlayMode, + PlayModeBackpressure, + TickInput, + TickResult, } from "./playback"; // --- Domain types --- @@ -149,21 +182,21 @@ export type * from "./types/selection"; // --- Pure utilities --- export type { - AbortSignalLike, - WorkerFactoryLike, - WorkerLike, + AbortSignalLike, + WorkerFactoryLike, + WorkerLike, } from "./environment"; export { - ARC_ID_PREFIX, - ARC_ID_SEPARATOR, - generateArcId, - type ArcIdPrefix, + ARC_ID_PREFIX, + ARC_ID_SEPARATOR, + generateArcId, + type ArcIdPrefix, } from "./arc-id"; export { GRID_SIZE } from "./grid-size"; export { - type DefaultParameterValues, - deriveDefaultParameterValues, - mergeParameterValues, + type DefaultParameterValues, + deriveDefaultParameterValues, + mergeParameterValues, } from "./parameter-values"; export { SDCPNItemError } from "./errors"; export { isSDCPNEqual } from "./lib/deep-equal"; @@ -171,58 +204,58 @@ export { getNodeConnections } from "./lib/get-connections"; // --- Authoring helpers --- export { - DEFAULT_DIFFERENTIAL_EQUATION_CODE, - DEFAULT_TRANSITION_KERNEL_CODE, - DEFAULT_VISUALIZER_CODE, - generateDefaultDifferentialEquationCode, - generateDefaultLambdaCode, - generateDefaultTransitionKernelCode, - generateDefaultVisualizerCode, + DEFAULT_DIFFERENTIAL_EQUATION_CODE, + DEFAULT_TRANSITION_KERNEL_CODE, + DEFAULT_VISUALIZER_CODE, + generateDefaultDifferentialEquationCode, + generateDefaultLambdaCode, + generateDefaultTransitionKernelCode, + generateDefaultVisualizerCode, } from "./default-codes"; export { - compileMetric, - type CompiledMetric, - type CompileMetricOutcome, - type MetricPlaceState, - type MetricState, + compileMetric, + type CompiledMetric, + type CompileMetricOutcome, + type MetricPlaceState, + type MetricState, } from "./simulation/authoring/metric/compile-metric"; export { - compileScenario, - type CompiledPlaceMarking, - type CompiledScenarioResult, - type CompileScenarioOptions, - type CompileScenarioOutcome, - type ScenarioCompilationError, - type ScenarioParameterValues, + compileScenario, + type CompiledPlaceMarking, + type CompiledScenarioResult, + type CompileScenarioOptions, + type CompileScenarioOutcome, + type ScenarioCompilationError, + type ScenarioParameterValues, } from "./simulation/authoring/scenario/compile-scenario"; export { buildMetricState } from "./simulation/frames/metric-state"; export { - displayNameSchema, - validateDisplayName, + displayNameSchema, + validateDisplayName, } from "./validation/display-name"; export { entityNameSchema, validateEntityName } from "./validation/entity-name"; export { validateVariableName } from "./validation/variable-name"; // --- File, clipboard, and editor protocol helpers --- export { - parseSDCPNFile, - type ImportResult, + parseSDCPNFile, + type ImportResult, } from "./file-format/parse-sdcpn-file"; export { serializeSDCPN } from "./file-format/serialize-sdcpn"; export { sdcpnToTikZ } from "./file-format/sdcpn-to-tikz"; export { pastePayloadIntoSDCPN } from "./clipboard/paste"; export { - parseClipboardPayload, - serializeSelection, + parseClipboardPayload, + serializeSelection, } from "./clipboard/serialize"; export { - CLIPBOARD_FORMAT_VERSION, - clipboardPayloadSchema, - type ClipboardPayload, + CLIPBOARD_FORMAT_VERSION, + clipboardPayloadSchema, + type ClipboardPayload, } from "./clipboard/types"; export { - getDocumentUri, - getMetricDocumentUri, - getScenarioDocumentUri, - parseDocumentUri, + getDocumentUri, + getMetricDocumentUri, + getScenarioDocumentUri, + parseDocumentUri, } from "./lsp/lib/document-uris"; diff --git a/libs/@hashintel/petrinaut-core/src/instance.ts b/libs/@hashintel/petrinaut-core/src/instance.ts index 2113feb5873..0ea6837da94 100644 --- a/libs/@hashintel/petrinaut-core/src/instance.ts +++ b/libs/@hashintel/petrinaut-core/src/instance.ts @@ -1,132 +1,157 @@ import { - createPetrinautActions, - type MutationHelperFunctions, + createPetrinautActions, + type MutationHelperFunctions, } from "./actions"; - +import { + type CommandHelperFunctions, + createPetrinautCommands, +} from "./commands"; import type { - PetrinautDocHandle, - PetrinautPatch, - ReadableStore, + PetrinautDocHandle, + PetrinautPatch, + ReadableStore, } from "./handle"; import type { SDCPN } from "./types/sdcpn"; const EMPTY_SDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], }; export type EventStream = { - subscribe(listener: (event: T) => void): () => void; + subscribe(listener: (event: T) => void): () => void; }; +export type PetrinautMutations = MutationHelperFunctions; +export type PetrinautCommands = CommandHelperFunctions; + /** - * The live document instance. Owns the handle, mutations, and patch stream. + * The live document instance. Owns the handle, mutations, commands, and + * patch stream. + * + * Mutations and commands are namespaced: + * + * - `instance.mutations` — atomic, schema-driven operations keyed by + * `mutationActionInputSchemas`. This is the AI-safe surface; the AI tool + * bundle is derived from these schemas. + * - `instance.commands` — composite host operations (clipboard paste, + * auto-layout). Only the subset registered in `aiCommandActionInputSchemas` + * is exposed to the AI. + * + * There is no top-level `mutate` escape hatch — every write must flow + * through a typed helper so it is schema-validated. * * **Simulation does not live here.** A simulation runs against a frozen SDCPN * snapshot and has no need for the live document. To run one, call * {@link createSimulation} directly with `instance.handle.doc()` (or any other * SDCPN value). The host owns the simulation's lifecycle. */ -export type Petrinaut = MutationHelperFunctions & { - readonly handle: PetrinautDocHandle; +export type Petrinaut = { + readonly handle: PetrinautDocHandle; + + /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ + readonly definition: ReadableStore; - /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ - readonly definition: ReadableStore; + /** Patch event stream. Only fires for handles that produce patches. */ + readonly patches: EventStream; - /** Patch event stream. Only fires for handles that produce patches. */ - readonly patches: EventStream; + /** Atomic, schema-driven mutations. */ + readonly mutations: PetrinautMutations; - /** Apply a mutation to the document via the underlying handle. No-op if read-only. */ - mutate(this: void, fn: (draft: SDCPN) => void): void; + /** Composite host operations (clipboard paste, auto-layout, ...). */ + readonly commands: PetrinautCommands; - readonly readonly: boolean; + readonly readonly: boolean; - dispose(this: void): void; + dispose(this: void): void; }; export type CreatePetrinautConfig = { - document: PetrinautDocHandle; - readonly?: boolean; + document: PetrinautDocHandle; + readonly?: boolean; }; function createDefinitionStore( - handle: PetrinautDocHandle, + handle: PetrinautDocHandle, ): ReadableStore { - const listeners = new Set<(value: SDCPN) => void>(); - - const unsubscribe = handle.subscribe((event) => { - for (const listener of listeners) { - listener(event.next); - } - }); - - return { - get: () => handle.doc() ?? EMPTY_SDCPN, - subscribe(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - // Keep the upstream subscription alive — disposed at instance.dispose(). - void unsubscribe; - } - }; - }, - }; + const listeners = new Set<(value: SDCPN) => void>(); + + const unsubscribe = handle.subscribe((event) => { + for (const listener of listeners) { + listener(event.next); + } + }); + + return { + get: () => handle.doc() ?? EMPTY_SDCPN, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + // Keep the upstream subscription alive — disposed at instance.dispose(). + void unsubscribe; + } + }; + }, + }; } function createPatchStream( - handle: PetrinautDocHandle, + handle: PetrinautDocHandle, ): EventStream { - const listeners = new Set<(event: PetrinautPatch[]) => void>(); - - handle.subscribe((event) => { - if (!event.patches) { - return; - } - for (const listener of listeners) { - listener(event.patches); - } - }); - - return { - subscribe(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; + const listeners = new Set<(event: PetrinautPatch[]) => void>(); + + handle.subscribe((event) => { + if (!event.patches) { + return; + } + for (const listener of listeners) { + listener(event.patches); + } + }); + + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; } export function createPetrinaut(config: CreatePetrinautConfig): Petrinaut { - const { document: handle, readonly = false } = config; - - const disposers: Array<() => void> = []; - - const definition = createDefinitionStore(handle); - const patches = createPatchStream(handle); - const mutate = (fn: (draft: SDCPN) => void) => { - if (readonly) { - return; - } - handle.change(fn); - }; - const actions = createPetrinautActions(mutate); - - return { - ...actions, - handle, - definition, - patches, - mutate, - readonly, - dispose() { - for (const dispose of disposers) { - dispose(); - } - disposers.length = 0; - }, - }; + const { document: handle, readonly = false } = config; + + const disposers: Array<() => void> = []; + + const definition = createDefinitionStore(handle); + const patches = createPatchStream(handle); + + const mutate = (fn: (draft: SDCPN) => void) => { + if (readonly) { + return; + } + handle.change(fn); + }; + + const mutations = createPetrinautActions(mutate); + const commands = createPetrinautCommands(mutate, () => definition.get()); + + return { + handle, + definition, + patches, + mutations, + commands, + readonly, + dispose() { + for (const dispose of disposers) { + dispose(); + } + disposers.length = 0; + }, + }; } diff --git a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts new file mode 100644 index 00000000000..ea7afd4d88d --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts @@ -0,0 +1,122 @@ +import ELK from "elkjs"; + +import type { SDCPN } from "@hashintel/petrinaut-core"; +import type { ElkNode } from "elkjs"; + +/** + * @see https://eclipse.dev/elk/documentation/tooldevelopers + * @see https://rtsys.informatik.uni-kiel.de/elklive/json.html for JSON playground + */ +const elk = new ELK(); + +const graphPadding = 30; + +/** + * @see https://eclipse.dev/elk/reference.html + */ +const elkLayoutOptions: ElkNode["layoutOptions"] = { + "elk.algorithm": "layered", + "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "100", + "elk.direction": "RIGHT", + "elk.padding": `[left=${graphPadding},top=${graphPadding},right=${graphPadding},bottom=${graphPadding}]`, +}; + +export type NodePosition = { + x: number; + y: number; +}; + +export type LayoutDimensions = { + place: { width: number; height: number }; + transition: { width: number; height: number }; +}; + +/** + * Calculates the optimal layout positions for nodes in an SDCPN graph using the ELK (Eclipse Layout Kernel) algorithm. + * + * This is a pure function that takes an SDCPN as input and returns the calculated positions. + * It does not mutate any state or trigger side effects. + * + * ## Design note: layout-stable dimensions + * + * `dimensions` should be **stable across the user's visualization choice** + * (compact vs. classic). Layout output must not depend on rendering mode — + * otherwise toggling `compactNodes` would shift every node and visually + * scramble the graph. Callers should normally pass {@link layoutNodeDimensions} + * from `./dimensions`. + * + * @param sdcpn - The SDCPN to layout + * @param dimensions - Node dimensions for places and transitions; should be + * layout-stable (independent of rendering mode), see note above. + * @returns A promise that resolves to a map of node IDs to their calculated positions + */ +export const calculateGraphLayout = async ( + sdcpn: SDCPN, + dimensions: LayoutDimensions, +): Promise> => { + if (sdcpn.places.length === 0) { + return {}; + } + + const elkNodes: ElkNode["children"] = [ + ...sdcpn.places.map((place) => ({ + id: place.id, + width: dimensions.place.width, + height: dimensions.place.height, + })), + ...sdcpn.transitions.map((transition) => ({ + id: transition.id, + width: dimensions.transition.width, + height: dimensions.transition.height, + })), + ]; + + const elkEdges: ElkNode["edges"] = []; + for (const transition of sdcpn.transitions) { + for (const inputArc of transition.inputArcs) { + elkEdges.push({ + id: `arc__${inputArc.placeId}-${transition.id}`, + sources: [inputArc.placeId], + targets: [transition.id], + }); + } + for (const outputArc of transition.outputArcs) { + elkEdges.push({ + id: `arc__${transition.id}-${outputArc.placeId}`, + sources: [transition.id], + targets: [outputArc.placeId], + }); + } + } + + const graph: ElkNode = { + id: "root", + children: elkNodes, + edges: elkEdges, + layoutOptions: elkLayoutOptions, + }; + + const updatedElements = await elk.layout(graph); + + const placeIds = new Set(sdcpn.places.map((place) => place.id)); + + /** + * ELK returns top-left positions, but the SDCPN store uses center + * coordinates, so we offset by half the node dimensions. + */ + const positionsByNodeId: Record = {}; + for (const child of updatedElements.children ?? []) { + if (child.x !== undefined && child.y !== undefined) { + const nodeDimensions = placeIds.has(child.id) + ? dimensions.place + : dimensions.transition; + + positionsByNodeId[child.id] = { + x: child.x + nodeDimensions.width / 2, + y: child.y + nodeDimensions.height / 2, + }; + } + } + + return positionsByNodeId; +}; diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts new file mode 100644 index 00000000000..5e93b6140f1 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -0,0 +1,15 @@ +import type { LayoutDimensions } from "./calculate-graph-layout"; + +/** + * Layout-stable node dimensions used by {@link calculateGraphLayout}. + * + * Per-axis maximum of the compact and classic rendering dimensions (see + * `ui/views/SDCPN/node-dimensions.ts`) so auto-layout output is invariant to + * the user's compact/classic visualization choice. Without this, toggling + * `userSettings.compactNodes` after running layout would visually shift every + * node. + */ +export const layoutNodeDimensions: LayoutDimensions = { + place: { width: 180, height: 130 }, + transition: { width: 180, height: 80 }, +}; diff --git a/libs/@hashintel/petrinaut-core/src/layout/index.ts b/libs/@hashintel/petrinaut-core/src/layout/index.ts new file mode 100644 index 00000000000..630c48d4435 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/index.ts @@ -0,0 +1,6 @@ +export { + calculateGraphLayout, + type LayoutDimensions, + type NodePosition, +} from "./calculate-graph-layout"; +export { layoutNodeDimensions } from "./dimensions"; diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 0301811ffc4..1adb2d7077e 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -4,236 +4,268 @@ import { displayNameSchema } from "../validation/display-name"; import { entityNameSchema } from "../validation/entity-name"; import type { - Color, - DifferentialEquation, - Parameter, - Place, - Transition, + Color, + DifferentialEquation, + Parameter, + Place, + Transition, } from "../types/sdcpn"; +import { variableNameSchema } from "../validation/variable-name"; export const idSchema = z.string().min(1).meta({ - description: - "Stable identifier for an SDCPN entity. Use unique IDs within the net.", + description: + "Stable identifier for an SDCPN entity. Use unique IDs within the net.", }); export const positionSchema = z - .strictObject({ - x: z.number().meta({ - description: "Horizontal canvas position.", - }), - y: z.number().meta({ - description: "Vertical canvas position.", - }), - }) - .meta({ - description: "Canvas position for a place or transition.", - }); + .strictObject({ + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: "Canvas position for a place or transition.", + }); export const nodePositionCommitSchema = z - .strictObject({ - id: idSchema, - itemType: z.enum(["place", "transition"]).meta({ - description: "Whether the positioned node is a place or transition.", - }), - position: positionSchema, - }) - .meta({ - description: "A pending canvas-position update for one node.", - }); + .strictObject({ + id: idSchema, + itemType: z.enum(["place", "transition"]).meta({ + description: "Whether the positioned node is a place or transition.", + }), + position: positionSchema, + }) + .meta({ + description: "A pending canvas-position update for one node.", + }); export const inputArcSchema = z - .strictObject({ - placeId: idSchema.meta({ - description: "ID of the input place connected to the transition.", - }), - weight: z.number().positive().meta({ - description: "Number of tokens consumed from the input place.", - }), - type: z.enum(["standard", "inhibitor"]).meta({ - description: - "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated.", - }), - }) - .meta({ - description: "Input arc from a place into a transition.", - }); + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the input place connected to the transition.", + }), + weight: z.number().positive().meta({ + description: + "Number of tokens consumed from the input place per firing. For coloured input places this also determines the tuple length the transition's lambda and kernel see at `input.PlaceName` (weight 2 means a 2-token array).", + }), + type: z.enum(["standard", "inhibitor"]).meta({ + description: + "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated. Inhibitor arcs do NOT consume tokens and their place is NOT present in the lambda or kernel `input`.", + }), + }) + .meta({ + description: "Input arc from a place into a transition.", + }); export const outputArcSchema = z - .strictObject({ - placeId: idSchema.meta({ - description: "ID of the output place connected from the transition.", - }), - weight: z.number().positive().meta({ - description: "Number of tokens produced into the output place.", - }), - }) - .meta({ - description: "Output arc from a transition into a place.", - }); + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the output place connected from the transition.", + }), + weight: z.number().positive().meta({ + description: "Number of tokens produced into the output place.", + }), + }) + .meta({ + description: "Output arc from a transition into a place.", + }); export const arcDirectionSchema = z.enum(["input", "output"]).meta({ - description: - "Whether the arc connects a place into a transition or a transition out to a place.", + description: + "Whether the arc connects a place into a transition or a transition out to a place.", }); export const colorElementSchema = z - .strictObject({ - elementId: idSchema.meta({ - description: "Stable identifier for this colour element.", - }), - name: displayNameSchema.meta({ - description: - "Token attribute name used in lambda, kernel, visualizer, and dynamics code.", - }), - type: z.enum(["real", "integer", "boolean"]).meta({ - description: "Primitive token attribute type.", - }), - }) - .meta({ - description: "One typed attribute on a coloured token.", - }); + .strictObject({ + elementId: idSchema.meta({ + description: "Stable identifier for this colour element.", + }), + name: displayNameSchema + .check( + z.refine((val) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(val), { + message: + "Element name must be a valid JavaScript identifier (start with a letter, `_`, or `$`; only letters, digits, `_`, `$` allowed).", + }), + ) + .meta({ + description: + "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: + "Primitive token attribute type. Note: the simulation buffer stores all values as Float64; `integer`/`boolean` are documentation/type-hints only, not enforced at runtime.", + }), + }) + .meta({ + description: "One typed attribute on a coloured token.", + }); export const placeSchema = z - .strictObject({ - id: idSchema, - name: entityNameSchema.meta({ - description: - "PascalCase place name. Use concise names that can be referenced by transition code.", - }), - colorId: idSchema.nullable().meta({ - description: - "ID of the token colour/type accepted by this place, or null for uncoloured token counts.", - }), - dynamicsEnabled: z.boolean().meta({ - description: - "Whether tokens in this place are updated by a differential equation during simulation.", - }), - differentialEquationId: idSchema.nullable().meta({ - description: - "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled.", - }), - visualizerCode: z.string().optional().meta({ - description: - "Optional visualization module code for rendering tokens in this place.", - }), - showAsInitialState: z.boolean().optional().meta({ - description: - "Optional UI hint to show this place in the initial-state view.", - }), - x: z.number().meta({ - description: "Horizontal canvas position.", - }), - y: z.number().meta({ - description: "Vertical canvas position.", - }), - }) - .meta({ - description: - "A Petri net place. Places store tokens and may optionally use colours and continuous dynamics.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: entityNameSchema.meta({ + description: + "PascalCase identifier used DIRECTLY in user code: lambdas and kernels reference input/output places as `input.PlaceName` and `{ PlaceName: [...] }`, metrics access them as `state.places.PlaceName.count`, scenario code-mode initial state keys are place names, and visualizer scope is implicitly per-place. Renaming a place breaks every code reference, so rename only when you also update dependent lambda/kernel/dynamics/metric/visualizer/scenario code in the same batch.", + }), + colorId: idSchema.nullable().meta({ + description: + "ID of the token colour/type accepted by this place, or null for uncoloured token counts. Uncoloured places have no token attributes and do not appear in lambda/kernel `input` objects.", + }), + dynamicsEnabled: z.boolean().meta({ + description: + "Whether tokens in this place are updated by a differential equation during simulation. Dynamics only run when this is true AND `differentialEquationId` is set AND `colorId` is set.", + }), + differentialEquationId: idSchema.nullable().meta({ + description: + "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled. The referenced equation's `colorId` MUST match this place's `colorId`.", + }), + visualizerCode: z.string().optional().meta({ + description: + "Optional module: `export default Visualization(({ tokens, parameters }) => )`. JSX is compiled with React's CLASSIC runtime — do NOT `import React`, do NOT use `<>…` fragments (use `` or explicit elements), and do NOT use hooks; treat it as a pure render. `tokens` is this place's current tokens (only meaningful for coloured places; empty for uncoloured). `parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.crash_threshold`). Convention is to return a sized ``.", + }), + showAsInitialState: z.boolean().optional().meta({ + description: + "Optional UI hint to show this place in the initial-state view.", + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net place. Places store tokens and may optionally use colours and continuous dynamics.", + }) satisfies z.ZodType; export const transitionSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable transition name.", - }), - inputArcs: z.array(inputArcSchema).meta({ - description: - "Input arcs that gate and consume tokens for this transition.", - }), - outputArcs: z.array(outputArcSchema).meta({ - description: - "Output arcs that receive tokens after this transition fires.", - }), - lambdaType: z.enum(["predicate", "stochastic"]).meta({ - description: - "Use predicate for boolean enabling logic; use stochastic for rate-based firing.", - }), - lambdaCode: z.string().meta({ - description: - "JavaScript module code exporting Lambda(...). Predicate lambdas return booleans; stochastic lambdas return rates.", - }), - transitionKernelCode: z.string().meta({ - description: - "Optional JavaScript module code exporting TransitionKernel(...). Use distributions here to create stochastic output token attributes.", - }), - x: z.number().meta({ - description: "Horizontal canvas position.", - }), - y: z.number().meta({ - description: "Vertical canvas position.", - }), - }) - .meta({ - description: - "A Petri net transition. Transitions connect places and define firing logic.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable transition name.", + }), + inputArcs: z.array(inputArcSchema).meta({ + description: + "Input arcs that gate and consume tokens for this transition.", + }), + outputArcs: z.array(outputArcSchema).meta({ + description: + "Output arcs that receive tokens after this transition fires.", + }), + lambdaType: z.enum(["predicate", "stochastic"]).meta({ + description: + "Use predicate for boolean enabling logic; use stochastic for rate-based firing.", + }), + lambdaCode: z.string().meta({ + description: [ + "Module: `export default Lambda((input, parameters) => …)`.", + "`input` is keyed by INPUT PLACE NAME (PascalCase) and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", + "Inhibitor arcs and uncoloured input places are NOT present in `input`.", + "Each token is an object keyed by the colour type's element names (e.g. `{ x, y, velocity }`).", + "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.infection_rate`).", + "Predicate lambdas MUST return a boolean (true = enabled given these tokens, false = disabled).", + "Stochastic lambdas MUST return a non-negative finite number = expected firings per simulation second (0 disables, Infinity always fires).", + "Lambda is called per token combination satisfying arc weights, so it MUST be deterministic — put randomness in the transition kernel, not here.", + ].join(" "), + }), + transitionKernelCode: z.string().meta({ + description: [ + "Module: `export default TransitionKernel((input, parameters) => …)`.", + "`input` and `parameters` have the same shape as the transition's lambda.", + "MUST return an object keyed by OUTPUT PLACE NAME with a tuple sized to that arc's weight. Coloured output places MUST be present; uncoloured output places MUST be omitted (they are auto-populated with empty tokens).", + "Token attribute values can be plain numbers/booleans OR `Distribution.Gaussian(mean, sd)` / `Distribution.Uniform(min, max)` / `Distribution.Lognormal(mu, sigma)`; each distribution is sampled once per token, and chained `.map(fn)` calls on the same distribution share that single sample (useful for deriving multiple attributes from one draw).", + "Always required even when no stochasticity is needed; use `export default TransitionKernel(() => ({}))` when every output place is uncoloured.", + ].join(" "), + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net transition. Transitions connect places and define firing logic.", + }) satisfies z.ZodType; export const colorSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable colour/type name.", - }), - iconSlug: z.string().min(1).meta({ - description: "Icon identifier used by the UI for this colour/type.", - }), - displayColor: z.string().min(1).meta({ - description: "CSS colour used by the UI to display this colour/type.", - }), - elements: z.array(colorElementSchema).meta({ - description: - "Typed token attributes available on tokens of this colour/type.", - }), - }) - .meta({ - description: - "A coloured-token type. Coloured places store token objects with these attributes.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable colour/type name.", + }), + iconSlug: z.string().min(1).meta({ + description: + 'Short icon identifier used by the UI for this colour/type. Typical values are `"circle"` or `"square"`; the UI defaults to `"circle"`.', + }), + displayColor: z.string().min(1).meta({ + description: + 'CSS colour string for the UI badge, e.g. `"#1E90FF"` or `"rgb(30,144,255)"`.', + }), + elements: z.array(colorElementSchema).meta({ + description: + "Typed token attributes available on tokens of this colour/type. Element order matters: coloured initial state in scenario per_place mode supplies `number[][]` rows in this order.", + }), + }) + .meta({ + description: + "A coloured-token type. Coloured places store token objects with these attributes.", + }) satisfies z.ZodType; export const differentialEquationSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable dynamics name.", - }), - colorId: idSchema.nullable().meta({ - description: - "ID of the colour/type whose token attributes this dynamics function updates.", - }), - code: z.string().meta({ - description: - "JavaScript module code exporting Dynamics(...). Return derivatives for each token attribute that changes continuously.", - }), - }) - .meta({ - description: - "A differential equation for continuous dynamics on coloured tokens.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable dynamics name.", + }), + colorId: idSchema.nullable().meta({ + description: + "ID of the colour/type whose token attributes this dynamics function updates. MUST match the `colorId` of every place that references this equation via `differentialEquationId`.", + }), + code: z.string().meta({ + description: [ + "Module: `export default Dynamics((tokens, parameters) => …)`.", + "`tokens` is THIS place's current tokens only — `Array<{ [elementName]: number }>` — NOT all places' tokens.", + "MUST return an array of the SAME LENGTH where each entry is `{ [elementName]: derivative }` (i.e. dx/dt, NOT the new value).", + "The engine integrates with Euler: `next = current + derivative * dt`.", + "Missing keys default to 0 silently, so return every element your colour type declares.", + "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.damage_per_second`).", + ].join(" "), + }), + }) + .meta({ + description: + "A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and the returned derivative keys MUST cover that colour's elements.", + }) satisfies z.ZodType; export const parameterSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable parameter name.", - }), - variableName: z.string().min(1).meta({ - description: - "Identifier used by lambda, kernel, visualizer, metric, and dynamics code.", - }), - type: z.enum(["real", "integer", "boolean"]).meta({ - description: "Primitive parameter type.", - }), - defaultValue: z.string().meta({ - description: - "Default parameter value as an expression string parsed by the simulator.", - }), - }) - .meta({ - description: - "A net-level parameter available to executable SDCPN code and scenarios.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable parameter name.", + }), + variableName: variableNameSchema.meta({ + description: + "lower_snake_case identifier used DIRECTLY in user code as `parameters.` (e.g. `parameters.crash_threshold`, NOT `parameters.crashThreshold`). Must start with a lowercase letter; only `[a-z0-9_]` allowed.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: + "Primitive parameter type. Note: parameter values are stored numerically (booleans coerce via `Number()`); the type is primarily a documentation/UI hint.", + }), + defaultValue: z.string().meta({ + description: + 'Default parameter value as a plain numeric string (e.g. `"3"`, `"0.05"`). Parsed via `Number()` with a `|| 0` fallback, so non-numeric strings silently become 0. Expressions are NOT supported here — use scenario `parameterOverrides` for expressions.', + }), + }) + .meta({ + description: + "A net-level parameter available to executable SDCPN code and scenarios.", + }) satisfies z.ZodType; export type PlaceSchema = typeof placeSchema; export type TransitionSchema = typeof transitionSchema; diff --git a/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts index d83ef73d869..f9165645aaf 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts @@ -15,8 +15,13 @@ export const metricSchema = z description: "Optional metric summary shown to users.", }), code: z.string().meta({ - description: - "JavaScript function body invoked with state in scope. It must return one number.", + description: [ + "Plain function body (NOT a module — no `export default`, no `Metric(...)` wrapper, no enclosing `function` declaration).", + "The only variable in scope is `state`. The body MUST `return` a finite number — NaN, Infinity, and -Infinity throw and the metric series shows an error.", + "Access places by NAME: `state.places.PlaceName.count` (token count for any place) and `state.places.PlaceName.tokens` (`Array<{ [elementName]: number }>` for coloured places; always `[]` for uncoloured places).", + "`parameters` and `scenario` are NOT available inside metrics.", + "Example: `const i = state.places.Infected.count; const r = state.places.Recovered.count; return (i + r) === 0 ? 0 : i / (i + r);`", + ].join(" "), }), }) .meta({ diff --git a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts index cb57aa7e608..1cb5017a610 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts @@ -42,30 +42,38 @@ const initialStateSchema = z z.union([z.string(), z.array(z.array(z.number()))]), ) .meta({ - description: - 'Map from place ID to initial tokens for that place. For uncoloured places, use a string expression that evaluates to the initial token count, for example "scenario.population * scenario.initial_ratio". For coloured places, use number[][] token rows.', + description: [ + "Map keyed by place ID (NOT place name).", + 'For uncoloured places, the value is a string expression with `parameters` and `scenario` in scope (e.g. `"scenario.population * (1 - scenario.infected_ratio)"`). The result is `Math.round`ed and clamped to >= 0 (token counts are always non-negative integers).', + "For coloured places, the value is `number[][]` where each inner array supplies element values in the SAME ORDER as the colour type's `elements`. Extra columns throw at compile time; missing columns default to 0.", + "`parameters` in expressions is keyed by each parameter's `variableName` value (lower_snake_case).", + ].join(" "), }), }) .meta({ description: - "Initial state specified place-by-place. Use this for most scenarios. The content keys must be existing place IDs.", + "Initial state specified place-by-place. Use this for most scenarios. The content keys MUST be existing place IDs.", }), z .strictObject({ type: z.literal("code"), content: z.string().meta({ - description: - "Executable code for advanced initial-state setup. It should return the full initial token mapping by place ID.", + description: [ + "Function body (NOT a module — no `export default`, no wrapper) with `parameters` and `scenario` in scope.", + "MUST `return` an object keyed by PLACE NAME (NOT place ID — note the asymmetry with per_place mode, which uses place IDs).", + "Per-place values: a number for uncoloured places (rounded and clamped to >= 0); `Array<{ [elementName]: number }>` for coloured places.", + "Unknown place names in the returned object are silently dropped — typos produce an empty initial state with no error, so verify names exactly match.", + ].join(" "), }), }) .meta({ description: - "Initial state specified by code. Use only when per_place expressions cannot express the setup.", + "Initial state specified by code. Use only when per_place expressions cannot express the setup (e.g. constructing many coloured tokens from a scenario parameter).", }), ]) .meta({ description: - 'Initial token state for a scenario. Prefer type "per_place" with content keyed by place ID; use type "code" only for advanced custom setup.', + 'Initial token state for a scenario. Prefer type "per_place" (content keyed by place ID); use type "code" (content keyed by place NAME) only for advanced custom setup.', }); export const scenarioSchema = z @@ -96,10 +104,17 @@ export const scenarioSchema = z description: "User-tunable parameters available only within this scenario. Add scenario parameters for important scenario variables so users can adjust them without editing net-level parameters or code. Reference them as scenario.identifier in parameterOverrides and initialState expressions.", }), - parameterOverrides: z.record(z.string(), z.string()).default({}).meta({ - description: - 'Map from existing net-level parameter ID to a concrete value or expression for this scenario. Keys must be parameter IDs from the current net. Values may be literals such as "1.5" or expressions using scenario parameters such as "scenario.transmission_multiplier * 0.4". Omit this field or use {} when the scenario does not override any net-level parameters.', - }), + parameterOverrides: z + .record(z.string(), z.string()) + .default({}) + .meta({ + description: [ + "Map from existing net-level parameter ID to a concrete value or expression for this scenario. Keys MUST be parameter IDs from the current net.", + 'Values may be numeric literals such as `"1.5"` or expressions using `scenario` and `parameters`, e.g. `"scenario.transmission_multiplier * 0.4"`.', + "Inside an override expression, `parameters` resolves to net-level DEFAULTS (not other override results) — overrides cannot reference each other.", + 'Omit this field entirely, or use `{}`, when the scenario does not override any net-level parameters. Do NOT emit `""` as a value (it is a no-op at runtime but adds noise).', + ].join(" "), + }), initialState: initialStateSchema, }) .meta({ diff --git a/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts b/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts index 03ca859cf6a..0ef1c92fb64 100644 --- a/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts +++ b/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts @@ -9,7 +9,7 @@ import { z } from "zod"; */ const LOWER_SNAKE_CASE_REGEX = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; -const variableNameSchema = z +export const variableNameSchema = z .string() .trim() .check( diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 1e11da74587..f7cc1974e1a 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -4,48 +4,48 @@ export { ErrorTrackerContext } from "./react/error-tracker-context"; export type { ViewportAction } from "./ui/types/viewport-action"; export { - createJsonDocHandle, - createPetrinaut, - createPetrinautActions, - createSimulation, - createWorkerTransport, - isSDCPNEqual, - type BackpressureConfig, - type Color, - type CreateJsonDocHandleOptions, - type CreatePetrinautConfig, - type CreateSimulationConfig, - type DifferentialEquation, - type DocChangeEvent, - type DocHandleState, - type DocumentId, - type EventStream, - type HistoryEntry, - type MinimalNetMetadata, - type MutateSDCPN, - type MutationHelperFunctions, - type Parameter, - type PetrinautDocHandle, - type PetrinautHistory, - type Petrinaut as PetrinautInstance, - type PetrinautPatch, - type Place, - type ReadableStore, - type InitialMarking, - type SDCPN, - type Simulation, - type SimulationCompleteEvent, - type SimulationConfig, - type SimulationErrorEvent, - type SimulationEvent, - type SimulationFrameReader, - type SimulationFrameState, - type SimulationFrameSummary, - type SimulationPlaceTokenValues, - type SimulationState, - type SimulationTransport, - type Transition, - type WorkerFactory, + createJsonDocHandle, + createPetrinaut, + createPetrinautActions, + createSimulation, + createWorkerTransport, + isSDCPNEqual, + type BackpressureConfig, + type Color, + type CreateJsonDocHandleOptions, + type CreatePetrinautConfig, + type CreateSimulationConfig, + type DifferentialEquation, + type DocChangeEvent, + type DocHandleState, + type DocumentId, + type EventStream, + type HistoryEntry, + type MinimalNetMetadata, + type MutateSDCPN, + type MutationHelperFunctions, + type Parameter, + type PetrinautDocHandle, + type PetrinautHistory, + type Petrinaut as PetrinautInstance, + type PetrinautPatch, + type Place, + type ReadableStore, + type InitialMarking, + type SDCPN, + type Simulation, + type SimulationCompleteEvent, + type SimulationConfig, + type SimulationErrorEvent, + type SimulationEvent, + type SimulationFrameReader, + type SimulationFrameState, + type SimulationFrameSummary, + type SimulationPlaceTokenValues, + type SimulationState, + type SimulationTransport, + type Transition, + type WorkerFactory, } from "@hashintel/petrinaut-core"; export { Petrinaut } from "./ui/petrinaut"; export type { PetrinautProps } from "./ui/petrinaut"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index dfbca552c62..15dff71836a 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -9,7 +9,6 @@ export { useDocumentId, useDocumentState, useIsDocumentReady, - useMutate, usePetrinautDefinition, usePetrinautDefinitionSelector, usePetrinautPatches, @@ -58,6 +57,19 @@ export { // Re-export the existing read-only hook from its current location. export { useIsReadOnly } from "../state/use-is-read-only"; +export { + formatReadOnlyReason, + useReadOnlyReason, + type ReadOnlyReason, +} from "../state/use-read-only-reason"; + +// Mutation + command surfaces. +export { usePetrinautMutations } from "./use-petrinaut-mutations"; +export { usePetrinautCommands } from "./use-petrinaut-commands"; +export type { + PetrinautCommands, + PetrinautMutations, +} from "../../core/instance"; // Instance access + low-level store adapter. export { usePetrinautInstance } from "../use-petrinaut-instance"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts index b92174806d7..89c85c5bcc1 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts @@ -30,17 +30,6 @@ export function usePetrinautDefinitionSelector( return selector(usePetrinautDefinition()); } -/** - * Apply a mutation to the document. Reads through the Petrinaut instance - * (must be inside `` / ``). - * - * Returns a stable function reference for the lifetime of the instance. - * In read-only mode the call is a no-op. - */ -export function useMutate(): (fn: (draft: SDCPN) => void) => void { - return usePetrinautInstance().mutate; -} - /** Set the current net's title. */ export function useSetTitle(): (title: string) => void { return use(SDCPNContext).setTitle; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx new file mode 100644 index 00000000000..6023836074f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -0,0 +1,268 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { describe, expect, test } from "vitest"; + +import { + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, +} from "../../core/clipboard/types"; +import { createJsonDocHandle } from "../../core/handle"; +import { createPetrinaut, type Petrinaut } from "../../core/instance"; +import type { SDCPN } from "../../core/types/sdcpn"; +import { PetrinautInstanceContext } from "../instance-context"; +import { SimulationContext, type SimulationState } from "../simulation/context"; +import { + EditorContext, + type EditorContextValue, + initialEditorState, +} from "../state/editor-context"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { usePetrinautCommands } from "./use-petrinaut-commands"; + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const editorContextValue = ( + globalMode: "edit" | "simulate" = "edit", +): EditorContextValue => ({ + ...initialEditorState, + globalMode, + setGlobalMode: () => {}, + setEditionMode: () => {}, + setCursorMode: () => {}, + setLeftSidebarOpen: () => {}, + setLeftSidebarWidth: () => {}, + setPropertiesPanelWidth: () => {}, + setBottomPanelOpen: () => {}, + toggleBottomPanel: () => {}, + setBottomPanelHeight: () => {}, + setActiveBottomPanelTab: () => {}, + isSelected: () => false, + isSelectedConnection: () => false, + isNotSelectedConnection: () => false, + isHoveredConnection: () => false, + isNotHoveredConnection: () => false, + selectedConnections: new Map(), + setSelection: () => {}, + selectItem: () => {}, + toggleItem: () => {}, + clearSelection: () => {}, + setHoveredItem: () => {}, + clearHoveredItem: () => {}, + isHovered: () => false, + setDraggingStateByNodeId: () => {}, + updateDraggingStateByNodeId: () => {}, + resetDraggingState: () => {}, + collapseAllPanels: () => {}, + setTimelineChartType: () => {}, + setTimelineView: () => {}, + setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, + setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, + searchInputRef: { current: null }, + triggerPanelAnimation: () => {}, + __reinitialize: () => {}, +}); + +type WrapperOptions = { + sdcpn?: SDCPN; + readonly?: boolean; + globalMode?: "edit" | "simulate"; + simulationState?: SimulationState; +}; + +const createWrapper = (options: WrapperOptions = {}) => { + const { + sdcpn: initialSdcpn = EMPTY_SDCPN, + readonly = false, + globalMode = "edit", + simulationState = "NotRun", + } = options; + + const instance: Petrinaut = createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(initialSdcpn) }), + }); + + const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: instance.definition.get(), + readonly, + setTitle: () => {}, + title: "Test", + getItemType: () => null, + }; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + Promise.resolve(null), + getAllFrames: () => Promise.resolve([]), + getFramesInRange: () => Promise.resolve([]), + setSelectedScenarioId: () => {}, + setScenarioParameterValue: () => {}, + setInitialMarking: () => {}, + setParameterValue: () => {}, + setDt: () => {}, + setMaxTime: () => {}, + initialize: () => Promise.resolve(), + run: () => {}, + pause: () => {}, + reset: () => {}, + setBackpressure: () => {}, + ack: () => {}, + }} + > + + {children} + + + + + ); + + return { Wrapper, instance }; +}; + +const samplePastePayload: ClipboardPayload = { + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [ + { + id: "p1", + name: "Pasted", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 10, + y: 10, + }, + ], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, +}; + +describe("usePetrinautCommands", () => { + describe("when not readonly", () => { + test("applyClipboardPaste pastes new items and reports IDs", () => { + const { Wrapper, instance } = createWrapper(); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + let output: ReturnType; + act(() => { + output = result.current.applyClipboardPaste({ + payload: samplePastePayload, + }); + }); + + expect(output!.newItemIds.some((item) => item.type === "place")).toBe( + true, + ); + expect(instance.definition.get().places).toHaveLength(1); + }); + + test("applyAutoLayout returns commitCount=0 on an empty net", async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + const { commitCount } = await result.current.applyAutoLayout(); + expect(commitCount).toBe(0); + }); + }); + + describe("readonly enforcement", () => { + test("applyClipboardPaste is a no-op when host readonly is true", () => { + const { Wrapper, instance } = createWrapper({ readonly: true }); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + let output: ReturnType; + act(() => { + output = result.current.applyClipboardPaste({ + payload: samplePastePayload, + }); + }); + + expect(output!.newItemIds).toEqual([]); + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("applyClipboardPaste is a no-op when globalMode is simulate", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + let output: ReturnType; + act(() => { + output = result.current.applyClipboardPaste({ + payload: samplePastePayload, + }); + }); + + expect(output!.newItemIds).toEqual([]); + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("applyAutoLayout returns commitCount=0 when readonly", async () => { + const { Wrapper } = createWrapper({ + readonly: true, + sdcpn: { + ...EMPTY_SDCPN, + places: [ + { + id: "p1", + name: "P", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }, + }); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + const { commitCount } = await result.current.applyAutoLayout(); + expect(commitCount).toBe(0); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts new file mode 100644 index 00000000000..313b6cf856a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts @@ -0,0 +1,40 @@ +import { use } from "react"; + +import type { PetrinautCommands } from "../../core/instance"; +import { PetrinautInstanceContext } from "../instance-context"; +import { useIsReadOnly } from "../state/use-is-read-only"; + +/** + * React-facing bundle of composite host commands (clipboard paste, + * auto-layout, ...). + * + * Each command no-ops or returns a default-shaped result when + * {@link useIsReadOnly} returns `true`, matching the behaviour of + * {@link usePetrinautMutations}. Components MUST NOT reach for + * `usePetrinautInstance().commands` directly. + */ +export function usePetrinautCommands(): PetrinautCommands { + const instance = use(PetrinautInstanceContext); + if (!instance) { + throw new Error( + "usePetrinautCommands must be used inside (or ).", + ); + } + const isReadOnly = useIsReadOnly(); + const { commands } = instance; + + return { + applyClipboardPaste(input) { + if (isReadOnly) { + return { newItemIds: [] }; + } + return commands.applyClipboardPaste(input); + }, + async applyAutoLayout() { + if (isReadOnly) { + return { commitCount: 0 }; + } + return commands.applyAutoLayout(); + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx new file mode 100644 index 00000000000..4486f00fafb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -0,0 +1,367 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { describe, expect, test } from "vitest"; + +import { createJsonDocHandle } from "../../core/handle"; +import { createPetrinaut, type Petrinaut } from "../../core/instance"; +import type { SDCPN } from "../../core/types/sdcpn"; +import { PetrinautInstanceContext } from "../instance-context"; +import { SimulationContext, type SimulationState } from "../simulation/context"; +import { + EditorContext, + type EditorContextValue, + initialEditorState, +} from "../state/editor-context"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { usePetrinautMutations } from "./use-petrinaut-mutations"; + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const makeSDCPN = (partial?: Partial): SDCPN => ({ + ...EMPTY_SDCPN, + ...partial, +}); + +const editorContextValue = ( + globalMode: "edit" | "simulate" = "edit", +): EditorContextValue => ({ + ...initialEditorState, + globalMode, + setGlobalMode: () => {}, + setEditionMode: () => {}, + setCursorMode: () => {}, + setLeftSidebarOpen: () => {}, + setLeftSidebarWidth: () => {}, + setPropertiesPanelWidth: () => {}, + setBottomPanelOpen: () => {}, + toggleBottomPanel: () => {}, + setBottomPanelHeight: () => {}, + setActiveBottomPanelTab: () => {}, + isSelected: () => false, + isSelectedConnection: () => false, + isNotSelectedConnection: () => false, + isHoveredConnection: () => false, + isNotHoveredConnection: () => false, + selectedConnections: new Map(), + setSelection: () => {}, + selectItem: () => {}, + toggleItem: () => {}, + clearSelection: () => {}, + setHoveredItem: () => {}, + clearHoveredItem: () => {}, + isHovered: () => false, + setDraggingStateByNodeId: () => {}, + updateDraggingStateByNodeId: () => {}, + resetDraggingState: () => {}, + collapseAllPanels: () => {}, + setTimelineChartType: () => {}, + setTimelineView: () => {}, + setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, + setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, + searchInputRef: { current: null }, + triggerPanelAnimation: () => {}, + __reinitialize: () => {}, +}); + +type WrapperOptions = { + sdcpn?: SDCPN; + readonly?: boolean; + globalMode?: "edit" | "simulate"; + simulationState?: SimulationState; +}; + +const createWrapper = (options: WrapperOptions = {}) => { + const { + sdcpn: initialSdcpn = EMPTY_SDCPN, + readonly = false, + globalMode = "edit", + simulationState = "NotRun", + } = options; + + const instance: Petrinaut = createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(initialSdcpn) }), + }); + + const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: instance.definition.get(), + readonly, + setTitle: () => {}, + title: "Test", + getItemType: () => null, + }; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + Promise.resolve(null), + getAllFrames: () => Promise.resolve([]), + getFramesInRange: () => Promise.resolve([]), + setSelectedScenarioId: () => {}, + setScenarioParameterValue: () => {}, + setInitialMarking: () => {}, + setParameterValue: () => {}, + setDt: () => {}, + setMaxTime: () => {}, + initialize: () => Promise.resolve(), + run: () => {}, + pause: () => {}, + reset: () => {}, + setBackpressure: () => {}, + ack: () => {}, + }} + > + + {children} + + + + + ); + + return { Wrapper, instance }; +}; + +describe("usePetrinautMutations", () => { + describe("when not readonly", () => { + test("addPlace mutates SDCPN", () => { + const { Wrapper, instance } = createWrapper(); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + const places = instance.definition.get().places; + expect(places).toHaveLength(1); + expect(places[0]!.id).toBe("p1"); + }); + + test("commitNodePositions updates positions", () => { + const sdcpn = makeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + const { Wrapper, instance } = createWrapper({ sdcpn }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.commitNodePositions({ + commits: [ + { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, + ], + }); + }); + + expect(instance.definition.get().places[0]!.x).toBe(100); + expect(instance.definition.get().places[0]!.y).toBe(200); + }); + }); + + describe("readonly enforcement", () => { + test("mutations no-op when host readonly prop is true", () => { + const { Wrapper, instance } = createWrapper({ readonly: true }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("mutations no-op when globalMode is simulate", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("mutations no-op when simulation is Running/Paused/Complete", () => { + for (const state of ["Running", "Paused", "Complete"] as const) { + const { Wrapper, instance } = createWrapper({ simulationState: state }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + expect(instance.definition.get().places).toHaveLength(0); + } + }); + + test("scenario mutations still work in simulate mode", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addScenario({ + id: "scenario-1", + name: "Default", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }); + }); + + expect(instance.definition.get().scenarios ?? []).toHaveLength(1); + }); + + test("scenario mutations are blocked by host readonly", () => { + const { Wrapper, instance } = createWrapper({ readonly: true }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addScenario({ + id: "scenario-1", + name: "Default", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }); + }); + + expect(instance.definition.get().scenarios ?? []).toHaveLength(0); + }); + }); + + describe("cascading deletes", () => { + test("removePlace cascades to remove connected arcs", () => { + const sdcpn = makeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "p2", + name: "P2", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + const { Wrapper, instance } = createWrapper({ sdcpn }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.removePlace({ placeId: "p1" }); + }); + + const updated = instance.definition.get(); + expect(updated.places).toHaveLength(1); + expect(updated.places[0]!.id).toBe("p2"); + expect(updated.transitions[0]!.inputArcs).toHaveLength(0); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts new file mode 100644 index 00000000000..21b69052016 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -0,0 +1,97 @@ +import { use } from "react"; + +import type { PetrinautMutations } from "../../core/instance"; +import { PetrinautInstanceContext } from "../instance-context"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { useIsReadOnly } from "../state/use-is-read-only"; + +/** + * Names of mutations that are allowed in simulate mode. Scenario and metric + * CRUD are managed from the Simulate panel — only the host `readonly` flag + * blocks them. + */ +const SCENARIO_MUTATION_NAMES = new Set([ + "addScenario", + "updateScenario", + "removeScenario", + "addMetric", + "updateMetric", + "removeMetric", +]); + +/** + * React-facing bundle of atomic SDCPN mutations. + * + * Each helper is wrapped so that: + * + * - Most mutations no-op when {@link useIsReadOnly} returns `true` (host + * `readonly`, simulate mode, or an active simulation). + * - Scenario/metric mutations only check the host `readonly` flag — they + * remain available in simulate mode where the Simulate panel manages them. + * + * Components MUST NOT reach for `usePetrinautInstance().mutations` directly; + * the public `usePetrinautInstance()` return type narrows away the mutation + * surface to keep the readonly guard centralised in this hook. + */ +export function usePetrinautMutations(): PetrinautMutations { + const instance = use(PetrinautInstanceContext); + if (!instance) { + throw new Error( + "usePetrinautMutations must be used inside (or ).", + ); + } + const { readonly } = use(SDCPNContext); + const isReadOnly = useIsReadOnly(); + const { mutations } = instance; + + const withReadonlyGuard = ( + name: Name, + ): PetrinautMutations[Name] => { + const allowedInSimulate = SCENARIO_MUTATION_NAMES.has(name); + const target = mutations[name] as (input: never) => void; + const wrapped = ((input: never) => { + if (allowedInSimulate ? readonly : isReadOnly) { + return; + } + target(input); + }) as PetrinautMutations[Name]; + return wrapped; + }; + + return { + addPlace: withReadonlyGuard("addPlace"), + updatePlace: withReadonlyGuard("updatePlace"), + updatePlacePosition: withReadonlyGuard("updatePlacePosition"), + removePlace: withReadonlyGuard("removePlace"), + addTransition: withReadonlyGuard("addTransition"), + updateTransition: withReadonlyGuard("updateTransition"), + updateTransitionPosition: withReadonlyGuard("updateTransitionPosition"), + removeTransition: withReadonlyGuard("removeTransition"), + addArc: withReadonlyGuard("addArc"), + removeArc: withReadonlyGuard("removeArc"), + updateArcWeight: withReadonlyGuard("updateArcWeight"), + updateArcType: withReadonlyGuard("updateArcType"), + updateArcPlace: withReadonlyGuard("updateArcPlace"), + addType: withReadonlyGuard("addType"), + updateType: withReadonlyGuard("updateType"), + removeType: withReadonlyGuard("removeType"), + addTypeElement: withReadonlyGuard("addTypeElement"), + updateTypeElement: withReadonlyGuard("updateTypeElement"), + removeTypeElement: withReadonlyGuard("removeTypeElement"), + moveTypeElement: withReadonlyGuard("moveTypeElement"), + addDifferentialEquation: withReadonlyGuard("addDifferentialEquation"), + updateDifferentialEquation: withReadonlyGuard("updateDifferentialEquation"), + removeDifferentialEquation: withReadonlyGuard("removeDifferentialEquation"), + addParameter: withReadonlyGuard("addParameter"), + updateParameter: withReadonlyGuard("updateParameter"), + removeParameter: withReadonlyGuard("removeParameter"), + addScenario: withReadonlyGuard("addScenario"), + updateScenario: withReadonlyGuard("updateScenario"), + removeScenario: withReadonlyGuard("removeScenario"), + addMetric: withReadonlyGuard("addMetric"), + updateMetric: withReadonlyGuard("updateMetric"), + removeMetric: withReadonlyGuard("removeMetric"), + deleteItemsByIds: withReadonlyGuard("deleteItemsByIds"), + commitNodePositions: withReadonlyGuard("commitNodePositions"), + }; +} diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx deleted file mode 100644 index 8cd784c2e8d..00000000000 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx +++ /dev/null @@ -1,637 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { type ReactNode, use } from "react"; -import { describe, expect, test, vi } from "vitest"; - -import { - createPetrinautActions, - type Petrinaut, - type SDCPN, -} from "@hashintel/petrinaut-core"; - -import { PetrinautInstanceContext } from "./instance-context"; -import { MutationProvider } from "./mutation-provider"; -import { - SimulationContext, - type SimulationContextValue, -} from "./simulation/context"; -import { - EditorContext, - type EditorContextValue, - initialEditorState, -} from "./state/editor-context"; -import { MutationContext } from "./state/mutation-context"; -import { SDCPNContext, type SDCPNContextValue } from "./state/sdcpn-context"; -import { - defaultUserSettings, - UserSettingsContext, - type UserSettingsContextValue, -} from "./state/user-settings-context"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const EMPTY_SDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], -}; - -function makeSDCPN(partial?: Partial): SDCPN { - return { ...EMPTY_SDCPN, ...partial }; -} - -const DEFAULT_SIMULATION: SimulationContextValue = { - state: "NotRun", - error: null, - errorItemId: null, - parameterValues: {}, - initialMarking: {}, - selectedScenarioId: null, - scenarioParameterValues: {}, - compiledScenarioResult: null, - dt: 0.01, - maxTime: null, - totalFrames: 0, - getFrame: () => Promise.resolve(null), - getAllFrames: () => Promise.resolve([]), - getFramesInRange: () => Promise.resolve([]), - setSelectedScenarioId: () => {}, - setScenarioParameterValue: () => {}, - setInitialMarking: () => {}, - setParameterValue: () => {}, - setDt: () => {}, - setMaxTime: () => {}, - initialize: () => Promise.resolve(), - run: () => {}, - pause: () => {}, - reset: () => {}, - setBackpressure: () => {}, - ack: () => {}, -}; - -const DEFAULT_EDITOR: EditorContextValue = { - ...initialEditorState, - setGlobalMode: () => {}, - setEditionMode: () => {}, - setCursorMode: () => {}, - setLeftSidebarOpen: () => {}, - setLeftSidebarWidth: () => {}, - setPropertiesPanelWidth: () => {}, - setBottomPanelOpen: () => {}, - toggleBottomPanel: () => {}, - setBottomPanelHeight: () => {}, - setActiveBottomPanelTab: () => {}, - isSelected: () => false, - isSelectedConnection: () => false, - isNotSelectedConnection: () => false, - isHoveredConnection: () => false, - isNotHoveredConnection: () => false, - selectedConnections: new Map(), - setSelection: () => {}, - selectItem: () => {}, - toggleItem: () => {}, - clearSelection: () => {}, - setHoveredItem: () => {}, - clearHoveredItem: () => {}, - isHovered: () => false, - setDraggingStateByNodeId: () => {}, - updateDraggingStateByNodeId: () => {}, - resetDraggingState: () => {}, - collapseAllPanels: () => {}, - setTimelineChartType: () => {}, - setTimelineView: () => {}, - setSimulateViewMode: () => {}, - setSimulateDrawer: () => {}, - setSearchOpen: () => {}, - setAiAssistantOpen: () => {}, - toggleAiAssistant: () => {}, - searchInputRef: { current: null }, - triggerPanelAnimation: () => {}, - __reinitialize: () => {}, -}; - -const DEFAULT_USER_SETTINGS: UserSettingsContextValue = { - ...defaultUserSettings, - setShowAnimations: () => {}, - setKeepPanelsMounted: () => {}, - setCompactNodes: () => {}, - setArcRendering: () => {}, - setIsLeftSidebarOpen: () => {}, - setLeftSidebarWidth: () => {}, - setPropertiesPanelWidth: () => {}, - setIsBottomPanelOpen: () => {}, - setBottomPanelHeight: () => {}, - setActiveBottomPanelTab: () => {}, - setCursorMode: () => {}, - setTimelineChartType: () => {}, - setShowMinimap: () => {}, - setSnapToGrid: () => {}, - setPartialSelection: () => {}, - setUseEntitiesTreeView: () => {}, - updateSubViewSection: () => {}, -}; - -type WrapperOptions = { - sdcpn?: SDCPN; - readonly?: boolean; - globalMode?: "edit" | "simulate"; - simulationState?: SimulationContextValue["state"]; -}; - -/** - * Mounts every context the new bridge {@link MutationProvider} reads — most - * importantly a stub {@link Petrinaut} instance whose `mutate` is the spied - * function under test. - */ -function createWrapper(options: WrapperOptions = {}) { - const { - sdcpn: initialSdcpn = EMPTY_SDCPN, - readonly = false, - globalMode = "edit", - simulationState = "NotRun", - } = options; - - let currentSdcpn = structuredClone(initialSdcpn); - const mutateFn = vi.fn((fn: (sdcpn: SDCPN) => void) => { - const draft = structuredClone(currentSdcpn); - fn(draft); - currentSdcpn = draft; - }); - - const fakeInstance = { - ...createPetrinautActions(mutateFn), - handle: { id: "test-net" }, - definition: { get: () => currentSdcpn, subscribe: () => () => {} }, - patches: { subscribe: () => () => {} }, - mutate: mutateFn, - readonly, - dispose: () => {}, - } as unknown as Petrinaut; - - const sdcpnContextValue: SDCPNContextValue = { - createNewNet: () => {}, - existingNets: [], - loadPetriNet: () => {}, - petriNetId: "test-net", - petriNetDefinition: initialSdcpn, - readonly, - setTitle: () => {}, - title: "Test", - getItemType: () => null, - }; - - const editorContextValue: EditorContextValue = { - ...DEFAULT_EDITOR, - globalMode, - }; - - const simulationContextValue: SimulationContextValue = { - ...DEFAULT_SIMULATION, - state: simulationState, - }; - - const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - - - {children} - - - - - - ); - - return { Wrapper, mutateFn, getSdcpn: () => currentSdcpn }; -} - -function useMutations() { - return use(MutationContext); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("MutationProvider (instance bridge)", () => { - describe("when not readonly", () => { - test("addPlace mutates SDCPN", () => { - const { Wrapper, mutateFn, getSdcpn } = createWrapper(); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addPlace({ - id: "p1", - name: "Place1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - }); - - expect(mutateFn).toHaveBeenCalledTimes(1); - expect(getSdcpn().places).toHaveLength(1); - expect(getSdcpn().places[0]!.id).toBe("p1"); - }); - - test("addTransition mutates SDCPN", () => { - const { Wrapper, mutateFn, getSdcpn } = createWrapper(); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addTransition({ - id: "t1", - name: "Trans1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }); - }); - - expect(mutateFn).toHaveBeenCalledTimes(1); - expect(getSdcpn().transitions).toHaveLength(1); - }); - - test("commitNodePositions updates positions", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - transitions: [ - { - id: "t1", - name: "T1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.commitNodePositions({ - commits: [ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - { - id: "t1", - itemType: "transition", - position: { x: 300, y: 400 }, - }, - ], - }); - }); - - expect(getSdcpn().places[0]!.x).toBe(100); - expect(getSdcpn().places[0]!.y).toBe(200); - expect(getSdcpn().transitions[0]!.x).toBe(300); - expect(getSdcpn().transitions[0]!.y).toBe(400); - }); - }); - - describe("readonly enforcement", () => { - test("mutations no-op when readonly prop is true", () => { - const { Wrapper, mutateFn } = createWrapper({ readonly: true }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addPlace({ - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when globalMode is simulate", () => { - const { Wrapper, mutateFn } = createWrapper({ - globalMode: "simulate", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addPlace({ - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when simulation is Running", () => { - const { Wrapper, mutateFn } = createWrapper({ - simulationState: "Running", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addTransition({ - id: "t1", - name: "T1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when simulation is Paused", () => { - const { Wrapper, mutateFn } = createWrapper({ - simulationState: "Paused", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeType({ typeId: "type-1" }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when simulation is Complete", () => { - const { Wrapper, mutateFn } = createWrapper({ - simulationState: "Complete", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeParameter({ parameterId: "param-1" }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("commitNodePositions no-ops when readonly", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - }); - const { Wrapper, mutateFn } = createWrapper({ - sdcpn, - readonly: true, - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.commitNodePositions({ - commits: [ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - ], - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - }); - - describe("cascading deletes", () => { - test("removePlace cascades to remove connected arcs", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "p2", - name: "P2", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - transitions: [ - { - id: "t1", - name: "T1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removePlace({ placeId: "p1" }); - }); - - expect(getSdcpn().places).toHaveLength(1); - expect(getSdcpn().places[0]!.id).toBe("p2"); - expect(getSdcpn().transitions[0]!.inputArcs).toHaveLength(0); - expect(getSdcpn().transitions[0]!.outputArcs).toHaveLength(1); - }); - - test("removeType cascades to clear colorId on places and equations", () => { - const sdcpn = makeSDCPN({ - types: [ - { - id: "type-1", - name: "MyType", - iconSlug: "circle", - displayColor: "#f00", - elements: [], - }, - ], - places: [ - { - id: "p1", - name: "P1", - colorId: "type-1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - differentialEquations: [ - { - id: "eq-1", - name: "Eq1", - colorId: "type-1", - code: "", - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeType({ typeId: "type-1" }); - }); - - expect(getSdcpn().types).toHaveLength(0); - expect(getSdcpn().places[0]!.colorId).toBeNull(); - expect(getSdcpn().differentialEquations[0]!.colorId).toBeNull(); - }); - - test("removeDifferentialEquation cascades to clear differentialEquationId on places", () => { - const sdcpn = makeSDCPN({ - differentialEquations: [ - { id: "eq-1", name: "Eq1", colorId: null, code: "" }, - ], - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: true, - differentialEquationId: "eq-1", - x: 0, - y: 0, - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeDifferentialEquation({ equationId: "eq-1" }); - }); - - expect(getSdcpn().differentialEquations).toHaveLength(0); - expect(getSdcpn().places[0]!.differentialEquationId).toBeNull(); - }); - - test("deleteItemsByIds handles mixed item types with cascading", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: "type-1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "p2", - name: "P2", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - transitions: [ - { - id: "t1", - name: "T1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - types: [ - { - id: "type-1", - name: "T", - iconSlug: "circle", - displayColor: "#f00", - elements: [], - }, - ], - parameters: [ - { - id: "param-1", - name: "P", - variableName: "p", - type: "real", - defaultValue: "0", - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - const items = new Map([ - ["p1", { type: "place" as const, id: "p1" }], - ["type-1", { type: "type" as const, id: "type-1" }], - ["param-1", { type: "parameter" as const, id: "param-1" }], - ]); - - act(() => { - result.current.deleteItemsByIds({ items: Array.from(items.values()) }); - }); - - const final = getSdcpn(); - expect(final.places).toHaveLength(1); - expect(final.places[0]!.id).toBe("p2"); - expect(final.transitions[0]!.inputArcs).toHaveLength(0); - expect(final.types).toHaveLength(0); - expect(final.parameters).toHaveLength(0); - }); - }); -}); diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx deleted file mode 100644 index 9eac7bee2ba..00000000000 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { use, type ReactNode } from "react"; - -import { - MutationContext, - type MutationContextValue, -} from "./state/mutation-context"; -import { SDCPNContext } from "./state/sdcpn-context"; -import { useIsReadOnly } from "./state/use-is-read-only"; -import { usePetrinautInstance } from "./use-petrinaut-instance"; - -/** - * Provides the mutation context surface, delegating all writes to the Core - * instance's actions. Read-only checks honour the editor mode (which lives in - * `EditorContext`) — only `readonly` blocks scenario mutations. - */ -export const MutationProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const instance = usePetrinautInstance(); - const { readonly } = use(SDCPNContext); - const isReadOnly = useIsReadOnly(); - - function guardedMutate(callback: () => void): void { - if (isReadOnly) { - return; - } - callback(); - } - - /** - * Scenario CRUD is allowed even in simulate mode (the Simulate panel is - * where scenarios are managed). Only true `readonly` blocks them. - */ - function scenarioMutate(callback: () => void): void { - if (readonly) { - return; - } - callback(); - } - - const value: MutationContextValue = { - addPlace(place) { - guardedMutate(() => { - instance.addPlace(place); - }); - }, - updatePlace(input) { - guardedMutate(() => { - instance.updatePlace(input); - }); - }, - updatePlacePosition(input) { - guardedMutate(() => { - instance.updatePlacePosition(input); - }); - }, - removePlace(input) { - guardedMutate(() => { - instance.removePlace(input); - }); - }, - addTransition(transition) { - guardedMutate(() => { - instance.addTransition(transition); - }); - }, - updateTransition(input) { - guardedMutate(() => { - instance.updateTransition(input); - }); - }, - updateTransitionPosition(input) { - guardedMutate(() => { - instance.updateTransitionPosition(input); - }); - }, - removeTransition(input) { - guardedMutate(() => { - instance.removeTransition(input); - }); - }, - addArc(input) { - guardedMutate(() => { - instance.addArc(input); - }); - }, - removeArc(input) { - guardedMutate(() => { - instance.removeArc(input); - }); - }, - updateArcWeight(input) { - guardedMutate(() => { - instance.updateArcWeight(input); - }); - }, - updateArcType(input) { - guardedMutate(() => { - instance.updateArcType(input); - }); - }, - updateArcPlace(input) { - guardedMutate(() => { - instance.updateArcPlace(input); - }); - }, - addType(type) { - guardedMutate(() => { - instance.addType(type); - }); - }, - updateType(input) { - guardedMutate(() => { - instance.updateType(input); - }); - }, - removeType(input) { - guardedMutate(() => { - instance.removeType(input); - }); - }, - addTypeElement(input) { - guardedMutate(() => { - instance.addTypeElement(input); - }); - }, - updateTypeElement(input) { - guardedMutate(() => { - instance.updateTypeElement(input); - }); - }, - removeTypeElement(input) { - guardedMutate(() => { - instance.removeTypeElement(input); - }); - }, - moveTypeElement(input) { - guardedMutate(() => { - instance.moveTypeElement(input); - }); - }, - addDifferentialEquation(equation) { - guardedMutate(() => { - instance.addDifferentialEquation(equation); - }); - }, - updateDifferentialEquation(input) { - guardedMutate(() => { - instance.updateDifferentialEquation(input); - }); - }, - removeDifferentialEquation(input) { - guardedMutate(() => { - instance.removeDifferentialEquation(input); - }); - }, - addParameter(parameter) { - guardedMutate(() => { - instance.addParameter(parameter); - }); - }, - updateParameter(input) { - guardedMutate(() => { - instance.updateParameter(input); - }); - }, - removeParameter(input) { - guardedMutate(() => { - instance.removeParameter(input); - }); - }, - addScenario(scenario) { - scenarioMutate(() => { - instance.addScenario(scenario); - }); - }, - updateScenario(input) { - scenarioMutate(() => { - instance.updateScenario(input); - }); - }, - removeScenario(input) { - scenarioMutate(() => { - instance.removeScenario(input); - }); - }, - addMetric(metric) { - scenarioMutate(() => { - instance.addMetric(metric); - }); - }, - updateMetric(input) { - scenarioMutate(() => { - instance.updateMetric(input); - }); - }, - removeMetric(input) { - scenarioMutate(() => { - instance.removeMetric(input); - }); - }, - deleteItemsByIds(input) { - guardedMutate(() => { - instance.deleteItemsByIds(input); - }); - }, - commitNodePositions(input) { - guardedMutate(() => { - instance.commitNodePositions(input); - }); - }, - }; - - return ( - - {children} - - ); -}; diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index afadfd85d9a..88d65be87bd 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -9,7 +9,6 @@ import { import { ExperimentsProvider } from "./experiments/provider"; import { PetrinautInstanceContext } from "./instance-context"; import { LanguageClientProvider } from "./lsp/provider"; -import { MutationProvider } from "./mutation-provider"; import { NetManagementContext, type NetManagement, @@ -79,9 +78,7 @@ export const PetrinautProvider: React.FC = ({ - - {children} - + {children} diff --git a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts b/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts deleted file mode 100644 index e8b33c411a3..00000000000 --- a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createContext } from "react"; - -import type { MutationHelperFunctions } from "@hashintel/petrinaut-core"; - -export type MutationContextValue = MutationHelperFunctions; - -const DEFAULT_CONTEXT_VALUE: MutationContextValue = { - addPlace: () => {}, - updatePlace: () => {}, - updatePlacePosition: () => {}, - removePlace: () => {}, - addTransition: () => {}, - updateTransition: () => {}, - updateTransitionPosition: () => {}, - removeTransition: () => {}, - addArc: () => {}, - removeArc: () => {}, - updateArcWeight: () => {}, - updateArcType: () => {}, - updateArcPlace: () => {}, - addType: () => {}, - updateType: () => {}, - removeType: () => {}, - addTypeElement: () => {}, - updateTypeElement: () => {}, - removeTypeElement: () => {}, - moveTypeElement: () => {}, - addDifferentialEquation: () => {}, - updateDifferentialEquation: () => {}, - removeDifferentialEquation: () => {}, - addParameter: () => {}, - updateParameter: () => {}, - removeParameter: () => {}, - addScenario: () => {}, - updateScenario: () => {}, - removeScenario: () => {}, - addMetric: () => {}, - updateMetric: () => {}, - removeMetric: () => {}, - deleteItemsByIds: () => {}, - commitNodePositions: () => {}, -}; - -export const MutationContext = createContext( - DEFAULT_CONTEXT_VALUE, -); diff --git a/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts b/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts index 30b76a6aef3..f0cb8a05334 100644 --- a/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts +++ b/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts @@ -1,8 +1,4 @@ -import { use } from "react"; - -import { SimulationContext } from "../simulation/context"; -import { EditorContext } from "./editor-context"; -import { SDCPNContext } from "./sdcpn-context"; +import { useReadOnlyReason } from "./use-read-only-reason"; /** * Hook that determines if the editor is in read-only mode. @@ -12,18 +8,7 @@ import { SDCPNContext } from "./sdcpn-context"; * 2. The global mode is "simulate" (user has switched to simulation mode) * 3. A simulation is currently running, paused, or complete * - * When read-only, structural changes to the SDCPN (places, transitions, arcs, etc.) - * are prevented. + * For a structured refusal reason (e.g. for AI tool feedback), use + * {@link useReadOnlyReason} directly. */ -export const useIsReadOnly = (): boolean => { - const { readonly } = use(SDCPNContext); - const { globalMode } = use(EditorContext); - const { state: simulationState } = use(SimulationContext); - - const isSimulationActive = - simulationState === "Running" || - simulationState === "Paused" || - simulationState === "Complete"; - - return readonly || globalMode === "simulate" || isSimulationActive; -}; +export const useIsReadOnly = (): boolean => useReadOnlyReason() !== null; diff --git a/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts new file mode 100644 index 00000000000..06b7ff75a73 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts @@ -0,0 +1,59 @@ +import { use } from "react"; + +import { SimulationContext } from "../simulation/context"; +import { EditorContext } from "./editor-context"; +import { SDCPNContext } from "./sdcpn-context"; + +/** + * Why the editor currently disallows mutations, or `null` when mutations + * are allowed. + * + * - `host-readonly`: the consumer passed `readonly` to ``. + * - `simulate-mode`: the user has switched to simulate mode. + * - `simulation-active`: a simulation is Running, Paused, or Complete. + */ +export type ReadOnlyReason = + | { kind: "host-readonly" } + | { kind: "simulate-mode" } + | { kind: "simulation-active"; state: "Running" | "Paused" | "Complete" }; + +/** + * Single source of truth for "is the document currently writable" plus a + * structured reason for refusal. UI consumers that only need a boolean can + * use {@link useIsReadOnly}, which collapses this to `reason !== null`. + */ +export const useReadOnlyReason = (): ReadOnlyReason | null => { + const { readonly } = use(SDCPNContext); + const { globalMode } = use(EditorContext); + const { state: simulationState } = use(SimulationContext); + + if (readonly) { + return { kind: "host-readonly" }; + } + if (globalMode === "simulate") { + return { kind: "simulate-mode" }; + } + if ( + simulationState === "Running" || + simulationState === "Paused" || + simulationState === "Complete" + ) { + return { kind: "simulation-active", state: simulationState }; + } + return null; +}; + +/** + * Human-readable explanation for a refusal — used to surface refusal + * feedback to the AI tool dispatcher. + */ +export const formatReadOnlyReason = (reason: ReadOnlyReason): string => { + switch (reason.kind) { + case "host-readonly": + return "This document is read-only; mutations are disabled."; + case "simulate-mode": + return "The editor is in simulate mode. Ask the user to switch reset the simulation before mutating."; + case "simulation-active": + return `A simulation is currently ${reason.state.toLowerCase()}. Ask the user to reset the simulation before mutating.`; + } +}; diff --git a/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts b/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts index d8e973f368f..1fccaa65f93 100644 --- a/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts +++ b/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts @@ -1,15 +1,23 @@ import { use } from "react"; import { PetrinautInstanceContext } from "./instance-context"; - import type { Petrinaut } from "@hashintel/petrinaut-core"; -export function usePetrinautInstance(): Petrinaut { - const instance = use(PetrinautInstanceContext); - if (!instance) { - throw new Error( - "usePetrinautInstance must be used inside (or ).", - ); - } - return instance; +/** + * Public-facing view of the {@link Petrinaut} instance. The mutation and + * command surfaces are intentionally stripped so React components must reach + * them through {@link usePetrinautMutations} / {@link usePetrinautCommands} + * (which apply the read-only guards) rather than calling them directly on + * the raw instance. + */ +export type PetrinautReactInstance = Omit; + +export function usePetrinautInstance(): PetrinautReactInstance { + const instance = use(PetrinautInstanceContext); + if (!instance) { + throw new Error( + "usePetrinautInstance must be used inside (or ).", + ); + } + return instance; } diff --git a/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts b/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts index ad1bc0bec56..2a8206b46f6 100644 --- a/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts +++ b/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts @@ -1,54 +1,50 @@ import { - pastePayloadIntoSDCPN, - parseClipboardPayload, - serializeSelection, - type SDCPN, - type SelectionMap, + parseClipboardPayload, + serializeSelection, + type SDCPN, + type SelectionMap, } from "@hashintel/petrinaut-core"; +import type { PetrinautCommands } from "../../react"; /** * Copy the current selection to the system clipboard. */ export async function copySelectionToClipboard( - sdcpn: SDCPN, - selection: SelectionMap, - documentId: string | null, + sdcpn: SDCPN, + selection: SelectionMap, + documentId: string | null, ): Promise { - const payload = serializeSelection(sdcpn, selection, documentId); - const json = JSON.stringify(payload); - try { - await navigator.clipboard.writeText(json); - } catch { - // Clipboard write can fail (permissions denied, non-secure context, etc.) - } + const payload = serializeSelection(sdcpn, selection, documentId); + const json = JSON.stringify(payload); + try { + await navigator.clipboard.writeText(json); + } catch { + // Clipboard write can fail (permissions denied, non-secure context, etc.) + } } /** - * Read from the system clipboard and paste into the SDCPN. - * Returns the IDs of newly created items (for selection), or null if clipboard - * didn't contain valid petrinaut data. + * Read from the system clipboard and paste into the SDCPN via the typed + * `applyClipboardPaste` command. Returns the IDs of newly created items + * (for selection), or `null` if the clipboard did not contain valid + * petrinaut data. */ export async function pasteFromClipboard( - mutatePetriNetDefinition: (mutateFn: (sdcpn: SDCPN) => void) => void, + applyClipboardPaste: PetrinautCommands["applyClipboardPaste"], ): Promise | null> { - let text: string; - try { - text = await navigator.clipboard.readText(); - } catch { - // Clipboard read can fail (permissions denied, non-secure context, etc.) - return null; - } - const payload = parseClipboardPayload(text); + let text: string; + try { + text = await navigator.clipboard.readText(); + } catch { + // Clipboard read can fail (permissions denied, non-secure context, etc.) + return null; + } + const payload = parseClipboardPayload(text); - if (!payload) { - return null; - } + if (!payload) { + return null; + } - let newItemIds: Array<{ type: string; id: string }> = []; - mutatePetriNetDefinition((sdcpn) => { - const result = pastePayloadIntoSDCPN(sdcpn, payload); - newItemIds = result.newItemIds; - }); - - return newItemIds; + const { newItemIds } = applyClipboardPaste({ payload }); + return newItemIds; } diff --git a/libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts deleted file mode 100644 index c3e10c05515..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts +++ /dev/null @@ -1,127 +0,0 @@ -import ELK from "elkjs"; - -import type { SDCPN } from "@hashintel/petrinaut-core"; -import type { ElkNode } from "elkjs"; - -/** - * @see https://eclipse.dev/elk/documentation/tooldevelopers - * @see https://rtsys.informatik.uni-kiel.de/elklive/json.html for JSON playground - */ -const elk = new ELK(); - -const graphPadding = 30; - -/** - * @see https://eclipse.dev/elk/reference.html - */ -const elkLayoutOptions: ElkNode["layoutOptions"] = { - "elk.algorithm": "layered", - "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "100", - "elk.direction": "RIGHT", - "elk.padding": `[left=${graphPadding},top=${graphPadding},right=${graphPadding},bottom=${graphPadding}]`, -}; - -export type NodePosition = { - x: number; - y: number; -}; - -/** - * Calculates the optimal layout positions for nodes in an SDCPN graph using the ELK (Eclipse Layout Kernel) algorithm. - * - * This is a pure function that takes an SDCPN as input and returns the calculated positions. - * It does not mutate any state or trigger side effects. - * - * ## Design note: layout-stable dimensions - * - * `dimensions` should be **stable across the user's visualization choice** - * (compact vs. classic). Layout output must not depend on rendering mode — - * otherwise toggling `compactNodes` would shift every node and visually - * scramble the graph. Today `runAutoLayout` and the import flow both pass - * whichever rendering dimensions are active, which is a known leak. The - * intended fix is to feed a `layoutNodeDimensions` (per-axis max of compact - * and classic) here instead — see the design note in - * `ui/views/SDCPN/node-dimensions.ts`. - * - * @param sdcpn - The SDCPN to layout - * @param dimensions - Node dimensions for places and transitions; should be - * layout-stable (independent of rendering mode), see note above. - * @returns A promise that resolves to a map of node IDs to their calculated positions - */ -export const calculateGraphLayout = async ( - sdcpn: SDCPN, - dimensions: { - place: { width: number; height: number }; - transition: { width: number; height: number }; - }, -): Promise> => { - if (sdcpn.places.length === 0) { - return {}; - } - - // Build ELK nodes from places and transitions - const elkNodes: ElkNode["children"] = [ - ...sdcpn.places.map((place) => ({ - id: place.id, - width: dimensions.place.width, - height: dimensions.place.height, - })), - ...sdcpn.transitions.map((transition) => ({ - id: transition.id, - width: dimensions.transition.width, - height: dimensions.transition.height, - })), - ]; - - // Build ELK edges from input and output arcs - const elkEdges: ElkNode["edges"] = []; - for (const transition of sdcpn.transitions) { - // Input arcs: place -> transition - for (const inputArc of transition.inputArcs) { - elkEdges.push({ - id: `arc__${inputArc.placeId}-${transition.id}`, - sources: [inputArc.placeId], - targets: [transition.id], - }); - } - // Output arcs: transition -> place - for (const outputArc of transition.outputArcs) { - elkEdges.push({ - id: `arc__${transition.id}-${outputArc.placeId}`, - sources: [transition.id], - targets: [outputArc.placeId], - }); - } - } - - const graph: ElkNode = { - id: "root", - children: elkNodes, - edges: elkEdges, - layoutOptions: elkLayoutOptions, - }; - - const updatedElements = await elk.layout(graph); - - const placeIds = new Set(sdcpn.places.map((place) => place.id)); - - /** - * ELK returns top-left positions, but the SDCPN store uses center - * coordinates, so we offset by half the node dimensions. - */ - const positionsByNodeId: Record = {}; - for (const child of updatedElements.children ?? []) { - if (child.x !== undefined && child.y !== undefined) { - const nodeDimensions = placeIds.has(child.id) - ? dimensions.place - : dimensions.transition; - - positionsByNodeId[child.id] = { - x: child.x + nodeDimensions.width / 2, - y: child.y + nodeDimensions.height / 2, - }; - } - } - - return positionsByNodeId; -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 590138d2a91..56b995d0762 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -1,203 +1,205 @@ import { use, useEffect, useEffectEvent } from "react"; import { EditorContext } from "../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { UndoRedoContext } from "../../../../../react/state/undo-redo-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; -import { usePetrinautInstance } from "../../../../../react/use-petrinaut-instance"; import { - copySelectionToClipboard, - pasteFromClipboard, + copySelectionToClipboard, + pasteFromClipboard, } from "../../../../clipboard/clipboard"; import type { - CursorMode, - EditorState, + CursorMode, + EditorState, } from "../../../../../react/state/editor-context"; import type { SelectionItem } from "@hashintel/petrinaut-core"; +import { + usePetrinautMutations, + usePetrinautCommands, +} from "../../../../../react"; type EditorMode = EditorState["globalMode"]; type EditorEditionMode = EditorState["editionMode"]; export function useKeyboardShortcuts( - mode: EditorMode, - onEditionModeChange: (mode: EditorEditionMode) => void, - onCursorModeChange: (mode: CursorMode) => void, + mode: EditorMode, + onEditionModeChange: (mode: EditorEditionMode) => void, + onCursorModeChange: (mode: CursorMode) => void, ) { - const undoRedo = use(UndoRedoContext); - const { - selection, - hasSelection, - clearSelection, - setSelection, - isSearchOpen, - setSearchOpen, - searchInputRef, - } = use(EditorContext); - const { petriNetDefinition, petriNetId } = use(SDCPNContext); - const { deleteItemsByIds } = use(MutationContext); - const instance = usePetrinautInstance(); - const isReadonly = useIsReadOnly(); - - const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { - const target = event.target as HTMLElement; - - const isInputFocused = - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - target.closest(".monaco-editor") !== null || - target.closest("#sentry-feedback") !== null; - - // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. - if ( - undoRedo && - !isInputFocused && - (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === "z" - ) { - event.preventDefault(); - if (event.shiftKey) { - undoRedo.redo(); - } else { - undoRedo.undo(); - } - return; - } - - // Open search with Ctrl/Cmd+F, or focus input if already open. - // Skip when focus is inside Monaco or another input so their native find works. - if ( - !isInputFocused && - (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === "f" - ) { - event.preventDefault(); - if (isSearchOpen) { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - } else { - setSearchOpen(true); - } - return; - } - - // Escape closes search only when the search input itself is focused - if ( - event.key === "Escape" && - isSearchOpen && - document.activeElement === searchInputRef.current - ) { - event.preventDefault(); - searchInputRef.current?.blur(); - setSearchOpen(false); - return; - } - - // Handle copy/paste/select-all shortcuts (Cmd/Ctrl + C/V/A) - if (!isInputFocused && (event.metaKey || event.ctrlKey)) { - const key = event.key.toLowerCase(); - - if (key === "c" && hasSelection) { - event.preventDefault(); - void copySelectionToClipboard( - petriNetDefinition, - selection, - petriNetId, - ); - return; - } - - if (key === "v" && !isReadonly) { - event.preventDefault(); - void pasteFromClipboard(instance.mutate).then((newItemIds) => { - if (newItemIds && newItemIds.length > 0) { - setSelection( - new Map( - newItemIds.map((item) => [item.id, item as SelectionItem]), - ), - ); - } - }); - return; - } - - if (key === "a") { - event.preventDefault(); - const items = new Map(); - for (const place of petriNetDefinition.places) { - items.set(place.id, { type: "place", id: place.id }); - } - for (const transition of petriNetDefinition.transitions) { - items.set(transition.id, { - type: "transition", - id: transition.id, - }); - } - setSelection(items); - return; - } - } - - if (isInputFocused) { - return; - } - - // Delete selected items with Backspace or Delete - if ( - (event.key === "Delete" || event.key === "Backspace") && - !isReadonly && - hasSelection - ) { - event.preventDefault(); - deleteItemsByIds({ items: Array.from(selection.values()) }); - clearSelection(); - return; - } - - // Check that no modifier keys are pressed - if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { - return; - } - - // Switch modes based on key - switch (event.key.toLowerCase()) { - // If escape is pressed, switch to cursor mode (keep current cursor) - case "escape": - event.preventDefault(); - clearSelection(); - onEditionModeChange("cursor"); - break; - case "v": - event.preventDefault(); - onCursorModeChange("select"); - onEditionModeChange("cursor"); - break; - case "h": - event.preventDefault(); - onCursorModeChange("pan"); - onEditionModeChange("cursor"); - break; - case "n": - if (mode === "edit") { - event.preventDefault(); - onEditionModeChange("add-place"); - } - break; - case "t": - if (mode === "edit") { - event.preventDefault(); - onEditionModeChange("add-transition"); - } - break; - } - }); - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); + const undoRedo = use(UndoRedoContext); + const { + selection, + hasSelection, + clearSelection, + setSelection, + isSearchOpen, + setSearchOpen, + searchInputRef, + } = use(EditorContext); + const { petriNetDefinition, petriNetId } = use(SDCPNContext); + const { deleteItemsByIds } = usePetrinautMutations(); + const { applyClipboardPaste } = usePetrinautCommands(); + const isReadonly = useIsReadOnly(); + + const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { + const target = event.target as HTMLElement; + + const isInputFocused = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest(".monaco-editor") !== null || + target.closest("#sentry-feedback") !== null; + + // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. + if ( + undoRedo && + !isInputFocused && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "z" + ) { + event.preventDefault(); + if (event.shiftKey) { + undoRedo.redo(); + } else { + undoRedo.undo(); + } + return; + } + + // Open search with Ctrl/Cmd+F, or focus input if already open. + // Skip when focus is inside Monaco or another input so their native find works. + if ( + !isInputFocused && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "f" + ) { + event.preventDefault(); + if (isSearchOpen) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else { + setSearchOpen(true); + } + return; + } + + // Escape closes search only when the search input itself is focused + if ( + event.key === "Escape" && + isSearchOpen && + document.activeElement === searchInputRef.current + ) { + event.preventDefault(); + searchInputRef.current?.blur(); + setSearchOpen(false); + return; + } + + // Handle copy/paste/select-all shortcuts (Cmd/Ctrl + C/V/A) + if (!isInputFocused && (event.metaKey || event.ctrlKey)) { + const key = event.key.toLowerCase(); + + if (key === "c" && hasSelection) { + event.preventDefault(); + void copySelectionToClipboard( + petriNetDefinition, + selection, + petriNetId, + ); + return; + } + + if (key === "v" && !isReadonly) { + event.preventDefault(); + void pasteFromClipboard(applyClipboardPaste).then((newItemIds) => { + if (newItemIds && newItemIds.length > 0) { + setSelection( + new Map( + newItemIds.map((item) => [item.id, item as SelectionItem]), + ), + ); + } + }); + return; + } + + if (key === "a") { + event.preventDefault(); + const items = new Map(); + for (const place of petriNetDefinition.places) { + items.set(place.id, { type: "place", id: place.id }); + } + for (const transition of petriNetDefinition.transitions) { + items.set(transition.id, { + type: "transition", + id: transition.id, + }); + } + setSelection(items); + return; + } + } + + if (isInputFocused) { + return; + } + + // Delete selected items with Backspace or Delete + if ( + (event.key === "Delete" || event.key === "Backspace") && + !isReadonly && + hasSelection + ) { + event.preventDefault(); + deleteItemsByIds({ items: Array.from(selection.values()) }); + clearSelection(); + return; + } + + // Check that no modifier keys are pressed + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + + // Switch modes based on key + switch (event.key.toLowerCase()) { + // If escape is pressed, switch to cursor mode (keep current cursor) + case "escape": + event.preventDefault(); + clearSelection(); + onEditionModeChange("cursor"); + break; + case "v": + event.preventDefault(); + onCursorModeChange("select"); + onEditionModeChange("cursor"); + break; + case "h": + event.preventDefault(); + onCursorModeChange("pan"); + onEditionModeChange("cursor"); + break; + case "n": + if (mode === "edit") { + event.preventDefault(); + onEditionModeChange("add-place"); + } + break; + case "t": + if (mode === "edit") { + event.preventDefault(); + onEditionModeChange("add-transition"); + } + break; + } + }); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 53d2f50dd0b..6213c81781d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -1,271 +1,261 @@ import { use, useRef, useState } from "react"; import { css, cx } from "@hashintel/ds-helpers/css"; +import { calculateGraphLayout, type SDCPN } from "@hashintel/petrinaut-core"; import { - deploymentPipelineSDCPN, - probabilisticSatellitesSDCPN, - productionMachines, - satellitesSDCPN, - sirModel, - supplyChainStochasticSDCPN, + deploymentPipelineSDCPN, + probabilisticSatellitesSDCPN, + productionMachines, + satellitesSDCPN, + sirModel, + supplyChainStochasticSDCPN, } from "@hashintel/petrinaut-core/examples"; +import { usePetrinautCommands } from "../../../react"; import { ExperimentsContext } from "../../../react/experiments/context"; -import { Box } from "../../components/box"; -import { Button } from "../../components/button"; -import { Input } from "../../components/input"; -import { Stack } from "../../components/stack"; -import { importSDCPN } from "../../file-io/import-sdcpn"; -import { exportSDCPN } from "../../file-io/export-sdcpn"; -import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { EditorContext } from "../../../react/state/editor-context"; -import { MutationContext } from "../../../react/state/mutation-context"; import { PortalContainerContext } from "../../../react/state/portal-container-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; -import { exportTikZ } from "../../file-io/export-tikz"; import { AiAssistantIcon } from "../../components/ai-assistant-icon"; +import { Box } from "../../components/box"; +import { Button } from "../../components/button"; +import { Input } from "../../components/input"; +import { Stack } from "../../components/stack"; +import { exportSDCPN } from "../../file-io/export-sdcpn"; +import { exportTikZ } from "../../file-io/export-tikz"; +import { importSDCPN } from "../../file-io/import-sdcpn"; import { - classicNodeDimensions, - compactNodeDimensions, + classicNodeDimensions, + compactNodeDimensions, } from "../SDCPN/node-dimensions"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { ImportErrorDialog } from "./components/import-error-dialog"; import { TopBar } from "./components/TopBar/top-bar"; +import { AiAssistantPanel } from "./panels/ai-assistant-panel"; import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; import { SimulateView } from "./panels/SimulateView/simulate-view"; -import { AiAssistantPanel } from "./panels/ai-assistant-panel"; -import { runAutoLayout } from "./run-auto-layout"; -import type { ViewportAction } from "../../types/viewport-action"; import type { PetrinautAiAssistant } from "../../petrinaut"; -import type { SDCPN } from "@hashintel/petrinaut-core"; +import type { ViewportAction } from "../../types/viewport-action"; const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { - numeric: "auto", + numeric: "auto", }); const formatRelativeTime = (isoTimestamp: string): string => { - const diffMs = Date.now() - new Date(isoTimestamp).getTime(); - const diffSecs = Math.round(diffMs / 1_000); - const diffMins = Math.round(diffMs / 60_000); - const diffHours = Math.round(diffMs / 3_600_000); - const diffDays = Math.round(diffMs / 86_400_000); - - if (diffSecs < 60) { - return relativeTimeFormat.format(-diffSecs, "second"); - } else if (diffMins < 60) { - return relativeTimeFormat.format(-diffMins, "minute"); - } else if (diffHours < 24) { - return relativeTimeFormat.format(-diffHours, "hour"); - } else if (diffDays < 30) { - return relativeTimeFormat.format(-diffDays, "day"); - } - return new Intl.DateTimeFormat("en", { - month: "short", - day: "numeric", - }).format(new Date(isoTimestamp)); + const diffMs = Date.now() - new Date(isoTimestamp).getTime(); + const diffSecs = Math.round(diffMs / 1_000); + const diffMins = Math.round(diffMs / 60_000); + const diffHours = Math.round(diffMs / 3_600_000); + const diffDays = Math.round(diffMs / 86_400_000); + + if (diffSecs < 60) { + return relativeTimeFormat.format(-diffSecs, "second"); + } else if (diffMins < 60) { + return relativeTimeFormat.format(-diffMins, "minute"); + } else if (diffHours < 24) { + return relativeTimeFormat.format(-diffHours, "hour"); + } else if (diffDays < 30) { + return relativeTimeFormat.format(-diffDays, "day"); + } + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + }).format(new Date(isoTimestamp)); }; const rowContainerStyle = css({ - height: "full", - userSelect: "none", + height: "full", + userSelect: "none", }); const canvasContainerStyle = css({ - width: "full", - position: "relative", - flexGrow: 1, + width: "full", + position: "relative", + flexGrow: 1, }); const editorRootStyle = css({ - position: "relative", - height: "full", - overflow: "hidden", - backgroundColor: "neutral.s25", + position: "relative", + height: "full", + overflow: "hidden", + backgroundColor: "neutral.s25", }); const portalContainerStyle = css({ - position: "absolute", - top: "0", - left: "0", - width: "full", - height: "full", - zIndex: "99999", - pointerEvents: "none", + position: "absolute", + top: "0", + left: "0", + width: "full", + height: "full", + zIndex: "99999", + pointerEvents: "none", }); const emptyAiHeroLayerStyle = css({ - position: "absolute", - inset: "0", - zIndex: 20, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "8", - pointerEvents: "none", + position: "absolute", + inset: "0", + zIndex: 20, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "8", + pointerEvents: "none", }); const emptyAiHeroStyle = css({ - pointerEvents: "auto", - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: "5", - width: "[min(560px, calc(100% - 48px))]", - padding: "[28px]", - borderRadius: "[24px]", - borderWidth: "thin", - borderStyle: "solid", - borderColor: "blue.a30", - backgroundColor: "white.a95", - boxShadow: - "[0px 20px 60px rgba(15, 23, 42, 0.18), 0px 2px 8px rgba(15, 23, 42, 0.08), inset 0px 1px 0px rgba(255, 255, 255, 0.9)]", - textAlign: "center", - userSelect: "text", - backdropFilter: "[blur(14px)]", + pointerEvents: "auto", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "5", + width: "[min(560px, calc(100% - 48px))]", + padding: "[28px]", + borderRadius: "[24px]", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "blue.a30", + backgroundColor: "white.a95", + boxShadow: + "[0px 20px 60px rgba(15, 23, 42, 0.18), 0px 2px 8px rgba(15, 23, 42, 0.08), inset 0px 1px 0px rgba(255, 255, 255, 0.9)]", + textAlign: "center", + userSelect: "text", + backdropFilter: "[blur(14px)]", }); const emptyAiHeroIconStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - width: "[56px]", - height: "[56px]", - borderRadius: "2xl", - backgroundColor: "blue.s20", - boxShadow: "[0px 0px 0px 8px rgba(42, 128, 200, 0.08)]", - color: "blue.s90", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[56px]", + height: "[56px]", + borderRadius: "2xl", + backgroundColor: "blue.s20", + boxShadow: "[0px 0px 0px 8px rgba(42, 128, 200, 0.08)]", + color: "blue.s90", }); const emptyAiHeroCopyStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - maxWidth: "[420px]", + display: "flex", + flexDirection: "column", + gap: "2", + maxWidth: "[420px]", }); const emptyAiHeroTitleStyle = css({ - margin: "0", - color: "neutral.s110", - fontFamily: "[Inter Tight, Inter, sans-serif]", - fontSize: "[24px]", - fontWeight: "semibold", - lineHeight: "[30px]", -}); - -const emptyAiHeroDescriptionStyle = css({ - margin: "0", - color: "neutral.s80", - fontSize: "sm", - fontWeight: "medium", - lineHeight: "[20px]", + margin: "0", + color: "neutral.s110", + fontFamily: "[Inter Tight, Inter, sans-serif]", + fontSize: "[24px]", + fontWeight: "semibold", + lineHeight: "[30px]", }); const emptyAiHeroFormStyle = css({ - display: "flex", - alignItems: "center", - gap: "2", - width: "full", - padding: "1.5", - borderRadius: "[20px]", - backgroundColor: "neutral.s00", - boxShadow: - "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + padding: "1.5", + borderRadius: "[20px]", + backgroundColor: "neutral.s00", + boxShadow: + "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", }); const emptyAiHeroInputStyle = css({ - flex: "[1]", - minWidth: "[0]", - height: "[48px]", - borderColor: "[transparent]", - backgroundColor: "[transparent]", - boxShadow: "[none]", - fontSize: "base", - _hover: { - borderColor: "[transparent]", - }, - _focus: { - borderColor: "[transparent]", - boxShadow: "[none]", - }, - _active: { - borderColor: "[transparent]", - boxShadow: "[none]", - }, + flex: "[1]", + minWidth: "[0]", + height: "[48px]", + borderColor: "[transparent]", + backgroundColor: "[transparent]", + boxShadow: "[none]", + fontSize: "base", + _hover: { + borderColor: "[transparent]", + }, + _focus: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _active: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, }); const isEmptySDCPN = (sdcpn: SDCPN) => - sdcpn.places.length === 0 && - sdcpn.transitions.length === 0 && - sdcpn.types.length === 0 && - sdcpn.parameters.length === 0 && - sdcpn.differentialEquations.length === 0; + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; const EmptyAiHero = ({ - bottomClearance, - input, - onInputChange, - onSubmit, + bottomClearance, + input, + onInputChange, + onSubmit, }: { - bottomClearance: number; - input: string; - onInputChange: (value: string) => void; - onSubmit: (message: string) => void; + bottomClearance: number; + input: string; + onInputChange: (value: string) => void; + onSubmit: (message: string) => void; }) => { - const canSubmit = input.trim().length > 0; - - return ( -
-
{ - event.preventDefault(); - const trimmedInput = input.trim(); - if (!trimmedInput) { - return; - } - - onSubmit(trimmedInput); - }} - > -
- -
-
-

- Describe the process you want to create -

-
-
- onInputChange(event.currentTarget.value)} - placeholder="e.g. Model an SIR outbreak with recovery" - aria-label="Describe the process you want to create" - size="lg" - /> -
-
-
- ); + const canSubmit = input.trim().length > 0; + + return ( +
+
{ + event.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput) { + return; + } + + onSubmit(trimmedInput); + }} + > +
+ +
+
+

+ Describe the process you want to create +

+
+
+ onInputChange(event.currentTarget.value)} + placeholder="e.g. Model an SIR outbreak with recovery" + aria-label="Describe the process you want to create" + size="lg" + /> +
+
+
+ ); }; /** @@ -273,364 +263,356 @@ const EmptyAiHero = ({ * It relies on sdcpn-store and editor-store for state, and uses SDCPNView for visualization. */ export const EditorView = ({ - aiAssistant, - hideNetManagementControls, - viewportActions, + aiAssistant, + hideNetManagementControls, + viewportActions, }: { - aiAssistant?: PetrinautAiAssistant; - hideNetManagementControls: boolean; - viewportActions?: ViewportAction[]; + aiAssistant?: PetrinautAiAssistant; + hideNetManagementControls: boolean; + viewportActions?: ViewportAction[]; }) => { - // Get data from sdcpn-store - const { - createNewNet, - existingNets, - loadPetriNet, - petriNetDefinition, - title, - setTitle, - } = use(SDCPNContext); - const { commitNodePositions } = use(MutationContext); - - // Get editor context - const { - globalMode: mode, - isAiAssistantOpen, - setGlobalMode, - editionMode, - setEditionMode, - cursorMode, - setCursorMode, - clearSelection, - setSimulateViewMode, - setAiAssistantOpen, - isBottomPanelOpen, - bottomPanelHeight, - } = use(EditorContext); - const { setSelectedExperimentId } = use(ExperimentsContext); - - const { compactNodes } = use(UserSettingsContext); - const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; - const [emptyAiPromptInput, setEmptyAiPromptInput] = useState(""); - const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< - string | null - >(null); - - async function handleLayout() { - await runAutoLayout({ - sdcpn: petriNetDefinition, - dimensions: dims, - commitNodePositions, - }); - } - - const [importError, setImportError] = useState(null); - - // Clean up stale selections when items are deleted - useSelectionCleanup(); - - function handleCreateEmpty() { - createNewNet({ - title: "Untitled", - petriNetDefinition: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - }); - clearSelection(); - } - - function handleNew() { - handleCreateEmpty(); - } - - function handleExport() { - exportSDCPN({ petriNetDefinition, title }); - } - - function handleExportWithoutVisualInfo() { - exportSDCPN({ petriNetDefinition, title, removeVisualInfo: true }); - } - - function handleExportTikZ() { - exportTikZ({ petriNetDefinition, title }); - } - - function handleRunningExperimentClick(experimentId: string) { - setGlobalMode("simulate"); - setSimulateViewMode("experiments"); - setSelectedExperimentId(experimentId); - } - - async function handleImport() { - const result = await importSDCPN(); - if (!result) { - return; // User cancelled file picker - } - - if (!result.ok) { - setImportError(result.error); - return; - } - - const { sdcpn: loadedSDCPN, hadMissingPositions } = result; - let sdcpnToLoad = loadedSDCPN; - - // If any nodes were missing positions, run ELK layout BEFORE creating the net. - // We must do this before createNewNet because after createNewNet triggers a - // re-render, the mutatePetriNetDefinition closure would be stale. - if (hadMissingPositions) { - const positions = await calculateGraphLayout(sdcpnToLoad, dims); - - if (Object.keys(positions).length > 0) { - sdcpnToLoad = { - ...sdcpnToLoad, - places: sdcpnToLoad.places.map((place) => { - const position = positions[place.id]; - return position - ? { ...place, x: position.x, y: position.y } - : place; - }), - transitions: sdcpnToLoad.transitions.map((transition) => { - const position = positions[transition.id]; - return position - ? { ...transition, x: position.x, y: position.y } - : transition; - }), - }; - } - } - - createNewNet({ - title: loadedSDCPN.title, - petriNetDefinition: sdcpnToLoad, - }); - clearSelection(); - } - - const menuItems = [ - ...(!hideNetManagementControls - ? [ - { - id: "new", - label: "New", - onClick: handleNew, - }, - ] - : []), - ...(!hideNetManagementControls && Object.keys(existingNets).length > 0 - ? [ - { - id: "open", - label: "Open", - submenu: existingNets.map((net) => ({ - id: `open-${net.netId}`, - label: net.title, - suffix: formatRelativeTime(net.lastUpdated), - onClick: () => { - loadPetriNet(net.netId); - clearSelection(); - }, - })), - }, - ] - : []), - { - id: "export", - label: "Export", - submenu: [ - { - id: "export-json", - label: "JSON", - onClick: handleExport, - }, - { - id: "export-without-visuals", - label: "JSON without visual info", - onClick: handleExportWithoutVisualInfo, - }, - { - id: "export-tikz", - label: "TikZ", - onClick: handleExportTikZ, - }, - ], - }, - ...(!hideNetManagementControls - ? [ - { - id: "import", - label: "Import", - onClick: handleImport, - }, - ] - : []), - { - id: "layout", - label: "Layout", - onClick: handleLayout, - }, - ...(!hideNetManagementControls - ? [ - { - id: "load-example", - label: "Load example", - submenu: [ - { - id: "load-example-supply-chain-stochastic", - label: "Probabilistic Supply Chain", - onClick: () => { - createNewNet(supplyChainStochasticSDCPN); - clearSelection(); - }, - }, - { - id: "load-example-satellites", - label: "Satellites", - onClick: () => { - createNewNet(satellitesSDCPN); - clearSelection(); - }, - }, - { - id: "load-example-probabilistic-satellites", - label: "Probabilistic Satellites Launcher", - onClick: () => { - createNewNet(probabilisticSatellitesSDCPN); - clearSelection(); - }, - }, - { - id: "load-example-production-machines", - label: "Production Machines", - onClick: () => { - createNewNet(productionMachines); - clearSelection(); - }, - }, - { - id: "load-example-sir-model", - label: "SIR Model", - onClick: () => { - createNewNet(sirModel); - clearSelection(); - }, - }, - { - id: "load-example-deployment-pipeline", - label: "Deployment Pipeline", - onClick: () => { - createNewNet(deploymentPipelineSDCPN); - clearSelection(); - }, - }, - ], - }, - ] - : []), - { - id: "docs", - label: "Docs", - onClick: () => { - window.open( - "https://github.com/hashintel/hash/tree/main/libs/%40hashintel/petrinaut/docs", - "_blank", - "noopener,noreferrer", - ); - }, - }, - ]; - - const portalContainerRef = useRef(null); - const showEmptyAiHero = - aiAssistant !== undefined && - !isAiAssistantOpen && - isEmptySDCPN(petriNetDefinition); - - return ( - - -
- - { - if (!open) { - setImportError(null); - } - }} - errorMessage={importError ?? ""} - onCreateEmpty={handleCreateEmpty} - /> - - {/* Top Bar - always visible */} - - handleRunningExperimentClick(experiment.id) - } - /> - - - {mode === "simulate" ? ( - - ) : ( - - {/* Left Sidebar - Tools and content panels */} - - - {/* Properties Panel - Right Side */} - - - {/* SDCPN Visualization */} - - - {showEmptyAiHero && ( - { - setEmptyAiPromptInput(""); - setPendingAiAssistantMessage(message); - setAiAssistantOpen(true); - }} - /> - )} - - {/* Bottom Panel - Diagnostics, Simulation Settings */} - - - - - {aiAssistant && ( - - setPendingAiAssistantMessage(null) - } - /> - )} - - )} - - - - ); + // Get data from sdcpn-store + const { + createNewNet, + existingNets, + loadPetriNet, + petriNetDefinition, + title, + setTitle, + } = use(SDCPNContext); + const { applyAutoLayout } = usePetrinautCommands(); + + // Get editor context + const { + globalMode: mode, + isAiAssistantOpen, + setGlobalMode, + editionMode, + setEditionMode, + cursorMode, + setCursorMode, + clearSelection, + setSimulateViewMode, + setAiAssistantOpen, + isBottomPanelOpen, + bottomPanelHeight, + } = use(EditorContext); + const { setSelectedExperimentId } = use(ExperimentsContext); + + const { compactNodes } = use(UserSettingsContext); + const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; + const [emptyAiPromptInput, setEmptyAiPromptInput] = useState(""); + const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< + string | null + >(null); + + const [importError, setImportError] = useState(null); + + // Clean up stale selections when items are deleted + useSelectionCleanup(); + + function handleCreateEmpty() { + createNewNet({ + title: "Untitled", + petriNetDefinition: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + clearSelection(); + } + + function handleNew() { + handleCreateEmpty(); + } + + function handleExport() { + exportSDCPN({ petriNetDefinition, title }); + } + + function handleExportWithoutVisualInfo() { + exportSDCPN({ petriNetDefinition, title, removeVisualInfo: true }); + } + + function handleExportTikZ() { + exportTikZ({ petriNetDefinition, title }); + } + + function handleRunningExperimentClick(experimentId: string) { + setGlobalMode("simulate"); + setSimulateViewMode("experiments"); + setSelectedExperimentId(experimentId); + } + + async function handleImport() { + const result = await importSDCPN(); + if (!result) { + return; // User cancelled file picker + } + + if (!result.ok) { + setImportError(result.error); + return; + } + + const { sdcpn: loadedSDCPN, hadMissingPositions } = result; + let sdcpnToLoad = loadedSDCPN; + + // If any nodes were missing positions, run ELK layout BEFORE creating the net. + // We must do this before createNewNet because after createNewNet triggers a + // re-render, the mutatePetriNetDefinition closure would be stale. + if (hadMissingPositions) { + const positions = await calculateGraphLayout(sdcpnToLoad, dims); + + if (Object.keys(positions).length > 0) { + sdcpnToLoad = { + ...sdcpnToLoad, + places: sdcpnToLoad.places.map((place) => { + const position = positions[place.id]; + return position + ? { ...place, x: position.x, y: position.y } + : place; + }), + transitions: sdcpnToLoad.transitions.map((transition) => { + const position = positions[transition.id]; + return position + ? { ...transition, x: position.x, y: position.y } + : transition; + }), + }; + } + } + + createNewNet({ + title: loadedSDCPN.title, + petriNetDefinition: sdcpnToLoad, + }); + clearSelection(); + } + + const menuItems = [ + ...(!hideNetManagementControls + ? [ + { + id: "new", + label: "New", + onClick: handleNew, + }, + ] + : []), + ...(!hideNetManagementControls && Object.keys(existingNets).length > 0 + ? [ + { + id: "open", + label: "Open", + submenu: existingNets.map((net) => ({ + id: `open-${net.netId}`, + label: net.title, + suffix: formatRelativeTime(net.lastUpdated), + onClick: () => { + loadPetriNet(net.netId); + clearSelection(); + }, + })), + }, + ] + : []), + { + id: "export", + label: "Export", + submenu: [ + { + id: "export-json", + label: "JSON", + onClick: handleExport, + }, + { + id: "export-without-visuals", + label: "JSON without visual info", + onClick: handleExportWithoutVisualInfo, + }, + { + id: "export-tikz", + label: "TikZ", + onClick: handleExportTikZ, + }, + ], + }, + ...(!hideNetManagementControls + ? [ + { + id: "import", + label: "Import", + onClick: handleImport, + }, + ] + : []), + { + id: "layout", + label: "Layout", + onClick: applyAutoLayout, + }, + ...(!hideNetManagementControls + ? [ + { + id: "load-example", + label: "Load example", + submenu: [ + { + id: "load-example-supply-chain-stochastic", + label: "Probabilistic Supply Chain", + onClick: () => { + createNewNet(supplyChainStochasticSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-satellites", + label: "Satellites", + onClick: () => { + createNewNet(satellitesSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-probabilistic-satellites", + label: "Probabilistic Satellites Launcher", + onClick: () => { + createNewNet(probabilisticSatellitesSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-production-machines", + label: "Production Machines", + onClick: () => { + createNewNet(productionMachines); + clearSelection(); + }, + }, + { + id: "load-example-sir-model", + label: "SIR Model", + onClick: () => { + createNewNet(sirModel); + clearSelection(); + }, + }, + { + id: "load-example-deployment-pipeline", + label: "Deployment Pipeline", + onClick: () => { + createNewNet(deploymentPipelineSDCPN); + clearSelection(); + }, + }, + ], + }, + ] + : []), + { + id: "docs", + label: "Docs", + onClick: () => { + window.open( + "https://github.com/hashintel/hash/tree/main/libs/%40hashintel/petrinaut/docs", + "_blank", + "noopener,noreferrer", + ); + }, + }, + ]; + + const portalContainerRef = useRef(null); + const showEmptyAiHero = + aiAssistant !== undefined && + !isAiAssistantOpen && + isEmptySDCPN(petriNetDefinition); + + return ( + + +
+ + { + if (!open) { + setImportError(null); + } + }} + errorMessage={importError ?? ""} + onCreateEmpty={handleCreateEmpty} + /> + + {/* Top Bar - always visible */} + + handleRunningExperimentClick(experiment.id) + } + /> + + + {mode === "simulate" ? ( + + ) : ( + + {/* Left Sidebar - Tools and content panels */} + + + {/* Properties Panel - Right Side */} + + + {/* SDCPN Visualization */} + + + {showEmptyAiHero && ( + { + setEmptyAiPromptInput(""); + setPendingAiAssistantMessage(message); + setAiAssistantOpen(true); + }} + /> + )} + + {/* Bottom Panel - Diagnostics, Simulation Settings */} + + + + + {aiAssistant && ( + + setPendingAiAssistantMessage(null) + } + /> + )} + + )} + + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 1e9d80e3cbd..47a0167f088 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -5,7 +5,7 @@ import { Icon } from "@hashintel/ds-components"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "@hashintel/petrinaut-core"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; +import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; @@ -25,7 +25,7 @@ export const DifferentialEquationsSectionHeaderAction: React.FC = () => { const { petriNetDefinition: { types, differentialEquations }, } = use(SDCPNContext); - const { addDifferentialEquation } = use(MutationContext); + const { addDifferentialEquation } = usePetrinautMutations(); const { selectItem } = use(EditorContext); const isReadOnly = useIsReadOnly(); @@ -57,7 +57,7 @@ export const DifferentialEquationsSectionHeaderAction: React.FC = () => { }; const DiffEqRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { - const { removeDifferentialEquation } = use(MutationContext); + const { removeDifferentialEquation } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); return ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx index 7e9e156bb3b..906c2915f2c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx @@ -3,8 +3,8 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; +import { usePetrinautMutations } from "../../../../../../react"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { @@ -47,7 +47,7 @@ interface EntityTreeItem { const EntityRowMenu: React.FC<{ item: EntityTreeItem }> = ({ item }) => { const { removeType, removeDifferentialEquation, removeParameter } = - use(MutationContext); + usePetrinautMutations(); const { globalMode } = use(EditorContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 2a7df00434e..3b446761f03 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -5,7 +5,7 @@ import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; +import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; @@ -33,7 +33,7 @@ export const ParametersHeaderAction: React.FC = () => { const { petriNetDefinition: { parameters }, } = use(SDCPNContext); - const { addParameter } = use(MutationContext); + const { addParameter } = usePetrinautMutations(); const { selectItem } = use(EditorContext); const isReadOnly = useIsReadOnly(); @@ -66,7 +66,7 @@ export const ParametersHeaderAction: React.FC = () => { }; const ParameterRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { - const { removeParameter } = use(MutationContext); + const { removeParameter } = usePetrinautMutations(); const { globalMode } = use(EditorContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 494bbaafc2c..c32fa07cd9f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -3,7 +3,7 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; +import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; @@ -67,7 +67,7 @@ export const TypesSectionHeaderAction: React.FC = () => { const { petriNetDefinition: { types }, } = use(SDCPNContext); - const { addType } = use(MutationContext); + const { addType } = usePetrinautMutations(); const { selectItem } = use(EditorContext); const isReadOnly = useIsReadOnly(); @@ -109,7 +109,7 @@ export const TypesSectionHeaderAction: React.FC = () => { }; const TypeRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { - const { removeType } = use(MutationContext); + const { removeType } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); return ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index 0b9e17d96d7..37e7ef760b3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -16,7 +16,7 @@ import { Select } from "../../../../../components/select"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; const containerStyle = css({ @@ -41,9 +41,9 @@ interface ArcPropertiesData { targetName: string; weight: number; type: "standard" | "inhibitor"; - updateArcWeight: MutationContextValue["updateArcWeight"]; - updateArcType: MutationContextValue["updateArcType"]; - removeArc: MutationContextValue["removeArc"]; + updateArcWeight: PetrinautMutations["updateArcWeight"]; + updateArcType: PetrinautMutations["updateArcType"]; + removeArc: PetrinautMutations["removeArc"]; } const ArcPropertiesContext = createContext(null); @@ -166,9 +166,9 @@ const subViews: SubView[] = [arcMainContentSubView]; interface ArcPropertiesProps { arcId: string; petriNetDefinition: SDCPN; - updateArcWeight: MutationContextValue["updateArcWeight"]; - updateArcType: MutationContextValue["updateArcType"]; - removeArc: MutationContextValue["removeArc"]; + updateArcWeight: PetrinautMutations["updateArcWeight"]; + updateArcType: PetrinautMutations["updateArcType"]; + removeArc: PetrinautMutations["removeArc"]; } export const ArcProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx index 14188ab745b..ed20a0d49eb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx @@ -1,6 +1,6 @@ import { createContext, use } from "react"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { Color, DifferentialEquation, @@ -11,7 +11,7 @@ export interface DiffEqPropertiesContextValue { differentialEquation: DifferentialEquation; types: Color[]; places: Place[]; - updateDifferentialEquation: MutationContextValue["updateDifferentialEquation"]; + updateDifferentialEquation: PetrinautMutations["updateDifferentialEquation"]; } export const DiffEqPropertiesContext = diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx index ed0feca90d8..8629b7b7c61 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx @@ -4,7 +4,7 @@ import { VerticalSubViewsContainer } from "../../../../../components/sub-view/ve import { DiffEqPropertiesContext } from "./context"; import { diffEqMainContentSubView } from "./subviews/main"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; import type { Color, @@ -25,7 +25,7 @@ interface DifferentialEquationPropertiesProps { differentialEquation: DifferentialEquation; types: Color[]; places: Place[]; - updateDifferentialEquation: MutationContextValue["updateDifferentialEquation"]; + updateDifferentialEquation: PetrinautMutations["updateDifferentialEquation"]; } export const DifferentialEquationProperties: React.FC< diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx index e30e113e30f..574ad1842ca 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx @@ -11,7 +11,7 @@ import { Button } from "../../../../components/button"; import { VerticalSubViewsContainer } from "../../../../components/sub-view/vertical/vertical-sub-views-container"; import { UI_MESSAGES } from "../../../../constants/ui-messages"; -import type { MutationContextValue } from "../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../react"; import type { SubView } from "../../../../components/sub-view/types"; import type { SelectionItem } from "@hashintel/petrinaut-core"; @@ -30,7 +30,7 @@ const summaryStyle = css({ interface MultiSelectionData { items: SelectionItem[]; - deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; + deleteItemsByIds: PetrinautMutations["deleteItemsByIds"]; } const MultiSelectionContext = createContext(null); @@ -110,7 +110,7 @@ const subViews: SubView[] = [multiSelectionMainSubView]; interface MultiSelectionPanelProps { items: SelectionItem[]; - deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; + deleteItemsByIds: PetrinautMutations["deleteItemsByIds"]; } export const MultiSelectionPanel: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx index 70e8fdb6f0a..a5fee1f17eb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx @@ -2,8 +2,8 @@ import { use, useCallback, useEffect, useState } from "react"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; +import { usePetrinautMutations } from "../../../../../react"; import { EditorContext } from "../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../react/state/mutation-context"; import { DEFAULT_PROPERTIES_PANEL_WIDTH } from "../../../../../react/state/panel-defaults"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { usePanelTarget } from "../../../../../react/state/use-selection"; @@ -82,7 +82,7 @@ export const PropertiesPanel: React.FC = () => { updateDifferentialEquation, updateParameter, deleteItemsByIds, - } = use(MutationContext); + } = usePetrinautMutations(); const panelTarget = usePanelTarget(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx index 6c7b6e40b2c..3b59d8636c4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx @@ -1,11 +1,11 @@ import { createContext, use } from "react"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { Parameter } from "@hashintel/petrinaut-core"; export interface ParameterPropertiesContextValue { parameter: Parameter; - updateParameter: MutationContextValue["updateParameter"]; + updateParameter: PetrinautMutations["updateParameter"]; } export const ParameterPropertiesContext = diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx index c8eb4ec7def..536a9c17734 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx @@ -4,7 +4,7 @@ import { VerticalSubViewsContainer } from "../../../../../components/sub-view/ve import { ParameterPropertiesContext } from "./context"; import { parameterMainContentSubView } from "./subviews/main"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; import type { Parameter } from "@hashintel/petrinaut-core"; @@ -19,7 +19,7 @@ const subViews: SubView[] = [parameterMainContentSubView]; interface ParameterPropertiesProps { parameter: Parameter; - updateParameter: MutationContextValue["updateParameter"]; + updateParameter: PetrinautMutations["updateParameter"]; } export const ParameterProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx index 9060c794ec1..721f28e6ad3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx @@ -1,6 +1,6 @@ import { createContext, type ReactNode, use } from "react"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { Color, Place } from "@hashintel/petrinaut-core"; /** @@ -17,7 +17,7 @@ interface PlacePropertiesContextValue { /** Whether the panel is in read-only mode */ isReadOnly: boolean; /** Function to update the place */ - updatePlace: MutationContextValue["updatePlace"]; + updatePlace: PetrinautMutations["updatePlace"]; } const PlacePropertiesContext = @@ -42,7 +42,7 @@ interface PlacePropertiesProviderProps { placeType: Color | null; types: Color[]; isReadOnly: boolean; - updatePlace: MutationContextValue["updatePlace"]; + updatePlace: PetrinautMutations["updatePlace"]; children: ReactNode; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx index ca3fadc32d7..f15cb7904ab 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx @@ -7,7 +7,7 @@ import { placeMainContentSubView } from "./subviews/main"; import { placeInitialStateSubView } from "./subviews/place-initial-state/subview"; import { placeVisualizerSubView } from "./subviews/place-visualizer/subview"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; import type { Color, Place } from "@hashintel/petrinaut-core"; @@ -27,7 +27,7 @@ const subViews: SubView[] = [ interface PlacePropertiesProps { place: Place; types: Color[]; - updatePlace: MutationContextValue["updatePlace"]; + updatePlace: PetrinautMutations["updatePlace"]; } export const PlaceProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 279abd1e1d8..97a45b19510 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -4,8 +4,8 @@ import { Checkbox, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { validateEntityName } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../../../../../react"; import { EditorContext } from "../../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; import { Button } from "../../../../../../components/button"; import { Input } from "../../../../../../components/input"; @@ -350,7 +350,7 @@ const PlaceMainContent: React.FC = () => { const DeletePlaceAction: React.FC = () => { const { place, isReadOnly } = usePlacePropertiesContext(); - const { removePlace } = use(MutationContext); + const { removePlace } = usePetrinautMutations(); return ( + +
+
+ ); +}; + +/** + * Interactive descriptor for `applyAutoLayout`. The AI may opt out of the + * confirmation by passing `askUserFirst: false`; we only intercept when it is + * `true`. + */ +export const applyAutoLayoutInteractiveTool: InteractiveToolDefinition< + ApplyAutoLayoutInput, + AiToolOutput +> = { + toolName: "applyAutoLayout" satisfies AiCommandActionName, + shouldHandle: (raw): boolean => { + const parsed = aiCommandActionInputSchemas.applyAutoLayout.safeParse(raw); + return parsed.success && parsed.data.askUserFirst === true; + }, + parseInput: (raw): ApplyAutoLayoutInput => + aiCommandActionInputSchemas.applyAutoLayout.parse(raw), + Widget: ApplyAutoLayoutWidget, +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts new file mode 100644 index 00000000000..e9baf8a6133 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts @@ -0,0 +1,35 @@ +import type { AiToolOutput } from "../tool-summaries"; +import { applyAutoLayoutInteractiveTool } from "./apply-auto-layout-widget"; +import type { InteractiveToolDefinition } from "./types"; + +/** + * Registry of AI tools that require an inline chat widget for user input. + * + * The AI dispatcher consults this map in `onToolCall`: when a tool name has a + * matching descriptor whose {@link InteractiveToolDefinition.shouldHandle} + * returns `true`, the dispatcher stores the call as pending instead of + * invoking the writable callback, and the AI surface renders the registered + * widget. Once the user interacts with the widget, the surface calls the + * dispatcher's `onInteractiveToolSubmit` to commit a tool output to the chat. + */ +export const interactiveTools: Record< + string, + InteractiveToolDefinition +> = { + [applyAutoLayoutInteractiveTool.toolName]: + applyAutoLayoutInteractiveTool as InteractiveToolDefinition< + unknown, + AiToolOutput + >, +}; + +export const getInteractiveTool = ( + toolName: string, + input: unknown, +): InteractiveToolDefinition | undefined => { + const descriptor = interactiveTools[toolName]; + if (!descriptor) { + return undefined; + } + return descriptor.shouldHandle(input) ? descriptor : undefined; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts new file mode 100644 index 00000000000..3d31eabd336 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts @@ -0,0 +1,41 @@ +import type { ComponentType } from "react"; + +/** + * Props passed to every interactive tool widget. The widget renders inline in + * the AI chat while a tool call is awaiting human input, then becomes a + * read-only summary once the user submits. + */ +export type InteractiveToolWidgetProps = { + /** Validated input the AI passed to the tool. */ + input: Input; + /** + * Submit a tool output to the chat. After submission, the widget remains + * mounted in `submitted` state with the chosen output visible. + */ + submit: (output: Output) => void; + /** "awaiting" while the user has not yet picked; "submitted" afterwards. */ + state: "awaiting" | "submitted"; + /** Output that was submitted (only set when `state === "submitted"`). */ + submittedOutput?: Output; +}; + +/** + * Descriptor for an AI tool that requires synchronous user input rendered + * inline in the chat. The registry maps tool names to a definition; the panel + * dispatcher defers `onToolCall` for any tool whose `shouldHandle` returns + * `true`, and the surface renders the registered {@link Widget} until the + * user submits. + */ +export type InteractiveToolDefinition = { + toolName: string; + /** + * Whether this tool call should be handled interactively. Lets a single + * tool branch between interactive and non-interactive paths based on its + * input shape (e.g. `applyAutoLayout` is interactive only when + * `askUserFirst: true`). + */ + shouldHandle: (input: unknown) => boolean; + /** Parse the raw input into the widget's typed input. */ + parseInput: (raw: unknown) => Input; + Widget: ComponentType>; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts index 67fc3d5a52a..0fb3f5125fb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts @@ -1,8 +1,11 @@ import type { + PetrinautAiCommandToolInput, + PetrinautAiCommandToolName, PetrinautAiMutationToolInput, PetrinautAiMutationToolName, } from "../../../../../core/ai"; import { generateArcId } from "../../../../../core/arc-id"; +import type { ReadOnlyReason } from "../../../../../react/state/use-read-only-reason"; import type { SDCPN } from "../../../../../core/types/sdcpn"; import type { SelectionItem } from "../../../../../core/types/selection"; @@ -13,7 +16,21 @@ export type AiToolSummary = { target?: AiToolTarget; }; -export type AiToolOutput = AiToolSummary & { applied: true }; +export type AiToolBlockedOutput = { + applied: false; + blocked: ReadOnlyReason["kind"]; + reason: string; +}; + +export type AiToolDeclinedOutput = { + applied: false; + reason: string; +}; + +export type AiToolOutput = + | (AiToolSummary & { applied: true }) + | AiToolBlockedOutput + | AiToolDeclinedOutput; export type AiToolTarget = | { kind: "selection"; item: SelectionItem } @@ -30,6 +47,8 @@ export const toPetrinautAiToolOutput = ( applied: true, }); +export type AiToolAppliedSummary = AiToolSummary & { applied: true }; + const prettifyToolName = (toolName: string): string => toolName .replace(/([A-Z])/g, " $1") @@ -158,12 +177,35 @@ const selectionTarget = (item: SelectionItem): AiToolTarget => ({ item, }); -export type AiToolCall = { - [Name in PetrinautAiMutationToolName]: { - toolName: Name; - input: PetrinautAiMutationToolInput; +export type AiToolCall = + | { + [Name in PetrinautAiMutationToolName]: { + toolName: Name; + input: PetrinautAiMutationToolInput; + }; + }[PetrinautAiMutationToolName] + | { + [Name in PetrinautAiCommandToolName]: { + toolName: Name; + input: PetrinautAiCommandToolInput; + }; + }[PetrinautAiCommandToolName]; + +export type AiToolApplyAutoLayoutSummaryContext = { + commitCount: number; +}; + +export const summarizeApplyAutoLayout = ( + context: AiToolApplyAutoLayoutSummaryContext, +): AiToolSummary => { + const { commitCount } = context; + return { + title: + commitCount === 0 + ? "Auto-layout had no effect" + : `Auto-laid out ${commitCount} node${commitCount === 1 ? "" : "s"}`, }; -}[PetrinautAiMutationToolName]; +}; export const summarizePetrinautAiToolCall = ( { input, toolName }: AiToolCall, @@ -487,6 +529,12 @@ export const summarizePetrinautAiToolCall = ( input.commits.length === 1 ? "" : "s" }`, }; + case "applyAutoLayout": + return { + title: input.askUserFirst + ? "Requested auto-layout (awaiting user confirmation)" + : "Auto-laid out the net", + }; default: return { title: prettifyToolName(toolName) }; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts index d1aee0b546c..bf83a35bb8f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -3,10 +3,11 @@ import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; import type { getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, + PetrinautAiCommandToolInput, + PetrinautAiCommandToolName, PetrinautAiMutationToolInput, PetrinautAiMutationToolName, PetrinautAiToolInput, - PetrinautAiToolName, } from "../../../../../core/ai"; import type { SDCPN } from "../../../../../core/types/sdcpn"; import type { AiToolOutput } from "./tool-summaries"; @@ -16,6 +17,11 @@ type PetrinautAiUiTools = { input: PetrinautAiMutationToolInput; output: AiToolOutput; }; +} & { + [Name in PetrinautAiCommandToolName]: { + input: PetrinautAiCommandToolInput; + output: AiToolOutput; + }; } & { [getLatestNetDefinitionToolName]: { input: PetrinautAiToolInput; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts deleted file mode 100644 index 225617d3a60..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; - -import type { MutationContextValue } from "../../../react/state/mutation-context"; -import type { SDCPN } from "@hashintel/petrinaut-core"; - -type NodeDimensions = { - place: { width: number; height: number }; - transition: { width: number; height: number }; -}; - -/** - * Run auto-layout on the current SDCPN and apply the computed positions via - * the Mutation bridge. - * - * This composes the layout primitives in `/ui` so `/react` doesn't have to - * reach for visual dimensions. The mutate side flows through - * {@link MutationContextValue.commitNodePositions} — the same path used by - * drag commits — so read-only / simulate-mode guards apply uniformly. - * - * `dimensions` should be layout-stable (independent of the user's - * `compactNodes` choice) — see the note in `node-dimensions.ts`. - */ -export async function runAutoLayout({ - sdcpn, - dimensions, - commitNodePositions, -}: { - sdcpn: SDCPN; - dimensions: NodeDimensions; - commitNodePositions: MutationContextValue["commitNodePositions"]; -}): Promise { - if (sdcpn.places.length === 0 && sdcpn.transitions.length === 0) { - return; - } - - const positions = await calculateGraphLayout(sdcpn, dimensions); - - const commits: Parameters< - MutationContextValue["commitNodePositions"] - >[0]["commits"] = []; - - for (const place of sdcpn.places) { - const position = positions[place.id]; - if (position && (place.x !== position.x || place.y !== position.y)) { - commits.push({ id: place.id, itemType: "place", position }); - } - } - - for (const transition of sdcpn.transitions) { - const position = positions[transition.id]; - if ( - position && - (transition.x !== position.x || transition.y !== position.y) - ) { - commits.push({ - id: transition.id, - itemType: "transition", - position, - }); - } - } - - if (commits.length > 0) { - commitNodePositions({ commits }); - } -} diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts index f39e22d664e..34b249ceea2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts @@ -1,7 +1,7 @@ import { use } from "react"; import { EditorContext } from "../../../../react/state/editor-context"; -import { MutationContext } from "../../../../react/state/mutation-context"; +import { usePetrinautMutations } from "../../../../react/hooks/use-petrinaut-mutations"; import { SDCPNContext } from "../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { snapPositionToGrid } from "../../../lib/snap-position-to-grid"; @@ -17,7 +17,7 @@ import type { EdgeChange, NodeChange } from "@xyflow/react"; */ export function useApplyNodeChanges() { const { getItemType } = use(SDCPNContext); - const { commitNodePositions } = use(MutationContext); + const { commitNodePositions } = usePetrinautMutations(); const { updateDraggingStateByNodeId, setSelection } = use(EditorContext); const { snapToGrid } = use(UserSettingsContext); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 522709d001f..3f7f63dca14 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -16,8 +16,8 @@ import { generateDefaultLambdaCode, } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../react"; import { EditorContext } from "../../../react/state/editor-context"; -import { MutationContext } from "../../../react/state/mutation-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../react/state/use-is-read-only"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; @@ -95,7 +95,7 @@ export const SDCPNView: React.FC<{ // SDCPN store const { petriNetId } = use(SDCPNContext); - const { addPlace, addTransition, addArc } = use(MutationContext); + const { addPlace, addTransition, addArc } = usePetrinautMutations(); const { editionMode, From ae7dfeb1f142177ce3576b0a8202fc14e5b51c1e Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 22 May 2026 14:58:41 +0100 Subject: [PATCH 07/21] fractal file structuring skill --- .../skills/fractal-file-structuring/SKILL.md | 193 ++++++++++++++++++ .claude/skills/skill-rules.json | 30 +++ 2 files changed, 223 insertions(+) create mode 100644 .claude/skills/fractal-file-structuring/SKILL.md diff --git a/.claude/skills/fractal-file-structuring/SKILL.md b/.claude/skills/fractal-file-structuring/SKILL.md new file mode 100644 index 00000000000..8ce1a160191 --- /dev/null +++ b/.claude/skills/fractal-file-structuring/SKILL.md @@ -0,0 +1,193 @@ +--- +name: fractal-file-structuring +description: "Use when creating, moving, splitting, or organizing TypeScript files and folders. Applies fractal tree file-structuring rules which reduce the cognitive overhead of choosing where to put files and ultimately navigating a codebase (once the structure is established and understood)." +license: MIT +metadata: + triggers: + type: domain + enforcement: suggest + priority: high + keywords: + - TypeScript + - JavaScript + - file structure + - folder structure + - create file + - create folder + - split file + - shared folder + intent-patterns: + - "\\b(create|add|move|split|organize|refactor)\\b.*?\\b(file|folder|directory|module|component|hook|type|helper)\\b" + - "\\b(file|folder|directory)\\b.*?\\b(structure|layout|organization|placement)\\b" +--- + +# Fractal File Structuring + +TypeScript and JavaScript files should be organised in a fractal tree structure. Use this skill when deciding where to create, move, split, or organize files and folders in a TypeScript or JavaScript workspace. + +This guidance is based on HASH's file-structuring approach: https://hash.dev/blog/file-structuring + +## Scope + +Apply this skill to TypeScript and JavaScript source files, including modules, components, hooks, helpers, types, tests, scripts, and entry points. + +## Core Rules + +### Use kebab-case names + +Use kebab-case for all TypeScript and JavaScript file and folder names. + +```text +create-worker-factory.ts +playback-settings-menu.tsx +button.tsx +``` + +Avoid PascalCase, camelCase, and mixed-case file names, even for React components. + +### Do not create index files + +Do not add `index.ts`, `index.tsx`, `index.js`, or `index.jsx` files for folder imports. Prefer explicit file entry points with meaningful names. + +If a subtree needs a public entry point, name that file after the concept it exposes (e.g. `schema.ts`) + +### Treat each file as a mini-library + +A file should expose one or more named exports with a shared semantic purpose. The file name should summarize that purpose (e.g. `users.ts`) + +If a file contains only one main export, prefer naming the file after that export in kebab-case (e.g. `create-user.ts`) + +Avoid default exports unless a framework or external API requires them. + +### Split outgrown files into private subtrees + +When a file becomes too large or contains implementation details worth extracting, create a same-named folder next to it and move private pieces there. + +```text +editor-view.tsx # public mini-library: the component other files import +editor-view/ + panels.tsx # private entry point imported by editor-view.tsx + panels/ + simulate-view.tsx # private to panels.tsx + calculate-timeline-range.ts # private helper used only by editor-view.tsx + create-panel-state.ts # private helper used only by editor-view.tsx +``` + +Only `editor-view.tsx` should import from direct child mini-libraries such as `editor-view/panels.tsx` and `editor-view/calculate-timeline-range.ts`. Only `editor-view/panels.tsx` should import from `editor-view/panels/*.tsx`. Other files should import from `editor-view.tsx`, not from its private subtree. This keeps `editor-view.tsx` as the API boundary and makes `editor-view/` read as its implementation. + +If `editor-view/calculate-timeline-range.ts` grows and needs its own private implementation files, create `editor-view/calculate-timeline-range/`. Only `editor-view/calculate-timeline-range.ts` should import from that deeper subtree. + +```text +editor-view/ + calculate-timeline-range.ts + calculate-timeline-range/ + clamp-time.ts # private to calculate-timeline-range.ts + get-visible-duration.ts # private to calculate-timeline-range.ts +``` + +### Keep private subtrees private + +Do not import directly from another file's implementation folder. + +```typescript +// Avoid: reaches into another file's private subtree +import { SimulateView } from "../editor-view/panels/simulate-view"; + +// Prefer (1): import from a public mini-library (if it is conceptually part of editor-view) +import { EditorView } from "../editor-view"; + + +// Prefer (2): move shared code to a shared folder (if it is NOT conceptually part of editor-view) +import { Button } from "../shared/button"; +``` + +If a resource must be available outside the subtree, re-export it from the subtree root only when it is part of that root's public concept. If it is independently useful to sibling branches, move it to an appropriate `shared/` folder instead. + +### Put shared resources at the closest fork + +When multiple sibling branches need the same helper, type, component, constant, or hook, place it in the nearest applicable `shared/` folder. + +```text +editor-view.tsx +editor-view/ + shared/ + duration-label.tsx # used by both panels.tsx and bottom-section.tsx + playback-time.ts # shared formatting/parsing logic for this subtree + panels.tsx # imports from panels/ + panels/ + simulate-view.tsx # private to panels.tsx + bottom-section.tsx # imports from bottom-section/ + bottom-section/ + bottom-bar.tsx # private to bottom-section.tsx +``` + +Place shared files as deep as possible while still covering all current consumers. Do not move something to a high-level shared folder just because it might be reused later. + +Here `editor-view.tsx` imports `./editor-view/panels` and `./editor-view/bottom-section`. `panels.tsx` may import `./panels/simulate-view` and `./shared/duration-label`; `bottom-section.tsx` may import `./bottom-section/bottom-bar` and `./shared/duration-label`. Nothing else should import from `panels/` or `bottom-section/` directly. + +Shared files are mini-libraries too. A shared file can have its own private same-named subtree, and those internals should remain private to that shared file. + +```text +editor-view/ + shared/ + playback-time.ts # public to editor-view/* branches + playback-time/ + parse-playback-time.ts # private to playback-time.ts + format-playback-time.ts # private to playback-time.ts +``` + +If later only `bottom-bar.tsx` uses `duration-label.tsx`, move it beside `bottom-bar.tsx` or under `bottom-bar/`. The folder structure should describe current consumers, not preserve old sharing. + +### Use relative imports within a workspace + +For imports inside the same workspace, use relative paths. Do not introduce workspace-local aliases just to shorten paths. + +Imports from other workspaces should use the package name. + +### Co-locate unit tests + +Place unit tests next to the file they cover. + +```text +foo.ts +foo.test.ts +``` + +If a private extracted file needs direct tests, place those tests next to that extracted file. + +```text +editor-view.tsx +editor-view.test.tsx +editor-view/ + calculate-timeline-range.ts + calculate-timeline-range.test.ts +``` + +Prefer testing through the public mini-library when that gives enough coverage. Add direct tests for private extracted files when the logic is complex enough that tests through the owner would be indirect or brittle. + +### Match the current shape + +Organize files for the code's current relationships, not speculative future reuse. Moving files later is expected and cheaper than adding premature structure now. + +## Decision Checklist + +Before creating a TypeScript or JavaScript file or folder: + +1. Identify the semantic concept the file represents. +2. Name the file or folder in kebab-case. +3. If extracting from an existing file, put private implementation files under a same-named folder. +4. If multiple current branches need the resource, put it in the nearest `shared/` folder. +5. Avoid `index` files and implicit folder imports. +6. Use relative imports within the workspace. +7. Co-locate tests with the file under test. + +## When Unsure + +Choose the location that communicates the file's current consumers and API boundary most clearly: + +- Private implementation detail: place it under the owning file's same-named folder, and import it only from that owner. +- Named mini-library: create a normal named file when the concept has its own purpose and exports a small API for nearby consumers. +- Shared mini-library: place the named file in the closest `shared/` folder when multiple branches need that API. +- Subtree entry point: expose the public API from a named root file, and keep any deeper implementation files private to that root. + +Do not add broad `components`, `hooks`, `utils`, `types`, or `services` folders unless absolutely necessary. If they exist, these folders MUST only be imported from by files called `components.ts`, `hooks.ts`, etc. diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index 00bde60a200..ebeb6f166aa 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -78,6 +78,36 @@ "blockMessage": "Skill is required to proceed", "skipConditions": {} }, + "fractal-file-structuring": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "description": "Use when creating, moving, splitting, or organizing TypeScript files and folders. Applies fractal tree file-structuring rules which reduce the cognitive overhead of choosing where to put files and ultimately navigating a codebase (once the structure is established and understood).", + "promptTriggers": { + "keywords": [ + "TypeScript", + "JavaScript", + "file structure", + "folder structure", + "create file", + "create folder", + "split file", + "shared folder" + ], + "intentPatterns": [ + "\\b(create|add|move|split|organize|refactor)\\b.*?\\b(file|folder|directory|module|component|hook|type|helper)\\b", + "\\b(file|folder|directory)\\b.*?\\b(structure|layout|organization|placement)\\b" + ] + }, + "fileTriggers": { + "include": [], + "exclude": [], + "content": [], + "create-only": false + }, + "blockMessage": "Skill is required to proceed", + "skipConditions": {} + }, "handling-rust-errors": { "type": "domain", "enforcement": "suggest", From 20b04bb50e7c17e89abebf456143d2b35652e88b Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 22 May 2026 16:44:44 +0100 Subject: [PATCH 08/21] merge cleanup --- apps/petrinaut-website/api/chat.ts | 2 +- apps/petrinaut-website/package.json | 76 +- libs/@hashintel/petrinaut-core/package.json | 3 +- .../petrinaut-core/src/actions.test.ts | 940 +++++++++--------- .../petrinaut-core/src/commands.test.ts | 252 ++--- libs/@hashintel/petrinaut-core/src/index.ts | 1 + libs/@hashintel/petrinaut-core/vite.config.ts | 1 + libs/@hashintel/petrinaut/package.json | 213 ++-- .../petrinaut/src/react/hooks/index.ts | 2 +- .../hooks/use-petrinaut-commands.test.tsx | 10 +- .../src/react/hooks/use-petrinaut-commands.ts | 3 +- .../hooks/use-petrinaut-mutations.test.tsx | 10 +- .../react/hooks/use-petrinaut-mutations.ts | 3 +- .../BottomBar/playback-settings-menu.tsx | 2 +- .../experiments-story-fixtures.tsx | 3 + .../Editor/panels/ai-assistant-panel.tsx | 18 +- .../ai-assistant-surface.test.tsx | 3 +- .../ai-assistant-surface.tsx | 69 +- .../format-diagnostics-for-ai.test.ts | 4 +- .../format-diagnostics-for-ai.ts | 4 +- .../apply-auto-layout-widget.tsx | 10 +- .../ai-assistant-panel/tool-summaries.test.ts | 2 +- .../ai-assistant-panel/tool-summaries.ts | 19 +- .../Editor/panels/ai-assistant-panel/types.ts | 8 +- libs/@hashintel/petrinaut/vite.config.ts | 1 - yarn.lock | 649 +----------- 26 files changed, 852 insertions(+), 1456 deletions(-) diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts index 8e1d2386a3a..018c5e520d0 100644 --- a/apps/petrinaut-website/api/chat.ts +++ b/apps/petrinaut-website/api/chat.ts @@ -7,7 +7,7 @@ import { type ToolSet, type UIMessage, } from "ai"; -import { petrinautAiTools, petrinautAiPrompt } from "@hashintel/petrinaut/core"; +import { petrinautAiTools, petrinautAiPrompt } from "@hashintel/petrinaut-core"; import { z } from "zod"; declare const process: { diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index f6c9cbc6c9c..c4488aab8b4 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -1,40 +1,40 @@ { - "name": "@apps/petrinaut-website", - "version": "0.0.0-private", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite", - "lint:tsc": "tsgo --noEmit", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error ." - }, - "dependencies": { - "@ai-sdk/openai": "3.0.63", - "@hashintel/ds-components": "workspace:*", - "@hashintel/ds-helpers": "workspace:*", - "@hashintel/petrinaut": "workspace:*", - "@mantine/hooks": "8.3.5", - "@pandacss/dev": "1.4.3", - "@sentry/react": "10.22.0", - "ai": "6.0.182", - "immer": "10.1.3", - "react": "19.2.3", - "react-dom": "19.2.3", - "react-icons": "5.5.0", - "zod": "4.4.3" - }, - "devDependencies": { - "@rolldown/plugin-babel": "0.2.1", - "@types/react": "19.2.7", - "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260511.1", - "@vitejs/plugin-react": "6.0.1", - "@whatwg-node/server": "0.10.18", - "babel-plugin-react-compiler": "1.0.0", - "oxlint": "1.63.0", - "oxlint-tsgolint": "0.22.1", - "vite": "8.0.12" - } + "name": "@apps/petrinaut-website", + "version": "0.0.0-private", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit" + }, + "dependencies": { + "@ai-sdk/openai": "3.0.63", + "@hashintel/ds-components": "workspace:*", + "@hashintel/ds-helpers": "workspace:*", + "@hashintel/petrinaut": "workspace:*", + "@mantine/hooks": "8.3.5", + "@pandacss/dev": "1.11.1", + "@sentry/react": "10.22.0", + "ai": "6.0.182", + "immer": "10.1.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-icons": "5.5.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@rolldown/plugin-babel": "0.2.1", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "@vitejs/plugin-react": "6.0.1", + "@whatwg-node/server": "0.10.18", + "babel-plugin-react-compiler": "1.0.0", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "vite": "8.0.12" + } } diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json index b2f1e4b5c7c..72444215926 100644 --- a/libs/@hashintel/petrinaut-core/package.json +++ b/libs/@hashintel/petrinaut-core/package.json @@ -57,10 +57,11 @@ }, "dependencies": { "@babel/standalone": "7.28.5", + "elkjs": "0.11.0", "immer": "10.1.3", "uuid": "14.0.0", "vscode-languageserver-types": "3.17.5", - "zod": "4.1.12" + "zod": "4.4.3" }, "devDependencies": { "@types/babel__standalone": "7.1.9", diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index 21f71f2651d..934ea4c8a03 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -6,488 +6,480 @@ import { createPetrinaut } from "./instance"; import type { SDCPN } from "./types/sdcpn"; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], }; -const cloneSDCPN = (sdcpn: SDCPN): SDCPN => - // `structuredClone` is available as a global in both Node and DOM, but we will need to - // configure TypeScript `types` to be at the intersection of both to avoid DOM dependencies in core. - // For now we define the type inline to avoid that issue in tests, which run in Node but still need to clone SDCPN documents. - ( - globalThis as typeof globalThis & { - structuredClone: (value: Value) => Value; - } - ).structuredClone(sdcpn); +const cloneSDCPN = (sdcpn: SDCPN): SDCPN => JSON.parse(JSON.stringify(sdcpn)); const createInstance = (initial: SDCPN = emptySDCPN) => - createPetrinaut({ - document: createJsonDocHandle({ initial: cloneSDCPN(initial) }), - }); + createPetrinaut({ + document: createJsonDocHandle({ initial: cloneSDCPN(initial) }), + }); const callActionWithUnknownInput = ( - action: (input: Input) => void, - input: unknown, + action: (input: Input) => void, + input: unknown, ): void => { - action(input as Input); + action(input as Input); }; describe("Petrinaut core actions", () => { - test("adds and updates places", () => { - const instance = createInstance(); - - instance.mutations.addPlace({ - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - instance.mutations.updatePlace({ - placeId: "place-1", - update: { - name: "UpdatedQueue", - }, - }); - instance.mutations.updatePlacePosition({ - placeId: "place-1", - position: { x: 12, y: 24 }, - }); - - expect(instance.definition.get().places).toEqual([ - { - id: "place-1", - name: "UpdatedQueue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 12, - y: 24, - }, - ]); - }); - - test("removing a place also removes connected arcs", () => { - const instance = createInstance({ - ...emptySDCPN, - places: [ - { - id: "place-1", - name: "Input", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "place-2", - name: "Output", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - }); - - instance.mutations.removePlace({ placeId: "place-1" }); - - const definition = instance.definition.get(); - expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); - expect(definition.transitions[0]!.inputArcs).toEqual([]); - expect(definition.transitions[0]!.outputArcs).toEqual([ - { placeId: "place-2", weight: 1 }, - ]); - }); - - test("updates arc endpoints granularly", () => { - const instance = createInstance({ - ...emptySDCPN, - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - }); - - instance.mutations.updateArcPlace({ - transitionId: "transition-1", - arcDirection: "input", - oldPlaceId: "place-1", - newPlaceId: "place-3", - }); - instance.mutations.updateArcPlace({ - transitionId: "transition-1", - arcDirection: "output", - oldPlaceId: "place-2", - newPlaceId: "place-4", - }); - - expect(instance.definition.get().transitions[0]).toMatchObject({ - inputArcs: [{ placeId: "place-3", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-4", weight: 1 }], - }); - }); - - test("adds, updates, removes, and moves type elements granularly", () => { - const instance = createInstance({ - ...emptySDCPN, - types: [ - { - id: "type-1", - name: "Particle", - iconSlug: "circle", - displayColor: "#34a0fa", - elements: [ - { elementId: "element-1", name: "Mass", type: "real" }, - { elementId: "element-2", name: "Velocity", type: "real" }, - ], - }, - ], - }); - - instance.mutations.addTypeElement({ - typeId: "type-1", - element: { elementId: "element-3", name: "Charge", type: "integer" }, - }); - instance.mutations.updateTypeElement({ - typeId: "type-1", - elementId: "element-1", - update: { name: "MassKg" }, - }); - instance.mutations.moveTypeElement({ - typeId: "type-1", - elementId: "element-3", - toIndex: 1, - }); - instance.mutations.removeTypeElement({ - typeId: "type-1", - elementId: "element-2", - }); - - expect(instance.definition.get().types[0]!.elements).toEqual([ - { elementId: "element-1", name: "MassKg", type: "real" }, - { elementId: "element-3", name: "Charge", type: "integer" }, - ]); - }); - - test("deleteItemsByIds removes referenced types and equations", () => { - const instance = createInstance({ - ...emptySDCPN, - places: [ - { - id: "place-1", - name: "Dynamic", - colorId: "type-1", - dynamicsEnabled: true, - differentialEquationId: "equation-1", - x: 0, - y: 0, - }, - ], - types: [ - { - id: "type-1", - name: "Particle", - iconSlug: "circle", - displayColor: "#34a0fa", - elements: [], - }, - ], - differentialEquations: [ - { - id: "equation-1", - name: "Motion", - colorId: "type-1", - code: "export default Dynamics(() => []);", - }, - ], - }); - - instance.mutations.deleteItemsByIds({ - items: [ - { type: "type", id: "type-1" }, - { type: "differentialEquation", id: "equation-1" }, - ], - }); - - const definition = instance.definition.get(); - expect(definition.types).toEqual([]); - expect(definition.differentialEquations).toEqual([]); - expect(definition.places[0]!.colorId).toBeNull(); - expect(definition.places[0]!.differentialEquationId).toBeNull(); - }); - - test("does not mutate readonly instances", () => { - const instance = createPetrinaut({ - document: createJsonDocHandle({ initial: cloneSDCPN(emptySDCPN) }), - readonly: true, - }); - - instance.mutations.addPlace({ - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - - expect(instance.definition.get().places).toEqual([]); - }); - - test("validates add action inputs before mutating", () => { - const instance = createInstance(); - - expect(() => - instance.mutations.addPlace({ - id: "", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }), - ).toThrow(); - - expect(instance.definition.get().places).toEqual([]); - }); - - test("validates callback-updated entities", () => { - const instance = createInstance(); - - instance.mutations.addPlace({ - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - - expect(() => - instance.mutations.updatePlace({ - placeId: "place-1", - update: { - name: "", - }, - }), - ).toThrow(); - }); - - test("rejects over-wide update action payloads", () => { - const instance = createInstance(); - - expect(() => - callActionWithUnknownInput(instance.mutations.updatePlace, { - placeId: "place-1", - update: { id: "place-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updatePlace, { - placeId: "place-1", - update: { x: 10 }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateTransition, { - transitionId: "transition-1", - update: { inputArcs: [] }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateTransition, { - transitionId: "transition-1", - update: { y: 10 }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateType, { - typeId: "type-1", - update: { elements: [] }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateTypeElement, { - typeId: "type-1", - elementId: "element-1", - update: { elementId: "element-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput( - instance.mutations.updateDifferentialEquation, - { - equationId: "equation-1", - update: { id: "equation-2" }, - }, - ), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateParameter, { - parameterId: "parameter-1", - update: { id: "parameter-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateScenario, { - scenarioId: "scenario-1", - update: { id: "scenario-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateMetric, { - metricId: "metric-1", - update: { id: "metric-2" }, - }), - ).toThrow(); - }); - - test("validates granular arc and type element action inputs", () => { - const instance = createInstance({ - ...emptySDCPN, - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - types: [ - { - id: "type-1", - name: "Particle", - iconSlug: "circle", - displayColor: "#34a0fa", - elements: [{ elementId: "element-1", name: "Mass", type: "real" }], - }, - ], - }); - - expect(() => - instance.mutations.updateArcPlace({ - transitionId: "transition-1", - arcDirection: "input", - oldPlaceId: "place-1", - newPlaceId: "", - }), - ).toThrow(); - expect(() => - instance.mutations.addTypeElement({ - typeId: "type-1", - element: { elementId: "element-2", name: "", type: "real" }, - }), - ).toThrow(); - expect(() => - instance.mutations.moveTypeElement({ - typeId: "type-1", - elementId: "element-1", - toIndex: -1, - }), - ).toThrow(); - - expect(instance.definition.get().transitions[0]!.inputArcs).toEqual([ - { placeId: "place-1", weight: 1, type: "standard" }, - ]); - expect(instance.definition.get().types[0]!.elements).toEqual([ - { elementId: "element-1", name: "Mass", type: "real" }, - ]); - }); - - test("reuses existing name validation rules for action inputs", () => { - const instance = createInstance(); - - expect(() => - instance.mutations.addPlace({ - id: "place-1", - name: "invalid place name", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }), - ).toThrow(); - - expect(() => - instance.mutations.addTransition({ - id: "transition-1", - name: "Display Name", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }), - ).not.toThrow(); - }); - - test("preserves scenario-specific validation in action inputs", () => { - const instance = createInstance(); - - expect(() => - instance.mutations.addScenario({ - id: "scenario-1", - name: "Scenario", - scenarioParameters: [ - { type: "real", identifier: "launch_rate", default: 1 }, - { type: "integer", identifier: "launch_rate", default: 2 }, - ], - parameterOverrides: {}, - initialState: { type: "per_place", content: {} }, - }), - ).toThrow(); - - expect(() => - instance.mutations.addScenario({ - id: "scenario-1", - name: "Scenario", - scenarioParameters: [ - { type: "real", identifier: "LaunchRate", default: 1 }, - ], - parameterOverrides: {}, - initialState: { type: "per_place", content: {} }, - }), - ).toThrow(); - }); + test("adds and updates places", () => { + const instance = createInstance(); + + instance.mutations.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + instance.mutations.updatePlace({ + placeId: "place-1", + update: { + name: "UpdatedQueue", + }, + }); + instance.mutations.updatePlacePosition({ + placeId: "place-1", + position: { x: 12, y: 24 }, + }); + + expect(instance.definition.get().places).toEqual([ + { + id: "place-1", + name: "UpdatedQueue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 12, + y: 24, + }, + ]); + }); + + test("removing a place also removes connected arcs", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.mutations.removePlace({ placeId: "place-1" }); + + const definition = instance.definition.get(); + expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); + expect(definition.transitions[0]!.inputArcs).toEqual([]); + expect(definition.transitions[0]!.outputArcs).toEqual([ + { placeId: "place-2", weight: 1 }, + ]); + }); + + test("updates arc endpoints granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.mutations.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "place-3", + }); + instance.mutations.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "output", + oldPlaceId: "place-2", + newPlaceId: "place-4", + }); + + expect(instance.definition.get().transitions[0]).toMatchObject({ + inputArcs: [{ placeId: "place-3", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-4", weight: 1 }], + }); + }); + + test("adds, updates, removes, and moves type elements granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [ + { elementId: "element-1", name: "Mass", type: "real" }, + { elementId: "element-2", name: "Velocity", type: "real" }, + ], + }, + ], + }); + + instance.mutations.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-3", name: "Charge", type: "integer" }, + }); + instance.mutations.updateTypeElement({ + typeId: "type-1", + elementId: "element-1", + update: { name: "MassKg" }, + }); + instance.mutations.moveTypeElement({ + typeId: "type-1", + elementId: "element-3", + toIndex: 1, + }); + instance.mutations.removeTypeElement({ + typeId: "type-1", + elementId: "element-2", + }); + + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "MassKg", type: "real" }, + { elementId: "element-3", name: "Charge", type: "integer" }, + ]); + }); + + test("deleteItemsByIds removes referenced types and equations", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Dynamic", + colorId: "type-1", + dynamicsEnabled: true, + differentialEquationId: "equation-1", + x: 0, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [], + }, + ], + differentialEquations: [ + { + id: "equation-1", + name: "Motion", + colorId: "type-1", + code: "export default Dynamics(() => []);", + }, + ], + }); + + instance.mutations.deleteItemsByIds({ + items: [ + { type: "type", id: "type-1" }, + { type: "differentialEquation", id: "equation-1" }, + ], + }); + + const definition = instance.definition.get(); + expect(definition.types).toEqual([]); + expect(definition.differentialEquations).toEqual([]); + expect(definition.places[0]!.colorId).toBeNull(); + expect(definition.places[0]!.differentialEquationId).toBeNull(); + }); + + test("does not mutate readonly instances", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ initial: cloneSDCPN(emptySDCPN) }), + readonly: true, + }); + + instance.mutations.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates add action inputs before mutating", () => { + const instance = createInstance(); + + expect(() => + instance.mutations.addPlace({ + id: "", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates callback-updated entities", () => { + const instance = createInstance(); + + instance.mutations.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(() => + instance.mutations.updatePlace({ + placeId: "place-1", + update: { + name: "", + }, + }), + ).toThrow(); + }); + + test("rejects over-wide update action payloads", () => { + const instance = createInstance(); + + expect(() => + callActionWithUnknownInput(instance.mutations.updatePlace, { + placeId: "place-1", + update: { id: "place-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updatePlace, { + placeId: "place-1", + update: { x: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateTransition, { + transitionId: "transition-1", + update: { inputArcs: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateTransition, { + transitionId: "transition-1", + update: { y: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateType, { + typeId: "type-1", + update: { elements: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateTypeElement, { + typeId: "type-1", + elementId: "element-1", + update: { elementId: "element-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput( + instance.mutations.updateDifferentialEquation, + { + equationId: "equation-1", + update: { id: "equation-2" }, + }, + ), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateParameter, { + parameterId: "parameter-1", + update: { id: "parameter-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateScenario, { + scenarioId: "scenario-1", + update: { id: "scenario-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateMetric, { + metricId: "metric-1", + update: { id: "metric-2" }, + }), + ).toThrow(); + }); + + test("validates granular arc and type element action inputs", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [{ elementId: "element-1", name: "Mass", type: "real" }], + }, + ], + }); + + expect(() => + instance.mutations.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "", + }), + ).toThrow(); + expect(() => + instance.mutations.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-2", name: "", type: "real" }, + }), + ).toThrow(); + expect(() => + instance.mutations.moveTypeElement({ + typeId: "type-1", + elementId: "element-1", + toIndex: -1, + }), + ).toThrow(); + + expect(instance.definition.get().transitions[0]!.inputArcs).toEqual([ + { placeId: "place-1", weight: 1, type: "standard" }, + ]); + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "Mass", type: "real" }, + ]); + }); + + test("reuses existing name validation rules for action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.mutations.addPlace({ + id: "place-1", + name: "invalid place name", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(() => + instance.mutations.addTransition({ + id: "transition-1", + name: "Display Name", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }), + ).not.toThrow(); + }); + + test("preserves scenario-specific validation in action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.mutations.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "launch_rate", default: 1 }, + { type: "integer", identifier: "launch_rate", default: 2 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + + expect(() => + instance.mutations.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "LaunchRate", default: 1 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/commands.test.ts b/libs/@hashintel/petrinaut-core/src/commands.test.ts index 03f6a4db550..eadc4f2ac7d 100644 --- a/libs/@hashintel/petrinaut-core/src/commands.test.ts +++ b/libs/@hashintel/petrinaut-core/src/commands.test.ts @@ -1,147 +1,149 @@ import { describe, expect, test } from "vitest"; import { - CLIPBOARD_FORMAT_VERSION, - type ClipboardPayload, + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, } from "./clipboard/types"; import { createJsonDocHandle } from "./handle"; import { createPetrinaut } from "./instance"; import type { SDCPN } from "./types/sdcpn"; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], }; const createInstance = (initial: SDCPN = emptySDCPN) => - createPetrinaut({ - document: createJsonDocHandle({ initial: structuredClone(initial) }), - }); + createPetrinaut({ + document: createJsonDocHandle({ + initial: JSON.parse(JSON.stringify(initial)), + }), + }); const buildClipboardPayload = ( - data: Partial = {}, + data: Partial = {}, ): ClipboardPayload => ({ - format: "petrinaut-sdcpn", - version: CLIPBOARD_FORMAT_VERSION, - documentId: null, - data: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - ...data, - }, + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + ...data, + }, }); describe("applyClipboardPaste", () => { - test("returns new IDs for pasted places", () => { - const instance = createInstance(); - - const payload = buildClipboardPayload({ - places: [ - { - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - }); - - const { newItemIds } = instance.commands.applyClipboardPaste({ payload }); - const pastedPlace = newItemIds.find((item) => item.type === "place"); - - expect(pastedPlace).toBeDefined(); - expect(instance.definition.get().places).toHaveLength(1); - expect(instance.definition.get().places[0]!.id).toBe(pastedPlace!.id); - }); - - test("throws when the payload fails schema validation", () => { - const instance = createInstance(); - - expect(() => - instance.commands.applyClipboardPaste({ - payload: { - format: "not-petrinaut", - version: CLIPBOARD_FORMAT_VERSION, - documentId: null, - data: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - } as unknown as ClipboardPayload, - }), - ).toThrow(); - - expect(instance.definition.get().places).toEqual([]); - }); + test("returns new IDs for pasted places", () => { + const instance = createInstance(); + + const payload = buildClipboardPayload({ + places: [ + { + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + const { newItemIds } = instance.commands.applyClipboardPaste({ payload }); + const pastedPlace = newItemIds.find((item) => item.type === "place"); + + expect(pastedPlace).toBeDefined(); + expect(instance.definition.get().places).toHaveLength(1); + expect(instance.definition.get().places[0]!.id).toBe(pastedPlace!.id); + }); + + test("throws when the payload fails schema validation", () => { + const instance = createInstance(); + + expect(() => + instance.commands.applyClipboardPaste({ + payload: { + format: "not-petrinaut", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + } as unknown as ClipboardPayload, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); }); describe("applyAutoLayout", () => { - test("no-ops for an empty net", async () => { - const instance = createInstance(); - - const { commitCount } = await instance.commands.applyAutoLayout(); - - expect(commitCount).toBe(0); - }); - - test("repositions places when they have non-zero deltas", async () => { - const instance = createInstance({ - ...emptySDCPN, - places: [ - { - id: "place-1", - name: "Input", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "place-2", - name: "Output", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 0, - y: 0, - }, - ], - }); - - const { commitCount } = await instance.commands.applyAutoLayout(); - - expect(commitCount).toBeGreaterThan(0); - const places = instance.definition.get().places; - expect(places.map((place) => place.id).sort()).toEqual([ - "place-1", - "place-2", - ]); - expect(places.some((place) => place.x !== 0 || place.y !== 0)).toBe(true); - }); + test("no-ops for an empty net", async () => { + const instance = createInstance(); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBe(0); + }); + + test("repositions places when they have non-zero deltas", async () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBeGreaterThan(0); + const places = instance.definition.get().places; + expect(places.map((place) => place.id).sort()).toEqual([ + "place-1", + "place-2", + ]); + expect(places.some((place) => place.x !== 0 || place.y !== 0)).toBe(true); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 421b974a749..a56d0962af2 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -44,6 +44,7 @@ export type { CommandActionInput, CommandActionName, } from "./command-schemas"; +export { mutationActionInputSchemas } from "./action-schemas"; export { calculateGraphLayout, layoutNodeDimensions, diff --git a/libs/@hashintel/petrinaut-core/vite.config.ts b/libs/@hashintel/petrinaut-core/vite.config.ts index 19e5c35edb5..e5f181722cf 100644 --- a/libs/@hashintel/petrinaut-core/vite.config.ts +++ b/libs/@hashintel/petrinaut-core/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig(({ command }) => ({ rolldownOptions: { external: [ "@babel/standalone", + "elkjs", "immer", "uuid", "vscode-languageserver-types", diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 956800d0683..64789b12096 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -1,109 +1,108 @@ { - "name": "@hashintel/petrinaut", - "version": "0.0.14", - "description": "A visual editor for Petri nets", - "license": "(MIT OR Apache-2.0)", - "repository": { - "type": "git", - "url": "git+https://github.com/hashintel/hash.git", - "directory": "libs/@hashintel/petrinaut" - }, - "style": "dist/main.css", - "files": [ - "dist", - "CHANGELOG.md", - "LICENSE", - "LICENSE-APACHE.md", - "LICENSE-MIT.md", - "README.md", - "package.json" - ], - "type": "module", - "sideEffects": [ - "*.css" - ], - "main": "dist/main.js", - "types": "dist/main.d.d.ts", - "exports": { - ".": { - "types": "./dist/main.d.d.ts", - "default": "./dist/main.js" - }, - "./react": { - "types": "./dist/react.d.d.ts", - "default": "./dist/react.js" - }, - "./ui": { - "types": "./dist/ui.d.d.ts", - "default": "./dist/ui.js" - }, - "./styles.css": "./dist/main.css", - "./dist/main.css": "./dist/main.css", - "./package.json": "./package.json" - }, - "scripts": { - "build": "vite build", - "dev": "storybook dev", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit", - "prepublishOnly": "turbo run build", - "test:unit": "vitest" - }, - "dependencies": { - "@ai-sdk/react": "3.0.184", - "@ark-ui/react": "5.26.2", - "@babel/standalone": "7.28.5", - "@fontsource-variable/inter": "5.2.8", - "@fontsource-variable/inter-tight": "5.2.7", - "@fontsource-variable/jetbrains-mono": "5.2.8", - "@hashintel/ds-components": "workspace:^", - "@hashintel/ds-helpers": "workspace:^", - "@hashintel/petrinaut-core": "workspace:^", - "@hashintel/refractive": "workspace:^", - "@monaco-editor/react": "4.8.0-rc.3", - "@tanstack/react-form": "1.29.0", - "@xyflow/react": "12.10.1", - "ai": "6.0.182", - "elkjs": "0.11.0", - "fuzzysort": "3.1.0", - "lodash-es": "4.18.1", - "monaco-editor": "0.55.1", - "react-markdown": "10.1.0", - "react-resizable-panels": "4.6.5", - "uplot": "1.6.32", - "use-sync-external-store": "1.6.0", - "uuid": "14.0.0" - }, - "devDependencies": { - "@hashintel/ds-helpers": "workspace:*", - "@pandacss/dev": "1.11.1", - "@rolldown/plugin-babel": "0.2.1", - "@storybook/react-vite": "10.2.19", - "@testing-library/dom": "10.4.1", - "@testing-library/react": "16.3.2", - "@types/babel__standalone": "7.1.9", - "@types/lodash-es": "4.17.12", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "@typescript/native-preview": "7.0.0-dev.20260511.1", - "@vitejs/plugin-react": "6.0.1", - "babel-plugin-react-compiler": "1.0.0", - "jsdom": "24.1.3", - "oxlint": "1.63.0", - "oxlint-tsgolint": "0.22.1", - "react": "19.2.6", - "react-dom": "19.2.6", - "rolldown": "1.0.0", - "rolldown-plugin-dts": "0.25.0", - "storybook": "10.3.6", - "vite": "8.0.12", - "vitest": "4.1.5" - }, - "peerDependencies": { - "@hashintel/ds-components": "workspace:^", - "@hashintel/ds-helpers": "workspace:^", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } + "name": "@hashintel/petrinaut", + "version": "0.0.14", + "description": "A visual editor for Petri nets", + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "git+https://github.com/hashintel/hash.git", + "directory": "libs/@hashintel/petrinaut" + }, + "style": "dist/main.css", + "files": [ + "dist", + "CHANGELOG.md", + "LICENSE", + "LICENSE-APACHE.md", + "LICENSE-MIT.md", + "README.md", + "package.json" + ], + "type": "module", + "sideEffects": [ + "*.css" + ], + "main": "dist/main.js", + "types": "dist/main.d.d.ts", + "exports": { + ".": { + "types": "./dist/main.d.d.ts", + "default": "./dist/main.js" + }, + "./react": { + "types": "./dist/react.d.d.ts", + "default": "./dist/react.js" + }, + "./ui": { + "types": "./dist/ui.d.d.ts", + "default": "./dist/ui.js" + }, + "./styles.css": "./dist/main.css", + "./dist/main.css": "./dist/main.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "vite build", + "dev": "storybook dev", + "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", + "prepublishOnly": "turbo run build", + "test:unit": "vitest" + }, + "dependencies": { + "@ai-sdk/react": "3.0.184", + "@ark-ui/react": "5.26.2", + "@babel/standalone": "7.28.5", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/inter-tight": "5.2.7", + "@fontsource-variable/jetbrains-mono": "5.2.8", + "@hashintel/ds-components": "workspace:^", + "@hashintel/ds-helpers": "workspace:^", + "@hashintel/petrinaut-core": "workspace:^", + "@hashintel/refractive": "workspace:^", + "@monaco-editor/react": "4.8.0-rc.3", + "@tanstack/react-form": "1.29.0", + "@xyflow/react": "12.10.1", + "ai": "6.0.182", + "fuzzysort": "3.1.0", + "lodash-es": "4.18.1", + "monaco-editor": "0.55.1", + "react-markdown": "10.1.0", + "react-resizable-panels": "4.6.5", + "uplot": "1.6.32", + "use-sync-external-store": "1.6.0", + "uuid": "14.0.0" + }, + "devDependencies": { + "@hashintel/ds-helpers": "workspace:*", + "@pandacss/dev": "1.11.1", + "@rolldown/plugin-babel": "0.2.1", + "@storybook/react-vite": "10.2.19", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@types/babel__standalone": "7.1.9", + "@types/lodash-es": "4.17.12", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "@vitejs/plugin-react": "6.0.1", + "babel-plugin-react-compiler": "1.0.0", + "jsdom": "24.1.3", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "react": "19.2.6", + "react-dom": "19.2.6", + "rolldown": "1.0.0", + "rolldown-plugin-dts": "0.25.0", + "storybook": "10.3.6", + "vite": "8.0.12", + "vitest": "4.1.5" + }, + "peerDependencies": { + "@hashintel/ds-components": "workspace:^", + "@hashintel/ds-helpers": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } } diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index 15dff71836a..69fd67a3a96 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -69,7 +69,7 @@ export { usePetrinautCommands } from "./use-petrinaut-commands"; export type { PetrinautCommands, PetrinautMutations, -} from "../../core/instance"; +} from "@hashintel/petrinaut-core"; // Instance access + low-level store adapter. export { usePetrinautInstance } from "../use-petrinaut-instance"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx index 6023836074f..42ddcb608d1 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -8,10 +8,12 @@ import { describe, expect, test } from "vitest"; import { CLIPBOARD_FORMAT_VERSION, type ClipboardPayload, -} from "../../core/clipboard/types"; -import { createJsonDocHandle } from "../../core/handle"; -import { createPetrinaut, type Petrinaut } from "../../core/instance"; -import type { SDCPN } from "../../core/types/sdcpn"; + createJsonDocHandle, + createPetrinaut, + type Petrinaut, + type SDCPN, +} from "@hashintel/petrinaut-core"; + import { PetrinautInstanceContext } from "../instance-context"; import { SimulationContext, type SimulationState } from "../simulation/context"; import { diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts index 313b6cf856a..ccd11406e7a 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts @@ -1,6 +1,7 @@ import { use } from "react"; -import type { PetrinautCommands } from "../../core/instance"; +import type { PetrinautCommands } from "@hashintel/petrinaut-core"; + import { PetrinautInstanceContext } from "../instance-context"; import { useIsReadOnly } from "../state/use-is-read-only"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index 4486f00fafb..37fbc352b42 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -5,9 +5,13 @@ import { act, renderHook } from "@testing-library/react"; import { type ReactNode } from "react"; import { describe, expect, test } from "vitest"; -import { createJsonDocHandle } from "../../core/handle"; -import { createPetrinaut, type Petrinaut } from "../../core/instance"; -import type { SDCPN } from "../../core/types/sdcpn"; +import { + createJsonDocHandle, + createPetrinaut, + type Petrinaut, + type SDCPN, +} from "@hashintel/petrinaut-core"; + import { PetrinautInstanceContext } from "../instance-context"; import { SimulationContext, type SimulationState } from "../simulation/context"; import { diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index 21b69052016..69553884f2d 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -1,6 +1,7 @@ import { use } from "react"; -import type { PetrinautMutations } from "../../core/instance"; +import type { PetrinautMutations } from "@hashintel/petrinaut-core"; + import { PetrinautInstanceContext } from "../instance-context"; import { SDCPNContext } from "../state/sdcpn-context"; import { useIsReadOnly } from "../state/use-is-read-only"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index 48b6d35851f..cf60b859006 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -1,4 +1,4 @@ -import { use, useEffect } from "react"; +import { use } from "react"; import { Icon } from "@hashintel/ds-components"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx index 96da86116f0..da5ff3e5189 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx @@ -251,6 +251,9 @@ export function FakeEditorProvider({ isNotHoveredConnection: () => false, setDraggingStateByNodeId: () => {}, updateDraggingStateByNodeId: () => {}, + setSimulateDrawer: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, resetDraggingState: () => {}, collapseAllPanels: () => {}, setTimelineChartType: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index 8eba4e236fd..177c996b9f5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -1,19 +1,18 @@ import { useChat } from "@ai-sdk/react"; -import { use, useEffect, useRef, useState } from "react"; import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; +import { use, useEffect, useRef, useState } from "react"; import { + aiCommandActionInputSchemas, + type AiCommandActionName, createPetrinautAiWritableCallbacks, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, mutationActionInputSchemas as petrinautAiMutationToolInputSchemas, + type Petrinaut, type PetrinautAiMutationToolName, -} from "../../../../core/ai"; -import { - aiCommandActionInputSchemas, - type AiCommandActionName, -} from "../../../../core/command-schemas"; -import type { Petrinaut } from "../../../../core/instance"; +} from "@hashintel/petrinaut-core"; + import { PetrinautInstanceContext } from "../../../../react/instance-context"; import { LanguageClientContext } from "../../../../react/lsp/context"; import { @@ -26,7 +25,6 @@ import { useReadOnlyReason, } from "../../../../react/state/use-read-only-reason"; import { PANEL_MARGIN } from "../../../constants/ui"; -import type { PetrinautAiAssistant } from "../../../petrinaut"; import { AiAssistantSurface } from "./ai-assistant-panel/ai-assistant-surface"; import { createDiagnosticsAwareAiTransport } from "./ai-assistant-panel/create-diagnostics-aware-ai-transport"; import { formatDiagnosticsForAi } from "./ai-assistant-panel/format-diagnostics-for-ai"; @@ -39,6 +37,8 @@ import { summarizePetrinautAiToolCall, toPetrinautAiToolOutput, } from "./ai-assistant-panel/tool-summaries"; + +import type { PetrinautAiAssistant } from "../../../petrinaut"; import type { PetrinautAiMessage } from "./ai-assistant-panel/types"; export type { @@ -89,6 +89,7 @@ const logToolCallError = ({ input: unknown; toolName: string; }) => { + // oxlint-disable-next-line no-console console.error("Petrinaut AI tool call failed", { error, input, @@ -472,6 +473,7 @@ export const AiAssistantPanel = ({ } const lastMessage = messages.at(-1); + // oxlint-disable-next-line no-console console.error("Petrinaut AI chat failed", { error, lastMessage, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx index 85f3b7f4c30..2f203039767 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx @@ -10,7 +10,8 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; +import type { SDCPN } from "@hashintel/petrinaut-core"; + import { AiAssistantSurface } from "./ai-assistant-surface"; import type { PetrinautAiMessage } from "./types"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx index 86bc7227f11..b5db4978021 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx @@ -1,5 +1,4 @@ import { Collapsible } from "@ark-ui/react/collapsible"; -import { css, cva } from "@hashintel/ds-helpers/css"; import { type PointerEvent as ReactPointerEvent, useEffect, @@ -7,25 +6,28 @@ import { useState, } from "react"; import ReactMarkdown from "react-markdown"; -import { TbCheck, TbChevronUp, TbList, TbLoader2, TbX } from "react-icons/tb"; -import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; -import { Button } from "../../../../components/button"; -import { Input } from "../../../../components/input"; +import { Icon, LoadingSpinner } from "@hashintel/ds-components"; +import { css, cva } from "@hashintel/ds-helpers/css"; import { getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, petrinautAiMutationTools, -} from "../../../../../core/ai"; -import type { SelectionItem } from "../../../../../core/types/selection"; + type SelectionItem, +} from "@hashintel/petrinaut-core"; + +import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; +import { Button } from "../../../../components/button"; +import { Input } from "../../../../components/input"; import { getInteractiveTool } from "./interactive-tools/registry"; -import type { InteractiveToolDefinition } from "./interactive-tools/types"; import { type AiToolOutput, type AiToolTarget, type AiToolSummary, summarizePetrinautAiToolCall, } from "./tool-summaries"; + +import type { InteractiveToolDefinition } from "./interactive-tools/types"; import type { PetrinautAiMessage } from "./types"; type AiAssistantStatus = "submitted" | "streaming" | "ready" | "error"; @@ -420,11 +422,6 @@ const collapsibleContentStyle = css({ }, }); -const spinnerStyle = css({ - animation: "[spin 900ms linear infinite]", - color: "neutral.s80", -}); - const reasoningLoadingStyle = css({ display: "flex", alignItems: "center", @@ -961,16 +958,19 @@ const formatElapsedTime = (elapsedMs: number): string => { }; const useElapsedTime = (isRunning: boolean): string => { - const startedAt = useRef(Date.now()); + const startedAtRef = useRef(null); const [elapsedMs, setElapsedMs] = useState(0); useEffect(() => { + startedAtRef.current ??= Date.now(); + const startedAt = startedAtRef.current; + if (!isRunning) { - setElapsedMs((current) => current || Date.now() - startedAt.current); + setElapsedMs((current) => current || Date.now() - startedAt); return; } - const updateElapsed = () => setElapsedMs(Date.now() - startedAt.current); + const updateElapsed = () => setElapsedMs(Date.now() - startedAt); updateElapsed(); const intervalId = window.setInterval(updateElapsed, 1_000); @@ -1006,10 +1006,10 @@ const ReasoningPart = ({ onOpenChange={(details) => setOpen(details.open)} > - + Reasoning {elapsedTime} - +
@@ -1018,14 +1018,12 @@ const ReasoningPart = ({ {renderedText}
) : ( -
- -
+ + + )}
@@ -1093,7 +1091,11 @@ const ToolItem = ({ tone: tool.tone, })} > - {errored ? : complete ? : null} + {errored ? ( + + ) : complete ? ( + + ) : null} {title} @@ -1103,7 +1105,7 @@ const ToolItem = ({ )} - {expandable && } + {expandable && } ); @@ -1117,6 +1119,7 @@ const ToolItem = ({
{children.map((item, index) => ( + // oxlint-disable-next-line react/no-array-index-key
{item}
@@ -1177,10 +1180,10 @@ const ToolList = ({ - + {tools.length} changes - +
@@ -1356,6 +1359,12 @@ export const AiAssistantSurface = ({ onSelectToolTarget={onSelectToolTarget} /> ); + default: { + const exhaustiveCheck: never = item; + throw new Error( + `Unknown message part: ${JSON.stringify(exhaustiveCheck)}`, + ); + } } })}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts index a51db98e4ab..7161b8dc302 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts @@ -1,10 +1,10 @@ +import type { SDCPN } from "@hashintel/petrinaut-core"; +import { describe, expect, test } from "vitest"; import { type Diagnostic, DiagnosticSeverity, } from "vscode-languageserver-types"; -import { describe, expect, test } from "vitest"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; import { formatDiagnosticsForAi } from "./format-diagnostics-for-ai"; const definition: SDCPN = { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts index e89b5f02b1b..2b1d5bffe8f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts @@ -1,9 +1,7 @@ +import { parseDocumentUri, type SDCPN } from "@hashintel/petrinaut-core"; import type { Diagnostic, DocumentUri } from "vscode-languageserver-types"; import { DiagnosticSeverity } from "vscode-languageserver-types"; -import { parseDocumentUri } from "../../../../../core/lsp/lib/document-uris"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; - const DEFAULT_MAX_DIAGNOSTICS = 25; const diagnosticSeverityLabel = ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx index 5c8c21c1de5..2228da89ffc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx @@ -1,10 +1,10 @@ +import { + aiCommandActionInputSchemas, + type AiCommandActionInput, + type AiCommandActionName, +} from "@hashintel/petrinaut-core"; import { css } from "@hashintel/ds-helpers/css"; -import { aiCommandActionInputSchemas } from "../../../../../../core/command-schemas"; -import type { - AiCommandActionInput, - AiCommandActionName, -} from "../../../../../../core/command-schemas"; import { Button } from "../../../../../components/button"; import type { AiToolOutput } from "../tool-summaries"; import type { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts index e7c5c0b6f5f..15ea4b50bbb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts @@ -1,6 +1,6 @@ +import type { SDCPN } from "@hashintel/petrinaut-core"; import { describe, expect, test } from "vitest"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; import { summarizePetrinautAiToolCall } from "./tool-summaries"; const definition: SDCPN = { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts index 0fb3f5125fb..910412af8be 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts @@ -1,13 +1,14 @@ -import type { - PetrinautAiCommandToolInput, - PetrinautAiCommandToolName, - PetrinautAiMutationToolInput, - PetrinautAiMutationToolName, -} from "../../../../../core/ai"; -import { generateArcId } from "../../../../../core/arc-id"; +import { + generateArcId, + type PetrinautAiCommandToolInput, + type PetrinautAiCommandToolName, + type PetrinautAiMutationToolInput, + type PetrinautAiMutationToolName, + type SDCPN, + type SelectionItem, +} from "@hashintel/petrinaut-core"; + import type { ReadOnlyReason } from "../../../../../react/state/use-read-only-reason"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; -import type { SelectionItem } from "../../../../../core/types/selection"; export type AiToolSummary = { title: string; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts index bf83a35bb8f..375fb07e3ac 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -1,5 +1,3 @@ -import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; - import type { getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, @@ -8,8 +6,10 @@ import type { PetrinautAiMutationToolInput, PetrinautAiMutationToolName, PetrinautAiToolInput, -} from "../../../../../core/ai"; -import type { SDCPN } from "../../../../../core/types/sdcpn"; + SDCPN, +} from "@hashintel/petrinaut-core"; +import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; + import type { AiToolOutput } from "./tool-summaries"; type PetrinautAiUiTools = { diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index 578a947a075..f18ca3949ae 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -56,7 +56,6 @@ export default defineConfig(({ command }) => ({ plugins: [ esmExternalRequirePlugin({ external: [ - "elkjs", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime", diff --git a/yarn.lock b/yarn.lock index 59b443f3b03..a436a2c259b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -847,10 +847,10 @@ __metadata: "@hashintel/ds-helpers": "workspace:*" "@hashintel/petrinaut": "workspace:*" "@mantine/hooks": "npm:8.3.5" - "@pandacss/dev": "npm:1.4.3" + "@pandacss/dev": "npm:1.11.1" "@rolldown/plugin-babel": "npm:0.2.1" "@sentry/react": "npm:10.22.0" - "@types/react": "npm:19.2.7" + "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" "@vitejs/plugin-react": "npm:6.0.1" @@ -860,8 +860,8 @@ __metadata: immer: "npm:10.1.3" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1" - react: "npm:19.2.3" - react-dom: "npm:19.2.3" + react: "npm:19.2.6" + react-dom: "npm:19.2.6" react-icons: "npm:5.5.0" vite: "npm:8.0.12" zod: "npm:4.4.3" @@ -5443,16 +5443,6 @@ __metadata: languageName: node linkType: hard -"@clack/core@npm:0.4.1": - version: 0.4.1 - resolution: "@clack/core@npm:0.4.1" - dependencies: - picocolors: "npm:^1.0.0" - sisteransi: "npm:^1.0.5" - checksum: 10c0/60c59e2d0017ce81567566c4f2a288f1738ef6356e25d9c56055be68aa449f75b6a7271b32c335213eb3a7af4b02db90de88c6999c432f09ca00c3d8004ea95b - languageName: node - linkType: hard - "@clack/core@npm:0.5.0": version: 0.5.0 resolution: "@clack/core@npm:0.5.0" @@ -5474,17 +5464,6 @@ __metadata: languageName: node linkType: hard -"@clack/prompts@npm:0.9.1": - version: 0.9.1 - resolution: "@clack/prompts@npm:0.9.1" - dependencies: - "@clack/core": "npm:0.4.1" - picocolors: "npm:^1.0.0" - sisteransi: "npm:^1.0.5" - checksum: 10c0/6cda9f56963dcbbfca4d9a64c82cf57e7f00dd563cd9e9ad28973b10ac761723fc21453254effbf08d5862efd57bad41d48008316c345202b74035ae905329cf - languageName: node - linkType: hard - "@cloudamqp/amqp-client@npm:^2.1.1": version: 2.1.1 resolution: "@cloudamqp/amqp-client@npm:2.1.1" @@ -7754,6 +7733,7 @@ __metadata: "@types/babel__standalone": "npm:7.1.9" "@types/node": "npm:22.18.13" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + elkjs: "npm:0.11.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1" @@ -7764,7 +7744,7 @@ __metadata: vite: "npm:8.0.12" vitest: "npm:4.1.5" vscode-languageserver-types: "npm:3.17.5" - zod: "npm:4.1.12" + zod: "npm:4.4.3" languageName: unknown linkType: soft @@ -7798,7 +7778,6 @@ __metadata: "@xyflow/react": "npm:12.10.1" ai: "npm:6.0.182" babel-plugin-react-compiler: "npm:1.0.0" - elkjs: "npm:0.11.0" fuzzysort: "npm:3.1.0" jsdom: "npm:24.1.3" lodash-es: "npm:4.18.1" @@ -12634,24 +12613,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/config@npm:1.4.3, @pandacss/config@npm:^1.4.3": - version: 1.4.3 - resolution: "@pandacss/config@npm:1.4.3" - dependencies: - "@pandacss/logger": "npm:1.4.3" - "@pandacss/preset-base": "npm:1.4.3" - "@pandacss/preset-panda": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - bundle-n-require: "npm:1.1.2" - escalade: "npm:3.1.2" - merge-anything: "npm:5.1.7" - microdiff: "npm:1.5.0" - typescript: "npm:5.8.3" - checksum: 10c0/eda86c6a6422e3c892ecf35cf5c1073c9011c34b5070566d7b1505081dc5913f9c8c0918bad2ec2f5bf5424906825a9a3b5e52a45a5575d4b7da7fb78b58272a - languageName: node - linkType: hard - "@pandacss/core@npm:1.11.1, @pandacss/core@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/core@npm:1.11.1" @@ -12677,34 +12638,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/core@npm:1.4.3, @pandacss/core@npm:^1.4.3": - version: 1.4.3 - resolution: "@pandacss/core@npm:1.4.3" - dependencies: - "@csstools/postcss-cascade-layers": "npm:5.0.2" - "@pandacss/is-valid-prop": "npm:^1.4.3" - "@pandacss/logger": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/token-dictionary": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - browserslist: "npm:4.24.4" - hookable: "npm:5.5.3" - lightningcss: "npm:1.25.1" - lodash.merge: "npm:4.6.2" - outdent: "npm:0.8.0" - postcss: "npm:8.5.6" - postcss-discard-duplicates: "npm:7.0.2" - postcss-discard-empty: "npm:7.0.1" - postcss-merge-rules: "npm:7.0.6" - postcss-minify-selectors: "npm:7.0.5" - postcss-nested: "npm:7.0.2" - postcss-normalize-whitespace: "npm:7.0.1" - postcss-selector-parser: "npm:7.1.0" - ts-pattern: "npm:5.8.0" - checksum: 10c0/17974a2f420c7845216d9010e6785d1687e73246705c252ed72ecf2df703fe90cde4a92a4096d2f13e8f3080115e9c6bb09cb5163b0cd06a515243ebfb4ef6c2 - languageName: node - linkType: hard - "@pandacss/dev@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/dev@npm:1.11.1" @@ -12728,28 +12661,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/dev@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/dev@npm:1.4.3" - dependencies: - "@clack/prompts": "npm:0.9.1" - "@pandacss/config": "npm:1.4.3" - "@pandacss/logger": "npm:1.4.3" - "@pandacss/node": "npm:1.4.3" - "@pandacss/postcss": "npm:1.4.3" - "@pandacss/preset-base": "npm:1.4.3" - "@pandacss/preset-panda": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/token-dictionary": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - cac: "npm:6.7.14" - bin: - panda: bin.js - pandacss: bin.js - checksum: 10c0/ff14d316e4a51f74fc9121b33951363e0b2fc1334adc79962517687313da69acb89fc4dd27e17819151290fced62669c9f9aa1339626c210dbb781738ddb479f - languageName: node - linkType: hard - "@pandacss/extractor@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/extractor@npm:1.11.1" @@ -12761,17 +12672,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/extractor@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/extractor@npm:1.4.3" - dependencies: - "@pandacss/shared": "npm:1.4.3" - ts-evaluator: "npm:1.2.0" - ts-morph: "npm:26.0.0" - checksum: 10c0/3a422e9418db4a1190a2ddc53ca52460a697501e0253bfb1f54812ba7d585cc87c39ac1e9ccb878416be1b16c8910f4df60820ad55859c7f38efae767c0be4cd - languageName: node - linkType: hard - "@pandacss/generator@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/generator@npm:1.11.1" @@ -12791,25 +12691,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/generator@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/generator@npm:1.4.3" - dependencies: - "@pandacss/core": "npm:1.4.3" - "@pandacss/is-valid-prop": "npm:^1.4.3" - "@pandacss/logger": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/token-dictionary": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - javascript-stringify: "npm:2.1.0" - outdent: "npm: ^0.8.0" - pluralize: "npm:8.0.0" - postcss: "npm:8.5.6" - ts-pattern: "npm:5.8.0" - checksum: 10c0/6913f82d2c937de0cb63b34d11aeba56fb0c3d181ace20ac4982db6cf740753e9d5af07fb80b29677c849da56987085904ea42ba973a4abdaf8c24d363389eef - languageName: node - linkType: hard - "@pandacss/is-valid-prop@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/is-valid-prop@npm:1.11.1" @@ -12817,13 +12698,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/is-valid-prop@npm:^1.4.3": - version: 1.4.3 - resolution: "@pandacss/is-valid-prop@npm:1.4.3" - checksum: 10c0/e5f3cd692b517cb212b93602e2f9d6861191f3c075d34c2a9f3414b1b56beeb5f0413d582c1b9f87a70ba246fa0eaac4dbcfa2640901e4144bd1aae79ac2b3fb - languageName: node - linkType: hard - "@pandacss/logger@npm:1.11.1, @pandacss/logger@npm:^1.11.1": version: 1.11.1 resolution: "@pandacss/logger@npm:1.11.1" @@ -12834,16 +12708,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/logger@npm:1.4.3, @pandacss/logger@npm:^1.4.3": - version: 1.4.3 - resolution: "@pandacss/logger@npm:1.4.3" - dependencies: - "@pandacss/types": "npm:1.4.3" - kleur: "npm:4.1.5" - checksum: 10c0/79b288232b9dcdfc816615eb209767d8af6738800ebb7ee56a37f4bfbeb5af659bb10b25a1281979653b3dda0eb0f5c4eb3ec41a0bada8f4297a3c4a104cf815 - languageName: node - linkType: hard - "@pandacss/mcp@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/mcp@npm:1.11.1" @@ -12899,42 +12763,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/node@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/node@npm:1.4.3" - dependencies: - "@pandacss/config": "npm:1.4.3" - "@pandacss/core": "npm:1.4.3" - "@pandacss/generator": "npm:1.4.3" - "@pandacss/logger": "npm:1.4.3" - "@pandacss/parser": "npm:1.4.3" - "@pandacss/reporter": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/token-dictionary": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - browserslist: "npm:4.24.4" - chokidar: "npm:4.0.3" - fast-glob: "npm:3.3.3" - fs-extra: "npm:11.2.0" - glob-parent: "npm:6.0.2" - is-glob: "npm:4.0.3" - lodash.merge: "npm:4.6.2" - look-it-up: "npm:2.1.0" - outdent: "npm: ^0.8.0" - package-manager-detector: "npm:0.1.0" - perfect-debounce: "npm:1.0.0" - picomatch: "npm:4.0.3" - pkg-types: "npm:2.3.0" - pluralize: "npm:8.0.0" - postcss: "npm:8.5.6" - prettier: "npm:3.2.5" - ts-morph: "npm:26.0.0" - ts-pattern: "npm:5.8.0" - tsconfck: "npm:3.1.6" - checksum: 10c0/8c338edf210caef97e0939b86ecc4a78fc80c69ff833392d16ed4b687134021f814d36a358054f3c74fb76ae378481e3e13957c7d622e980f35a4fc5a66ddf84 - languageName: node - linkType: hard - "@pandacss/parser@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/parser@npm:1.11.1" @@ -12951,24 +12779,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/parser@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/parser@npm:1.4.3" - dependencies: - "@pandacss/config": "npm:^1.4.3" - "@pandacss/core": "npm:^1.4.3" - "@pandacss/extractor": "npm:1.4.3" - "@pandacss/logger": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - "@vue/compiler-sfc": "npm:3.5.22" - magic-string: "npm:0.30.19" - ts-morph: "npm:26.0.0" - ts-pattern: "npm:5.8.0" - checksum: 10c0/7af08a9a5a340c4ec6b77aad11da0c3b34969c8e05fe9ad40482b8b2c288ab86ccd40e896acc713916c98e16287aa10910d68a84222b9de9f22b6cb5a5e0d95f - languageName: node - linkType: hard - "@pandacss/plugin-lightningcss@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/plugin-lightningcss@npm:1.11.1" @@ -13012,16 +12822,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/postcss@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/postcss@npm:1.4.3" - dependencies: - "@pandacss/node": "npm:1.4.3" - postcss: "npm:8.5.6" - checksum: 10c0/d4029fe47540d78c130443ac8432827586a4215f94863f831ce5932a82cf647d3255f99187aa27c58ca60fc9c4954f7619fcda636fcb6873246e17feaf67f81a - languageName: node - linkType: hard - "@pandacss/preset-base@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/preset-base@npm:1.11.1" @@ -13031,15 +12831,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/preset-base@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/preset-base@npm:1.4.3" - dependencies: - "@pandacss/types": "npm:1.4.3" - checksum: 10c0/3771690de58550ae96cb571260db6676990a6c966d892253f659ef0181db63b48fd9586637a456a1989e490af2106d86801625250804e3743e0ba3db96ffd03d - languageName: node - linkType: hard - "@pandacss/preset-panda@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/preset-panda@npm:1.11.1" @@ -13049,15 +12840,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/preset-panda@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/preset-panda@npm:1.4.3" - dependencies: - "@pandacss/types": "npm:1.4.3" - checksum: 10c0/89d231dd26d4f880a29517f2672c7c34214d67b265c2eda4d53f87f57faabee3e2518bdda9d371f94db34918658e442666847fd1cf84ecdf9ca8fab0d261ae58 - languageName: node - linkType: hard - "@pandacss/reporter@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/reporter@npm:1.11.1" @@ -13073,21 +12855,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/reporter@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/reporter@npm:1.4.3" - dependencies: - "@pandacss/core": "npm:1.4.3" - "@pandacss/generator": "npm:1.4.3" - "@pandacss/logger": "npm:1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - table: "npm:6.9.0" - wordwrapjs: "npm:5.1.0" - checksum: 10c0/2feed289228cfc192ac61883c0fcf1a71306e094c18131fd331601823c6db7a1caf25e419e21f9f5f9cbfe05b83f39fbd36f68815ee8a905a0eb5f2f67a6c7ba - languageName: node - linkType: hard - "@pandacss/shared@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/shared@npm:1.11.1" @@ -13095,13 +12862,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/shared@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/shared@npm:1.4.3" - checksum: 10c0/c51de8018bc401f606998598719330d556f948ff0ef34a7ca524137222123193417b48902f1a9fcb1e08709a08a626747bd70ddb7603463bf3abd8f560d458fe - languageName: node - linkType: hard - "@pandacss/token-dictionary@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/token-dictionary@npm:1.11.1" @@ -13115,18 +12875,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/token-dictionary@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/token-dictionary@npm:1.4.3" - dependencies: - "@pandacss/logger": "npm:^1.4.3" - "@pandacss/shared": "npm:1.4.3" - "@pandacss/types": "npm:1.4.3" - ts-pattern: "npm:5.8.0" - checksum: 10c0/cd0654687ebe75f5bdbcfae9cf9a17b203d407bef59c03f0de92c258d55c422669e9497767118f10f94a23a38b859a7c104301fda66151d00c491a13beba6542 - languageName: node - linkType: hard - "@pandacss/types@npm:1.11.1": version: 1.11.1 resolution: "@pandacss/types@npm:1.11.1" @@ -13134,13 +12882,6 @@ __metadata: languageName: node linkType: hard -"@pandacss/types@npm:1.4.3": - version: 1.4.3 - resolution: "@pandacss/types@npm:1.4.3" - checksum: 10c0/8e83b39560dfb31ccdafddf19b89f75355f92f1e0dc2500c2a1cb37ca05fb9dabf91de13f97b46f89bdf25c49237e1422fae1ad702a179d08ab5d4fdeef127c3 - languageName: node - linkType: hard - "@parcel/watcher-android-arm64@npm:2.5.1": version: 2.5.1 resolution: "@parcel/watcher-android-arm64@npm:2.5.1" @@ -18869,17 +18610,6 @@ __metadata: languageName: node linkType: hard -"@ts-morph/common@npm:~0.27.0": - version: 0.27.0 - resolution: "@ts-morph/common@npm:0.27.0" - dependencies: - fast-glob: "npm:^3.3.3" - minimatch: "npm:^10.0.1" - path-browserify: "npm:^1.0.1" - checksum: 10c0/3daa267bd78114ff504eb064c5215da6e46589e775b781ec0da4998d999b0d7130eff287e70d6e13e0a0a897ea16e9387f4cd885b4b9d6d628f318cecb81d473 - languageName: node - linkType: hard - "@ts-morph/common@npm:~0.29.0": version: 0.29.0 resolution: "@ts-morph/common@npm:0.29.0" @@ -20068,15 +19798,6 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:19.2.7": - version: 19.2.7 - resolution: "@types/react@npm:19.2.7" - dependencies: - csstype: "npm:^3.2.2" - checksum: 10c0/a7b75f1f9fcb34badd6f84098be5e35a0aeca614bc91f93d2698664c0b2ba5ad128422bd470ada598238cebe4f9e604a752aead7dc6f5a92261d0c7f9b27cfd1 - languageName: node - linkType: hard - "@types/readable-stream@npm:^4.0.0, @types/readable-stream@npm:^4.0.5": version: 4.0.18 resolution: "@types/readable-stream@npm:4.0.18" @@ -21370,19 +21091,6 @@ __metadata: languageName: node linkType: hard -"@vue/compiler-core@npm:3.5.22": - version: 3.5.22 - resolution: "@vue/compiler-core@npm:3.5.22" - dependencies: - "@babel/parser": "npm:^7.28.4" - "@vue/shared": "npm:3.5.22" - entities: "npm:^4.5.0" - estree-walker: "npm:^2.0.2" - source-map-js: "npm:^1.2.1" - checksum: 10c0/7575fdef8d2b69aa9a7f55ba237abe0ab86a855dba1048dc32b32e2e5212a66410f922603b1191a8fbbf6e0caee7efab0cea705516304eeb1108d3819a10b092 - languageName: node - linkType: hard - "@vue/compiler-core@npm:3.5.25": version: 3.5.25 resolution: "@vue/compiler-core@npm:3.5.25" @@ -21396,16 +21104,6 @@ __metadata: languageName: node linkType: hard -"@vue/compiler-dom@npm:3.5.22": - version: 3.5.22 - resolution: "@vue/compiler-dom@npm:3.5.22" - dependencies: - "@vue/compiler-core": "npm:3.5.22" - "@vue/shared": "npm:3.5.22" - checksum: 10c0/f853e7533a6e2f51321b5ce258c6ed2bdac8a294e833a61e87b00d3fdd36cd39e1045c03027c31d85f518422062e50085f1358a37d104ccf0866bc174a5c7b9a - languageName: node - linkType: hard - "@vue/compiler-dom@npm:3.5.25, @vue/compiler-dom@npm:^3.5.0": version: 3.5.25 resolution: "@vue/compiler-dom@npm:3.5.25" @@ -21416,23 +21114,6 @@ __metadata: languageName: node linkType: hard -"@vue/compiler-sfc@npm:3.5.22": - version: 3.5.22 - resolution: "@vue/compiler-sfc@npm:3.5.22" - dependencies: - "@babel/parser": "npm:^7.28.4" - "@vue/compiler-core": "npm:3.5.22" - "@vue/compiler-dom": "npm:3.5.22" - "@vue/compiler-ssr": "npm:3.5.22" - "@vue/shared": "npm:3.5.22" - estree-walker: "npm:^2.0.2" - magic-string: "npm:^0.30.19" - postcss: "npm:^8.5.6" - source-map-js: "npm:^1.2.1" - checksum: 10c0/662838a31f69cf6eedfcb5dc9f7f67a67ec6761645f2f09e6d2b5a4833c0e08a11fb960665d16519599e865e9a883490116e984132f8f7bb5d8ba07fca062ca5 - languageName: node - linkType: hard - "@vue/compiler-sfc@npm:3.5.25, @vue/compiler-sfc@npm:^3.5.13": version: 3.5.25 resolution: "@vue/compiler-sfc@npm:3.5.25" @@ -21450,16 +21131,6 @@ __metadata: languageName: node linkType: hard -"@vue/compiler-ssr@npm:3.5.22": - version: 3.5.22 - resolution: "@vue/compiler-ssr@npm:3.5.22" - dependencies: - "@vue/compiler-dom": "npm:3.5.22" - "@vue/shared": "npm:3.5.22" - checksum: 10c0/d27721b96784d078e410d978ed5e7c0a2fca10b8a8087d7cfc832baedf79de8b3d34d05def3e54d7baaca0f7583c7261628dca482ba4e8b3c908302e44a53b2f - languageName: node - linkType: hard - "@vue/compiler-ssr@npm:3.5.25": version: 3.5.25 resolution: "@vue/compiler-ssr@npm:3.5.25" @@ -21501,13 +21172,6 @@ __metadata: languageName: node linkType: hard -"@vue/shared@npm:3.5.22": - version: 3.5.22 - resolution: "@vue/shared@npm:3.5.22" - checksum: 10c0/5866eab1dd6caa949f4ae2da2a7bac69612b35e316a298785279fb4de101bfe89a3572db56448aa35023b01d069b80a664be4fe22847ce5e5fbc1990e5970ec5 - languageName: node - linkType: hard - "@vue/shared@npm:3.5.25, @vue/shared@npm:^3.5.0": version: 3.5.25 resolution: "@vue/shared@npm:3.5.25" @@ -24350,21 +24014,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:4.24.4": - version: 4.24.4 - resolution: "browserslist@npm:4.24.4" - dependencies: - caniuse-lite: "npm:^1.0.30001688" - electron-to-chromium: "npm:^1.5.73" - node-releases: "npm:^2.0.19" - update-browserslist-db: "npm:^1.1.1" - bin: - browserslist: cli.js - checksum: 10c0/db7ebc1733cf471e0b490b4f47e3e2ea2947ce417192c9246644e92c667dd56a71406cc58f62ca7587caf828364892e9952904a02b7aead752bc65b62a37cfe9 - languageName: node - linkType: hard - -"browserslist@npm:4.28.1, browserslist@npm:^4.0.0, browserslist@npm:^4.24.0, browserslist@npm:^4.25.1, browserslist@npm:^4.28.1": +"browserslist@npm:4.28.1, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1": version: 4.28.1 resolution: "browserslist@npm:4.28.1" dependencies: @@ -24717,19 +24367,7 @@ __metadata: languageName: node linkType: hard -"caniuse-api@npm:^3.0.0": - version: 3.0.0 - resolution: "caniuse-api@npm:3.0.0" - dependencies: - browserslist: "npm:^4.0.0" - caniuse-lite: "npm:^1.0.0" - lodash.memoize: "npm:^4.1.2" - lodash.uniq: "npm:^4.5.0" - checksum: 10c0/60f9e85a3331e6d761b1b03eec71ca38ef7d74146bece34694853033292156b815696573ed734b65583acf493e88163618eda915c6c826d46a024c71a9572b4c - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001688, caniuse-lite@npm:^1.0.30001759": +"caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001759": version: 1.0.30001768 resolution: "caniuse-lite@npm:1.0.30001768" checksum: 10c0/16808cb39f9563098deab6d45bcd0642a79fc5ace8dbcea8106b008b179820353e3ec089ed7e54f1f3c8bb84f2c2835b451f308212d8f36c2b7942f879e91955 @@ -26157,15 +25795,6 @@ __metadata: languageName: node linkType: hard -"cssnano-utils@npm:^5.0.1": - version: 5.0.1 - resolution: "cssnano-utils@npm:5.0.1" - peerDependencies: - postcss: ^8.4.32 - checksum: 10c0/e416e58587ccec4d904093a2834c66c44651578a58960019884add376d4f151c5b809674108088140dd57b0787cb7132a083d40ae33a72bf986d03c4b7b7c5f4 - languageName: node - linkType: hard - "csso@npm:^5.0.5": version: 5.0.5 resolution: "csso@npm:5.0.5" @@ -27346,7 +26975,7 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.263, electron-to-chromium@npm:^1.5.73": +"electron-to-chromium@npm:^1.5.263": version: 1.5.286 resolution: "electron-to-chromium@npm:1.5.286" checksum: 10c0/5384510f9682d7e46f98fa48b874c3901d9639de96e9e387afce1fe010fbac31376df0534524edc15f66e9902bfacee54037a5e598004e9c6a617884e379926d @@ -28044,13 +27673,6 @@ __metadata: languageName: node linkType: hard -"escalade@npm:3.1.2": - version: 3.1.2 - resolution: "escalade@npm:3.1.2" - checksum: 10c0/6b4adafecd0682f3aa1cd1106b8fff30e492c7015b178bc81b2d2f75106dabea6c6d6e8508fc491bd58e597c74abb0e8e2368f943ecb9393d4162e3c2f3cf287 - languageName: node - linkType: hard - "escalade@npm:3.2.0, escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -30143,17 +29765,6 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:11.2.0": - version: 11.2.0 - resolution: "fs-extra@npm:11.2.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^2.0.0" - checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398 - languageName: node - linkType: hard - "fs-extra@npm:11.3.2": version: 11.3.2 resolution: "fs-extra@npm:11.3.2" @@ -31565,13 +31176,6 @@ __metadata: languageName: node linkType: hard -"hookable@npm:5.5.3": - version: 5.5.3 - resolution: "hookable@npm:5.5.3" - checksum: 10c0/275f4cc84d27f8d48c5a5cd5685b6c0fea9291be9deea5bff0cfa72856ed566abde1dcd8cb1da0f9a70b4da3d7ec0d60dc3554c4edbba647058cc38816eced3d - languageName: node - linkType: hard - "hookified@npm:^1.12.1": version: 1.12.2 resolution: "hookified@npm:1.12.2" @@ -33219,13 +32823,6 @@ __metadata: languageName: node linkType: hard -"is-what@npm:^4.1.8": - version: 4.1.16 - resolution: "is-what@npm:4.1.16" - checksum: 10c0/611f1947776826dcf85b57cfb7bd3b3ea6f4b94a9c2f551d4a53f653cf0cb9d1e6518846648256d46ee6c91d114b6d09d2ac8a07306f7430c5900f87466aae5b - languageName: node - linkType: hard - "is-windows@npm:^1.0.0, is-windows@npm:^1.0.1": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -34553,13 +34150,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-arm64@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-darwin-arm64@npm:1.25.1" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "lightningcss-darwin-arm64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-darwin-arm64@npm:1.31.1" @@ -34574,13 +34164,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-x64@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-darwin-x64@npm:1.25.1" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "lightningcss-darwin-x64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-darwin-x64@npm:1.31.1" @@ -34595,13 +34178,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-freebsd-x64@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-freebsd-x64@npm:1.25.1" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "lightningcss-freebsd-x64@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-freebsd-x64@npm:1.31.1" @@ -34616,13 +34192,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-arm-gnueabihf@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.25.1" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "lightningcss-linux-arm-gnueabihf@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-arm-gnueabihf@npm:1.31.1" @@ -34637,13 +34206,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-arm64-gnu@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-linux-arm64-gnu@npm:1.25.1" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "lightningcss-linux-arm64-gnu@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-arm64-gnu@npm:1.31.1" @@ -34658,13 +34220,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-arm64-musl@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-linux-arm64-musl@npm:1.25.1" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "lightningcss-linux-arm64-musl@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-arm64-musl@npm:1.31.1" @@ -34679,13 +34234,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-x64-gnu@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-linux-x64-gnu@npm:1.25.1" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "lightningcss-linux-x64-gnu@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-x64-gnu@npm:1.31.1" @@ -34700,13 +34248,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-linux-x64-musl@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-linux-x64-musl@npm:1.25.1" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "lightningcss-linux-x64-musl@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-linux-x64-musl@npm:1.31.1" @@ -34735,13 +34276,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-win32-x64-msvc@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss-win32-x64-msvc@npm:1.25.1" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "lightningcss-win32-x64-msvc@npm:1.31.1": version: 1.31.1 resolution: "lightningcss-win32-x64-msvc@npm:1.31.1" @@ -34756,43 +34290,6 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:1.25.1": - version: 1.25.1 - resolution: "lightningcss@npm:1.25.1" - dependencies: - detect-libc: "npm:^1.0.3" - lightningcss-darwin-arm64: "npm:1.25.1" - lightningcss-darwin-x64: "npm:1.25.1" - lightningcss-freebsd-x64: "npm:1.25.1" - lightningcss-linux-arm-gnueabihf: "npm:1.25.1" - lightningcss-linux-arm64-gnu: "npm:1.25.1" - lightningcss-linux-arm64-musl: "npm:1.25.1" - lightningcss-linux-x64-gnu: "npm:1.25.1" - lightningcss-linux-x64-musl: "npm:1.25.1" - lightningcss-win32-x64-msvc: "npm:1.25.1" - dependenciesMeta: - lightningcss-darwin-arm64: - optional: true - lightningcss-darwin-x64: - optional: true - lightningcss-freebsd-x64: - optional: true - lightningcss-linux-arm-gnueabihf: - optional: true - lightningcss-linux-arm64-gnu: - optional: true - lightningcss-linux-arm64-musl: - optional: true - lightningcss-linux-x64-gnu: - optional: true - lightningcss-linux-x64-musl: - optional: true - lightningcss-win32-x64-msvc: - optional: true - checksum: 10c0/143a412dfd3393804c9dedac4294d7d54752dd589eb9ba43e3548bd6b0f9d73765b2b4cc0c62fae767c96d5d532a64d7fdfabd8b299caf733160a751cbb28297 - languageName: node - linkType: hard - "lightningcss@npm:1.31.1": version: 1.31.1 resolution: "lightningcss@npm:1.31.1" @@ -35138,7 +34635,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:4.1.2, lodash.memoize@npm:^4.1.2": +"lodash.memoize@npm:4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10c0/c8713e51eccc650422716a14cece1809cfe34bc5ab5e242b7f8b4e2241c2483697b971a604252807689b9dd69bfe3a98852e19a5b89d506b000b4187a1285df8 @@ -35469,16 +34966,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.19": - version: 0.30.19 - resolution: "magic-string@npm:0.30.19" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10c0/db23fd2e2ee98a1aeb88a4cdb2353137fcf05819b883c856dd79e4c7dfb25151e2a5a4d5dbd88add5e30ed8ae5c51bcf4accbc6becb75249d924ec7b4fbcae27 - languageName: node - linkType: hard - -"magic-string@npm:0.30.21, magic-string@npm:^0.30.0, magic-string@npm:^0.30.17, magic-string@npm:^0.30.19, magic-string@npm:^0.30.21, magic-string@npm:^0.30.3": +"magic-string@npm:0.30.21, magic-string@npm:^0.30.0, magic-string@npm:^0.30.17, magic-string@npm:^0.30.21, magic-string@npm:^0.30.3": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -35992,15 +35480,6 @@ __metadata: languageName: node linkType: hard -"merge-anything@npm:5.1.7": - version: 5.1.7 - resolution: "merge-anything@npm:5.1.7" - dependencies: - is-what: "npm:^4.1.8" - checksum: 10c0/1820c8dfa5da65de1829b5e9adb65d1685ec4bc5d358927cacd20a9917eff9448f383f937695f4dbd2162b152faf41ce24187a931621839ee8a8b3c306a65136 - languageName: node - linkType: hard - "merge-descriptors@npm:1.0.3": version: 1.0.3 resolution: "merge-descriptors@npm:1.0.3" @@ -37620,7 +37099,7 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.19, node-releases@npm:^2.0.27": +"node-releases@npm:^2.0.27": version: 2.0.27 resolution: "node-releases@npm:2.0.27" checksum: 10c0/f1e6583b7833ea81880627748d28a3a7ff5703d5409328c216ae57befbced10ce2c991bea86434e8ec39003bd017f70481e2e5f8c1f7e0a7663241f81d6e00e2 @@ -38659,13 +38138,6 @@ __metadata: languageName: node linkType: hard -"package-manager-detector@npm:0.1.0": - version: 0.1.0 - resolution: "package-manager-detector@npm:0.1.0" - checksum: 10c0/7d461515a8be38dbe13ac3d19d7774ab95d6795b177c890998511cda87e090ab1e65ee56dd2a66890802cbc2732c0c06c9402a7a1cdb64f7b615d9488b3ddb9c - languageName: node - linkType: hard - "package-manager-detector@npm:1.6.0, package-manager-detector@npm:^1.6.0": version: 1.6.0 resolution: "package-manager-detector@npm:1.6.0" @@ -39279,13 +38751,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 - languageName: node - linkType: hard - "picomatch@npm:4.0.4, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": version: 4.0.4 resolution: "picomatch@npm:4.0.4" @@ -39559,20 +39024,6 @@ __metadata: languageName: node linkType: hard -"postcss-merge-rules@npm:7.0.6": - version: 7.0.6 - resolution: "postcss-merge-rules@npm:7.0.6" - dependencies: - browserslist: "npm:^4.25.1" - caniuse-api: "npm:^3.0.0" - cssnano-utils: "npm:^5.0.1" - postcss-selector-parser: "npm:^7.1.0" - peerDependencies: - postcss: ^8.4.32 - checksum: 10c0/1708d2e862825f79077aff1f7d82ff815c015929f0fb5bb3fb58dbc83f9bc79ef9aa40ef585afbe2dcb2563ea3516f21332be926e746189649459eb9399cc95e - languageName: node - linkType: hard - "postcss-minify-selectors@npm:7.0.5": version: 7.0.5 resolution: "postcss-minify-selectors@npm:7.0.5" @@ -39651,16 +39102,6 @@ __metadata: languageName: node linkType: hard -"postcss-selector-parser@npm:7.1.0": - version: 7.1.0 - resolution: "postcss-selector-parser@npm:7.1.0" - dependencies: - cssesc: "npm:^3.0.0" - util-deprecate: "npm:^1.0.2" - checksum: 10c0/0fef257cfd1c0fe93c18a3f8a6e739b4438b527054fd77e9a62730a89b2d0ded1b59314a7e4aaa55bc256204f40830fecd2eb50f20f8cb7ab3a10b52aa06c8aa - languageName: node - linkType: hard - "postcss-selector-parser@npm:7.1.1, postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.0": version: 7.1.1 resolution: "postcss-selector-parser@npm:7.1.1" @@ -39713,17 +39154,6 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.5.6": - version: 8.5.6 - resolution: "postcss@npm:8.5.6" - dependencies: - nanoid: "npm:^3.3.11" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024 - languageName: node - linkType: hard - "postgres-array@npm:^3.0.1": version: 3.0.2 resolution: "postgres-array@npm:3.0.2" @@ -45112,16 +44542,6 @@ __metadata: languageName: node linkType: hard -"ts-morph@npm:26.0.0": - version: 26.0.0 - resolution: "ts-morph@npm:26.0.0" - dependencies: - "@ts-morph/common": "npm:~0.27.0" - code-block-writer: "npm:^13.0.3" - checksum: 10c0/c6880d90a1eefe0ce6555bf8c11cc104b1f36f84bd36a37a82b9ae0b974f51fe6b1bc91bb0ec42550158dc1c812329d6433e1237cba64f1ef515c129b321dd5d - languageName: node - linkType: hard - "ts-morph@npm:28.0.0": version: 28.0.0 resolution: "ts-morph@npm:28.0.0" @@ -45132,13 +44552,6 @@ __metadata: languageName: node linkType: hard -"ts-pattern@npm:5.8.0": - version: 5.8.0 - resolution: "ts-pattern@npm:5.8.0" - checksum: 10c0/0e41006a8de7490c7edbba36c095550cd4b0e334247f9e76cddbdaadea4bcdc479763fb403a787db19bb83480c02fe6ea0e9799ceaaba0573acbe31e341ab947 - languageName: node - linkType: hard - "ts-pattern@npm:5.9.0, ts-pattern@npm:^5.5.0": version: 5.9.0 resolution: "ts-pattern@npm:5.9.0" @@ -45146,7 +44559,7 @@ __metadata: languageName: node linkType: hard -"tsconfck@npm:3.1.6, tsconfck@npm:^3.0.3": +"tsconfck@npm:^3.0.3": version: 3.1.6 resolution: "tsconfck@npm:3.1.6" peerDependencies: @@ -45512,16 +44925,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.8.3": - version: 5.8.3 - resolution: "typescript@npm:5.8.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 - languageName: node - linkType: hard - "typescript@npm:5.9.3, typescript@npm:^5.7.3, typescript@npm:^5.8.3": version: 5.9.3 resolution: "typescript@npm:5.9.3" @@ -45582,16 +44985,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.8.3#optional!builtin": - version: 5.8.3 - resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A5.9.3#optional!builtin, typescript@patch:typescript@npm%3A^5.7.3#optional!builtin, typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" @@ -46110,7 +45503,7 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.1, update-browserslist-db@npm:^1.2.0": +"update-browserslist-db@npm:^1.2.0": version: 1.2.3 resolution: "update-browserslist-db@npm:1.2.3" dependencies: @@ -47455,13 +46848,6 @@ __metadata: languageName: node linkType: hard -"wordwrapjs@npm:5.1.0": - version: 5.1.0 - resolution: "wordwrapjs@npm:5.1.0" - checksum: 10c0/e147162f139eb8c05257729fde586f5422a2d242aa8f027b5fa5adead1b571b455d0690a15c73aeaa31c93ba96864caa06d84ebdb2c32a0890602ab86a7568d1 - languageName: node - linkType: hard - "wordwrapjs@npm:5.1.1": version: 5.1.1 resolution: "wordwrapjs@npm:5.1.1" @@ -48013,13 +47399,6 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.1.12": - version: 4.1.12 - resolution: "zod@npm:4.1.12" - checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 - languageName: node - linkType: hard - "zod@npm:4.4.3, zod@npm:^4.0.0": version: 4.4.3 resolution: "zod@npm:4.4.3" From e5d139abe2e02879c09a6f715a6f3b11fb6ecdc7 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 22 May 2026 18:34:09 +0100 Subject: [PATCH 09/21] allow ai to set net title. lint fixes. extract ai hero modal --- apps/petrinaut-website/vite.config.ts | 6 +- libs/@hashintel/petrinaut-core/src/ai.ts | 29 +- libs/@hashintel/petrinaut-core/src/index.ts | 2 + .../views/Editor/components/ai-cta-modal.tsx | 165 ++++++++++ .../src/ui/views/Editor/editor-view.tsx | 158 +-------- .../Editor/panels/ai-assistant-panel.tsx | 311 ++++++++---------- .../ai-assistant-surface.test.tsx | 94 +++++- .../ai-assistant-surface.tsx | 63 +++- .../format-diagnostics-for-ai.test.ts | 2 +- .../Editor/panels/ai-assistant-panel/types.ts | 7 +- yarn.lock | 33 +- 11 files changed, 464 insertions(+), 406 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index 58a62d569c1..c72e5cbd281 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -18,10 +18,8 @@ const loadServerEnv = (mode: string) => { } }; -// Mounts `api/chat.ts` during `vite dev` so the front-end can hit `/api/chat` -// on the same origin. `@whatwg-node/server` handles the Node <-> Fetch -// translation (streaming, abort signals, header semantics) that Vercel's -// production runtime performs for the deployed function. +// Plugin required to serve the chat endpoint in dev. +// In production, it will be bundled and served by Vercel. const petrinautApiDevPlugin = (): Plugin => ({ name: "petrinaut-api-dev", apply: "serve", diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index b22c643604f..e6648a3cac7 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -79,10 +79,13 @@ function createToolBundle>( export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; export const getNetCompilationErrorsToolName = "getNetCompilationErrors"; +export const setNetTitleToolName = "setNetTitle"; const getLatestNetDefinitionToolInputSchema = z .strictObject({}) - .describe("Get the latest complete Petrinaut SDCPN net definition."); + .describe( + "Get the current Petrinaut net state. Returns `{ title, definition }` where `title` is the user-visible net title and `definition` is the complete SDCPN net definition.", + ); const getNetCompilationErrorsToolInputSchema = z .strictObject({}) @@ -90,11 +93,27 @@ const getNetCompilationErrorsToolInputSchema = z "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", ); +export const setNetTitleToolInputSchema = z + .strictObject({ + title: z + .string() + .min(1) + .max(120) + .meta({ + description: + "Short human-readable title for the net (sentence case, no quotes, ideally under ~60 characters).", + }), + }) + .describe( + "Set the human-readable title shown for the current Petrinaut net.", + ); + export const petrinautAiToolInputSchemas = { ...mutationActionInputSchemas, ...aiCommandActionInputSchemas, [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, + [setNetTitleToolName]: setNetTitleToolInputSchema, }; export const petrinautAiMutationTools = createToolBundle( @@ -116,6 +135,10 @@ export const petrinautAiTools = { description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), inputSchema: getNetCompilationErrorsToolInputSchema, }, + [setNetTitleToolName]: { + description: getSchemaDescription(setNetTitleToolInputSchema), + inputSchema: setNetTitleToolInputSchema, + }, } satisfies PetrinautAiTools; export type PetrinautAiToolName = keyof typeof petrinautAiTools; @@ -168,8 +191,9 @@ export function createPetrinautAiWritableCallbacks( export const petrinautAiPrompt = `You are an expert assistant for building Stochastic Dynamic Coloured Petri Nets (SDCPNs) in Petrinaut. Use the provided tools to directly modify the current net. The tools use Petrinaut's raw mutation interfaces, so include stable IDs, full entity objects where required, and canvas positions for places and transitions. -You can check the latest complete net definition at any point using the ${getLatestNetDefinitionToolName} tool. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types. +You can check the current net state at any point using the ${getLatestNetDefinitionToolName} tool, which returns \`{ title, definition }\` — the user-visible net title plus the complete SDCPN. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types, and consult the \`title\` when deciding whether the net could use a more descriptive name. You can check current TypeScript compilation diagnostics at any point using the ${getNetCompilationErrorsToolName} tool. +You can rename the net at any point using the ${setNetTitleToolName} tool. Interview first, build second. Before creating a new net (or adding a substantial new subsystem to an existing one), do NOT jump straight to tool calls. Run a brief, focused interview to establish: @@ -192,6 +216,7 @@ When creating or revising a net: - Use differential equations only for places whose coloured tokens have continuous dynamics. - Suggest place visualisations. Once the structure is agreed, proactively propose 1–2 vivid, domain-specific \`visualizerCode\` ideas (e.g. a queue as a stacked bar, satellites as orbit dots, infected population as a heat-dot grid, machines as a row of state-coloured rectangles, inventory as a shelf of boxes) and offer to add them. Default to compact, single-glance SVGs sized for a place node, following the visualizer rules in the code-surface cheatsheet below. - Keep executable code self-contained and readable. +- Title the net. After building or substantially extending a model, check the title returned by \`${getLatestNetDefinitionToolName}\`. If it is \`Untitled\` or an obvious placeholder, call \`${setNetTitleToolName}\` with a concise, descriptive title (sentence case, ideally under ~60 characters). Don't overwrite a user-chosen title without being asked. Validate every code-writing change. After any tool call that writes code — lambda, transition kernel, dynamics, visualizer, metric, or scenario code-mode initial state — call ${getNetCompilationErrorsToolName} before continuing and fix any reported diagnostics before relying on the new code. Do not assume a code edit is correct just because the tool call succeeded; mutations only validate the schema, not the runtime contract. diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index a56d0962af2..03591c58c4c 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -68,6 +68,8 @@ export { petrinautAiTools, placeSchema, scenarioSchema, + setNetTitleToolInputSchema, + setNetTitleToolName, transitionSchema, } from "./ai"; export type { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx new file mode 100644 index 00000000000..eaefb604c0f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx @@ -0,0 +1,165 @@ +import { useRef, useEffect } from "react"; + +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { AiAssistantIcon } from "../../../components/ai-assistant-icon"; +import { Input } from "../../../components/input"; + +const emptyAiHeroLayerStyle = css({ + position: "absolute", + inset: "0", + zIndex: 20, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "8", + pointerEvents: "none", +}); + +const emptyAiHeroStyle = css({ + pointerEvents: "auto", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "5", + width: "[min(560px, calc(100% - 48px))]", + padding: "[28px]", + borderRadius: "[24px]", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "blue.a30", + backgroundColor: "white.a95", + boxShadow: + "[0px 20px 60px rgba(15, 23, 42, 0.18), 0px 2px 8px rgba(15, 23, 42, 0.08), inset 0px 1px 0px rgba(255, 255, 255, 0.9)]", + textAlign: "center", + userSelect: "text", + backdropFilter: "[blur(14px)]", +}); + +const emptyAiHeroIconStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[56px]", + height: "[56px]", + borderRadius: "2xl", + backgroundColor: "blue.s20", + boxShadow: "[0px 0px 0px 8px rgba(42, 128, 200, 0.08)]", + color: "blue.s90", +}); + +const emptyAiHeroCopyStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + maxWidth: "[420px]", +}); + +const emptyAiHeroTitleStyle = css({ + margin: "0", + color: "neutral.s110", + fontFamily: "[Inter Tight, Inter, sans-serif]", + fontSize: "[24px]", + fontWeight: "semibold", + lineHeight: "[30px]", +}); + +const emptyAiHeroFormStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + padding: "1.5", + borderRadius: "[20px]", + backgroundColor: "neutral.s00", + boxShadow: + "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", +}); + +const emptyAiHeroInputStyle = css({ + flex: "[1]", + minWidth: "[0]", + height: "[48px]", + borderColor: "[transparent]", + backgroundColor: "[transparent]", + boxShadow: "[none]", + fontSize: "base", + _hover: { + borderColor: "[transparent]", + }, + _focus: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _active: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, +}); + +export const EmptyAiHero = ({ + bottomClearance, + input, + onInputChange, + onSubmit, +}: { + bottomClearance: number; + input: string; + onInputChange: (value: string) => void; + onSubmit: (message: string) => void; +}) => { + const canSubmit = input.trim().length > 0; + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( +
+
{ + event.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput) { + return; + } + + onSubmit(trimmedInput); + }} + > +
+ +
+
+

+ Describe the process you want to create +

+
+
+ onInputChange(event.currentTarget.value)} + placeholder="e.g. Model an SIR outbreak with recovery" + aria-label="Describe the process you want to create" + size="lg" + /> +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 6213c81781d..3a5778defd1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -18,10 +18,7 @@ import { PortalContainerContext } from "../../../react/state/portal-container-co import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; -import { AiAssistantIcon } from "../../components/ai-assistant-icon"; import { Box } from "../../components/box"; -import { Button } from "../../components/button"; -import { Input } from "../../components/input"; import { Stack } from "../../components/stack"; import { exportSDCPN } from "../../file-io/export-sdcpn"; import { exportTikZ } from "../../file-io/export-tikz"; @@ -31,6 +28,7 @@ import { compactNodeDimensions, } from "../SDCPN/node-dimensions"; import { SDCPNView } from "../SDCPN/sdcpn-view"; +import { EmptyAiHero } from "./components/ai-cta-modal"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { ImportErrorDialog } from "./components/import-error-dialog"; import { TopBar } from "./components/TopBar/top-bar"; @@ -97,98 +95,6 @@ const portalContainerStyle = css({ pointerEvents: "none", }); -const emptyAiHeroLayerStyle = css({ - position: "absolute", - inset: "0", - zIndex: 20, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "8", - pointerEvents: "none", -}); - -const emptyAiHeroStyle = css({ - pointerEvents: "auto", - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: "5", - width: "[min(560px, calc(100% - 48px))]", - padding: "[28px]", - borderRadius: "[24px]", - borderWidth: "thin", - borderStyle: "solid", - borderColor: "blue.a30", - backgroundColor: "white.a95", - boxShadow: - "[0px 20px 60px rgba(15, 23, 42, 0.18), 0px 2px 8px rgba(15, 23, 42, 0.08), inset 0px 1px 0px rgba(255, 255, 255, 0.9)]", - textAlign: "center", - userSelect: "text", - backdropFilter: "[blur(14px)]", -}); - -const emptyAiHeroIconStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - width: "[56px]", - height: "[56px]", - borderRadius: "2xl", - backgroundColor: "blue.s20", - boxShadow: "[0px 0px 0px 8px rgba(42, 128, 200, 0.08)]", - color: "blue.s90", -}); - -const emptyAiHeroCopyStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - maxWidth: "[420px]", -}); - -const emptyAiHeroTitleStyle = css({ - margin: "0", - color: "neutral.s110", - fontFamily: "[Inter Tight, Inter, sans-serif]", - fontSize: "[24px]", - fontWeight: "semibold", - lineHeight: "[30px]", -}); - -const emptyAiHeroFormStyle = css({ - display: "flex", - alignItems: "center", - gap: "2", - width: "full", - padding: "1.5", - borderRadius: "[20px]", - backgroundColor: "neutral.s00", - boxShadow: - "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", -}); - -const emptyAiHeroInputStyle = css({ - flex: "[1]", - minWidth: "[0]", - height: "[48px]", - borderColor: "[transparent]", - backgroundColor: "[transparent]", - boxShadow: "[none]", - fontSize: "base", - _hover: { - borderColor: "[transparent]", - }, - _focus: { - borderColor: "[transparent]", - boxShadow: "[none]", - }, - _active: { - borderColor: "[transparent]", - boxShadow: "[none]", - }, -}); - const isEmptySDCPN = (sdcpn: SDCPN) => sdcpn.places.length === 0 && sdcpn.transitions.length === 0 && @@ -196,68 +102,6 @@ const isEmptySDCPN = (sdcpn: SDCPN) => sdcpn.parameters.length === 0 && sdcpn.differentialEquations.length === 0; -const EmptyAiHero = ({ - bottomClearance, - input, - onInputChange, - onSubmit, -}: { - bottomClearance: number; - input: string; - onInputChange: (value: string) => void; - onSubmit: (message: string) => void; -}) => { - const canSubmit = input.trim().length > 0; - - return ( -
-
{ - event.preventDefault(); - const trimmedInput = input.trim(); - if (!trimmedInput) { - return; - } - - onSubmit(trimmedInput); - }} - > -
- -
-
-

- Describe the process you want to create -

-
-
- onInputChange(event.currentTarget.value)} - placeholder="e.g. Model an SIR outbreak with recovery" - aria-label="Describe the process you want to create" - size="lg" - /> -
-
-
- ); -}; - /** * EditorView is responsible for the overall editor UI layout and controls. * It relies on sdcpn-store and editor-store for state, and uses SDCPNView for visualization. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index 177c996b9f5..be39a139e62 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -11,6 +11,8 @@ import { mutationActionInputSchemas as petrinautAiMutationToolInputSchemas, type Petrinaut, type PetrinautAiMutationToolName, + setNetTitleToolInputSchema, + setNetTitleToolName, } from "@hashintel/petrinaut-core"; import { PetrinautInstanceContext } from "../../../../react/instance-context"; @@ -80,59 +82,6 @@ const isPetrinautAiCommandToolName = ( toolName: string, ): toolName is AiCommandActionName => toolName in aiCommandActionInputSchemas; -const logToolCallError = ({ - error, - input, - toolName, -}: { - error: unknown; - input: unknown; - toolName: string; -}) => { - // oxlint-disable-next-line no-console - console.error("Petrinaut AI tool call failed", { - error, - input, - toolName, - }); -}; - -const getErroredToolParts = (messages: PetrinautAiMessage[]) => - messages.flatMap((message) => - message.parts.flatMap((part) => { - if ( - !("state" in part) || - part.state !== "output-error" || - !part.type.startsWith("tool-") - ) { - return []; - } - - const toolPart = part as { - errorText?: unknown; - input?: unknown; - toolCallId?: unknown; - type: string; - }; - - return [ - { - errorText: - typeof toolPart.errorText === "string" - ? toolPart.errorText - : undefined, - input: toolPart.input, - messageId: message.id, - toolCallId: - typeof toolPart.toolCallId === "string" - ? toolPart.toolCallId - : undefined, - toolName: toolPart.type.replace(/^tool-/, ""), - }, - ]; - }), - ); - const safelyAddToolOutput = ( addToolOutput: ReturnType< typeof useChat @@ -141,13 +90,10 @@ const safelyAddToolOutput = ( ReturnType>["addToolOutput"] >[0], ) => { - void Promise.resolve(addToolOutput(params)).catch((error: unknown) => { - logToolCallError({ - error, - input: undefined, - toolName: String(params.tool), - }); - }); + // Failures here surface in the UI as an errored tool call (with the + // error message on hover), so we just swallow the rejection to avoid an + // unhandled-promise warning. + void Promise.resolve(addToolOutput(params)).catch(() => {}); }; const waitForDiagnosticsRefresh = async ({ @@ -231,6 +177,11 @@ export const AiAssistantPanel = ({ initialMessage?: string | null; onInitialMessageConsumed?: () => void; }) => { + // The wrapped AI transport closes over several refs (diagnostics version, + // pending mutation version, diagnostics context) so the transport's + // `sendMessages` can read the latest values when it eventually runs. React + // Compiler can't prove those reads happen off-render, so we opt out here. + "use no memo"; const instance = use(PetrinautInstanceContext); const readOnlyReason = useReadOnlyReason(); const readOnlyReasonRef = useRef(readOnlyReason); @@ -248,9 +199,13 @@ export const AiAssistantPanel = ({ setSimulateDrawer, setSimulateViewMode, } = use(EditorContext); - const { petriNetDefinition } = use(SDCPNContext); + const { petriNetDefinition, setTitle, title } = use(SDCPNContext); const [input, setInput] = useState(""); const submittedInitialMessageRef = useRef(null); + const titleRef = useRef(title); + useEffect(() => { + titleRef.current = title; + }, [title]); const diagnosticsContextRef = useRef("No current TypeScript diagnostics."); const diagnosticsVersionRef = useRef(0); const pendingMutationDiagnosticsVersionRef = useRef(null); @@ -266,6 +221,9 @@ export const AiAssistantPanel = ({ }); }, [diagnosticsByUri, petriNetDefinition]); + /* eslint-disable react-hooks-js/refs -- See the `"use no memo"` directive + above: the refs are only read when the wrapped transport runs, never during + render. The lint rule can't see that. */ const [diagnosticsTransportState, setDiagnosticsTransportState] = useState( () => ({ source: aiAssistant.transport, @@ -309,6 +267,7 @@ export const AiAssistantPanel = ({ }), }); }, [aiAssistant.transport, diagnosticsTransportState.source]); + /* eslint-enable react-hooks-js/refs */ const { error, @@ -326,119 +285,146 @@ export const AiAssistantPanel = ({ aiAssistant.onMessages?.(finishedMessages); }, onToolCall: async ({ toolCall }) => { - try { - if (!instance) { - throw new Error( - "Petrinaut AI cannot run without an editor instance.", - ); - } - if (toolCall.dynamic) { - throw new Error(`Unknown Petrinaut AI tool: ${toolCall.toolName}`); - } - if (toolCall.toolName === getLatestNetDefinitionToolName) { - safelyAddToolOutput(addToolOutput, { - tool: toolCall.toolName, - toolCallId: toolCall.toolCallId, - output: instance.definition.get(), - }); - return; - } - if (toolCall.toolName === getNetCompilationErrorsToolName) { - await waitForDiagnosticsRefresh({ - consumePendingMutationDiagnosticsVersion: () => { - const pendingVersion = - pendingMutationDiagnosticsVersionRef.current; - pendingMutationDiagnosticsVersionRef.current = null; - return pendingVersion; - }, - diagnosticsVersionRef, - }); - safelyAddToolOutput(addToolOutput, { - tool: toolCall.toolName, - toolCallId: toolCall.toolCallId, - output: diagnosticsContextRef.current, - }); - return; - } - - const toolName = toolCall.toolName; - if ( - !isPetrinautAiMutationToolName(toolName) && - !isPetrinautAiCommandToolName(toolName) - ) { - throw new Error( - `Unknown Petrinaut AI tool: ${String(toolName as string)}`, - ); - } + if (!instance) { + throw new Error("Petrinaut AI cannot run without an editor instance."); + } + if (toolCall.dynamic) { + throw new Error(`Unknown Petrinaut AI tool: ${toolCall.toolName}`); + } + if (toolCall.toolName === getLatestNetDefinitionToolName) { + safelyAddToolOutput(addToolOutput, { + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: { + title: titleRef.current, + definition: instance.definition.get(), + }, + }); + return; + } + if (toolCall.toolName === getNetCompilationErrorsToolName) { + await waitForDiagnosticsRefresh({ + consumePendingMutationDiagnosticsVersion: () => { + const pendingVersion = + pendingMutationDiagnosticsVersionRef.current; + pendingMutationDiagnosticsVersionRef.current = null; + return pendingVersion; + }, + diagnosticsVersionRef, + }); + safelyAddToolOutput(addToolOutput, { + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: diagnosticsContextRef.current, + }); + return; + } - const currentReadOnlyReason = readOnlyReasonRef.current; - if (currentReadOnlyReason !== null) { + if (toolCall.toolName === setNetTitleToolName) { + const setNetTitleReadOnlyReason = readOnlyReasonRef.current; + if (setNetTitleReadOnlyReason !== null) { safelyAddToolOutput(addToolOutput, { - tool: toolName, + tool: toolCall.toolName, toolCallId: toolCall.toolCallId, output: { applied: false, - blocked: currentReadOnlyReason.kind, - reason: formatReadOnlyReason(currentReadOnlyReason), + blocked: setNetTitleReadOnlyReason.kind, + reason: formatReadOnlyReason(setNetTitleReadOnlyReason), } satisfies AiToolOutput, }); return; } + const parsedSetNetTitleInput = setNetTitleToolInputSchema.parse( + toolCall.input, + ); + const previousTitle = titleRef.current; + setTitle(parsedSetNetTitleInput.title); + safelyAddToolOutput(addToolOutput, { + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: { + applied: true, + title: `Renamed net to "${parsedSetNetTitleInput.title}"`, + detail: + previousTitle && + previousTitle !== parsedSetNetTitleInput.title + ? `Previous title: ${previousTitle}` + : undefined, + } satisfies AiToolOutput, + }); + return; + } - if (isPetrinautAiCommandToolName(toolName)) { - const commandInput = aiCommandActionInputSchemas[toolName].parse( - toolCall.input, - ); - if (getInteractiveTool(toolName, commandInput)) { - // Defer: the surface will render the widget and call - // onInteractiveToolSubmit when the user decides. - return; - } - pendingMutationDiagnosticsVersionRef.current = - diagnosticsVersionRef.current; - const aiToolCall = { - toolName, - input: commandInput, - } as Extract; - const output = await applyPetrinautAiCommand({ - aiToolCall, - instance, - }); - safelyAddToolOutput(addToolOutput, { - tool: toolName, - toolCallId: toolCall.toolCallId, - output, - }); - return; - } + const toolName = toolCall.toolName; + if ( + !isPetrinautAiMutationToolName(toolName) && + !isPetrinautAiCommandToolName(toolName) + ) { + throw new Error( + `Unknown Petrinaut AI tool: ${String(toolName as string)}`, + ); + } - const toolInput = petrinautAiMutationToolInputSchemas[toolName].parse( + const currentReadOnlyReason = readOnlyReasonRef.current; + if (currentReadOnlyReason !== null) { + safelyAddToolOutput(addToolOutput, { + tool: toolName, + toolCallId: toolCall.toolCallId, + output: { + applied: false, + blocked: currentReadOnlyReason.kind, + reason: formatReadOnlyReason(currentReadOnlyReason), + } satisfies AiToolOutput, + }); + return; + } + + if (isPetrinautAiCommandToolName(toolName)) { + const commandInput = aiCommandActionInputSchemas[toolName].parse( toolCall.input, ); + if (getInteractiveTool(toolName, commandInput)) { + // Defer: the surface will render the widget and call + // onInteractiveToolSubmit when the user decides. + return; + } pendingMutationDiagnosticsVersionRef.current = diagnosticsVersionRef.current; const aiToolCall = { toolName, - input: toolInput, - } as Extract; - const output = applyPetrinautAiMutation({ + input: commandInput, + } as Extract; + const output = await applyPetrinautAiCommand({ aiToolCall, instance, }); - safelyAddToolOutput(addToolOutput, { tool: toolName, toolCallId: toolCall.toolCallId, output, }); - } catch (toolError) { - logToolCallError({ - error: toolError, - input: toolCall.input, - toolName: toolCall.toolName, - }); - throw toolError; + return; } + + const toolInput = petrinautAiMutationToolInputSchemas[toolName].parse( + toolCall.input, + ); + pendingMutationDiagnosticsVersionRef.current = + diagnosticsVersionRef.current; + const aiToolCall = { + toolName, + input: toolInput, + } as Extract; + const output = applyPetrinautAiMutation({ + aiToolCall, + instance, + }); + + safelyAddToolOutput(addToolOutput, { + tool: toolName, + toolCallId: toolCall.toolCallId, + output, + }); }, }); @@ -467,35 +453,6 @@ export const AiAssistantPanel = ({ sendMessage, ]); - useEffect(() => { - if (!error) { - return; - } - - const lastMessage = messages.at(-1); - // oxlint-disable-next-line no-console - console.error("Petrinaut AI chat failed", { - error, - lastMessage, - messageCount: messages.length, - status, - }); - }, [error, messages, status]); - - const loggedErroredToolCallsRef = useRef>(new Set()); - - useEffect(() => { - for (const toolPart of getErroredToolParts(messages)) { - const key = `${toolPart.messageId}:${toolPart.toolCallId ?? toolPart.toolName}`; - if (loggedErroredToolCallsRef.current.has(key)) { - continue; - } - - loggedErroredToolCallsRef.current.add(key); - console.error("Petrinaut AI tool call failed", toolPart); - } - }, [messages]); - if (!isAiAssistantOpen || !instance) { return null; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx index 2f203039767..8d05a26477f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx @@ -10,8 +10,6 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; -import type { SDCPN } from "@hashintel/petrinaut-core"; - import { AiAssistantSurface } from "./ai-assistant-surface"; import type { PetrinautAiMessage } from "./types"; @@ -111,11 +109,17 @@ describe("AiAssistantSurface", () => { }); test("scrolls to the latest chat content", async () => { + // jsdom does not implement `scrollIntoView`, so we install a stub on the + // prototype and restore it afterwards. The `unbound-method` lint warning + // is a false positive — we never invoke the saved reference, we only + // assign it back. + // eslint-disable-next-line @typescript-eslint/unbound-method const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; const scrollIntoView = vi.fn(); window.HTMLElement.prototype.scrollIntoView = scrollIntoView; + // Make rAF synchronous so the scroll effect runs before the assertion. window.requestAnimationFrame = (callback) => { callback(0); return 0; @@ -242,10 +246,9 @@ describe("AiAssistantSurface", () => { status="ready" />, ); - const renderedText = container.textContent ?? ""; - expect(renderedText.indexOf("Reasoning")).toBeLessThan( - renderedText.indexOf("I found the current places."), + expect(container.textContent).toMatch( + /Reasoning[\s\S]*I found the current places\./u, ); }); @@ -389,15 +392,11 @@ describe("AiAssistantSurface", () => { }, { type: "tool-deleteItemsByIds", - state: "output-available", + state: "input-available", toolCallId: "tool-2", input: { items: [{ type: "place", id: "place__old" }], }, - output: { - applied: true, - title: "Deleted 1 item", - }, }, ], }, @@ -411,7 +410,7 @@ describe("AiAssistantSurface", () => { onInputChange={noop} onStop={noop} onSubmit={noop} - status="ready" + status="streaming" />, ); @@ -429,6 +428,65 @@ describe("AiAssistantSurface", () => { ).toBe("danger"); }); + test("auto-collapses grouped changes once every tool is complete", () => { + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-addPlace", + state: "output-available", + toolCallId: "tool-1", + input: { + id: "place__buffer", + name: "Buffer", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + output: { + applied: true, + title: "Added place Buffer", + }, + }, + { + type: "tool-deleteItemsByIds", + state: "output-available", + toolCallId: "tool-2", + input: { + items: [{ type: "place", id: "place__old" }], + }, + output: { + applied: true, + title: "Deleted 1 item", + }, + }, + ], + }, + ]; + + render( + , + ); + + expect( + screen + .getByRole("button", { name: /2 changes/u }) + .getAttribute("aria-expanded"), + ).toBe("false"); + }); + test("keeps net definition checks separate from grouped changes", () => { const messages: PetrinautAiMessage[] = [ { @@ -442,12 +500,14 @@ describe("AiAssistantSurface", () => { input: {}, output: { title: "HyProGen 121 - Stochastic Petri Net", - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - } as SDCPN & { title: string }, + definition: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }, }, { type: "tool-addPlace", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx index b5db4978021..f29d6091e6c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx @@ -14,6 +14,7 @@ import { getNetCompilationErrorsToolName, petrinautAiMutationTools, type SelectionItem, + setNetTitleToolName, } from "@hashintel/petrinaut-core"; import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; @@ -40,6 +41,8 @@ type ToolRenderItem = { summary: AiToolSummary; tone: ToolTone; toolName: string; + /** Server-reported error message for tools whose state is `output-error`. */ + errorText?: string; /** Set when the tool requires an inline widget for human input. */ interactive?: { definition: InteractiveToolDefinition; @@ -53,6 +56,7 @@ type TextPart = Extract; type ReasoningMessagePart = Extract; type RenderableToolPart = PetrinautAiMessage["parts"][number] & { + errorText?: unknown; input?: unknown; output?: unknown; state?: string; @@ -340,8 +344,12 @@ const markdownStyle = css({ fontFamily: "mono", fontSize: "xs", backgroundColor: "neutral.s20", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", borderRadius: "sm", paddingX: "1", + paddingY: "[2px]", }, "& pre code": { display: "block", @@ -400,9 +408,8 @@ const reasoningBodyStyle = css({ borderWidth: "thin", borderStyle: "solid", borderColor: "neutral.a30", - borderRadius: "lg", + borderRadius: "md", backgroundColor: "neutral.s10", - boxShadow: "[0px 0px 0px 2px {colors.neutral.bg.subtle}]", padding: "2", color: "neutral.s90", fontSize: "sm", @@ -454,11 +461,7 @@ const toolGroupPanelStyle = css({ flexDirection: "column", gap: "[0]", overflow: "hidden", - borderWidth: "thin", - borderStyle: "solid", - borderColor: "[rgba(0,0,0,0.13)]", borderRadius: "lg", - backgroundColor: "white", "& > button": { borderRadius: "[0]", }, @@ -756,6 +759,17 @@ const getToolSummaryFromPart = (part: RenderableToolPart): AiToolSummary => { if (toolName === getNetCompilationErrorsToolName) { return { title: "Checked net compilation errors" }; } + if (toolName === setNetTitleToolName) { + const proposedTitle = + typeof part.input === "object" && + part.input !== null && + typeof (part.input as { title?: unknown }).title === "string" + ? (part.input as { title: string }).title + : undefined; + if (proposedTitle) { + return { title: `Renaming net to "${proposedTitle}"` }; + } + } const output = part.output; if (typeof output === "object" && output !== null) { @@ -855,6 +869,10 @@ const toToolRenderItem = ( summary, tone: getToolTone({ state, summary, toolName }), toolName, + errorText: + state === "output-error" && typeof part.errorText === "string" + ? part.errorText + : undefined, interactive, }; }; @@ -939,7 +957,7 @@ const getMessagesScrollKey = (messages: PetrinautAiMessage[]): string => } return "state" in part - ? `${part.type}:${part.state ?? ""}` + ? `${part.type}:${part.state}` : part.type; }) .join(","), @@ -1084,6 +1102,7 @@ const ToolItem = ({ onSelectToolTarget?.(target); } }} + title={errored ? tool.errorText : undefined} > void; tools: ToolRenderItem[]; }) => { + const allComplete = tools.every( + (tool) => + tool.state === "output-available" || tool.state === "output-error", + ); + if (tools.length === 0) { return null; } @@ -1176,8 +1200,15 @@ const ToolList = ({ ); } + // Remount when the group transitions between in-progress and complete so + // `defaultOpen` re-initialises (auto-collapse on completion) without + // controlled state fighting user toggles. return ( - + @@ -1249,20 +1280,18 @@ export const AiAssistantSurface = ({ useEffect(() => { const scrollToEnd = () => { + // The inner optional chain (`scrollIntoView?.`) is intentional — jsdom + // omits `Element.prototype.scrollIntoView`, so unit tests need the + // graceful no-op. The lint rule can't see that. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition messagesEndRef.current?.scrollIntoView?.({ block: "end", behavior: "smooth", }); }; - const requestFrame = - window.requestAnimationFrame ?? - ((callback: FrameRequestCallback) => window.setTimeout(callback, 0)); - const cancelFrame = - window.cancelAnimationFrame ?? - ((handle: number) => window.clearTimeout(handle)); - const frameId = requestFrame(scrollToEnd); - - return () => cancelFrame(frameId); + const frameId = window.requestAnimationFrame(scrollToEnd); + + return () => window.cancelAnimationFrame(frameId); }, [messagesScrollKey, status]); return ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts index 7161b8dc302..eea45bcfcce 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts @@ -55,7 +55,7 @@ describe("formatDiagnosticsForAi", () => { definition, diagnosticsByUri: new Map(), }), - ).toBe("No current TypeScript diagnostics."); + ).toBe("No errors detected in your model – everything compiles!"); }); test("formats transition and differential-equation diagnostics", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts index 375fb07e3ac..c0cbeffc45f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -7,6 +7,7 @@ import type { PetrinautAiMutationToolName, PetrinautAiToolInput, SDCPN, + setNetTitleToolName, } from "@hashintel/petrinaut-core"; import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; @@ -25,12 +26,16 @@ type PetrinautAiUiTools = { } & { [getLatestNetDefinitionToolName]: { input: PetrinautAiToolInput; - output: SDCPN; + output: { title: string; definition: SDCPN }; }; [getNetCompilationErrorsToolName]: { input: PetrinautAiToolInput; output: string; }; + [setNetTitleToolName]: { + input: PetrinautAiToolInput; + output: AiToolOutput; + }; }; export type PetrinautAiMessage = UIMessage< diff --git a/yarn.lock b/yarn.lock index a436a2c259b..a23135c5d66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3674,18 +3674,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.25.6, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.3, @babel/parser@npm:^7.26.7, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": - version: 7.29.2 - resolution: "@babel/parser@npm:7.29.2" - dependencies: - "@babel/types": "npm:^7.29.0" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317 - languageName: node - linkType: hard - -"@babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.25.6, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.26.3, @babel/parser@npm:^7.26.7, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": version: 7.29.3 resolution: "@babel/parser@npm:7.29.3" dependencies: @@ -30143,16 +30132,7 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5": - version: 4.13.6 - resolution: "get-tsconfig@npm:4.13.6" - dependencies: - resolve-pkg-maps: "npm:^1.0.0" - checksum: 10c0/bab6937302f542f97217cbe7cbbdfa7e85a56a377bc7a73e69224c1f0b7c9ae8365918e55752ae8648265903f506c1705f63c0de1d4bab1ec2830fef3e539a1a - languageName: node - linkType: hard - -"get-tsconfig@npm:^4.13.0": +"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.13.0, get-tsconfig@npm:^4.7.5": version: 4.14.0 resolution: "get-tsconfig@npm:4.14.0" dependencies: @@ -47399,20 +47379,13 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.4.3, zod@npm:^4.0.0": +"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 languageName: node linkType: hard -"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.1.5": - version: 4.3.6 - resolution: "zod@npm:4.3.6" - checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 - languageName: node - linkType: hard - "zrender@npm:5.6.1": version: 5.6.1 resolution: "zrender@npm:5.6.1" From 1d72539a349d045192633162d379528c6c4e09d1 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 22 May 2026 19:57:32 +0100 Subject: [PATCH 10/21] format --- .../skills/fractal-file-structuring/SKILL.md | 1 - apps/petrinaut-website/README.md | 10 +- apps/petrinaut-website/api/chat.ts | 3 +- apps/petrinaut-website/src/main/app.tsx | 480 ++++----- .../main/app/use-local-storage-ai-messages.ts | 3 +- apps/petrinaut-website/vite.config.ts | 3 +- .../petrinaut-core/src/actions.test.ts | 930 +++++++++--------- libs/@hashintel/petrinaut-core/src/ai.ts | 206 ++-- .../petrinaut-core/src/commands.test.ts | 255 ++--- .../@hashintel/petrinaut-core/src/commands.ts | 5 +- libs/@hashintel/petrinaut-core/src/index.ts | 344 +++---- .../@hashintel/petrinaut-core/src/instance.ts | 195 ++-- .../src/layout/calculate-graph-layout.ts | 134 +-- .../src/schemas/entity-schemas.ts | 470 ++++----- libs/@hashintel/petrinaut/src/main.ts | 84 +- .../src/react/hooks/use-petrinaut-commands.ts | 4 +- .../react/hooks/use-petrinaut-mutations.ts | 4 +- .../src/react/use-petrinaut-instance.ts | 15 +- .../petrinaut/src/ui/clipboard/clipboard.ts | 57 +- .../petrinaut/src/ui/components/popover.tsx | 152 +-- .../src/ui/petrinaut-story-provider.tsx | 262 ++--- .../petrinaut/src/ui/petrinaut.stories.tsx | 7 +- .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 1 + .../BottomBar/playback-settings-menu.tsx | 654 ++++++------ .../BottomBar/use-keyboard-shortcuts.ts | 372 +++---- .../subviews/differential-equations-list.tsx | 2 +- .../LeftSideBar/subviews/parameters-list.tsx | 2 +- .../LeftSideBar/subviews/types-list.tsx | 2 +- .../panels/SimulateView/simulate-view.tsx | 114 +-- .../Editor/panels/ai-assistant-panel.tsx | 6 +- .../ai-assistant-surface.stories.tsx | 3 +- .../ai-assistant-surface.test.tsx | 1 + .../ai-assistant-surface.tsx | 4 +- ...ate-diagnostics-aware-ai-transport.test.ts | 3 +- .../create-diagnostics-aware-ai-transport.ts | 3 +- .../format-diagnostics-for-ai.test.ts | 3 +- .../format-diagnostics-for-ai.ts | 4 +- .../apply-auto-layout-widget.test.tsx | 3 +- .../apply-auto-layout-widget.tsx | 3 +- .../interactive-tools/registry.ts | 3 +- .../ai-assistant-panel/tool-summaries.test.ts | 3 +- .../Editor/panels/ai-assistant-panel/types.ts | 3 +- .../panels/create-storybook-ai-transport.ts | 3 +- .../SDCPN/hooks/use-apply-node-changes.ts | 2 +- package.json | 228 ++--- 45 files changed, 2527 insertions(+), 2519 deletions(-) diff --git a/.claude/skills/fractal-file-structuring/SKILL.md b/.claude/skills/fractal-file-structuring/SKILL.md index 8ce1a160191..102c8771a63 100644 --- a/.claude/skills/fractal-file-structuring/SKILL.md +++ b/.claude/skills/fractal-file-structuring/SKILL.md @@ -96,7 +96,6 @@ import { SimulateView } from "../editor-view/panels/simulate-view"; // Prefer (1): import from a public mini-library (if it is conceptually part of editor-view) import { EditorView } from "../editor-view"; - // Prefer (2): move shared code to a shared folder (if it is NOT conceptually part of editor-view) import { Button } from "../shared/button"; ``` diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 85c2e955495..c39572abd18 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -19,11 +19,11 @@ In production, the function in the `api` folder is automatically deployed as a V ## Environment variables -| Name | Required | Used by | Notes | -| ------------------- | ------------------ | --------------- | ---------------------------------------------------- | -| `OPENAI_API_KEY` | for chat to work | `api/chat.ts` | OpenAI key the function uses to call `streamText`. | -| `PETRINAUT_AI_MODEL`| no | `api/chat.ts` | Overrides the default OpenAI model id. | -| `SENTRY_DSN` | no | `vite.config.ts`| Wired into the bundle via `__SENTRY_DSN__` at build time. | +| Name | Required | Used by | Notes | +| -------------------- | ---------------- | ---------------- | --------------------------------------------------------- | +| `OPENAI_API_KEY` | for chat to work | `api/chat.ts` | OpenAI key the function uses to call `streamText`. | +| `PETRINAUT_AI_MODEL` | no | `api/chat.ts` | Overrides the default OpenAI model id. | +| `SENTRY_DSN` | no | `vite.config.ts` | Wired into the bundle via `__SENTRY_DSN__` at build time. | Local values live in `.env.local`; Vite's `loadEnv` (see [`vite.config.ts`](vite.config.ts)) copies them into `process.env` for both the dev server and the chat function. In production, set these in the Vercel project settings. diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts index 018c5e520d0..1b1d0fbfb3e 100644 --- a/apps/petrinaut-website/api/chat.ts +++ b/apps/petrinaut-website/api/chat.ts @@ -7,9 +7,10 @@ import { type ToolSet, type UIMessage, } from "ai"; -import { petrinautAiTools, petrinautAiPrompt } from "@hashintel/petrinaut-core"; import { z } from "zod"; +import { petrinautAiTools, petrinautAiPrompt } from "@hashintel/petrinaut-core"; + declare const process: { env: Record; }; diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 438a4548477..d4452c45b5d 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -3,45 +3,45 @@ import { useEffect, useMemo, useState } from "react"; import { createJsonDocHandle } from "@hashintel/petrinaut-core"; import { - DefaultChatTransport, - Petrinaut, - type PetrinautAiChatTransport, - type PetrinautAiMessage, + DefaultChatTransport, + Petrinaut, + type PetrinautAiChatTransport, + type PetrinautAiMessage, } from "@hashintel/petrinaut/ui"; import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; import { useLocalStorageAiMessages } from "./app/use-local-storage-ai-messages"; import { - type SDCPNInLocalStorage, - useLocalStorageSDCPNs, + type SDCPNInLocalStorage, + useLocalStorageSDCPNs, } from "./app/use-local-storage-sdcpns"; import type { - MinimalNetMetadata, - PetrinautDocHandle, - SDCPN, + MinimalNetMetadata, + PetrinautDocHandle, + SDCPN, } from "@hashintel/petrinaut-core"; const isEmptySDCPN = (sdcpn: SDCPN) => - sdcpn.places.length === 0 && - sdcpn.transitions.length === 0 && - sdcpn.types.length === 0 && - sdcpn.parameters.length === 0 && - sdcpn.differentialEquations.length === 0; + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], }; const createDefaultStoredSDCPN = (): SDCPNInLocalStorage => ({ - id: "net-1", - title: "New Process", - sdcpn: emptySDCPN, - lastUpdated: new Date(0).toISOString(), + id: "net-1", + title: "New Process", + sdcpn: emptySDCPN, + lastUpdated: new Date(0).toISOString(), }); /** @@ -49,48 +49,48 @@ const createDefaultStoredSDCPN = (): SDCPNInLocalStorage => ({ * id and last-updated timestamp in sync. */ const createLocalStorageNetRecord = (params: { - petriNetDefinition: SDCPN; - title: string; + petriNetDefinition: SDCPN; + title: string; }): SDCPNInLocalStorage => { - const now = new Date(); - - return { - id: `net-${now.getTime()}`, - title: params.title, - sdcpn: params.petriNetDefinition, - lastUpdated: now.toISOString(), - }; + const now = new Date(); + + return { + id: `net-${now.getTime()}`, + title: params.title, + sdcpn: params.petriNetDefinition, + lastUpdated: now.toISOString(), + }; }; const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => - createJsonDocHandle({ id: net.id, initial: net.sdcpn }); + createJsonDocHandle({ id: net.id, initial: net.sdcpn }); const petrinautAiChatTransport: PetrinautAiChatTransport = - new DefaultChatTransport({ - api: "/api/chat", - }); + new DefaultChatTransport({ + api: "/api/chat", + }); const getStoredSDCPNsForDisplay = ( - storedSDCPNs: Record, + storedSDCPNs: Record, ): Record => { - if (Object.values(storedSDCPNs).length > 0) { - return storedSDCPNs; - } + if (Object.values(storedSDCPNs).length > 0) { + return storedSDCPNs; + } - const defaultStoredSDCPN = createDefaultStoredSDCPN(); - return { [defaultStoredSDCPN.id]: defaultStoredSDCPN }; + const defaultStoredSDCPN = createDefaultStoredSDCPN(); + return { [defaultStoredSDCPN.id]: defaultStoredSDCPN }; }; type ActiveHandle = { - handle: PetrinautDocHandle; - netId: string; - fallbackNet: SDCPNInLocalStorage; + handle: PetrinautDocHandle; + netId: string; + fallbackNet: SDCPNInLocalStorage; }; const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ - handle: createHandle(net), - netId: net.id, - fallbackNet: net, + handle: createHandle(net), + netId: net.id, + fallbackNet: net, }); /** @@ -102,195 +102,195 @@ const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ * for background nets. */ export const DevApp = () => { - const sentryFeedbackAction = useSentryFeedbackAction(); - const { aiMessagesByNetId, setAiMessagesByNetId } = - useLocalStorageAiMessages(); - const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); - const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); - - // Pick the most recently modified net - const mostRecentlyModifiedNet = - Object.values(storedSDCPNsForDisplay).sort( - (a, b) => - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), - )[0] ?? null; - - // The net currently selected in the UI. - const [currentNetId, setCurrentNetId] = useState( - () => mostRecentlyModifiedNet?.id ?? null, - ); - - // Metadata and persisted SDCPN snapshot for the selected net. - const currentNet = currentNetId - ? (storedSDCPNsForDisplay[currentNetId] ?? null) - : null; - - // Live editable document handle for the selected net only. - const [activeHandle, setActiveHandle] = useState(() => - mostRecentlyModifiedNet - ? createActiveHandle(mostRecentlyModifiedNet) - : null, - ); - - useEffect(() => { - if (!activeHandle) { - return; - } - - const { fallbackNet, handle, netId } = activeHandle; - - return handle.subscribe((event) => { - const lastUpdated = new Date().toISOString(); - - setStoredSDCPNs((prev) => { - const stored = prev[netId] ?? fallbackNet; - - return produce(prev, (draft) => { - draft[netId] = { - ...stored, - sdcpn: event.next, - lastUpdated, - }; - }); - }); - }); - }, [activeHandle, setStoredSDCPNs]); - - const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) - .map((net) => ({ - netId: net.id, - title: net.title, - lastUpdated: net.lastUpdated, - })) - .sort( - (a, b) => - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), - ); - - const createNewNet = (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const newNet = createLocalStorageNetRecord(params); - const previousNet = - currentNetId && currentNetId !== newNet.id ? currentNet : null; - const previousNetIdToRemove = previousNet !== null ? currentNetId : null; - - setStoredSDCPNs((prev) => { - const next = { ...prev, [newNet.id]: newNet }; - - // Remove the previous net if it was empty and unmodified - if ( - previousNetIdToRemove && - previousNet && - isEmptySDCPN(prev[previousNetIdToRemove]?.sdcpn ?? previousNet.sdcpn) - ) { - delete next[previousNetIdToRemove]; - } - - return next; - }); - setActiveHandle(createActiveHandle(newNet)); - setCurrentNetId(newNet.id); - }; - - const loadPetriNet = (petriNetId: string) => { - const netToLoad = storedSDCPNsForDisplay[petriNetId]; - if (!netToLoad) { - return; - } - - // Remove the current net if it was empty and unmodified - if (currentNetId && currentNetId !== petriNetId) { - const previousNetIdToRemove = - currentNet && isEmptySDCPN(currentNet.sdcpn) ? currentNetId : null; - - setStoredSDCPNs((prev) => { - const prevNet = previousNetIdToRemove - ? prev[previousNetIdToRemove] - : null; - - if (previousNetIdToRemove && prevNet && isEmptySDCPN(prevNet.sdcpn)) { - const next = { ...prev }; - delete next[previousNetIdToRemove]; - return next; - } - return prev; - }); - } - setActiveHandle(createActiveHandle(netToLoad)); - setCurrentNetId(petriNetId); - }; - - const setTitle = (title: string) => { - if (!currentNetId || !currentNet) { - return; - } - - const lastUpdated = new Date().toISOString(); - - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - draft[currentNetId] = { - ...(draft[currentNetId] ?? currentNet), - title, - lastUpdated, - }; - }), - ); - }; - - const aiAssistant = useMemo( - () => ({ - transport: petrinautAiChatTransport, - messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, - onMessages: (messages: PetrinautAiMessage[]) => { - if (!currentNetId) { - return; - } - - setAiMessagesByNetId((prev) => ({ - ...prev, - [currentNetId]: messages, - })); - }, - onClearMessages: () => { - if (!currentNetId) { - return; - } - - setAiMessagesByNetId((prev) => { - const next = { ...prev }; - delete next[currentNetId]; - return next; - }); - }, - }), - [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], - ); - - if (!currentNet) { - return null; - } - - if (!activeHandle || activeHandle.netId !== currentNet.id) { - return null; - } - - return ( -
- -
- ); + const sentryFeedbackAction = useSentryFeedbackAction(); + const { aiMessagesByNetId, setAiMessagesByNetId } = + useLocalStorageAiMessages(); + const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); + const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); + + // Pick the most recently modified net + const mostRecentlyModifiedNet = + Object.values(storedSDCPNsForDisplay).sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + )[0] ?? null; + + // The net currently selected in the UI. + const [currentNetId, setCurrentNetId] = useState( + () => mostRecentlyModifiedNet?.id ?? null, + ); + + // Metadata and persisted SDCPN snapshot for the selected net. + const currentNet = currentNetId + ? (storedSDCPNsForDisplay[currentNetId] ?? null) + : null; + + // Live editable document handle for the selected net only. + const [activeHandle, setActiveHandle] = useState(() => + mostRecentlyModifiedNet + ? createActiveHandle(mostRecentlyModifiedNet) + : null, + ); + + useEffect(() => { + if (!activeHandle) { + return; + } + + const { fallbackNet, handle, netId } = activeHandle; + + return handle.subscribe((event) => { + const lastUpdated = new Date().toISOString(); + + setStoredSDCPNs((prev) => { + const stored = prev[netId] ?? fallbackNet; + + return produce(prev, (draft) => { + draft[netId] = { + ...stored, + sdcpn: event.next, + lastUpdated, + }; + }); + }); + }); + }, [activeHandle, setStoredSDCPNs]); + + const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) + .map((net) => ({ + netId: net.id, + title: net.title, + lastUpdated: net.lastUpdated, + })) + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const newNet = createLocalStorageNetRecord(params); + const previousNet = + currentNetId && currentNetId !== newNet.id ? currentNet : null; + const previousNetIdToRemove = previousNet !== null ? currentNetId : null; + + setStoredSDCPNs((prev) => { + const next = { ...prev, [newNet.id]: newNet }; + + // Remove the previous net if it was empty and unmodified + if ( + previousNetIdToRemove && + previousNet && + isEmptySDCPN(prev[previousNetIdToRemove]?.sdcpn ?? previousNet.sdcpn) + ) { + delete next[previousNetIdToRemove]; + } + + return next; + }); + setActiveHandle(createActiveHandle(newNet)); + setCurrentNetId(newNet.id); + }; + + const loadPetriNet = (petriNetId: string) => { + const netToLoad = storedSDCPNsForDisplay[petriNetId]; + if (!netToLoad) { + return; + } + + // Remove the current net if it was empty and unmodified + if (currentNetId && currentNetId !== petriNetId) { + const previousNetIdToRemove = + currentNet && isEmptySDCPN(currentNet.sdcpn) ? currentNetId : null; + + setStoredSDCPNs((prev) => { + const prevNet = previousNetIdToRemove + ? prev[previousNetIdToRemove] + : null; + + if (previousNetIdToRemove && prevNet && isEmptySDCPN(prevNet.sdcpn)) { + const next = { ...prev }; + delete next[previousNetIdToRemove]; + return next; + } + return prev; + }); + } + setActiveHandle(createActiveHandle(netToLoad)); + setCurrentNetId(petriNetId); + }; + + const setTitle = (title: string) => { + if (!currentNetId || !currentNet) { + return; + } + + const lastUpdated = new Date().toISOString(); + + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + draft[currentNetId] = { + ...(draft[currentNetId] ?? currentNet), + title, + lastUpdated, + }; + }), + ); + }; + + const aiAssistant = useMemo( + () => ({ + transport: petrinautAiChatTransport, + messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, + onMessages: (messages: PetrinautAiMessage[]) => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => ({ + ...prev, + [currentNetId]: messages, + })); + }, + onClearMessages: () => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => { + const next = { ...prev }; + delete next[currentNetId]; + return next; + }); + }, + }), + [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], + ); + + if (!currentNet) { + return null; + } + + if (!activeHandle || activeHandle.netId !== currentNet.id) { + return null; + } + + return ( +
+ +
+ ); }; diff --git a/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts b/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts index ac353b30ec4..56c1c97157c 100644 --- a/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts +++ b/apps/petrinaut-website/src/main/app/use-local-storage-ai-messages.ts @@ -1,6 +1,7 @@ -import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; import { useLocalStorage } from "@mantine/hooks"; +import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; + const rootLocalStorageKey = "petrinaut-ai-messages"; type AiMessagesByNetId = Record; diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index c72e5cbd281..147814dc9a0 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -1,4 +1,3 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import { fileURLToPath } from "node:url"; import babel from "@rolldown/plugin-babel"; @@ -6,6 +5,8 @@ import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { createServerAdapter } from "@whatwg-node/server"; import { defineConfig, loadEnv, type Plugin } from "vite"; +import type { IncomingMessage, ServerResponse } from "node:http"; + const appRoot = fileURLToPath(new URL(".", import.meta.url)); const loadServerEnv = (mode: string) => { diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index 934ea4c8a03..b2e3cefddec 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -6,480 +6,480 @@ import { createPetrinaut } from "./instance"; import type { SDCPN } from "./types/sdcpn"; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], }; const cloneSDCPN = (sdcpn: SDCPN): SDCPN => JSON.parse(JSON.stringify(sdcpn)); const createInstance = (initial: SDCPN = emptySDCPN) => - createPetrinaut({ - document: createJsonDocHandle({ initial: cloneSDCPN(initial) }), - }); + createPetrinaut({ + document: createJsonDocHandle({ initial: cloneSDCPN(initial) }), + }); const callActionWithUnknownInput = ( - action: (input: Input) => void, - input: unknown, + action: (input: Input) => void, + input: unknown, ): void => { - action(input as Input); + action(input as Input); }; describe("Petrinaut core actions", () => { - test("adds and updates places", () => { - const instance = createInstance(); - - instance.mutations.addPlace({ - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - instance.mutations.updatePlace({ - placeId: "place-1", - update: { - name: "UpdatedQueue", - }, - }); - instance.mutations.updatePlacePosition({ - placeId: "place-1", - position: { x: 12, y: 24 }, - }); - - expect(instance.definition.get().places).toEqual([ - { - id: "place-1", - name: "UpdatedQueue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 12, - y: 24, - }, - ]); - }); - - test("removing a place also removes connected arcs", () => { - const instance = createInstance({ - ...emptySDCPN, - places: [ - { - id: "place-1", - name: "Input", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "place-2", - name: "Output", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - }); - - instance.mutations.removePlace({ placeId: "place-1" }); - - const definition = instance.definition.get(); - expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); - expect(definition.transitions[0]!.inputArcs).toEqual([]); - expect(definition.transitions[0]!.outputArcs).toEqual([ - { placeId: "place-2", weight: 1 }, - ]); - }); - - test("updates arc endpoints granularly", () => { - const instance = createInstance({ - ...emptySDCPN, - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - }); - - instance.mutations.updateArcPlace({ - transitionId: "transition-1", - arcDirection: "input", - oldPlaceId: "place-1", - newPlaceId: "place-3", - }); - instance.mutations.updateArcPlace({ - transitionId: "transition-1", - arcDirection: "output", - oldPlaceId: "place-2", - newPlaceId: "place-4", - }); - - expect(instance.definition.get().transitions[0]).toMatchObject({ - inputArcs: [{ placeId: "place-3", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-4", weight: 1 }], - }); - }); - - test("adds, updates, removes, and moves type elements granularly", () => { - const instance = createInstance({ - ...emptySDCPN, - types: [ - { - id: "type-1", - name: "Particle", - iconSlug: "circle", - displayColor: "#34a0fa", - elements: [ - { elementId: "element-1", name: "Mass", type: "real" }, - { elementId: "element-2", name: "Velocity", type: "real" }, - ], - }, - ], - }); - - instance.mutations.addTypeElement({ - typeId: "type-1", - element: { elementId: "element-3", name: "Charge", type: "integer" }, - }); - instance.mutations.updateTypeElement({ - typeId: "type-1", - elementId: "element-1", - update: { name: "MassKg" }, - }); - instance.mutations.moveTypeElement({ - typeId: "type-1", - elementId: "element-3", - toIndex: 1, - }); - instance.mutations.removeTypeElement({ - typeId: "type-1", - elementId: "element-2", - }); - - expect(instance.definition.get().types[0]!.elements).toEqual([ - { elementId: "element-1", name: "MassKg", type: "real" }, - { elementId: "element-3", name: "Charge", type: "integer" }, - ]); - }); - - test("deleteItemsByIds removes referenced types and equations", () => { - const instance = createInstance({ - ...emptySDCPN, - places: [ - { - id: "place-1", - name: "Dynamic", - colorId: "type-1", - dynamicsEnabled: true, - differentialEquationId: "equation-1", - x: 0, - y: 0, - }, - ], - types: [ - { - id: "type-1", - name: "Particle", - iconSlug: "circle", - displayColor: "#34a0fa", - elements: [], - }, - ], - differentialEquations: [ - { - id: "equation-1", - name: "Motion", - colorId: "type-1", - code: "export default Dynamics(() => []);", - }, - ], - }); - - instance.mutations.deleteItemsByIds({ - items: [ - { type: "type", id: "type-1" }, - { type: "differentialEquation", id: "equation-1" }, - ], - }); - - const definition = instance.definition.get(); - expect(definition.types).toEqual([]); - expect(definition.differentialEquations).toEqual([]); - expect(definition.places[0]!.colorId).toBeNull(); - expect(definition.places[0]!.differentialEquationId).toBeNull(); - }); - - test("does not mutate readonly instances", () => { - const instance = createPetrinaut({ - document: createJsonDocHandle({ initial: cloneSDCPN(emptySDCPN) }), - readonly: true, - }); - - instance.mutations.addPlace({ - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - - expect(instance.definition.get().places).toEqual([]); - }); - - test("validates add action inputs before mutating", () => { - const instance = createInstance(); - - expect(() => - instance.mutations.addPlace({ - id: "", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }), - ).toThrow(); - - expect(instance.definition.get().places).toEqual([]); - }); - - test("validates callback-updated entities", () => { - const instance = createInstance(); - - instance.mutations.addPlace({ - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - - expect(() => - instance.mutations.updatePlace({ - placeId: "place-1", - update: { - name: "", - }, - }), - ).toThrow(); - }); - - test("rejects over-wide update action payloads", () => { - const instance = createInstance(); - - expect(() => - callActionWithUnknownInput(instance.mutations.updatePlace, { - placeId: "place-1", - update: { id: "place-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updatePlace, { - placeId: "place-1", - update: { x: 10 }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateTransition, { - transitionId: "transition-1", - update: { inputArcs: [] }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateTransition, { - transitionId: "transition-1", - update: { y: 10 }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateType, { - typeId: "type-1", - update: { elements: [] }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateTypeElement, { - typeId: "type-1", - elementId: "element-1", - update: { elementId: "element-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput( - instance.mutations.updateDifferentialEquation, - { - equationId: "equation-1", - update: { id: "equation-2" }, - }, - ), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateParameter, { - parameterId: "parameter-1", - update: { id: "parameter-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateScenario, { - scenarioId: "scenario-1", - update: { id: "scenario-2" }, - }), - ).toThrow(); - expect(() => - callActionWithUnknownInput(instance.mutations.updateMetric, { - metricId: "metric-1", - update: { id: "metric-2" }, - }), - ).toThrow(); - }); - - test("validates granular arc and type element action inputs", () => { - const instance = createInstance({ - ...emptySDCPN, - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - types: [ - { - id: "type-1", - name: "Particle", - iconSlug: "circle", - displayColor: "#34a0fa", - elements: [{ elementId: "element-1", name: "Mass", type: "real" }], - }, - ], - }); - - expect(() => - instance.mutations.updateArcPlace({ - transitionId: "transition-1", - arcDirection: "input", - oldPlaceId: "place-1", - newPlaceId: "", - }), - ).toThrow(); - expect(() => - instance.mutations.addTypeElement({ - typeId: "type-1", - element: { elementId: "element-2", name: "", type: "real" }, - }), - ).toThrow(); - expect(() => - instance.mutations.moveTypeElement({ - typeId: "type-1", - elementId: "element-1", - toIndex: -1, - }), - ).toThrow(); - - expect(instance.definition.get().transitions[0]!.inputArcs).toEqual([ - { placeId: "place-1", weight: 1, type: "standard" }, - ]); - expect(instance.definition.get().types[0]!.elements).toEqual([ - { elementId: "element-1", name: "Mass", type: "real" }, - ]); - }); - - test("reuses existing name validation rules for action inputs", () => { - const instance = createInstance(); - - expect(() => - instance.mutations.addPlace({ - id: "place-1", - name: "invalid place name", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }), - ).toThrow(); - - expect(() => - instance.mutations.addTransition({ - id: "transition-1", - name: "Display Name", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }), - ).not.toThrow(); - }); - - test("preserves scenario-specific validation in action inputs", () => { - const instance = createInstance(); - - expect(() => - instance.mutations.addScenario({ - id: "scenario-1", - name: "Scenario", - scenarioParameters: [ - { type: "real", identifier: "launch_rate", default: 1 }, - { type: "integer", identifier: "launch_rate", default: 2 }, - ], - parameterOverrides: {}, - initialState: { type: "per_place", content: {} }, - }), - ).toThrow(); - - expect(() => - instance.mutations.addScenario({ - id: "scenario-1", - name: "Scenario", - scenarioParameters: [ - { type: "real", identifier: "LaunchRate", default: 1 }, - ], - parameterOverrides: {}, - initialState: { type: "per_place", content: {} }, - }), - ).toThrow(); - }); + test("adds and updates places", () => { + const instance = createInstance(); + + instance.mutations.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + instance.mutations.updatePlace({ + placeId: "place-1", + update: { + name: "UpdatedQueue", + }, + }); + instance.mutations.updatePlacePosition({ + placeId: "place-1", + position: { x: 12, y: 24 }, + }); + + expect(instance.definition.get().places).toEqual([ + { + id: "place-1", + name: "UpdatedQueue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 12, + y: 24, + }, + ]); + }); + + test("removing a place also removes connected arcs", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.mutations.removePlace({ placeId: "place-1" }); + + const definition = instance.definition.get(); + expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); + expect(definition.transitions[0]!.inputArcs).toEqual([]); + expect(definition.transitions[0]!.outputArcs).toEqual([ + { placeId: "place-2", weight: 1 }, + ]); + }); + + test("updates arc endpoints granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.mutations.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "place-3", + }); + instance.mutations.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "output", + oldPlaceId: "place-2", + newPlaceId: "place-4", + }); + + expect(instance.definition.get().transitions[0]).toMatchObject({ + inputArcs: [{ placeId: "place-3", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-4", weight: 1 }], + }); + }); + + test("adds, updates, removes, and moves type elements granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [ + { elementId: "element-1", name: "Mass", type: "real" }, + { elementId: "element-2", name: "Velocity", type: "real" }, + ], + }, + ], + }); + + instance.mutations.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-3", name: "Charge", type: "integer" }, + }); + instance.mutations.updateTypeElement({ + typeId: "type-1", + elementId: "element-1", + update: { name: "MassKg" }, + }); + instance.mutations.moveTypeElement({ + typeId: "type-1", + elementId: "element-3", + toIndex: 1, + }); + instance.mutations.removeTypeElement({ + typeId: "type-1", + elementId: "element-2", + }); + + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "MassKg", type: "real" }, + { elementId: "element-3", name: "Charge", type: "integer" }, + ]); + }); + + test("deleteItemsByIds removes referenced types and equations", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Dynamic", + colorId: "type-1", + dynamicsEnabled: true, + differentialEquationId: "equation-1", + x: 0, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [], + }, + ], + differentialEquations: [ + { + id: "equation-1", + name: "Motion", + colorId: "type-1", + code: "export default Dynamics(() => []);", + }, + ], + }); + + instance.mutations.deleteItemsByIds({ + items: [ + { type: "type", id: "type-1" }, + { type: "differentialEquation", id: "equation-1" }, + ], + }); + + const definition = instance.definition.get(); + expect(definition.types).toEqual([]); + expect(definition.differentialEquations).toEqual([]); + expect(definition.places[0]!.colorId).toBeNull(); + expect(definition.places[0]!.differentialEquationId).toBeNull(); + }); + + test("does not mutate readonly instances", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ initial: cloneSDCPN(emptySDCPN) }), + readonly: true, + }); + + instance.mutations.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates add action inputs before mutating", () => { + const instance = createInstance(); + + expect(() => + instance.mutations.addPlace({ + id: "", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates callback-updated entities", () => { + const instance = createInstance(); + + instance.mutations.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(() => + instance.mutations.updatePlace({ + placeId: "place-1", + update: { + name: "", + }, + }), + ).toThrow(); + }); + + test("rejects over-wide update action payloads", () => { + const instance = createInstance(); + + expect(() => + callActionWithUnknownInput(instance.mutations.updatePlace, { + placeId: "place-1", + update: { id: "place-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updatePlace, { + placeId: "place-1", + update: { x: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateTransition, { + transitionId: "transition-1", + update: { inputArcs: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateTransition, { + transitionId: "transition-1", + update: { y: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateType, { + typeId: "type-1", + update: { elements: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateTypeElement, { + typeId: "type-1", + elementId: "element-1", + update: { elementId: "element-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput( + instance.mutations.updateDifferentialEquation, + { + equationId: "equation-1", + update: { id: "equation-2" }, + }, + ), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateParameter, { + parameterId: "parameter-1", + update: { id: "parameter-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateScenario, { + scenarioId: "scenario-1", + update: { id: "scenario-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.mutations.updateMetric, { + metricId: "metric-1", + update: { id: "metric-2" }, + }), + ).toThrow(); + }); + + test("validates granular arc and type element action inputs", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [{ elementId: "element-1", name: "Mass", type: "real" }], + }, + ], + }); + + expect(() => + instance.mutations.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "", + }), + ).toThrow(); + expect(() => + instance.mutations.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-2", name: "", type: "real" }, + }), + ).toThrow(); + expect(() => + instance.mutations.moveTypeElement({ + typeId: "type-1", + elementId: "element-1", + toIndex: -1, + }), + ).toThrow(); + + expect(instance.definition.get().transitions[0]!.inputArcs).toEqual([ + { placeId: "place-1", weight: 1, type: "standard" }, + ]); + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "Mass", type: "real" }, + ]); + }); + + test("reuses existing name validation rules for action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.mutations.addPlace({ + id: "place-1", + name: "invalid place name", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(() => + instance.mutations.addTransition({ + id: "transition-1", + name: "Display Name", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }), + ).not.toThrow(); + }); + + test("preserves scenario-specific validation in action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.mutations.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "launch_rate", default: 1 }, + { type: "integer", identifier: "launch_rate", default: 2 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + + expect(() => + instance.mutations.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "LaunchRate", default: 1 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index e6648a3cac7..d4428e39e9b 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -1,80 +1,80 @@ import { z } from "zod"; import { - mutationActionInputSchemas, - type MutationActionName, + mutationActionInputSchemas, + type MutationActionName, } from "./action-schemas"; import { - aiCommandActionInputSchemas, - type AiCommandActionName, + aiCommandActionInputSchemas, + type AiCommandActionName, } from "./command-schemas"; +import { probabilisticSatellitesSDCPN } from "./examples"; import { typedKeys } from "./lib/typed-entries"; import type { Petrinaut } from "./instance"; -import { probabilisticSatellitesSDCPN } from "./examples"; export { - colorSchema, - differentialEquationSchema, - metricSchema, - parameterSchema, - mutationActionInputSchemas, - placeSchema, - scenarioSchema, - transitionSchema, + colorSchema, + differentialEquationSchema, + metricSchema, + parameterSchema, + mutationActionInputSchemas, + placeSchema, + scenarioSchema, + transitionSchema, } from "./action-schemas"; export type { - MutationActionInput as PetrinautAiMutationToolInput, - MutationActionName as PetrinautAiMutationToolName, + MutationActionInput as PetrinautAiMutationToolInput, + MutationActionName as PetrinautAiMutationToolName, } from "./action-schemas"; export { aiCommandActionInputSchemas } from "./command-schemas"; export type { - AiCommandActionInput as PetrinautAiCommandToolInput, - AiCommandActionName as PetrinautAiCommandToolName, + AiCommandActionInput as PetrinautAiCommandToolInput, + AiCommandActionName as PetrinautAiCommandToolName, } from "./command-schemas"; export type PetrinautAiTool = { - description: string; - inputSchema: InputSchema; + description: string; + inputSchema: InputSchema; }; export type PetrinautAiTools = { - [Name in keyof typeof petrinautAiToolInputSchemas]: PetrinautAiTool< - (typeof petrinautAiToolInputSchemas)[Name] - >; + [Name in keyof typeof petrinautAiToolInputSchemas]: PetrinautAiTool< + (typeof petrinautAiToolInputSchemas)[Name] + >; }; const getSchemaDescription = (schema: z.ZodType): string => { - if (!schema.description) { - throw new Error("Petrinaut AI tool schemas must have descriptions"); - } - return schema.description; + if (!schema.description) { + throw new Error("Petrinaut AI tool schemas must have descriptions"); + } + return schema.description; }; function createToolBundle>( - schemas: InputSchemas, + schemas: InputSchemas, ): { - [Name in keyof InputSchemas]: PetrinautAiTool; + [Name in keyof InputSchemas]: PetrinautAiTool; } { - const tools = {} as { - [Name in keyof InputSchemas]: PetrinautAiTool; - }; - - const setTool = ( - name: Name, - inputSchema: InputSchemas[Name], - ) => { - tools[name] = { - description: getSchemaDescription(inputSchema), - inputSchema, - }; - }; - - for (const name of typedKeys(schemas)) { - setTool(name, schemas[name]); - } - - return tools; + const tools = {} as { + [Name in keyof InputSchemas]: PetrinautAiTool; + }; + + const setTool = ( + name: Name, + inputSchema: InputSchemas[Name], + ) => { + tools[name] = { + description: getSchemaDescription(inputSchema), + inputSchema, + }; + }; + + for (const name of typedKeys(schemas)) { + setTool(name, schemas[name]); + } + + return tools; } export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; @@ -82,77 +82,73 @@ export const getNetCompilationErrorsToolName = "getNetCompilationErrors"; export const setNetTitleToolName = "setNetTitle"; const getLatestNetDefinitionToolInputSchema = z - .strictObject({}) - .describe( - "Get the current Petrinaut net state. Returns `{ title, definition }` where `title` is the user-visible net title and `definition` is the complete SDCPN net definition.", - ); + .strictObject({}) + .describe( + "Get the current Petrinaut net state. Returns `{ title, definition }` where `title` is the user-visible net title and `definition` is the complete SDCPN net definition.", + ); const getNetCompilationErrorsToolInputSchema = z - .strictObject({}) - .describe( - "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", - ); + .strictObject({}) + .describe( + "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", + ); export const setNetTitleToolInputSchema = z - .strictObject({ - title: z - .string() - .min(1) - .max(120) - .meta({ - description: - "Short human-readable title for the net (sentence case, no quotes, ideally under ~60 characters).", - }), - }) - .describe( - "Set the human-readable title shown for the current Petrinaut net.", - ); + .strictObject({ + title: z.string().min(1).max(120).meta({ + description: + "Short human-readable title for the net (sentence case, no quotes, ideally under ~60 characters).", + }), + }) + .describe( + "Set the human-readable title shown for the current Petrinaut net.", + ); export const petrinautAiToolInputSchemas = { - ...mutationActionInputSchemas, - ...aiCommandActionInputSchemas, - [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, - [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, - [setNetTitleToolName]: setNetTitleToolInputSchema, + ...mutationActionInputSchemas, + ...aiCommandActionInputSchemas, + [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, + [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, + [setNetTitleToolName]: setNetTitleToolInputSchema, }; export const petrinautAiMutationTools = createToolBundle( - mutationActionInputSchemas, + mutationActionInputSchemas, ); export const petrinautAiCommandTools = createToolBundle( - aiCommandActionInputSchemas, + aiCommandActionInputSchemas, ); export const petrinautAiTools = { - ...petrinautAiMutationTools, - ...petrinautAiCommandTools, - [getLatestNetDefinitionToolName]: { - description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), - inputSchema: getLatestNetDefinitionToolInputSchema, - }, - [getNetCompilationErrorsToolName]: { - description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), - inputSchema: getNetCompilationErrorsToolInputSchema, - }, - [setNetTitleToolName]: { - description: getSchemaDescription(setNetTitleToolInputSchema), - inputSchema: setNetTitleToolInputSchema, - }, + ...petrinautAiMutationTools, + ...petrinautAiCommandTools, + [getLatestNetDefinitionToolName]: { + description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), + inputSchema: getLatestNetDefinitionToolInputSchema, + }, + [getNetCompilationErrorsToolName]: { + description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), + inputSchema: getNetCompilationErrorsToolInputSchema, + }, + [setNetTitleToolName]: { + description: getSchemaDescription(setNetTitleToolInputSchema), + inputSchema: setNetTitleToolInputSchema, + }, } satisfies PetrinautAiTools; export type PetrinautAiToolName = keyof typeof petrinautAiTools; export type PetrinautAiToolInput = z.input< - (typeof petrinautAiTools)[Name]["inputSchema"] + (typeof petrinautAiTools)[Name]["inputSchema"] >; /** * @deprecated Use {@link PetrinautAiWritableCallbacks}. */ export type PetrinautMutationAiToolCallbacks = Pick< - Petrinaut["mutations"], - MutationActionName + Petrinaut["mutations"], + MutationActionName >; /** @@ -162,30 +158,30 @@ export type PetrinautMutationAiToolCallbacks = Pick< * separately and are not part of this bundle. */ export type PetrinautAiWritableCallbacks = Pick< - Petrinaut["mutations"], - MutationActionName + Petrinaut["mutations"], + MutationActionName > & - Pick; + Pick; /** * @deprecated Use {@link createPetrinautAiWritableCallbacks}. */ export function createPetrinautMutationAiToolCallbacks( - instance: Petrinaut, + instance: Petrinaut, ): PetrinautMutationAiToolCallbacks { - return instance.mutations; + return instance.mutations; } export function createPetrinautAiWritableCallbacks( - instance: Petrinaut, + instance: Petrinaut, ): PetrinautAiWritableCallbacks { - const writable: PetrinautAiWritableCallbacks = { - ...instance.mutations, - } as PetrinautAiWritableCallbacks; - for (const name of typedKeys(aiCommandActionInputSchemas)) { - (writable as Record)[name] = instance.commands[name]; - } - return writable; + const writable: PetrinautAiWritableCallbacks = { + ...instance.mutations, + } as PetrinautAiWritableCallbacks; + for (const name of typedKeys(aiCommandActionInputSchemas)) { + (writable as Record)[name] = instance.commands[name]; + } + return writable; } export const petrinautAiPrompt = `You are an expert assistant for building Stochastic Dynamic Coloured Petri Nets (SDCPNs) in Petrinaut. diff --git a/libs/@hashintel/petrinaut-core/src/commands.test.ts b/libs/@hashintel/petrinaut-core/src/commands.test.ts index eadc4f2ac7d..ecbff2cb9be 100644 --- a/libs/@hashintel/petrinaut-core/src/commands.test.ts +++ b/libs/@hashintel/petrinaut-core/src/commands.test.ts @@ -1,149 +1,150 @@ import { describe, expect, test } from "vitest"; import { - CLIPBOARD_FORMAT_VERSION, - type ClipboardPayload, + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, } from "./clipboard/types"; import { createJsonDocHandle } from "./handle"; import { createPetrinaut } from "./instance"; + import type { SDCPN } from "./types/sdcpn"; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], }; const createInstance = (initial: SDCPN = emptySDCPN) => - createPetrinaut({ - document: createJsonDocHandle({ - initial: JSON.parse(JSON.stringify(initial)), - }), - }); + createPetrinaut({ + document: createJsonDocHandle({ + initial: JSON.parse(JSON.stringify(initial)), + }), + }); const buildClipboardPayload = ( - data: Partial = {}, + data: Partial = {}, ): ClipboardPayload => ({ - format: "petrinaut-sdcpn", - version: CLIPBOARD_FORMAT_VERSION, - documentId: null, - data: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - ...data, - }, + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + ...data, + }, }); describe("applyClipboardPaste", () => { - test("returns new IDs for pasted places", () => { - const instance = createInstance(); - - const payload = buildClipboardPayload({ - places: [ - { - id: "place-1", - name: "Queue", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - }); - - const { newItemIds } = instance.commands.applyClipboardPaste({ payload }); - const pastedPlace = newItemIds.find((item) => item.type === "place"); - - expect(pastedPlace).toBeDefined(); - expect(instance.definition.get().places).toHaveLength(1); - expect(instance.definition.get().places[0]!.id).toBe(pastedPlace!.id); - }); - - test("throws when the payload fails schema validation", () => { - const instance = createInstance(); - - expect(() => - instance.commands.applyClipboardPaste({ - payload: { - format: "not-petrinaut", - version: CLIPBOARD_FORMAT_VERSION, - documentId: null, - data: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - } as unknown as ClipboardPayload, - }), - ).toThrow(); - - expect(instance.definition.get().places).toEqual([]); - }); + test("returns new IDs for pasted places", () => { + const instance = createInstance(); + + const payload = buildClipboardPayload({ + places: [ + { + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + const { newItemIds } = instance.commands.applyClipboardPaste({ payload }); + const pastedPlace = newItemIds.find((item) => item.type === "place"); + + expect(pastedPlace).toBeDefined(); + expect(instance.definition.get().places).toHaveLength(1); + expect(instance.definition.get().places[0]!.id).toBe(pastedPlace!.id); + }); + + test("throws when the payload fails schema validation", () => { + const instance = createInstance(); + + expect(() => + instance.commands.applyClipboardPaste({ + payload: { + format: "not-petrinaut", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + } as unknown as ClipboardPayload, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); }); describe("applyAutoLayout", () => { - test("no-ops for an empty net", async () => { - const instance = createInstance(); - - const { commitCount } = await instance.commands.applyAutoLayout(); - - expect(commitCount).toBe(0); - }); - - test("repositions places when they have non-zero deltas", async () => { - const instance = createInstance({ - ...emptySDCPN, - places: [ - { - id: "place-1", - name: "Input", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "place-2", - name: "Output", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - transitions: [ - { - id: "transition-1", - name: "Move", - inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "place-2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "export default Lambda(() => true);", - transitionKernelCode: "", - x: 0, - y: 0, - }, - ], - }); - - const { commitCount } = await instance.commands.applyAutoLayout(); - - expect(commitCount).toBeGreaterThan(0); - const places = instance.definition.get().places; - expect(places.map((place) => place.id).sort()).toEqual([ - "place-1", - "place-2", - ]); - expect(places.some((place) => place.x !== 0 || place.y !== 0)).toBe(true); - }); + test("no-ops for an empty net", async () => { + const instance = createInstance(); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBe(0); + }); + + test("repositions places when they have non-zero deltas", async () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBeGreaterThan(0); + const places = instance.definition.get().places; + expect(places.map((place) => place.id).sort()).toEqual([ + "place-1", + "place-2", + ]); + expect(places.some((place) => place.x !== 0 || place.y !== 0)).toBe(true); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/commands.ts b/libs/@hashintel/petrinaut-core/src/commands.ts index 3563148b6b1..3a698612a5f 100644 --- a/libs/@hashintel/petrinaut-core/src/commands.ts +++ b/libs/@hashintel/petrinaut-core/src/commands.ts @@ -5,11 +5,12 @@ import { placeSchema, transitionSchema, } from "./action-schemas"; -import { commandActionInputSchemas } from "./command-schemas"; import { pastePayloadIntoSDCPN } from "./clipboard/paste"; -import type { ClipboardPayload } from "./clipboard/types"; +import { commandActionInputSchemas } from "./command-schemas"; import { calculateGraphLayout } from "./layout/calculate-graph-layout"; import { layoutNodeDimensions } from "./layout/dimensions"; + +import type { ClipboardPayload } from "./clipboard/types"; import type { SDCPN } from "./types/sdcpn"; export type ApplyClipboardPasteResult = { diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 03591c58c4c..7632473945c 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -5,177 +5,177 @@ // --- Document --- export { - createJsonDocHandle, - type CreateJsonDocHandleOptions, - type DocChangeEvent, - type DocHandleState, - type DocumentId, - type HistoryEntry, - type PetrinautDocHandle, - type PetrinautHistory, - type PetrinautPatch, - type ReadableStore, + createJsonDocHandle, + type CreateJsonDocHandleOptions, + type DocChangeEvent, + type DocHandleState, + type DocumentId, + type HistoryEntry, + type PetrinautDocHandle, + type PetrinautHistory, + type PetrinautPatch, + type ReadableStore, } from "./handle"; // --- Instance --- export { createPetrinaut } from "./instance"; export type { - CreatePetrinautConfig, - EventStream, - Petrinaut, - PetrinautCommands, - PetrinautMutations, + CreatePetrinautConfig, + EventStream, + Petrinaut, + PetrinautCommands, + PetrinautMutations, } from "./instance"; export { createPetrinautActions } from "./actions"; export type { MutationHelperFunctions } from "./actions"; export { createPetrinautCommands } from "./commands"; export type { - ApplyAutoLayoutResult, - ApplyClipboardPasteResult, - CommandHelperFunctions, + ApplyAutoLayoutResult, + ApplyClipboardPasteResult, + CommandHelperFunctions, } from "./commands"; export { - aiCommandActionInputSchemas, - commandActionInputSchemas, + aiCommandActionInputSchemas, + commandActionInputSchemas, } from "./command-schemas"; export type { - AiCommandActionInput, - AiCommandActionName, - CommandActionInput, - CommandActionName, + AiCommandActionInput, + AiCommandActionName, + CommandActionInput, + CommandActionName, } from "./command-schemas"; export { mutationActionInputSchemas } from "./action-schemas"; export { - calculateGraphLayout, - layoutNodeDimensions, - type LayoutDimensions, - type NodePosition, + calculateGraphLayout, + layoutNodeDimensions, + type LayoutDimensions, + type NodePosition, } from "./layout"; // --- AI --- export { - colorSchema, - createPetrinautAiWritableCallbacks, - createPetrinautMutationAiToolCallbacks, - differentialEquationSchema, - getLatestNetDefinitionToolName, - getNetCompilationErrorsToolName, - metricSchema, - parameterSchema, - petrinautAiCommandTools, - petrinautAiMutationTools, - petrinautAiPrompt, - petrinautAiTools, - placeSchema, - scenarioSchema, - setNetTitleToolInputSchema, - setNetTitleToolName, - transitionSchema, + colorSchema, + createPetrinautAiWritableCallbacks, + createPetrinautMutationAiToolCallbacks, + differentialEquationSchema, + getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, + metricSchema, + parameterSchema, + petrinautAiCommandTools, + petrinautAiMutationTools, + petrinautAiPrompt, + petrinautAiTools, + placeSchema, + scenarioSchema, + setNetTitleToolInputSchema, + setNetTitleToolName, + transitionSchema, } from "./ai"; export type { - PetrinautAiCommandToolInput, - PetrinautAiCommandToolName, - PetrinautAiTool, - PetrinautAiWritableCallbacks, - PetrinautMutationAiToolCallbacks, - PetrinautAiToolInput, - PetrinautAiMutationToolInput, - PetrinautAiMutationToolName, - PetrinautAiToolName, - PetrinautAiTools, + PetrinautAiCommandToolInput, + PetrinautAiCommandToolName, + PetrinautAiTool, + PetrinautAiWritableCallbacks, + PetrinautMutationAiToolCallbacks, + PetrinautAiToolInput, + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, + PetrinautAiToolName, + PetrinautAiTools, } from "./ai"; // --- Simulation --- export { - createMonteCarloExperiment, - createMonteCarloSimulator, - createPlaceTokenCountDistributionMetric, - createSimulation, - createWorkerTransport, + createMonteCarloExperiment, + createMonteCarloSimulator, + createPlaceTokenCountDistributionMetric, + createSimulation, + createWorkerTransport, } from "./simulation"; export type { - BackpressureConfig, - CreateMonteCarloExperimentConfig, - CreateSimulationConfig, - Simulation, - SimulationCompleteEvent, - SimulationConfig, - SimulationErrorEvent, - SimulationEvent, - SimulationFrameReader, - SimulationFrameState, - SimulationFrameSummary, - SimulationPlaceTokenValues, - SimulationState, - SimulationTransport, - WorkerFactory, - InitialMarking, - InitialPlaceMarking, - MonteCarloAdvanceResult, - MonteCarloActiveRunPlaceCountsVisitor, - MonteCarloExperiment, - MonteCarloExperimentDistributions, - MonteCarloExperimentEvent, - MonteCarloExperimentState, - MonteCarloFrameMetric, - MonteCarloFrameMetricContext, - MonteCarloRunConfig, - MonteCarloRunSnapshot, - MonteCarloRunStatus, - MonteCarloRunSummary, - MonteCarloRunUntilCompleteOptions, - MonteCarloSimulator, - MonteCarloSimulatorConfig, - PlaceTokenCountDistributionBin, - PlaceTokenCountDistributionFrame, - PlaceTokenCountDistributionMetric, - PlaceTokenCountDistributionPlace, - MonteCarloWorkerProgress, + BackpressureConfig, + CreateMonteCarloExperimentConfig, + CreateSimulationConfig, + Simulation, + SimulationCompleteEvent, + SimulationConfig, + SimulationErrorEvent, + SimulationEvent, + SimulationFrameReader, + SimulationFrameState, + SimulationFrameSummary, + SimulationPlaceTokenValues, + SimulationState, + SimulationTransport, + WorkerFactory, + InitialMarking, + InitialPlaceMarking, + MonteCarloAdvanceResult, + MonteCarloActiveRunPlaceCountsVisitor, + MonteCarloExperiment, + MonteCarloExperimentDistributions, + MonteCarloExperimentEvent, + MonteCarloExperimentState, + MonteCarloFrameMetric, + MonteCarloFrameMetricContext, + MonteCarloRunConfig, + MonteCarloRunSnapshot, + MonteCarloRunStatus, + MonteCarloRunSummary, + MonteCarloRunUntilCompleteOptions, + MonteCarloSimulator, + MonteCarloSimulatorConfig, + PlaceTokenCountDistributionBin, + PlaceTokenCountDistributionFrame, + PlaceTokenCountDistributionMetric, + PlaceTokenCountDistributionPlace, + MonteCarloWorkerProgress, } from "./simulation"; // --- LSP --- export { - CompletionItemKind, - createLanguageClient, - createWorkerLspTransport, - DiagnosticSeverity, - MarkupKind, - Position, - Range, + CompletionItemKind, + createLanguageClient, + createWorkerLspTransport, + DiagnosticSeverity, + MarkupKind, + Position, + Range, } from "./lsp"; export type { - CompletionItem, - CompletionList, - CreateLanguageClientConfig, - Diagnostic, - DiagnosticsSnapshot, - DocumentUri, - Hover, - LanguageClient, - LspTransport, - LspWorkerFactory, - MarkupContent, - SignatureHelp, - TextDocumentIdentifier, + CompletionItem, + CompletionList, + CreateLanguageClientConfig, + Diagnostic, + DiagnosticsSnapshot, + DocumentUri, + Hover, + LanguageClient, + LspTransport, + LspWorkerFactory, + MarkupContent, + SignatureHelp, + TextDocumentIdentifier, } from "./lsp"; // --- Playback --- export { - createPlayback, - formatPlaybackSpeed, - getPlayModeBackpressure, - PLAYBACK_SPEEDS, + createPlayback, + formatPlaybackSpeed, + getPlayModeBackpressure, + PLAYBACK_SPEEDS, } from "./playback"; export type { - Playback, - ComputePlayMode, - PlaybackSnapshot, - PlaybackSpeed, - PlaybackState, - PlayMode, - PlayModeBackpressure, - TickInput, - TickResult, + Playback, + ComputePlayMode, + PlaybackSnapshot, + PlaybackSpeed, + PlaybackState, + PlayMode, + PlayModeBackpressure, + TickInput, + TickResult, } from "./playback"; // --- Domain types --- @@ -185,21 +185,21 @@ export type * from "./types/selection"; // --- Pure utilities --- export type { - AbortSignalLike, - WorkerFactoryLike, - WorkerLike, + AbortSignalLike, + WorkerFactoryLike, + WorkerLike, } from "./environment"; export { - ARC_ID_PREFIX, - ARC_ID_SEPARATOR, - generateArcId, - type ArcIdPrefix, + ARC_ID_PREFIX, + ARC_ID_SEPARATOR, + generateArcId, + type ArcIdPrefix, } from "./arc-id"; export { GRID_SIZE } from "./grid-size"; export { - type DefaultParameterValues, - deriveDefaultParameterValues, - mergeParameterValues, + type DefaultParameterValues, + deriveDefaultParameterValues, + mergeParameterValues, } from "./parameter-values"; export { SDCPNItemError } from "./errors"; export { isSDCPNEqual } from "./lib/deep-equal"; @@ -207,58 +207,58 @@ export { getNodeConnections } from "./lib/get-connections"; // --- Authoring helpers --- export { - DEFAULT_DIFFERENTIAL_EQUATION_CODE, - DEFAULT_TRANSITION_KERNEL_CODE, - DEFAULT_VISUALIZER_CODE, - generateDefaultDifferentialEquationCode, - generateDefaultLambdaCode, - generateDefaultTransitionKernelCode, - generateDefaultVisualizerCode, + DEFAULT_DIFFERENTIAL_EQUATION_CODE, + DEFAULT_TRANSITION_KERNEL_CODE, + DEFAULT_VISUALIZER_CODE, + generateDefaultDifferentialEquationCode, + generateDefaultLambdaCode, + generateDefaultTransitionKernelCode, + generateDefaultVisualizerCode, } from "./default-codes"; export { - compileMetric, - type CompiledMetric, - type CompileMetricOutcome, - type MetricPlaceState, - type MetricState, + compileMetric, + type CompiledMetric, + type CompileMetricOutcome, + type MetricPlaceState, + type MetricState, } from "./simulation/authoring/metric/compile-metric"; export { - compileScenario, - type CompiledPlaceMarking, - type CompiledScenarioResult, - type CompileScenarioOptions, - type CompileScenarioOutcome, - type ScenarioCompilationError, - type ScenarioParameterValues, + compileScenario, + type CompiledPlaceMarking, + type CompiledScenarioResult, + type CompileScenarioOptions, + type CompileScenarioOutcome, + type ScenarioCompilationError, + type ScenarioParameterValues, } from "./simulation/authoring/scenario/compile-scenario"; export { buildMetricState } from "./simulation/frames/metric-state"; export { - displayNameSchema, - validateDisplayName, + displayNameSchema, + validateDisplayName, } from "./validation/display-name"; export { entityNameSchema, validateEntityName } from "./validation/entity-name"; export { validateVariableName } from "./validation/variable-name"; // --- File, clipboard, and editor protocol helpers --- export { - parseSDCPNFile, - type ImportResult, + parseSDCPNFile, + type ImportResult, } from "./file-format/parse-sdcpn-file"; export { serializeSDCPN } from "./file-format/serialize-sdcpn"; export { sdcpnToTikZ } from "./file-format/sdcpn-to-tikz"; export { pastePayloadIntoSDCPN } from "./clipboard/paste"; export { - parseClipboardPayload, - serializeSelection, + parseClipboardPayload, + serializeSelection, } from "./clipboard/serialize"; export { - CLIPBOARD_FORMAT_VERSION, - clipboardPayloadSchema, - type ClipboardPayload, + CLIPBOARD_FORMAT_VERSION, + clipboardPayloadSchema, + type ClipboardPayload, } from "./clipboard/types"; export { - getDocumentUri, - getMetricDocumentUri, - getScenarioDocumentUri, - parseDocumentUri, + getDocumentUri, + getMetricDocumentUri, + getScenarioDocumentUri, + parseDocumentUri, } from "./lsp/lib/document-uris"; diff --git a/libs/@hashintel/petrinaut-core/src/instance.ts b/libs/@hashintel/petrinaut-core/src/instance.ts index 0ea6837da94..f4945d789d6 100644 --- a/libs/@hashintel/petrinaut-core/src/instance.ts +++ b/libs/@hashintel/petrinaut-core/src/instance.ts @@ -1,28 +1,29 @@ import { - createPetrinautActions, - type MutationHelperFunctions, + createPetrinautActions, + type MutationHelperFunctions, } from "./actions"; import { - type CommandHelperFunctions, - createPetrinautCommands, + type CommandHelperFunctions, + createPetrinautCommands, } from "./commands"; + import type { - PetrinautDocHandle, - PetrinautPatch, - ReadableStore, + PetrinautDocHandle, + PetrinautPatch, + ReadableStore, } from "./handle"; import type { SDCPN } from "./types/sdcpn"; const EMPTY_SDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], }; export type EventStream = { - subscribe(listener: (event: T) => void): () => void; + subscribe(listener: (event: T) => void): () => void; }; export type PetrinautMutations = MutationHelperFunctions; @@ -50,108 +51,108 @@ export type PetrinautCommands = CommandHelperFunctions; * SDCPN value). The host owns the simulation's lifecycle. */ export type Petrinaut = { - readonly handle: PetrinautDocHandle; + readonly handle: PetrinautDocHandle; - /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ - readonly definition: ReadableStore; + /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ + readonly definition: ReadableStore; - /** Patch event stream. Only fires for handles that produce patches. */ - readonly patches: EventStream; + /** Patch event stream. Only fires for handles that produce patches. */ + readonly patches: EventStream; - /** Atomic, schema-driven mutations. */ - readonly mutations: PetrinautMutations; + /** Atomic, schema-driven mutations. */ + readonly mutations: PetrinautMutations; - /** Composite host operations (clipboard paste, auto-layout, ...). */ - readonly commands: PetrinautCommands; + /** Composite host operations (clipboard paste, auto-layout, ...). */ + readonly commands: PetrinautCommands; - readonly readonly: boolean; + readonly readonly: boolean; - dispose(this: void): void; + dispose(this: void): void; }; export type CreatePetrinautConfig = { - document: PetrinautDocHandle; - readonly?: boolean; + document: PetrinautDocHandle; + readonly?: boolean; }; function createDefinitionStore( - handle: PetrinautDocHandle, + handle: PetrinautDocHandle, ): ReadableStore { - const listeners = new Set<(value: SDCPN) => void>(); - - const unsubscribe = handle.subscribe((event) => { - for (const listener of listeners) { - listener(event.next); - } - }); - - return { - get: () => handle.doc() ?? EMPTY_SDCPN, - subscribe(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - // Keep the upstream subscription alive — disposed at instance.dispose(). - void unsubscribe; - } - }; - }, - }; + const listeners = new Set<(value: SDCPN) => void>(); + + const unsubscribe = handle.subscribe((event) => { + for (const listener of listeners) { + listener(event.next); + } + }); + + return { + get: () => handle.doc() ?? EMPTY_SDCPN, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + // Keep the upstream subscription alive — disposed at instance.dispose(). + void unsubscribe; + } + }; + }, + }; } function createPatchStream( - handle: PetrinautDocHandle, + handle: PetrinautDocHandle, ): EventStream { - const listeners = new Set<(event: PetrinautPatch[]) => void>(); - - handle.subscribe((event) => { - if (!event.patches) { - return; - } - for (const listener of listeners) { - listener(event.patches); - } - }); - - return { - subscribe(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; + const listeners = new Set<(event: PetrinautPatch[]) => void>(); + + handle.subscribe((event) => { + if (!event.patches) { + return; + } + for (const listener of listeners) { + listener(event.patches); + } + }); + + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; } export function createPetrinaut(config: CreatePetrinautConfig): Petrinaut { - const { document: handle, readonly = false } = config; - - const disposers: Array<() => void> = []; - - const definition = createDefinitionStore(handle); - const patches = createPatchStream(handle); - - const mutate = (fn: (draft: SDCPN) => void) => { - if (readonly) { - return; - } - handle.change(fn); - }; - - const mutations = createPetrinautActions(mutate); - const commands = createPetrinautCommands(mutate, () => definition.get()); - - return { - handle, - definition, - patches, - mutations, - commands, - readonly, - dispose() { - for (const dispose of disposers) { - dispose(); - } - disposers.length = 0; - }, - }; + const { document: handle, readonly = false } = config; + + const disposers: Array<() => void> = []; + + const definition = createDefinitionStore(handle); + const patches = createPatchStream(handle); + + const mutate = (fn: (draft: SDCPN) => void) => { + if (readonly) { + return; + } + handle.change(fn); + }; + + const mutations = createPetrinautActions(mutate); + const commands = createPetrinautCommands(mutate, () => definition.get()); + + return { + handle, + definition, + patches, + mutations, + commands, + readonly, + dispose() { + for (const dispose of disposers) { + dispose(); + } + disposers.length = 0; + }, + }; } diff --git a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts index ea7afd4d88d..52fb4e836f8 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts @@ -15,20 +15,20 @@ const graphPadding = 30; * @see https://eclipse.dev/elk/reference.html */ const elkLayoutOptions: ElkNode["layoutOptions"] = { - "elk.algorithm": "layered", - "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "100", - "elk.direction": "RIGHT", - "elk.padding": `[left=${graphPadding},top=${graphPadding},right=${graphPadding},bottom=${graphPadding}]`, + "elk.algorithm": "layered", + "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "100", + "elk.direction": "RIGHT", + "elk.padding": `[left=${graphPadding},top=${graphPadding},right=${graphPadding},bottom=${graphPadding}]`, }; export type NodePosition = { - x: number; - y: number; + x: number; + y: number; }; export type LayoutDimensions = { - place: { width: number; height: number }; - transition: { width: number; height: number }; + place: { width: number; height: number }; + transition: { width: number; height: number }; }; /** @@ -51,72 +51,72 @@ export type LayoutDimensions = { * @returns A promise that resolves to a map of node IDs to their calculated positions */ export const calculateGraphLayout = async ( - sdcpn: SDCPN, - dimensions: LayoutDimensions, + sdcpn: SDCPN, + dimensions: LayoutDimensions, ): Promise> => { - if (sdcpn.places.length === 0) { - return {}; - } + if (sdcpn.places.length === 0) { + return {}; + } - const elkNodes: ElkNode["children"] = [ - ...sdcpn.places.map((place) => ({ - id: place.id, - width: dimensions.place.width, - height: dimensions.place.height, - })), - ...sdcpn.transitions.map((transition) => ({ - id: transition.id, - width: dimensions.transition.width, - height: dimensions.transition.height, - })), - ]; + const elkNodes: ElkNode["children"] = [ + ...sdcpn.places.map((place) => ({ + id: place.id, + width: dimensions.place.width, + height: dimensions.place.height, + })), + ...sdcpn.transitions.map((transition) => ({ + id: transition.id, + width: dimensions.transition.width, + height: dimensions.transition.height, + })), + ]; - const elkEdges: ElkNode["edges"] = []; - for (const transition of sdcpn.transitions) { - for (const inputArc of transition.inputArcs) { - elkEdges.push({ - id: `arc__${inputArc.placeId}-${transition.id}`, - sources: [inputArc.placeId], - targets: [transition.id], - }); - } - for (const outputArc of transition.outputArcs) { - elkEdges.push({ - id: `arc__${transition.id}-${outputArc.placeId}`, - sources: [transition.id], - targets: [outputArc.placeId], - }); - } - } + const elkEdges: ElkNode["edges"] = []; + for (const transition of sdcpn.transitions) { + for (const inputArc of transition.inputArcs) { + elkEdges.push({ + id: `arc__${inputArc.placeId}-${transition.id}`, + sources: [inputArc.placeId], + targets: [transition.id], + }); + } + for (const outputArc of transition.outputArcs) { + elkEdges.push({ + id: `arc__${transition.id}-${outputArc.placeId}`, + sources: [transition.id], + targets: [outputArc.placeId], + }); + } + } - const graph: ElkNode = { - id: "root", - children: elkNodes, - edges: elkEdges, - layoutOptions: elkLayoutOptions, - }; + const graph: ElkNode = { + id: "root", + children: elkNodes, + edges: elkEdges, + layoutOptions: elkLayoutOptions, + }; - const updatedElements = await elk.layout(graph); + const updatedElements = await elk.layout(graph); - const placeIds = new Set(sdcpn.places.map((place) => place.id)); + const placeIds = new Set(sdcpn.places.map((place) => place.id)); - /** - * ELK returns top-left positions, but the SDCPN store uses center - * coordinates, so we offset by half the node dimensions. - */ - const positionsByNodeId: Record = {}; - for (const child of updatedElements.children ?? []) { - if (child.x !== undefined && child.y !== undefined) { - const nodeDimensions = placeIds.has(child.id) - ? dimensions.place - : dimensions.transition; + /** + * ELK returns top-left positions, but the SDCPN store uses center + * coordinates, so we offset by half the node dimensions. + */ + const positionsByNodeId: Record = {}; + for (const child of updatedElements.children ?? []) { + if (child.x !== undefined && child.y !== undefined) { + const nodeDimensions = placeIds.has(child.id) + ? dimensions.place + : dimensions.transition; - positionsByNodeId[child.id] = { - x: child.x + nodeDimensions.width / 2, - y: child.y + nodeDimensions.height / 2, - }; - } - } + positionsByNodeId[child.id] = { + x: child.x + nodeDimensions.width / 2, + y: child.y + nodeDimensions.height / 2, + }; + } + } - return positionsByNodeId; + return positionsByNodeId; }; diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 1adb2d7077e..7764d4abc89 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -2,270 +2,270 @@ import { z } from "zod"; import { displayNameSchema } from "../validation/display-name"; import { entityNameSchema } from "../validation/entity-name"; +import { variableNameSchema } from "../validation/variable-name"; import type { - Color, - DifferentialEquation, - Parameter, - Place, - Transition, + Color, + DifferentialEquation, + Parameter, + Place, + Transition, } from "../types/sdcpn"; -import { variableNameSchema } from "../validation/variable-name"; export const idSchema = z.string().min(1).meta({ - description: - "Stable identifier for an SDCPN entity. Use unique IDs within the net.", + description: + "Stable identifier for an SDCPN entity. Use unique IDs within the net.", }); export const positionSchema = z - .strictObject({ - x: z.number().meta({ - description: "Horizontal canvas position.", - }), - y: z.number().meta({ - description: "Vertical canvas position.", - }), - }) - .meta({ - description: "Canvas position for a place or transition.", - }); + .strictObject({ + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: "Canvas position for a place or transition.", + }); export const nodePositionCommitSchema = z - .strictObject({ - id: idSchema, - itemType: z.enum(["place", "transition"]).meta({ - description: "Whether the positioned node is a place or transition.", - }), - position: positionSchema, - }) - .meta({ - description: "A pending canvas-position update for one node.", - }); + .strictObject({ + id: idSchema, + itemType: z.enum(["place", "transition"]).meta({ + description: "Whether the positioned node is a place or transition.", + }), + position: positionSchema, + }) + .meta({ + description: "A pending canvas-position update for one node.", + }); export const inputArcSchema = z - .strictObject({ - placeId: idSchema.meta({ - description: "ID of the input place connected to the transition.", - }), - weight: z.number().positive().meta({ - description: - "Number of tokens consumed from the input place per firing. For coloured input places this also determines the tuple length the transition's lambda and kernel see at `input.PlaceName` (weight 2 means a 2-token array).", - }), - type: z.enum(["standard", "inhibitor"]).meta({ - description: - "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated. Inhibitor arcs do NOT consume tokens and their place is NOT present in the lambda or kernel `input`.", - }), - }) - .meta({ - description: "Input arc from a place into a transition.", - }); + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the input place connected to the transition.", + }), + weight: z.number().positive().meta({ + description: + "Number of tokens consumed from the input place per firing. For coloured input places this also determines the tuple length the transition's lambda and kernel see at `input.PlaceName` (weight 2 means a 2-token array).", + }), + type: z.enum(["standard", "inhibitor"]).meta({ + description: + "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated. Inhibitor arcs do NOT consume tokens and their place is NOT present in the lambda or kernel `input`.", + }), + }) + .meta({ + description: "Input arc from a place into a transition.", + }); export const outputArcSchema = z - .strictObject({ - placeId: idSchema.meta({ - description: "ID of the output place connected from the transition.", - }), - weight: z.number().positive().meta({ - description: "Number of tokens produced into the output place.", - }), - }) - .meta({ - description: "Output arc from a transition into a place.", - }); + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the output place connected from the transition.", + }), + weight: z.number().positive().meta({ + description: "Number of tokens produced into the output place.", + }), + }) + .meta({ + description: "Output arc from a transition into a place.", + }); export const arcDirectionSchema = z.enum(["input", "output"]).meta({ - description: - "Whether the arc connects a place into a transition or a transition out to a place.", + description: + "Whether the arc connects a place into a transition or a transition out to a place.", }); export const colorElementSchema = z - .strictObject({ - elementId: idSchema.meta({ - description: "Stable identifier for this colour element.", - }), - name: displayNameSchema - .check( - z.refine((val) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(val), { - message: - "Element name must be a valid JavaScript identifier (start with a letter, `_`, or `$`; only letters, digits, `_`, `$` allowed).", - }), - ) - .meta({ - description: - "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", - }), - type: z.enum(["real", "integer", "boolean"]).meta({ - description: - "Primitive token attribute type. Note: the simulation buffer stores all values as Float64; `integer`/`boolean` are documentation/type-hints only, not enforced at runtime.", - }), - }) - .meta({ - description: "One typed attribute on a coloured token.", - }); + .strictObject({ + elementId: idSchema.meta({ + description: "Stable identifier for this colour element.", + }), + name: displayNameSchema + .check( + z.refine((val) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(val), { + message: + "Element name must be a valid JavaScript identifier (start with a letter, `_`, or `$`; only letters, digits, `_`, `$` allowed).", + }), + ) + .meta({ + description: + "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: + "Primitive token attribute type. Note: the simulation buffer stores all values as Float64; `integer`/`boolean` are documentation/type-hints only, not enforced at runtime.", + }), + }) + .meta({ + description: "One typed attribute on a coloured token.", + }); export const placeSchema = z - .strictObject({ - id: idSchema, - name: entityNameSchema.meta({ - description: - "PascalCase identifier used DIRECTLY in user code: lambdas and kernels reference input/output places as `input.PlaceName` and `{ PlaceName: [...] }`, metrics access them as `state.places.PlaceName.count`, scenario code-mode initial state keys are place names, and visualizer scope is implicitly per-place. Renaming a place breaks every code reference, so rename only when you also update dependent lambda/kernel/dynamics/metric/visualizer/scenario code in the same batch.", - }), - colorId: idSchema.nullable().meta({ - description: - "ID of the token colour/type accepted by this place, or null for uncoloured token counts. Uncoloured places have no token attributes and do not appear in lambda/kernel `input` objects.", - }), - dynamicsEnabled: z.boolean().meta({ - description: - "Whether tokens in this place are updated by a differential equation during simulation. Dynamics only run when this is true AND `differentialEquationId` is set AND `colorId` is set.", - }), - differentialEquationId: idSchema.nullable().meta({ - description: - "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled. The referenced equation's `colorId` MUST match this place's `colorId`.", - }), - visualizerCode: z.string().optional().meta({ - description: - "Optional module: `export default Visualization(({ tokens, parameters }) => )`. JSX is compiled with React's CLASSIC runtime — do NOT `import React`, do NOT use `<>…` fragments (use `` or explicit elements), and do NOT use hooks; treat it as a pure render. `tokens` is this place's current tokens (only meaningful for coloured places; empty for uncoloured). `parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.crash_threshold`). Convention is to return a sized ``.", - }), - showAsInitialState: z.boolean().optional().meta({ - description: - "Optional UI hint to show this place in the initial-state view.", - }), - x: z.number().meta({ - description: "Horizontal canvas position.", - }), - y: z.number().meta({ - description: "Vertical canvas position.", - }), - }) - .meta({ - description: - "A Petri net place. Places store tokens and may optionally use colours and continuous dynamics.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: entityNameSchema.meta({ + description: + "PascalCase identifier used DIRECTLY in user code: lambdas and kernels reference input/output places as `input.PlaceName` and `{ PlaceName: [...] }`, metrics access them as `state.places.PlaceName.count`, scenario code-mode initial state keys are place names, and visualizer scope is implicitly per-place. Renaming a place breaks every code reference, so rename only when you also update dependent lambda/kernel/dynamics/metric/visualizer/scenario code in the same batch.", + }), + colorId: idSchema.nullable().meta({ + description: + "ID of the token colour/type accepted by this place, or null for uncoloured token counts. Uncoloured places have no token attributes and do not appear in lambda/kernel `input` objects.", + }), + dynamicsEnabled: z.boolean().meta({ + description: + "Whether tokens in this place are updated by a differential equation during simulation. Dynamics only run when this is true AND `differentialEquationId` is set AND `colorId` is set.", + }), + differentialEquationId: idSchema.nullable().meta({ + description: + "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled. The referenced equation's `colorId` MUST match this place's `colorId`.", + }), + visualizerCode: z.string().optional().meta({ + description: + "Optional module: `export default Visualization(({ tokens, parameters }) => )`. JSX is compiled with React's CLASSIC runtime — do NOT `import React`, do NOT use `<>…` fragments (use `` or explicit elements), and do NOT use hooks; treat it as a pure render. `tokens` is this place's current tokens (only meaningful for coloured places; empty for uncoloured). `parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.crash_threshold`). Convention is to return a sized ``.", + }), + showAsInitialState: z.boolean().optional().meta({ + description: + "Optional UI hint to show this place in the initial-state view.", + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net place. Places store tokens and may optionally use colours and continuous dynamics.", + }) satisfies z.ZodType; export const transitionSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable transition name.", - }), - inputArcs: z.array(inputArcSchema).meta({ - description: - "Input arcs that gate and consume tokens for this transition.", - }), - outputArcs: z.array(outputArcSchema).meta({ - description: - "Output arcs that receive tokens after this transition fires.", - }), - lambdaType: z.enum(["predicate", "stochastic"]).meta({ - description: - "Use predicate for boolean enabling logic; use stochastic for rate-based firing.", - }), - lambdaCode: z.string().meta({ - description: [ - "Module: `export default Lambda((input, parameters) => …)`.", - "`input` is keyed by INPUT PLACE NAME (PascalCase) and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", - "Inhibitor arcs and uncoloured input places are NOT present in `input`.", - "Each token is an object keyed by the colour type's element names (e.g. `{ x, y, velocity }`).", - "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.infection_rate`).", - "Predicate lambdas MUST return a boolean (true = enabled given these tokens, false = disabled).", - "Stochastic lambdas MUST return a non-negative finite number = expected firings per simulation second (0 disables, Infinity always fires).", - "Lambda is called per token combination satisfying arc weights, so it MUST be deterministic — put randomness in the transition kernel, not here.", - ].join(" "), - }), - transitionKernelCode: z.string().meta({ - description: [ - "Module: `export default TransitionKernel((input, parameters) => …)`.", - "`input` and `parameters` have the same shape as the transition's lambda.", - "MUST return an object keyed by OUTPUT PLACE NAME with a tuple sized to that arc's weight. Coloured output places MUST be present; uncoloured output places MUST be omitted (they are auto-populated with empty tokens).", - "Token attribute values can be plain numbers/booleans OR `Distribution.Gaussian(mean, sd)` / `Distribution.Uniform(min, max)` / `Distribution.Lognormal(mu, sigma)`; each distribution is sampled once per token, and chained `.map(fn)` calls on the same distribution share that single sample (useful for deriving multiple attributes from one draw).", - "Always required even when no stochasticity is needed; use `export default TransitionKernel(() => ({}))` when every output place is uncoloured.", - ].join(" "), - }), - x: z.number().meta({ - description: "Horizontal canvas position.", - }), - y: z.number().meta({ - description: "Vertical canvas position.", - }), - }) - .meta({ - description: - "A Petri net transition. Transitions connect places and define firing logic.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable transition name.", + }), + inputArcs: z.array(inputArcSchema).meta({ + description: + "Input arcs that gate and consume tokens for this transition.", + }), + outputArcs: z.array(outputArcSchema).meta({ + description: + "Output arcs that receive tokens after this transition fires.", + }), + lambdaType: z.enum(["predicate", "stochastic"]).meta({ + description: + "Use predicate for boolean enabling logic; use stochastic for rate-based firing.", + }), + lambdaCode: z.string().meta({ + description: [ + "Module: `export default Lambda((input, parameters) => …)`.", + "`input` is keyed by INPUT PLACE NAME (PascalCase) and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", + "Inhibitor arcs and uncoloured input places are NOT present in `input`.", + "Each token is an object keyed by the colour type's element names (e.g. `{ x, y, velocity }`).", + "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.infection_rate`).", + "Predicate lambdas MUST return a boolean (true = enabled given these tokens, false = disabled).", + "Stochastic lambdas MUST return a non-negative finite number = expected firings per simulation second (0 disables, Infinity always fires).", + "Lambda is called per token combination satisfying arc weights, so it MUST be deterministic — put randomness in the transition kernel, not here.", + ].join(" "), + }), + transitionKernelCode: z.string().meta({ + description: [ + "Module: `export default TransitionKernel((input, parameters) => …)`.", + "`input` and `parameters` have the same shape as the transition's lambda.", + "MUST return an object keyed by OUTPUT PLACE NAME with a tuple sized to that arc's weight. Coloured output places MUST be present; uncoloured output places MUST be omitted (they are auto-populated with empty tokens).", + "Token attribute values can be plain numbers/booleans OR `Distribution.Gaussian(mean, sd)` / `Distribution.Uniform(min, max)` / `Distribution.Lognormal(mu, sigma)`; each distribution is sampled once per token, and chained `.map(fn)` calls on the same distribution share that single sample (useful for deriving multiple attributes from one draw).", + "Always required even when no stochasticity is needed; use `export default TransitionKernel(() => ({}))` when every output place is uncoloured.", + ].join(" "), + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net transition. Transitions connect places and define firing logic.", + }) satisfies z.ZodType; export const colorSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable colour/type name.", - }), - iconSlug: z.string().min(1).meta({ - description: - 'Short icon identifier used by the UI for this colour/type. Typical values are `"circle"` or `"square"`; the UI defaults to `"circle"`.', - }), - displayColor: z.string().min(1).meta({ - description: - 'CSS colour string for the UI badge, e.g. `"#1E90FF"` or `"rgb(30,144,255)"`.', - }), - elements: z.array(colorElementSchema).meta({ - description: - "Typed token attributes available on tokens of this colour/type. Element order matters: coloured initial state in scenario per_place mode supplies `number[][]` rows in this order.", - }), - }) - .meta({ - description: - "A coloured-token type. Coloured places store token objects with these attributes.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable colour/type name.", + }), + iconSlug: z.string().min(1).meta({ + description: + 'Short icon identifier used by the UI for this colour/type. Typical values are `"circle"` or `"square"`; the UI defaults to `"circle"`.', + }), + displayColor: z.string().min(1).meta({ + description: + 'CSS colour string for the UI badge, e.g. `"#1E90FF"` or `"rgb(30,144,255)"`.', + }), + elements: z.array(colorElementSchema).meta({ + description: + "Typed token attributes available on tokens of this colour/type. Element order matters: coloured initial state in scenario per_place mode supplies `number[][]` rows in this order.", + }), + }) + .meta({ + description: + "A coloured-token type. Coloured places store token objects with these attributes.", + }) satisfies z.ZodType; export const differentialEquationSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable dynamics name.", - }), - colorId: idSchema.nullable().meta({ - description: - "ID of the colour/type whose token attributes this dynamics function updates. MUST match the `colorId` of every place that references this equation via `differentialEquationId`.", - }), - code: z.string().meta({ - description: [ - "Module: `export default Dynamics((tokens, parameters) => …)`.", - "`tokens` is THIS place's current tokens only — `Array<{ [elementName]: number }>` — NOT all places' tokens.", - "MUST return an array of the SAME LENGTH where each entry is `{ [elementName]: derivative }` (i.e. dx/dt, NOT the new value).", - "The engine integrates with Euler: `next = current + derivative * dt`.", - "Missing keys default to 0 silently, so return every element your colour type declares.", - "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.damage_per_second`).", - ].join(" "), - }), - }) - .meta({ - description: - "A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and the returned derivative keys MUST cover that colour's elements.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable dynamics name.", + }), + colorId: idSchema.nullable().meta({ + description: + "ID of the colour/type whose token attributes this dynamics function updates. MUST match the `colorId` of every place that references this equation via `differentialEquationId`.", + }), + code: z.string().meta({ + description: [ + "Module: `export default Dynamics((tokens, parameters) => …)`.", + "`tokens` is THIS place's current tokens only — `Array<{ [elementName]: number }>` — NOT all places' tokens.", + "MUST return an array of the SAME LENGTH where each entry is `{ [elementName]: derivative }` (i.e. dx/dt, NOT the new value).", + "The engine integrates with Euler: `next = current + derivative * dt`.", + "Missing keys default to 0 silently, so return every element your colour type declares.", + "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.damage_per_second`).", + ].join(" "), + }), + }) + .meta({ + description: + "A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and the returned derivative keys MUST cover that colour's elements.", + }) satisfies z.ZodType; export const parameterSchema = z - .strictObject({ - id: idSchema, - name: displayNameSchema.meta({ - description: "Human-readable parameter name.", - }), - variableName: variableNameSchema.meta({ - description: - "lower_snake_case identifier used DIRECTLY in user code as `parameters.` (e.g. `parameters.crash_threshold`, NOT `parameters.crashThreshold`). Must start with a lowercase letter; only `[a-z0-9_]` allowed.", - }), - type: z.enum(["real", "integer", "boolean"]).meta({ - description: - "Primitive parameter type. Note: parameter values are stored numerically (booleans coerce via `Number()`); the type is primarily a documentation/UI hint.", - }), - defaultValue: z.string().meta({ - description: - 'Default parameter value as a plain numeric string (e.g. `"3"`, `"0.05"`). Parsed via `Number()` with a `|| 0` fallback, so non-numeric strings silently become 0. Expressions are NOT supported here — use scenario `parameterOverrides` for expressions.', - }), - }) - .meta({ - description: - "A net-level parameter available to executable SDCPN code and scenarios.", - }) satisfies z.ZodType; + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable parameter name.", + }), + variableName: variableNameSchema.meta({ + description: + "lower_snake_case identifier used DIRECTLY in user code as `parameters.` (e.g. `parameters.crash_threshold`, NOT `parameters.crashThreshold`). Must start with a lowercase letter; only `[a-z0-9_]` allowed.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: + "Primitive parameter type. Note: parameter values are stored numerically (booleans coerce via `Number()`); the type is primarily a documentation/UI hint.", + }), + defaultValue: z.string().meta({ + description: + 'Default parameter value as a plain numeric string (e.g. `"3"`, `"0.05"`). Parsed via `Number()` with a `|| 0` fallback, so non-numeric strings silently become 0. Expressions are NOT supported here — use scenario `parameterOverrides` for expressions.', + }), + }) + .meta({ + description: + "A net-level parameter available to executable SDCPN code and scenarios.", + }) satisfies z.ZodType; export type PlaceSchema = typeof placeSchema; export type TransitionSchema = typeof transitionSchema; diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index f7cc1974e1a..1e11da74587 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -4,48 +4,48 @@ export { ErrorTrackerContext } from "./react/error-tracker-context"; export type { ViewportAction } from "./ui/types/viewport-action"; export { - createJsonDocHandle, - createPetrinaut, - createPetrinautActions, - createSimulation, - createWorkerTransport, - isSDCPNEqual, - type BackpressureConfig, - type Color, - type CreateJsonDocHandleOptions, - type CreatePetrinautConfig, - type CreateSimulationConfig, - type DifferentialEquation, - type DocChangeEvent, - type DocHandleState, - type DocumentId, - type EventStream, - type HistoryEntry, - type MinimalNetMetadata, - type MutateSDCPN, - type MutationHelperFunctions, - type Parameter, - type PetrinautDocHandle, - type PetrinautHistory, - type Petrinaut as PetrinautInstance, - type PetrinautPatch, - type Place, - type ReadableStore, - type InitialMarking, - type SDCPN, - type Simulation, - type SimulationCompleteEvent, - type SimulationConfig, - type SimulationErrorEvent, - type SimulationEvent, - type SimulationFrameReader, - type SimulationFrameState, - type SimulationFrameSummary, - type SimulationPlaceTokenValues, - type SimulationState, - type SimulationTransport, - type Transition, - type WorkerFactory, + createJsonDocHandle, + createPetrinaut, + createPetrinautActions, + createSimulation, + createWorkerTransport, + isSDCPNEqual, + type BackpressureConfig, + type Color, + type CreateJsonDocHandleOptions, + type CreatePetrinautConfig, + type CreateSimulationConfig, + type DifferentialEquation, + type DocChangeEvent, + type DocHandleState, + type DocumentId, + type EventStream, + type HistoryEntry, + type MinimalNetMetadata, + type MutateSDCPN, + type MutationHelperFunctions, + type Parameter, + type PetrinautDocHandle, + type PetrinautHistory, + type Petrinaut as PetrinautInstance, + type PetrinautPatch, + type Place, + type ReadableStore, + type InitialMarking, + type SDCPN, + type Simulation, + type SimulationCompleteEvent, + type SimulationConfig, + type SimulationErrorEvent, + type SimulationEvent, + type SimulationFrameReader, + type SimulationFrameState, + type SimulationFrameSummary, + type SimulationPlaceTokenValues, + type SimulationState, + type SimulationTransport, + type Transition, + type WorkerFactory, } from "@hashintel/petrinaut-core"; export { Petrinaut } from "./ui/petrinaut"; export type { PetrinautProps } from "./ui/petrinaut"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts index ccd11406e7a..f5f7ca01596 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts @@ -1,10 +1,10 @@ import { use } from "react"; -import type { PetrinautCommands } from "@hashintel/petrinaut-core"; - import { PetrinautInstanceContext } from "../instance-context"; import { useIsReadOnly } from "../state/use-is-read-only"; +import type { PetrinautCommands } from "@hashintel/petrinaut-core"; + /** * React-facing bundle of composite host commands (clipboard paste, * auto-layout, ...). diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index 69553884f2d..f9681203fed 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -1,11 +1,11 @@ import { use } from "react"; -import type { PetrinautMutations } from "@hashintel/petrinaut-core"; - import { PetrinautInstanceContext } from "../instance-context"; import { SDCPNContext } from "../state/sdcpn-context"; import { useIsReadOnly } from "../state/use-is-read-only"; +import type { PetrinautMutations } from "@hashintel/petrinaut-core"; + /** * Names of mutations that are allowed in simulate mode. Scenario and metric * CRUD are managed from the Simulate panel — only the host `readonly` flag diff --git a/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts b/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts index 1fccaa65f93..2047ec65ff1 100644 --- a/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts +++ b/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts @@ -1,6 +1,7 @@ import { use } from "react"; import { PetrinautInstanceContext } from "./instance-context"; + import type { Petrinaut } from "@hashintel/petrinaut-core"; /** @@ -13,11 +14,11 @@ import type { Petrinaut } from "@hashintel/petrinaut-core"; export type PetrinautReactInstance = Omit; export function usePetrinautInstance(): PetrinautReactInstance { - const instance = use(PetrinautInstanceContext); - if (!instance) { - throw new Error( - "usePetrinautInstance must be used inside (or ).", - ); - } - return instance; + const instance = use(PetrinautInstanceContext); + if (!instance) { + throw new Error( + "usePetrinautInstance must be used inside (or ).", + ); + } + return instance; } diff --git a/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts b/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts index 2a8206b46f6..9f9ee010170 100644 --- a/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts +++ b/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts @@ -1,26 +1,27 @@ import { - parseClipboardPayload, - serializeSelection, - type SDCPN, - type SelectionMap, + parseClipboardPayload, + serializeSelection, + type SDCPN, + type SelectionMap, } from "@hashintel/petrinaut-core"; + import type { PetrinautCommands } from "../../react"; /** * Copy the current selection to the system clipboard. */ export async function copySelectionToClipboard( - sdcpn: SDCPN, - selection: SelectionMap, - documentId: string | null, + sdcpn: SDCPN, + selection: SelectionMap, + documentId: string | null, ): Promise { - const payload = serializeSelection(sdcpn, selection, documentId); - const json = JSON.stringify(payload); - try { - await navigator.clipboard.writeText(json); - } catch { - // Clipboard write can fail (permissions denied, non-secure context, etc.) - } + const payload = serializeSelection(sdcpn, selection, documentId); + const json = JSON.stringify(payload); + try { + await navigator.clipboard.writeText(json); + } catch { + // Clipboard write can fail (permissions denied, non-secure context, etc.) + } } /** @@ -30,21 +31,21 @@ export async function copySelectionToClipboard( * petrinaut data. */ export async function pasteFromClipboard( - applyClipboardPaste: PetrinautCommands["applyClipboardPaste"], + applyClipboardPaste: PetrinautCommands["applyClipboardPaste"], ): Promise | null> { - let text: string; - try { - text = await navigator.clipboard.readText(); - } catch { - // Clipboard read can fail (permissions denied, non-secure context, etc.) - return null; - } - const payload = parseClipboardPayload(text); + let text: string; + try { + text = await navigator.clipboard.readText(); + } catch { + // Clipboard read can fail (permissions denied, non-secure context, etc.) + return null; + } + const payload = parseClipboardPayload(text); - if (!payload) { - return null; - } + if (!payload) { + return null; + } - const { newItemIds } = applyClipboardPaste({ payload }); - return newItemIds; + const { newItemIds } = applyClipboardPaste({ payload }); + return newItemIds; } diff --git a/libs/@hashintel/petrinaut/src/ui/components/popover.tsx b/libs/@hashintel/petrinaut/src/ui/components/popover.tsx index 6e7a0d4b3c4..88a0f77421e 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/popover.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/popover.tsx @@ -11,58 +11,58 @@ import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; // -- Styles ------------------------------------------------------------------ const contentStyle = css({ - backgroundColor: "neutral.s25", - borderRadius: "xl", - boxShadow: "[0px 0px 0px 1px rgba(0, 0, 0, 0.08)]", - overflow: "hidden", - zIndex: "dropdown", - transformOrigin: "var(--transform-origin)", - userSelect: "none", - '&[data-state="open"]': { - animation: "popover-in 150ms ease-out", - }, - '&[data-state="closed"]': { - animation: "popover-out 100ms ease-in", - }, + backgroundColor: "neutral.s25", + borderRadius: "xl", + boxShadow: "[0px 0px 0px 1px rgba(0, 0, 0, 0.08)]", + overflow: "hidden", + zIndex: "dropdown", + transformOrigin: "var(--transform-origin)", + userSelect: "none", + '&[data-state="open"]': { + animation: "popover-in 150ms ease-out", + }, + '&[data-state="closed"]': { + animation: "popover-out 100ms ease-in", + }, }); const headerStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingX: "3", - paddingY: "2", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingX: "3", + paddingY: "2", }); const titleStyle = css({ - fontSize: "xs", - fontWeight: "medium", - color: "neutral.s100", - textTransform: "uppercase", - letterSpacing: "[0.48px]", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s100", + textTransform: "uppercase", + letterSpacing: "[0.48px]", }); const sectionStyle = css({ - paddingX: "1", - paddingBottom: "1", + paddingX: "1", + paddingBottom: "1", }); const sectionCardStyle = css({ - backgroundColor: "neutral.s00", - borderRadius: "lg", - boxShadow: - "[0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 1px -0.5px rgba(0, 0, 0, 0.04), 0px 4px 4px -12px rgba(0, 0, 0, 0.02), 0px 12px 12px -6px rgba(0, 0, 0, 0.02)]", - overflow: "hidden", - padding: "1", + backgroundColor: "neutral.s00", + borderRadius: "lg", + boxShadow: + "[0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 1px -0.5px rgba(0, 0, 0, 0.04), 0px 4px 4px -12px rgba(0, 0, 0, 0.02), 0px 12px 12px -6px rgba(0, 0, 0, 0.02)]", + overflow: "hidden", + padding: "1", }); const sectionLabelStyle = css({ - fontSize: "xs", - fontWeight: "medium", - color: "neutral.s100", - paddingX: "2", - paddingTop: "2", - paddingBottom: "1.5", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s100", + paddingX: "2", + paddingTop: "2", + paddingBottom: "1.5", }); // -- Subcomponents ----------------------------------------------------------- @@ -72,64 +72,64 @@ const sectionLabelStyle = css({ * Wraps Portal + Positioner + Content from Ark UI. */ const Content = ({ - children, - className, - ...props + children, + className, + ...props }: HTMLAttributes & { - children: ReactNode; - className?: string; + children: ReactNode; + className?: string; }) => { - const portalContainerRef = usePortalContainerRef(); - - return ( - - - - {children} - - - - ); + const portalContainerRef = usePortalContainerRef(); + + return ( + + + + {children} + + + + ); }; /** * Popover header with a title and close button. */ const Header = ({ children }: { children: ReactNode }) => ( -
- {children} - -
+
+ {children} + +
); /** * Padded section wrapper inside popover content. */ const Section = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); /** * White card with subtle shadow, used to group related items inside a Section. */ const SectionCard = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); /** * Label for a section card. */ const SectionLabel = ({ children }: { children: ReactNode }) => ( -
{children}
+
{children}
); // -- Compound export --------------------------------------------------------- @@ -137,11 +137,11 @@ const SectionLabel = ({ children }: { children: ReactNode }) => ( export type PopoverRootProps = ComponentProps; export const Popover = { - Root: ArkPopover.Root, - Trigger: ArkPopover.Trigger, - Content, - Header, - Section, - SectionCard, - SectionLabel, + Root: ArkPopover.Root, + Trigger: ArkPopover.Trigger, + Content, + Header, + Section, + SectionCard, + SectionLabel, }; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx index db0132de671..b9062f10817 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx @@ -1,27 +1,27 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import { - createJsonDocHandle, - type PetrinautDocHandle, - type MinimalNetMetadata, - type SDCPN, + createJsonDocHandle, + type PetrinautDocHandle, + type MinimalNetMetadata, + type SDCPN, } from "@hashintel/petrinaut-core"; import { Petrinaut, type PetrinautAiAssistant } from "./petrinaut"; const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], }; type StoredNet = { - id: string; - title: string; - /** Latest snapshot — kept in sync with handle.doc() via subscribe. */ - sdcpn: SDCPN; + id: string; + title: string; + /** Latest snapshot — kept in sync with handle.doc() via subscribe. */ + sdcpn: SDCPN; }; type HandlesByNetId = Record; @@ -34,124 +34,124 @@ type HandlesByNetId = Record; * history survives switching between nets. */ export const PetrinautStoryProvider = ({ - aiAssistant, - initialTitle = "New Process", - initialDefinition = emptySDCPN, - hideNetManagementControls = false, - readonly = false, - children, + aiAssistant, + initialTitle = "New Process", + initialDefinition = emptySDCPN, + hideNetManagementControls = false, + readonly = false, + children, }: { - aiAssistant?: PetrinautAiAssistant; - initialTitle?: string; - initialDefinition?: SDCPN; - hideNetManagementControls?: boolean; - readonly?: boolean; - children?: ReactNode; + aiAssistant?: PetrinautAiAssistant; + initialTitle?: string; + initialDefinition?: SDCPN; + hideNetManagementControls?: boolean; + readonly?: boolean; + children?: ReactNode; }) => { - const [nets, setNets] = useState>(() => { - const id = "net-1"; - return { - [id]: { id, title: initialTitle, sdcpn: initialDefinition }, - }; - }); - const [currentNetId, setCurrentNetId] = useState("net-1"); - const [handlesByNetId, setHandlesByNetId] = useState(() => ({ - "net-1": createJsonDocHandle({ id: "net-1", initial: initialDefinition }), - })); - - // Track which handles have an active subscription so adding a new net only - // wires up the new handle instead of tearing down every existing one. - const unsubscribersRef = useRef void>>(new Map()); - - useEffect(() => { - for (const handle of Object.values(handlesByNetId)) { - if (unsubscribersRef.current.has(handle.id)) { - continue; - } - const off = handle.subscribe((event) => { - setNets((prev) => { - const stored = prev[handle.id]; - if (!stored) { - return prev; - } - return { ...prev, [handle.id]: { ...stored, sdcpn: event.next } }; - }); - }); - unsubscribersRef.current.set(handle.id, off); - } - }, [handlesByNetId]); - - useEffect( - () => () => { - for (const off of unsubscribersRef.current.values()) { - off(); - } - unsubscribersRef.current.clear(); - }, - [], - ); - - const existingNets: MinimalNetMetadata[] = Object.values(nets).map((net) => ({ - netId: net.id, - title: net.title, - lastUpdated: new Date().toISOString(), - })); - - const createNewNet = (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const id = `net-${Date.now()}`; - const handle = createJsonDocHandle({ - id, - initial: params.petriNetDefinition, - }); - setHandlesByNetId((prev) => ({ - ...prev, - [id]: handle, - })); - setNets((prev) => ({ - ...prev, - [id]: { id, title: params.title, sdcpn: params.petriNetDefinition }, - })); - setCurrentNetId(id); - }; - - const loadPetriNet = (petriNetId: string) => { - setCurrentNetId(petriNetId); - }; - - const setTitle = (title: string) => { - setNets((prev) => { - const net = prev[currentNetId]; - if (!net) { - return prev; - } - return { ...prev, [currentNetId]: { ...net, title } }; - }); - }; - - const currentNet = nets[currentNetId]!; - const handle = handlesByNetId[currentNetId]; - - if (!handle) { - return null; - } - - return ( - <> - - {children} - - ); + const [nets, setNets] = useState>(() => { + const id = "net-1"; + return { + [id]: { id, title: initialTitle, sdcpn: initialDefinition }, + }; + }); + const [currentNetId, setCurrentNetId] = useState("net-1"); + const [handlesByNetId, setHandlesByNetId] = useState(() => ({ + "net-1": createJsonDocHandle({ id: "net-1", initial: initialDefinition }), + })); + + // Track which handles have an active subscription so adding a new net only + // wires up the new handle instead of tearing down every existing one. + const unsubscribersRef = useRef void>>(new Map()); + + useEffect(() => { + for (const handle of Object.values(handlesByNetId)) { + if (unsubscribersRef.current.has(handle.id)) { + continue; + } + const off = handle.subscribe((event) => { + setNets((prev) => { + const stored = prev[handle.id]; + if (!stored) { + return prev; + } + return { ...prev, [handle.id]: { ...stored, sdcpn: event.next } }; + }); + }); + unsubscribersRef.current.set(handle.id, off); + } + }, [handlesByNetId]); + + useEffect( + () => () => { + for (const off of unsubscribersRef.current.values()) { + off(); + } + unsubscribersRef.current.clear(); + }, + [], + ); + + const existingNets: MinimalNetMetadata[] = Object.values(nets).map((net) => ({ + netId: net.id, + title: net.title, + lastUpdated: new Date().toISOString(), + })); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const id = `net-${Date.now()}`; + const handle = createJsonDocHandle({ + id, + initial: params.petriNetDefinition, + }); + setHandlesByNetId((prev) => ({ + ...prev, + [id]: handle, + })); + setNets((prev) => ({ + ...prev, + [id]: { id, title: params.title, sdcpn: params.petriNetDefinition }, + })); + setCurrentNetId(id); + }; + + const loadPetriNet = (petriNetId: string) => { + setCurrentNetId(petriNetId); + }; + + const setTitle = (title: string) => { + setNets((prev) => { + const net = prev[currentNetId]; + if (!net) { + return prev; + } + return { ...prev, [currentNetId]: { ...net, title } }; + }); + }; + + const currentNet = nets[currentNetId]!; + const handle = handlesByNetId[currentNetId]; + + if (!handle) { + return null; + } + + return ( + <> + + {children} + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx index 32194a64dce..75167abefce 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.stories.tsx @@ -1,10 +1,11 @@ +import { useMemo, useState, useEffect } from "react"; + import { sirModel } from "@hashintel/petrinaut-core/examples"; -import { PetrinautStoryProvider } from "./petrinaut-story-provider"; +import { createJsonDocHandle, type SDCPN } from "../main"; import { Petrinaut } from "../ui/petrinaut"; +import { PetrinautStoryProvider } from "./petrinaut-story-provider"; import { createStorybookAiTransport } from "./views/Editor/panels/create-storybook-ai-transport"; -import { createJsonDocHandle, type SDCPN } from "../main"; -import { useMemo, useState, useEffect } from "react"; const emptySDCPN: SDCPN = { places: [], diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 13a027fb6d2..ebe13e18538 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -18,6 +18,7 @@ import { import { PetrinautProvider } from "../react/petrinaut-provider"; import { MonacoProvider } from "./monaco/provider"; import { EditorView } from "./views/Editor/editor-view"; + import type { PetrinautAiMessage, PetrinautAiTransport, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index cf60b859006..751a4760e8f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -4,10 +4,10 @@ import { Icon } from "@hashintel/ds-components"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; import { - formatPlaybackSpeed, - PLAYBACK_SPEEDS, - PlaybackContext, - type PlaybackSpeed, + formatPlaybackSpeed, + PLAYBACK_SPEEDS, + PlaybackContext, + type PlaybackSpeed, } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; import { Button } from "../../../../components/button"; @@ -16,367 +16,367 @@ import { Popover } from "../../../../components/popover"; import { ToolbarButton } from "./toolbar-button"; const contentWidthStyle = css({ - width: "[280px]", + width: "[280px]", }); const menuItemStyle = cva({ - base: { - display: "flex !important", - alignItems: "center", - gap: "2", - width: "[100%]", - minWidth: "[130px]", - height: "[28px]", - paddingX: "2", - borderRadius: "lg", - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", - backgroundColor: "[transparent]", - border: "none", - cursor: "pointer", - textAlign: "left", - _hover: { - backgroundColor: "neutral.s10", - }, - }, - variants: { - selected: { - true: { - backgroundColor: "blue.s20", - _hover: { - backgroundColor: "blue.s20", - }, - }, - }, - disabled: { - true: { - opacity: "[0.4]", - cursor: "not-allowed", - _hover: { - backgroundColor: "[transparent]", - }, - }, - }, - }, + base: { + display: "flex !important", + alignItems: "center", + gap: "2", + width: "[100%]", + minWidth: "[130px]", + height: "[28px]", + paddingX: "2", + borderRadius: "lg", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", + backgroundColor: "[transparent]", + border: "none", + cursor: "pointer", + textAlign: "left", + _hover: { + backgroundColor: "neutral.s10", + }, + }, + variants: { + selected: { + true: { + backgroundColor: "blue.s20", + _hover: { + backgroundColor: "blue.s20", + }, + }, + }, + disabled: { + true: { + opacity: "[0.4]", + cursor: "not-allowed", + _hover: { + backgroundColor: "[transparent]", + }, + }, + }, + }, }); const menuItemIconStyle = css({ - fontSize: "sm", - color: "neutral.s100", - flexShrink: 0, + fontSize: "sm", + color: "neutral.s100", + flexShrink: 0, }); const menuItemTextStyle = css({ - flex: "[1]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + flex: "[1]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); const checkIconStyle = css({ - color: "blue.s50", + color: "blue.s50", }); const speedGridStyle = css({ - display: "grid", - gridTemplateColumns: "repeat(4, 1fr)", - paddingX: "2", - paddingBottom: "1", + display: "grid", + gridTemplateColumns: "repeat(4, 1fr)", + paddingX: "2", + paddingBottom: "1", }); const speedButtonStyle = cva({ - base: { - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "2", - minWidth: "0", - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", - backgroundColor: "[transparent]", - border: "none", - borderRadius: "lg", - cursor: "pointer", - _hover: { - backgroundColor: "neutral.s10", - }, - }, - variants: { - selected: { - true: { - backgroundColor: "blue.s20", - _hover: { - backgroundColor: "blue.s20", - }, - }, - }, - }, + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "2", + minWidth: "0", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", + backgroundColor: "[transparent]", + border: "none", + borderRadius: "lg", + cursor: "pointer", + _hover: { + backgroundColor: "neutral.s10", + }, + }, + variants: { + selected: { + true: { + backgroundColor: "blue.s20", + _hover: { + backgroundColor: "blue.s20", + }, + }, + }, + }, }); const popoverDividerStyle = css({ - height: "[1px]", - backgroundColor: "[transparent]", - marginTop: "1", + height: "[1px]", + backgroundColor: "[transparent]", + marginTop: "1", }); const maxTimeInputStyle = css({ - width: "[60px]", - textAlign: "right", - flexShrink: 0, - fontVariantNumeric: "tabular-nums", + width: "[60px]", + textAlign: "right", + flexShrink: 0, + fontVariantNumeric: "tabular-nums", }); // Split speeds into two rows of 4 const speedRows: PlaybackSpeed[][] = [ - PLAYBACK_SPEEDS.slice(0, 4), - PLAYBACK_SPEEDS.slice(4), + PLAYBACK_SPEEDS.slice(0, 4), + PLAYBACK_SPEEDS.slice(4), ]; export const PlaybackSettingsMenu = () => { - const { - state: simulationState, - maxTime, - setMaxTime, - } = use(SimulationContext); + const { + state: simulationState, + maxTime, + setMaxTime, + } = use(SimulationContext); - const { - playbackSpeed, - playMode, - isViewOnlyAvailable, - isComputeAvailable, - setPlaybackSpeed, - setPlayMode, - } = use(PlaybackContext); + const { + playbackSpeed, + playMode, + isViewOnlyAvailable, + isComputeAvailable, + setPlaybackSpeed, + setPlayMode, + } = use(PlaybackContext); - const hasSimulation = simulationState !== "NotRun"; + const hasSimulation = simulationState !== "NotRun"; - // Derive stopping condition from maxTime - const stoppingCondition: "indefinitely" | "fixed" = - maxTime === null ? "indefinitely" : "fixed"; + // Derive stopping condition from maxTime + const stoppingCondition: "indefinitely" | "fixed" = + maxTime === null ? "indefinitely" : "fixed"; - const handleStoppingConditionChange = ( - condition: "indefinitely" | "fixed", - ) => { - if (condition === "indefinitely") { - setMaxTime(null); - } else { - // Set default of 10 seconds when switching to fixed time - setMaxTime(10); - } - }; + const handleStoppingConditionChange = ( + condition: "indefinitely" | "fixed", + ) => { + if (condition === "indefinitely") { + setMaxTime(null); + } else { + // Set default of 10 seconds when switching to fixed time + setMaxTime(10); + } + }; - return ( - - - - - - - - + return ( + + + + + + + + - - Playback Controls + + Playback Controls - {/* When pressing play section */} - - - When pressing play - - - -
- - + {/* When pressing play section */} + + + When pressing play + + + +
+ + - {/* Playback speed section */} - - - Playback speed - {speedRows.map((row) => ( -
- {row.map((speed) => ( - - ))} -
- ))} -
- - + {/* Playback speed section */} + + + Playback speed + {speedRows.map((row) => ( +
+ {row.map((speed) => ( + + ))} +
+ ))} +
+ + - {/* Stopping conditions section */} - - - Stopping conditions - - -
- - - - - ); + {/* Stopping conditions section */} + + + Stopping conditions + + +
+ + + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 56b995d0762..7b57f43f6ed 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -1,205 +1,205 @@ import { use, useEffect, useEffectEvent } from "react"; +import { + usePetrinautMutations, + usePetrinautCommands, +} from "../../../../../react"; import { EditorContext } from "../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { UndoRedoContext } from "../../../../../react/state/undo-redo-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; import { - copySelectionToClipboard, - pasteFromClipboard, + copySelectionToClipboard, + pasteFromClipboard, } from "../../../../clipboard/clipboard"; import type { - CursorMode, - EditorState, + CursorMode, + EditorState, } from "../../../../../react/state/editor-context"; import type { SelectionItem } from "@hashintel/petrinaut-core"; -import { - usePetrinautMutations, - usePetrinautCommands, -} from "../../../../../react"; type EditorMode = EditorState["globalMode"]; type EditorEditionMode = EditorState["editionMode"]; export function useKeyboardShortcuts( - mode: EditorMode, - onEditionModeChange: (mode: EditorEditionMode) => void, - onCursorModeChange: (mode: CursorMode) => void, + mode: EditorMode, + onEditionModeChange: (mode: EditorEditionMode) => void, + onCursorModeChange: (mode: CursorMode) => void, ) { - const undoRedo = use(UndoRedoContext); - const { - selection, - hasSelection, - clearSelection, - setSelection, - isSearchOpen, - setSearchOpen, - searchInputRef, - } = use(EditorContext); - const { petriNetDefinition, petriNetId } = use(SDCPNContext); - const { deleteItemsByIds } = usePetrinautMutations(); - const { applyClipboardPaste } = usePetrinautCommands(); - const isReadonly = useIsReadOnly(); - - const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { - const target = event.target as HTMLElement; - - const isInputFocused = - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - target.closest(".monaco-editor") !== null || - target.closest("#sentry-feedback") !== null; - - // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. - if ( - undoRedo && - !isInputFocused && - (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === "z" - ) { - event.preventDefault(); - if (event.shiftKey) { - undoRedo.redo(); - } else { - undoRedo.undo(); - } - return; - } - - // Open search with Ctrl/Cmd+F, or focus input if already open. - // Skip when focus is inside Monaco or another input so their native find works. - if ( - !isInputFocused && - (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === "f" - ) { - event.preventDefault(); - if (isSearchOpen) { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - } else { - setSearchOpen(true); - } - return; - } - - // Escape closes search only when the search input itself is focused - if ( - event.key === "Escape" && - isSearchOpen && - document.activeElement === searchInputRef.current - ) { - event.preventDefault(); - searchInputRef.current?.blur(); - setSearchOpen(false); - return; - } - - // Handle copy/paste/select-all shortcuts (Cmd/Ctrl + C/V/A) - if (!isInputFocused && (event.metaKey || event.ctrlKey)) { - const key = event.key.toLowerCase(); - - if (key === "c" && hasSelection) { - event.preventDefault(); - void copySelectionToClipboard( - petriNetDefinition, - selection, - petriNetId, - ); - return; - } - - if (key === "v" && !isReadonly) { - event.preventDefault(); - void pasteFromClipboard(applyClipboardPaste).then((newItemIds) => { - if (newItemIds && newItemIds.length > 0) { - setSelection( - new Map( - newItemIds.map((item) => [item.id, item as SelectionItem]), - ), - ); - } - }); - return; - } - - if (key === "a") { - event.preventDefault(); - const items = new Map(); - for (const place of petriNetDefinition.places) { - items.set(place.id, { type: "place", id: place.id }); - } - for (const transition of petriNetDefinition.transitions) { - items.set(transition.id, { - type: "transition", - id: transition.id, - }); - } - setSelection(items); - return; - } - } - - if (isInputFocused) { - return; - } - - // Delete selected items with Backspace or Delete - if ( - (event.key === "Delete" || event.key === "Backspace") && - !isReadonly && - hasSelection - ) { - event.preventDefault(); - deleteItemsByIds({ items: Array.from(selection.values()) }); - clearSelection(); - return; - } - - // Check that no modifier keys are pressed - if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { - return; - } - - // Switch modes based on key - switch (event.key.toLowerCase()) { - // If escape is pressed, switch to cursor mode (keep current cursor) - case "escape": - event.preventDefault(); - clearSelection(); - onEditionModeChange("cursor"); - break; - case "v": - event.preventDefault(); - onCursorModeChange("select"); - onEditionModeChange("cursor"); - break; - case "h": - event.preventDefault(); - onCursorModeChange("pan"); - onEditionModeChange("cursor"); - break; - case "n": - if (mode === "edit") { - event.preventDefault(); - onEditionModeChange("add-place"); - } - break; - case "t": - if (mode === "edit") { - event.preventDefault(); - onEditionModeChange("add-transition"); - } - break; - } - }); - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); + const undoRedo = use(UndoRedoContext); + const { + selection, + hasSelection, + clearSelection, + setSelection, + isSearchOpen, + setSearchOpen, + searchInputRef, + } = use(EditorContext); + const { petriNetDefinition, petriNetId } = use(SDCPNContext); + const { deleteItemsByIds } = usePetrinautMutations(); + const { applyClipboardPaste } = usePetrinautCommands(); + const isReadonly = useIsReadOnly(); + + const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { + const target = event.target as HTMLElement; + + const isInputFocused = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest(".monaco-editor") !== null || + target.closest("#sentry-feedback") !== null; + + // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. + if ( + undoRedo && + !isInputFocused && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "z" + ) { + event.preventDefault(); + if (event.shiftKey) { + undoRedo.redo(); + } else { + undoRedo.undo(); + } + return; + } + + // Open search with Ctrl/Cmd+F, or focus input if already open. + // Skip when focus is inside Monaco or another input so their native find works. + if ( + !isInputFocused && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "f" + ) { + event.preventDefault(); + if (isSearchOpen) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else { + setSearchOpen(true); + } + return; + } + + // Escape closes search only when the search input itself is focused + if ( + event.key === "Escape" && + isSearchOpen && + document.activeElement === searchInputRef.current + ) { + event.preventDefault(); + searchInputRef.current?.blur(); + setSearchOpen(false); + return; + } + + // Handle copy/paste/select-all shortcuts (Cmd/Ctrl + C/V/A) + if (!isInputFocused && (event.metaKey || event.ctrlKey)) { + const key = event.key.toLowerCase(); + + if (key === "c" && hasSelection) { + event.preventDefault(); + void copySelectionToClipboard( + petriNetDefinition, + selection, + petriNetId, + ); + return; + } + + if (key === "v" && !isReadonly) { + event.preventDefault(); + void pasteFromClipboard(applyClipboardPaste).then((newItemIds) => { + if (newItemIds && newItemIds.length > 0) { + setSelection( + new Map( + newItemIds.map((item) => [item.id, item as SelectionItem]), + ), + ); + } + }); + return; + } + + if (key === "a") { + event.preventDefault(); + const items = new Map(); + for (const place of petriNetDefinition.places) { + items.set(place.id, { type: "place", id: place.id }); + } + for (const transition of petriNetDefinition.transitions) { + items.set(transition.id, { + type: "transition", + id: transition.id, + }); + } + setSelection(items); + return; + } + } + + if (isInputFocused) { + return; + } + + // Delete selected items with Backspace or Delete + if ( + (event.key === "Delete" || event.key === "Backspace") && + !isReadonly && + hasSelection + ) { + event.preventDefault(); + deleteItemsByIds({ items: Array.from(selection.values()) }); + clearSelection(); + return; + } + + // Check that no modifier keys are pressed + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + + // Switch modes based on key + switch (event.key.toLowerCase()) { + // If escape is pressed, switch to cursor mode (keep current cursor) + case "escape": + event.preventDefault(); + clearSelection(); + onEditionModeChange("cursor"); + break; + case "v": + event.preventDefault(); + onCursorModeChange("select"); + onEditionModeChange("cursor"); + break; + case "h": + event.preventDefault(); + onCursorModeChange("pan"); + onEditionModeChange("cursor"); + break; + case "n": + if (mode === "edit") { + event.preventDefault(); + onEditionModeChange("add-place"); + } + break; + case "t": + if (mode === "edit") { + event.preventDefault(); + onEditionModeChange("add-transition"); + } + break; + } + }); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 47a0167f088..80517eea479 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -4,8 +4,8 @@ import { v4 as uuidv4 } from "uuid"; import { Icon } from "@hashintel/ds-components"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "@hashintel/petrinaut-core"; -import { EditorContext } from "../../../../../../react/state/editor-context"; import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; +import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 3b446761f03..b619dc1fcbc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -4,8 +4,8 @@ import { v4 as uuidv4 } from "uuid"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { EditorContext } from "../../../../../../react/state/editor-context"; import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; +import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index c32fa07cd9f..836a76c6c20 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -2,8 +2,8 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; -import { EditorContext } from "../../../../../../react/state/editor-context"; import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; +import { EditorContext } from "../../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx index 0941ac89e6d..23137ed831f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/simulate-view.tsx @@ -4,8 +4,8 @@ import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { - EditorContext, - type SimulateViewMode, + EditorContext, + type SimulateViewMode, } from "../../../../../react/state/editor-context"; import { SegmentGroup } from "../../../../components/segment-group"; import { ExperimentsView } from "./experiments/experiments-view"; @@ -18,77 +18,77 @@ import type { ComponentType } from "react"; // -- Layout styles ------------------------------------------------------------- const containerStyle = css({ - display: "flex", - flexDirection: "row", - width: "full", - height: "full", - backgroundColor: "neutral.s00", + display: "flex", + flexDirection: "row", + width: "full", + height: "full", + backgroundColor: "neutral.s00", }); const sidebarStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[2px]", - padding: "[12px]", - backgroundColor: "neutral.s00", - borderRightWidth: "[1px]", - borderRightStyle: "solid", - borderRightColor: "neutral.s40", - flexShrink: 0, + display: "flex", + flexDirection: "column", + gap: "[2px]", + padding: "[12px]", + backgroundColor: "neutral.s00", + borderRightWidth: "[1px]", + borderRightStyle: "solid", + borderRightColor: "neutral.s40", + flexShrink: 0, }); // -- Mode options -------------------------------------------------------------- const modeOptions: SegmentOption[] = [ - { - value: "scenarios", - label: "Scenarios", - icon: , - hideLabel: true, - tooltip: "Scenarios", - }, - { - value: "metrics", - label: "Metrics", - icon: , - hideLabel: true, - tooltip: "Metrics", - }, - { - value: "experiments", - label: "Experiments", - icon: , - hideLabel: true, - tooltip: "Experiments", - }, + { + value: "scenarios", + label: "Scenarios", + icon: , + hideLabel: true, + tooltip: "Scenarios", + }, + { + value: "metrics", + label: "Metrics", + icon: , + hideLabel: true, + tooltip: "Metrics", + }, + { + value: "experiments", + label: "Experiments", + icon: , + hideLabel: true, + tooltip: "Experiments", + }, ]; const views = { - scenarios: ScenariosView, - metrics: MetricsView, - experiments: ExperimentsView, + scenarios: ScenariosView, + metrics: MetricsView, + experiments: ExperimentsView, } satisfies Record; // -- Component ----------------------------------------------------------------- export const SimulateView = () => { - const { simulateViewMode: mode, setSimulateViewMode: setMode } = - use(EditorContext); - const ActiveView = views[mode]; + const { simulateViewMode: mode, setSimulateViewMode: setMode } = + use(EditorContext); + const ActiveView = views[mode]; - return ( -
-
- setMode(value as SimulateViewMode)} - orientation="vertical" - size="sm" - /> -
+ return ( +
+
+ setMode(value as SimulateViewMode)} + orientation="vertical" + size="sm" + /> +
- -
- ); + +
+ ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index be39a139e62..ddf204092d8 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -305,8 +305,7 @@ export const AiAssistantPanel = ({ if (toolCall.toolName === getNetCompilationErrorsToolName) { await waitForDiagnosticsRefresh({ consumePendingMutationDiagnosticsVersion: () => { - const pendingVersion = - pendingMutationDiagnosticsVersionRef.current; + const pendingVersion = pendingMutationDiagnosticsVersionRef.current; pendingMutationDiagnosticsVersionRef.current = null; return pendingVersion; }, @@ -346,8 +345,7 @@ export const AiAssistantPanel = ({ applied: true, title: `Renamed net to "${parsedSetNetTitleInput.title}"`, detail: - previousTitle && - previousTitle !== parsedSetNetTitleInput.title + previousTitle && previousTitle !== parsedSetNetTitleInput.title ? `Previous title: ${previousTitle}` : undefined, } satisfies AiToolOutput, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx index 58576314e7a..c4d3cd99703 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx @@ -1,8 +1,9 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; import { AiAssistantSurface } from "./ai-assistant-surface"; + import type { PetrinautAiMessage } from "./types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; const meta = { title: "Editor / AI Assistant", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx index 8d05a26477f..b90298a559c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.test.tsx @@ -11,6 +11,7 @@ import { import { afterEach, describe, expect, test, vi } from "vitest"; import { AiAssistantSurface } from "./ai-assistant-surface"; + import type { PetrinautAiMessage } from "./types"; const noop = () => {}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx index f29d6091e6c..4a046c6c89e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx @@ -956,9 +956,7 @@ const getMessagesScrollKey = (messages: PetrinautAiMessage[]): string => return `${part.type}:${part.state ?? ""}:${part.text}`; } - return "state" in part - ? `${part.type}:${part.state}` - : part.type; + return "state" in part ? `${part.type}:${part.state}` : part.type; }) .join(","), ].join(":"), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts index b6d64dd350c..3d622c2ba63 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts @@ -1,8 +1,9 @@ -import type { UIMessageChunk } from "ai"; import { describe, expect, test, vi } from "vitest"; import { createDiagnosticsAwareAiTransport } from "./create-diagnostics-aware-ai-transport"; + import type { PetrinautAiMessage, PetrinautAiTransport } from "./types"; +import type { UIMessageChunk } from "ai"; const emptyStream = (): ReadableStream => new ReadableStream({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts index 0f6f88e9a6c..d44a0ae0c85 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts @@ -1,6 +1,5 @@ -import type { ChatTransport } from "ai"; - import type { PetrinautAiMessage, PetrinautAiTransport } from "./types"; +import type { ChatTransport } from "ai"; const diagnosticsContextMessageId = "petrinaut-diagnostics-context"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts index eea45bcfcce..45e72d55820 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts @@ -1,4 +1,3 @@ -import type { SDCPN } from "@hashintel/petrinaut-core"; import { describe, expect, test } from "vitest"; import { type Diagnostic, @@ -7,6 +6,8 @@ import { import { formatDiagnosticsForAi } from "./format-diagnostics-for-ai"; +import type { SDCPN } from "@hashintel/petrinaut-core"; + const definition: SDCPN = { places: [], transitions: [ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts index 2b1d5bffe8f..ca313681623 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts @@ -1,6 +1,8 @@ +import { DiagnosticSeverity } from "vscode-languageserver-types"; + import { parseDocumentUri, type SDCPN } from "@hashintel/petrinaut-core"; + import type { Diagnostic, DocumentUri } from "vscode-languageserver-types"; -import { DiagnosticSeverity } from "vscode-languageserver-types"; const DEFAULT_MAX_DIAGNOSTICS = 25; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx index 6d6fd794142..4bf09fa10ed 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx @@ -4,9 +4,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; -import type { AiToolOutput } from "../tool-summaries"; import { applyAutoLayoutInteractiveTool } from "./apply-auto-layout-widget"; +import type { AiToolOutput } from "../tool-summaries"; + const Widget = applyAutoLayoutInteractiveTool.Widget; afterEach(cleanup); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx index 2228da89ffc..554834f0efb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx @@ -1,11 +1,12 @@ +import { css } from "@hashintel/ds-helpers/css"; import { aiCommandActionInputSchemas, type AiCommandActionInput, type AiCommandActionName, } from "@hashintel/petrinaut-core"; -import { css } from "@hashintel/ds-helpers/css"; import { Button } from "../../../../../components/button"; + import type { AiToolOutput } from "../tool-summaries"; import type { InteractiveToolDefinition, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts index e9baf8a6133..dbec90871c0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts @@ -1,5 +1,6 @@ -import type { AiToolOutput } from "../tool-summaries"; import { applyAutoLayoutInteractiveTool } from "./apply-auto-layout-widget"; + +import type { AiToolOutput } from "../tool-summaries"; import type { InteractiveToolDefinition } from "./types"; /** diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts index 15ea4b50bbb..485da294a7a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts @@ -1,8 +1,9 @@ -import type { SDCPN } from "@hashintel/petrinaut-core"; import { describe, expect, test } from "vitest"; import { summarizePetrinautAiToolCall } from "./tool-summaries"; +import type { SDCPN } from "@hashintel/petrinaut-core"; + const definition: SDCPN = { differentialEquations: [], parameters: [], diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts index c0cbeffc45f..1ba966247f6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -1,3 +1,4 @@ +import type { AiToolOutput } from "./tool-summaries"; import type { getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, @@ -11,8 +12,6 @@ import type { } from "@hashintel/petrinaut-core"; import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; -import type { AiToolOutput } from "./tool-summaries"; - type PetrinautAiUiTools = { [Name in PetrinautAiMutationToolName]: { input: PetrinautAiMutationToolInput; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts index 4bb573943bc..f4cab31231a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts @@ -1,6 +1,5 @@ -import type { ChatTransport, UIMessageChunk } from "ai"; - import type { PetrinautAiMessage } from "./ai-assistant-panel"; +import type { ChatTransport, UIMessageChunk } from "ai"; const placeInput = { id: "place__ai_buffer", diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts index 34b249ceea2..f015cc88923 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts @@ -1,7 +1,7 @@ import { use } from "react"; -import { EditorContext } from "../../../../react/state/editor-context"; import { usePetrinautMutations } from "../../../../react/hooks/use-petrinaut-mutations"; +import { EditorContext } from "../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { snapPositionToGrid } from "../../../lib/snap-position-to-grid"; diff --git a/package.json b/package.json index 6aef9f5ad72..572315ed43f 100644 --- a/package.json +++ b/package.json @@ -1,116 +1,116 @@ { - "name": "hash", - "version": "0.0.0-private", - "private": true, - "description": "HASH monorepo", - "repository": { - "type": "git", - "url": "https://github.com/hashintel/hash.git" - }, - "workspaces": { - "packages": [ - "!**/node_modules", - "!**/pkg", - ".claude/hooks", - "apps/**", - "libs/**", - "tests/**" - ] - }, - "scripts": { - "agents:skill-management": "yarn workspace @local/repo-chores exe scripts/skill-management.ts", - "agents:symlink-rules": "yarn workspace @local/repo-chores exe scripts/symlink-agent-rules.ts", - "bench": "npm-run-all --continue-on-error \"bench:*\"", - "bench:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:integration --env-mode=loose --", - "bench:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:unit --env-mode=loose --", - "changeset:publish": "yarn changeset:resolve-workspace-ranges && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn && yarn changeset publish", - "changeset:resolve-workspace-ranges": "node scripts/resolve-workspace-ranges.mjs", - "changeset:version": "yarn changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn", - "codegen": "CARGO_TERM_PROGRESS_WHEN=never turbo codegen", - "create-block": "yarn workspace @local/repo-chores exe scripts/create-block.ts", - "dev": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --filter '@apps/hash-frontend' --", - "dev:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", - "dev:backend:api": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", - "dev:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-frontend' --", - "external-services": "turbo deploy --filter '@apps/hash-external-services' --", - "external-services:offline": "turbo deploy:offline --filter '@apps/hash-external-services' --", - "external-services:prod": "turbo deploy:prod --filter '@apps/hash-external-services' --", - "external-services:test": "turbo deploy:test --filter '@apps/hash-external-services' --", - "fix": "npm-run-all --continue-on-error \"fix:*\"", - "fix:constraints": "yarn constraints --fix", - "fix:eslint": "mise run fix:eslint", - "fix:format": "oxfmt --write", - "fix:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2 --fix", - "fix:taplo": "taplo fmt", - "fix:yarn-deduplicate": "yarn dedupe --strategy highest", - "generate-ontology-type-ids": "yarn workspace @apps/hash-api generate-ontology-type-ids", - "generate-system-types": "yarn workspace @local/hash-isomorphic-utils generate-system-types", - "graph:reset-database": "yarn workspace @rust/hash-graph-http-tests reset-database", - "lint": "npm-run-all --continue-on-error \"lint:*\"", - "lint:constraints": "yarn constraints", - "lint:eslint": "CARGO_TERM_PROGRESS_WHEN=never turbo --continue=always lint:eslint --", - "lint:format": "oxfmt --check", - "lint:license-in-workspaces": "yarn workspace @local/repo-chores exe scripts/check-license-in-workspaces.ts", - "lint:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2", - "lint:skill": "yarn agents:skill-management validate", - "lint:taplo": "taplo fmt --check", - "lint:tsc": "mise run lint:tsc", - "lint:yarn-deduplicate": "yarn dedupe --strategy highest --check", - "postinstall": "CARGO_TERM_PROGRESS_WHEN=never turbo run postinstall", - "prune-node-modules": "find . -type d -name \"node_modules\" -exec rm -rf {} +", - "start": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --filter @apps/hash-api --filter @apps/hash-ai-worker-ts --filter @apps/hash-integration-worker --filter @apps/hash-frontend --env-mode=loose", - "start:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-api --env-mode=loose", - "start:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-frontend --env-mode=loose", - "start:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --env-mode=loose", - "start:test": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --env-mode=loose", - "start:test:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-api --env-mode=loose", - "start:test:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-frontend --env-mode=loose", - "start:test:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-graph --env-mode=loose", - "start:test:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", - "start:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", - "test": "npm-run-all --continue-on-error \"test:*\"", - "test:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --", - "test:playwright": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --filter @tests/hash-playwright --", - "test:stale-approvals": "bash .github/actions/dismiss-stale-approvals/self-test.sh", - "test:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:unit --env-mode=loose --" - }, - "devDependencies": { - "@changesets/changelog-github": "0.5.1", - "@changesets/cli": "patch:@changesets/cli@npm%3A2.30.0#~/.yarn/patches/@changesets-cli-npm-2.30.0-83a4e8887c.patch", - "@local/claude-hooks": "workspace:*", - "@yarnpkg/types": "^4.0.1", - "lefthook": "2.0.0", - "npm-run-all2": "8.0.4", - "oxfmt": "0.50.0" - }, - "resolutions": { - "@artilleryio/int-commons@npm:2.11.0": "patch:@artilleryio/int-commons@npm%3A2.11.0#~/.yarn/patches/@artilleryio-int-commons-npm-2.11.0-5b69c05121.patch", - "@changesets/assemble-release-plan@npm:^6.0.9": "patch:@changesets/assemble-release-plan@npm%3A6.0.9#~/.yarn/patches/@changesets-assemble-release-plan-npm-6.0.9-e01af97ef4.patch", - "@playwright/test": "1.58.2", - "@tldraw/editor@npm:2.0.0-alpha.12": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", - "@tldraw/tlschema@npm:2.0.0-alpha.12": "patch:@tldraw/tlschema@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-tlschema-npm-2.0.0-alpha.12-13bf88407b.patch", - "blockprotocol@npm:0.0.10": "patch:blockprotocol@npm%3A0.0.12#~/.yarn/patches/blockprotocol-npm-0.0.12-2558a31f0a.patch", - "canvas": "3.2.0", - "dompurify": "3.4.0", - "fast-xml-parser": "5.7.0", - "http-proxy-middleware@npm:^2.0.9": "patch:http-proxy-middleware@npm%3A3.0.5#~/.yarn/patches/http-proxy-middleware-npm-3.0.5-5c57f2e983.patch", - "jsondiffpatch": "0.7.2", - "lodash": "4.18.1", - "next/postcss": "8.5.10", - "prosemirror-model@npm:>=1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.16.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.20.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-model@npm:^1.21.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", - "prosemirror-view@npm:^1.1.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", - "prosemirror-view@npm:^1.27.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", - "qs": "6.14.2", - "react": "19.2.6", - "react-dom": "19.2.6", - "underscore": "1.13.8" - }, - "engines": { - "node": ">= v22" - }, - "packageManager": "yarn@4.12.0" + "name": "hash", + "version": "0.0.0-private", + "private": true, + "description": "HASH monorepo", + "repository": { + "type": "git", + "url": "https://github.com/hashintel/hash.git" + }, + "workspaces": { + "packages": [ + "!**/node_modules", + "!**/pkg", + ".claude/hooks", + "apps/**", + "libs/**", + "tests/**" + ] + }, + "scripts": { + "agents:skill-management": "yarn workspace @local/repo-chores exe scripts/skill-management.ts", + "agents:symlink-rules": "yarn workspace @local/repo-chores exe scripts/symlink-agent-rules.ts", + "bench": "npm-run-all --continue-on-error \"bench:*\"", + "bench:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:integration --env-mode=loose --", + "bench:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run bench:unit --env-mode=loose --", + "changeset:publish": "yarn changeset:resolve-workspace-ranges && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn && yarn changeset publish", + "changeset:resolve-workspace-ranges": "node scripts/resolve-workspace-ranges.mjs", + "changeset:version": "yarn changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn", + "codegen": "CARGO_TERM_PROGRESS_WHEN=never turbo codegen", + "create-block": "yarn workspace @local/repo-chores exe scripts/create-block.ts", + "dev": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --filter '@apps/hash-frontend' --", + "dev:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", + "dev:backend:api": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", + "dev:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-frontend' --", + "external-services": "turbo deploy --filter '@apps/hash-external-services' --", + "external-services:offline": "turbo deploy:offline --filter '@apps/hash-external-services' --", + "external-services:prod": "turbo deploy:prod --filter '@apps/hash-external-services' --", + "external-services:test": "turbo deploy:test --filter '@apps/hash-external-services' --", + "fix": "npm-run-all --continue-on-error \"fix:*\"", + "fix:constraints": "yarn constraints --fix", + "fix:eslint": "mise run fix:eslint", + "fix:format": "oxfmt --write", + "fix:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2 --fix", + "fix:taplo": "taplo fmt", + "fix:yarn-deduplicate": "yarn dedupe --strategy highest", + "generate-ontology-type-ids": "yarn workspace @apps/hash-api generate-ontology-type-ids", + "generate-system-types": "yarn workspace @local/hash-isomorphic-utils generate-system-types", + "graph:reset-database": "yarn workspace @rust/hash-graph-http-tests reset-database", + "lint": "npm-run-all --continue-on-error \"lint:*\"", + "lint:constraints": "yarn constraints", + "lint:eslint": "CARGO_TERM_PROGRESS_WHEN=never turbo --continue=always lint:eslint --", + "lint:format": "oxfmt --check", + "lint:license-in-workspaces": "yarn workspace @local/repo-chores exe scripts/check-license-in-workspaces.ts", + "lint:markdownlint": "mise exec --env dev markdownlint-cli2 -- markdownlint-cli2", + "lint:skill": "yarn agents:skill-management validate", + "lint:taplo": "taplo fmt --check", + "lint:tsc": "mise run lint:tsc", + "lint:yarn-deduplicate": "yarn dedupe --strategy highest --check", + "postinstall": "CARGO_TERM_PROGRESS_WHEN=never turbo run postinstall", + "prune-node-modules": "find . -type d -name \"node_modules\" -exec rm -rf {} +", + "start": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --filter @apps/hash-api --filter @apps/hash-ai-worker-ts --filter @apps/hash-integration-worker --filter @apps/hash-frontend --env-mode=loose", + "start:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-api --env-mode=loose", + "start:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-frontend --env-mode=loose", + "start:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter @apps/hash-graph --env-mode=loose", + "start:test": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --env-mode=loose", + "start:test:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-api --env-mode=loose", + "start:test:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-frontend --env-mode=loose", + "start:test:graph": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter @apps/hash-graph --env-mode=loose", + "start:test:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start:test start:test:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", + "start:worker": "CARGO_TERM_PROGRESS_WHEN=never turbo run start start:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", + "test": "npm-run-all --continue-on-error \"test:*\"", + "test:integration": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --", + "test:playwright": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:integration --env-mode=loose --filter @tests/hash-playwright --", + "test:stale-approvals": "bash .github/actions/dismiss-stale-approvals/self-test.sh", + "test:unit": "CARGO_TERM_PROGRESS_WHEN=never turbo run test:unit --env-mode=loose --" + }, + "devDependencies": { + "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "patch:@changesets/cli@npm%3A2.30.0#~/.yarn/patches/@changesets-cli-npm-2.30.0-83a4e8887c.patch", + "@local/claude-hooks": "workspace:*", + "@yarnpkg/types": "^4.0.1", + "lefthook": "2.0.0", + "npm-run-all2": "8.0.4", + "oxfmt": "0.50.0" + }, + "resolutions": { + "@artilleryio/int-commons@npm:2.11.0": "patch:@artilleryio/int-commons@npm%3A2.11.0#~/.yarn/patches/@artilleryio-int-commons-npm-2.11.0-5b69c05121.patch", + "@changesets/assemble-release-plan@npm:^6.0.9": "patch:@changesets/assemble-release-plan@npm%3A6.0.9#~/.yarn/patches/@changesets-assemble-release-plan-npm-6.0.9-e01af97ef4.patch", + "@playwright/test": "1.58.2", + "@tldraw/editor@npm:2.0.0-alpha.12": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", + "@tldraw/tlschema@npm:2.0.0-alpha.12": "patch:@tldraw/tlschema@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-tlschema-npm-2.0.0-alpha.12-13bf88407b.patch", + "blockprotocol@npm:0.0.10": "patch:blockprotocol@npm%3A0.0.12#~/.yarn/patches/blockprotocol-npm-0.0.12-2558a31f0a.patch", + "canvas": "3.2.0", + "dompurify": "3.4.0", + "fast-xml-parser": "5.7.0", + "http-proxy-middleware@npm:^2.0.9": "patch:http-proxy-middleware@npm%3A3.0.5#~/.yarn/patches/http-proxy-middleware-npm-3.0.5-5c57f2e983.patch", + "jsondiffpatch": "0.7.2", + "lodash": "4.18.1", + "next/postcss": "8.5.10", + "prosemirror-model@npm:>=1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.0.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.16.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.20.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-model@npm:^1.21.0": "patch:prosemirror-model@npm%3A1.18.2#~/.yarn/patches/prosemirror-model-npm-1.18.2-479d845b52.patch", + "prosemirror-view@npm:^1.1.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", + "prosemirror-view@npm:^1.27.0": "patch:prosemirror-view@npm%3A1.29.1#~/.yarn/patches/prosemirror-view-npm-1.29.1-ff37db4eea.patch", + "qs": "6.14.2", + "react": "19.2.6", + "react-dom": "19.2.6", + "underscore": "1.13.8" + }, + "engines": { + "node": ">= v22" + }, + "packageManager": "yarn@4.12.0" } From 674b767a5f2f0aa5fa7ec269e9c085f225a6bcda Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Sat, 23 May 2026 12:00:37 +0100 Subject: [PATCH 11/21] fix chat server function in vercel --- apps/petrinaut-website/api/chat.ts | 15 +++++++++++++-- apps/petrinaut-website/package.json | 1 + apps/petrinaut-website/vite.config.ts | 20 +++++++++++++------- libs/@hashintel/petrinaut-core/src/ai.ts | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts index 1b1d0fbfb3e..016095f91f8 100644 --- a/apps/petrinaut-website/api/chat.ts +++ b/apps/petrinaut-website/api/chat.ts @@ -48,6 +48,7 @@ const logChatFailure = ( reason: string, context: Record = {}, ) => { + // oxlint-disable-next-line no-console console.error(`[Petrinaut AI] ${reason}`, context); }; @@ -111,8 +112,16 @@ const checkRateLimit = (clientIp: string): boolean => { /** * API endpoint to proxy requests for AI assistance to OpenAI. + * + * Exported via a default `{ fetch }` object so Vercel's Node.js runtime treats + * this as a Web fetch handler and hands us a `Request`. Without this opt-in, + * the default export is invoked with a Node.js `IncomingMessage`, whose + * `headers` is a plain object (no `.get(...)` method) and would crash + * `resolveClientIp`. + * + * See https://vercel.com/changelog/node-js-vercel-functions-now-support-fetch-web-handlers */ -export default async function handler(request: Request): Promise { +const fetch = async (request: Request): Promise => { if (request.method === "OPTIONS") { // We'll always serve this same-origin so we don't need any CORS config return new Response(null, { status: 204 }); @@ -201,4 +210,6 @@ export default async function handler(request: Request): Promise { }); return result.toUIMessageStreamResponse({ sendReasoning: true }); -} +}; + +export default { fetch }; diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index c4488aab8b4..4ce1a11b54d 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -15,6 +15,7 @@ "@hashintel/ds-components": "workspace:*", "@hashintel/ds-helpers": "workspace:*", "@hashintel/petrinaut": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", "@mantine/hooks": "8.3.5", "@pandacss/dev": "1.11.1", "@sentry/react": "10.22.0", diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index 147814dc9a0..e5f0e39aec8 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -25,16 +25,16 @@ const petrinautApiDevPlugin = (): Plugin => ({ name: "petrinaut-api-dev", apply: "serve", configureServer(server) { + // The chat endpoint ships a default `{ fetch }` so Vercel's Node.js + // runtime treats it as a Web fetch handler in production. We mirror the + // same shape here so dev and prod hit the same code path. const adapter = createServerAdapter(async (request) => { - const apiModule = await server.ssrLoadModule("/api/chat.ts"); - const handler = (apiModule as { default?: unknown }).default; - - if (typeof handler !== "function") { - throw new Error("Expected /api/chat.ts to export a default handler."); - } + const { default: api } = (await server.ssrLoadModule( + "/api/chat.ts", + )) as { default: { fetch: (request: Request) => Promise } }; try { - return await (handler as (req: Request) => Promise)(request); + return await api.fetch(request); } catch (error) { server.ssrFixStacktrace(error as Error); throw error; @@ -68,6 +68,11 @@ export default defineConfig(({ mode }) => { cssMinify: "esbuild" as const, }, + server: { + /** vercel dev will provide a PORT to run on */ + port: process.env.PORT ? Number(process.env.PORT) : 5173, + }, + plugins: [ petrinautApiDevPlugin(), react(), @@ -85,6 +90,7 @@ export default defineConfig(({ mode }) => { }), ], }), + ], }; }); diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index d4428e39e9b..52f2951e8a8 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -90,7 +90,7 @@ const getLatestNetDefinitionToolInputSchema = z const getNetCompilationErrorsToolInputSchema = z .strictObject({}) .describe( - "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after editing lambdas, kernels, differential equations, scenarios, or metrics to check whether the model compiles.", + "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after the net to check whether the model compiles.", ); export const setNetTitleToolInputSchema = z From 29e710550cf35d1db135027b81c38c3d144094a0 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Sat, 23 May 2026 12:02:03 +0100 Subject: [PATCH 12/21] yarn --- yarn.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index a23135c5d66..862a66c8a6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -846,6 +846,7 @@ __metadata: "@hashintel/ds-components": "workspace:*" "@hashintel/ds-helpers": "workspace:*" "@hashintel/petrinaut": "workspace:*" + "@hashintel/petrinaut-core": "workspace:*" "@mantine/hooks": "npm:8.3.5" "@pandacss/dev": "npm:1.11.1" "@rolldown/plugin-babel": "npm:0.2.1" @@ -7714,7 +7715,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/petrinaut-core@workspace:^, @hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core": +"@hashintel/petrinaut-core@workspace:*, @hashintel/petrinaut-core@workspace:^, @hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core": version: 0.0.0-use.local resolution: "@hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core" dependencies: From 66dbb43611281c9080fafebfd3e81bfc26d78b03 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Sat, 23 May 2026 12:08:21 +0100 Subject: [PATCH 13/21] move state deeper --- .../views/Editor/components/ai-cta-modal.tsx | 46 +++++++++---------- .../src/ui/views/Editor/editor-view.tsx | 14 +++--- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx index eaefb604c0f..6c1a778e2dc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -6,7 +6,7 @@ import { css } from "@hashintel/ds-helpers/css"; import { AiAssistantIcon } from "../../../components/ai-assistant-icon"; import { Input } from "../../../components/input"; -const emptyAiHeroLayerStyle = css({ +const aiCtaModalLayerStyle = css({ position: "absolute", inset: "0", zIndex: 20, @@ -17,7 +17,7 @@ const emptyAiHeroLayerStyle = css({ pointerEvents: "none", }); -const emptyAiHeroStyle = css({ +const aiCtaModalStyle = css({ pointerEvents: "auto", display: "flex", flexDirection: "column", @@ -37,7 +37,7 @@ const emptyAiHeroStyle = css({ backdropFilter: "[blur(14px)]", }); -const emptyAiHeroIconStyle = css({ +const aiCtaModalIconStyle = css({ display: "flex", alignItems: "center", justifyContent: "center", @@ -49,14 +49,14 @@ const emptyAiHeroIconStyle = css({ color: "blue.s90", }); -const emptyAiHeroCopyStyle = css({ +const aiCtaModalCopyStyle = css({ display: "flex", flexDirection: "column", gap: "2", maxWidth: "[420px]", }); -const emptyAiHeroTitleStyle = css({ +const aiCtaModalTitleStyle = css({ margin: "0", color: "neutral.s110", fontFamily: "[Inter Tight, Inter, sans-serif]", @@ -65,7 +65,7 @@ const emptyAiHeroTitleStyle = css({ lineHeight: "[30px]", }); -const emptyAiHeroFormStyle = css({ +const aiCtaModalFormStyle = css({ display: "flex", alignItems: "center", gap: "2", @@ -77,7 +77,7 @@ const emptyAiHeroFormStyle = css({ "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", }); -const emptyAiHeroInputStyle = css({ +const aiCtaModalInputStyle = css({ flex: "[1]", minWidth: "[0]", height: "[48px]", @@ -98,18 +98,16 @@ const emptyAiHeroInputStyle = css({ }, }); -export const EmptyAiHero = ({ +export const AiCtaModal = ({ bottomClearance, - input, - onInputChange, onSubmit, }: { bottomClearance: number; - input: string; - onInputChange: (value: string) => void; onSubmit: (message: string) => void; }) => { - const canSubmit = input.trim().length > 0; + const [promptInput, setPromptInput] = useState(""); + + const canSubmit = promptInput.trim().length > 0; const inputRef = useRef(null); useEffect(() => { @@ -117,12 +115,12 @@ export const EmptyAiHero = ({ }, []); return ( -
+
{ event.preventDefault(); - const trimmedInput = input.trim(); + const trimmedInput = promptInput.trim(); if (!trimmedInput) { return; } @@ -130,20 +128,20 @@ export const EmptyAiHero = ({ onSubmit(trimmedInput); }} > -
+
-
-

+
+

Describe the process you want to create

-
+
onInputChange(event.currentTarget.value)} + className={aiCtaModalInputStyle} + value={promptInput} + onChange={(event) => setPromptInput(event.currentTarget.value)} placeholder="e.g. Model an SIR outbreak with recovery" aria-label="Describe the process you want to create" size="lg" diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 3a5778defd1..57e535e67ff 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -28,7 +28,7 @@ import { compactNodeDimensions, } from "../SDCPN/node-dimensions"; import { SDCPNView } from "../SDCPN/sdcpn-view"; -import { EmptyAiHero } from "./components/ai-cta-modal"; +import { AiCtaModal } from "./components/ai-cta-modal"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { ImportErrorDialog } from "./components/import-error-dialog"; import { TopBar } from "./components/TopBar/top-bar"; @@ -143,13 +143,13 @@ export const EditorView = ({ } = use(EditorContext); const { setSelectedExperimentId } = use(ExperimentsContext); - const { compactNodes } = use(UserSettingsContext); - const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; - const [emptyAiPromptInput, setEmptyAiPromptInput] = useState(""); const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< string | null >(null); + const { compactNodes } = use(UserSettingsContext); + const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; + const [importError, setImportError] = useState(null); // Clean up stale selections when items are deleted @@ -371,6 +371,7 @@ export const EditorView = ({ ]; const portalContainerRef = useRef(null); + const showEmptyAiHero = aiAssistant !== undefined && !isAiAssistantOpen && @@ -420,12 +421,9 @@ export const EditorView = ({ {showEmptyAiHero && ( - { - setEmptyAiPromptInput(""); setPendingAiAssistantMessage(message); setAiAssistantOpen(true); }} From 15294c4a08d131ec434e86f492e9b5aa99b4cc41 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 25 May 2026 11:35:06 +0100 Subject: [PATCH 14/21] better assistant message part UX, error handling. file organisation, cleanup --- apps/petrinaut-website/README.md | 3 +- apps/petrinaut-website/api/chat.ts | 12 +- apps/petrinaut-website/package.json | 3 +- apps/petrinaut-website/vercel.json | 3 +- apps/petrinaut-website/vite.config.ts | 11 +- .../src/components/Icon/icon.tsx | 2 + .../svgs/regular/arrow-right-arrow-left.svg | 1 + libs/@hashintel/ds-components/src/preset.ts | 8 + .../petrinaut-core/src/actions.test.ts | 10 +- libs/@hashintel/petrinaut-core/src/ai.ts | 17 - libs/@hashintel/petrinaut-core/src/index.ts | 2 - .../src/layout/calculate-graph-layout.ts | 4 + .../Editor/panels/ai-assistant-panel.tsx | 85 +- ....tsx => ai-assistant-contents.stories.tsx} | 4 +- ...est.tsx => ai-assistant-contents.test.tsx} | 43 +- .../ai-assistant-contents.tsx | 578 +++++++ .../get-active-phase-label.ts | 52 + .../get-message-render-items.ts | 87 + .../message-status-footer.tsx | 90 + .../ai-assistant-contents/reasoning.tsx | 256 +++ .../shared/collapsible-content-style.ts | 13 + .../shared/markdown-style.ts | 98 ++ .../shared/streaming-ellipsis.tsx | 34 + .../ai-assistant-contents/tool-list.tsx | 635 ++++++++ .../ai-assistant-surface.tsx | 1448 ----------------- ...ate-reasoning-timing-aware-ai-transport.ts | 68 + .../Editor/panels/ai-assistant-panel/types.ts | 19 + 27 files changed, 2066 insertions(+), 1520 deletions(-) create mode 100644 libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/{ai-assistant-surface.stories.tsx => ai-assistant-contents.stories.tsx} (98%) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/{ai-assistant-surface.test.tsx => ai-assistant-contents.test.tsx} (94%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/collapsible-content-style.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/markdown-style.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index c39572abd18..fe771f3f03e 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -47,8 +47,7 @@ npx vercel dev # builds + serves on http://localhost:3000 Notes: -- `vercel dev` runs the commands in [`vercel.json`](vercel.json), including [`vercel-build.sh`](vercel-build.sh). That script deletes the repo-root `.env` to work around mise picking it up - so do not keep anything you cannot regenerate there before running this locally. -- `vercel dev` does not read your existing `dist/`; it rebuilds. If you specifically need to inspect the artifact you already produced, use option B. +- `vercel dev` does not read your existing `dist/`; it rebuilds. If you specifically need to inspect the artifact you already produced, use option B (or amend the devCommand in vercel.json to remove the build step). ### Option B: `vite preview` + a sibling Node API server diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts index 016095f91f8..b6a20e1bd79 100644 --- a/apps/petrinaut-website/api/chat.ts +++ b/apps/petrinaut-website/api/chat.ts @@ -209,7 +209,17 @@ const fetch = async (request: Request): Promise => { }, }); - return result.toUIMessageStreamResponse({ sendReasoning: true }); + // `streamText`'s own `onError` only logs server-side — the + // `toUIMessageStreamResponse` `onError` is what propagates a visible error + // chunk to the client so `useChat` can surface a failure instead of just + // quietly transitioning the status back to `"ready"` on a truncated stream. + return result.toUIMessageStreamResponse({ + sendReasoning: true, + onError: (error) => { + logChatFailure("AI response error", { error }); + return error instanceof Error ? error.message : "AI request failed"; + }, + }); }; export default { fetch }; diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 4ce1a11b54d..87e230614d2 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -8,7 +8,8 @@ "dev": "vite", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit" + "lint:tsc": "tsgo --noEmit", + "preview": "vite preview" }, "dependencies": { "@ai-sdk/openai": "3.0.63", diff --git a/apps/petrinaut-website/vercel.json b/apps/petrinaut-website/vercel.json index ea65bdf6b90..55c31b0694f 100644 --- a/apps/petrinaut-website/vercel.json +++ b/apps/petrinaut-website/vercel.json @@ -5,10 +5,11 @@ }, "buildCommand": "./vercel-build.sh", "installCommand": "./vercel-install.sh", + "devCommand": "turbo run build && yarn preview", "outputDirectory": "./dist", "functions": { "api/chat.ts": { - "maxDuration": 60 + "maxDuration": 300 } } } diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index e5f0e39aec8..96fcef1331e 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -29,9 +29,9 @@ const petrinautApiDevPlugin = (): Plugin => ({ // runtime treats it as a Web fetch handler in production. We mirror the // same shape here so dev and prod hit the same code path. const adapter = createServerAdapter(async (request) => { - const { default: api } = (await server.ssrLoadModule( - "/api/chat.ts", - )) as { default: { fetch: (request: Request) => Promise } }; + const { default: api } = (await server.ssrLoadModule("/api/chat.ts")) as { + default: { fetch: (request: Request) => Promise }; + }; try { return await api.fetch(request); @@ -68,9 +68,9 @@ export default defineConfig(({ mode }) => { cssMinify: "esbuild" as const, }, - server: { + preview: { /** vercel dev will provide a PORT to run on */ - port: process.env.PORT ? Number(process.env.PORT) : 5173, + port: process.env.PORT ? Number(process.env.PORT) : 4173, }, plugins: [ @@ -90,7 +90,6 @@ export default defineConfig(({ mode }) => { }), ], }), - ], }; }); diff --git a/libs/@hashintel/ds-components/src/components/Icon/icon.tsx b/libs/@hashintel/ds-components/src/components/Icon/icon.tsx index 09d35e08097..0a57e06a266 100644 --- a/libs/@hashintel/ds-components/src/components/Icon/icon.tsx +++ b/libs/@hashintel/ds-components/src/components/Icon/icon.tsx @@ -9,6 +9,7 @@ import ArrowDownWideShort from "./svgs/regular/arrow-down-wide-short.svg"; import ArrowDown from "./svgs/regular/arrow-down.svg"; import ArrowLeft from "./svgs/regular/arrow-left.svg"; import ArrowPointer from "./svgs/regular/arrow-pointer.svg"; +import ArrowRightArrowLeft from "./svgs/regular/arrow-right-arrow-left.svg"; import ArrowRightToLine from "./svgs/regular/arrow-right-to-line.svg"; import ArrowRight from "./svgs/regular/arrow-right.svg"; import ArrowRotateLeft from "./svgs/regular/arrow-rotate-left.svg"; @@ -128,6 +129,7 @@ const IconMap = { arrowRight: ArrowRight, arrowUp: ArrowUp, arrowUpRight: ArrowUpRight, + arrowsLeftRight: ArrowRightArrowLeft, asterisk: Asterisk, at: At, barcode: Barcode, diff --git a/libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg b/libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg new file mode 100644 index 00000000000..e04f708f8d5 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/@hashintel/ds-components/src/preset.ts b/libs/@hashintel/ds-components/src/preset.ts index aef6f5c6e61..73dd3282168 100644 --- a/libs/@hashintel/ds-components/src/preset.ts +++ b/libs/@hashintel/ds-components/src/preset.ts @@ -132,6 +132,14 @@ export function createPreset(options?: PresetOptions) { from: { height: "var(--height)" }, to: { height: "0" }, }, + pulse: { + "0%, 100%": { opacity: "0.25" }, + "50%": { opacity: "1" }, + }, + shimmer: { + from: { backgroundPosition: "200% 0" }, + to: { backgroundPosition: "-200% 0" }, + }, }, textStyles: { xxs: { diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index b2e3cefddec..21f71f2651d 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -13,7 +13,15 @@ const emptySDCPN: SDCPN = { parameters: [], }; -const cloneSDCPN = (sdcpn: SDCPN): SDCPN => JSON.parse(JSON.stringify(sdcpn)); +const cloneSDCPN = (sdcpn: SDCPN): SDCPN => + // `structuredClone` is available as a global in both Node and DOM, but we will need to + // configure TypeScript `types` to be at the intersection of both to avoid DOM dependencies in core. + // For now we define the type inline to avoid that issue in tests, which run in Node but still need to clone SDCPN documents. + ( + globalThis as typeof globalThis & { + structuredClone: (value: Value) => Value; + } + ).structuredClone(sdcpn); const createInstance = (initial: SDCPN = emptySDCPN) => createPetrinaut({ diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 52f2951e8a8..a359febdc7a 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -143,14 +143,6 @@ export type PetrinautAiToolInput = z.input< (typeof petrinautAiTools)[Name]["inputSchema"] >; -/** - * @deprecated Use {@link PetrinautAiWritableCallbacks}. - */ -export type PetrinautMutationAiToolCallbacks = Pick< - Petrinaut["mutations"], - MutationActionName ->; - /** * Writable tool callbacks exposed to the AI: every mutation, plus the subset * of commands registered in {@link aiCommandActionInputSchemas}. Read-only @@ -163,15 +155,6 @@ export type PetrinautAiWritableCallbacks = Pick< > & Pick; -/** - * @deprecated Use {@link createPetrinautAiWritableCallbacks}. - */ -export function createPetrinautMutationAiToolCallbacks( - instance: Petrinaut, -): PetrinautMutationAiToolCallbacks { - return instance.mutations; -} - export function createPetrinautAiWritableCallbacks( instance: Petrinaut, ): PetrinautAiWritableCallbacks { diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 7632473945c..a01a81e0744 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -56,7 +56,6 @@ export { export { colorSchema, createPetrinautAiWritableCallbacks, - createPetrinautMutationAiToolCallbacks, differentialEquationSchema, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, @@ -77,7 +76,6 @@ export type { PetrinautAiCommandToolName, PetrinautAiTool, PetrinautAiWritableCallbacks, - PetrinautMutationAiToolCallbacks, PetrinautAiToolInput, PetrinautAiMutationToolInput, PetrinautAiMutationToolName, diff --git a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts index 52fb4e836f8..3e0b0b8b389 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts @@ -58,6 +58,7 @@ export const calculateGraphLayout = async ( return {}; } + // Build ELK nodes from places and transitions const elkNodes: ElkNode["children"] = [ ...sdcpn.places.map((place) => ({ id: place.id, @@ -71,8 +72,10 @@ export const calculateGraphLayout = async ( })), ]; + // Build ELK edges from input and output arcs const elkEdges: ElkNode["edges"] = []; for (const transition of sdcpn.transitions) { + // Input arcs: place -> transition for (const inputArc of transition.inputArcs) { elkEdges.push({ id: `arc__${inputArc.placeId}-${transition.id}`, @@ -80,6 +83,7 @@ export const calculateGraphLayout = async ( targets: [transition.id], }); } + // Output arcs: transition -> place for (const outputArc of transition.outputArcs) { elkEdges.push({ id: `arc__${transition.id}-${outputArc.placeId}`, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index ddf204092d8..315feff2adf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -27,8 +27,9 @@ import { useReadOnlyReason, } from "../../../../react/state/use-read-only-reason"; import { PANEL_MARGIN } from "../../../constants/ui"; -import { AiAssistantSurface } from "./ai-assistant-panel/ai-assistant-surface"; +import { AiAssistantContents } from "./ai-assistant-panel/ai-assistant-contents"; import { createDiagnosticsAwareAiTransport } from "./ai-assistant-panel/create-diagnostics-aware-ai-transport"; +import { createReasoningTimingAwareAiTransport } from "./ai-assistant-panel/create-reasoning-timing-aware-ai-transport"; import { formatDiagnosticsForAi } from "./ai-assistant-panel/format-diagnostics-for-ai"; import { getInteractiveTool } from "./ai-assistant-panel/interactive-tools/registry"; import { @@ -182,13 +183,17 @@ export const AiAssistantPanel = ({ // `sendMessages` can read the latest values when it eventually runs. React // Compiler can't prove those reads happen off-render, so we opt out here. "use no memo"; + const instance = use(PetrinautInstanceContext); + const readOnlyReason = useReadOnlyReason(); const readOnlyReasonRef = useRef(readOnlyReason); useEffect(() => { readOnlyReasonRef.current = readOnlyReason; }, [readOnlyReason]); + const { diagnosticsByUri } = use(LanguageClientContext); + const { hasSelection, isAiAssistantOpen, @@ -199,13 +204,17 @@ export const AiAssistantPanel = ({ setSimulateDrawer, setSimulateViewMode, } = use(EditorContext); + const { petriNetDefinition, setTitle, title } = use(SDCPNContext); + const [input, setInput] = useState(""); const submittedInitialMessageRef = useRef(null); + const titleRef = useRef(title); useEffect(() => { titleRef.current = title; }, [title]); + const diagnosticsContextRef = useRef("No current TypeScript diagnostics."); const diagnosticsVersionRef = useRef(0); const pendingMutationDiagnosticsVersionRef = useRef(null); @@ -224,12 +233,17 @@ export const AiAssistantPanel = ({ /* eslint-disable react-hooks-js/refs -- See the `"use no memo"` directive above: the refs are only read when the wrapped transport runs, never during render. The lint rule can't see that. */ - const [diagnosticsTransportState, setDiagnosticsTransportState] = useState( - () => ({ - source: aiAssistant.transport, - transport: createDiagnosticsAwareAiTransport({ + const buildWrappedTransport = (transport: typeof aiAssistant.transport) => + // The timing wrapper sits on the outside so reasoning-chunk receipt is + // tagged with `Date.now()` even when the inner diagnostics wrapper has + // added the post-tool diagnostics context message to the request. Order + // matters here only insofar as the timing wrapper consumes the *response* + // stream from whatever inner transport produced it — it does not touch + // the request side. + createReasoningTimingAwareAiTransport( + createDiagnosticsAwareAiTransport({ getDiagnosticsContext: () => diagnosticsContextRef.current, - transport: aiAssistant.transport, + transport, waitForDiagnosticsRefresh: () => waitForDiagnosticsRefresh({ consumePendingMutationDiagnosticsVersion: () => { @@ -241,6 +255,12 @@ export const AiAssistantPanel = ({ diagnosticsVersionRef, }), }), + ); + + const [diagnosticsTransportState, setDiagnosticsTransportState] = useState( + () => ({ + source: aiAssistant.transport, + transport: buildWrappedTransport(aiAssistant.transport), }), ); @@ -251,24 +271,18 @@ export const AiAssistantPanel = ({ setDiagnosticsTransportState({ source: aiAssistant.transport, - transport: createDiagnosticsAwareAiTransport({ - getDiagnosticsContext: () => diagnosticsContextRef.current, - transport: aiAssistant.transport, - waitForDiagnosticsRefresh: () => - waitForDiagnosticsRefresh({ - consumePendingMutationDiagnosticsVersion: () => { - const pendingVersion = - pendingMutationDiagnosticsVersionRef.current; - pendingMutationDiagnosticsVersionRef.current = null; - return pendingVersion; - }, - diagnosticsVersionRef, - }), - }), + transport: buildWrappedTransport(aiAssistant.transport), }); }, [aiAssistant.transport, diagnosticsTransportState.source]); /* eslint-enable react-hooks-js/refs */ + // Stream errors (server returned an error chunk, function timed out, etc.) + // are otherwise opaque to the user — `useChat` resets `status` to `"ready"` + // and clears its internal `error` value once a follow-up send happens, but + // the user sees nothing in the meantime. Capture them into local state so + // the surface can render the failure under the conversation. + const [streamError, setStreamError] = useState(null); + const { error, messages, @@ -281,16 +295,24 @@ export const AiAssistantPanel = ({ messages: aiAssistant.messages, transport: diagnosticsTransportState.transport, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onError: (chatError) => { + setStreamError( + chatError instanceof Error ? chatError : new Error(String(chatError)), + ); + }, onFinish: ({ messages: finishedMessages }) => { + setStreamError(null); aiAssistant.onMessages?.(finishedMessages); }, onToolCall: async ({ toolCall }) => { if (!instance) { throw new Error("Petrinaut AI cannot run without an editor instance."); } + if (toolCall.dynamic) { throw new Error(`Unknown Petrinaut AI tool: ${toolCall.toolName}`); } + if (toolCall.toolName === getLatestNetDefinitionToolName) { safelyAddToolOutput(addToolOutput, { tool: toolCall.toolName, @@ -302,6 +324,7 @@ export const AiAssistantPanel = ({ }); return; } + if (toolCall.toolName === getNetCompilationErrorsToolName) { await waitForDiagnosticsRefresh({ consumePendingMutationDiagnosticsVersion: () => { @@ -333,11 +356,13 @@ export const AiAssistantPanel = ({ }); return; } + const parsedSetNetTitleInput = setNetTitleToolInputSchema.parse( toolCall.input, ); const previousTitle = titleRef.current; setTitle(parsedSetNetTitleInput.title); + safelyAddToolOutput(addToolOutput, { tool: toolCall.toolName, toolCallId: toolCall.toolCallId, @@ -386,12 +411,15 @@ export const AiAssistantPanel = ({ // onInteractiveToolSubmit when the user decides. return; } + pendingMutationDiagnosticsVersionRef.current = diagnosticsVersionRef.current; + const aiToolCall = { toolName, input: commandInput, } as Extract; + const output = await applyPetrinautAiCommand({ aiToolCall, instance, @@ -407,12 +435,15 @@ export const AiAssistantPanel = ({ const toolInput = petrinautAiMutationToolInputSchemas[toolName].parse( toolCall.input, ); + pendingMutationDiagnosticsVersionRef.current = diagnosticsVersionRef.current; + const aiToolCall = { toolName, input: toolInput, } as Extract; + const output = applyPetrinautAiMutation({ aiToolCall, instance, @@ -432,9 +463,11 @@ export const AiAssistantPanel = ({ submittedInitialMessageRef.current = null; return; } + if (!isAiAssistantOpen || !instance) { return; } + if (submittedInitialMessageRef.current === trimmedInitialMessage) { return; } @@ -442,6 +475,8 @@ export const AiAssistantPanel = ({ submittedInitialMessageRef.current = trimmedInitialMessage; onInitialMessageConsumed?.(); setInput(""); + setStreamError(null); + void sendMessage({ text: trimmedInitialMessage }); }, [ initialMessage, @@ -456,8 +491,8 @@ export const AiAssistantPanel = ({ } return ( - { @@ -465,6 +500,7 @@ export const AiAssistantPanel = ({ void stop(); } setInput(""); + setStreamError(null); setMessages([]); aiAssistant.onMessages?.([]); aiAssistant.onClearMessages?.(); @@ -476,6 +512,7 @@ export const AiAssistantPanel = ({ // Defensive — the registry only exposes AI command tools today. return; } + // applyAutoLayout is the only interactive command today. The widget // signals "apply" by passing `{ applied: true }`; we still need to // run the command to compute the real commitCount before reporting @@ -488,6 +525,7 @@ export const AiAssistantPanel = ({ }); return; } + const readOnlyAtSubmit = readOnlyReasonRef.current; if (readOnlyAtSubmit !== null) { safelyAddToolOutput(addToolOutput, { @@ -501,8 +539,10 @@ export const AiAssistantPanel = ({ }); return; } + pendingMutationDiagnosticsVersionRef.current = diagnosticsVersionRef.current; + void instance.commands.applyAutoLayout().then((result) => { safelyAddToolOutput(addToolOutput, { tool: toolName, @@ -528,6 +568,7 @@ export const AiAssistantPanel = ({ return; } setInput(""); + setStreamError(null); void sendMessage({ text: trimmed }); }} rightOffset={hasSelection ? propertiesPanelWidth + PANEL_MARGIN : 0} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx similarity index 98% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx rename to libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx index c4d3cd99703..1f302e3945b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-surface.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.stories.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { AiAssistantSurface } from "./ai-assistant-surface"; +import { AiAssistantContents } from "./ai-assistant-contents"; import type { PetrinautAiMessage } from "./types"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -201,7 +201,7 @@ const Frame = ({ return (
- { vi.useRealTimers(); }); -describe("AiAssistantSurface", () => { +describe("AiAssistantContents", () => { test("renders the empty assistant state", () => { render( - { }); test("renders streamed markdown and collapsed reasoning", () => { + const startedAt = Date.parse("2026-05-14T12:00:00Z"); + const finishedAt = startedAt + 4_500; const messages: PetrinautAiMessage[] = [ { id: "assistant-1", @@ -50,6 +52,9 @@ describe("AiAssistantSurface", () => { type: "reasoning", state: "done", text: "Understanding the requested model.", + providerMetadata: { + petrinaut: { startedAt, finishedAt }, + }, }, { type: "text", @@ -61,7 +66,7 @@ describe("AiAssistantSurface", () => { ]; render( - { const onClearMessages = vi.fn(); render( - { window.cancelAnimationFrame = () => {}; render( - { ]; render( - { ]; render( - { ]; const { container } = render( - { }); test("right-aligns user text and renders active reasoning time", () => { + const startedAt = Date.parse("2026-05-14T12:00:00Z"); vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-14T12:00:00Z")); + vi.setSystemTime(new Date(startedAt)); const messages: PetrinautAiMessage[] = [ { @@ -271,13 +277,16 @@ describe("AiAssistantSurface", () => { type: "reasoning", state: "streaming", text: "Choosing the smallest valid place update.", + providerMetadata: { + petrinaut: { startedAt }, + }, }, ], }, ]; render( - { ]; render( - { ]; render( - { ]; render( - { ]; render( - { ]; render( - { ]; render( - void; + onClose: () => void; + onInputChange: (value: string) => void; + onInteractiveToolSubmit?: OnInteractiveToolSubmit; + onSelectToolTarget?: (target: AiToolTarget) => void; + onStop: () => void; + onSubmit: () => void; + rightOffset?: number; + status: AiAssistantStatus; +}; + +const shellStyle = css({ + position: "absolute", + top: "0", + right: "0", + bottom: "0", + width: "[420px]", + maxWidth: "[calc(100vw - 32px)]", + zIndex: 1090, + padding: "2", + pointerEvents: "auto", + transition: "[right 150ms ease-in-out]", + _before: { + content: '""', + position: "absolute", + inset: "2", + borderRadius: "[14px]", + background: + "[radial-gradient(circle at 78% 28%, rgba(52,160,250,0.22), rgba(190,230,255,0.04) 54%, transparent 80%)]", + filter: "[blur(4px)]", + pointerEvents: "none", + }, +}); + +const resizeHandleStyle = css({ + position: "absolute", + top: "2", + bottom: "2", + left: "0", + width: "[10px]", + cursor: "ew-resize", + zIndex: 1, + touchAction: "none", + _before: { + content: '""', + position: "absolute", + top: "[12px]", + bottom: "[12px]", + left: "[4px]", + width: "[2px]", + borderRadius: "full", + backgroundColor: "[transparent]", + transition: "[background-color 120ms ease-out]", + }, + _hover: { + _before: { + backgroundColor: "neutral.a40", + }, + }, +}); + +const cardStyle = css({ + position: "relative", + display: "flex", + flexDirection: "column", + height: "full", + overflow: "hidden", + backgroundColor: "neutral.s10", + borderRadius: "[12px]", + boxShadow: + "[0px 0px 0px 1px rgba(0,0,0,0.06), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.02), 0px 4px 4px -12px rgba(0,0,0,0.02)]", +}); + +const headerStyle = css({ + display: "flex", + alignItems: "center", + gap: "[1px]", + paddingX: "1", + paddingTop: "[6px]", + borderBottom: "[1px solid rgba(0,0,0,0.08)]", + flexShrink: 0, +}); + +const tabStyle = cva({ + base: { + display: "flex", + alignItems: "center", + height: "[28px]", + maxWidth: "[112px]", + paddingX: "3", + borderTopLeftRadius: "lg", + borderTopRightRadius: "lg", + fontSize: "xs", + fontWeight: "medium", + lineHeight: "[12px]", + color: "neutral.s90", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, + variants: { + active: { + true: { + backgroundColor: "neutral.s00", + boxShadow: "[0px 0px 0px 1px rgba(0,0,0,0.08)]", + color: "neutral.s100", + }, + }, + }, +}); + +const headerButtonStyle = css({ + color: "neutral.s90", + _hover: { + color: "neutral.s110", + }, +}); + +const messagesStyle = css({ + display: "flex", + flexDirection: "column", + gap: "3", + flex: "[1]", + minHeight: "[0]", + overflowY: "auto", + padding: "2", +}); + +const emptyStyle = css({ + display: "flex", + flex: "[1]", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "2", + minHeight: "[240px]", + color: "neutral.s90", + textAlign: "center", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[20px]", + padding: "[20px]", +}); + +const messageStyle = cva({ + base: { + position: "relative", + display: "flex", + flexDirection: "column", + gap: "2", + borderRadius: "xl", + padding: "[10px]", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[1.5]", + color: "neutral.s100", + boxShadow: + "[0px 0px 0px 1px rgba(0,0,0,0.07), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 8px 8px -6px rgba(0,0,0,0.04)]", + }, + variants: { + role: { + assistant: { + alignSelf: "stretch", + backgroundColor: "white.a95", + }, + user: { + alignSelf: "flex-end", + maxWidth: "[92%]", + backgroundColor: "neutral.s20", + textAlign: "right", + }, + }, + activity: { + active: {}, + complete: {}, + }, + }, + compoundVariants: [ + { + role: "assistant", + activity: "active", + css: { + backgroundColor: "neutral.s00", + // Subtle reflective sweep to signal "something is happening" without + // requiring the user to watch the elapsed-time counter. + _after: { + content: '""', + position: "absolute", + inset: "0", + borderRadius: "[inherit]", + background: + "[linear-gradient(110deg, transparent 35%, rgba(255,255,255,0.55) 50%, transparent 65%)]", + backgroundSize: "[200% 100%]", + animationName: "shimmer", + animationDuration: "[2.4s]", + animationTimingFunction: "linear", + animationIterationCount: "[infinite]", + pointerEvents: "none", + }, + }, + }, + { + role: "assistant", + activity: "complete", + css: { + backgroundColor: "neutral.s10", + }, + }, + ], +}); + +const errorStyle = css({ + borderRadius: "lg", + padding: "2", + backgroundColor: "red.bg.subtle", + color: "red.s100", + fontSize: "sm", + fontWeight: "medium", +}); + +const composerWrapStyle = css({ + padding: "2", + backgroundColor: "neutral.bg.subtle", + flexShrink: 0, +}); + +const composerStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + borderRadius: "lg", + backgroundColor: "neutral.s10", + boxShadow: + "[0px 0px 0px 1px rgba(0,0,0,0.06), 0px 1px 1px -0.5px rgba(0,0,0,0.04), 0px 12px 12px -6px rgba(0,0,0,0.02), 0px 4px 4px -12px rgba(0,0,0,0.02)]", + padding: "1", +}); + +const inputStyle = css({ + flex: "[1]", + minWidth: "[0]", + width: "auto", + borderColor: "[transparent]", + backgroundColor: "[transparent]", + boxShadow: "[none]", + _hover: { + borderColor: "[transparent]", + }, + _focus: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _active: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _placeholder: { + color: "neutral.s70", + }, +}); + +const getMessagesScrollKey = (messages: PetrinautAiMessage[]): string => + messages + .map((message) => + [ + message.id, + message.parts + .map((part) => { + if (part.type === "text" || part.type === "reasoning") { + return `${part.type}:${part.state ?? ""}:${part.text}`; + } + + return "state" in part ? `${part.type}:${part.state}` : part.type; + }) + .join(","), + ].join(":"), + ) + .join("|"); + +export const AiAssistantContents = ({ + error, + input, + messages, + onClearMessages, + onClose, + onInputChange, + onInteractiveToolSubmit, + onSelectToolTarget, + onStop, + onSubmit, + rightOffset = 0, + status, +}: AiAssistantContentsProps) => { + const isBusy = status === "submitted" || status === "streaming"; + const canSubmit = input.trim().length > 0 && !isBusy; + const [assistantWidth, setAssistantWidth] = useState(420); + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + const messagesScrollKey = getMessagesScrollKey(messages); + + // Stall detection: track when we last observed the message tree change. When + // the busy state lingers without any chunk movement we surface a soft + // warning under the active message footer so users aren't left wondering + // whether the request has silently died. + // + // The ref starts as `null` because `Date.now()` is impure (lint rule) and + // calling it during render would produce non-idempotent component output; + // the mount-time effect below seeds it with the current timestamp. The + // tick happens inside an interval callback (not synchronously in the effect + // body) to avoid cascading-render warnings, and `effectiveStallMs` lets us + // derive the "no stall when idle" view without writing state on transitions. + const lastChunkAtRef = useRef(null); + const [stallMs, setStallMs] = useState(0); + + useEffect(() => { + lastChunkAtRef.current = Date.now(); + }, [messagesScrollKey]); + + useEffect(() => { + if (!isBusy) { + return; + } + const tick = () => { + const lastChunkAt = lastChunkAtRef.current; + if (lastChunkAt == null) { + return; + } + setStallMs(Math.max(0, Date.now() - lastChunkAt)); + }; + const intervalId = window.setInterval(tick, 1_000); + return () => window.clearInterval(intervalId); + }, [isBusy]); + + const effectiveStallMs = isBusy ? stallMs : 0; + + const onResizeStart = (event: ReactPointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = assistantWidth; + const maxWidth = Math.min(window.innerWidth - 32, 720); + + const onPointerMove = (moveEvent: PointerEvent) => { + setAssistantWidth( + Math.min( + Math.max(startWidth + startX - moveEvent.clientX, 320), + maxWidth, + ), + ); + }; + + const onPointerUp = () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const scrollToEnd = () => { + // The inner optional chain (`scrollIntoView?.`) is intentional — jsdom + // omits `Element.prototype.scrollIntoView`, so unit tests need the + // graceful no-op. The lint rule can't see that. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + messagesEndRef.current?.scrollIntoView?.({ + block: "end", + behavior: "smooth", + }); + }; + const frameId = window.requestAnimationFrame(scrollToEnd); + + return () => window.cancelAnimationFrame(frameId); + }, [messagesScrollKey, status]); + + return ( + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts new file mode 100644 index 00000000000..cb2d4cafff3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts @@ -0,0 +1,52 @@ +import { + getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, +} from "@hashintel/petrinaut-core"; + +import { isPartActive } from "./get-message-render-items"; +import { extractReasoningHeading } from "./reasoning"; +import { getToolName, getToolSummaryFromPart, isToolPart } from "./tool-list"; + +import type { PetrinautAiMessage } from "../types"; + +/** + * Walks an assistant message's parts to derive a short status label for the + * footer of the active message. Returns `undefined` when no part is currently + * streaming/awaiting input. + * + * Falls back gracefully when provider-specific signals (reasoning heading, + * tool summary title) aren't available — the user just sees the generic phase + * verb (`Thinking…`, `Working on a change…`, etc.). + */ +export const getActivePhaseLabel = ( + message: PetrinautAiMessage, +): string | undefined => { + const activePart = [...message.parts].reverse().find(isPartActive); + if (!activePart) { + return undefined; + } + + if (activePart.type === "reasoning") { + const { heading } = extractReasoningHeading(activePart.text, true); + return heading ? `Thinking about ${heading}` : "Thinking"; + } + + if (activePart.type === "text") { + return "Writing reply"; + } + + if (isToolPart(activePart)) { + const toolName = getToolName(activePart); + if (toolName === getLatestNetDefinitionToolName) { + return "Checking the current net"; + } + if (toolName === getNetCompilationErrorsToolName) { + return "Checking for compilation errors"; + } + const summary = getToolSummaryFromPart(activePart); + const label = summary.title || toolName; + return label ? `Working on ${label}` : "Working on a change"; + } + + return undefined; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts new file mode 100644 index 00000000000..bb299b35cc4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts @@ -0,0 +1,87 @@ +import { + getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, +} from "@hashintel/petrinaut-core"; + +import { isToolPart, toToolRenderItem, type ToolRenderItem } from "./tool-list"; + +import type { PetrinautAiMessage } from "../types"; + +export type MessagePart = PetrinautAiMessage["parts"][number]; +export type TextPart = Extract; +export type ReasoningMessagePart = Extract; + +export type MessageRenderItem = + | { type: "reasoning"; key: string; part: ReasoningMessagePart } + | { type: "text"; key: string; part: TextPart } + | { type: "tools"; key: string; tools: ToolRenderItem[] }; + +export const isPartActive = ( + part: PetrinautAiMessage["parts"][number], +): boolean => + "state" in part && + (part.state === "streaming" || + part.state === "input-streaming" || + part.state === "input-available"); + +export const getMessageRenderItems = ( + message: PetrinautAiMessage, +): MessageRenderItem[] => { + const items: MessageRenderItem[] = []; + let pendingTools: ToolRenderItem[] = []; + + const flushTools = () => { + if (pendingTools.length === 0) { + return; + } + + items.push({ + type: "tools", + key: `${message.id}-tools-${items.length}`, + tools: pendingTools, + }); + pendingTools = []; + }; + + message.parts.forEach((part, index) => { + if (part.type === "text") { + flushTools(); + items.push({ + type: "text", + key: `${message.id}-text-${index}`, + part, + }); + return; + } + + if (part.type === "reasoning") { + flushTools(); + items.push({ + type: "reasoning", + key: `${message.id}-reasoning-${index}`, + part, + }); + return; + } + + if (isToolPart(part)) { + const tool = toToolRenderItem(message, part); + + if ( + tool.toolName === getLatestNetDefinitionToolName || + tool.toolName === getNetCompilationErrorsToolName + ) { + flushTools(); + pendingTools.push(tool); + flushTools(); + return; + } + + pendingTools.push(tool); + } + }); + + flushTools(); + + return items; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx new file mode 100644 index 00000000000..5502b85730a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx @@ -0,0 +1,90 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { StreamingEllipsis } from "./shared/streaming-ellipsis"; + +const STALL_MILD_THRESHOLD_MS = 90_000; +const STALL_SEVERE_THRESHOLD_MS = 240_000; + +const phaseStatusStyle = css({ + position: "relative", + display: "flex", + alignItems: "center", + gap: "2", + paddingX: "1", + color: "neutral.s80", + fontSize: "xs", + fontWeight: "medium", + lineHeight: "[1.4]", +}); + +const phaseStatusLabelStyle = css({ + flex: "[1]", + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const stallWarningStyle = cva({ + base: { + color: "neutral.s70", + fontWeight: "normal", + }, + variants: { + severity: { + mild: { + color: "neutral.s70", + }, + severe: { + color: "red.s90", + fontWeight: "medium", + }, + }, + }, +}); + +export const MessageStatusFooter = ({ + phaseLabel, + stallMs, +}: { + phaseLabel?: string; + stallMs: number; +}) => { + const severity = + stallMs >= STALL_SEVERE_THRESHOLD_MS + ? "severe" + : stallMs >= STALL_MILD_THRESHOLD_MS + ? "mild" + : null; + const stallMessage = + severity === "severe" + ? "the model may have stalled — you can stop and retry" + : severity === "mild" + ? "still working…" + : null; + + if (!phaseLabel && !stallMessage) { + return null; + } + + return ( +
+ {phaseLabel && ( + + {phaseLabel} + + + )} + {stallMessage && severity && ( + + ({stallMessage}) + + )} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx new file mode 100644 index 00000000000..062f5f402a4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx @@ -0,0 +1,256 @@ +import { Collapsible } from "@ark-ui/react/collapsible"; +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; + +import { Icon, LoadingSpinner } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { collapsibleContentStyle } from "./shared/collapsible-content-style"; +import { markdownStyle } from "./shared/markdown-style"; +import { StreamingEllipsis } from "./shared/streaming-ellipsis"; + +import type { PetrinautReasoningMetadata } from "../types"; +import type { ReasoningMessagePart } from "./get-message-render-items"; + +const reasoningGroupStyle = css({ + display: "flex", + flexDirection: "column", + gap: "3", + borderRadius: "lg", + backgroundColor: "neutral.bg.subtle", + padding: "1", +}); + +const reasoningHeaderStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + height: "8", + paddingX: "2", + border: "none", + borderRadius: "lg", + backgroundColor: "[transparent]", + color: "neutral.s90", + cursor: "pointer", + fontSize: "sm", + fontWeight: "medium", + textAlign: "left", + _hover: { + backgroundColor: "white.a60", + }, + "& svg[data-chevron]": { + transition: "[transform 150ms ease-out]", + }, + "&[data-state=closed] svg[data-chevron]": { + transform: "[rotate(180deg)]", + }, +}); + +const reasoningLabelGroupStyle = css({ + display: "flex", + flex: "[1]", + alignItems: "baseline", + gap: "[6px]", + minWidth: "[0]", +}); + +const reasoningHeadingStyle = css({ + flex: "[1]", + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: "neutral.s70", + fontWeight: "normal", +}); + +const reasoningBodyStyle = css({ + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "md", + backgroundColor: "neutral.s10", + padding: "2", + color: "neutral.s90", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[1.5]", +}); + +const reasoningLoadingStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + minHeight: "6", + color: "neutral.s80", +}); + +const reasoningBodyWithEllipsisStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[6px]", +}); + +const formatElapsedTime = (elapsedMs: number): string => { + const seconds = Math.max(0, Math.floor(elapsedMs / 1_000)); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + return minutes > 0 + ? `${minutes}m ${remainingSeconds.toString().padStart(2, "0")}s` + : `${seconds}s`; +}; + +/** + * Compute the elapsed time string for a reasoning step from server-provided + * timestamps. Returns `undefined` when `startedAt` is missing — older messages + * persisted before the server-side injector existed, or messages from a + * provider that does not attach Petrinaut metadata, simply omit the timer + * rather than fall back to a misleading client-side clock that resets on + * panel close/reopen. + */ +const useReasoningElapsed = ({ + isStreaming, + startedAt, + finishedAt, +}: { + isStreaming: boolean; + startedAt?: number; + finishedAt?: number; +}): string | undefined => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!isStreaming || finishedAt != null || startedAt == null) { + return; + } + const updateNow = () => setNow(Date.now()); + updateNow(); + const intervalId = window.setInterval(updateNow, 1_000); + + return () => window.clearInterval(intervalId); + }, [isStreaming, finishedAt, startedAt]); + + if (startedAt == null) { + return undefined; + } + + const end = finishedAt ?? now; + return formatElapsedTime(Math.max(0, end - startedAt)); +}; + +/** + * Pulls the bold heading off the front of an OpenAI reasoning-summary block. + * + * The current backend ([apps/petrinaut-website/api/chat.ts]) sets + * `reasoningSummary: "auto"` for the OpenAI provider, which emits each summary + * item as `**Heading**\n\n`. This helper hoists the heading so the + * collapsible card trigger can preview what the model is thinking about. + * + * If the convention is not matched (different provider, OpenAI changes the + * format, or the model just produced an unheaded summary), we fall back to + * returning the original text as the body and let the trigger render the + * plain "Reasoning" label. + */ +const reasoningHeadingPattern = + /^\s*(?:\*\*([^*\n]+?)\*\*|#+\s+([^\n]+))\s*(?:\n|$)/u; + +export const extractReasoningHeading = ( + text: string, + isStreaming: boolean, +): { heading?: string; body: string } => { + const match = text.match(reasoningHeadingPattern); + if (!match) { + return { body: text }; + } + // While the part is streaming, don't commit to a heading until the + // terminating newline has arrived — otherwise the trigger label flickers + // character-by-character as deltas come in. + if (isStreaming && !match[0].includes("\n")) { + return { body: text }; + } + const heading = (match[1] ?? match[2])?.trim(); + if (!heading) { + return { body: text }; + } + return { heading, body: text.slice(match[0].length).trimStart() }; +}; + +const getReasoningTiming = ( + part: ReasoningMessagePart, +): { startedAt?: number; finishedAt?: number } => { + const metadata = part.providerMetadata as + | PetrinautReasoningMetadata + | undefined; + return metadata?.petrinaut ?? {}; +}; + +export const AiAssistantReasoning = ({ + isStreaming, + part, +}: { + isStreaming: boolean; + part: ReasoningMessagePart; +}) => { + const { startedAt, finishedAt } = getReasoningTiming(part); + const elapsedTime = useReasoningElapsed({ + isStreaming, + startedAt, + finishedAt, + }); + const renderedText = part.text.trim(); + const { heading, body } = extractReasoningHeading(renderedText, isStreaming); + const [open, setOpen] = useState(isStreaming); + + useEffect(() => { + setOpen(isStreaming); + }, [isStreaming]); + + if (!isStreaming && !renderedText) { + return null; + } + + return ( + setOpen(details.open)} + > + + + + Reasoning + {heading && ( + ({heading}) + )} + + {elapsedTime !== undefined && ( + + {elapsedTime} + + )} + + + +
+ {body ? ( +
+
+ {body} +
+ {isStreaming && } +
+ ) : ( + + + + )} +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/collapsible-content-style.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/collapsible-content-style.ts new file mode 100644 index 00000000000..08a2bbb4e19 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/collapsible-content-style.ts @@ -0,0 +1,13 @@ +import { css } from "@hashintel/ds-helpers/css"; + +export const collapsibleContentStyle = css({ + overflow: "hidden", + animationDuration: "[200ms]", + animationTimingFunction: "ease-in-out", + "&[data-state=open]": { + animationName: "expand", + }, + "&[data-state=closed]": { + animationName: "collapse", + }, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/markdown-style.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/markdown-style.ts new file mode 100644 index 00000000000..d915ef9c375 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/markdown-style.ts @@ -0,0 +1,98 @@ +import { css } from "@hashintel/ds-helpers/css"; + +export const markdownStyle = css({ + overflowWrap: "anywhere", + wordBreak: "break-word", + "& > :first-child": { + marginTop: "[0]", + }, + "& > :last-child": { + marginBottom: "[0]", + }, + "& p": { + marginY: "2", + }, + "& h1, & h2, & h3, & h4, & h5, & h6": { + marginTop: "3", + marginBottom: "1", + fontWeight: "semibold", + lineHeight: "[1.25]", + color: "neutral.s110", + }, + "& h1": { + fontSize: "[15px]", + }, + "& h2, & h3": { + fontSize: "sm", + }, + "& h4, & h5, & h6": { + fontSize: "xs", + }, + "& ul, & ol": { + marginY: "2", + paddingLeft: "5", + }, + "& ul": { + listStyleType: "disc", + }, + "& ol": { + listStyleType: "decimal", + }, + "& li": { + marginY: "1", + }, + "& li > p": { + marginY: "1", + }, + "& a": { + color: "blue.s90", + textDecorationLine: "underline", + textUnderlineOffset: "[2px]", + }, + "& blockquote": { + marginY: "2", + marginX: "[0]", + borderLeftWidth: "[3px]", + borderLeftStyle: "solid", + borderLeftColor: "neutral.a40", + paddingLeft: "3", + color: "neutral.s90", + }, + "& pre": { + marginY: "2", + overflowX: "auto", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "md", + backgroundColor: "neutral.s20", + padding: "2", + }, + "& :not(pre) > code": { + fontFamily: "mono", + fontSize: "xs", + backgroundColor: "neutral.s20", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "sm", + paddingX: "1", + paddingY: "[2px]", + }, + "& pre code": { + display: "block", + minWidth: "[max-content]", + backgroundColor: "[transparent]", + padding: "[0]", + fontFamily: "mono", + fontSize: "xs", + lineHeight: "[1.5]", + }, + "& hr": { + marginY: "3", + borderWidth: "[0]", + borderTopWidth: "thin", + borderTopStyle: "solid", + borderTopColor: "neutral.a30", + }, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx new file mode 100644 index 00000000000..21917eae090 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx @@ -0,0 +1,34 @@ +import { css } from "@hashintel/ds-helpers/css"; + +const ellipsisStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "[3px]", + marginLeft: "[2px]", + color: "neutral.s70", + "& > span": { + display: "inline-block", + width: "[4px]", + height: "[4px]", + borderRadius: "full", + backgroundColor: "[currentColor]", + animationName: "pulse", + animationDuration: "[1.2s]", + animationTimingFunction: "ease-in-out", + animationIterationCount: "[infinite]", + }, + "& > span:nth-child(2)": { + animationDelay: "[0.15s]", + }, + "& > span:nth-child(3)": { + animationDelay: "[0.3s]", + }, +}); + +export const StreamingEllipsis = () => ( +
); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts deleted file mode 100644 index cb2d4cafff3..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-active-phase-label.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - getLatestNetDefinitionToolName, - getNetCompilationErrorsToolName, -} from "@hashintel/petrinaut-core"; - -import { isPartActive } from "./get-message-render-items"; -import { extractReasoningHeading } from "./reasoning"; -import { getToolName, getToolSummaryFromPart, isToolPart } from "./tool-list"; - -import type { PetrinautAiMessage } from "../types"; - -/** - * Walks an assistant message's parts to derive a short status label for the - * footer of the active message. Returns `undefined` when no part is currently - * streaming/awaiting input. - * - * Falls back gracefully when provider-specific signals (reasoning heading, - * tool summary title) aren't available — the user just sees the generic phase - * verb (`Thinking…`, `Working on a change…`, etc.). - */ -export const getActivePhaseLabel = ( - message: PetrinautAiMessage, -): string | undefined => { - const activePart = [...message.parts].reverse().find(isPartActive); - if (!activePart) { - return undefined; - } - - if (activePart.type === "reasoning") { - const { heading } = extractReasoningHeading(activePart.text, true); - return heading ? `Thinking about ${heading}` : "Thinking"; - } - - if (activePart.type === "text") { - return "Writing reply"; - } - - if (isToolPart(activePart)) { - const toolName = getToolName(activePart); - if (toolName === getLatestNetDefinitionToolName) { - return "Checking the current net"; - } - if (toolName === getNetCompilationErrorsToolName) { - return "Checking for compilation errors"; - } - const summary = getToolSummaryFromPart(activePart); - const label = summary.title || toolName; - return label ? `Working on ${label}` : "Working on a change"; - } - - return undefined; -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx deleted file mode 100644 index 5502b85730a..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/message-status-footer.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; - -import { StreamingEllipsis } from "./shared/streaming-ellipsis"; - -const STALL_MILD_THRESHOLD_MS = 90_000; -const STALL_SEVERE_THRESHOLD_MS = 240_000; - -const phaseStatusStyle = css({ - position: "relative", - display: "flex", - alignItems: "center", - gap: "2", - paddingX: "1", - color: "neutral.s80", - fontSize: "xs", - fontWeight: "medium", - lineHeight: "[1.4]", -}); - -const phaseStatusLabelStyle = css({ - flex: "[1]", - minWidth: "[0]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const stallWarningStyle = cva({ - base: { - color: "neutral.s70", - fontWeight: "normal", - }, - variants: { - severity: { - mild: { - color: "neutral.s70", - }, - severe: { - color: "red.s90", - fontWeight: "medium", - }, - }, - }, -}); - -export const MessageStatusFooter = ({ - phaseLabel, - stallMs, -}: { - phaseLabel?: string; - stallMs: number; -}) => { - const severity = - stallMs >= STALL_SEVERE_THRESHOLD_MS - ? "severe" - : stallMs >= STALL_MILD_THRESHOLD_MS - ? "mild" - : null; - const stallMessage = - severity === "severe" - ? "the model may have stalled — you can stop and retry" - : severity === "mild" - ? "still working…" - : null; - - if (!phaseLabel && !stallMessage) { - return null; - } - - return ( -
- {phaseLabel && ( - - {phaseLabel} - - - )} - {stallMessage && severity && ( - - ({stallMessage}) - - )} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx new file mode 100644 index 00000000000..c6de2f102fd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx @@ -0,0 +1,146 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { Button } from "../../../../../components/button"; + +export type PromptChip = { + id: string; + label: string; + prompt: string; +}; + +/** + * Quick-action chips shown when the net is empty — they kick off a + * domain-shaped build with the AI's interview-first behaviour taking over. + */ +export const STARTER_CHIPS: PromptChip[] = [ + { + id: "supply-chain", + label: "Supply chain", + prompt: + "Build a small supply-chain SDCPN — orders flowing through warehouses, in-transit, and delivered states. Interview me briefly about volumes and lead times first, then build it with a couple of useful metrics and scenarios.", + }, + { + id: "manufacturing-line", + label: "Manufacturing line", + prompt: + "Build a small manufacturing line with stations, buffers, and rework. Ask me a couple of clarifying questions about throughput, cycle times, and failure rates first, then build it with relevant metrics and scenarios.", + }, + { + id: "epidemic", + label: "Epidemic", + prompt: + "Build an SIR-style epidemic model with susceptible, infected, and recovered places. Ask me briefly about population size and transmission/recovery rates, then add a couple of useful metrics (peak prevalence, attack rate) and a baseline-vs-intervention scenario.", + }, + { + id: "surprise-me", + label: "Surprise me", + prompt: + "Pick an interesting domain and build a small but complete SDCPN end-to-end — places, transitions, parameters, one or two scenarios, and a couple of metrics. Use sensible defaults throughout and tell me the choices you made.", + }, +]; + +/** + * Quick-action chips shown when the net already has content — they ask the + * AI to audit or describe the existing model rather than build from scratch. + */ +export const REVIEW_CHIPS: PromptChip[] = [ + { + id: "suggest-improvements", + label: "Suggest improvements", + prompt: + "Review the current Petri net and suggest improvements. Look at naming, structure, missing transitions, parameter tunability, scenario coverage, and code quality. Don't make changes yet — just list the proposals so I can pick which to apply.", + }, + { + id: "review-completeness", + label: "Review completeness", + prompt: + "Review the current Petri net for completeness. Are there states or transitions implied by the domain that I haven't modelled? Any places without producers or consumers? Inputs the simulation can't reach, or outputs that go nowhere? List any gaps you find.", + }, + { + id: "explain-this-model", + label: "Explain this model", + prompt: + "Explain this Petri net in plain English — what the modelled process is, the role of each place and transition, what the parameters represent, what the scenarios are testing, and what the metrics measure. Aim for someone who's never seen this net before.", + }, +]; + +const containerStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + minWidth: "[0]", +}); + +const railStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + flex: "[1]", + minWidth: "[0]", + overflowX: "auto", + scrollbarWidth: "[none]", + "&::-webkit-scrollbar": { + display: "none", + }, +}); + +const chipStyle = css({ + flexShrink: 0, +}); + +const dismissStyle = css({ + flexShrink: 0, + color: "neutral.s80", + _hover: { + color: "neutral.s110", + }, +}); + +export type PromptChipsProps = { + chips: PromptChip[]; + disabled?: boolean; + onDismiss: () => void; + onSelect: (prompt: string) => void; +}; + +export const PromptChips = ({ + chips, + disabled = false, + onDismiss, + onSelect, +}: PromptChipsProps) => { + if (chips.length === 0) { + return null; + } + + return ( +
+
+ {chips.map((chip) => ( + + ))} +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx index 062f5f402a4..34d6999448c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx @@ -2,8 +2,8 @@ import { Collapsible } from "@ark-ui/react/collapsible"; import { useEffect, useState } from "react"; import ReactMarkdown from "react-markdown"; -import { Icon, LoadingSpinner } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; +import { Icon } from "@hashintel/ds-components"; +import { css, cva } from "@hashintel/ds-helpers/css"; import { collapsibleContentStyle } from "./shared/collapsible-content-style"; import { markdownStyle } from "./shared/markdown-style"; @@ -27,7 +27,7 @@ const reasoningHeaderStyle = css({ gap: "2", width: "full", height: "8", - paddingX: "2", + paddingX: "1", border: "none", borderRadius: "lg", backgroundColor: "[transparent]", @@ -61,21 +61,60 @@ const reasoningHeadingStyle = css({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", - color: "neutral.s70", + color: "neutral.s80", fontWeight: "normal", }); -const reasoningBodyStyle = css({ - borderWidth: "thin", - borderStyle: "solid", - borderColor: "neutral.a30", - borderRadius: "md", - backgroundColor: "neutral.s10", - padding: "2", - color: "neutral.s90", - fontSize: "sm", - fontWeight: "medium", - lineHeight: "[1.5]", +// The elapsed-time span sits between two flexible siblings; without an +// explicit `flex-shrink: 0` and `nowrap` it can collapse to zero width once +// the heading appears and consumes the label-group's `flex: 1` budget. +const reasoningElapsedStyle = css({ + flexShrink: "[0]", + whiteSpace: "nowrap", + fontVariantNumeric: "tabular-nums", + color: "neutral.s80", + fontWeight: "normal", +}); + +const reasoningBodyStyle = cva({ + base: { + position: "relative", + overflow: "hidden", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a30", + borderRadius: "md", + backgroundColor: "neutral.s10", + padding: "2", + color: "neutral.s90", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "[1.5]", + }, + variants: { + streaming: { + true: { + // Subtle reflective sweep across the body so the user can see the + // step is still in progress without watching the elapsed-time + // counter. The gradient sits above the markdown content but is + // mostly transparent, so the text underneath stays readable. + _after: { + content: '""', + position: "absolute", + inset: "0", + borderRadius: "[inherit]", + background: + "[linear-gradient(110deg, transparent 35%, rgba(255,255,255,0.55) 50%, transparent 65%)]", + backgroundSize: "[200% 100%]", + animationName: "shimmer", + animationDuration: "[2.4s]", + animationTimingFunction: "linear", + animationIterationCount: "[infinite]", + pointerEvents: "none", + }, + }, + }, + }, }); const reasoningLoadingStyle = css({ @@ -86,12 +125,6 @@ const reasoningLoadingStyle = css({ color: "neutral.s80", }); -const reasoningBodyWithEllipsisStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[6px]", -}); - const formatElapsedTime = (elapsedMs: number): string => { const seconds = Math.max(0, Math.floor(elapsedMs / 1_000)); const minutes = Math.floor(seconds / 60); @@ -222,33 +255,36 @@ export const AiAssistantReasoning = ({ Reasoning {heading && ( - ({heading}) + + ({heading.toLowerCase()}) + )} {elapsedTime !== undefined && ( - + {elapsedTime} )} -
+
{body ? ( -
-
- {body} -
- {isStreaming && } +
+ {body}
- ) : ( - - - - )} + +
+ ) : null}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx index 21917eae090..15af57c3cfd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/shared/streaming-ellipsis.tsx @@ -8,8 +8,8 @@ const ellipsisStyle = css({ color: "neutral.s70", "& > span": { display: "inline-block", - width: "[4px]", - height: "[4px]", + width: "[3px]", + height: "[3px]", borderRadius: "full", backgroundColor: "[currentColor]", animationName: "pulse", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx index ed274186256..e8d7cb31f28 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx @@ -140,13 +140,22 @@ const toolHeaderIconStyle = css({ display: "flex", alignItems: "center", justifyContent: "center", - width: "[16px]", - height: "[16px]", + width: "[14px]", + height: "[14px]", borderRadius: "full", backgroundColor: "[#2a80c8]", color: "white", boxShadow: "[0px 0px 0px 1px white]", flexShrink: 0, + // The `arrow-right-arrow-left` glyph fills more of its 640×640 viewBox than + // the other tool-status icons (check/close), so even at `size="xs"` (12px) + // it looks crowded inside the 14px circle. Pull the inner svg back to a + // tighter visual size — descendant selector wins over the Icon recipe's + // own class-level width/height. + "& svg": { + width: "[9px]", + height: "[9px]", + }, }); const toolItemStyle = cva({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx index 554834f0efb..b4622cfe4b5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx @@ -63,8 +63,8 @@ const ApplyAutoLayoutWidget = ({ return (
- Petrinaut AI wants to auto-layout the net. This will reposition every - place and transition. + Petrinaut AI suggests running auto-layout on the net. This may + reposition places and transitions.
From 8475e94bcc6875c15fdef1bf1205190b0c38ebd3 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 25 May 2026 14:36:10 +0100 Subject: [PATCH 16/21] performance + UX improvements --- .../views/Editor/components/ai-cta-modal.tsx | 48 +++++ .../src/ui/views/Editor/editor-view.tsx | 3 + .../Editor/panels/ai-assistant-panel.tsx | 23 +- .../ai-assistant-contents.tsx | 199 ++++++++++++------ .../ai-assistant-contents/prompt-chips.tsx | 25 +-- .../ai-assistant-contents/reasoning.tsx | 2 +- ...ate-reasoning-timing-aware-ai-transport.ts | 59 ++++-- 7 files changed, 259 insertions(+), 100 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx index 6c1a778e2dc..3722f74cc98 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx @@ -18,6 +18,7 @@ const aiCtaModalLayerStyle = css({ }); const aiCtaModalStyle = css({ + position: "relative", pointerEvents: "auto", display: "flex", flexDirection: "column", @@ -37,6 +38,12 @@ const aiCtaModalStyle = css({ backdropFilter: "[blur(14px)]", }); +const aiCtaModalCloseStyle = css({ + position: "absolute", + top: "3", + right: "3", +}); + const aiCtaModalIconStyle = css({ display: "flex", alignItems: "center", @@ -100,23 +107,54 @@ const aiCtaModalInputStyle = css({ export const AiCtaModal = ({ bottomClearance, + onDismiss, onSubmit, }: { bottomClearance: number; + onDismiss: () => void; onSubmit: (message: string) => void; }) => { const [promptInput, setPromptInput] = useState(""); const canSubmit = promptInput.trim().length > 0; const inputRef = useRef(null); + const formRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (formRef.current?.contains(target)) { + return; + } + onDismiss(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onDismiss(); + } + }; + + document.addEventListener("mousedown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onDismiss]); + return (
{ event.preventDefault(); @@ -128,6 +166,16 @@ export const AiCtaModal = ({ onSubmit(trimmedInput); }} > +
)} - {messages.map((message) => { - const role = message.role === "user" ? "user" : "assistant"; - const renderItems = getMessageRenderItems(message); - - return ( -
- {renderItems.map((item) => { - switch (item.type) { - case "text": - return ( -
- {item.part.text} -
- ); - case "reasoning": - return ( - - ); - case "tools": - return ( - - ); - default: { - const exhaustiveCheck: never = item; - throw new Error( - `Unknown message part: ${JSON.stringify(exhaustiveCheck)}`, - ); - } - } - })} -
- ); - })} + {messages.map((message) => ( + + ))} {error &&
{error.message}
}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx index c6de2f102fd..41697d95965 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/prompt-chips.tsx @@ -17,25 +17,19 @@ export const STARTER_CHIPS: PromptChip[] = [ id: "supply-chain", label: "Supply chain", prompt: - "Build a small supply-chain SDCPN — orders flowing through warehouses, in-transit, and delivered states. Interview me briefly about volumes and lead times first, then build it with a couple of useful metrics and scenarios.", - }, - { - id: "manufacturing-line", - label: "Manufacturing line", - prompt: - "Build a small manufacturing line with stations, buffers, and rework. Ask me a couple of clarifying questions about throughput, cycle times, and failure rates first, then build it with relevant metrics and scenarios.", + "Build a supply-chain SDCPN — orders flowing through warehouses, in-transit, and delivered states. Interview me first for more details.", }, { id: "epidemic", label: "Epidemic", prompt: - "Build an SIR-style epidemic model with susceptible, infected, and recovered places. Ask me briefly about population size and transmission/recovery rates, then add a couple of useful metrics (peak prevalence, attack rate) and a baseline-vs-intervention scenario.", + "Build an SIR-style epidemic model with susceptible, infected, and recovered places. Interview me first for more details.", }, { id: "surprise-me", label: "Surprise me", prompt: - "Pick an interesting domain and build a small but complete SDCPN end-to-end — places, transitions, parameters, one or two scenarios, and a couple of metrics. Use sensible defaults throughout and tell me the choices you made.", + "Pick an interesting domain and build a small but complete SDCPN end-to-end — use all available features (including place visualizers).", }, ]; @@ -48,19 +42,19 @@ export const REVIEW_CHIPS: PromptChip[] = [ id: "suggest-improvements", label: "Suggest improvements", prompt: - "Review the current Petri net and suggest improvements. Look at naming, structure, missing transitions, parameter tunability, scenario coverage, and code quality. Don't make changes yet — just list the proposals so I can pick which to apply.", + "Review the current Petri net and suggest a few improvements. Don't make changes yet — let me choose.", }, { id: "review-completeness", label: "Review completeness", prompt: - "Review the current Petri net for completeness. Are there states or transitions implied by the domain that I haven't modelled? Any places without producers or consumers? Inputs the simulation can't reach, or outputs that go nowhere? List any gaps you find.", + "Review the current Petri net for completeness. Anything implied by the domain that isn't modelled?", }, { id: "explain-this-model", label: "Explain this model", prompt: - "Explain this Petri net in plain English — what the modelled process is, the role of each place and transition, what the parameters represent, what the scenarios are testing, and what the metrics measure. Aim for someone who's never seen this net before.", + "Explain this Petri net in plain terms — what the modelled process is, the role of each feature, etc.", }, ]; @@ -86,6 +80,13 @@ const railStyle = css({ const chipStyle = css({ flexShrink: 0, + backgroundColor: "white", + _hover: { + backgroundColor: "neutral.s10", + }, + _disabled: { + backgroundColor: "white", + }, }); const dismissStyle = css({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx index 34d6999448c..19846e71932 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/reasoning.tsx @@ -15,7 +15,7 @@ import type { ReasoningMessagePart } from "./get-message-render-items"; const reasoningGroupStyle = css({ display: "flex", flexDirection: "column", - gap: "3", + gap: "1", borderRadius: "lg", backgroundColor: "neutral.bg.subtle", padding: "1", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts index 3da5af19c0a..2c20c666c70 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts @@ -2,37 +2,71 @@ import type { PetrinautAiTransport } from "./types"; import type { UIMessageChunk } from "ai"; /** - * Build a fresh `TransformStream` that tags each reasoning-summary chunk with + * Build a fresh `TransformStream` that tags every reasoning chunk with * Petrinaut timing metadata as it streams in from the model. * * The metadata lives under the `petrinaut` namespace inside the standard AI * SDK `providerMetadata` map. The SDK then merges per-chunk metadata into the - * final `ReasoningUIPart` and that part is persisted alongside the rest of - * the message — which gives the UI an accurate elapsed time that survives - * panel close/reopen and new tabs, without any consumer-specific server code. + * final `ReasoningUIPart`, which is persisted alongside the rest of the + * message — so the UI can render an accurate elapsed time that survives the + * panel being closed and reopened. * - * `TransformStream`s are single-use, so this factory must be called fresh per - * stream. + * Important: the AI SDK's stream reducer assigns `chunk.providerMetadata` + * onto the part on every `reasoning-delta` (not a merge), so any chunk that + * arrives with provider-supplied metadata (OpenAI emits its own on reasoning + * deltas) will _replace_ whatever we set on `reasoning-start`. To survive, + * we have to re-inject the timing under `petrinaut` on every reasoning + * chunk for the same id — start, every delta, and end. + * + * `TransformStream`s are single-use, so this factory must be called fresh + * per `sendMessages` / `reconnectToStream` call. */ -const createReasoningTimingTransform = () => - new TransformStream({ +const createReasoningTimingTransform = () => { + const startedAtById = new Map(); + + return new TransformStream({ transform(chunk, controller) { if (chunk.type === "reasoning-start") { + const startedAt = Date.now(); + startedAtById.set(chunk.id, startedAt); + controller.enqueue({ + ...chunk, + providerMetadata: { + ...chunk.providerMetadata, + petrinaut: { startedAt }, + }, + }); + return; + } + if (chunk.type === "reasoning-delta") { + const startedAt = startedAtById.get(chunk.id); + if (startedAt == null) { + // Should not happen — `reasoning-start` always precedes deltas — + // but be defensive and just pass the chunk through if the upstream + // skipped the start event for some reason. + controller.enqueue(chunk); + return; + } controller.enqueue({ ...chunk, providerMetadata: { ...chunk.providerMetadata, - petrinaut: { startedAt: Date.now() }, + petrinaut: { startedAt }, }, }); return; } if (chunk.type === "reasoning-end") { + const startedAt = startedAtById.get(chunk.id); + startedAtById.delete(chunk.id); controller.enqueue({ ...chunk, providerMetadata: { ...chunk.providerMetadata, - petrinaut: { finishedAt: Date.now() }, + petrinaut: { + ...(startedAt != null ? { startedAt } : {}), + finishedAt: Date.now(), + }, }, }); return; @@ -40,10 +74,11 @@ const createReasoningTimingTransform = () => controller.enqueue(chunk); }, }); +}; /** - * Wrap a Petrinaut chat transport so that reasoning chunks pick up - * client-side receipt timestamps as they arrive. + * Wrap a Petrinaut chat transport so reasoning chunks pick up client-side + * receipt timestamps as they arrive. * * This is applied by `AiAssistantPanel` to every consumer-provided transport, * which means consumers do not have to plumb timing into their own backend. From 5c79142c1b43a904eb684f6bc1e675a9521dc285 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 25 May 2026 14:41:05 +0100 Subject: [PATCH 17/21] lint fix --- libs/@hashintel/petrinaut-core/src/commands.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut-core/src/commands.test.ts b/libs/@hashintel/petrinaut-core/src/commands.test.ts index ecbff2cb9be..af4659e08a0 100644 --- a/libs/@hashintel/petrinaut-core/src/commands.test.ts +++ b/libs/@hashintel/petrinaut-core/src/commands.test.ts @@ -20,7 +20,7 @@ const emptySDCPN: SDCPN = { const createInstance = (initial: SDCPN = emptySDCPN) => createPetrinaut({ document: createJsonDocHandle({ - initial: JSON.parse(JSON.stringify(initial)), + initial: JSON.parse(JSON.stringify(initial)) as SDCPN, }), }); From bee10aabeef8937c1a050251f54bd3b0acb61321 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 25 May 2026 17:48:30 +0100 Subject: [PATCH 18/21] address pr feedback --- .../react/hooks/use-petrinaut-mutations.ts | 19 +++------- .../simulate-mode-allowed-mutation-names.ts | 18 ++++++++++ .../src/ui/views/Editor/editor-view.tsx | 3 ++ .../Editor/panels/ai-assistant-panel.tsx | 35 +++++++++++++------ 4 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index f9681203fed..d2ca622a40b 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -2,24 +2,11 @@ import { use } from "react"; import { PetrinautInstanceContext } from "../instance-context"; import { SDCPNContext } from "../state/sdcpn-context"; +import { simulateModeAllowedMutationNames } from "../state/simulate-mode-allowed-mutation-names"; import { useIsReadOnly } from "../state/use-is-read-only"; import type { PetrinautMutations } from "@hashintel/petrinaut-core"; -/** - * Names of mutations that are allowed in simulate mode. Scenario and metric - * CRUD are managed from the Simulate panel — only the host `readonly` flag - * blocks them. - */ -const SCENARIO_MUTATION_NAMES = new Set([ - "addScenario", - "updateScenario", - "removeScenario", - "addMetric", - "updateMetric", - "removeMetric", -]); - /** * React-facing bundle of atomic SDCPN mutations. * @@ -29,6 +16,8 @@ const SCENARIO_MUTATION_NAMES = new Set([ * `readonly`, simulate mode, or an active simulation). * - Scenario/metric mutations only check the host `readonly` flag — they * remain available in simulate mode where the Simulate panel manages them. + * The list lives in {@link simulateModeAllowedMutationNames} so the AI + * tool dispatcher stays in sync. * * Components MUST NOT reach for `usePetrinautInstance().mutations` directly; * the public `usePetrinautInstance()` return type narrows away the mutation @@ -48,7 +37,7 @@ export function usePetrinautMutations(): PetrinautMutations { const withReadonlyGuard = ( name: Name, ): PetrinautMutations[Name] => { - const allowedInSimulate = SCENARIO_MUTATION_NAMES.has(name); + const allowedInSimulate = simulateModeAllowedMutationNames.has(name); const target = mutations[name] as (input: never) => void; const wrapped = ((input: never) => { if (allowedInSimulate ? readonly : isReadOnly) { diff --git a/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts new file mode 100644 index 00000000000..8800f95d344 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts @@ -0,0 +1,18 @@ +import type { PetrinautMutations } from "@hashintel/petrinaut-core"; + +/** + * Mutations remain available in simulate mode. + * Only the host `readonly` flag blocks them — + * neither a `globalMode === "simulate"` switch + * nor an active simulation (Running / Paused / Complete) disables them + */ +export const simulateModeAllowedMutationNames = new Set< + keyof PetrinautMutations +>([ + "addScenario", + "updateScenario", + "removeScenario", + "addMetric", + "updateMetric", + "removeMetric", +]); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index f0d1310241d..dca0f6bbcdf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -121,6 +121,7 @@ export const EditorView = ({ existingNets, loadPetriNet, petriNetDefinition, + petriNetId, title, setTitle, } = use(SDCPNContext); @@ -447,6 +448,8 @@ export const EditorView = ({ {aiAssistant && ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index 58fb0a85ad5..ca97353a47a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -22,6 +22,7 @@ import { type EditorContextValue, } from "../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../react/state/sdcpn-context"; +import { simulateModeAllowedMutationNames } from "../../../../react/state/simulate-mode-allowed-mutation-names"; import { formatReadOnlyReason, useReadOnlyReason, @@ -400,16 +401,30 @@ export const AiAssistantPanel = ({ const currentReadOnlyReason = readOnlyReasonRef.current; if (currentReadOnlyReason !== null) { - safelyAddToolOutput(addToolOutput, { - tool: toolName, - toolCallId: toolCall.toolCallId, - output: { - applied: false, - blocked: currentReadOnlyReason.kind, - reason: formatReadOnlyReason(currentReadOnlyReason), - } satisfies AiToolOutput, - }); - return; + // Scenario and metric mutations stay live in simulate mode and + // during an active simulation — the Simulate panel itself drives + // them, so `usePetrinautMutations` only blocks them when the host + // is fully read-only. Mirror that here so the assistant can do + // what the UI already permits. + const isSimulateAllowedMutation = + isPetrinautAiMutationToolName(toolName) && + simulateModeAllowedMutationNames.has(toolName); + const allowedDespiteReadOnly = + isSimulateAllowedMutation && + currentReadOnlyReason.kind !== "host-readonly"; + + if (!allowedDespiteReadOnly) { + safelyAddToolOutput(addToolOutput, { + tool: toolName, + toolCallId: toolCall.toolCallId, + output: { + applied: false, + blocked: currentReadOnlyReason.kind, + reason: formatReadOnlyReason(currentReadOnlyReason), + } satisfies AiToolOutput, + }); + return; + } } if (isPetrinautAiCommandToolName(toolName)) { From 16a3a3c3d846858c8f1e997014ba8463f1d6744b Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 25 May 2026 17:57:02 +0100 Subject: [PATCH 19/21] address pr feedback --- apps/petrinaut-website/api/chat.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts index b6a20e1bd79..587e01bce49 100644 --- a/apps/petrinaut-website/api/chat.ts +++ b/apps/petrinaut-website/api/chat.ts @@ -94,6 +94,10 @@ const checkRateLimit = (clientIp: string): boolean => { rateLimitBuckets.delete(key); } } + if (rateLimitBuckets.size >= RATE_LIMIT_MAX_TRACKED_CLIENTS) { + // If we've somehow hit the client cap, refuse the request. + return false; + } } rateLimitBuckets.set(clientIp, { count: 1, From 511efcbb30df2c2e298c453c2e19d9bb62998349 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan <37743469+CiaranMn@users.noreply.github.com> Date: Mon, 25 May 2026 18:01:09 +0100 Subject: [PATCH 20/21] Update libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts --- libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 7764d4abc89..1f52a74a04b 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -232,7 +232,6 @@ export const differentialEquationSchema = z "Module: `export default Dynamics((tokens, parameters) => …)`.", "`tokens` is THIS place's current tokens only — `Array<{ [elementName]: number }>` — NOT all places' tokens.", "MUST return an array of the SAME LENGTH where each entry is `{ [elementName]: derivative }` (i.e. dx/dt, NOT the new value).", - "The engine integrates with Euler: `next = current + derivative * dt`.", "Missing keys default to 0 silently, so return every element your colour type declares.", "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.damage_per_second`).", ].join(" "), From 67ba2f04190f9bbc6de81868084efdc31eaf6e85 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Mon, 25 May 2026 18:09:06 +0100 Subject: [PATCH 21/21] add changesets. update import --- .changeset/fluffy-masks-visit.md | 5 +++++ .changeset/polite-snakes-send.md | 5 +++++ .changeset/young-kids-laugh.md | 5 +++++ .../petrinaut-core/src/layout/calculate-graph-layout.ts | 2 +- 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/fluffy-masks-visit.md create mode 100644 .changeset/polite-snakes-send.md create mode 100644 .changeset/young-kids-laugh.md diff --git a/.changeset/fluffy-masks-visit.md b/.changeset/fluffy-masks-visit.md new file mode 100644 index 00000000000..cd47772c5b3 --- /dev/null +++ b/.changeset/fluffy-masks-visit.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +add arrow-right-arrow-left icon diff --git a/.changeset/polite-snakes-send.md b/.changeset/polite-snakes-send.md new file mode 100644 index 00000000000..af8a771d9b8 --- /dev/null +++ b/.changeset/polite-snakes-send.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +improve and expand instance action schemas diff --git a/.changeset/young-kids-laugh.md b/.changeset/young-kids-laugh.md new file mode 100644 index 00000000000..b822924d16a --- /dev/null +++ b/.changeset/young-kids-laugh.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +add AI assistant diff --git a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts index 3e0b0b8b389..0a01a4ea817 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts @@ -1,6 +1,6 @@ import ELK from "elkjs"; -import type { SDCPN } from "@hashintel/petrinaut-core"; +import type { SDCPN } from "../types/sdcpn"; import type { ElkNode } from "elkjs"; /**