@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
@@ -11,6 +11,7 @@ import { diffEnv } from "../environment/diffEnv.js";
11
11
  import { SYSTEM_ENV_VAR_KEYS } from "../environment/systemEnvVars.js";
12
12
  import type { EnvVarFormVariable } from "../environment/EnvVarForm.js";
13
13
  import { useOAuthGrantStatus } from "./useOAuthGrantStatus.js";
14
+ import { useOrgOAuthApp } from "./useOrgOAuthApp.js";
14
15
 
15
16
  /**
16
17
  * Credential acquisition mode for an MCP server.
@@ -93,9 +94,15 @@ export interface UseMcpServerCredentialsReturn {
93
94
  * Servers whose env vars are all optional are always ready.
94
95
  */
95
96
  readonly isReady: boolean;
96
- /** `true` while the personal environment or grant status is being fetched. */
97
+ /**
98
+ * `true` while the personal environment, grant status, or org-override
99
+ * lookup is being fetched.
100
+ */
97
101
  readonly isLoading: boolean;
98
- /** Error from the personal environment or grant status fetch, or `null`. */
102
+ /**
103
+ * Error from the personal environment, grant status, or org-override
104
+ * fetch, or `null`.
105
+ */
99
106
  readonly error: Error | null;
100
107
  /**
101
108
  * Save the provided credentials to the user's personal environment.
@@ -131,22 +138,35 @@ export interface UseMcpServerCredentialsReturn {
131
138
  */
132
139
  readonly isVendorApprovalBlocked: boolean;
133
140
  /**
134
- * Where the effective OAuth app was resolved from for the caller's org.
141
+ * Where the effective OAuth app is resolved from for the caller's org
142
+ * — the same source the connect flow will use.
135
143
  *
136
- * Enriched at query time by the backend no additional RPC needed.
137
- * `UNSPECIFIED` when `authMode` is `"manual"` or enrichment has not
138
- * been performed yet.
144
+ * Resolved client-side from the `getOrgOAuthApp` RPC keyed on this
145
+ * hook's `org` parameter. It cannot come from the fetched McpServer:
146
+ * the caller's active org is client-side context the read RPCs never
147
+ * carry, so `status.oauth_status` fields 3-4 are never populated by
148
+ * any backend (see the OAuthStatus proto).
149
+ *
150
+ * `UNSPECIFIED` when `authMode` is `"manual"` or while the override
151
+ * lookup has not yet resolved.
139
152
  */
140
153
  readonly effectiveOAuthSource: OAuthAppSource;
141
154
  /**
142
- * `true` when an org-level BYOA override is active for this server.
143
- * Derived from `effectiveOAuthSource === OAUTH_APP_SOURCE_ORG_OVERRIDE`.
155
+ * `true` when an org-level BYOA override is active for this server
156
+ * and org. Derived from the `getOrgOAuthApp` lookup.
144
157
  */
145
158
  readonly isOrgOAuthApp: boolean;
