From 47f46c833dfd255fa25ca01f2b0e349ca6067f36 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 13 Mar 2026 00:14:26 +0100 Subject: [PATCH 1/6] Integrate Sentry feedback button into ViewportControls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `viewportActions` prop to Petrinaut that allows consumers to inject custom action buttons into the viewport controls panel. Use this to move the Sentry feedback trigger from a floating overlay into the controls, with a purple button style and bug report icon. - Add `ViewportAction` type with key, icon, label, tooltip, onClick, style, className, and ref fields - Thread `viewportActions` through Petrinaut → EditorView → SDCPNView → ViewportControls - Set `autoInject: false` on Sentry feedbackIntegration - Use React 19 ref cleanup to attach/detach Sentry feedback listener Co-Authored-By: Claude Opus 4.6 --- apps/petrinaut-website/package.json | 3 +- apps/petrinaut-website/src/main/app.tsx | 3 ++ .../src/main/app/sentry-feedback-button.tsx | 37 +++++++++++++++++++ .../src/sentry/instrument.ts | 2 +- libs/@hashintel/petrinaut/src/petrinaut.tsx | 9 +++++ .../petrinaut/src/types/viewport-action.ts | 18 +++++++++ .../src/views/Editor/editor-view.tsx | 5 ++- .../SDCPN/components/viewport-controls.tsx | 20 +++++++++- .../petrinaut/src/views/SDCPN/sdcpn-view.tsx | 7 +++- 9 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx create mode 100644 libs/@hashintel/petrinaut/src/types/viewport-action.ts diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index e1d53688154..1a970488ae2 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -15,7 +15,8 @@ "@sentry/react": "10.22.0", "immer": "10.1.3", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-icons": "5.5.0" }, "devDependencies": { "@types/react": "19.2.7", diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 0a0f0fdd54a..42a3b095b81 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -3,6 +3,7 @@ import { convertOldFormatToSDCPN, Petrinaut } from "@hashintel/petrinaut"; import { produce } from "immer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; import { isOldFormatInLocalStorage, type SDCPNInLocalStorage, @@ -11,6 +12,7 @@ import { import { useUndoRedo } from "./app/use-undo-redo"; export const DevApp = () => { + const sentryFeedbackAction = useSentryFeedbackAction(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); const [currentNetId, setCurrentNetId] = useState(null); @@ -253,6 +255,7 @@ export const DevApp = () => { setTitle={setTitle} title={currentNet.title} undoRedo={undoRedo} + viewportActions={[sentryFeedbackAction]} /> ); diff --git a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx new file mode 100644 index 00000000000..41511847b77 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx @@ -0,0 +1,37 @@ +import type { ViewportAction } from "@hashintel/petrinaut"; +import * as Sentry from "@sentry/react"; +import { MdBugReport } from "react-icons/md"; + +const feedbackButtonStyle: React.CSSProperties = { + backgroundColor: "#8b5cf6dd", + borderColor: "#7c3aed", + color: "#fff", +}; + +const icon = ; + +export function useSentryFeedbackAction(): ViewportAction { + return { + key: "sentry-feedback", + icon, + label: "Give feedback", + tooltip: "Give feedback", + style: feedbackButtonStyle, + ref: (node: HTMLButtonElement | null) => { + if (!node) { + return; + } + + const feedback = Sentry.getFeedback(); + const unsubscribe = feedback?.attachTo(node, { + formTitle: "Give feedback", + messagePlaceholder: "Report a bug or suggest an improvement", + submitButtonLabel: "Submit feedback", + }); + + return () => { + unsubscribe?.(); + }; + }, + }; +} diff --git a/apps/petrinaut-website/src/sentry/instrument.ts b/apps/petrinaut-website/src/sentry/instrument.ts index 7cf1372eea5..6a24681a2db 100644 --- a/apps/petrinaut-website/src/sentry/instrument.ts +++ b/apps/petrinaut-website/src/sentry/instrument.ts @@ -8,8 +8,8 @@ Sentry.init({ Sentry.browserApiErrorsIntegration(), Sentry.browserTracingIntegration(), Sentry.feedbackIntegration({ + autoInject: false, colorScheme: "system", - triggerLabel: "", formTitle: "Give feedback", messagePlaceholder: "Report a bug or suggest an improvement", submitButtonLabel: "Submit feedback", diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index d3ea8d69b4b..2ec4665f108 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -25,6 +25,7 @@ import { type UndoRedoContextValue, } from "./state/undo-redo-context"; import { UserSettingsProvider } from "./state/user-settings-provider"; +import type { ViewportAction } from "./types/viewport-action"; import { EditorView } from "./views/Editor/editor-view"; export { isSDCPNEqual } from "./lib/deep-equal"; @@ -41,6 +42,7 @@ export type { }; export type { UndoRedoContextValue as UndoRedoProps } from "./state/undo-redo-context"; +export type { ViewportAction } from "./types/viewport-action"; export type PetrinautProps = { /** @@ -103,11 +105,17 @@ export type PetrinautProps = { * undo/redo buttons in the top bar and register keyboard shortcuts. */ undoRedo?: UndoRedoContextValue; + /** + * Optional additional action buttons to render in the viewport controls panel, + * after the built-in buttons. + */ + viewportActions?: ViewportAction[]; }; export const Petrinaut: FunctionComponent = ({ hideNetManagementControls, undoRedo, + viewportActions, ...rest }) => { return ( @@ -122,6 +130,7 @@ export const Petrinaut: FunctionComponent = ({ diff --git a/libs/@hashintel/petrinaut/src/types/viewport-action.ts b/libs/@hashintel/petrinaut/src/types/viewport-action.ts new file mode 100644 index 00000000000..dfea8c8f662 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/types/viewport-action.ts @@ -0,0 +1,18 @@ +export type ViewportAction = { + /** Unique key for React rendering. */ + key: string; + /** Icon element to render inside the button. */ + icon: React.ReactNode; + /** Accessible label for the button. */ + label: string; + /** Tooltip text shown on hover. */ + tooltip: string; + /** Click handler. */ + onClick?: () => void; + /** Inline styles applied to the button element. */ + style?: React.CSSProperties; + /** CSS class name applied to the button element. */ + className?: string; + /** Ref callback to access the underlying button DOM element. */ + ref?: React.Ref; +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 0fe4dc934c0..3efa6428d6c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -14,6 +14,7 @@ import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; import { SDCPNContext } from "../../state/sdcpn-context"; import { useSelectionCleanup } from "../../state/use-selection-cleanup"; +import type { ViewportAction } from "../../types/viewport-action"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { TopBar } from "./components/TopBar/top-bar"; @@ -57,8 +58,10 @@ const portalContainerStyle = css({ */ export const EditorView = ({ hideNetManagementControls, + viewportActions, }: { hideNetManagementControls: boolean; + viewportActions?: ViewportAction[]; }) => { // Get data from sdcpn-store const { @@ -259,7 +262,7 @@ export const EditorView = ({ {/* SDCPN Visualization */} - + {/* Bottom Panel - Diagnostics, Simulation Settings */} diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx index e079354dcd8..08ff6f7c045 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-controls.tsx @@ -12,6 +12,7 @@ import { import { IconButton } from "../../../components/icon-button"; import { PANEL_MARGIN } from "../../../constants/ui"; import { EditorContext } from "../../../state/editor-context"; +import type { ViewportAction } from "../../../types/viewport-action"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; const BASE_OFFSET = 12; @@ -35,7 +36,9 @@ const animatingStyle = cva({ }, }); -export const ViewportControls: React.FC = () => { +export const ViewportControls: React.FC<{ + viewportActions?: ViewportAction[]; +}> = ({ viewportActions }) => { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const { zoomIn, zoomOut } = useReactFlow(); const { @@ -110,6 +113,21 @@ export const ViewportControls: React.FC = () => { open={isSettingsOpen} onOpenChange={(details) => setIsSettingsOpen(details.open)} /> + {viewportActions?.map((action) => ( + + {action.icon} + + ))} ); }; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx index 6088c7e43d0..5f0a0a9aea1 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx @@ -14,6 +14,7 @@ import { EditorContext } from "../../state/editor-context"; import { SDCPNContext } from "../../state/sdcpn-context"; import { useIsReadOnly } from "../../state/use-is-read-only"; import { UserSettingsContext } from "../../state/user-settings-context"; +import type { ViewportAction } from "../../types/viewport-action"; import { Arc } from "./components/arc"; import { ClassicPlaceNode } from "./components/classic-place-node"; import { ClassicTransitionNode } from "./components/classic-transition-node"; @@ -54,7 +55,9 @@ const canvasContainerStyle = css({ * SDCPNView is responsible for rendering the SDCPN using ReactFlow. * It reads from SDCPNContext and EditorContext, and handles all ReactFlow interactions. */ -export const SDCPNView: React.FC = () => { +export const SDCPNView: React.FC<{ + viewportActions?: ViewportAction[]; +}> = ({ viewportActions }) => { const canvasContainer = useRef(null); const [reactFlowInstance, setReactFlowInstance] = useState(null); @@ -335,7 +338,7 @@ export const SDCPNView: React.FC = () => { > - + ); From 0b34f57b2fa804789151c67e489f0337b45940b2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 13 Mar 2026 00:15:54 +0100 Subject: [PATCH 2/6] Update yarn.lock for react-icons dependency Co-Authored-By: Claude Opus 4.6 --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 49fd0ac25d6..261c4f5a514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -792,6 +792,7 @@ __metadata: oxlint: "npm:1.55.0" react: "npm:19.2.3" react-dom: "npm:19.2.3" + react-icons: "npm:5.5.0" vite: "npm:8.0.0-beta.18" languageName: unknown linkType: soft From e61b5cd72b65bfa09365f7706cf75231d111883a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 14:43:17 +0100 Subject: [PATCH 3/6] Clean and force "use memo" for React Compiler --- .../src/main/app/sentry-feedback-button.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx index 41511847b77..a5cf4633f4d 100644 --- a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx +++ b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx @@ -1,5 +1,6 @@ import type { ViewportAction } from "@hashintel/petrinaut"; import * as Sentry from "@sentry/react"; +import { useCallback, useMemo } from "react"; import { MdBugReport } from "react-icons/md"; const feedbackButtonStyle: React.CSSProperties = { @@ -11,27 +12,28 @@ const feedbackButtonStyle: React.CSSProperties = { const icon = ; export function useSentryFeedbackAction(): ViewportAction { + // Wouldn't be optimized by React Compiler otherwise + "use memo"; + return { key: "sentry-feedback", icon, label: "Give feedback", tooltip: "Give feedback", style: feedbackButtonStyle, - ref: (node: HTMLButtonElement | null) => { - if (!node) { + ref: (node) => { + const feedback = Sentry.getFeedback(); + + if (!node || !feedback) { return; } - const feedback = Sentry.getFeedback(); - const unsubscribe = feedback?.attachTo(node, { + // Attach feedback to the button and return the unsubscribe function + return feedback.attachTo(node, { formTitle: "Give feedback", messagePlaceholder: "Report a bug or suggest an improvement", submitButtonLabel: "Submit feedback", }); - - return () => { - unsubscribe?.(); - }; }, }; } From f0ec4085327a66a0f3d009380618436a92c404d9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 14:46:34 +0100 Subject: [PATCH 4/6] Remove manual memoization in DevApp, let React Compiler handle it Co-Authored-By: Claude Opus 4.6 --- apps/petrinaut-website/src/main/app.tsx | 111 +++++++++++------------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index 42a3b095b81..9b911f91207 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -1,7 +1,7 @@ import type { MinimalNetMetadata, SDCPN } from "@hashintel/petrinaut"; import { convertOldFormatToSDCPN, Petrinaut } from "@hashintel/petrinaut"; import { produce } from "immer"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; import { @@ -11,68 +11,63 @@ import { } from "./app/use-local-storage-sdcpns"; import { useUndoRedo } from "./app/use-undo-redo"; +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], +}; + export const DevApp = () => { const sentryFeedbackAction = useSentryFeedbackAction(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); const [currentNetId, setCurrentNetId] = useState(null); - const currentNet = useMemo(() => { - if (!currentNetId) { - return null; - } - return storedSDCPNs[currentNetId] ?? null; - }, [currentNetId, storedSDCPNs]); - - const existingNets: MinimalNetMetadata[] = useMemo(() => { - return Object.values(storedSDCPNs) - .filter( - (net): net is SDCPNInLocalStorage => !isOldFormatInLocalStorage(net), - ) - .map((net) => ({ - netId: net.id, - title: net.title, - })); - }, [storedSDCPNs]); - - const createNewNet = useCallback( - (params: { - petriNetDefinition: SDCPN; - title: string; - }) => { - const newNet: SDCPNInLocalStorage = { - id: `net-${Date.now()}`, - title: params.title, - sdcpn: params.petriNetDefinition, - lastUpdated: new Date().toISOString(), - }; - - setStoredSDCPNs((prev) => ({ ...prev, [newNet.id]: newNet })); - setCurrentNetId(newNet.id); - }, - [setStoredSDCPNs], - ); + const currentNet = currentNetId ? (storedSDCPNs[currentNetId] ?? null) : null; + + const existingNets: MinimalNetMetadata[] = Object.values(storedSDCPNs) + .filter( + (net): net is SDCPNInLocalStorage => !isOldFormatInLocalStorage(net), + ) + .map((net) => ({ + netId: net.id, + title: net.title, + })); + + const createNewNet = (params: { + petriNetDefinition: SDCPN; + title: string; + }) => { + const newNet: SDCPNInLocalStorage = { + id: `net-${Date.now()}`, + title: params.title, + sdcpn: params.petriNetDefinition, + lastUpdated: new Date().toISOString(), + }; + + setStoredSDCPNs((prev) => ({ ...prev, [newNet.id]: newNet })); + setCurrentNetId(newNet.id); + }; - const loadPetriNet = useCallback((petriNetId: string) => { + const loadPetriNet = (petriNetId: string) => { setCurrentNetId(petriNetId); - }, []); + }; - const setTitle = useCallback( - (title: string) => { - if (!currentNetId) { - return; - } + const setTitle = (title: string) => { + if (!currentNetId) { + return; + } - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - if (draft[currentNetId] && "title" in draft[currentNetId]) { - draft[currentNetId].title = title; - } - }), - ); - }, - [currentNetId, setStoredSDCPNs], - ); + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + if (draft[currentNetId] && "title" in draft[currentNetId]) { + draft[currentNetId].title = title; + } + }), + ); + }; const setSDCPNDirectly = (sdcpn: SDCPN) => { if (!currentNetId) { @@ -87,14 +82,6 @@ export const DevApp = () => { ); }; - const emptySDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - parameters: [], - differentialEquations: [], - }; - const { pushState, undo: undoHistory, @@ -108,7 +95,7 @@ export const DevApp = () => { } = useUndoRedo( currentNet && !isOldFormatInLocalStorage(currentNet) ? currentNet.sdcpn - : emptySDCPN, + : EMPTY_SDCPN, ); const mutatePetriNetDefinition = ( From 888cdb809923f47e3ffeb2466fe45e67a5678c90 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 15:19:29 +0100 Subject: [PATCH 5/6] Remove unused useCallback/useMemo imports Co-Authored-By: Claude Opus 4.6 --- apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx index a5cf4633f4d..f5d4a04f216 100644 --- a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx +++ b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx @@ -1,6 +1,5 @@ import type { ViewportAction } from "@hashintel/petrinaut"; import * as Sentry from "@sentry/react"; -import { useCallback, useMemo } from "react"; import { MdBugReport } from "react-icons/md"; const feedbackButtonStyle: React.CSSProperties = { From e95f1636a953931cd958afe0f62338758bd3d107 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 15:22:00 +0100 Subject: [PATCH 6/6] Lint --- .../petrinaut-website/src/main/app/sentry-feedback-button.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx index f5d4a04f216..1da6f4d18d9 100644 --- a/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx +++ b/apps/petrinaut-website/src/main/app/sentry-feedback-button.tsx @@ -8,15 +8,13 @@ const feedbackButtonStyle: React.CSSProperties = { color: "#fff", }; -const icon = ; - export function useSentryFeedbackAction(): ViewportAction { // Wouldn't be optimized by React Compiler otherwise "use memo"; return { key: "sentry-feedback", - icon, + icon: , label: "Give feedback", tooltip: "Give feedback", style: feedbackButtonStyle,