@stigmer/react 3.4.0 → 3.4.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 (131) hide show
  1. package/agent/useAgentCount.d.ts +2 -0
  2. package/agent/useAgentCount.d.ts.map +1 -1
  3. package/agent/useAgentCount.js.map +1 -1
  4. package/api-key/CreateApiKeyForm.d.ts +7 -1
  5. package/api-key/CreateApiKeyForm.d.ts.map +1 -1
  6. package/api-key/CreateApiKeyForm.js +2 -2
  7. package/api-key/CreateApiKeyForm.js.map +1 -1
  8. package/datastore/useDatastoreCount.d.ts +2 -0
  9. package/datastore/useDatastoreCount.d.ts.map +1 -1
  10. package/datastore/useDatastoreCount.js.map +1 -1
  11. package/index.d.ts +4 -4
  12. package/index.d.ts.map +1 -1
  13. package/index.js +2 -2
  14. package/index.js.map +1 -1
  15. package/manifest/ApplyManifestDialog.d.ts +10 -5
  16. package/manifest/ApplyManifestDialog.d.ts.map +1 -1
  17. package/manifest/ApplyManifestDialog.js +30 -13
  18. package/manifest/ApplyManifestDialog.js.map +1 -1
  19. package/manifest/useApplyManifest.d.ts +6 -2
  20. package/manifest/useApplyManifest.d.ts.map +1 -1
  21. package/manifest/useApplyManifest.js +5 -5
  22. package/manifest/useApplyManifest.js.map +1 -1
  23. package/mcp-server/McpServerDetailView.d.ts +38 -2
  24. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  25. package/mcp-server/McpServerDetailView.js +21 -5
  26. package/mcp-server/McpServerDetailView.js.map +1 -1
  27. package/mcp-server/index.d.ts +7 -0
  28. package/mcp-server/index.d.ts.map +1 -1
  29. package/mcp-server/index.js +9 -0
  30. package/mcp-server/index.js.map +1 -1
  31. package/mcp-server/steps/EnvironmentAuthStep.d.ts +4 -0
  32. package/mcp-server/steps/EnvironmentAuthStep.d.ts.map +1 -1
  33. package/mcp-server/steps/EnvironmentAuthStep.js +4 -0
  34. package/mcp-server/steps/EnvironmentAuthStep.js.map +1 -1
  35. package/mcp-server/steps/IdentityTransportStep.d.ts +6 -0
  36. package/mcp-server/steps/IdentityTransportStep.d.ts.map +1 -1
  37. package/mcp-server/steps/IdentityTransportStep.js +7 -1
  38. package/mcp-server/steps/IdentityTransportStep.js.map +1 -1
  39. package/mcp-server/steps/ReviewStep.d.ts +5 -0
  40. package/mcp-server/steps/ReviewStep.d.ts.map +1 -1
  41. package/mcp-server/steps/ReviewStep.js +16 -2
  42. package/mcp-server/steps/ReviewStep.js.map +1 -1
  43. package/mcp-server/useMcpServerCount.d.ts +2 -0
  44. package/mcp-server/useMcpServerCount.d.ts.map +1 -1
  45. package/mcp-server/useMcpServerCount.js.map +1 -1
  46. package/package.json +4 -4
  47. package/resource-creation/CreationPicker.d.ts.map +1 -1
  48. package/resource-creation/CreationPicker.js +3 -3
  49. package/resource-creation/CreationPicker.js.map +1 -1
  50. package/resource-creation/WizardNav.d.ts.map +1 -1
  51. package/resource-creation/WizardNav.js +1 -1
  52. package/resource-creation/WizardNav.js.map +1 -1
  53. package/resource-detail/Section.d.ts +8 -1
  54. package/resource-detail/Section.d.ts.map +1 -1
  55. package/resource-detail/Section.js +2 -2
  56. package/resource-detail/Section.js.map +1 -1
  57. package/resource-workbench/components/ResourceWorkbench.d.ts +9 -1
  58. package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
  59. package/resource-workbench/components/ResourceWorkbench.js +2 -1
  60. package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
  61. package/resource-workbench/hooks/useResourceCollection.d.ts +9 -0
  62. package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
  63. package/resource-workbench/hooks/useResourceCollection.js +2 -2
  64. package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
  65. package/search/useResourceCount.d.ts +7 -0
  66. package/search/useResourceCount.d.ts.map +1 -1
  67. package/search/useResourceCount.js +2 -1
  68. package/search/useResourceCount.js.map +1 -1
  69. package/session/NewSessionViewer.d.ts.map +1 -1
  70. package/session/NewSessionViewer.js +17 -3
  71. package/session/NewSessionViewer.js.map +1 -1
  72. package/session/SessionViewer.d.ts +2 -1
  73. package/session/SessionViewer.d.ts.map +1 -1
  74. package/session/SessionViewer.js +19 -11
  75. package/session/SessionViewer.js.map +1 -1
  76. package/session/SessionViewerLayout.d.ts +90 -0
  77. package/session/SessionViewerLayout.d.ts.map +1 -0
  78. package/session/SessionViewerLayout.js +32 -0
  79. package/session/SessionViewerLayout.js.map +1 -0
  80. package/session/index.d.ts +4 -0
  81. package/session/index.d.ts.map +1 -1
  82. package/session/index.js +10 -0
  83. package/session/index.js.map +1 -1
  84. package/session/useSessionRailViews.d.ts +2 -2
  85. package/skill/useSkillCount.d.ts +2 -0
  86. package/skill/useSkillCount.d.ts.map +1 -1
  87. package/skill/useSkillCount.js.map +1 -1
  88. package/src/agent/useAgentCount.ts +2 -0
  89. package/src/api-key/CreateApiKeyForm.tsx +8 -1
  90. package/src/api-key/__tests__/CreateApiKeyForm.test.tsx +58 -0
  91. package/src/datastore/useDatastoreCount.ts +2 -0
  92. package/src/index.ts +11 -0
  93. package/src/manifest/ApplyManifestDialog.tsx +40 -16
  94. package/src/manifest/__tests__/ApplyManifestDialog.test.tsx +147 -0
  95. package/src/manifest/__tests__/useApplyManifest.test.tsx +6 -6
  96. package/src/manifest/useApplyManifest.ts +12 -8
  97. package/src/mcp-server/McpServerDetailView.tsx +52 -4
  98. package/src/mcp-server/__tests__/McpServerDetailView.test.tsx +345 -0
  99. package/src/mcp-server/index.ts +13 -0
  100. package/src/mcp-server/steps/EnvironmentAuthStep.tsx +4 -0
  101. package/src/mcp-server/steps/IdentityTransportStep.tsx +9 -1
  102. package/src/mcp-server/steps/ReviewStep.tsx +18 -1
  103. package/src/mcp-server/useMcpServerCount.ts +2 -0
  104. package/src/resource-creation/CreationPicker.tsx +7 -0
  105. package/src/resource-creation/WizardNav.tsx +1 -0
  106. package/src/resource-detail/Section.tsx +9 -1
  107. package/src/resource-workbench/components/ResourceWorkbench.tsx +13 -0
  108. package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
  109. package/src/search/useResourceCount.ts +9 -1
  110. package/src/session/NewSessionViewer.tsx +48 -61
  111. package/src/session/SessionViewer.tsx +71 -86
  112. package/src/session/SessionViewerLayout.tsx +131 -0
  113. package/src/session/__tests__/SessionViewerLayout.test.tsx +140 -0
  114. package/src/session/index.ts +14 -0
  115. package/src/session/useSessionRailViews.tsx +2 -2
  116. package/src/skill/useSkillCount.ts +2 -0
  117. package/src/test/__tests__/samples.test.ts +159 -2
  118. package/src/test/index.ts +1 -1
  119. package/src/test/samples.ts +109 -14
  120. package/src/workflow/useWorkflowCount.ts +2 -0
  121. package/test/index.d.ts +1 -1
  122. package/test/index.d.ts.map +1 -1
  123. package/test/index.js +1 -1
  124. package/test/index.js.map +1 -1
  125. package/test/samples.d.ts +79 -7
  126. package/test/samples.d.ts.map +1 -1
  127. package/test/samples.js +101 -14
  128. package/test/samples.js.map +1 -1
  129. package/workflow/useWorkflowCount.d.ts +2 -0
  130. package/workflow/useWorkflowCount.d.ts.map +1 -1
  131. package/workflow/useWorkflowCount.js.map +1 -1
