@stigmer/react 3.0.5 → 3.0.7-dev.20260611143057

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 (137) hide show
  1. package/agent/AgentDetailView.d.ts.map +1 -1
  2. package/agent/AgentDetailView.js +1 -1
  3. package/agent/AgentDetailView.js.map +1 -1
  4. package/agent-instance/AgentInstanceDetailPanel.d.ts.map +1 -1
  5. package/agent-instance/AgentInstanceDetailPanel.js +2 -13
  6. package/agent-instance/AgentInstanceDetailPanel.js.map +1 -1
  7. package/agent-instance/AgentInstanceList.d.ts.map +1 -1
  8. package/agent-instance/AgentInstanceList.js +2 -13
  9. package/agent-instance/AgentInstanceList.js.map +1 -1
  10. package/agent-instance/CreateAgentInstanceDialog.d.ts.map +1 -1
  11. package/agent-instance/CreateAgentInstanceDialog.js +1 -1
  12. package/agent-instance/CreateAgentInstanceDialog.js.map +1 -1
  13. package/composer/SessionComposer.d.ts +14 -0
  14. package/composer/SessionComposer.d.ts.map +1 -1
  15. package/composer/SessionComposer.js +15 -9
  16. package/composer/SessionComposer.js.map +1 -1
  17. package/index.d.ts +3 -3
  18. package/index.d.ts.map +1 -1
  19. package/index.js +1 -1
  20. package/index.js.map +1 -1
  21. package/library/InstanceVisibilitySelector.d.ts +30 -23
  22. package/library/InstanceVisibilitySelector.d.ts.map +1 -1
  23. package/library/InstanceVisibilitySelector.js +22 -145
  24. package/library/InstanceVisibilitySelector.js.map +1 -1
  25. package/library/ResourceVisibilityControl.d.ts +23 -6
  26. package/library/ResourceVisibilityControl.d.ts.map +1 -1
  27. package/library/ResourceVisibilityControl.js +38 -9
  28. package/library/ResourceVisibilityControl.js.map +1 -1
  29. package/library/ScopeToggle.d.ts +1 -1
  30. package/library/ScopeToggle.js +1 -1
  31. package/library/VisibilityOptionRow.d.ts +52 -0
  32. package/library/VisibilityOptionRow.d.ts.map +1 -0
  33. package/library/VisibilityOptionRow.js +92 -0
  34. package/library/VisibilityOptionRow.js.map +1 -0
  35. package/library/VisibilitySelector.d.ts +98 -0
  36. package/library/VisibilitySelector.d.ts.map +1 -0
  37. package/library/VisibilitySelector.js +193 -0
  38. package/library/VisibilitySelector.js.map +1 -0
  39. package/library/index.d.ts +4 -2
  40. package/library/index.d.ts.map +1 -1
  41. package/library/index.js +2 -1
  42. package/library/index.js.map +1 -1
  43. package/library/useUpdateVisibility.d.ts +5 -4
  44. package/library/useUpdateVisibility.d.ts.map +1 -1
  45. package/library/useUpdateVisibility.js +5 -4
  46. package/library/useUpdateVisibility.js.map +1 -1
  47. package/library/visibilityLevels.d.ts +96 -0
  48. package/library/visibilityLevels.d.ts.map +1 -0
  49. package/library/visibilityLevels.js +97 -0
  50. package/library/visibilityLevels.js.map +1 -0
  51. package/mcp-server/McpServerDetailView.d.ts +1 -11
  52. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  53. package/mcp-server/McpServerDetailView.js +3 -6
  54. package/mcp-server/McpServerDetailView.js.map +1 -1
  55. package/package.json +4 -4
  56. package/resource-detail/types.d.ts +1 -1
  57. package/session/NewSessionViewer.d.ts +32 -1
  58. package/session/NewSessionViewer.d.ts.map +1 -1
  59. package/session/NewSessionViewer.js +20 -9
  60. package/session/NewSessionViewer.js.map +1 -1
  61. package/session/SessionViewer.d.ts +24 -1
  62. package/session/SessionViewer.d.ts.map +1 -1
  63. package/session/SessionViewer.js +18 -12
  64. package/session/SessionViewer.js.map +1 -1
  65. package/session/audience.d.ts +21 -0
  66. package/session/audience.d.ts.map +1 -0
  67. package/session/audience.js +2 -0
  68. package/session/audience.js.map +1 -0
  69. package/session/index.d.ts +2 -0
  70. package/session/index.d.ts.map +1 -1
  71. package/session/index.js.map +1 -1
  72. package/session/runtime-env.d.ts +47 -0
  73. package/session/runtime-env.d.ts.map +1 -0
  74. package/session/runtime-env.js +20 -0
  75. package/session/runtime-env.js.map +1 -0
  76. package/session/useNewSessionFlow.d.ts +25 -0
  77. package/session/useNewSessionFlow.d.ts.map +1 -1
  78. package/session/useNewSessionFlow.js +20 -8
  79. package/session/useNewSessionFlow.js.map +1 -1
  80. package/session/useSessionPageFlow.d.ts +27 -2
  81. package/session/useSessionPageFlow.d.ts.map +1 -1
  82. package/session/useSessionPageFlow.js +34 -13
  83. package/session/useSessionPageFlow.js.map +1 -1
  84. package/skill/SkillDetailView.d.ts.map +1 -1
  85. package/skill/SkillDetailView.js +1 -1
  86. package/skill/SkillDetailView.js.map +1 -1
  87. package/src/agent/AgentDetailView.tsx +1 -0
  88. package/src/agent-instance/AgentInstanceDetailPanel.tsx +7 -32
  89. package/src/agent-instance/AgentInstanceList.tsx +7 -32
  90. package/src/agent-instance/CreateAgentInstanceDialog.tsx +1 -0
  91. package/src/composer/SessionComposer.tsx +30 -8
  92. package/src/composer/__tests__/SessionComposer-lockAgent.test.tsx +150 -0
  93. package/src/index.ts +10 -2
  94. package/src/library/InstanceVisibilitySelector.tsx +44 -283
  95. package/src/library/ResourceVisibilityControl.tsx +54 -8
  96. package/src/library/ScopeToggle.tsx +1 -1
  97. package/src/library/VisibilityOptionRow.tsx +244 -0
  98. package/src/library/VisibilitySelector.tsx +436 -0
  99. package/src/library/__tests__/VisibilitySelector.test.tsx +256 -0
  100. package/src/library/index.ts +13 -2
  101. package/src/library/useUpdateVisibility.ts +5 -4
  102. package/src/library/visibilityLevels.ts +174 -0
  103. package/src/mcp-server/McpServerDetailView.tsx +10 -35
  104. package/src/resource-detail/types.ts +1 -1
  105. package/src/session/NewSessionViewer.tsx +61 -12
  106. package/src/session/SessionViewer.tsx +51 -15
  107. package/src/session/__tests__/audienceWiring.test.tsx +274 -0
  108. package/src/session/__tests__/useNewSessionFlow.test.tsx +122 -0
  109. package/src/session/__tests__/useSessionPageFlow.runtimeEnv.test.tsx +170 -0
  110. package/src/session/audience.ts +20 -0
  111. package/src/session/index.ts +3 -0
  112. package/src/session/runtime-env.ts +57 -0
  113. package/src/session/useNewSessionFlow.ts +44 -9
  114. package/src/session/useSessionPageFlow.ts +65 -17
  115. package/src/skill/SkillDetailView.tsx +1 -0
  116. package/src/workflow/WorkflowDetailView.tsx +1 -0
  117. package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +1 -0
  118. package/src/workflow/instance/WorkflowInstanceDetailPanel.tsx +7 -32
  119. package/src/workflow/instance/WorkflowInstanceList.tsx +7 -32
  120. package/styles.css +1 -1
  121. package/workflow/WorkflowDetailView.d.ts.map +1 -1
  122. package/workflow/WorkflowDetailView.js +1 -1
  123. package/workflow/WorkflowDetailView.js.map +1 -1
  124. package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
  125. package/workflow/instance/CreateWorkflowInstanceDialog.js +1 -1
  126. package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
  127. package/workflow/instance/WorkflowInstanceDetailPanel.d.ts.map +1 -1
  128. package/workflow/instance/WorkflowInstanceDetailPanel.js +2 -13
  129. package/workflow/instance/WorkflowInstanceDetailPanel.js.map +1 -1
  130. package/workflow/instance/WorkflowInstanceList.d.ts.map +1 -1
  131. package/workflow/instance/WorkflowInstanceList.js +2 -13
  132. package/workflow/instance/WorkflowInstanceList.js.map +1 -1
  133. package/library/VisibilityToggle.d.ts +0 -53
  134. package/library/VisibilityToggle.d.ts.map +0 -1
  135. package/library/VisibilityToggle.js +0 -100
  136. package/library/VisibilityToggle.js.map +0 -1
  137. package/src/library/VisibilityToggle.tsx +0 -280
