@stigmer/react 3.12.2 → 3.12.3

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 (151) hide show
  1. package/access/ManageAccessDialog.d.ts.map +1 -1
  2. package/access/ManageAccessDialog.js +3 -19
  3. package/access/ManageAccessDialog.js.map +1 -1
  4. package/agent-instance/CreateAgentInstanceDialog.d.ts.map +1 -1
  5. package/agent-instance/CreateAgentInstanceDialog.js +7 -20
  6. package/agent-instance/CreateAgentInstanceDialog.js.map +1 -1
  7. package/attachment/AttachmentImageLightbox.d.ts.map +1 -1
  8. package/attachment/AttachmentImageLightbox.js +8 -23
  9. package/attachment/AttachmentImageLightbox.js.map +1 -1
  10. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  11. package/channel/ChannelConversationsDialog.js +3 -19
  12. package/channel/ChannelConversationsDialog.js.map +1 -1
  13. package/channel/ChannelCredentialsDialog.d.ts.map +1 -1
  14. package/channel/ChannelCredentialsDialog.js +3 -19
  15. package/channel/ChannelCredentialsDialog.js.map +1 -1
  16. package/channel/ChannelTemplatesDialog.d.ts.map +1 -1
  17. package/channel/ChannelTemplatesDialog.js +4 -20
  18. package/channel/ChannelTemplatesDialog.js.map +1 -1
  19. package/channel/ConnectSlackDialog.d.ts.map +1 -1
  20. package/channel/ConnectSlackDialog.js +3 -21
  21. package/channel/ConnectSlackDialog.js.map +1 -1
  22. package/channel/ConnectWhatsAppDialog.d.ts.map +1 -1
  23. package/channel/ConnectWhatsAppDialog.js +3 -21
  24. package/channel/ConnectWhatsAppDialog.js.map +1 -1
  25. package/conversation/ConversationAttentionBanner.d.ts +23 -6
  26. package/conversation/ConversationAttentionBanner.d.ts.map +1 -1
  27. package/conversation/ConversationAttentionBanner.js +25 -10
  28. package/conversation/ConversationAttentionBanner.js.map +1 -1
  29. package/conversation/ConversationListPane.d.ts.map +1 -1
  30. package/conversation/ConversationListPane.js +25 -6
  31. package/conversation/ConversationListPane.js.map +1 -1
  32. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  33. package/conversation/ConversationsWorkbench.js +1 -1
  34. package/conversation/ConversationsWorkbench.js.map +1 -1
  35. package/execution/ApprovalCard.d.ts.map +1 -1
  36. package/execution/ApprovalCard.js +13 -3
  37. package/execution/ApprovalCard.js.map +1 -1
  38. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  39. package/execution/ArtifactPreviewModal.js +11 -17
  40. package/execution/ArtifactPreviewModal.js.map +1 -1
  41. package/execution/ToolCallItem.d.ts +3 -1
  42. package/execution/ToolCallItem.d.ts.map +1 -1
  43. package/execution/ToolCallItem.js +19 -7
  44. package/execution/ToolCallItem.js.map +1 -1
  45. package/execution/index.d.ts +1 -1
  46. package/execution/index.d.ts.map +1 -1
  47. package/execution/index.js +1 -1
  48. package/execution/index.js.map +1 -1
  49. package/execution/tool-categories.d.ts +51 -0
  50. package/execution/tool-categories.d.ts.map +1 -1
  51. package/execution/tool-categories.js +103 -1
  52. package/execution/tool-categories.js.map +1 -1
  53. package/execution/tool-presenter.d.ts +15 -0
  54. package/execution/tool-presenter.d.ts.map +1 -1
  55. package/execution/tool-presenter.js +7 -2
  56. package/execution/tool-presenter.js.map +1 -1
  57. package/index.d.ts +1 -1
  58. package/index.d.ts.map +1 -1
  59. package/index.js +1 -1
  60. package/index.js.map +1 -1
  61. package/internal/DialogShell.d.ts +77 -0
  62. package/internal/DialogShell.d.ts.map +1 -0
  63. package/internal/DialogShell.js +77 -0
  64. package/internal/DialogShell.js.map +1 -0
  65. package/manifest/ApplyManifestDialog.d.ts.map +1 -1
  66. package/manifest/ApplyManifestDialog.js +7 -17
  67. package/manifest/ApplyManifestDialog.js.map +1 -1
  68. package/manifest/EditResourceYamlDialog.d.ts.map +1 -1
  69. package/manifest/EditResourceYamlDialog.js +3 -19
  70. package/manifest/EditResourceYamlDialog.js.map +1 -1
  71. package/mcp-server/McpServerConnectDialog.d.ts.map +1 -1
  72. package/mcp-server/McpServerConnectDialog.js +8 -20
  73. package/mcp-server/McpServerConnectDialog.js.map +1 -1
  74. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  75. package/mcp-server/McpServerDetailView.js +4 -15
  76. package/mcp-server/McpServerDetailView.js.map +1 -1
  77. package/package.json +4 -4
  78. package/resource-detail/ConfirmDialog.d.ts +2 -3
  79. package/resource-detail/ConfirmDialog.d.ts.map +1 -1
  80. package/resource-detail/ConfirmDialog.js +8 -20
  81. package/resource-detail/ConfirmDialog.js.map +1 -1
  82. package/sharing/ShareAgentDialog.d.ts.map +1 -1
  83. package/sharing/ShareAgentDialog.js +2 -20
  84. package/sharing/ShareAgentDialog.js.map +1 -1
  85. package/skill/SkillDiffDialog.d.ts.map +1 -1
  86. package/skill/SkillDiffDialog.js +5 -21
  87. package/skill/SkillDiffDialog.js.map +1 -1
  88. package/src/access/ManageAccessDialog.tsx +7 -27
  89. package/src/agent-instance/CreateAgentInstanceDialog.tsx +14 -27
  90. package/src/attachment/AttachmentImageLightbox.tsx +13 -36
  91. package/src/channel/ChannelConversationsDialog.tsx +8 -27
  92. package/src/channel/ChannelCredentialsDialog.tsx +8 -28
  93. package/src/channel/ChannelTemplatesDialog.tsx +10 -29
  94. package/src/channel/ConnectSlackDialog.tsx +8 -30
  95. package/src/channel/ConnectWhatsAppDialog.tsx +8 -30
  96. package/src/conversation/ConversationAttentionBanner.tsx +64 -19
  97. package/src/conversation/ConversationListPane.tsx +42 -17
  98. package/src/conversation/ConversationsWorkbench.tsx +1 -0
  99. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +76 -2
  100. package/src/conversation/__tests__/ConversationListPane.test.tsx +15 -4
  101. package/src/execution/ApprovalCard.tsx +23 -2
  102. package/src/execution/ArtifactPreviewModal.tsx +15 -27
  103. package/src/execution/ToolCallItem.tsx +19 -8
  104. package/src/execution/__tests__/intent-title.test.tsx +221 -0
  105. package/src/execution/index.ts +3 -0
  106. package/src/execution/tool-categories.ts +123 -1
  107. package/src/execution/tool-presenter.ts +22 -1
  108. package/src/index.ts +3 -0
  109. package/src/internal/DialogShell.tsx +169 -0
  110. package/src/internal/__tests__/DialogShell.test.tsx +223 -0
  111. package/src/manifest/ApplyManifestDialog.tsx +8 -26
  112. package/src/manifest/EditResourceYamlDialog.tsx +4 -31
  113. package/src/mcp-server/McpServerConnectDialog.tsx +15 -29
  114. package/src/mcp-server/McpServerDetailView.tsx +12 -24
  115. package/src/resource-detail/ConfirmDialog.tsx +9 -30
  116. package/src/sharing/ShareAgentDialog.tsx +7 -29
  117. package/src/skill/SkillDiffDialog.tsx +13 -36
  118. package/src/workflow/ViewYamlDialog.tsx +18 -37
  119. package/src/workflow/WorkflowArchitectDialog.tsx +11 -43
  120. package/src/workflow/WorkflowExplainDialog.tsx +11 -37
  121. package/src/workflow/WorkflowGraphFullscreenDialog.tsx +14 -26
  122. package/src/workflow/WorkflowRunDialog.tsx +21 -48
  123. package/src/workflow/execution-comparison/ExecutionComparisonPicker.tsx +14 -31
  124. package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +14 -27
  125. package/src/workflow/templates/WorkflowTemplatePreview.tsx +18 -28
  126. package/styles.css +1 -1
  127. package/workflow/ViewYamlDialog.d.ts.map +1 -1
  128. package/workflow/ViewYamlDialog.js +10 -29
  129. package/workflow/ViewYamlDialog.js.map +1 -1
  130. package/workflow/WorkflowArchitectDialog.d.ts.map +1 -1
  131. package/workflow/WorkflowArchitectDialog.js +5 -24
  132. package/workflow/WorkflowArchitectDialog.js.map +1 -1
  133. package/workflow/WorkflowExplainDialog.d.ts.map +1 -1
  134. package/workflow/WorkflowExplainDialog.js +5 -21
  135. package/workflow/WorkflowExplainDialog.js.map +1 -1
  136. package/workflow/WorkflowGraphFullscreenDialog.d.ts.map +1 -1
  137. package/workflow/WorkflowGraphFullscreenDialog.js +11 -17
  138. package/workflow/WorkflowGraphFullscreenDialog.js.map +1 -1
  139. package/workflow/WorkflowRunDialog.d.ts.map +1 -1
  140. package/workflow/WorkflowRunDialog.js +13 -29
  141. package/workflow/WorkflowRunDialog.js.map +1 -1
  142. package/workflow/execution-comparison/ExecutionComparisonPicker.d.ts.map +1 -1
  143. package/workflow/execution-comparison/ExecutionComparisonPicker.js +8 -26
  144. package/workflow/execution-comparison/ExecutionComparisonPicker.js.map +1 -1
  145. package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
  146. package/workflow/instance/CreateWorkflowInstanceDialog.js +7 -20
  147. package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
  148. package/workflow/templates/WorkflowTemplatePreview.d.ts +1 -1
  149. package/workflow/templates/WorkflowTemplatePreview.d.ts.map +1 -1
  150. package/workflow/templates/WorkflowTemplatePreview.js +10 -22
  151. package/workflow/templates/WorkflowTemplatePreview.js.map +1 -1
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+ import type { MouseEvent, ReactNode, SyntheticEvent } from "react";
5
+ import { cn } from "@stigmer/theme";
6
+
7
+ /**
8
+ * Width presets for {@link DialogShell}, mapping to Tailwind `max-w-*`
9
+ * utilities. Free-form sizes (e.g. a lightbox's `85vw`) ride `className`,
10
+ * which wins over the preset via `cn()`'s tailwind-merge.
11
+ */
12
+ export type DialogShellWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
13
+
14
+ const WIDTH_CLASS: Record<DialogShellWidth, string> = {
15
+ sm: "stg:max-w-sm",
16
+ md: "stg:max-w-md",
17
+ lg: "stg:max-w-lg",
18
+ xl: "stg:max-w-xl",
19
+ "2xl": "stg:max-w-2xl",
20
+ "3xl": "stg:max-w-3xl",
21
+ "4xl": "stg:max-w-4xl",
22
+ };
23
+
24
+ /** Props for {@link DialogShell}. */
25
+ export interface DialogShellProps {
26
+ /** Whether the dialog is open (controlled — the SDK dialog convention). */
27
+ readonly open: boolean;
28
+ /**
29
+ * Called with `false` when the dialog should close: Escape / the platform
30
+ * cancel gesture, or a native close the host did not drive (e.g. a
31
+ * `method="dialog"` form submit). The shell never closes itself — state
32
+ * stays authoritative, so a host that ignores the callback keeps the
33
+ * dialog open (matching the pre-extraction per-dialog behavior).
34
+ */
35
+ readonly onOpenChange: (open: boolean) => void;
36
+ /** Max-width preset (default `"md"`). See {@link DialogShellWidth}. */
37
+ readonly width?: DialogShellWidth;
38
+ /**
39
+ * Modal (default) renders in the top layer via `showModal()` — focus trap,
40
+ * Escape, and the token backdrop. `modal={false}` renders the dialog
41
+ * in-flow (`open` attribute, relative positioning, no backdrop, no focus
42
+ * trap) — the channel-dialog embedding mode.
43
+ */
44
+ readonly modal?: boolean;
45
+ /**
46
+ * Light dismiss: a click on the backdrop (the dialog element itself —
47
+ * clicks inside the content land on descendants) reports close intent.
48
+ * The house rule from the pre-extraction census: read-only viewers
49
+ * (diff/YAML/explain/lightbox/pickers) dismiss on backdrop, form dialogs
50
+ * do NOT — a stray click must never discard a half-filled form. Default
51
+ * `false`.
52
+ */
53
+ readonly dismissOnBackdrop?: boolean;
54
+ /**
55
+ * Merged after the shell's own chrome, so deliberate outliers (a
56
+ * lightbox's viewport sizing, a fullscreen graph's `h-[90vh]`, a
57
+ * `bg-background` surface) override the defaults per class.
58
+ */
59
+ readonly className?: string;
60
+ /** Dialog content. Lazy-mounting bodies (`{open && …}`) stay caller-owned. */
61
+ readonly children: ReactNode;
62
+ /** Accessible name, when no visible heading labels the dialog. */
63
+ readonly "aria-label"?: string;
64
+ /** Id of the visible heading that labels the dialog. */
65
+ readonly "aria-labelledby"?: string;
66
+ }
67
+
68
+ /**
69
+ * The one dialog shell (stigmer#653). Owns everything every modal dialog in
70
+ * the SDK used to re-transcribe by hand: the `<dialog>` element and its
71
+ * `showModal()`/`close()` lifecycle, cancel/Escape wiring, the token
72
+ * backdrop (`--stgm-backdrop`), the open animation, and the base
73
+ * positioning/chrome — parameterized by {@link DialogShellWidth}.
74
+ *
75
+ * Converged chrome: `rounded-xl` + `shadow-xl` on the `popover` token
76
+ * surface (the token family for top-layer content). Before the extraction
77
+ * the 24 hand-rolled shells had drifted across `rounded-lg/xl`,
78
+ * `shadow-lg/xl/2xl`, three background tokens, two hardcoded backdrops that
79
+ * escaped the #652 fence, and animation classes present on some dialogs and
80
+ * missing from others.
81
+ *
82
+ * Internal on purpose — not exported from the package until a host needs it.
83
+ * The `stigmer/no-handrolled-dialog` lint rule fences new `<dialog>`
84
+ * elements onto this shell.
85
+ *
86
+ * @example
87
+ * ```tsx
88
+ * <DialogShell open={open} onOpenChange={setOpen} width="lg" aria-labelledby={titleId}>
89
+ * {open && <MyDialogBody titleId={titleId} onClose={() => setOpen(false)} />}
90
+ * </DialogShell>
91
+ * ```
92
+ */
93
+ export function DialogShell({
94
+ open,
95
+ onOpenChange,
96
+ width = "md",
97
+ modal = true,
98
+ dismissOnBackdrop = false,
99
+ className,
100
+ children,
101
+ "aria-label": ariaLabel,
102
+ "aria-labelledby": ariaLabelledby,
103
+ }: DialogShellProps) {
104
+ const dialogRef = useRef<HTMLDialogElement>(null);
105
+
106
+ // Sync the native top-layer state to the controlled prop. Runs on mount
107
+ // too, so a shell mounted with `open` already true shows immediately.
108
+ useEffect(() => {
109
+ const dialog = dialogRef.current;
110
+ if (!dialog || !modal) return;
111
+ if (open && !dialog.open) {
112
+ dialog.showModal();
113
+ } else if (!open && dialog.open) {
114
+ dialog.close();
115
+ }
116
+ }, [open, modal]);
117
+
118
+ // Escape / platform cancel: keep the native dialog open (preventDefault)
119
+ // and report the intent — the controlled `open` prop then drives the
120
+ // actual close through the effect above.
121
+ const handleCancel = useCallback(
122
+ (e: SyntheticEvent<HTMLDialogElement>) => {
123
+ e.preventDefault();
124
+ onOpenChange(false);
125
+ },
126
+ [onOpenChange],
127
+ );
128
+
129
+ // A native close the effect did not perform (e.g. a `method="dialog"`
130
+ // form submit) must not leave the controlled state stranded open.
131
+ const handleClose = useCallback(() => {
132
+ if (open) onOpenChange(false);
133
+ }, [open, onOpenChange]);
134
+
135
+ // Backdrop clicks hit the <dialog> element itself; clicks anywhere in the
136
+ // content land on a descendant, so the target check is exact.
137
+ const handleClick = useCallback(
138
+ (e: MouseEvent<HTMLDialogElement>) => {
139
+ if (e.target === e.currentTarget) onOpenChange(false);
140
+ },
141
+ [onOpenChange],
142
+ );
143
+
144
+ return (
145
+ <dialog
146
+ ref={dialogRef}
147
+ open={modal ? undefined : open || undefined}
148
+ onCancel={modal ? handleCancel : undefined}
149
+ onClose={modal ? handleClose : undefined}
150
+ onClick={modal && dismissOnBackdrop ? handleClick : undefined}
151
+ aria-label={ariaLabel}
152
+ aria-labelledby={ariaLabelledby}
153
+ className={cn(
154
+ "stg:w-full stg:rounded-xl stg:border stg:border-border stg:bg-popover stg:p-0 stg:text-popover-foreground stg:shadow-xl",
155
+ modal
156
+ ? cn(
157
+ "stg:fixed stg:inset-0 stg:z-50 stg:m-auto",
158
+ "stg:backdrop:bg-backdrop",
159
+ "stg:open:animate-in stg:open:fade-in-0 stg:open:zoom-in-95",
160
+ )
161
+ : "stg:relative",
162
+ WIDTH_CLASS[width],
163
+ className,
164
+ )}
165
+ >
166
+ {children}
167
+ </dialog>
168
+ );
169
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * DialogShell (stigmer#653): the one modal shell — showModal lifecycle,
3
+ * cancel/Escape wiring with state authority, native-close sync, the token
4
+ * backdrop + converged chrome, and the non-modal in-flow mode.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
8
+ import { render, cleanup, fireEvent } from "@testing-library/react";
9
+ import { DialogShell } from "../DialogShell";
10
+
11
+ // happy-dom does not implement the native dialog show/close methods.
12
+ beforeAll(() => {
13
+ HTMLDialogElement.prototype.showModal = function showModal() {
14
+ this.open = true;
15
+ };
16
+ HTMLDialogElement.prototype.close = function close() {
17
+ this.open = false;
18
+ };
19
+ });
20
+
21
+ afterEach(cleanup);
22
+
23
+ function dialogOf(container: HTMLElement): HTMLDialogElement {
24
+ return container.querySelector("dialog")!;
25
+ }
26
+
27
+ describe("DialogShell lifecycle", () => {
28
+ it("opens via showModal when the controlled prop flips true", () => {
29
+ const { container, rerender } = render(
30
+ <DialogShell open={false} onOpenChange={() => {}}>
31
+ <p>body</p>
32
+ </DialogShell>,
33
+ );
34
+ expect(dialogOf(container).open).toBe(false);
35
+
36
+ rerender(
37
+ <DialogShell open onOpenChange={() => {}}>
38
+ <p>body</p>
39
+ </DialogShell>,
40
+ );
41
+ expect(dialogOf(container).open).toBe(true);
42
+ });
43
+
44
+ it("shows immediately when mounted already open", () => {
45
+ const { container } = render(
46
+ <DialogShell open onOpenChange={() => {}}>
47
+ <p>body</p>
48
+ </DialogShell>,
49
+ );
50
+ expect(dialogOf(container).open).toBe(true);
51
+ });
52
+
53
+ it("closes the native dialog when the controlled prop flips false", () => {
54
+ const { container, rerender } = render(
55
+ <DialogShell open onOpenChange={() => {}}>
56
+ <p>body</p>
57
+ </DialogShell>,
58
+ );
59
+ rerender(
60
+ <DialogShell open={false} onOpenChange={() => {}}>
61
+ <p>body</p>
62
+ </DialogShell>,
63
+ );
64
+ expect(dialogOf(container).open).toBe(false);
65
+ });
66
+
67
+ it("Escape/cancel reports intent but never closes on its own — state stays authoritative", () => {
68
+ const onOpenChange = vi.fn();
69
+ const { container } = render(
70
+ <DialogShell open onOpenChange={onOpenChange}>
71
+ <p>body</p>
72
+ </DialogShell>,
73
+ );
74
+
75
+ fireEvent(dialogOf(container), new Event("cancel", { cancelable: true }));
76
+
77
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
78
+ // The shell prevented the native close; the dialog stays open until the
79
+ // host flips the prop.
80
+ expect(dialogOf(container).open).toBe(true);
81
+ });
82
+
83
+ it("syncs the controlled state when something else closes the dialog natively", () => {
84
+ const onOpenChange = vi.fn();
85
+ const { container } = render(
86
+ <DialogShell open onOpenChange={onOpenChange}>
87
+ <p>body</p>
88
+ </DialogShell>,
89
+ );
90
+
91
+ // e.g. a method="dialog" form submit closes without going through props.
92
+ const dialog = dialogOf(container);
93
+ dialog.close();
94
+ fireEvent(dialog, new Event("close"));
95
+
96
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
97
+ });
98
+
99
+ it("does not re-report a close the host itself drove", () => {
100
+ const onOpenChange = vi.fn();
101
+ const { container, rerender } = render(
102
+ <DialogShell open onOpenChange={onOpenChange}>
103
+ <p>body</p>
104
+ </DialogShell>,
105
+ );
106
+ rerender(
107
+ <DialogShell open={false} onOpenChange={onOpenChange}>
108
+ <p>body</p>
109
+ </DialogShell>,
110
+ );
111
+ // The effect's own dialog.close() fires a native close event in real
112
+ // browsers — replay it against the now-false prop.
113
+ fireEvent(dialogOf(container), new Event("close"));
114
+ expect(onOpenChange).not.toHaveBeenCalled();
115
+ });
116
+ });
117
+
118
+ describe("DialogShell chrome", () => {
119
+ it("carries the token backdrop, animation, positioning, and width preset", () => {
120
+ const { container } = render(
121
+ <DialogShell open onOpenChange={() => {}} width="3xl">
122
+ <p>body</p>
123
+ </DialogShell>,
124
+ );
125
+ const cls = dialogOf(container).className;
126
+
127
+ expect(cls).toContain("stg:backdrop:bg-backdrop");
128
+ expect(cls).toContain("stg:open:animate-in");
129
+ expect(cls).toContain("stg:fixed");
130
+ expect(cls).toContain("stg:m-auto");
131
+ expect(cls).toContain("stg:max-w-3xl");
132
+ expect(cls).toContain("stg:bg-popover");
133
+ });
134
+
135
+ it("className overrides the shell's own chrome per class (tailwind-merge)", () => {
136
+ const { container } = render(
137
+ <DialogShell
138
+ open
139
+ onOpenChange={() => {}}
140
+ className="stg:bg-background stg:max-w-[85vw]"
141
+ >
142
+ <p>body</p>
143
+ </DialogShell>,
144
+ );
145
+ const cls = dialogOf(container).className;
146
+
147
+ expect(cls).toContain("stg:bg-background");
148
+ expect(cls).not.toContain("stg:bg-popover");
149
+ expect(cls).toContain("stg:max-w-[85vw]");
150
+ expect(cls).not.toContain("stg:max-w-md");
151
+ });
152
+
153
+ it("labels the dialog for assistive tech", () => {
154
+ const { container } = render(
155
+ <DialogShell open onOpenChange={() => {}} aria-label="Confirm delete">
156
+ <p>body</p>
157
+ </DialogShell>,
158
+ );
159
+ expect(dialogOf(container).getAttribute("aria-label")).toBe("Confirm delete");
160
+ });
161
+ });
162
+
163
+ describe("DialogShell backdrop dismiss", () => {
164
+ it("dismissOnBackdrop: a click on the dialog element itself reports close intent", () => {
165
+ const onOpenChange = vi.fn();
166
+ const { container } = render(
167
+ <DialogShell open onOpenChange={onOpenChange} dismissOnBackdrop>
168
+ <p>body</p>
169
+ </DialogShell>,
170
+ );
171
+
172
+ fireEvent.click(dialogOf(container));
173
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
174
+ });
175
+
176
+ it("dismissOnBackdrop: clicks on content descendants never dismiss", () => {
177
+ const onOpenChange = vi.fn();
178
+ const { container, getByText } = render(
179
+ <DialogShell open onOpenChange={onOpenChange} dismissOnBackdrop>
180
+ <p>body</p>
181
+ </DialogShell>,
182
+ );
183
+
184
+ fireEvent.click(getByText("body"));
185
+ expect(onOpenChange).not.toHaveBeenCalled();
186
+ expect(dialogOf(container).open).toBe(true);
187
+ });
188
+
189
+ it("default: backdrop clicks are inert (form dialogs)", () => {
190
+ const onOpenChange = vi.fn();
191
+ const { container } = render(
192
+ <DialogShell open onOpenChange={onOpenChange}>
193
+ <p>body</p>
194
+ </DialogShell>,
195
+ );
196
+
197
+ fireEvent.click(dialogOf(container));
198
+ expect(onOpenChange).not.toHaveBeenCalled();
199
+ });
200
+ });
201
+
202
+ describe("DialogShell non-modal mode", () => {
203
+ it("renders in-flow: open attribute, no backdrop, no fixed positioning", () => {
204
+ const { container, rerender } = render(
205
+ <DialogShell open modal={false} onOpenChange={() => {}}>
206
+ <p>body</p>
207
+ </DialogShell>,
208
+ );
209
+ const dialog = dialogOf(container);
210
+
211
+ expect(dialog.open).toBe(true);
212
+ expect(dialog.className).toContain("stg:relative");
213
+ expect(dialog.className).not.toContain("stg:backdrop:bg-backdrop");
214
+ expect(dialog.className).not.toContain("stg:fixed");
215
+
216
+ rerender(
217
+ <DialogShell open={false} modal={false} onOpenChange={() => {}}>
218
+ <p>body</p>
219
+ </DialogShell>,
220
+ );
221
+ expect(dialogOf(container).open).toBe(false);
222
+ });
223
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { lazy, Suspense, useCallback, useEffect, useRef } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
+ import { DialogShell } from "../internal/DialogShell.js";
5
6
  import { toast } from "../feedback/toast.js";
6
7
  import { RedactedSecretsNotice } from "./RedactedSecretsNotice.js";
7
8
  import {
@@ -71,22 +72,10 @@ export function ApplyManifestDialog({
71
72
  org,
72
73
  onApplied,
73
74
  }: ApplyManifestDialogProps) {
74
- const dialogRef = useRef<HTMLDialogElement>(null);
75
75
  const fileInputRef = useRef<HTMLInputElement>(null);
76
76
  const manifest = useApplyManifest(org);
77
77
  const { reset } = manifest;
78
78
 
79
- // Sync dialog open state with the native <dialog> element.
80
- useEffect(() => {
81
- const dialog = dialogRef.current;
82
- if (!dialog) return;
83
- if (open && !dialog.open) {
84
- dialog.showModal();
85
- } else if (!open && dialog.open) {
86
- dialog.close();
87
- }
88
- }, [open]);
89
-
90
79
  // Start each session clean.
91
80
  useEffect(() => {
92
81
  if (open) {
@@ -109,10 +98,11 @@ export function ApplyManifestDialog({
109
98
  if (applied.length > 0) onApplied?.(applied);
110
99
  }, [onOpenChange, manifest.entries, onApplied]);
111
100
 
112
- const handleCancel = useCallback(
113
- (e: React.SyntheticEvent) => {
114
- e.preventDefault();
115
- closeAndMaybeRefresh();
101
+ // Escape/cancel routes through the same dismiss-after-partial path as the
102
+ // Close button, via the shell's single close-intent callback.
103
+ const handleShellOpenChange = useCallback(
104
+ (nextOpen: boolean) => {
105
+ if (!nextOpen) closeAndMaybeRefresh();
116
106
  },
117
107
  [closeAndMaybeRefresh],
118
108
  );
@@ -151,15 +141,7 @@ export function ApplyManifestDialog({
151
141
  !manifest.isApplying;
152
142
 
153
143
  return (
154
- <dialog
155
- ref={dialogRef}
156
- onCancel={handleCancel}
157
- className={cn(
158
- "stg:fixed stg:inset-0 stg:z-50 stg:m-auto stg:w-full stg:max-w-3xl stg:rounded-lg stg:border stg:border-border stg:bg-popover stg:p-0 stg:text-popover-foreground stg:shadow-lg",
159
- "stg:backdrop:bg-backdrop",
160
- "stg:open:animate-in stg:open:fade-in-0 stg:open:zoom-in-95",
161
- )}
162
- >
144
+ <DialogShell open={open} onOpenChange={handleShellOpenChange} width="3xl" aria-label="Apply YAML">
163
145
  <div className="stg:flex stg:flex-col stg:gap-4 stg:p-6">
164
146
  {/* Header */}
165
147
  <div className="stg:flex stg:flex-col stg:gap-1">
@@ -252,7 +234,7 @@ export function ApplyManifestDialog({
252
234
  </div>
253
235
  </div>
254
236
  </div>
255
- </dialog>
237
+ </DialogShell>
256
238
  );
257
239
  }
258
240
 
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { lazy, Suspense, useCallback, useEffect, useRef } from "react";
3
+ import { lazy, Suspense, useCallback, useEffect } from "react";
4
4
  import type { Message } from "@bufbuild/protobuf";
5
5
  import { cn } from "@stigmer/theme";
6
+ import { DialogShell } from "../internal/DialogShell.js";
6
7
  import { getUserMessage, type AppliedManifest } from "@stigmer/sdk";
7
8
  import { toast } from "../feedback/toast.js";
8
9
  import { RedactedSecretsNotice } from "./RedactedSecretsNotice.js";
@@ -67,34 +68,14 @@ export function EditResourceYamlDialog({
67
68
  resource,
68
69
  onApplied,
69
70
  }: EditResourceYamlDialogProps) {
70
- const dialogRef = useRef<HTMLDialogElement>(null);
71
71
  const edit = useEditResourceYaml({ resource });
72
72
  const { reset } = edit;
73
73
 
74
- // Sync dialog open state with the native <dialog> element.
75
- useEffect(() => {
76
- const dialog = dialogRef.current;
77
- if (!dialog) return;
78
- if (open && !dialog.open) {
79
- dialog.showModal();
80
- } else if (!open && dialog.open) {
81
- dialog.close();
82
- }
83
- }, [open]);
84
-
85
74
  // Discard stale edits from a previous session when the dialog opens.
86
75
  useEffect(() => {
87
76
  if (open) reset();
88
77
  }, [open, reset]);
89
78
 
90
- const handleCancel = useCallback(
91
- (e: React.SyntheticEvent) => {
92
- e.preventDefault();
93
- onOpenChange(false);
94
- },
95
- [onOpenChange],
96
- );
97
-
98
79
  const handleApply = useCallback(async () => {
99
80
  try {
100
81
  const result = await edit.apply();
@@ -111,15 +92,7 @@ export function EditResourceYamlDialog({
111
92
  edit.validation.status === "valid" && edit.isDirty && !edit.isApplying;
112
93
 
113
94
  return (
114
- <dialog
115
- ref={dialogRef}
116
- onCancel={handleCancel}
117
- className={cn(
118
- "stg:fixed stg:inset-0 stg:z-50 stg:m-auto stg:w-full stg:max-w-3xl stg:rounded-lg stg:border stg:border-border stg:bg-popover stg:p-0 stg:text-popover-foreground stg:shadow-lg",
119
- "stg:backdrop:bg-backdrop",
120
- "stg:open:animate-in stg:open:fade-in-0 stg:open:zoom-in-95",
121
- )}
122
- >
95
+ <DialogShell open={open} onOpenChange={onOpenChange} width="3xl" aria-label="Edit YAML">
123
96
  <div className="stg:flex stg:flex-col stg:gap-4 stg:p-6">
124
97
  {/* Header */}
125
98
  <div className="stg:flex stg:flex-col stg:gap-1">
@@ -233,7 +206,7 @@ export function EditResourceYamlDialog({
233
206
  </div>
234
207
  </div>
235
208
  </div>
236
- </dialog>
209
+ </DialogShell>
237
210
  );
238
211
  }
239
212
 
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
+ import { DialogShell } from "../internal/DialogShell.js";
5
6
  import type { EnvVarInput } from "@stigmer/sdk";
6
7
  import { useMcpServer } from "./useMcpServer.js";
7
8
  import { useMcpServerCredentials } from "./useMcpServerCredentials.js";
@@ -88,29 +89,11 @@ export function McpServerConnectDialog({
88
89
  onOpenDetails,
89
90
  className,
90
91
  }: McpServerConnectDialogProps) {
91
- const dialogRef = useRef<HTMLDialogElement>(null);
92
92
  const resolvedOrg = activeOrg || org;
93
93
 
94
- useEffect(() => {
95
- const dialog = dialogRef.current;
96
- if (!dialog) return;
97
-
98
- if (open && !dialog.open) {
99
- dialog.showModal();
100
- } else if (!open && dialog.open) {
101
- dialog.close();
102
- }
103
- }, [open]);
104
-
105
- const handleDialogClose = useCallback(() => {
106
- onClose();
107
- }, [onClose]);
108
-
109
- const handleBackdropClick = useCallback(
110
- (e: React.MouseEvent<HTMLDialogElement>) => {
111
- if (e.target === dialogRef.current) {
112
- onClose();
113
- }
94
+ const handleOpenChange = useCallback(
95
+ (nextOpen: boolean) => {
96
+ if (!nextOpen) onClose();
114
97
  },
115
98
  [onClose],
116
99
  );
@@ -118,15 +101,18 @@ export function McpServerConnectDialog({
118
101
  if (!open) return null;
119
102
 
120
103
  return (
121
- <dialog
122
- ref={dialogRef}
123
- onClose={handleDialogClose}
124
- onClick={handleBackdropClick}
104
+ <DialogShell
105
+ open
106
+ onOpenChange={handleOpenChange}
107
+ width="md"
108
+ dismissOnBackdrop
109
+ // Card surface + visible overflow are deliberate: the connect form
110
+ // hosts popovers that extend past the dialog bounds.
125
111
  className={cn(
126
- "stg:m-auto stg:max-h-[85vh] stg:w-full stg:max-w-md stg:overflow-visible stg:rounded-lg stg:border stg:border-border stg:bg-card stg:p-0 stg:text-foreground stg:shadow-lg",
127
- "stg:backdrop:bg-backdrop",
112
+ "stg:max-h-[85vh] stg:overflow-visible stg:bg-card stg:text-foreground",
128
113
  className,
129
114
  )}
115
+ aria-label={`Connect ${slug}`}
130
116
  >
131
117
  <div
132
118
  className="stg:flex stg:max-h-[85vh] stg:flex-col stg:overflow-y-auto stg:p-6"
@@ -141,7 +127,7 @@ export function McpServerConnectDialog({
141
127
  onOpenDetails={onOpenDetails}
142
128
  />
143
129
  </div>
144
- </dialog>
130
+ </DialogShell>
145
131
  );
146
132
  }
147
133
 
@@ -36,6 +36,7 @@ import { OAuthAppForm } from "./OAuthAppForm.js";
36
36
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
37
37
  import { ErrorMessage } from "../error/ErrorMessage.js";
38
38
  import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js";
39
+ import { DialogShell } from "../internal/DialogShell.js";
39
40
  import { EnvVarForm } from "../environment/EnvVarForm.js";
40
41
  import type { EnvVarFormVariable } from "../environment/EnvVarForm.js";
41
42
  import { VisibilityBadge } from "../library/VisibilitySelector.js";
@@ -282,7 +283,6 @@ export function McpServerDetailView({
282
283
  const [showCredentialForm, setShowCredentialForm] = useState(defaultShowCredentialForm);
283
284
  const [showByoaForm, setShowByoaForm] = useState(false);
284
285
  const [capabilityTab, setCapabilityTab] = useState<CapabilityTab>(defaultCapabilityTab);
285
- const byoaDialogRef = useRef<HTMLDialogElement>(null);
286
286
 
287
287
  const onResourceLoadRef = useRef(onResourceLoad);
288
288
  onResourceLoadRef.current = onResourceLoad;
@@ -388,20 +388,9 @@ export function McpServerDetailView({
388
388
  }
389
389
  }, [mcpServer, disconnectOAuth, credentials, refetch, activeOrg, org]);
390
390
 
391
- // BYOA dialog lifecycle — native <dialog> toggle
392
- useEffect(() => {
393
- const dialog = byoaDialogRef.current;
394
- if (!dialog) return;
395
- if (showByoaForm && !dialog.open) {
396
- dialog.showModal();
397
- } else if (!showByoaForm && dialog.open) {
398
- dialog.close();
399
- }
400
- }, [showByoaForm]);
401
-
402
- const handleByoaDialogCancel = useCallback(
403
- (e: React.SyntheticEvent) => {
404
- e.preventDefault();
391
+ const handleByoaOpenChange = useCallback(
392
+ (nextOpen: boolean) => {
393
+ if (nextOpen) return;
405
394
  setShowByoaForm(false);
406
395
  orgOAuthApp.clearErrors();
407
396
  },
@@ -706,14 +695,13 @@ export function McpServerDetailView({
706
695
  )}
707
696
  </Section>
708
697
 
709
- {/* BYOA dialog — native <dialog> for zero-dependency modal behavior */}
710
- <dialog
711
- ref={byoaDialogRef}
712
- onCancel={handleByoaDialogCancel}
713
- className={cn(
714
- "stg:m-auto stg:w-full stg:max-w-md stg:rounded-lg stg:border stg:border-border stg:bg-background stg:p-6 stg:shadow-lg",
715
- "stg:backdrop:bg-backdrop",
716
- )}
698
+ {/* BYOA dialog */}
699
+ <DialogShell
700
+ open={showByoaForm}
701
+ onOpenChange={handleByoaOpenChange}
702
+ width="md"
703
+ className="stg:p-6"
704
+ aria-label="Use your own OAuth app"
717
705
  >
718
706
  <h3 className="stg:mb-4 stg:text-base stg:font-semibold stg:text-foreground">
719
707
  Use your own OAuth app
@@ -729,7 +717,7 @@ export function McpServerDetailView({
729
717
  isSubmitting={orgOAuthApp.isSetting}
730
718
  error={orgOAuthApp.setError}
731
719
  />
732
- </dialog>
720
+ </DialogShell>
733
721
 
734
722
  <Section title="Capabilities" scrollTarget="mcp-capabilities">
735
723
  <Tabs