146
159
  /**
147
160
  * `true` when the BYOA option is relevant for this server: the server
148
- * uses vendor OAuth (`authMode === "oauth"` with an `oauth_app_ref`)
149
- * and no org override is currently active.
161
+ * uses vendor OAuth (`authMode === "oauth"` with an `oauth_app_ref`),
162
+ * no org override is currently active, AND the backend supports the
163
+ * org-override surface at all.
164
+ *
165
+ * The last condition is an edition capability, confirmed by probing
166
+ * `getOrgOAuthApp` (see {@link useOrgOAuthApp}): the surface is
167
+ * hosted-only, so on OSS deployments — where the submit could only
168
+ * answer UNIMPLEMENTED — this stays `false` and no BYOA affordance
169
+ * renders (stigmer/stigmer#558).
150
170
  *
151
171
  * When `true`, the UI should offer "Use your own OAuth app" as either
152
172
  * a primary action (when vendor approval is blocked) or a secondary
@@ -201,6 +221,15 @@ export interface UseMcpServerCredentialsReturn {
201
221
  * form. Additional non-OAuth vars still appear in `missingVariables`
202
222
  * (mixed mode).
203
223
  *
224
+ * **Org-override-aware**: for vendor-OAuth servers with an
225
+ * `oauth_app_ref`, the hook also composes {@link useOrgOAuthApp} to
226
+ * resolve which OAuth app the connect flow will use for `org`
227
+ * (`effectiveOAuthSource` / `isOrgOAuthApp` / `canBringOwnApp`).
228
+ * The `org` parameter must therefore be the caller's ACTIVE org — the
229
+ * org credentials are stored in and the org whose BYOA override
230
+ * applies — not necessarily the org that owns the MCP server (they
231
+ * differ when browsing another org's public server).
232
+ *
204
233
  * Unlike {@link useMcpServerSetup} which manages multi-server setup
205
234
  * for session creation, this hook is scoped to a single server and
206
235
  * always persists to the personal environment (no one-time option).
@@ -281,14 +310,51 @@ export function useMcpServerCredentials(
281
310
  oauthStatus?.vendorApprovalStatus === VendorApprovalStatus.REJECTED);
282
311
  const vendorApprovalDocsUrl = oauthStatus?.vendorApprovalDocsUrl || null;
283
312
 
284
- const effectiveOAuthSource =
285
- oauthStatus?.effectiveOauthSource ??
286
- OAuthAppSource.OAUTH_APP_SOURCE_UNSPECIFIED;
287
- const isOrgOAuthApp =
288
- effectiveOAuthSource === OAuthAppSource.OAUTH_APP_SOURCE_ORG_OVERRIDE;
289
313
  const hasOAuthAppRef = Boolean(auth?.oauthAppRef?.slug);
314
+
315
+ // Org-override lookup, two duties in one fetch (shared cross-mount via
316
+ // the hook's fetch cache):
317
+ // 1. Capability probe (`isSupported`) — the org-override surface is
318
+ // hosted-only, so BYOA affordances must hide on OSS (#558).
319
+ // 2. The override signal (`hasOverride`) — which OAuthApp the connect
320
+ // flow will actually use for this org. Resolved client-side because
321
+ // the caller's active org is client-side context the read RPCs never
322
+ // carry (get has no org; getByReference's org is the server's OWNING
323
+ // org, which differs from the caller's org when browsing another
324
+ // org's public server), so the fetched McpServer cannot answer this
325
+ // (stigmer-cloud#401).
326
+ // Scoped to vendor-OAuth servers with a platform app ref — the only
327
+ // shape an override can exist for — so manual/DCR servers pay no RPC.
328
+ const orgOverride = useOrgOAuthApp(
329
+ authMode === "oauth" && hasOAuthAppRef
330
+ ? (mcpServer?.metadata?.id ?? null)
331
+ : null,
332
+ org,
333
+ );
334
+
335
+ const isOrgOAuthApp = orgOverride.hasOverride;
336
+ // While the lookup is in flight, failed, or unposable (an app ref exists
337
+ // but no org to resolve an override for), the override state is unknown —
338
+ // report UNSPECIFIED rather than guessing PLATFORM, matching the
339
+ // documented "not yet resolved" contract. UNSUPPORTED is not unknown:
340
+ // on OSS the platform-ref app is truthfully the effective source.
341
+ const overrideUnknown =
342
+ orgOverride.isLoading ||
343
+ orgOverride.error !== null ||
344
+ (hasOAuthAppRef && !org);
345
+ const effectiveOAuthSource =
346
+ authMode !== "oauth" || overrideUnknown
347
+ ? OAuthAppSource.OAUTH_APP_SOURCE_UNSPECIFIED
348
+ : isOrgOAuthApp
349
+ ? OAuthAppSource.OAUTH_APP_SOURCE_ORG_OVERRIDE
350
+ : hasOAuthAppRef
351
+ ? OAuthAppSource.OAUTH_APP_SOURCE_PLATFORM
352
+ : OAuthAppSource.OAUTH_APP_SOURCE_NONE;
290
353
  const canBringOwnApp =
291
- authMode === "oauth" && hasOAuthAppRef && !isOrgOAuthApp;
354
+ authMode === "oauth" &&
355
+ hasOAuthAppRef &&
356
+ !isOrgOAuthApp &&
357
+ orgOverride.isSupported;
292
358
 
293
359
  const grantStatus = useOAuthGrantStatus(
294
360
  authMode === "oauth" ? (mcpServer?.metadata?.id ?? null) : null,
@@ -339,7 +405,8 @@ export function useMcpServerCredentials(
339
405
  const refetch = useCallback(() => {
340
406
  personalEnv.refetch();
341
407
  grantStatus.refetch();
342
- }, [personalEnv, grantStatus]);
408
+ orgOverride.refetch();
409
+ }, [personalEnv, grantStatus, orgOverride]);
343
410
 
344
411
  return {
345
412
  authMode,
@@ -358,8 +425,9 @@ export function useMcpServerCredentials(
358
425
  manualEntrySupported,
359
426
  missingVariables,
360
427
  isReady,
361
- isLoading: personalEnv.isLoading || grantStatus.isLoading,
362
- error: personalEnv.error ?? grantStatus.error,
428
+ isLoading:
429
+ personalEnv.isLoading || grantStatus.isLoading || orgOverride.isLoading,
430
+ error: personalEnv.error ?? grantStatus.error ?? orgOverride.error,
363
431
  saveCredentials,
364
432
  isSaving: personalEnv.isMutating,
365
433
  refetch,
@@ -8,11 +8,26 @@ import {
8
8
  DeleteOrgOAuthAppInputSchema,
9
9
  } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
10
10
  import { useStigmer } from "../hooks.js";
11
+ import { isUnimplemented } from "../internal/isUnimplemented.js";
11
12
  import { toError } from "../internal/toError.js";
12
13
  import { useFetch } from "../internal/useFetch.js";
13
14
 
14
15
  /** Return value of {@link useOrgOAuthApp}. */
