Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ebf0d3d
H-5641: Extract virtual file generation and add global Distribution type
kube Feb 20, 2026
b7a426b
H-5641: Change DistributionGaussian to Distribution.Gaussian namespace
kube Feb 20, 2026
172e432
H-5641: Add runtime support for probabilistic transition kernels
kube Feb 20, 2026
9813382
H-5641: Add changeset for probabilistic transition kernels
kube Feb 20, 2026
f7d34b4
H-5641: Add Distribution.map() and satellites launcher example
kube Feb 21, 2026
c3e4427
H-5641: Fix ESLint errors in distribution and transition code
kube Feb 21, 2026
b7ee80c
H-5641: Add Distribution.Lognormal(mu, sigma) support
kube Mar 2, 2026
e60e8f4
Remove width/height from example
kube Mar 5, 2026
76a1dab
Update changeset to patch
kube Mar 5, 2026
0580476
H-5641: Fix review feedback and enable supply chain examples
kube Mar 9, 2026
ed260ed
Update changeset
kube Mar 9, 2026
0e399ad
Rename defect_threshold to quality_threshold in supply chain example
kube Mar 9, 2026
676b471
Fix
kube Mar 9, 2026
7d91cc5
Fix
kube Mar 9, 2026
033dfd6
Last fix
kube Mar 9, 2026
51a7939
H-6281: Add undo/redo support to Petrinaut demo app
kube Mar 4, 2026
584586a
H-6281: Clear drag state after drag ends to prevent stale position re…
kube Mar 4, 2026
157374a
H-6281: Add changeset for undo/redo support
kube Mar 4, 2026
1eb324e
H-6281: Remove window.confirm dialogs for deletions
kube Mar 5, 2026
e1f5a0f
Update changeset to patch
kube Mar 5, 2026
729fe1f
H-6281: Replace undo/redo buttons with version history menu in TopBar
kube Mar 5, 2026
0c4b53b
H-6281: Fix undo/redo bugs from AI review
kube Mar 5, 2026
3eeab7b
H-6281: Fix stale closure in multi-node drags and deduplicate input c…
kube Mar 9, 2026
b765bb1
H-6281: Improve version history menu UX
kube Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/probabilistic-transition-kernels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add probability distribution support to transition kernels (`Distribution.Gaussian`, `Distribution.Uniform`, `Distribution.Lognormal`)
5 changes: 5 additions & 0 deletions .changeset/undo-redo-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add optional undo/redo support with version history UI, keyboard shortcuts (Cmd|Ctrl+Z / Cmd|Ctrl+Shift+Z), and drag debouncing
120 changes: 103 additions & 17 deletions libs/@hashintel/petrinaut/demo-site/main/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { produce } from "immer";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import type { MinimalNetMetadata, SDCPN } from "../../src/core/types/sdcpn";
import { convertOldFormatToSDCPN } from "../../src/old-formats/convert-old-format";
Expand All @@ -9,6 +9,7 @@ import {
type SDCPNInLocalStorage,
useLocalStorageSDCPNs,
} from "./app/use-local-storage-sdcpns";
import { useUndoRedo } from "./app/use-undo-redo";

