@stigmer/react 3.12.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/execution/useCreateAgentExecution.d.ts +3 -2
  2. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  3. package/execution/useCreateAgentExecution.js.map +1 -1
  4. package/internal/isUnimplemented.d.ts +17 -0
  5. package/internal/isUnimplemented.d.ts.map +1 -0
  6. package/internal/isUnimplemented.js +26 -0
  7. package/internal/isUnimplemented.js.map +1 -0
  8. package/mcp-server/McpServerConnectDialog.js +7 -3
  9. package/mcp-server/McpServerConnectDialog.js.map +1 -1
  10. package/mcp-server/useMcpServerCredentials.d.ts +38 -10
  11. package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
  12. package/mcp-server/useMcpServerCredentials.js +50 -7
  13. package/mcp-server/useMcpServerCredentials.js.map +1 -1
  14. package/mcp-server/useOrgOAuthApp.d.ts +22 -0
  15. package/mcp-server/useOrgOAuthApp.d.ts.map +1 -1
  16. package/mcp-server/useOrgOAuthApp.js +39 -7
  17. package/mcp-server/useOrgOAuthApp.js.map +1 -1
  18. package/package.json +4 -4
  19. package/session/useSessionConversation.d.ts +4 -2
  20. package/session/useSessionConversation.d.ts.map +1 -1
  21. package/session/useSessionConversation.js.map +1 -1
  22. package/skill/useSkillVersions.d.ts.map +1 -1
  23. package/skill/useSkillVersions.js +1 -9
  24. package/skill/useSkillVersions.js.map +1 -1
  25. package/src/execution/useCreateAgentExecution.ts +3 -2
  26. package/src/internal/isUnimplemented.ts +26 -0
  27. package/src/mcp-server/McpServerConnectDialog.tsx +29 -11
  28. package/src/mcp-server/__tests__/VendorApprovalBlocked.surfaces.test.tsx +103 -5
  29. package/src/mcp-server/__tests__/byoaEditionDegradation.test.tsx +323 -0
  30. package/src/mcp-server/__tests__/useMcpServerCredentials.test.tsx +306 -0
  31. package/src/mcp-server/useMcpServerCredentials.ts +87 -19
  32. package/src/mcp-server/useOrgOAuthApp.ts +54 -8
  33. package/src/session/useSessionConversation.ts +4 -2
  34. package/src/skill/useSkillVersions.ts +1 -11
  35. package/src/workflow/WorkflowCanvasEditor.tsx +37 -7
  36. package/src/workflow/WorkflowExecutionGraph.tsx +26 -1
  37. package/src/workflow/WorkflowExecutionViewer.tsx +27 -13
  38. package/src/workflow/WorkflowNode.tsx +5 -2
  39. package/src/workflow/WorkflowRunDialog.tsx +28 -1
  40. package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +48 -1
  41. package/src/workflow/__tests__/WorkflowNode.test.tsx +93 -0
  42. package/src/workflow/__tests__/WorkflowRunDialog.test.tsx +127 -0
  43. package/src/workflow/__tests__/graph-history.test.ts +103 -0
  44. package/src/workflow/__tests__/overview-graph.test.ts +17 -0
  45. package/src/workflow/__tests__/useCanvasKeyboardShortcuts.undo.test.ts +115 -0
  46. package/src/workflow/__tests__/useWorkflowCanvas.undo.test.tsx +133 -0
  47. package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +16 -7
  48. package/src/workflow/instance/useDeleteWorkflowInstance.ts +9 -4
  49. package/src/workflow/layout/registry-dimensions.ts +2 -5
  50. package/src/workflow/node-shell/NodeShell.tsx +4 -0
  51. package/src/workflow/task-type-visual-registry.ts +5 -3
  52. package/src/workflow/useCanvasKeyboardShortcuts.ts +33 -3
  53. package/src/workflow/useGraphHistory.ts +22 -41
  54. package/src/workflow/useWorkflowCanvas.ts +12 -10
  55. package/src/workflow/useWorkflowSave.ts +2 -15
  56. package/src/workflow/workflow-graph-conversions.ts +25 -3
  57. package/workflow/WorkflowCanvasEditor.d.ts.map +1 -1
  58. package/workflow/WorkflowCanvasEditor.js +31 -6
  59. package/workflow/WorkflowCanvasEditor.js.map +1 -1
  60. package/workflow/WorkflowExecutionGraph.d.ts.map +1 -1
  61. package/workflow/WorkflowExecutionGraph.js +27 -1
  62. package/workflow/WorkflowExecutionGraph.js.map +1 -1
  63. package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
  64. package/workflow/WorkflowExecutionViewer.js +26 -12
  65. package/workflow/WorkflowExecutionViewer.js.map +1 -1
  66. package/workflow/WorkflowNode.d.ts.map +1 -1
  67. package/workflow/WorkflowNode.js +2 -2
  68. package/workflow/WorkflowNode.js.map +1 -1
  69. package/workflow/WorkflowRunDialog.d.ts +11 -1
  70. package/workflow/WorkflowRunDialog.d.ts.map +1 -1
  71. package/workflow/WorkflowRunDialog.js +16 -2
  72. package/workflow/WorkflowRunDialog.js.map +1 -1
  73. package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
  74. package/workflow/instance/CreateWorkflowInstanceDialog.js +10 -2
  75. package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
  76. package/workflow/instance/useDeleteWorkflowInstance.d.ts +9 -4
  77. package/workflow/instance/useDeleteWorkflowInstance.d.ts.map +1 -1
  78. package/workflow/instance/useDeleteWorkflowInstance.js +4 -2
  79. package/workflow/instance/useDeleteWorkflowInstance.js.map +1 -1
  80. package/workflow/layout/registry-dimensions.d.ts.map +1 -1
  81. package/workflow/layout/registry-dimensions.js +2 -5
  82. package/workflow/layout/registry-dimensions.js.map +1 -1
  83. package/workflow/node-shell/NodeShell.js +1 -1
  84. package/workflow/node-shell/NodeShell.js.map +1 -1
  85. package/workflow/task-type-visual-registry.d.ts.map +1 -1
  86. package/workflow/task-type-visual-registry.js +5 -3
  87. package/workflow/task-type-visual-registry.js.map +1 -1
  88. package/workflow/useCanvasKeyboardShortcuts.d.ts +15 -4
  89. package/workflow/useCanvasKeyboardShortcuts.d.ts.map +1 -1
  90. package/workflow/useCanvasKeyboardShortcuts.js +25 -4
  91. package/workflow/useCanvasKeyboardShortcuts.js.map +1 -1
  92. package/workflow/useGraphHistory.d.ts +16 -9
  93. package/workflow/useGraphHistory.d.ts.map +1 -1
  94. package/workflow/useGraphHistory.js +19 -33
  95. package/workflow/useGraphHistory.js.map +1 -1
  96. package/workflow/useWorkflowCanvas.d.ts +1 -3
  97. package/workflow/useWorkflowCanvas.d.ts.map +1 -1
  98. package/workflow/useWorkflowCanvas.js +15 -9
  99. package/workflow/useWorkflowCanvas.js.map +1 -1
  100. package/workflow/useWorkflowSave.d.ts.map +1 -1
  101. package/workflow/useWorkflowSave.js +2 -15
  102. package/workflow/useWorkflowSave.js.map +1 -1
  103. package/workflow/workflow-graph-conversions.d.ts +19 -1
  104. package/workflow/workflow-graph-conversions.d.ts.map +1 -1
  105. package/workflow/workflow-graph-conversions.js +17 -3
  106. package/workflow/workflow-graph-conversions.js.map +1 -1