@@ -12,6 +12,8 @@ export interface UseSkillCountOptions {
12
12
  * @default "org"
13
13
  */
14
14
  readonly scope?: ResourceListScope;
15
+ /** Opaque token that forces a recount when its value changes. */
16
+ readonly refetchToken?: unknown;
15
17
  }
16
18
  /** Return value of {@link useSkillCount}. */
17
19
  export interface UseSkillCountReturn {
@@ -1 +1 @@
1
- {"version":3,"file":"useSkillCount.d.ts","sourceRoot":"","sources":["../../src/skill/useSkillCount.ts"],"names":[],"mappings":"AAIA,OAAO,EAAoB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE9E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACnC,mDAAmD;IACnD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;OAOG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,iBAAiB,CAAC;CACpC;AAED,6CAA6C;AAC7C,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,iDAAiD;IACjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,OAAO,CAAC,EAAE,oBAAoB,GAC7B,mBAAmB,CAUrB"}
1
+ {"version":3,"file":"useSkillCount.d.ts","sourceRoot":"","sources":["../../src/skill/useSkillCount.ts"],"names":[],"mappings":"AAIA,OAAO,EAAoB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE9E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACnC,mDAAmD;IACnD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;OAOG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,iBAAiB,CAAC;IACnC,iEAAiE;IACjE,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC;AAED,6CAA6C;AAC7C,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,iDAAiD;IACjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,OAAO,CAAC,EAAE,oBAAoB,GAC7B,mBAAmB,CAUrB"}
@@ -1 +1 @@
1
- {"version":3,"file":"useSkillCount.js","sourceRoot":"","sources":["../../src/skill/useSkillCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAA0B,MAAM,oBAAoB,CAAC;AAgC9E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,aAAa,CAC3B,GAAkB,EAClB,OAA8B;IAE9B,MAAM,OAAO,GAAG,UAAU,EAAE,CAAC;IAE7B,MAAM,MAAM,GAAG,WAAW,CACxB,CAAC,MAAgD,EAAE,EAAE,CACnD,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAC5B,CAAC,OAAO,CAAC,CACV,CAAC;IAEF,OAAO,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC;AAChD,CAAC"}
1
+ {"version":3,"file":"useSkillCount.js","sourceRoot":"","sources":["../../src/skill/useSkillCount.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAA0B,MAAM,oBAAoB,CAAC;AAkC9E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,aAAa,CAC3B,GAAkB,EAClB,OAA8B;IAE9B,MAAM,OAAO,GAAG,UAAU,EAAE,CAAC;IAE7B,MAAM,MAAM,GAAG,WAAW,CACxB,CAAC,MAAgD,EAAE,EAAE,CACnD,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAC5B,CAAC,OAAO,CAAC,CACV,CAAC;IAEF,OAAO,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC;AAChD,CAAC"}
@@ -17,6 +17,8 @@ export interface UseAgentCountOptions {
17
17
  * @default "org"
18
18
  */
19
19
  readonly scope?: ResourceListScope;
20
+ /** Opaque token that forces a recount when its value changes. */
21
+ readonly refetchToken?: unknown;
20
22
  }
21
23
 
22
24
  /** Return value of {@link useAgentCount}. */
@@ -22,6 +22,12 @@ export interface CreateApiKeyFormProps {
22
22
  readonly onCreated?: (apiKey: ApiKey) => void;
23
23
  /** Fired when the user cancels creation. */
24
24
  readonly onCancel?: () => void;
25
+ /**
26
+ * Seeds the name field on mount (e.g. a suggested key name, or a demo
27
+ * scenario depicting the form mid-fill). One-time: consumed on mount;
28
+ * subsequent changes are ignored. The field stays fully editable.
29
+ */
30
+ readonly initialName?: string;
25
31
  /** Additional CSS class names for the root container. */
26
32
  readonly className?: string;
27
33
  }
@@ -51,11 +57,12 @@ export function CreateApiKeyForm({
51
57
  org,
52
58
  onCreated,
53
59
  onCancel,
60
+ initialName = "",
54
61
  className,
55
62
  }: CreateApiKeyFormProps) {
56
63
  const { create, isCreating, error, clearError } = useCreateApiKey();
57
64
 
58
- const [name, setName] = useState("");
65
+ const [name, setName] = useState(initialName);
59
66
  const [expiry, setExpiry] = useState<ExpiryOption>("never");
60
67
 
61
68
  const trimmedName = name.trim();
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { StigmerContext } from "../../context";
6
+ import { CreateApiKeyForm } from "../CreateApiKeyForm";
7
+
8
+ afterEach(cleanup);
9
+
10
+ function createWrapper() {
11
+ const mockClient = {
12
+ apiKey: { create: vi.fn() },
13
+ } as unknown as Stigmer;
14
+
15
+ return ({ children }: { children: ReactNode }) => (
16
+ <StigmerContext.Provider value={mockClient}>
17
+ {children}
18
+ </StigmerContext.Provider>
19
+ );
20
+ }
21
+
22
+ function nameInput(): HTMLInputElement {
23
+ return screen.getByLabelText("Name") as HTMLInputElement;
24
+ }
25
+
26
+ function submitButton(): HTMLButtonElement {
27
+ return screen.getByRole("button", {
28
+ name: "Create API key",
29
+ }) as HTMLButtonElement;
30
+ }
31
+
32
+ describe("CreateApiKeyForm initialName", () => {
33
+ it("starts with an empty name and a disabled submit by default", () => {
34
+ render(<CreateApiKeyForm org="acme" />, { wrapper: createWrapper() });
35
+
36
+ expect(nameInput().value).toBe("");
37
+ expect(submitButton().disabled).toBe(true);
38
+ });
39
+
40
+ it("seeds the name field and enables submit when initialName is set", () => {
41
+ render(<CreateApiKeyForm org="acme" initialName="quickstart-key" />, {
42
+ wrapper: createWrapper(),
43
+ });
44
+
45
+ expect(nameInput().value).toBe("quickstart-key");
46
+ expect(submitButton().disabled).toBe(false);
47
+ });
48
+
49
+ it("keeps the seeded field editable — initialName is a seed, not a lock", () => {
50
+ render(<CreateApiKeyForm org="acme" initialName="quickstart-key" />, {
51
+ wrapper: createWrapper(),
52
+ });
53
+
54
+ fireEvent.change(nameInput(), { target: { value: "renamed-key" } });
55
+
56
+ expect(nameInput().value).toBe("renamed-key");
57
+ });
58
+ });
@@ -17,6 +17,8 @@ export interface UseDatastoreCountOptions {
17
17
  * @default "org"
18
18
  */
19
19
  readonly scope?: ResourceListScope;
20
+ /** Opaque token that forces a recount when its value changes. */
21
+ readonly refetchToken?: unknown;
20
22
  }
21
23
 
22
24
  /** Return value of {@link useDatastoreCount}. */
package/src/index.ts CHANGED
@@ -156,7 +156,9 @@ export {
156
156
  channelSessionExternalUserKey,
157
157
  SessionViewer,
158
158
  NewSessionViewer,
159
+ SessionViewerLayout,
159
160
  useSessionPanel,
161
+ useSessionRailViews,
160
162
  usePlanDraft,
161
163
  planDraftKey,
162
164
  PlanEditor,
@@ -201,8 +203,10 @@ export type {
201
203
  SearchResultGroup,
202
204
  SessionViewerProps,
203
205
  NewSessionViewerProps,
206
+ SessionViewerLayoutProps,
204
207
  SessionPanelController,
205
208
  UseSessionPanelOptions,
209
+ UseSessionRailViewsOptions,
206
210
  PlanDraftController,
207
211
  PlanEditorProps,
208
212
  PlanStreamingDocumentProps,
@@ -526,6 +530,10 @@ export {
526
530
  useUpdateMcpServer,
527
531
  mcpServerToInput,
528
532
  toServerKey,
533
+ IdentityTransportStep,
534
+ EnvironmentAuthStep,
535
+ ReviewStep,
536
+ createInitialMcpServerWizardData,
529
537
  } from "./mcp-server/index.js";
530
538
  export type {
531
539
  UseMcpServerReturn,
@@ -563,6 +571,9 @@ export type {
563
571
  McpServerCreationWizardProps,
564
572
  McpServerCreationResult,
565
573
  McpServerWizardData,
574
+ IdentityTransportStepProps,
575
+ EnvironmentAuthStepProps,
576
+ ReviewStepProps,
566
577
  } from "./mcp-server/index.js";
567
578
 
568
579
  // Skill — data hooks, upload, file browser, and mutation
@@ -23,10 +23,15 @@ export interface ApplyManifestDialogProps {
23
23
  /** Target organization slug for documents that omit `metadata.org`. */
24
24
  readonly org: string;
25
25
  /**
26
- * Called after every document applied successfully, with the per-document
27
- * results in apply order. Use for navigation or list refresh.
26
+ * Called when the dialog closes after at least one document applied,
27
+ * with the applied entries in apply order on full success, and also
28
+ * when the operator dismisses the dialog after a partial apply (some
29
+ * documents applied, a later one failed). Use it to refresh a list so
30
+ * the newly applied resources appear without a reload. It never fires
31
+ * when nothing applied, and fires once per close. Mirrors
32
+ * {@link EditResourceYamlDialog}'s `onApplied`.
28
33
  */
29
- readonly onSuccess?: (entries: readonly ManifestPreviewEntry[]) => void;
34
+ readonly onApplied?: (applied: readonly ManifestPreviewEntry[]) => void;
30
35
  }
31
36
 
32
37
  /**
@@ -56,7 +61,7 @@ export interface ApplyManifestDialogProps {
56
61
  * open={open}
57
62
  * onOpenChange={setOpen}
58
63
  * org={activeOrg}
59
- * onSuccess={() => refetchList()}
64
+ * onApplied={() => refetchList()}
60
65
  * />
61
66
  * ```
62
67
  */
@@ -64,7 +69,7 @@ export function ApplyManifestDialog({
64
69
  open,
65
70
  onOpenChange,
66
71
  org,
67
- onSuccess,
72
+ onApplied,
68
73
  }: ApplyManifestDialogProps) {
69
74
  const dialogRef = useRef<HTMLDialogElement>(null);
70
75
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -90,12 +95,26 @@ export function ApplyManifestDialog({
90
95
  }
91
96
  }, [open, reset]);
92
97
 
98
+ // Close the dialog and, if any document applied this session, refresh
99
+ // the caller's list. This is the dismiss-after-partial path: some
100
+ // documents applied before a later one failed, and the operator closes
101
+ // the dialog rather than retrying — those applied resources must still
102
+ // appear. The full-success path in `handleApply` fires `onApplied`
103
+ // itself (it holds the fresh applied list), so it never routes here.
104
+ const closeAndMaybeRefresh = useCallback(() => {
105
+ onOpenChange(false);
106
+ const applied = (manifest.entries ?? []).filter(
107
+ (e) => e.status === "applied",
108
+ );
109
+ if (applied.length > 0) onApplied?.(applied);
110
+ }, [onOpenChange, manifest.entries, onApplied]);
111
+
93
112
  const handleCancel = useCallback(
94
113
  (e: React.SyntheticEvent) => {
95
114
  e.preventDefault();
96
- onOpenChange(false);
115
+ closeAndMaybeRefresh();
97
116
  },
98
- [onOpenChange],
117
+ [closeAndMaybeRefresh],
99
118
  );
100
119
 
101
120
  const handleFileChange = useCallback(
@@ -107,18 +126,23 @@ export function ApplyManifestDialog({
107
126
  );
108
127
 
109
128
  const handleApply = useCallback(async () => {
110
- const allApplied = await manifest.applyAll();
111
- if (allApplied && manifest.entries) {
112
- const count = manifest.entries.length;
129
+ const results = await manifest.applyAll();
130
+ const applied = results.filter((e) => e.status === "applied");
131
+ const allApplied = results.length > 0 && applied.length === results.length;
132
+ if (allApplied) {
113
133
  toast.success(
114
- count === 1 ? "Resource applied" : `${count} resources applied`,
134
+ applied.length === 1
135
+ ? "Resource applied"
136
+ : `${applied.length} resources applied`,
115
137
  );
116
138
  onOpenChange(false);
117
- onSuccess?.(manifest.entries);
139
+ onApplied?.(applied);
118
140
  }
119
- // On failure the dialog stays open — per-entry status shows what
120
- // applied and what failed.
121
- }, [manifest, onOpenChange, onSuccess]);
141
+ // On partial/total failure the dialog stays open — per-entry status
142
+ // shows what applied and what failed. Any entries that DID apply
143
+ // still refresh the caller's list when the dialog is dismissed
144
+ // (closeAndMaybeRefresh), so successful writes are never hidden.
145
+ }, [manifest, onOpenChange, onApplied]);
122
146
 
123
147
  const canApply =
124
148
  manifest.entries !== null &&
@@ -198,7 +222,7 @@ export function ApplyManifestDialog({
198
222
  <div className="flex gap-2">
199
223
  <button
200
224
  type="button"
201
- onClick={() => onOpenChange(false)}
225
+ onClick={closeAndMaybeRefresh}
202
226
  disabled={manifest.isApplying}
203
227
  className={cn(
204
228
  "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
@@ -0,0 +1,147 @@
1
+ // Tests for ApplyManifestDialog's onApplied contract — the refresh
2
+ // signal every Library list page wires to appear-without-reload. It must
3
+ // fire on full success AND when the operator dismisses the dialog after a
4
+ // partial apply (some documents applied, a later one failed), and must
5
+ // never fire when nothing applied. Historically onApplied's predecessor
6
+ // had zero coverage and stayed unwired at every call site; this locks the
7
+ // contract the list pages now depend on.
8
+
9
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
10
+ import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
11
+ import { ApplyManifestDialog } from "../ApplyManifestDialog";
12
+ import type {
13
+ ManifestPreviewEntry,
14
+ UseApplyManifestReturn,
15
+ } from "../useApplyManifest";
16
+
17
+ beforeAll(() => {
18
+ HTMLDialogElement.prototype.showModal = function showModal() {
19
+ this.open = true;
20
+ };
21
+ HTMLDialogElement.prototype.close = function close() {
22
+ this.open = false;
23
+ };
24
+ });
25
+
26
+ afterEach(() => {
27
+ cleanup();
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ // A preview entry in a chosen terminal status; the document shape is only
32
+ // what the dialog's preview row and apply flow read.
33
+ function entry(
34
+ slug: string,
35
+ status: ManifestPreviewEntry["status"],
36
+ ): ManifestPreviewEntry {
37
+ return {
38
+ document: {
39
+ handler: { yamlKind: "datastore", displayName: "Datastore" },
40
+ name: slug,
41
+ slug,
42
+ org: "acme",
43
+ },
44
+ action: "create",
45
+ status,
46
+ } as ManifestPreviewEntry;
47
+ }
48
+
49
+ // The mock's mutable knobs: the entries the hook exposes and the terminal
50
+ // entries applyAll resolves to. Set per test before rendering.
51
+ let mockEntries: readonly ManifestPreviewEntry[] | null;
52
+ let applyAllResult: readonly ManifestPreviewEntry[];
53
+ const applyAllMock = vi.fn(async () => applyAllResult);
54
+
55
+ vi.mock("../useApplyManifest", () => ({
56
+ useApplyManifest: (): UseApplyManifestReturn =>
57
+ ({
58
+ content: "kind: Datastore",
59
+ setContent: vi.fn(),
60
+ readFile: vi.fn(),
61
+ entries: mockEntries,
62
+ validationError: null,
63
+ isValidating: false,
64
+ hasRedactedSecrets: false,
65
+ applyAll: applyAllMock,
66
+ isApplying: false,
67
+ reset: vi.fn(),
68
+ }) as UseApplyManifestReturn,
69
+ }));
70
+
71
+ // CodeMirror stays out of the test environment.
72
+ vi.mock("../YamlEditor", () => ({
73
+ YamlEditor: () => <div data-testid="yaml-editor" />,
74
+ }));
75
+
76
+ describe("ApplyManifestDialog — onApplied", () => {
77
+ it("fires with the applied entries on full success and closes", async () => {
78
+ mockEntries = [entry("a", "pending"), entry("b", "pending")];
79
+ applyAllResult = [entry("a", "applied"), entry("b", "applied")];
80
+ const onApplied = vi.fn();
81
+ const onOpenChange = vi.fn();
82
+
83
+ render(
84
+ <ApplyManifestDialog
85
+ open
86
+ onOpenChange={onOpenChange}
87
+ org="acme"
88
+ onApplied={onApplied}
89
+ />,
90
+ );
91
+
92
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
93
+
94
+ await waitFor(() => expect(onApplied).toHaveBeenCalledTimes(1));
95
+ expect(
96
+ onApplied.mock.calls[0][0].map((e: ManifestPreviewEntry) => e.document.slug),
97
+ ).toEqual(["a", "b"]);
98
+ expect(onOpenChange).toHaveBeenCalledWith(false);
99
+ });
100
+
101
+ it("fires with only the applied subset when a partial apply is dismissed", async () => {
102
+ // Applied one, failed the next — the dialog stays open on failure.
103
+ mockEntries = [entry("a", "applied"), entry("b", "failed")];
104
+ applyAllResult = [entry("a", "applied"), entry("b", "failed")];
105
+ const onApplied = vi.fn();
106
+ const onOpenChange = vi.fn();
107
+
108
+ render(
109
+ <ApplyManifestDialog
110
+ open
111
+ onOpenChange={onOpenChange}
112
+ org="acme"
113
+ onApplied={onApplied}
114
+ />,
115
+ );
116
+
117
+ // Apply first: full success did not happen, so onApplied has not fired.
118
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
119
+ await waitFor(() => expect(applyAllMock).toHaveBeenCalled());
120
+ expect(onApplied).not.toHaveBeenCalled();
121
+
122
+ // Dismiss with an applied entry present → refresh the list.
123
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
124
+ expect(onApplied).toHaveBeenCalledTimes(1);
125
+ expect(
126
+ onApplied.mock.calls[0][0].map((e: ManifestPreviewEntry) => e.document.slug),
127
+ ).toEqual(["a"]);
128
+ expect(onOpenChange).toHaveBeenCalledWith(false);
129
+ });
130
+
131
+ it("does not fire when the dialog is dismissed with nothing applied", () => {
132
+ mockEntries = [entry("a", "pending")];
133
+ const onApplied = vi.fn();
134
+
135
+ render(
136
+ <ApplyManifestDialog
137
+ open
138
+ onOpenChange={vi.fn()}
139
+ org="acme"
140
+ onApplied={onApplied}
141
+ />,
142
+ );
143
+
144
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
145
+ expect(onApplied).not.toHaveBeenCalled();
146
+ });
147
+ });
@@ -174,12 +174,12 @@ describe("useApplyManifest", () => {
174
174
  });
175
175
  await act(settleValidation);
176
176
 
177
- let allApplied = false;
177
+ let applied: readonly { status: string }[] = [];
178
178
  await act(async () => {
179
- allApplied = await result.current.applyAll();
179
+ applied = await result.current.applyAll();
180
180
  });
181
181
 
182
- expect(allApplied).toBe(true);
182
+ expect(applied.map((e) => e.status)).toEqual(["applied", "applied"]);
183
183
  expect(apply).toHaveBeenCalledTimes(2);
184
184
  expect(result.current.entries!.map((e) => e.status)).toEqual([
185
185
  "applied",
@@ -201,12 +201,12 @@ describe("useApplyManifest", () => {
201
201
  });
202
202
  await act(settleValidation);
203
203
 
204
- let allApplied = true;
204
+ let applied: readonly { status: string }[] = [];
205
205
  await act(async () => {
206
- allApplied = await result.current.applyAll();
206
+ applied = await result.current.applyAll();
207
207
  });
208
208
 
209
- expect(allApplied).toBe(false);
209
+ expect(applied.map((e) => e.status)).toEqual(["failed", "skipped"]);
210
210
  expect(apply).toHaveBeenCalledTimes(1);
211
211
  expect(result.current.entries!.map((e) => e.status)).toEqual([
212
212
  "failed",
@@ -55,9 +55,13 @@ export interface UseApplyManifestReturn {
55
55
  /**
56
56
  * Apply all documents sequentially in dependency order. Entries update
57
57
  * live. Stops at the first failure (later entries become `"skipped"`).
58
- * Resolves `true` when every document applied.
58
+ * Resolves to the final per-document entries with terminal statuses —
59
+ * the caller derives full success (`every applied`) and the applied
60
+ * subset (for a list refresh) without re-reading React state, which is
61
+ * still stale in the calling closure. Empty when there was nothing to
62
+ * apply.
59
63
  */
60
- readonly applyAll: () => Promise<boolean>;
64
+ readonly applyAll: () => Promise<readonly ManifestPreviewEntry[]>;
61
65
  /** `true` while applies are in flight. */
62
66
  readonly isApplying: boolean;
63
67
  /** Clear all state (content, preview, errors). */
@@ -194,8 +198,8 @@ export function useApplyManifest(org: string): UseApplyManifestReturn {
194
198
  return () => clearTimeout(timer);
195
199
  }, [content, stigmer, isApplying]);
196
200
 
197
- const applyAll = useCallback(async (): Promise<boolean> => {
198
- if (!entries || entries.length === 0) return false;
201
+ const applyAll = useCallback(async (): Promise<readonly ManifestPreviewEntry[]> => {
202
+ if (!entries || entries.length === 0) return [];
199
203
 
200
204
  // Invalidate any in-flight validation so it cannot clobber apply statuses.
201
205
  validateSeq.current++;
@@ -206,10 +210,10 @@ export function useApplyManifest(org: string): UseApplyManifestReturn {
206
210
  const working = entries.map((entry): ManifestPreviewEntry => ({ ...entry, status: "pending" }));
207
211
  const commit = () => setEntries([...working]);
208
212
 
209
- let allApplied = true;
213
+ let stopped = false;
210
214
 
211
215
  for (let i = 0; i < working.length; i++) {
212
- if (!allApplied) {
216
+ if (stopped) {
213
217
  working[i] = { ...working[i], status: "skipped" };
214
218
  continue;
215
219
  }
@@ -226,14 +230,14 @@ export function useApplyManifest(org: string): UseApplyManifestReturn {
226
230
  status: "failed",
227
231
  errorMessage: toError(err).message,
228
232
  };
229
- allApplied = false;
233
+ stopped = true;
230
234
  }
231
235
  commit();
232
236
  }
233
237
 
234
238
  commit();
235
239
  setIsApplying(false);
236
- return allApplied;
240
+ return working;
237
241
  }, [entries, stigmer]);
238
242
 
239
243
  const hasRedactedSecrets = useMemo(
@@ -14,6 +14,7 @@ import type { ToolApprovalPolicy, McpServerSpec } from "@stigmer/protos/ai/stigm
14
14
  import { ValidationState } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
15
15
  import type { EnvVarDeclaration } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
16
16
  import { useMcpServer } from "./useMcpServer.js";
17
+ import type { UseMcpServerReturn } from "./useMcpServer.js";
17
18
  import { useUpdateMcpServer } from "./useUpdateMcpServer.js";
18
19
  import { mcpServerToInput } from "./internal/mcpServerToInput.js";
19
20
  import { useMcpServerConnect } from "./useMcpServerConnect.js";
@@ -51,6 +52,31 @@ export interface McpServerDetailViewProps {
51
52
  readonly org: string;
52
53
  /** MCP server slug (URL-friendly identifier unique within the org). */
53
54
  readonly slug: string;
55
+ /**
56
+ * Hoisted resource state from {@link useMcpServer}, for callers that
57
+ * already own the fetch. When provided, the view issues **no**
58
+ * `getByReference` RPC of its own and renders entirely from this state —
59
+ * loading, error, and not-found included, so a caller mid-fetch still
60
+ * gets the loading skeleton rather than a false not-found.
61
+ *
62
+ * Ownership contract: supplying this state means supplying its
63
+ * lifecycle. The view calls `mcpServerState.refetch()` after connect,
64
+ * disconnect, and inline edits, so the `refetch` you pass must refresh
65
+ * the data behind `mcpServer` or the view will render stale state after
66
+ * those actions.
67
+ *
68
+ * Two callers this serves:
69
+ * - A page that already fetched the server (e.g. for export/YAML
70
+ * actions) passes `useMcpServer(org, slug)` straight through instead
71
+ * of paying a duplicate RPC.
72
+ * - A guided tour or demo supplies frozen state
73
+ * (`{ mcpServer, isLoading: false, isRefetching: false, error: null,
74
+ * refetch: noop }`) so every depicted beat renders deterministically
75
+ * from data the tour owns (scenar-cloud DD-006).
76
+ *
77
+ * When omitted, the view fetches by `org`/`slug` itself, as before.
78
+ */
79
+ readonly mcpServerState?: UseMcpServerReturn;
54
80
  /**
55
81
  * Called once when the MCP server resource has been fetched successfully.
56
82
  * Provides the resource display name for use cases like breadcrumbs,
@@ -115,7 +141,8 @@ export interface McpServerDetailViewProps {
115
141
  /**
116
142
  * Detail view for an MCP Server integration.
117
143
  *
118
- * Fetches the server via {@link useMcpServer} internally and renders
144
+ * Fetches the server via {@link useMcpServer} internally or renders
145
+ * from hoisted state when the caller passes `mcpServerState` — and shows
119
146
  * its full configuration: validation banner (if invalid), header,
120
147
  * a **Connect bar** with a single Connect/Reconnect action, and a
121
148
  * **tabbed capabilities panel** (Tools, Policies, and optionally
@@ -135,10 +162,20 @@ export interface McpServerDetailViewProps {
135
162
  * // Minimal — self-contained, fetches its own data
136
163
  * <McpServerDetailView org="acme" slug="github" />
137
164
  * ```
165
+ *
166
+ * @example
167
+ * ```tsx
168
+ * // Hoisted — the page already fetched the server (e.g. for YAML
169
+ * // export actions), so pass that state through instead of paying a
170
+ * // second getByReference RPC.
171
+ * const state = useMcpServer(org, slug);
172
+ * <McpServerDetailView org={org} slug={slug} mcpServerState={state} />
173
+ * ```
138
174
  */
139
175
  export function McpServerDetailView({
140
176
  org,
141
177
  slug,
178
+ mcpServerState,
142
179
  onResourceLoad,
143
180
  defaultCapabilityTab = "tools",
144
181
  defaultShowCredentialForm = false,
@@ -150,7 +187,16 @@ export function McpServerDetailView({
150
187
  onResourceUpdated,
151
188
  className,
152
189
  }: McpServerDetailViewProps) {
153
- const { mcpServer, isLoading, error, refetch } = useMcpServer(org, slug);
190
+ // Hoisted-state mode: when the caller supplies `mcpServerState`, disable
191
+ // the internal fetch via the hook's documented null-skip (a stable no-op,
192
+ // so `fetched` reports isLoading=false with no RPC) and render from the
193
+ // caller's state instead — all four states (loading/error/not-found/data)
194
+ // transfer intact.
195
+ const fetched = useMcpServer(
196
+ mcpServerState ? null : org,
197
+ mcpServerState ? null : slug,
198
+ );
199
+ const { mcpServer, isLoading, error, refetch } = mcpServerState ?? fetched;
154
200
  const { update: updateMcpServer, isUpdating } = useUpdateMcpServer();
155
201
 
156
202
  const saveMcpField = useCallback(
@@ -493,7 +539,9 @@ export function McpServerDetailView({
493
539
  />
494
540
  )}
495
541
 
496
- <Section title="Connection">
542
+ {/* scrollTarget: guided tours/demos bring the connect flow into view
543
+ (see IdentityTransportStep's "mcp-transport" for the convention). */}
544
+ <Section title="Connection" scrollTarget="mcp-connection">
497
545
  <StdioSandboxNotice serverType={spec?.serverType} className="mb-3" />
498
546
  <OAuthRequiredNotice oauthOnly={spec?.auth?.oauthOnly} className="mb-3" />
499
547
  <ConnectBar
@@ -582,7 +630,7 @@ export function McpServerDetailView({
582
630
  />
583
631
  </dialog>
584
632
 
585
- <Section title="Capabilities">
633
+ <Section title="Capabilities" scrollTarget="mcp-capabilities">
586
634
  <Tabs
587
635
  tabs={capabilityTabs}
588
636
  activeTab={capabilityTab}