@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.
- package/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +1 -1
- package/agent/AgentDetailView.js.map +1 -1
- package/agent-instance/AgentInstanceDetailPanel.d.ts.map +1 -1
- package/agent-instance/AgentInstanceDetailPanel.js +2 -13
- package/agent-instance/AgentInstanceDetailPanel.js.map +1 -1
- package/agent-instance/AgentInstanceList.d.ts.map +1 -1
- package/agent-instance/AgentInstanceList.js +2 -13
- package/agent-instance/AgentInstanceList.js.map +1 -1
- package/agent-instance/CreateAgentInstanceDialog.d.ts.map +1 -1
- package/agent-instance/CreateAgentInstanceDialog.js +1 -1
- package/agent-instance/CreateAgentInstanceDialog.js.map +1 -1
- package/composer/SessionComposer.d.ts +14 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +15 -9
- package/composer/SessionComposer.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/library/InstanceVisibilitySelector.d.ts +30 -23
- package/library/InstanceVisibilitySelector.d.ts.map +1 -1
- package/library/InstanceVisibilitySelector.js +22 -145
- package/library/InstanceVisibilitySelector.js.map +1 -1
- package/library/ResourceVisibilityControl.d.ts +23 -6
- package/library/ResourceVisibilityControl.d.ts.map +1 -1
- package/library/ResourceVisibilityControl.js +38 -9
- package/library/ResourceVisibilityControl.js.map +1 -1
- package/library/ScopeToggle.d.ts +1 -1
- package/library/ScopeToggle.js +1 -1
- package/library/VisibilityOptionRow.d.ts +52 -0
- package/library/VisibilityOptionRow.d.ts.map +1 -0
- package/library/VisibilityOptionRow.js +92 -0
- package/library/VisibilityOptionRow.js.map +1 -0
- package/library/VisibilitySelector.d.ts +98 -0
- package/library/VisibilitySelector.d.ts.map +1 -0
- package/library/VisibilitySelector.js +193 -0
- package/library/VisibilitySelector.js.map +1 -0
- package/library/index.d.ts +4 -2
- package/library/index.d.ts.map +1 -1
- package/library/index.js +2 -1
- package/library/index.js.map +1 -1
- package/library/useUpdateVisibility.d.ts +5 -4
- package/library/useUpdateVisibility.d.ts.map +1 -1
- package/library/useUpdateVisibility.js +5 -4
- package/library/useUpdateVisibility.js.map +1 -1
- package/library/visibilityLevels.d.ts +96 -0
- package/library/visibilityLevels.d.ts.map +1 -0
- package/library/visibilityLevels.js +97 -0
- package/library/visibilityLevels.js.map +1 -0
- package/mcp-server/McpServerDetailView.d.ts +1 -11
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +3 -6
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/package.json +4 -4
- package/resource-detail/types.d.ts +1 -1
- package/session/NewSessionViewer.d.ts +32 -1
- package/session/NewSessionViewer.d.ts.map +1 -1
- package/session/NewSessionViewer.js +20 -9
- package/session/NewSessionViewer.js.map +1 -1
- package/session/SessionViewer.d.ts +24 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +18 -12
- package/session/SessionViewer.js.map +1 -1
- package/session/audience.d.ts +21 -0
- package/session/audience.d.ts.map +1 -0
- package/session/audience.js +2 -0
- package/session/audience.js.map +1 -0
- package/session/index.d.ts +2 -0
- package/session/index.d.ts.map +1 -1
- package/session/index.js.map +1 -1
- package/session/runtime-env.d.ts +47 -0
- package/session/runtime-env.d.ts.map +1 -0
- package/session/runtime-env.js +20 -0
- package/session/runtime-env.js.map +1 -0
- package/session/useNewSessionFlow.d.ts +25 -0
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +20 -8
- package/session/useNewSessionFlow.js.map +1 -1
- package/session/useSessionPageFlow.d.ts +27 -2
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +34 -13
- package/session/useSessionPageFlow.js.map +1 -1
- package/skill/SkillDetailView.d.ts.map +1 -1
- package/skill/SkillDetailView.js +1 -1
- package/skill/SkillDetailView.js.map +1 -1
- package/src/agent/AgentDetailView.tsx +1 -0
- package/src/agent-instance/AgentInstanceDetailPanel.tsx +7 -32
- package/src/agent-instance/AgentInstanceList.tsx +7 -32
- package/src/agent-instance/CreateAgentInstanceDialog.tsx +1 -0
- package/src/composer/SessionComposer.tsx +30 -8
- package/src/composer/__tests__/SessionComposer-lockAgent.test.tsx +150 -0
- package/src/index.ts +10 -2
- package/src/library/InstanceVisibilitySelector.tsx +44 -283
- package/src/library/ResourceVisibilityControl.tsx +54 -8
- package/src/library/ScopeToggle.tsx +1 -1
- package/src/library/VisibilityOptionRow.tsx +244 -0
- package/src/library/VisibilitySelector.tsx +436 -0
- package/src/library/__tests__/VisibilitySelector.test.tsx +256 -0
- package/src/library/index.ts +13 -2
- package/src/library/useUpdateVisibility.ts +5 -4
- package/src/library/visibilityLevels.ts +174 -0
- package/src/mcp-server/McpServerDetailView.tsx +10 -35
- package/src/resource-detail/types.ts +1 -1
- package/src/session/NewSessionViewer.tsx +61 -12
- package/src/session/SessionViewer.tsx +51 -15
- package/src/session/__tests__/audienceWiring.test.tsx +274 -0
- package/src/session/__tests__/useNewSessionFlow.test.tsx +122 -0
- package/src/session/__tests__/useSessionPageFlow.runtimeEnv.test.tsx +170 -0
- package/src/session/audience.ts +20 -0
- package/src/session/index.ts +3 -0
- package/src/session/runtime-env.ts +57 -0
- package/src/session/useNewSessionFlow.ts +44 -9
- package/src/session/useSessionPageFlow.ts +65 -17
- package/src/skill/SkillDetailView.tsx +1 -0
- package/src/workflow/WorkflowDetailView.tsx +1 -0
- package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +1 -0
- package/src/workflow/instance/WorkflowInstanceDetailPanel.tsx +7 -32
- package/src/workflow/instance/WorkflowInstanceList.tsx +7 -32
- package/styles.css +1 -1
- package/workflow/WorkflowDetailView.d.ts.map +1 -1
- package/workflow/WorkflowDetailView.js +1 -1
- package/workflow/WorkflowDetailView.js.map +1 -1
- package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
- package/workflow/instance/CreateWorkflowInstanceDialog.js +1 -1
- package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
- package/workflow/instance/WorkflowInstanceDetailPanel.d.ts.map +1 -1
- package/workflow/instance/WorkflowInstanceDetailPanel.js +2 -13
- package/workflow/instance/WorkflowInstanceDetailPanel.js.map +1 -1
- package/workflow/instance/WorkflowInstanceList.d.ts.map +1 -1
- package/workflow/instance/WorkflowInstanceList.js +2 -13
- package/workflow/instance/WorkflowInstanceList.js.map +1 -1
- package/library/VisibilityToggle.d.ts +0 -53
- package/library/VisibilityToggle.d.ts.map +0 -1
- package/library/VisibilityToggle.js +0 -100
- package/library/VisibilityToggle.js.map +0 -1
- 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
|
+
}
|