@@ -0,0 +1,93 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { render, cleanup } from "@testing-library/react";
3
+ import type { NodeProps } from "@xyflow/react";
4
+ import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
5
+ import { WorkflowNode } from "../WorkflowNode";
6
+ import { START_NODE_ID, END_NODE_ID } from "../workflow-graph-model";
7
+ import type { CanvasTaskNodeData } from "../workflow-graph-conversions";
8
+
9
+ // NodeHandles and NodeActions render @xyflow/react primitives (Handle,
10
+ // NodeToolbar) that throw outside a live React Flow canvas. Stub them so
11
+ // WorkflowNode's own DOM contract can be asserted in isolation — the
12
+ // established pattern for React Flow-adjacent component tests in this suite.
13
+ vi.mock("../node-shell/NodeHandles.js", () => ({
14
+ NodeHandles: () => null,
15
+ }));
16
+ vi.mock("../node-shell/NodeActions.js", () => ({
17
+ NodeActions: () => null,
18
+ }));
19
+
20
+ function makeData(overrides: Partial<CanvasTaskNodeData>): CanvasTaskNodeData {
21
+ return {
22
+ taskName: "analyze",
23
+ kind: WorkflowTaskKind.agent_call,
24
+ kindString: "agent_call",
25
+ category: "ai",
26
+ visualClass: "task-card",
27
+ displayName: "Agent Call",
28
+ ariaShapeLabel: "card",
29
+ config: {},
30
+ isSentinel: false,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function renderNode(id: string, data: CanvasTaskNodeData) {
36
+ const props = { id, data, selected: false } as unknown as NodeProps & {
37
+ data: CanvasTaskNodeData;
38
+ };
39
+ return render(<WorkflowNode {...props} />);
40
+ }
41
+
42
+ afterEach(cleanup);
43
+
44
+ describe("WorkflowNode data-task-kind contract (oss#581)", () => {
45
+ test("task nodes expose their kind string as data-task-kind", () => {
46
+ const { container } = renderNode("analyze", makeData({}));
47
+ const node = container.querySelector('[role="button"]');
48
+ expect(node).not.toBeNull();
49
+ expect(node!.getAttribute("data-task-kind")).toBe("agent_call");
50
+ });
51
+
52
+ test("sentinel nodes carry no data-task-kind attribute", () => {
53
+ for (const [id, taskName] of [
54
+ [START_NODE_ID, "Start"],
55
+ [END_NODE_ID, "End"],
56
+ ] as const) {
57
+ const { container, unmount } = renderNode(
58
+ id,
59
+ makeData({
60
+ taskName,
61
+ kind: WorkflowTaskKind.workflow_task_kind_unspecified,
62
+ kindString: id,
63
+ category: id === START_NODE_ID ? "start" : "end",
64
+ visualClass: "terminal-pill",
65
+ displayName: taskName,
66
+ ariaShapeLabel: "pill",
67
+ isSentinel: true,
68
+ }),
69
+ );
70
+ const node = container.querySelector('[role="button"]');
71
+ expect(node).not.toBeNull();
72
+ expect(node!.hasAttribute("data-task-kind")).toBe(false);
73
+ unmount();
74
+ }
75
+ });
76
+
77
+ test("the internal unknown_* enum fallback never reaches the DOM", () => {
78
+ const { container } = renderNode(
79
+ START_NODE_ID,
80
+ makeData({
81
+ taskName: "Start",
82
+ kind: WorkflowTaskKind.workflow_task_kind_unspecified,
83
+ kindString: START_NODE_ID,
84
+ category: "start",
85
+ visualClass: "terminal-pill",
86
+ displayName: "Start",
87
+ ariaShapeLabel: "pill",
88
+ isSentinel: true,
89
+ }),
90
+ );
91
+ expect(container.querySelector('[data-task-kind^="unknown_"]')).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,127 @@
1
+ // Tests for WorkflowRunDialog's `initialInstanceId` preselection (issue
2
+ // #582): a row-level "Run" must open the dialog with that instance already
3
+ // selected, while reopening without one resets to the server-resolved
4
+ // default. Selection is asserted through the rendered instance <select> —
5
+ // the observable surface — with the real useRunWorkflowFlow underneath.
6
+
7
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
8
+ import { render, screen, cleanup } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import type { Stigmer } from "@stigmer/sdk";
11
+ import { StigmerContext } from "../../context";
12
+ import { WorkflowRunDialog } from "../WorkflowRunDialog";
13
+
14
+ beforeAll(() => {
15
+ // happy-dom does not implement the native <dialog> modal API.
16
+ HTMLDialogElement.prototype.showModal = function showModal() {
17
+ this.open = true;
18
+ };
19
+ HTMLDialogElement.prototype.close = function close() {
20
+ this.open = false;
21
+ };
22
+ });
23
+
24
+ afterEach(() => {
25
+ cleanup();
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ function makeWorkflow() {
30
+ return {
31
+ metadata: { id: "wf_1", name: "my-workflow", slug: "my-workflow" },
32
+ spec: { env: {} },
33
+ } as never;
34
+ }
35
+
36
+ function makeInstance(id: string, name: string) {
37
+ return {
38
+ metadata: { id, name, slug: name },
39
+ spec: { environmentRefs: [] },
40
+ } as never;
41
+ }
42
+
43
+ // Two user instances so the picker always renders (it needs >= 1 user
44
+ // instance when defaultInstanceId is provided).
45
+ const DEFAULT_INSTANCE_ID = "wfi_default";
46
+ const INSTANCES = [
47
+ makeInstance("wfi_a", "instance-a"),
48
+ makeInstance("wfi_b", "instance-b"),
49
+ ];
50
+
51
+ const mockClient = {
52
+ workflowExecution: { create: vi.fn() },
53
+ environment: { getByReference: vi.fn() },
54
+ } as unknown as Stigmer;
55
+
56
+ function renderDialog(props?: {
57
+ open?: boolean;
58
+ initialInstanceId?: string | null;
59
+ }) {
60
+ const ui = (dialogProps?: {
61
+ open?: boolean;
62
+ initialInstanceId?: string | null;
63
+ }) => (
64
+ <StigmerContext.Provider value={mockClient}>
65
+ <WorkflowRunDialog
66
+ open={dialogProps?.open ?? true}
67
+ onOpenChange={vi.fn()}
68
+ org="acme"
69
+ workflow={makeWorkflow()}
70
+ instances={INSTANCES}
71
+ defaultInstanceId={DEFAULT_INSTANCE_ID}
72
+ initialInstanceId={dialogProps?.initialInstanceId}
73
+ onSuccess={vi.fn()}
74
+ onError={vi.fn()}
75
+ />
76
+ </StigmerContext.Provider>
77
+ );
78
+ const result = render(ui(props));
79
+ return {
80
+ ...result,
81
+ rerenderDialog: (nextProps?: {
82
+ open?: boolean;
83
+ initialInstanceId?: string | null;
84
+ }) => result.rerender(ui(nextProps)),
85
+ };
86
+ }
87
+
88
+ function instanceSelect(): HTMLSelectElement {
89
+ return screen.getByLabelText("Instance") as HTMLSelectElement;
90
+ }
91
+
92
+ describe("WorkflowRunDialog initialInstanceId", () => {
93
+ it("preselects the requested instance when the dialog opens", () => {
94
+ renderDialog({ initialInstanceId: "wfi_b" });
95
+
96
+ expect(instanceSelect().value).toBe("wfi_b");
97
+ });
98
+
99
+ it("defaults to the server-resolved option when omitted", () => {
100
+ renderDialog();
101
+
102
+ // "" is the "Default (no specific configuration)" option.
103
+ expect(instanceSelect().value).toBe("");
104
+ });
105
+
106
+ it("falls back to the default option when the id is stale", () => {
107
+ renderDialog({ initialInstanceId: "wfi_deleted" });
108
+
109
+ expect(instanceSelect().value).toBe("");
110
+ });
111
+
112
+ it("applies the fresh preselection on each open transition", () => {
113
+ const { rerenderDialog } = renderDialog({ initialInstanceId: "wfi_a" });
114
+ expect(instanceSelect().value).toBe("wfi_a");
115
+
116
+ // Close, then reopen targeting a different instance (a second row's
117
+ // Run click) — the reset-then-preselect path must apply the new id.
118
+ rerenderDialog({ open: false, initialInstanceId: "wfi_a" });
119
+ rerenderDialog({ open: true, initialInstanceId: "wfi_b" });
120
+ expect(instanceSelect().value).toBe("wfi_b");
121
+
122
+ // Reopen with none (the header Run button) — back to the default.
123
+ rerenderDialog({ open: false, initialInstanceId: "wfi_b" });
124
+ rerenderDialog({ open: true, initialInstanceId: null });
125
+ expect(instanceSelect().value).toBe("");
126
+ });
127
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { GraphHistory, DeleteNodeCommand } from "../graph-commands";
3
+ import { yamlToGraph } from "../workflow-graph-conversions";
4
+ import { START_NODE_ID, END_NODE_ID } from "../workflow-graph-model";
5
+
6
+ // Direct coverage for the GraphHistory command stack and the
7
+ // DeleteNodeCommand round-trip. Before oss#588 nothing exercised
8
+ // GraphHistory itself — the canvas-sync bug lived one layer up
9
+ // (useGraphHistory/useWorkflowCanvas), but the stack's synchronous
10
+ // return-the-model contract is what that fix builds on, so it is
11
+ // pinned here.
12
+
13
+ const FIXTURE_YAML = `apiVersion: agentic.stigmer.ai/v1
14
+ kind: Workflow
15
+ metadata:
16
+ name: undo-fixture
17
+ spec:
18
+ document:
19
+ dsl: "1.0.0"
20
+ namespace: test
21
+ name: undo-fixture
22
+ version: "0.0.1"
23
+ tasks:
24
+ - name: step_1
25
+ kind: agent_call
26
+ task_config:
27
+ agent: "org/agent-slug"
28
+ message: "first"
29
+ flow:
30
+ then: step_2
31
+ - name: step_2
32
+ kind: agent_call
33
+ task_config:
34
+ agent: "org/agent-slug"
35
+ message: "second"
36
+ flow:
37
+ then: end
38
+ `;
39
+
40
+ function nodeIds(model: { nodes: readonly { id: string }[] }): string[] {
41
+ return model.nodes.map((n) => n.id);
42
+ }
43
+
44
+ describe("GraphHistory — delete/undo/redo round-trip", () => {
45
+ it("dispatch(DeleteNodeCommand) removes the node and its edges", () => {
46
+ const initial = yamlToGraph(FIXTURE_YAML);
47
+ const history = new GraphHistory(initial);
48
+
49
+ const next = history.dispatch(new DeleteNodeCommand("step_2", "step_2"));
50
+
51
+ expect(nodeIds(next)).not.toContain("step_2");
52
+ expect(next.edges.some((e) => e.source === "step_2" || e.target === "step_2")).toBe(false);
53
+ expect(history.canUndo).toBe(true);
54
+ expect(history.canRedo).toBe(false);
55
+ });
56
+
57
+ it("undo() synchronously returns the model with the node and edges restored", () => {
58
+ const initial = yamlToGraph(FIXTURE_YAML);
59
+ const history = new GraphHistory(initial);
60
+ const deleted = history.dispatch(new DeleteNodeCommand("step_2", "step_2"));
61
+ const removedEdgeCount = initial.edges.length - deleted.edges.length;
62
+ expect(removedEdgeCount).toBeGreaterThan(0);
63
+
64
+ const restored = history.undo();
65
+
66
+ expect(nodeIds(restored)).toContain("step_2");
67
+ expect(restored.edges).toHaveLength(initial.edges.length);
68
+ expect(history.current).toBe(restored);
69
+ expect(history.canUndo).toBe(false);
70
+ expect(history.canRedo).toBe(true);
71
+ });
72
+
73
+ it("redo() synchronously returns the model with the node removed again", () => {
74
+ const initial = yamlToGraph(FIXTURE_YAML);
75
+ const history = new GraphHistory(initial);
76
+ history.dispatch(new DeleteNodeCommand("step_2", "step_2"));
77
+ history.undo();
78
+
79
+ const redone = history.redo();
80
+
81
+ expect(nodeIds(redone)).not.toContain("step_2");
82
+ expect(history.canUndo).toBe(true);
83
+ expect(history.canRedo).toBe(false);
84
+ });
85
+
86
+ it("undo()/redo() on an empty stack return the current model unchanged", () => {
87
+ const initial = yamlToGraph(FIXTURE_YAML);
88
+ const history = new GraphHistory(initial);
89
+
90
+ expect(history.undo()).toBe(initial);
91
+ expect(history.redo()).toBe(initial);
92
+ });
93
+
94
+ it("sentinels survive the round-trip", () => {
95
+ const initial = yamlToGraph(FIXTURE_YAML);
96
+ const history = new GraphHistory(initial);
97
+ history.dispatch(new DeleteNodeCommand("step_1", "step_1"));
98
+ const restored = history.undo();
99
+
100
+ expect(nodeIds(restored)).toContain(START_NODE_ID);
101
+ expect(nodeIds(restored)).toContain(END_NODE_ID);
102
+ });
103
+ });
@@ -51,6 +51,23 @@ describe("Overview graph pipeline", () => {
51
51
  expect((taskNode!.data as CanvasTaskNodeData).isSentinel).toBeFalsy();
52
52
  });
53
53
 
54
+ test("sentinel nodes carry the pseudo-kind as kindString, never the enum fallback (oss#581)", () => {
55
+ const laid = applyDagreLayout(threeNodeGraph);
56
+ const { nodes } = toReactFlowElements(laid);
57
+ const startData = nodes.find((n) => n.id === START_NODE_ID)!.data as CanvasTaskNodeData;
58
+ const endData = nodes.find((n) => n.id === END_NODE_ID)!.data as CanvasTaskNodeData;
59
+ expect(startData.kindString).toBe(START_NODE_ID);
60
+ expect(endData.kindString).toBe(END_NODE_ID);
61
+ });
62
+
63
+ test("no node's kindString is ever the internal unknown_* fallback", () => {
64
+ const laid = applyDagreLayout(threeNodeGraph);
65
+ const { nodes } = toReactFlowElements(laid);
66
+ for (const node of nodes) {
67
+ expect((node.data as CanvasTaskNodeData).kindString).not.toMatch(/^unknown_/);
68
+ }
69
+ });
70
+
54
71
  test("task nodes carry correct kindString and category", () => {
55
72
  const laid = applyDagreLayout(threeNodeGraph);
56
73
  const { nodes } = toReactFlowElements(laid);
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+ import { useCanvasKeyboardShortcuts } from "../useCanvasKeyboardShortcuts";
4
+ import type { UseCanvasKeyboardShortcutsOptions } from "../useCanvasKeyboardShortcuts";
5
+
6
+ // Keyboard undo/redo binding (moved here from useGraphHistory with the
7
+ // oss#588 fix so the shortcut routes through the orchestrator's
8
+ // canvas-syncing undo/redo instead of a raw model mutation).
9
+
10
+ let container: HTMLDivElement;
11
+
12
+ function makeOptions(
13
+ overrides: Partial<UseCanvasKeyboardShortcutsOptions> = {},
14
+ ): UseCanvasKeyboardShortcutsOptions {
15
+ return {
16
+ containerRef: { current: container },
17
+ selection: null,
18
+ duplicateNode: vi.fn(),
19
+ selectAll: vi.fn(),
20
+ clearSelection: vi.fn(),
21
+ onRequestTaskPicker: vi.fn(),
22
+ onDismiss: vi.fn(),
23
+ undo: vi.fn(),
24
+ redo: vi.fn(),
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function pressModZ(target: EventTarget, shiftKey = false) {
30
+ target.dispatchEvent(
31
+ new KeyboardEvent("keydown", {
32
+ key: "z",
33
+ metaKey: true,
34
+ shiftKey,
35
+ bubbles: true,
36
+ cancelable: true,
37
+ }),
38
+ );
39
+ }
40
+
41
+ beforeEach(() => {
42
+ container = document.createElement("div");
43
+ container.tabIndex = -1;
44
+ document.body.appendChild(container);
45
+ });
46
+
47
+ afterEach(() => {
48
+ container.remove();
49
+ });
50
+
51
+ describe("useCanvasKeyboardShortcuts — undo/redo", () => {
52
+ it("Mod+Z triggers undo when focus is inside the container", () => {
53
+ const options = makeOptions();
54
+ renderHook(() => useCanvasKeyboardShortcuts(options));
55
+
56
+ container.focus();
57
+ pressModZ(container);
58
+
59
+ expect(options.undo).toHaveBeenCalledTimes(1);
60
+ expect(options.redo).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it("Mod+Shift+Z triggers redo when focus is inside the container", () => {
64
+ const options = makeOptions();
65
+ renderHook(() => useCanvasKeyboardShortcuts(options));
66
+
67
+ container.focus();
68
+ pressModZ(container, true);
69
+
70
+ expect(options.redo).toHaveBeenCalledTimes(1);
71
+ expect(options.undo).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("Mod+Z does nothing when focus is outside the container", () => {
75
+ const options = makeOptions();
76
+ renderHook(() => useCanvasKeyboardShortcuts(options));
77
+
78
+ const outside = document.createElement("button");
79
+ document.body.appendChild(outside);
80
+ outside.focus();
81
+ pressModZ(outside);
82
+
83
+ expect(options.undo).not.toHaveBeenCalled();
84
+ outside.remove();
85
+ });
86
+
87
+ it("Mod+Z in a text input inside the container keeps native text undo", () => {
88
+ const options = makeOptions();
89
+ renderHook(() => useCanvasKeyboardShortcuts(options));
90
+
91
+ const input = document.createElement("input");
92
+ container.appendChild(input);
93
+ input.focus();
94
+ pressModZ(input);
95
+
96
+ expect(options.undo).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("Ctrl+Z (non-Mac modifier) also triggers undo", () => {
100
+ const options = makeOptions();
101
+ renderHook(() => useCanvasKeyboardShortcuts(options));
102
+
103
+ container.focus();
104
+ container.dispatchEvent(
105
+ new KeyboardEvent("keydown", {
106
+ key: "z",
107
+ ctrlKey: true,
108
+ bubbles: true,
109
+ cancelable: true,
110
+ }),
111
+ );
112
+
113
+ expect(options.undo).toHaveBeenCalledTimes(1);
114
+ });
115
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { ReactFlowProvider } from "@xyflow/react";
5
+ import { useWorkflowCanvas } from "../useWorkflowCanvas";
6
+
7
+ // Regression tests for oss#588: undoing a node deletion restored the graph
8
+ // MODEL (inspector rebinds, history entry consumed) but the React Flow
9
+ // nodes array was synced from a stale pre-undo snapshot, so the canvas
10
+ // node never reappeared. These tests exercise the hook composition — the
11
+ // exact layer the bug lived in; the pure command stack is covered in
12
+ // graph-history.test.ts.
13
+
14
+ const FIXTURE_YAML = `apiVersion: agentic.stigmer.ai/v1
15
+ kind: Workflow
16
+ metadata:
17
+ name: undo-fixture
18
+ spec:
19
+ document:
20
+ dsl: "1.0.0"
21
+ namespace: test
22
+ name: undo-fixture
23
+ version: "0.0.1"
24
+ tasks:
25
+ - name: step_1
26
+ kind: agent_call
27
+ task_config:
28
+ agent: "org/agent-slug"
29
+ message: "first"
30
+ flow:
31
+ then: step_2
32
+ - name: step_2
33
+ kind: agent_call
34
+ task_config:
35
+ agent: "org/agent-slug"
36
+ message: "second"
37
+ flow:
38
+ then: end
39
+ `;
40
+
41
+ function wrapper({ children }: { children: ReactNode }) {
42
+ return <ReactFlowProvider>{children}</ReactFlowProvider>;
43
+ }
44
+
45
+ function renderCanvas() {
46
+ return renderHook(() => useWorkflowCanvas(FIXTURE_YAML), { wrapper });
47
+ }
48
+
49
+ function canvasNodeIds(result: { current: { nodes: readonly { id: string }[] } }): string[] {
50
+ return result.current.nodes.map((n) => n.id);
51
+ }
52
+
53
+ describe("useWorkflowCanvas — undo/redo canvas sync (oss#588)", () => {
54
+ it("parses the fixture into canvas nodes", () => {
55
+ const { result } = renderCanvas();
56
+ expect(canvasNodeIds(result)).toEqual(
57
+ expect.arrayContaining(["step_1", "step_2"]),
58
+ );
59
+ });
60
+
61
+ it("undo after a node delete restores the node on the CANVAS, not just the model", () => {
62
+ const { result } = renderCanvas();
63
+
64
+ const target = result.current.nodes.find((n) => n.id === "step_2");
65
+ expect(target).toBeDefined();
66
+
67
+ act(() => {
68
+ result.current.onNodesDelete([target!]);
69
+ });
70
+ expect(canvasNodeIds(result)).not.toContain("step_2");
71
+ expect(result.current.canUndo).toBe(true);
72
+
73
+ act(() => {
74
+ result.current.undo();
75
+ });
76
+
77
+ // The model restores (this always worked — the inspector saw it) …
78
+ expect(result.current.graph?.nodes.map((n) => n.id)).toContain("step_2");
79
+ // … and the canvas must restore with it (the oss#588 gap).
80
+ expect(canvasNodeIds(result)).toContain("step_2");
81
+ expect(result.current.canUndo).toBe(false);
82
+ expect(result.current.canRedo).toBe(true);
83
+ });
84
+
85
+ it("undo restores the deleted node's edges on the canvas", () => {
86
+ const { result } = renderCanvas();
87
+ const edgeCountBefore = result.current.edges.length;
88
+
89
+ const target = result.current.nodes.find((n) => n.id === "step_2");
90
+ act(() => {
91
+ result.current.onNodesDelete([target!]);
92
+ });
93
+ expect(result.current.edges.length).toBeLessThan(edgeCountBefore);
94
+
95
+ act(() => {
96
+ result.current.undo();
97
+ });
98
+ expect(result.current.edges.length).toBe(edgeCountBefore);
99
+ });
100
+
101
+ it("redo after an undo removes the node from the canvas again", () => {
102
+ const { result } = renderCanvas();
103
+
104
+ const target = result.current.nodes.find((n) => n.id === "step_2");
105
+ act(() => {
106
+ result.current.onNodesDelete([target!]);
107
+ });
108
+ act(() => {
109
+ result.current.undo();
110
+ });
111
+ expect(canvasNodeIds(result)).toContain("step_2");
112
+
113
+ act(() => {
114
+ result.current.redo();
115
+ });
116
+
117
+ expect(result.current.graph?.nodes.map((n) => n.id)).not.toContain("step_2");
118
+ expect(canvasNodeIds(result)).not.toContain("step_2");
119
+ expect(result.current.canUndo).toBe(true);
120
+ expect(result.current.canRedo).toBe(false);
121
+ });
122
+
123
+ it("undo with an empty history leaves the canvas untouched", () => {
124
+ const { result } = renderCanvas();
125
+ const before = canvasNodeIds(result);
126
+
127
+ act(() => {
128
+ result.current.undo();
129
+ });
130
+
131
+ expect(canvasNodeIds(result)).toEqual(before);
132
+ });
133
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useRef, useState } from "react";
3
+ import { useCallback, useId, useRef, useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
6
6
  import type { WorkflowInstance } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
@@ -42,6 +42,15 @@ export function CreateWorkflowInstanceDialog({
42
42
  const dialogRef = useRef<HTMLDialogElement>(null);
43
43
  const { create, isCreating, error, clearError } = useCreateWorkflowInstance();
44
44
 
45
+ // Instance-scoped element ids (oss#571): a reusable component must not
46
+ // hardcode DOM ids — hosts legitimately mount this dialog more than once
47
+ // per page (e.g. zone-cached detail pages), and duplicate ids silently
48
+ // break the label→input association for every copy after the first.
49
+ const baseId = useId();
50
+ const titleId = `${baseId}-title`;
51
+ const nameId = `${baseId}-name`;
52
+ const descriptionId = `${baseId}-description`;
53
+
45
54
  const [name, setName] = useState("");
46
55
  const [description, setDescription] = useState("");
47
56
  const [environmentRefs, setEnvironmentRefs] = useState<ResourceRef[]>([]);
@@ -108,12 +117,12 @@ export function CreateWorkflowInstanceDialog({
108
117
  "stg:fixed stg:inset-0 stg:m-auto stg:w-full stg:max-w-lg stg:rounded-xl stg:border stg:border-border stg:bg-popover stg:p-0 stg:shadow-xl",
109
118
  "stg:backdrop:bg-black/50",
110
119
  )}
111
- aria-labelledby="create-instance-title"
120
+ aria-labelledby={titleId}
112
121
  >
113
122
  <form onSubmit={handleSubmit} className="stg:flex stg:flex-col">
114
123
  {/* Header */}
115
124
  <div className="stg:flex stg:items-center stg:justify-between stg:border-b stg:border-border stg:px-6 stg:py-4">
116
- <h2 id="create-instance-title" className="stg:text-base stg:font-semibold stg:text-foreground">
125
+ <h2 id={titleId} className="stg:text-base stg:font-semibold stg:text-foreground">
117
126
  Create Workflow Instance
118
127
  </h2>
119
128
  <button
@@ -134,11 +143,11 @@ export function CreateWorkflowInstanceDialog({
134
143
  <div className="stg:space-y-5 stg:px-6 stg:py-5">
135
144
  {/* Name */}
136
145
  <div>
137
- <label htmlFor="instance-name" className="stg:block stg:text-sm stg:font-medium stg:text-foreground stg:mb-1.5">
146
+ <label htmlFor={nameId} className="stg:block stg:text-sm stg:font-medium stg:text-foreground stg:mb-1.5">
138
147
  Name <span className="stg:text-destructive">*</span>
139
148
  </label>
140
149
  <input
141
- id="instance-name"
150
+ id={nameId}
142
151
  type="text"
143
152
  required
144
153
  value={name}
@@ -160,11 +169,11 @@ export function CreateWorkflowInstanceDialog({
160
169
 
161
170
  {/* Description */}
162
171
  <div>
163
- <label htmlFor="instance-description" className="stg:block stg:text-sm stg:font-medium stg:text-foreground stg:mb-1.5">
172
+ <label htmlFor={descriptionId} className="stg:block stg:text-sm stg:font-medium stg:text-foreground stg:mb-1.5">
164
173
  Description
165
174
  </label>
166
175
  <textarea
167
- id="instance-description"
176
+ id={descriptionId}
168
177
  value={description}
169
178
  onChange={(e) => setDescription(e.target.value)}
170
179
  placeholder="What is this instance used for?"
@@ -10,8 +10,11 @@ export interface UseDeleteWorkflowInstanceReturn {
10
10
  /**
11
11
  * Delete a workflow instance by ID. Returns the deleted resource.
12
12
  *
13
- * WARNING: Deleting an instance cascades all executions belonging
14
- * to this instance are permanently removed.
13
+ * Deletion does NOT cascade to executions: runs already created
14
+ * against the instance are preserved and stay visible in the parent
15
+ * workflow's execution history (every execution carries a
16
+ * denormalized workflow reference). Only the instance itself — its
17
+ * environment bindings and configuration — is removed.
15
18
  */
16
19
  readonly deleteInstance: (id: string) => Promise<WorkflowInstance>;
17
20
  /** `true` while the delete request is in flight. */
@@ -29,8 +32,10 @@ export interface UseDeleteWorkflowInstanceReturn {
29
32
  * The caller is responsible for handling post-delete UI updates
30
33
  * (e.g., refreshing the instance list, closing a detail panel).
31
34
  *
32
- * Deletion cascades to all WorkflowExecution resources that belong
33
- * to the instance. The confirmation UI should warn the user about this.
35
+ * Deletion is instance-only on both editions executions are NOT
36
+ * cascaded (deliberate platform posture: they keep a denormalized
37
+ * workflow reference and remain in the workflow's execution history).
38
+ * Confirmation UI must not claim execution history is deleted.
34
39
  *
35
40
  * @example
36
41
  * ```tsx