export const DevApp = () => {
const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs();
Expand Down Expand Up @@ -72,25 +73,109 @@ export const DevApp = () => {
[currentNetId, setStoredSDCPNs],
);

const mutatePetriNetDefinition = useCallback(
(definitionMutationFn: (draft: SDCPN) => void) => {
if (!currentNetId) {
return;
const setSDCPNDirectly = (sdcpn: SDCPN) => {
if (!currentNetId) {
return;
}
setStoredSDCPNs((prev) =>
produce(prev, (draft) => {
if (draft[currentNetId]) {
draft[currentNetId].sdcpn = sdcpn;
}
}),
);
};

const emptySDCPN: SDCPN = {
places: [],
transitions: [],
types: [],
parameters: [],
differentialEquations: [],
};

const {
pushState,
undo: undoHistory,
redo: redoHistory,
goToIndex: goToHistoryIndex,
canUndo,
canRedo,
history,
currentIndex,
reset: resetHistory,
} = useUndoRedo(
currentNet && !isOldFormatInLocalStorage(currentNet)
? currentNet.sdcpn
: emptySDCPN,
);

const mutatePetriNetDefinition = (
definitionMutationFn: (draft: SDCPN) => void,
) => {
if (!currentNetId) {
return;
}

let newSDCPN: SDCPN | undefined;

// Use the updater form so that multiple calls before a re-render
// (e.g. multi-node drag end) each see the latest state.
setStoredSDCPNs((prev) => {
const net = prev[currentNetId];
if (!net || isOldFormatInLocalStorage(net)) {
return prev;
}
const updatedSDCPN = produce(net.sdcpn, definitionMutationFn);
newSDCPN = updatedSDCPN;
return {
...prev,
[currentNetId]: {
...net,
sdcpn: updatedSDCPN,
},
};
});

setStoredSDCPNs((prev) =>
produce(prev, (draft) => {
if (draft[currentNetId]) {
draft[currentNetId].sdcpn = produce(
draft[currentNetId].sdcpn,
definitionMutationFn,
);
}
}),
);
if (newSDCPN) {
pushState(newSDCPN);
}
};

const prevNetIdRef = useRef(currentNetId);
useEffect(() => {
if (currentNetId !== prevNetIdRef.current) {
prevNetIdRef.current = currentNetId;
if (currentNet && !isOldFormatInLocalStorage(currentNet)) {
resetHistory(currentNet.sdcpn);
}
}
}, [currentNetId, currentNet, resetHistory]);

const undoRedo = {
undo: () => {
const sdcpn = undoHistory();
if (sdcpn) {
setSDCPNDirectly(sdcpn);
}
},
[currentNetId, setStoredSDCPNs],
);
redo: () => {
const sdcpn = redoHistory();
if (sdcpn) {
setSDCPNDirectly(sdcpn);
}
},
canUndo,
canRedo,
history: history.current,
currentIndex,
goToIndex: (index: number) => {
const sdcpn = goToHistoryIndex(index);
if (sdcpn) {
setSDCPNDirectly(sdcpn);
}
},
};

// Initialize with a default net if none exists
useEffect(() => {
Expand Down Expand Up @@ -168,6 +253,7 @@ export const DevApp = () => {
readonly={false}
setTitle={setTitle}
title={currentNet.title}
undoRedo={undoRedo}
/>
</div>
);
Expand Down
151 changes: 151 additions & 0 deletions libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useRef, useState } from "react";

import type { SDCPN } from "../../../src/core/types/sdcpn";
import { isSDCPNEqual } from "../../../src/petrinaut";

export type HistoryEntry = {
sdcpn: SDCPN;
timestamp: string;
};

const MAX_HISTORY = 50;
const DEBOUNCE_MS = 500;

export function useUndoRedo(initialSDCPN: SDCPN) {
const historyRef = useRef<HistoryEntry[]>([
{ sdcpn: initialSDCPN, timestamp: new Date().toISOString() },
]);
const currentIndexRef = useRef(0);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

/**
* Snapshot of render-visible values derived from the refs.
* Updated via `bump()` after every mutation so consumers re-render
* without reading refs during render.
*/
const [snapshot, setSnapshot] = useState({
currentIndex: 0,
historyLength: 1,
});
const bump = () =>
setSnapshot({
currentIndex: currentIndexRef.current,
historyLength: historyRef.current.length,
});

const canUndo = snapshot.currentIndex > 0;
const canRedo = snapshot.currentIndex < snapshot.historyLength - 1;

const pushState = (sdcpn: SDCPN) => {
const current = historyRef.current[currentIndexRef.current];

// No-op detection
if (current && isSDCPNEqual(current.sdcpn, sdcpn)) {
return;
}

// Debounce: coalesce rapid mutations into one entry
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
// Replace the entry at currentIndex (it was a pending debounced entry)
historyRef.current[currentIndexRef.current] = {
sdcpn,
timestamp: new Date().toISOString(),
};

debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
}, DEBOUNCE_MS);

bump();
return;
}

// Truncate any redo entries
historyRef.current = historyRef.current.slice(
0,
currentIndexRef.current + 1,
);

// Push new entry
historyRef.current.push({
sdcpn,
timestamp: new Date().toISOString(),
});

// Enforce max history size
if (historyRef.current.length > MAX_HISTORY) {
historyRef.current = historyRef.current.slice(
historyRef.current.length - MAX_HISTORY,
);
}

currentIndexRef.current = historyRef.current.length - 1;

// Start debounce window
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
}, DEBOUNCE_MS);

bump();
};