15
16
  export interface UseOrgOAuthAppReturn {
17
+ /**
18
+ * Whether the backend supports the org-OAuth-app override surface at all.
19
+ *
20
+ * The surface is hosted-only by design: the OSS server answers
21
+ * UNIMPLEMENTED for all three org-override RPCs because its flat
22
+ * OAuthApp store has no override binding (stigmer/stigmer#558 — the
23
+ * self-hosted equivalent is applying an OAuthApp resource and
24
+ * referencing it from `spec.auth.oauth_app_ref`). The fetch doubles
25
+ * as the capability probe: `isSupported` is `false` until the first
26
+ * successful response confirms the surface exists, so BYOA
27
+ * affordances gated on it never flash on deployments where the
28
+ * submit could only fail.
29
+ */
30
+ readonly isSupported: boolean;
16
31
  /** Whether an org-level BYOA override exists for this resource + org. */
17
32
  readonly hasOverride: boolean;
18
33
  /** System-generated ID of the override's OAuthApp, or `null` when absent. */
@@ -72,12 +87,25 @@ export interface UseOrgOAuthAppReturn {
72
87
  }
73
88
 
74
89
  interface OrgOAuthAppData {
90
+ isSupported: boolean;
75
91
  hasOverride: boolean;
76
92
  oauthAppId: string | null;
77
93
  clientId: string | null;
78
94
  }
79
95
 
80
96
  const IDLE_DATA: OrgOAuthAppData = {
97
+ isSupported: false,
98
+ hasOverride: false,
99
+ oauthAppId: null,
100
+ clientId: null,
101
+ };
102
+
103
+ /**
104
+ * The UNIMPLEMENTED answer resolved into data rather than an error:
105
+ * the backend is healthy, it just doesn't have this edition capability.
106
+ */
107
+ const UNSUPPORTED_DATA: OrgOAuthAppData = {
108
+ isSupported: false,
81
109
  hasOverride: false,
82
110
  oauthAppId: null,
83
111
  clientId: null,
@@ -110,6 +138,14 @@ const IDLE: UseOrgOAuthAppReturn = {
110
138
  * mutations bound to the hook's resource + org context, eliminating
111
139
  * parameter repetition at call sites.
112
140
  *
141
+ * **Edition awareness**: the fetch doubles as a capability probe. The
142
+ * org-override surface is hosted-only; the OSS server answers
143
+ * UNIMPLEMENTED for it, which this hook resolves into
144
+ * `isSupported: false` with no error. Gate every BYOA affordance on
145
+ * `isSupported` (as {@link useMcpServerCredentials} does for
146
+ * `canBringOwnApp`) — the mutations can only fail where the probe
147
+ * reports the surface absent.
148
+ *
113
149
  * Pass `null` for either parameter to skip fetching (stable no-op).
114
150
  *
115
151
  * @example
@@ -136,18 +172,28 @@ export function useOrgOAuthApp(
136
172
  const { data, isLoading, isRefetching, error, refetch } = useFetch<OrgOAuthAppData>(
137
173
  resourceId && org
138
174
  ? async () => {
139
- const result = await stigmer.mcpServer.getOrgOAuthApp(
140
- create(GetOrgOAuthAppInputSchema, { resourceId, org }),
141
- );
142
- return {
143
- hasOverride: result.hasOverride,
144
- oauthAppId: result.oauthAppId || null,
145
- clientId: result.clientId || null,
146
- };
175
+ try {
176
+ const result = await stigmer.mcpServer.getOrgOAuthApp(
177
+ create(GetOrgOAuthAppInputSchema, { resourceId, org }),
178
+ );
179
+ return {
180
+ isSupported: true,
181
+ hasOverride: result.hasOverride,
182
+ oauthAppId: result.oauthAppId || null,
183
+ clientId: result.clientId || null,
184
+ };
185
+ } catch (err) {
186
+ if (isUnimplemented(err)) return UNSUPPORTED_DATA;
187
+ throw err;
188
+ }
147
189
  }
148
190
  : null,
149
191
  [resourceId, org, stigmer],
150
192
  IDLE_DATA,
193
+ // Cross-mount cache: the credentials hook composes this fetch as its
194
+ // capability probe, so a detail page mounts two instances — the cache
195
+ // lets the second render from the first's result.
196
+ { cacheKey: `org-oauth-app:${org}:${resourceId}` },
151
197
  );
152
198
 
153
199
  const [isSetting, setIsSetting] = useState(false);
@@ -74,8 +74,10 @@ export interface SendFollowUpOptions {
74
74
  * Execution-scoped secrets and configuration (Execution Flow).
75
75
  *
76
76
  * Values are injected into the agent sandbox for this execution only
77
- * and deleted when the execution completes. They override both
78
- * Environment values and agent defaults.
77
+ * and deleted when the execution completes. They take the highest
78
+ * merge priority, overriding Environment values bound via the
79
+ * instance. Keys must be declared in the agent's env declarations
80
+ * (a whitelist, not a value source) or they are dropped.
79
81
  *
80
82
  * @see {@link CreateAgentExecutionInput.runtimeEnv}
81
83
  */
@@ -3,18 +3,15 @@
3
3
  import { useCallback, useMemo } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
5
5
  import { timestampDate } from "@bufbuild/protobuf/wkt";
6
- import { StigmerError } from "@stigmer/sdk";
7
6
  import {
8
7
  ListSkillVersionsInputSchema,
9
8
  type SkillVersionEntry as ProtoSkillVersionEntry,
10
9
  } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
11
10
  import { useStigmer } from "../hooks.js";
11
+ import { isUnimplemented } from "../internal/isUnimplemented.js";
12
12
  import { useFetch } from "../internal/useFetch.js";
13
13
  import type { VersionEntry } from "../version-history/types.js";
14
14
 
15
- // gRPC/Connect Code.Unimplemented = 12
16
- const CODE_UNIMPLEMENTED = 12;
17
-
18
15
  // ---------------------------------------------------------------------------
19
16
  // Public types
20
17
  // ---------------------------------------------------------------------------
@@ -156,10 +153,3 @@ function mapProtoToVersionEntry(proto: ProtoSkillVersionEntry): VersionEntry {
156
153
  };
157
154
  }
158
155
 
159
- // ---------------------------------------------------------------------------
160
- // Error detection
161
- // ---------------------------------------------------------------------------
162
-
163
- function isUnimplemented(err: unknown): boolean {
164
- return err instanceof StigmerError && err.connectCode === CODE_UNIMPLEMENTED;
165
- }
@@ -87,7 +87,7 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
87
87
  layoutEngine,
88
88
  }: WorkflowCanvasEditorProps) {
89
89
  const containerRef = useRef<HTMLDivElement>(null);
90
- const canvas = useWorkflowCanvas(yaml, containerRef, { layoutEngine: layoutEngine ?? undefined });
90
+ const canvas = useWorkflowCanvas(yaml, { layoutEngine: layoutEngine ?? undefined });
91
91
  const [paletteCollapsed, setPaletteCollapsed] = useState(false);
92
92
  const togglePalette = useCallback(() => setPaletteCollapsed((p) => !p), []);
93
93
 
@@ -161,8 +161,35 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
161
161
  copySelection: canvas.copySelection,
162
162
  pasteAtCenter: canvas.pasteAtCenter,
163
163
  cutSelection: canvas.cutSelection,
164
+ undo: canvas.undo,
165
+ redo: canvas.redo,
164
166
  });
165
167
 
168
+ // All canvas shortcuts are gated on focus being inside the container,
169
+ // but React Flow's pane prevents default on mousedown, so pane clicks
170
+ // never move focus into it on their own — without this, Cmd+Z (etc.)
171
+ // after a pane interaction silently does nothing (oss#588). Focusing
172
+ // unconditionally also commits-and-blurs an inspector field when the
173
+ // user clicks back into the canvas. Scoped to the canvas column so
174
+ // palette and inspector clicks keep their native focus flow; when the
175
+ // press lands on a real focusable (node, toolbar button, picker
176
+ // input), the default mousedown focus takes over right after.
177
+ const handleCanvasPointerDownCapture = useCallback(() => {
178
+ containerRef.current?.focus({ preventScroll: true });
179
+ }, []);
180
+
181
+ // Deleting the focused node drops DOM focus to <body> when React Flow
182
+ // unmounts the element, disarming every canvas shortcut at exactly the
183
+ // moment the user reaches for Cmd+Z (oss#588). Return focus to the
184
+ // container after any node deletion so undo stays reachable.
185
+ const handleNodesDelete = useCallback(
186
+ (deleted: Parameters<typeof canvas.onNodesDelete>[0]) => {
187
+ canvas.onNodesDelete(deleted);
188
+ containerRef.current?.focus({ preventScroll: true });
189
+ },
190
+ [canvas.onNodesDelete],
191
+ );
192
+
166
193
  useEffect(() => {
167
194
  onDirtyChange?.(canvas.isDirty);
168
195
  }, [canvas.isDirty, onDirtyChange]);
@@ -239,10 +266,10 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
239
266
 
240
267
  const handleContextMenuDeleteNode = useCallback(
241
268
  (nodeId: string) => {
242
- canvas.onNodesDelete([{ id: nodeId } as import("@xyflow/react").Node]);
269
+ handleNodesDelete([{ id: nodeId } as import("@xyflow/react").Node]);
243
270
  setContextMenu(null);
244
271
  },
245
- [canvas.onNodesDelete],
272
+ [handleNodesDelete],
246
273
  );
247
274
 
248
275
  const handleContextMenuDuplicateNode = useCallback(
@@ -371,6 +398,9 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
371
398
  const handleContextMenuDeleteSelection = useCallback(() => {
372
399
  canvas.deleteSelection();
373
400
  setContextMenu(null);
401
+ // Same focus restoration as handleNodesDelete — the selection may
402
+ // include the focused node element.
403
+ containerRef.current?.focus({ preventScroll: true });
374
404
  }, [canvas.deleteSelection]);
375
405
 
376
406
  const handleSelectionContextMenu = useCallback(
@@ -475,9 +505,9 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
475
505
 
476
506
  const handleDeleteNode = useCallback(
477
507
  (nodeId: string) => {
478
- canvas.onNodesDelete([{ id: nodeId } as import("@xyflow/react").Node]);
508
+ handleNodesDelete([{ id: nodeId } as import("@xyflow/react").Node]);
479
509
  },
480
- [canvas.onNodesDelete],
510
+ [handleNodesDelete],
481
511
  );
482
512
 
483
513
  const handleDuplicateNode = useCallback(
@@ -580,7 +610,7 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
580
610
 
581
611
  {showPalette && !paletteCollapsed && <WorkflowTaskPalette />}
582
612
 
583
- <div className="stg:relative stg:flex-1">
613
+ <div className="stg:relative stg:flex-1" onPointerDownCapture={handleCanvasPointerDownCapture}>
584
614
  {showPalette && (
585
615
  <PaletteToggle
586
616
  collapsed={paletteCollapsed}
@@ -613,7 +643,7 @@ const WorkflowCanvasEditorInner = memo(function WorkflowCanvasEditorInner({
613
643
  isValidConnection={canvas.isValidConnection}
614
644
  onDrop={canvas.onDrop}
615
645
  onDragOver={canvas.onDragOver}
616
- onNodesDelete={canvas.onNodesDelete}
646
+ onNodesDelete={handleNodesDelete}
617
647
  onEdgesDelete={canvas.onEdgesDelete}
618
648
  onNodeContextMenu={handleNodeContextMenu}
619
649
  onEdgeContextMenu={handleEdgeContextMenu}
@@ -152,6 +152,7 @@ function WorkflowExecutionGraphInner({
152
152
  const [didInitialFit, setDidInitialFit] = useState(false);
153
153
  const isInitializedRef = useRef(false);
154
154
  const didFitGuardRef = useRef(false);
155
+ const containerRef = useRef<HTMLDivElement | null>(null);
155
156
 
156
157
  // ── Ephemeral drag positions ───────────────────────────────────────
157
158
  // Resets when the execution changes (navigating to a different run).
@@ -188,6 +189,14 @@ function WorkflowExecutionGraphInner({
188
189
 
189
190
  const performInitialFit = useCallback(() => {
190
191
  if (didFitGuardRef.current) return;
192
+ // A CSS-hidden mount (the execution viewer keeps BOTH center views
193
+ // mounted with the inactive one `display:none`) measures 0×0 —
194
+ // `fitView` against that computes a degenerate transform that never
195
+ // self-corrects, so the first toggle to Graph would show an
196
+ // apparently empty canvas (oss#571). Defer: the ResizeObserver below
197
+ // re-attempts on the hidden→visible transition.
198
+ const el = containerRef.current;
199
+ if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
191
200
  didFitGuardRef.current = true;
192
201
  fitView({ padding: 0.15, duration: getAnimationDuration(300) });
193
202
  setDidInitialFit(true);
@@ -209,6 +218,22 @@ function WorkflowExecutionGraphInner({
209
218
  }
210
219
  }, [nodes.length, performInitialFit]);
211
220
 
221
+ // The deferred-fit path for hidden mounts: when the container gains
222
+ // real dimensions (first reveal, or a late layout pass), run the
223
+ // pending initial fit. One-shot — `performInitialFit` self-guards, so
224
+ // user pan/zoom is never overridden by later resizes.
225
+ useEffect(() => {
226
+ const el = containerRef.current;
227
+ if (!el || typeof ResizeObserver === "undefined") return;
228
+ const observer = new ResizeObserver(() => {
229
+ if (isInitializedRef.current && nodes.length > 0) {
230
+ performInitialFit();
231
+ }
232
+ });
233
+ observer.observe(el);
234
+ return () => observer.disconnect();
235
+ }, [nodes.length, performInitialFit]);
236
+
212
237
  // Derive active task name stably from taskStates (not from nodes array — DD-010)
213
238
  const activeTaskInfo = useActiveTaskName(taskStates);
214
239
  const isTerminal = executionPhase != null && TERMINAL_EXECUTION_PHASES.has(executionPhase);
@@ -276,7 +301,7 @@ function WorkflowExecutionGraphInner({
276
301
 
277
302
  return (
278
303
  <WorkflowGraphModeProvider mode="execution">
279
- <div className={cn("stg:relative stg:h-full stg:w-full", className)}>
304
+ <div ref={containerRef} className={cn("stg:relative stg:h-full stg:w-full", className)}>
280
305
  {versionMismatch && (
281
306
  <div className="stg:absolute stg:left-3 stg:right-3 stg:top-3 stg:z-50 stg:rounded-md stg:border stg:border-[var(--stgm-warning,#f59e0b)]/30 stg:bg-[var(--stgm-warning,#f59e0b)]/5 stg:px-3 stg:py-2 stg:text-xs stg:text-[var(--stgm-foreground,#1a1a2e)]">
282
307
  {versionMismatch}
@@ -344,24 +344,38 @@ export const WorkflowExecutionViewer = memo(function WorkflowExecutionViewer({
344
344
  // Thread view. One always-visible announcer serves both center views.
345
345
  const announcement = useExecutionAnnouncements(effectiveTaskStates);
346
346
 
347
- // Terminal-phase snapshot refetch (T04, DD-T04-4). While a run streams,
348
- // the cards' collapsed lines and bodies work from the TRUNCATED event
349
- // summaries; the full per-task output lands on `status.tasks[]` only when
350
- // the runner persists the terminal snapshot. The stream store's stage is
351
- // the live end-of-run signal (`"complete"` on the terminal event) — the
352
- // snapshot's own `phase` cannot be the trigger, since it only moves on a
353
- // refetch (the circularity behind the stale "Pending" header, R1-5).
354
- // Scoped to the live→complete TRANSITION: a terminal-replay mount starts
355
- // at "complete" without ever streaming and already fetched a settled
356
- // snapshot, so it must not refetch again.
347
+ // Boundary-transition snapshot refetches. The snapshot fetch is one-shot
348
+ // (`useWorkflowExecution` never polls), so the stream store's stage
349
+ // transitions are the only live signals that the mount snapshot went
350
+ // stale — the snapshot's own `phase` cannot be the trigger, since it
351
+ // only moves on a refetch (the circularity behind the stale "Pending"
352
+ // header, R1-5). Two transitions matter, one per direction:
353
+ //
354
+ // - ENTERING "complete" (T04, DD-T04-4): while a run streams, the cards'
355
+ // collapsed lines and bodies work from the TRUNCATED event summaries;
356
+ // the full per-task output lands on `status.tasks[]` only when the
357
+ // runner persists the terminal snapshot.
358
+ // - ENTERING "streaming" (oss#571): the store flips connecting→streaming
359
+ // on the FIRST delivered event, so this transition is the "run
360
+ // started" signal for a page that landed on a still-pending execution
361
+ // — without the refetch, the phase badge and the phase-gated
362
+ // Pause/Cancel would freeze at "Pending" for the entire run. It also
363
+ // covers reconnecting→streaming, where the snapshot slept through the
364
+ // outage.
365
+ //
366
+ // Both are TRANSITION-scoped (never fire on mount): a terminal-replay
367
+ // mount starts at "complete" and a mid-run mount starts at "streaming",
368
+ // and each already fetched a fresh snapshot.
357
369
  const prevStreamStageRef = useRef(streamState.stage);
358
370
  useEffect(() => {
359
371
  const prev = prevStreamStageRef.current;
360
372
  prevStreamStageRef.current = streamState.stage;
361
- if (
373
+ const enteredComplete =
362
374
  streamState.stage === "complete" &&
363
- (prev === "streaming" || prev === "reconnecting")
364
- ) {
375
+ (prev === "streaming" || prev === "reconnecting");
376
+ const enteredStreaming =
377
+ streamState.stage === "streaming" && prev !== "streaming";
378
+ if (enteredComplete || enteredStreaming) {
365
379
  refetchExecution();
366
380
  }
367
381
  }, [streamState.stage, refetchExecution]);
@@ -28,7 +28,7 @@ export const WorkflowNode = memo(function WorkflowNode({
28
28
  selected,
29
29
  }: NodeProps & { data: CanvasTaskNodeData }) {
30
30
  const mode = useWorkflowGraphMode();
31
- const visualSpec = getVisualSpec(data.isSentinel ? id : data.kindString);
31
+ const visualSpec = getVisualSpec(data.kindString);
32
32
  const categoryColor = CATEGORY_COLORS[data.category];
33
33
  const errorCount = data.errorCount ?? 0;
34
34
  const isNested = NESTED_TASK_KINDS.has(data.kindString);
@@ -38,7 +38,10 @@ export const WorkflowNode = memo(function WorkflowNode({
38
38
  return (
39
39
  <div
40
40
  data-visual-class={data.visualClass}
41
- data-task-kind={data.kindString}
41
+ // data-task-kind is a sanctioned e2e/embedding hook meaning "real
42
+ // workflow task kind" — sentinels omit it and are targeted via
43
+ // data-id + data-visual-class instead (oss#581).
44
+ data-task-kind={data.isSentinel ? undefined : data.kindString}
42
45
  data-execution-status={executionState?.status}
43
46
  data-diff-status={diffState?.status}
44
47
  aria-label={buildAriaLabel(data, errorCount, executionState?.status)}
@@ -24,6 +24,16 @@ export interface WorkflowRunDialogProps {
24
24
  * Passed through to the form's instance picker to control visibility threshold.
25
25
  */
26
26
  readonly defaultInstanceId?: string;
27
+ /**
28
+ * Instance to preselect when the dialog opens — wire this from a
29
+ * row-level "Run" action so the form reflects the instance the user
30
+ * clicked. Applied on each open transition (after the form resets);
31
+ * ignored when the id is not in `instances`, so a stale id degrades
32
+ * to the default option. Omit (or pass `null`) for the
33
+ * server-resolved default. Same convention as
34
+ * {@link SessionComposer}'s `initialInstanceId`.
35
+ */
36
+ readonly initialInstanceId?: string | null;
27
37
  /**
28
38
  * Called after the execution is created successfully.
29
39
  * Receives the execution ID — use for navigation.
@@ -67,6 +77,7 @@ export function WorkflowRunDialog({
67
77
  workflow,
68
78
  instances,
69
79
  defaultInstanceId,
80
+ initialInstanceId,
70
81
  onSuccess,
71
82
  onError,
72
83
  }: WorkflowRunDialogProps) {
@@ -94,11 +105,27 @@ export function WorkflowRunDialog({
94
105
  if (!dialog) return;
95
106
  if (open && !dialog.open) {
96
107
  flow.reset();
108
+ // Preselection must follow reset (reset clears the selection back to
109
+ // the server-resolved default). Guarded against stale ids: an instance
110
+ // deleted between the caller capturing the id and this open falls back
111
+ // to the default option instead of a <select> value with no option.
112
+ if (
113
+ initialInstanceId &&
114
+ instances.some((i) => i.metadata?.id === initialInstanceId)
115
+ ) {
116
+ flow.setSelectedInstanceId(initialInstanceId);
117
+ }
97
118
  dialog.showModal();
98
119
  } else if (!open && dialog.open) {
99
120
  dialog.close();
100
121
  }
101
- }, [open, flow.reset]);
122
+ }, [
123
+ open,
124
+ flow.reset,
125
+ flow.setSelectedInstanceId,
126
+ initialInstanceId,
127
+ instances,
128
+ ]);
102
129
 
103
130
  const handleDialogCancel = useCallback(
104
131
  (e: React.SyntheticEvent) => {
@@ -571,7 +571,9 @@ describe("WorkflowExecutionViewer (terminal-phase refetch, T04)", () => {
571
571
 
572
572
  // Re-mocks ONLY the stream stage (the execution mock — and its refetch
573
573
  // spy — must stay stable across the flip; the assertions count its calls).
574
- function mockStreamStage(stage: "streaming" | "reconnecting" | "complete") {
574
+ function mockStreamStage(
575
+ stage: "connecting" | "streaming" | "reconnecting" | "complete",
576
+ ) {
575
577
  mockedUseEventStream.mockReturnValue({
576
578
  events: [],
577
579
  taskStates: new Map([
@@ -630,4 +632,49 @@ describe("WorkflowExecutionViewer (terminal-phase refetch, T04)", () => {
630
632
 
631
633
  expect(refetch).not.toHaveBeenCalled();
632
634
  });
635
+
636
+ // The other stale direction (oss#571's live-run finding): a page that
637
+ // lands BEFORE the run starts fetches a PENDING snapshot, and nothing
638
+ // refetched it when the run began — the phase badge and the phase-gated
639
+ // Pause/Cancel froze at "Pending" for the entire run. The store flips
640
+ // connecting → streaming exactly when the FIRST event is delivered, so
641
+ // that transition IS the "run started" signal.
642
+ it("the stream's first delivery refetches a pending-landing snapshot — the phase badge and Pause/Cancel catch up to the started run", () => {
643
+ const { refetch } = arrange(ExecutionPhase.EXECUTION_PENDING);
644
+ mockStreamStage("connecting");
645
+ const { rerender } = render(<WorkflowExecutionViewer executionId="wex_1" />);
646
+ expect(refetch).not.toHaveBeenCalled();
647
+
648
+ mockStreamStage("streaming");
649
+ rerender(
650
+ <WorkflowExecutionViewer executionId="wex_1" className="pass-2" />,
651
+ );
652
+ expect(refetch).toHaveBeenCalledTimes(1);
653
+
654
+ // Idempotent: staying in streaming never re-triggers.
655
+ rerender(
656
+ <WorkflowExecutionViewer executionId="wex_1" className="pass-3" />,
657
+ );
658
+ expect(refetch).toHaveBeenCalledTimes(1);
659
+ });
660
+
661
+ it("a reconnect recovery (reconnecting → streaming) refetches — the snapshot may have moved during the outage", () => {
662
+ const { refetch } = arrange(ExecutionPhase.EXECUTION_IN_PROGRESS);
663
+ mockStreamStage("streaming");
664
+ const { rerender } = render(<WorkflowExecutionViewer executionId="wex_1" />);
665
+ // Mid-run mount: the snapshot was just fetched — no refetch.
666
+ expect(refetch).not.toHaveBeenCalled();
667
+
668
+ mockStreamStage("reconnecting");
669
+ rerender(
670
+ <WorkflowExecutionViewer executionId="wex_1" className="pass-2" />,
671
+ );
672
+ expect(refetch).not.toHaveBeenCalled();
673
+
674
+ mockStreamStage("streaming");
675
+ rerender(
676
+ <WorkflowExecutionViewer executionId="wex_1" className="pass-3" />,
677
+ );
678
+ expect(refetch).toHaveBeenCalledTimes(1);
679
+ });
633
680
  });