@stigmer/react 0.0.98 → 0.0.100

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 (112) hide show
  1. package/index.d.ts +17 -3
  2. package/index.d.ts.map +1 -1
  3. package/index.js +16 -3
  4. package/index.js.map +1 -1
  5. package/internal/menu.d.ts +17 -0
  6. package/internal/menu.d.ts.map +1 -0
  7. package/internal/menu.js +43 -0
  8. package/internal/menu.js.map +1 -0
  9. package/library/LibraryBreadcrumbContext.d.ts +30 -0
  10. package/library/LibraryBreadcrumbContext.d.ts.map +1 -0
  11. package/library/LibraryBreadcrumbContext.js +39 -0
  12. package/library/LibraryBreadcrumbContext.js.map +1 -0
  13. package/library/index.d.ts +1 -0
  14. package/library/index.d.ts.map +1 -1
  15. package/library/index.js +1 -0
  16. package/library/index.js.map +1 -1
  17. package/organization/OrgProvider.d.ts +59 -0
  18. package/organization/OrgProvider.d.ts.map +1 -0
  19. package/organization/OrgProvider.js +130 -0
  20. package/organization/OrgProvider.js.map +1 -0
  21. package/organization/OrgSwitcher.d.ts +36 -0
  22. package/organization/OrgSwitcher.d.ts.map +1 -0
  23. package/organization/OrgSwitcher.js +73 -0
  24. package/organization/OrgSwitcher.js.map +1 -0
  25. package/organization/index.d.ts +6 -0
  26. package/organization/index.d.ts.map +1 -1
  27. package/organization/index.js +3 -0
  28. package/organization/index.js.map +1 -1
  29. package/organization/useOrgGate.d.ts +101 -0
  30. package/organization/useOrgGate.d.ts.map +1 -0
  31. package/organization/useOrgGate.js +99 -0
  32. package/organization/useOrgGate.js.map +1 -0
  33. package/package.json +5 -4
  34. package/runner/RunnerListPanel.d.ts +13 -8
  35. package/runner/RunnerListPanel.d.ts.map +1 -1
  36. package/runner/RunnerListPanel.js +10 -6
  37. package/runner/RunnerListPanel.js.map +1 -1
  38. package/settings/ApiKeysSection.d.ts +3 -0
  39. package/settings/ApiKeysSection.d.ts.map +1 -0
  40. package/settings/ApiKeysSection.js +30 -0
  41. package/settings/ApiKeysSection.js.map +1 -0
  42. package/settings/EnvironmentsSection.d.ts +3 -0
  43. package/settings/EnvironmentsSection.d.ts.map +1 -0
  44. package/settings/EnvironmentsSection.js +49 -0
  45. package/settings/EnvironmentsSection.js.map +1 -0
  46. package/settings/IdentityProvidersSection.d.ts +12 -0
  47. package/settings/IdentityProvidersSection.d.ts.map +1 -0
  48. package/settings/IdentityProvidersSection.js +34 -0
  49. package/settings/IdentityProvidersSection.js.map +1 -0
  50. package/settings/InvitationsSection.d.ts +3 -0
  51. package/settings/InvitationsSection.d.ts.map +1 -0
  52. package/settings/InvitationsSection.js +13 -0
  53. package/settings/InvitationsSection.js.map +1 -0
  54. package/settings/MembersSection.d.ts +3 -0
  55. package/settings/MembersSection.d.ts.map +1 -0
  56. package/settings/MembersSection.js +14 -0
  57. package/settings/MembersSection.js.map +1 -0
  58. package/settings/OAuthAppsSection.d.ts +3 -0
  59. package/settings/OAuthAppsSection.d.ts.map +1 -0
  60. package/settings/OAuthAppsSection.js +33 -0
  61. package/settings/OAuthAppsSection.js.map +1 -0
  62. package/settings/OrgProfileSection.d.ts +3 -0
  63. package/settings/OrgProfileSection.d.ts.map +1 -0
  64. package/settings/OrgProfileSection.js +15 -0
  65. package/settings/OrgProfileSection.js.map +1 -0
  66. package/settings/PlatformClientsSection.d.ts +3 -0
  67. package/settings/PlatformClientsSection.d.ts.map +1 -0
  68. package/settings/PlatformClientsSection.js +48 -0
  69. package/settings/PlatformClientsSection.js.map +1 -0
  70. package/settings/UsageSection.d.ts +3 -0
  71. package/settings/UsageSection.d.ts.map +1 -0
  72. package/settings/UsageSection.js +14 -0
  73. package/settings/UsageSection.js.map +1 -0
  74. package/settings/index.d.ts +13 -0
  75. package/settings/index.d.ts.map +1 -0
  76. package/settings/index.js +11 -0
  77. package/settings/index.js.map +1 -0
  78. package/settings/settings-nav.d.ts +25 -0
  79. package/settings/settings-nav.d.ts.map +1 -0
  80. package/settings/settings-nav.js +41 -0
  81. package/settings/settings-nav.js.map +1 -0
  82. package/src/index.ts +32 -1
  83. package/src/internal/menu.tsx +160 -0
  84. package/src/library/LibraryBreadcrumbContext.tsx +70 -0
  85. package/src/library/index.ts +6 -0
  86. package/src/organization/OrgProvider.tsx +184 -0
  87. package/src/organization/OrgSwitcher.tsx +275 -0
  88. package/src/organization/index.ts +10 -0
  89. package/src/organization/useOrgGate.ts +183 -0
  90. package/src/runner/RunnerListPanel.tsx +14 -9
  91. package/src/settings/ApiKeysSection.tsx +96 -0
  92. package/src/settings/EnvironmentsSection.tsx +162 -0
  93. package/src/settings/IdentityProvidersSection.tsx +123 -0
  94. package/src/settings/InvitationsSection.tsx +42 -0
  95. package/src/settings/MembersSection.tsx +41 -0
  96. package/src/settings/OAuthAppsSection.tsx +100 -0
  97. package/src/settings/OrgProfileSection.tsx +41 -0
  98. package/src/settings/PlatformClientsSection.tsx +149 -0
  99. package/src/settings/UsageSection.tsx +41 -0
  100. package/src/settings/index.ts +13 -0
  101. package/src/settings/settings-nav.ts +78 -0
  102. package/src/user/UserMenu.tsx +241 -0
  103. package/src/user/index.ts +2 -0
  104. package/styles.css +1 -1
  105. package/user/UserMenu.d.ts +82 -0
  106. package/user/UserMenu.d.ts.map +1 -0
  107. package/user/UserMenu.js +51 -0
  108. package/user/UserMenu.js.map +1 -0
  109. package/user/index.d.ts +3 -0
  110. package/user/index.d.ts.map +1 -0
  111. package/user/index.js +2 -0
  112. package/user/index.js.map +1 -0
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Menu as MenuPrimitive } from "@base-ui/react/menu";
5
+ import { cn } from "@stigmer/theme";
6
+ import { CheckIcon } from "lucide-react";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // SDK-internal styled Menu primitives over @base-ui/react.
10
+ //
11
+ // These are NOT exported from @stigmer/react. They provide a single source
12
+ // of truth for dropdown menu styling across SDK styled components
13
+ // (OrgSwitcher, UserMenu, etc.) so that every menu looks identical.
14
+ //
15
+ // Portaled content uses popover-* / main-area tokens per DD-005.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function Menu(props: MenuPrimitive.Root.Props) {
19
+ return <MenuPrimitive.Root {...props} />;
20
+ }
21
+
22
+ function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
23
+ return <MenuPrimitive.Trigger {...props} />;
24
+ }
25
+
26
+ function MenuContent({
27
+ align = "start",
28
+ alignOffset = 0,
29
+ side = "bottom",
30
+ sideOffset = 4,
31
+ className,
32
+ ...props
33
+ }: MenuPrimitive.Popup.Props &
34
+ Pick<
35
+ MenuPrimitive.Positioner.Props,
36
+ "align" | "alignOffset" | "side" | "sideOffset"
37
+ >) {
38
+ return (
39
+ <MenuPrimitive.Portal>
40
+ <MenuPrimitive.Positioner
41
+ className="isolate z-50 outline-none"
42
+ align={align}
43
+ alignOffset={alignOffset}
44
+ side={side}
45
+ sideOffset={sideOffset}
46
+ >
47
+ <MenuPrimitive.Popup
48
+ className={cn(
49
+ "bg-popover text-popover-foreground ring-foreground/10",
50
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
+ "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
52
+ "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
53
+ "z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ </MenuPrimitive.Positioner>
59
+ </MenuPrimitive.Portal>
60
+ );
61
+ }
62
+
63
+ function MenuItem({
64
+ className,
65
+ variant = "default",
66
+ ...props
67
+ }: MenuPrimitive.Item.Props & {
68
+ variant?: "default" | "destructive";
69
+ }) {
70
+ return (
71
+ <MenuPrimitive.Item
72
+ data-variant={variant}
73
+ className={cn(
74
+ "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
75
+ "data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive-subtle data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive",
76
+ "relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none",
77
+ "data-disabled:pointer-events-none data-disabled:opacity-50",
78
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
79
+ className,
80
+ )}
81
+ {...props}
82
+ />
83
+ );
84
+ }
85
+
86
+ function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
87
+ return <MenuPrimitive.RadioGroup {...props} />;
88
+ }
89
+
90
+ function MenuRadioItem({
91
+ className,
92
+ children,
93
+ ...props
94
+ }: MenuPrimitive.RadioItem.Props) {
95
+ return (
96
+ <MenuPrimitive.RadioItem
97
+ className={cn(
98
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground",
99
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none",
100
+ "data-disabled:pointer-events-none data-disabled:opacity-50",
101
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
102
+ className,
103
+ )}
104
+ {...props}
105
+ >
106
+ <span className="pointer-events-none absolute right-2 flex items-center justify-center">
107
+ <MenuPrimitive.RadioItemIndicator>
108
+ <CheckIcon />
109
+ </MenuPrimitive.RadioItemIndicator>
110
+ </span>
111
+ {children}
112
+ </MenuPrimitive.RadioItem>
113
+ );
114
+ }
115
+
116
+ function MenuSeparator({
117
+ className,
118
+ ...props
119
+ }: MenuPrimitive.Separator.Props) {
120
+ return (
121
+ <MenuPrimitive.Separator
122
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
123
+ {...props}
124
+ />
125
+ );
126
+ }
127
+
128
+ function MenuGroup({
129
+ className,
130
+ ...props
131
+ }: React.ComponentPropsWithoutRef<"div"> & { role?: string }) {
132
+ return <div role="group" className={className} {...props} />;
133
+ }
134
+
135
+ function MenuLabel({
136
+ className,
137
+ ...props
138
+ }: React.ComponentPropsWithoutRef<"span">) {
139
+ return (
140
+ <span
141
+ className={cn(
142
+ "text-muted-foreground block px-1.5 py-1 text-[11px] font-medium uppercase tracking-wider select-none",
143
+ className,
144
+ )}
145
+ {...props}
146
+ />
147
+ );
148
+ }
149
+
150
+ export {
151
+ Menu,
152
+ MenuTrigger,
153
+ MenuContent,
154
+ MenuItem,
155
+ MenuRadioGroup,
156
+ MenuRadioItem,
157
+ MenuSeparator,
158
+ MenuGroup,
159
+ MenuLabel,
160
+ };
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ interface BreadcrumbOverrideContextValue {
13
+ readonly label: string | null;
14
+ readonly setLabel: (label: string | null) => void;
15
+ }
16
+
17
+ const BreadcrumbOverrideContext =
18
+ createContext<BreadcrumbOverrideContextValue | null>(null);
19
+
20
+ /**
21
+ * Provides a breadcrumb label override for library detail pages.
22
+ *
23
+ * Wrap the library zone in this provider so that detail pages can push
24
+ * a human-readable resource name into the breadcrumb via
25
+ * `useBreadcrumbOverride().setLabel(name)`.
26
+ */
27
+ export function LibraryBreadcrumbProvider({
28
+ children,
29
+ }: {
30
+ readonly children: ReactNode;
31
+ }) {
32
+ const [label, setLabelState] = useState<string | null>(null);
33
+ const setLabel = useCallback(
34
+ (next: string | null) => setLabelState(next),
35
+ [],
36
+ );
37
+ const value = useMemo(() => ({ label, setLabel }), [label, setLabel]);
38
+
39
+ return (
40
+ <BreadcrumbOverrideContext.Provider value={value}>
41
+ {children}
42
+ </BreadcrumbOverrideContext.Provider>
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Read the current breadcrumb override label.
48
+ *
49
+ * Used by breadcrumb UI components to display a resource display name
50
+ * instead of the raw URL slug for the last segment.
51
+ */
52
+ export function useBreadcrumbLabel(): string | null {
53
+ return useContext(BreadcrumbOverrideContext)?.label ?? null;
54
+ }
55
+
56
+ /**
57
+ * Set (or clear) the breadcrumb override label.
58
+ *
59
+ * Used by detail pages to push the resource display name up to the
60
+ * breadcrumb after the resource data has loaded. Returns a stable
61
+ * `setLabel` function safe for use as a `useEffect` dependency.
62
+ */
63
+ export function useBreadcrumbOverride(): {
64
+ /** Set or clear the breadcrumb label override for the active detail page. */
65
+ setLabel: (label: string | null) => void;
66
+ } {
67
+ const ctx = useContext(BreadcrumbOverrideContext);
68
+ const noop = useCallback((_label: string | null) => {}, []);
69
+ return { setLabel: ctx?.setLabel ?? noop };
70
+ }
@@ -1,3 +1,9 @@
1
+ export {
2
+ LibraryBreadcrumbProvider,
3
+ useBreadcrumbLabel,
4
+ useBreadcrumbOverride,
5
+ } from "./LibraryBreadcrumbContext";
6
+
1
7
  export { ScopeToggle } from "./ScopeToggle";
2
8
  export type { ScopeToggleProps } from "./ScopeToggle";
3
9
 
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
13
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
14
+ import { useStigmer } from "../hooks";
15
+
16
+ /** Value exposed by {@link OrgProvider} via {@link useOrg}. */
17
+ export interface OrgContextValue {
18
+ /** All organizations the authenticated user belongs to. */
19
+ readonly orgs: Organization[];
20
+ /** The currently selected organization. Null while loading or if the user has no orgs. */
21
+ readonly activeOrg: Organization | null;
22
+ /** Switch the active organization. Persisted to localStorage. */
23
+ readonly setActiveOrg: (org: Organization) => void;
24
+ /** True during the initial fetch of organizations. */
25
+ readonly isLoading: boolean;
26
+ /** Non-null when the fetch failed. */
27
+ readonly error: string | null;
28
+ /** Re-attempt the organization fetch after a failure. */
29
+ readonly retry: () => void;
30
+ /**
31
+ * Refetch the organization list. If `targetSlug` is provided, the
32
+ * org matching that slug will be auto-selected after the fetch
33
+ * completes (useful after creating a new organization).
34
+ */
35
+ readonly refresh: (targetSlug?: string) => void;
36
+ }
37
+
38
+ const OrgContext = createContext<OrgContextValue | null>(null);
39
+
40
+ const STORAGE_KEY = "stigmer:activeOrgSlug";
41
+
42
+ function readPersistedSlug(): string | null {
43
+ try {
44
+ return localStorage.getItem(STORAGE_KEY);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function persistSlug(slug: string): void {
51
+ try {
52
+ localStorage.setItem(STORAGE_KEY, slug);
53
+ } catch {
54
+ // SSR or private browsing — silently ignore.
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Provides organization context to the component tree.
60
+ *
61
+ * Fetches the authenticated user's organizations via
62
+ * `stigmer.organization.findMyOrganizations()`, manages the active
63
+ * organization selection, and persists the choice to `localStorage`
64
+ * under the key `stigmer:activeOrgSlug`.
65
+ *
66
+ * Must be rendered inside a {@link StigmerProvider}.
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * <StigmerProvider client={client}>
71
+ * <OrgProvider>
72
+ * <App />
73
+ * </OrgProvider>
74
+ * </StigmerProvider>
75
+ * ```
76
+ */
77
+ export function OrgProvider({ children }: { children: ReactNode }) {
78
+ const stigmer = useStigmer();
79
+ const [orgs, setOrgs] = useState<Organization[]>([]);
80
+ const [activeOrg, setActiveOrgState] = useState<Organization | null>(null);
81
+ const [isLoading, setIsLoading] = useState(true);
82
+ const [error, setError] = useState<string | null>(null);
83
+
84
+ const fetchIdRef = useRef(0);
85
+
86
+ const load = useCallback(
87
+ async (targetSlug?: string) => {
88
+ const fetchId = ++fetchIdRef.current;
89
+ setIsLoading(true);
90
+ setError(null);
91
+
92
+ try {
93
+ const response = await stigmer.organization.findMyOrganizations();
94
+ const entries = response.entries;
95
+
96
+ if (fetchId !== fetchIdRef.current) return;
97
+
98
+ setOrgs(entries);
99
+
100
+ if (entries.length === 0) {
101
+ setActiveOrgState(null);
102
+ return;
103
+ }
104
+
105
+ const preferred = targetSlug ?? readPersistedSlug();
106
+ const restored = preferred
107
+ ? entries.find((o) => o.metadata?.slug === preferred)
108
+ : undefined;
109
+
110
+ const selected = restored ?? entries[0];
111
+ setActiveOrgState(selected);
112
+ if (selected.metadata?.slug) {
113
+ persistSlug(selected.metadata.slug);
114
+ }
115
+ } catch (err: unknown) {
116
+ if (fetchId !== fetchIdRef.current) return;
117
+
118
+ const message =
119
+ err instanceof Error ? err.message : "Failed to load organizations";
120
+ setError(message);
121
+ setOrgs([]);
122
+ setActiveOrgState(null);
123
+ } finally {
124
+ if (fetchId === fetchIdRef.current) {
125
+ setIsLoading(false);
126
+ }
127
+ }
128
+ },
129
+ [stigmer],
130
+ );
131
+
132
+ useEffect(() => {
133
+ load();
134
+ }, [load]);
135
+
136
+ const setActiveOrg = useCallback((org: Organization) => {
137
+ setActiveOrgState(org);
138
+ if (org.metadata?.slug) {
139
+ persistSlug(org.metadata.slug);
140
+ }
141
+ }, []);
142
+
143
+ const value = useMemo<OrgContextValue>(
144
+ () => ({
145
+ orgs,
146
+ activeOrg,
147
+ setActiveOrg,
148
+ isLoading,
149
+ error,
150
+ retry: load,
151
+ refresh: load,
152
+ }),
153
+ [orgs, activeOrg, setActiveOrg, isLoading, error, load],
154
+ );
155
+
156
+ return <OrgContext.Provider value={value}>{children}</OrgContext.Provider>;
157
+ }
158
+
159
+ /**
160
+ * Access the active organization context from the nearest
161
+ * {@link OrgProvider}.
162
+ *
163
+ * Throws if called outside an `<OrgProvider>` — this surfaces wiring
164
+ * mistakes immediately during development.
165
+ */
166
+ export function useOrg(): OrgContextValue {
167
+ const ctx = useContext(OrgContext);
168
+ if (!ctx) {
169
+ throw new Error(
170
+ "useOrg must be used within <OrgProvider>. " +
171
+ "Wrap your component tree with <OrgProvider> inside a <StigmerProvider>.",
172
+ );
173
+ }
174
+ return ctx;
175
+ }
176
+
177
+ /**
178
+ * Convenience accessor: returns the active org's slug for use in API
179
+ * calls, or an empty string when no org is selected.
180
+ */
181
+ export function useActiveOrgSlug(): string {
182
+ const { activeOrg } = useOrg();
183
+ return activeOrg?.metadata?.slug ?? "";
184
+ }
@@ -0,0 +1,275 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import {
5
+ Building2,
6
+ AlertCircle,
7
+ RefreshCw,
8
+ ChevronsUpDown,
9
+ Plus,
10
+ User,
11
+ } from "lucide-react";
12
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
13
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
14
+ import { cn } from "@stigmer/theme";
15
+ import {
16
+ Menu,
17
+ MenuContent,
18
+ MenuItem,
19
+ MenuRadioGroup,
20
+ MenuRadioItem,
21
+ MenuSeparator,
22
+ MenuTrigger,
23
+ } from "../internal/menu";
24
+ import { useOrg } from "./OrgProvider";
25
+ import { CreateOrganizationForm } from "./CreateOrganizationForm";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public API
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Props for {@link OrgSwitcher}. */
32
+ export interface OrgSwitcherProps {
33
+ /**
34
+ * Called when the user explicitly switches to a different organization
35
+ * or creates a new one.
36
+ *
37
+ * This fires only on user-initiated changes — not on initial load,
38
+ * background refresh, or programmatic context updates. Use it to
39
+ * trigger side effects like navigation without fragile `useEffect`
40
+ * guards on the active org.
41
+ */
42
+ readonly onOrgChanged?: (org: Organization) => void;
43
+ /** Additional CSS class names merged onto the trigger (or root in error/loading states). */
44
+ readonly className?: string;
45
+ }
46
+
47
+ /**
48
+ * Organization switcher dropdown for sidebar navigation.
49
+ *
50
+ * Shows the active organization (name + slug), lists personal and team
51
+ * organizations grouped with icons, and provides a "Create organization"
52
+ * action that opens an inline dialog with {@link CreateOrganizationForm}.
53
+ *
54
+ * Designed for sidebar placement — the trigger uses `sidebar-*` design
55
+ * tokens. The portaled dropdown and dialog use standard `popover-*` /
56
+ * main-area tokens per theme-token-guidelines.
57
+ *
58
+ * Must be rendered inside an {@link OrgProvider}.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * <OrgSwitcher onOrgChanged={(org) => navigate("/")} />
63
+ * ```
64
+ */
65
+ export function OrgSwitcher({ onOrgChanged, className }: OrgSwitcherProps) {
66
+ const { orgs, activeOrg, setActiveOrg, isLoading, error, retry, refresh } =
67
+ useOrg();
68
+ const [createOpen, setCreateOpen] = useState(false);
69
+
70
+ const handleOrgSwitch = useCallback(
71
+ (slug: string) => {
72
+ const org = orgs.find((o) => o.metadata?.slug === slug);
73
+ if (org && org.metadata?.slug !== activeOrg?.metadata?.slug) {
74
+ setActiveOrg(org);
75
+ onOrgChanged?.(org);
76
+ }
77
+ },
78
+ [orgs, activeOrg, setActiveOrg, onOrgChanged],
79
+ );
80
+
81
+ const handleCreated = useCallback(
82
+ (org: Organization) => {
83
+ setCreateOpen(false);
84
+ refresh(org.metadata?.slug);
85
+ onOrgChanged?.(org);
86
+ },
87
+ [refresh, onOrgChanged],
88
+ );
89
+
90
+ const personalOrgs = useMemo(
91
+ () => orgs.filter((o) => o.spec?.isPersonal),
92
+ [orgs],
93
+ );
94
+ const teamOrgs = useMemo(
95
+ () => orgs.filter((o) => !o.spec?.isPersonal),
96
+ [orgs],
97
+ );
98
+
99
+ if (isLoading) {
100
+ return <OrgSwitcherSkeleton className={className} />;
101
+ }
102
+
103
+ if (error) {
104
+ return (
105
+ <div className={cn("flex items-center gap-2 px-2 py-1.5", className)}>
106
+ <AlertCircle className="text-destructive size-4 shrink-0" />
107
+ <span className="text-destructive truncate text-xs">{error}</span>
108
+ <button
109
+ onClick={retry}
110
+ className="text-sidebar-muted-foreground hover:text-sidebar-foreground shrink-0 rounded p-0.5 transition-colors"
111
+ aria-label="Retry loading organizations"
112
+ >
113
+ <RefreshCw className="size-3" />
114
+ </button>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ const hasOrgs = orgs.length > 0 && activeOrg;
120
+ const TriggerIcon = activeOrg?.spec?.isPersonal ? User : Building2;
121
+
122
+ return (
123
+ <>
124
+ <Menu>
125
+ <MenuTrigger
126
+ aria-label="Organization menu"
127
+ className={cn(
128
+ "hover:bg-sidebar-accent flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors focus:outline-none",
129
+ className,
130
+ )}
131
+ >
132
+ <TriggerIcon className="text-sidebar-muted-foreground mt-0.5 size-4 shrink-0 self-start" />
133
+ {hasOrgs ? (
134
+ <OrgLabel
135
+ org={activeOrg}
136
+ slugClassName="text-sidebar-muted-foreground"
137
+ />
138
+ ) : (
139
+ <span className="text-sidebar-muted-foreground truncate">
140
+ No organizations
141
+ </span>
142
+ )}
143
+ <ChevronsUpDown className="text-sidebar-muted-foreground ml-auto mt-0.5 size-3.5 shrink-0 self-start" />
144
+ </MenuTrigger>
145
+
146
+ <MenuContent align="start" side="bottom" sideOffset={4}>
147
+ {hasOrgs && (
148
+ <MenuRadioGroup
149
+ value={activeOrg.metadata?.slug ?? ""}
150
+ onValueChange={handleOrgSwitch}
151
+ >
152
+ {personalOrgs.map((org) => (
153
+ <MenuRadioItem
154
+ key={org.metadata?.slug}
155
+ value={org.metadata?.slug ?? ""}
156
+ className="items-start"
157
+ >
158
+ <User className="mt-0.5 size-3.5 shrink-0" />
159
+ <OrgLabel org={org} />
160
+ </MenuRadioItem>
161
+ ))}
162
+ {personalOrgs.length > 0 && teamOrgs.length > 0 && (
163
+ <MenuSeparator />
164
+ )}
165
+ {teamOrgs.map((org) => (
166
+ <MenuRadioItem
167
+ key={org.metadata?.slug}
168
+ value={org.metadata?.slug ?? ""}
169
+ className="items-start"
170
+ >
171
+ <Building2 className="mt-0.5 size-3.5 shrink-0" />
172
+ <OrgLabel org={org} />
173
+ </MenuRadioItem>
174
+ ))}
175
+ </MenuRadioGroup>
176
+ )}
177
+
178
+ {hasOrgs && <MenuSeparator />}
179
+
180
+ <MenuItem onClick={() => setCreateOpen(true)}>
181
+ <Plus className="size-4" />
182
+ Create organization
183
+ </MenuItem>
184
+ </MenuContent>
185
+ </Menu>
186
+
187
+ <DialogPrimitive.Root
188
+ open={createOpen}
189
+ onOpenChange={(open) => setCreateOpen(open)}
190
+ >
191
+ <DialogPrimitive.Portal>
192
+ <DialogPrimitive.Backdrop
193
+ className={cn(
194
+ "fixed inset-0 z-50 bg-black/50",
195
+ "data-open:animate-in data-open:fade-in-0",
196
+ "data-closed:animate-out data-closed:fade-out-0",
197
+ "duration-150",
198
+ )}
199
+ />
200
+ {/* eslint-disable stigmer/no-main-tokens-in-sidebar -- The entire dialog is portaled outside the sidebar; main-area tokens are correct per DD-005. */}
201
+ <DialogPrimitive.Popup
202
+ className={cn(
203
+ "bg-background text-foreground ring-border/20",
204
+ "fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg ring-1 outline-none",
205
+ "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
206
+ "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
207
+ "duration-150",
208
+ )}
209
+ >
210
+ <DialogPrimitive.Title className="text-foreground text-sm font-semibold">
211
+ Create organization
212
+ </DialogPrimitive.Title>
213
+ <DialogPrimitive.Description className="text-muted-foreground mt-1 mb-4 text-xs">
214
+ Organizations are tenancy boundaries that own agents,
215
+ environments, and other resources.
216
+ </DialogPrimitive.Description>
217
+ <CreateOrganizationForm
218
+ onCreated={handleCreated}
219
+ onCancel={() => setCreateOpen(false)}
220
+ />
221
+ </DialogPrimitive.Popup>
222
+ {/* eslint-enable stigmer/no-main-tokens-in-sidebar */}
223
+ </DialogPrimitive.Portal>
224
+ </DialogPrimitive.Root>
225
+ </>
226
+ );
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Internal sub-components
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Two-line org label: name (bold) + slug (muted). Used in the trigger
235
+ * (sidebar context → pass `slugClassName="text-sidebar-muted-foreground"`)
236
+ * and in dropdown radio items (popover context → default `text-muted-foreground`).
237
+ */
238
+ function OrgLabel({
239
+ org,
240
+ slugClassName,
241
+ }: {
242
+ org: Organization;
243
+ slugClassName?: string;
244
+ }) {
245
+ const name = org.metadata?.name || org.metadata?.slug;
246
+ const slug = org.metadata?.slug;
247
+
248
+ return (
249
+ <span className="min-w-0 flex-1">
250
+ <span className="block truncate text-sm font-medium leading-tight">
251
+ {name}
252
+ </span>
253
+ {slug && (
254
+ <span
255
+ className={cn(
256
+ "block truncate text-xs leading-tight",
257
+ // eslint-disable-next-line stigmer/no-main-tokens-in-sidebar -- OrgLabel renders inside portaled dropdown content (popover context)
258
+ slugClassName ?? "text-muted-foreground",
259
+ )}
260
+ >
261
+ {slug}
262
+ </span>
263
+ )}
264
+ </span>
265
+ );
266
+ }
267
+
268
+ function OrgSwitcherSkeleton({ className }: { className?: string }) {
269
+ return (
270
+ <div className={cn("flex items-center gap-2 px-2 py-1.5", className)}>
271
+ <div className="bg-sidebar-muted size-4 animate-pulse rounded" />
272
+ <div className="bg-sidebar-muted h-4 w-24 animate-pulse rounded" />
273
+ </div>
274
+ );
275
+ }