const clearDebounce = () => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};

const undo = (): SDCPN | null => {
if (currentIndexRef.current <= 0) {
return null;
}
clearDebounce();
currentIndexRef.current -= 1;
bump();
return historyRef.current[currentIndexRef.current]!.sdcpn;
};

const redo = (): SDCPN | null => {
if (currentIndexRef.current >= historyRef.current.length - 1) {
return null;
}
clearDebounce();
currentIndexRef.current += 1;
bump();
return historyRef.current[currentIndexRef.current]!.sdcpn;
};

const goToIndex = (index: number): SDCPN | null => {
if (index < 0 || index >= historyRef.current.length) {
return null;
}
clearDebounce();
currentIndexRef.current = index;
bump();
return historyRef.current[index]!.sdcpn;
};

const reset = (sdcpn: SDCPN) => {
historyRef.current = [{ sdcpn, timestamp: new Date().toISOString() }];
currentIndexRef.current = 0;
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
bump();
};

return {
pushState,
undo,
redo,
goToIndex,
canUndo,
canRedo,
history: historyRef,
currentIndex: snapshot.currentIndex,
reset,
};
}
15 changes: 14 additions & 1 deletion libs/@hashintel/petrinaut/src/components/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ const itemDescriptionStyle = css({
});

const itemSuffixStyle = css({
display: "flex",
alignItems: "center",
gap: "1",
marginLeft: "auto",
fontSize: "xs",
color: "neutral.s80",
Expand Down Expand Up @@ -204,6 +207,10 @@ export interface MenuProps {
items: MenuItem[] | MenuGroup[];
/** Whether to animate the menu open/close. Adapts direction automatically. */
animated?: boolean;
/** Maximum height of the menu content (enables scrolling). */
maxHeight?: string;
/** Whether to close the menu when an item is selected. Defaults to true. */
closeOnSelect?: boolean;
/** Preferred placement of the menu relative to the trigger. */
placement?:
| "top"
Expand Down Expand Up @@ -265,6 +272,8 @@ export const Menu: React.FC<MenuProps> = ({
trigger,
items,
animated,
maxHeight,
closeOnSelect,
placement,
}) => {
const portalContainerRef = usePortalContainerRef();
Expand All @@ -274,12 +283,16 @@ export const Menu: React.FC<MenuProps> = ({
<ArkMenu.Root
lazyMount={!!animated}
unmountOnExit={!!animated}
closeOnSelect={closeOnSelect}
positioning={placement ? { placement, gutter: 8 } : { gutter: 8 }}
>
<ArkMenu.Trigger asChild>{trigger}</ArkMenu.Trigger>
<Portal container={portalContainerRef}>
<ArkMenu.Positioner>
<ArkMenu.Content className={menuContentStyle({ animated })}>
<ArkMenu.Content
className={menuContentStyle({ animated })}
style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
>
{groups.map((group, groupIndex) => (
<div key={group.title ?? `group-${String(groupIndex)}`}>
{groupIndex > 0 && <div className={separatorStyle} />}
Expand Down
Loading
Loading