@@ -0,0 +1,244 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import type { VisibilityLevelOption } from "./visibilityLevels";
6
+
7
+ /** Color treatment key shared by every visibility surface. */
8
+ export type VisibilityTone = VisibilityLevelOption["tone"];
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Chip — the read-only badge and the editable trigger share this base so the
12
+ // two states are visually identical apart from the trigger's caret affordance.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Base classes for the visibility chip (badge + selector trigger). */
16
+ export const VISIBILITY_CHIP_CLASS =
17
+ "inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Option row — one selectable level inside the popover (role="option") or the
21
+ // create-mode inline list (role="radio"). Pure presentation; the parent owns
22
+ // selection, focus management, and confirmation.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Props for {@link VisibilityOptionRow}. */
26
+ export interface VisibilityOptionRowProps {
27
+ /** The level this row represents. */
28
+ readonly option: VisibilityLevelOption;
29
+ /** Whether this row is the resource's current visibility. */
30
+ readonly isSelected: boolean;
31
+ /**
32
+ * ARIA role for the row. `"option"` inside the popover listbox,
33
+ * `"radio"` inside the create-mode radiogroup — the matching selection
34
+ * attribute (`aria-selected` / `aria-checked`) is applied automatically.
35
+ */
36
+ readonly role: "option" | "radio";
37
+ /** Visually highlighted by keyboard/pointer navigation (popover only). */
38
+ readonly isHighlighted?: boolean;
39
+ /** Disables interaction (e.g. while a change is in flight). */
40
+ readonly disabled?: boolean;
41
+ /** Roving-tabindex value; the focused row is `0`, the rest `-1`. */
42
+ readonly tabIndex?: number;
43
+ /** Fires when the row is activated (click / Enter / Space). */
44
+ readonly onSelect: () => void;
45
+ /** Pointer hover, used to sync the keyboard highlight. */
46
+ readonly onMouseEnter?: () => void;
47
+ /** Keydown handler owned by the parent for roving focus. */
48
+ readonly onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
49
+ }
50
+
51
+ /**
52
+ * A single visibility level rendered as a left-aligned row: tone icon,
53
+ * label, one-line description, and a check when current. Shared by the
54
+ * {@link VisibilitySelector} popover and its create-mode inline list so
55
+ * option presentation has exactly one source.
56
+ */
57
+ export const VisibilityOptionRow = forwardRef<
58
+ HTMLButtonElement,
59
+ VisibilityOptionRowProps
60
+ >(function VisibilityOptionRow(
61
+ {
62
+ option,
63
+ isSelected,
64
+ role,
65
+ isHighlighted,
66
+ disabled,
67
+ tabIndex,
68
+ onSelect,
69
+ onMouseEnter,
70
+ onKeyDown,
71
+ },
72
+ ref,
73
+ ) {
74
+ const selectionAttr =
75
+ role === "radio"
76
+ ? { "aria-checked": isSelected }
77
+ : { "aria-selected": isSelected };
78
+
79
+ return (
80
+ <button
81
+ ref={ref}
82
+ type="button"
83
+ role={role}
84
+ {...selectionAttr}
85
+ tabIndex={tabIndex}
86
+ disabled={disabled}
87
+ onClick={onSelect}
88
+ onMouseEnter={onMouseEnter}
89
+ onKeyDown={onKeyDown}
90
+ className={cn(
91
+ "flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left transition-colors",
92
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
93
+ "disabled:pointer-events-none disabled:opacity-50",
94
+ isHighlighted ? "bg-accent text-foreground" : "hover:bg-accent-hover",
95
+ )}
96
+ >
97
+ <VisibilityIcon
98
+ tone={option.tone}
99
+ className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
100
+ />
101
+ <span className="min-w-0 flex-1">
102
+ <span className="block text-xs font-medium text-foreground">
103
+ {option.label}
104
+ </span>
105
+ <span className="block text-[0.65rem] leading-snug text-muted-foreground">
106
+ {option.description}
107
+ </span>
108
+ </span>
109
+ {isSelected && <CheckIcon className="mt-0.5 size-3.5 shrink-0 text-primary" />}
110
+ </button>
111
+ );
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Tone styling — one row per visibility tone, keyed by VisibilityLevelOption
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Selected-segment / active-tone treatment (also used by the inline confirm). */
119
+ export const SELECTED_STYLES: Record<VisibilityTone, string> = {
120
+ private:
121
+ "bg-amber-50 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
122
+ org: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
123
+ platform:
124
+ "bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300",
125
+ public:
126
+ "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
127
+ };
128
+
129
+ /** Inline-confirm (escalation prompt) treatment, keyed by tone. */
130
+ export const PROMPT_STYLES: Record<
131
+ VisibilityTone,
132
+ { container: string; text: string; confirm: string; cancel: string }
133
+ > = {
134
+ // Private never escalates, but the row keeps the Record total.
135
+ private: {
136
+ container:
137
+ "border-amber-200 bg-amber-50 dark:border-amber-800/50 dark:bg-amber-950/30",
138
+ text: "text-amber-800 dark:text-amber-200",
139
+ confirm:
140
+ "bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-500",
141
+ cancel:
142
+ "text-amber-700 hover:text-amber-900 dark:text-amber-300 dark:hover:text-amber-100",
143
+ },
144
+ org: {
145
+ container:
146
+ "border-blue-200 bg-blue-50 dark:border-blue-800/50 dark:bg-blue-950/30",
147
+ text: "text-blue-800 dark:text-blue-200",
148
+ confirm:
149
+ "bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500",
150
+ cancel:
151
+ "text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-100",
152
+ },
153
+ platform: {
154
+ container:
155
+ "border-violet-200 bg-violet-50 dark:border-violet-800/50 dark:bg-violet-950/30",
156
+ text: "text-violet-800 dark:text-violet-200",
157
+ confirm:
158
+ "bg-violet-600 hover:bg-violet-700 dark:bg-violet-600 dark:hover:bg-violet-500",
159
+ cancel:
160
+ "text-violet-700 hover:text-violet-900 dark:text-violet-300 dark:hover:text-violet-100",
161
+ },
162
+ public: {
163
+ container:
164
+ "border-emerald-200 bg-emerald-50 dark:border-emerald-800/50 dark:bg-emerald-950/30",
165
+ text: "text-emerald-800 dark:text-emerald-200",
166
+ confirm:
167
+ "bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-500",
168
+ cancel:
169
+ "text-emerald-700 hover:text-emerald-900 dark:text-emerald-300 dark:hover:text-emerald-100",
170
+ },
171
+ };
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Icons — inline SVGs following the SDK pattern (no icon library dependency)
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /** Icon for a visibility tone; shared by the row, badge, and trigger. */
178
+ export function VisibilityIcon({
179
+ tone,
180
+ className,
181
+ }: {
182
+ readonly tone: VisibilityTone;
183
+ readonly className?: string;
184
+ }) {
185
+ switch (tone) {
186
+ case "org":
187
+ return <UsersIcon className={className} />;
188
+ case "platform":
189
+ return <BuildingsIcon className={className} />;
190
+ case "public":
191
+ return <GlobeIcon className={className} />;
192
+ default:
193
+ return <LockIcon className={className} />;
194
+ }
195
+ }
196
+
197
+ function LockIcon({ className }: { readonly className?: string }) {
198
+ return (
199
+ <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
200
+ <rect x="3.5" y="7" width="9" height="7" rx="1.5" />
201
+ <path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" />
202
+ </svg>
203
+ );
204
+ }
205
+
206
+ function UsersIcon({ className }: { readonly className?: string }) {
207
+ return (
208
+ <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
209
+ <circle cx="6" cy="5" r="2.5" />
210
+ <path d="M2 13c0-2.21 1.79-4 4-4s4 1.79 4 4" />
211
+ <circle cx="11.5" cy="5.5" r="2" />
212
+ <path d="M14 13c0-1.66-1.12-3-2.5-3-.5 0-1 .14-1.4.4" />
213
+ </svg>
214
+ );
215
+ }
216
+
217
+ function BuildingsIcon({ className }: { readonly className?: string }) {
218
+ return (
219
+ <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
220
+ <path d="M2 14V4.5L6.5 2v12" />
221
+ <path d="M6.5 6.5 14 8.5V14" />
222
+ <path d="M2 14h12" />
223
+ <path d="M4.25 6h.01M4.25 8.5h.01M4.25 11h.01M10.5 10.5h.01M10.5 12.5h.01" />
224
+ </svg>
225
+ );
226
+ }
227
+
228
+ function GlobeIcon({ className }: { readonly className?: string }) {
229
+ return (
230
+ <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
231
+ <circle cx="8" cy="8" r="6" />
232
+ <path d="M2 8h12" />
233
+ <path d="M8 2c1.66 1.46 2.6 3.63 2.6 6s-.94 4.54-2.6 6c-1.66-1.46-2.6-3.63-2.6-6s.94-4.54 2.6-6Z" />
234
+ </svg>
235
+ );
236
+ }
237
+
238
+ function CheckIcon({ className }: { readonly className?: string }) {
239
+ return (
240
+ <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
241
+ <path d="M3 8.5 6.5 12 13 4.5" />
242
+ </svg>
243
+ );
244
+ }
@@ -0,0 +1,436 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Popover } from "@base-ui/react/popover";
5
+ import { cn } from "@stigmer/theme";
6
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
7
+ import { useStigmerPortalContainer } from "../portal-container";
8
+ import { ConfirmDialog } from "../resource-detail/ConfirmDialog";
9
+ import { useConfirmAction } from "../resource-detail/useConfirmAction";
10
+ import {
11
+ PROMPT_STYLES,
12
+ VISIBILITY_CHIP_CLASS,
13
+ VisibilityIcon,
14
+ VisibilityOptionRow,
15
+ } from "./VisibilityOptionRow";
16
+ import {
17
+ visibilityOption,
18
+ type VisibilityLevelOption,
19
+ } from "./visibilityLevels";
20
+
21
+ /** How the selector presents itself and how it confirms escalations. */
22
+ export type VisibilitySelectorMode = "manage" | "create";
23
+
24
+ /** Props for {@link VisibilitySelector}. */
25
+ export interface VisibilitySelectorProps {
26
+ /** Current visibility of the resource. */
27
+ readonly visibility: ApiResourceVisibility;
28
+ /**
29
+ * Levels to offer, in escalation order (least to most exposed). Selecting
30
+ * a level later in the list than the current one is an escalation and is
31
+ * confirmed by severity (see {@link mode}); de-escalation applies
32
+ * immediately (revoking access is always safe).
33
+ */
34
+ readonly options: readonly VisibilityLevelOption[];
35
+ /** Called when the user selects (and, for escalations, confirms) a level. */
36
+ readonly onVisibilityChange: (v: ApiResourceVisibility) => void;
37
+ /**
38
+ * Presentation + confirmation mode.
39
+ *
40
+ * - `"manage"` (default) — a compact current-state chip opens a popover
41
+ * ladder of levels. Escalations are confirmed by severity: a light
42
+ * inline prompt for levels carrying {@link VisibilityLevelOption.confirmPrompt}
43
+ * (e.g. Organization), and a blocking modal for levels carrying
44
+ * {@link VisibilityLevelOption.confirmDialog} (Platform, Public). This is
45
+ * the live-resource case.
46
+ * - `"create"` — an inline radio list that applies immediately, with no
47
+ * escalation confirmation. Used to pick an initial value while creating
48
+ * a resource (typically inside a native `<dialog>`, where a portaled
49
+ * popover would render beneath the modal's top layer).
50
+ *
51
+ * @default "manage"
52
+ */
53
+ readonly mode?: VisibilitySelectorMode;
54
+ /** Shows a spinner/disabled state while the RPC is in flight. */
55
+ readonly isPending?: boolean;
56
+ /** Disables all interaction (e.g., when the user lacks can_edit). */
57
+ readonly disabled?: boolean;
58
+ /** Accessible name for the control. Defaults to "Resource visibility". */
59
+ readonly ariaLabel?: string;
60
+ /** Additional CSS classes applied to the root element. */
61
+ readonly className?: string;
62
+ }
63
+
64
+ /**
65
+ * The single control for resource visibility across blueprints AND
66
+ * instances. Offered levels are pure data ({@link VisibilityLevelOption});
67
+ * per-kind level sets live in `visibilityLevels.ts`, so this component
68
+ * carries no kind-specific logic.
69
+ *
70
+ * In `"manage"` mode it renders a current-state chip (icon + label + caret)
71
+ * that opens a popover listing each offered level with its own description —
72
+ * scaling cleanly to four levels without the layout shift of a segmented
73
+ * control, and explaining every choice at a glance (Recognition over
74
+ * Recall). Escalation is confirmed in proportion to how far access expands:
75
+ * de-escalation applies instantly, an Organization escalation shows a light
76
+ * inline prompt, and Platform/Public escalations open a blocking
77
+ * {@link ConfirmDialog} that names the exact audience. Confirmation is owned
78
+ * here so every consumer — blueprint detail, instance detail, and any
79
+ * standalone embed — behaves identically.
80
+ *
81
+ * In `"create"` mode it renders an inline radio list that applies
82
+ * immediately (initial value selection has no escalation semantics).
83
+ *
84
+ * If the current visibility is not among the offered options (e.g. a
85
+ * platform-shared blueprint whose org no longer operates an
86
+ * IdentityProvider), its canonical option is rendered in place so the
87
+ * state stays legible and the user can still move to an offered level.
88
+ *
89
+ * All visual properties flow through `--stgm-*` design tokens; portaled
90
+ * content targets the {@link useStigmerPortalContainer} so it inherits the
91
+ * active theme.
92
+ *
93
+ * @example
94
+ * ```tsx
95
+ * <VisibilitySelector
96
+ * visibility={agent.metadata.visibility}
97
+ * options={blueprintVisibilityLevels({ deploymentMode, hasIdentityProvider })}
98
+ * onVisibilityChange={updateVisibility}
99
+ * isPending={isPending}
100
+ * />
101
+ * ```
102
+ */
103
+ export function VisibilitySelector({
104
+ visibility,
105
+ options,
106
+ onVisibilityChange,
107
+ mode = "manage",
108
+ isPending = false,
109
+ disabled = false,
110
+ ariaLabel = "Resource visibility",
111
+ className,
112
+ }: VisibilitySelectorProps) {
113
+ const portalContainer = useStigmerPortalContainer();
114
+ const { confirmState, confirm, handleConfirm, handleCancel } =
115
+ useConfirmAction();
116
+
117
+ const [open, setOpen] = useState(false);
118
+ // The Organization-style escalation awaiting its light inline confirm.
119
+ const [pendingInline, setPendingInline] =
120
+ useState<ApiResourceVisibility | null>(null);
121
+ const [highlightIdx, setHighlightIdx] = useState(-1);
122
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
123
+
124
+ const effectivelyDisabled = disabled || isPending;
125
+
126
+ // Keep the current state legible even when it is not offerable in the
127
+ // current context: render its canonical option as an extra row.
128
+ const effectiveOptions = useMemo(() => {
129
+ if (options.some((o) => o.value === visibility)) return options;
130
+ return [...options, visibilityOption(visibility)];
131
+ }, [options, visibility]);
132
+
133
+ const isEscalation = useCallback(
134
+ (target: ApiResourceVisibility) => {
135
+ const values = effectiveOptions.map((o) => o.value);
136
+ return values.indexOf(target) > values.indexOf(visibility);
137
+ },
138
+ [effectiveOptions, visibility],
139
+ );
140
+
141
+ const apply = useCallback(
142
+ (value: ApiResourceVisibility) => {
143
+ setOpen(false);
144
+ setPendingInline(null);
145
+ onVisibilityChange(value);
146
+ },
147
+ [onVisibilityChange],
148
+ );
149
+
150
+ const handleSelect = useCallback(
151
+ (option: VisibilityLevelOption) => {
152
+ const value = option.value;
153
+ if (value === visibility) {
154
+ setPendingInline(null);
155
+ setOpen(false);
156
+ return;
157
+ }
158
+
159
+ // De-escalation (and create mode) never confirm — narrowing access is
160
+ // always safe, and an initial pick has no escalation semantics.
161
+ if (mode === "create" || !isEscalation(value)) {
162
+ apply(value);
163
+ return;
164
+ }
165
+
166
+ if (option.confirmDialog) {
167
+ setOpen(false);
168
+ setPendingInline(null);
169
+ void confirm({
170
+ title: option.confirmDialog.title,
171
+ description: option.confirmDialog.description,
172
+ confirmLabel: `Make ${option.label}`,
173
+ // Exposure is reversible, so this is a primary (not destructive)
174
+ // confirmation; the audience-naming copy carries the caution.
175
+ variant: "default",
176
+ }).then((ok) => {
177
+ if (ok) onVisibilityChange(value);
178
+ });
179
+ return;
180
+ }
181
+
182
+ if (option.confirmPrompt) {
183
+ setPendingInline(value);
184
+ return;
185
+ }
186
+
187
+ apply(value);
188
+ },
189
+ [visibility, mode, isEscalation, apply, confirm, onVisibilityChange],
190
+ );
191
+
192
+ const moveFocus = useCallback(
193
+ (from: number, delta: number) => {
194
+ const count = effectiveOptions.length;
195
+ const next = (from + delta + count) % count;
196
+ setHighlightIdx(next);
197
+ optionRefs.current[next]?.focus();
198
+ },
199
+ [effectiveOptions.length],
200
+ );
201
+
202
+ const handleRowKeyDown = useCallback(
203
+ (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
204
+ switch (e.key) {
205
+ case "ArrowDown":
206
+ case "ArrowRight":
207
+ e.preventDefault();
208
+ moveFocus(index, 1);
209
+ break;
210
+ case "ArrowUp":
211
+ case "ArrowLeft":
212
+ e.preventDefault();
213
+ moveFocus(index, -1);
214
+ break;
215
+ case "Home":
216
+ e.preventDefault();
217
+ setHighlightIdx(0);
218
+ optionRefs.current[0]?.focus();
219
+ break;
220
+ case "End": {
221
+ e.preventDefault();
222
+ const last = effectiveOptions.length - 1;
223
+ setHighlightIdx(last);
224
+ optionRefs.current[last]?.focus();
225
+ break;
226
+ }
227
+ }
228
+ },
229
+ [moveFocus, effectiveOptions.length],
230
+ );
231
+
232
+ // On open, focus the current level so keyboard users land on a sensible row.
233
+ useEffect(() => {
234
+ if (!open) {
235
+ setPendingInline(null);
236
+ setHighlightIdx(-1);
237
+ return;
238
+ }
239
+ const current = effectiveOptions.findIndex((o) => o.value === visibility);
240
+ const start = current >= 0 ? current : 0;
241
+ setHighlightIdx(start);
242
+ const raf = requestAnimationFrame(() => optionRefs.current[start]?.focus());
243
+ return () => cancelAnimationFrame(raf);
244
+ }, [open, effectiveOptions, visibility]);
245
+
246
+ const dialog = (
247
+ <ConfirmDialog
248
+ state={confirmState}
249
+ onConfirm={handleConfirm}
250
+ onCancel={handleCancel}
251
+ />
252
+ );
253
+
254
+ const renderRows = (role: "option" | "radio") =>
255
+ effectiveOptions.map((option, index) => (
256
+ <VisibilityOptionRow
257
+ key={option.value}
258
+ ref={(el) => {
259
+ optionRefs.current[index] = el;
260
+ }}
261
+ option={option}
262
+ role={role}
263
+ isSelected={visibility === option.value}
264
+ isHighlighted={role === "option" && highlightIdx === index}
265
+ tabIndex={
266
+ role === "radio"
267
+ ? visibility === option.value
268
+ ? 0
269
+ : -1
270
+ : highlightIdx === index
271
+ ? 0
272
+ : -1
273
+ }
274
+ disabled={effectivelyDisabled}
275
+ onSelect={() => handleSelect(option)}
276
+ onMouseEnter={
277
+ role === "option" ? () => setHighlightIdx(index) : undefined
278
+ }
279
+ onKeyDown={(e) => handleRowKeyDown(e, index)}
280
+ />
281
+ ));
282
+
283
+ // ---- Create mode: inline radio list, applies immediately ---------------
284
+ if (mode === "create") {
285
+ return (
286
+ <div
287
+ role="radiogroup"
288
+ aria-label={ariaLabel}
289
+ aria-disabled={effectivelyDisabled || undefined}
290
+ className={cn(
291
+ "flex flex-col gap-0.5 rounded-md border border-border p-1",
292
+ effectivelyDisabled && "pointer-events-none opacity-50",
293
+ className,
294
+ )}
295
+ >
296
+ {renderRows("radio")}
297
+ </div>
298
+ );
299
+ }
300
+
301
+ // ---- Manage mode: current-state chip + popover ladder ------------------
302
+ const current = visibilityOption(visibility);
303
+ const pendingOption =
304
+ pendingInline !== null
305
+ ? effectiveOptions.find((o) => o.value === pendingInline)
306
+ : undefined;
307
+
308
+ return (
309
+ <>
310
+ <Popover.Root open={open} onOpenChange={setOpen}>
311
+ <Popover.Trigger
312
+ disabled={effectivelyDisabled}
313
+ aria-label={`${ariaLabel}: ${current.label}`}
314
+ className={cn(
315
+ VISIBILITY_CHIP_CLASS,
316
+ "transition-colors hover:bg-accent-hover hover:text-foreground",
317
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
318
+ "disabled:pointer-events-none disabled:opacity-50",
319
+ className,
320
+ )}
321
+ >
322
+ {isPending ? (
323
+ <span
324
+ className="inline-block size-2.5 animate-spin rounded-full border-2 border-current border-t-transparent"
325
+ aria-hidden="true"
326
+ />
327
+ ) : (
328
+ <VisibilityIcon tone={current.tone} className="size-2.5" />
329
+ )}
330
+ {current.label}
331
+ <CaretIcon />
332
+ </Popover.Trigger>
333
+
334
+ <Popover.Portal container={portalContainer}>
335
+ <Popover.Positioner sideOffset={4} align="start">
336
+ <Popover.Popup
337
+ role="listbox"
338
+ aria-label={ariaLabel}
339
+ className={cn(
340
+ "z-popover w-72 rounded-lg border border-border bg-popover p-1 shadow-md",
341
+ "text-popover-foreground animate-in fade-in-0 zoom-in-95",
342
+ )}
343
+ >
344
+ {renderRows("option")}
345
+
346
+ {pendingOption?.confirmPrompt && (
347
+ <div
348
+ role="alert"
349
+ className={cn(
350
+ "mt-1 flex items-center gap-2 rounded-md border px-2.5 py-1.5",
351
+ PROMPT_STYLES[pendingOption.tone].container,
352
+ )}
353
+ >
354
+ <span
355
+ className={cn(
356
+ "flex-1 text-[0.65rem] leading-snug",
357
+ PROMPT_STYLES[pendingOption.tone].text,
358
+ )}
359
+ >
360
+ {pendingOption.confirmPrompt}
361
+ </span>
362
+ <button
363
+ type="button"
364
+ onClick={() => setPendingInline(null)}
365
+ className={cn(
366
+ "rounded px-2 py-0.5 text-[0.65rem] font-medium",
367
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
368
+ PROMPT_STYLES[pendingOption.tone].cancel,
369
+ )}
370
+ >
371
+ Cancel
372
+ </button>
373
+ <button
374
+ type="button"
375
+ onClick={() => apply(pendingOption.value)}
376
+ className={cn(
377
+ "rounded px-2 py-0.5 text-[0.65rem] font-medium text-white",
378
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
379
+ PROMPT_STYLES[pendingOption.tone].confirm,
380
+ )}
381
+ >
382
+ Confirm
383
+ </button>
384
+ </div>
385
+ )}
386
+ </Popover.Popup>
387
+ </Popover.Positioner>
388
+ </Popover.Portal>
389
+ </Popover.Root>
390
+ {dialog}
391
+ </>
392
+ );
393
+ }
394
+
395
+ function CaretIcon() {
396
+ return (
397
+ <svg
398
+ className="size-2.5 shrink-0 text-muted-foreground"
399
+ viewBox="0 0 12 12"
400
+ fill="none"
401
+ stroke="currentColor"
402
+ strokeWidth="1.5"
403
+ strokeLinecap="round"
404
+ strokeLinejoin="round"
405
+ aria-hidden="true"
406
+ >
407
+ <path d="M3 4.5 6 7.5 9 4.5" />
408
+ </svg>
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Read-only visibility indicator with a matching icon, covering all four
414
+ * levels (Private / Organization / Platform / Public).
415
+ *
416
+ * Rendered wherever the interactive {@link VisibilitySelector} is not
417
+ * available — for viewers who lack `can_edit`, and while a permission check
418
+ * is in flight — so a resource's visibility is always legible rather than
419
+ * silently blank. Shares the chip styling with the selector trigger so the
420
+ * read-only and editable states are visually consistent.
421
+ */
422
+ export function VisibilityBadge({
423
+ visibility,
424
+ className,
425
+ }: {
426
+ readonly visibility: ApiResourceVisibility;
427
+ readonly className?: string;
428
+ }) {
429
+ const option = visibilityOption(visibility);
430
+ return (
431
+ <span className={cn(VISIBILITY_CHIP_CLASS, className)}>
432
+ <VisibilityIcon tone={option.tone} className="size-2.5" />
433
+ {option.label}
434
+ </span>
435
+ );
436
+ }