@object-ui/app-shell 7.0.0 → 7.2.0

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 (141) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/dist/console/AppContent.js +23 -17
  3. package/dist/console/ConsoleShell.d.ts +16 -0
  4. package/dist/console/ConsoleShell.js +43 -2
  5. package/dist/console/ai/AiChatPage.js +47 -16
  6. package/dist/console/ai/LiveCanvas.d.ts +8 -2
  7. package/dist/console/ai/LiveCanvas.js +6 -4
  8. package/dist/console/home/HomeLayout.js +5 -7
  9. package/dist/console/home/HomePage.js +1 -9
  10. package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
  11. package/dist/console/organizations/OrganizationsPage.js +22 -3
  12. package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
  13. package/dist/console/organizations/provisionEnvironment.js +64 -0
  14. package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
  15. package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
  16. package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
  17. package/dist/environment/EnvironmentListToolbar.js +59 -0
  18. package/dist/environment/entitlements.d.ts +90 -0
  19. package/dist/environment/entitlements.js +91 -0
  20. package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
  21. package/dist/environment/useEnvironmentEntitlements.js +108 -0
  22. package/dist/hooks/useActionModal.js +15 -1
  23. package/dist/hooks/useAiSurface.d.ts +59 -0
  24. package/dist/hooks/useAiSurface.js +78 -0
  25. package/dist/hooks/useChatConversation.d.ts +30 -0
  26. package/dist/hooks/useChatConversation.js +63 -0
  27. package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
  28. package/dist/hooks/useConsoleActionRuntime.js +42 -10
  29. package/dist/index.d.ts +5 -2
  30. package/dist/index.js +10 -2
  31. package/dist/layout/AppHeader.js +28 -4
  32. package/dist/layout/ConsoleFloatingChatbot.d.ts +6 -4
  33. package/dist/layout/ConsoleFloatingChatbot.js +41 -10
  34. package/dist/layout/ConsoleLayout.js +5 -6
  35. package/dist/layout/ContextSelectors.js +59 -35
  36. package/dist/layout/agentPicker.d.ts +56 -0
  37. package/dist/layout/agentPicker.js +40 -0
  38. package/dist/preview/CommitTimeline.d.ts +15 -0
  39. package/dist/preview/CommitTimeline.js +82 -0
  40. package/dist/preview/DraftPreviewBar.js +20 -7
  41. package/dist/preview/UnpublishedAppBar.js +11 -7
  42. package/dist/preview/commitHistory.d.ts +28 -0
  43. package/dist/preview/commitHistory.js +48 -0
  44. package/dist/providers/ExpressionProvider.js +9 -3
  45. package/dist/providers/MetadataProvider.js +9 -0
  46. package/dist/utils/index.d.ts +2 -2
  47. package/dist/utils/index.js +1 -1
  48. package/dist/utils/recordFormNavigation.d.ts +60 -0
  49. package/dist/utils/recordFormNavigation.js +35 -0
  50. package/dist/utils/resolvePageVarTokens.d.ts +31 -0
  51. package/dist/utils/resolvePageVarTokens.js +72 -0
  52. package/dist/views/CreateViewDialog.js +14 -1
  53. package/dist/views/FlowRunner.d.ts +2 -30
  54. package/dist/views/FlowRunner.js +18 -50
  55. package/dist/views/ObjectView.js +26 -12
  56. package/dist/views/ScreenView.d.ts +70 -0
  57. package/dist/views/ScreenView.js +73 -0
  58. package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
  59. package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
  60. package/dist/views/metadata-admin/DirectoryPage.js +2 -14
  61. package/dist/views/metadata-admin/JsonSourceEditor.d.ts +3 -1
  62. package/dist/views/metadata-admin/JsonSourceEditor.js +21 -3
  63. package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
  64. package/dist/views/metadata-admin/PackagesPage.js +58 -5
  65. package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
  66. package/dist/views/metadata-admin/ResourceEditPage.js +83 -24
  67. package/dist/views/metadata-admin/ResourceListPage.js +28 -19
  68. package/dist/views/metadata-admin/StudioHomePage.js +6 -14
  69. package/dist/views/metadata-admin/anchors.js +20 -2
  70. package/dist/views/metadata-admin/createBody.d.ts +26 -0
  71. package/dist/views/metadata-admin/createBody.js +30 -0
  72. package/dist/views/metadata-admin/i18n.js +108 -2
  73. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +10 -2
  74. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +136 -8
  75. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +99 -4
  76. package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
  77. package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
  78. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
  79. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
  80. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
  81. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
  82. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +81 -4
  83. package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
  84. package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
  85. package/dist/views/metadata-admin/inspectors/ObjectDefaultInspector.js +5 -4
  86. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +47 -12
  87. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.d.ts +1 -1
  88. package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.js +60 -2
  89. package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
  90. package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
  91. package/dist/views/metadata-admin/inspectors/_shared.d.ts +5 -1
  92. package/dist/views/metadata-admin/inspectors/_shared.js +2 -2
  93. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.d.ts +24 -0
  94. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +102 -0
  95. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
  96. package/dist/views/metadata-admin/inspectors/flow-node-config.js +67 -11
  97. package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
  98. package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
  99. package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
  100. package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
  101. package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
  102. package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
  103. package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
  104. package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
  105. package/dist/views/metadata-admin/issuePath.d.ts +22 -0
  106. package/dist/views/metadata-admin/issuePath.js +65 -0
  107. package/dist/views/metadata-admin/package-scope.d.ts +41 -0
  108. package/dist/views/metadata-admin/package-scope.js +59 -0
  109. package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
  110. package/dist/views/metadata-admin/previews/DatasetPreview.js +21 -5
  111. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +26 -1
  112. package/dist/views/metadata-admin/previews/FlowCanvas.js +143 -16
  113. package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
  114. package/dist/views/metadata-admin/previews/FlowPreview.js +47 -7
  115. package/dist/views/metadata-admin/previews/FlowSimulatorPanel.js +37 -3
  116. package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
  117. package/dist/views/metadata-admin/previews/PagePreview.js +112 -3
  118. package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
  119. package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
  120. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
  121. package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
  122. package/dist/views/metadata-admin/previews/ScreenPreview.d.ts +38 -0
  123. package/dist/views/metadata-admin/previews/ScreenPreview.js +61 -0
  124. package/dist/views/metadata-admin/previews/flow-canvas-layout.d.ts +14 -0
  125. package/dist/views/metadata-admin/previews/flow-canvas-layout.js +37 -0
  126. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +17 -1
  127. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +23 -6
  128. package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
  129. package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
  130. package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
  131. package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
  132. package/dist/views/metadata-admin/previews/object-fields-io.d.ts +21 -0
  133. package/dist/views/metadata-admin/previews/object-fields-io.js +37 -2
  134. package/dist/views/metadata-admin/previews/screen-spec.d.ts +43 -0
  135. package/dist/views/metadata-admin/previews/screen-spec.js +108 -0
  136. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +20 -0
  137. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.d.ts +7 -0
  138. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +76 -2
  139. package/dist/views/metadata-admin/previews/simulator/flow-simulator.d.ts +32 -3
  140. package/dist/views/metadata-admin/previews/simulator/flow-simulator.js +119 -9
  141. package/package.json +38 -38
@@ -44,6 +44,22 @@ export declare function ConnectedShell({ children }: {
44
44
  export declare function RequireOrganization({ children }: {
45
45
  children: ReactNode;
46
46
  }): import("react").JSX.Element;
47
+ /**
48
+ * RequireAiSurface — gate for the `/ai*` routes. The console is edition-agnostic
49
+ * (MIT, runtime-gated): a Community Edition runtime serves no AI agent, so a
50
+ * stale `/ai` bookmark or external link must not land on a broken/empty chat.
51
+ * When the server serves no agent it redirects to `redirectTo` (home) instead.
52
+ *
53
+ * Availability is the live agent catalog (see {@link useAiSurfaceEnabled}) — the
54
+ * same signal every other AI entry point now gates on, so the route is reachable
55
+ * from exactly the entry points that are visible (no shown CTA that bounces back
56
+ * to home, no hidden FAB to a working route). Waits for the catalog to resolve
57
+ * before deciding so the redirect never flashes on first paint.
58
+ */
59
+ export declare function RequireAiSurface({ children, redirectTo, }: {
60
+ children: ReactNode;
61
+ redirectTo?: string;
62
+ }): import("react").JSX.Element;
47
63
  /**
48
64
  * AuthenticatedRoute — convenience wrapper composing AuthGuard + ConnectedShell
49
65
  * (+ optional RequireOrganization). Covers the common case for protected
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  * JSX in App.tsx. See examples/console-starter/src/App.tsx for a minimal example,
11
11
  * apps/console/src/App.tsx for one with custom system routes + CreateApp.
12
12
  */
13
- import { Suspense, useEffect, useRef } from 'react';
13
+ import { Suspense, useEffect, useRef, useState } from 'react';
14
14
  import { Navigate, useLocation } from 'react-router-dom';
15
15
  import { AuthGuard, useAuth } from '@object-ui/auth';
16
16
  import { useObjectTranslation } from '@object-ui/i18n';
@@ -18,6 +18,7 @@ import { SchemaRendererProvider } from '@object-ui/react';
18
18
  import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
19
19
  import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
20
20
  import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
21
+ import { useAiSurfaceEnabled } from '../hooks/useAiSurface';
21
22
  import { PreviewModeProvider } from '../preview/PreviewModeContext';
22
23
  import { NavigationProvider } from '../context/NavigationContext';
23
24
  import { FavoritesProvider } from '../context/FavoritesProvider';
@@ -131,13 +132,53 @@ function UserStateBridge() {
131
132
  * deployments (empty organizations list) render through.
132
133
  */
133
134
  export function RequireOrganization({ children }) {
134
- const { activeOrganization, organizations, isOrganizationsLoading } = useAuth();
135
+ const { activeOrganization, organizations, isOrganizationsLoading, getAuthConfig } = useAuth();
136
+ // Multi-org (cloud) deployments route a brand-new, org-less user into the
137
+ // guided "Create your workspace" flow; single-tenant self-host renders through.
138
+ const [multiOrgEnabled, setMultiOrgEnabled] = useState(null);
139
+ useEffect(() => {
140
+ let cancelled = false;
141
+ getAuthConfig()
142
+ .then((cfg) => { if (!cancelled)
143
+ setMultiOrgEnabled(cfg?.features?.multiOrgEnabled !== false); })
144
+ .catch(() => { if (!cancelled)
145
+ setMultiOrgEnabled(false); });
146
+ return () => { cancelled = true; };
147
+ }, [getAuthConfig]);
135
148
  if (isOrganizationsLoading)
136
149
  return _jsx(LoadingFallback, {});
137
150
  const orgList = organizations ?? [];
138
151
  const orgFeatureEnabled = orgList.length > 0 || !!activeOrganization;
139
152
  if (orgFeatureEnabled && !activeOrganization)
140
153
  return _jsx(Navigate, { to: "/organizations", replace: true });
154
+ // No org at all: on multi-org, send them to /organizations (the create
155
+ // screen); wait for the flag so we don't flash /home then redirect.
156
+ if (orgList.length === 0 && !activeOrganization) {
157
+ if (multiOrgEnabled === null)
158
+ return _jsx(LoadingFallback, {});
159
+ if (multiOrgEnabled)
160
+ return _jsx(Navigate, { to: "/organizations", replace: true });
161
+ }
162
+ return _jsx(_Fragment, { children: children });
163
+ }
164
+ /**
165
+ * RequireAiSurface — gate for the `/ai*` routes. The console is edition-agnostic
166
+ * (MIT, runtime-gated): a Community Edition runtime serves no AI agent, so a
167
+ * stale `/ai` bookmark or external link must not land on a broken/empty chat.
168
+ * When the server serves no agent it redirects to `redirectTo` (home) instead.
169
+ *
170
+ * Availability is the live agent catalog (see {@link useAiSurfaceEnabled}) — the
171
+ * same signal every other AI entry point now gates on, so the route is reachable
172
+ * from exactly the entry points that are visible (no shown CTA that bounces back
173
+ * to home, no hidden FAB to a working route). Waits for the catalog to resolve
174
+ * before deciding so the redirect never flashes on first paint.
175
+ */
176
+ export function RequireAiSurface({ children, redirectTo = '/home', }) {
177
+ const { enabled, isLoading } = useAiSurfaceEnabled();
178
+ if (isLoading)
179
+ return _jsx(LoadingFallback, {});
180
+ if (!enabled)
181
+ return _jsx(Navigate, { to: redirectTo, replace: true });
141
182
  return _jsx(_Fragment, { children: children });
142
183
  }
143
184
  /**
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
3
  /**
4
4
  * AiChatPage — full-page ChatGPT-style AI surface.
@@ -17,7 +17,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
17
17
  import { useAuth } from '@object-ui/auth';
18
18
  import { useObjectTranslation } from '@object-ui/i18n';
19
19
  import { toast } from 'sonner';
20
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, cn, } from '@object-ui/components';
20
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Empty, EmptyTitle, EmptyDescription, cn, } from '@object-ui/components';
21
21
  import { PanelLeft, PanelLeftClose, PanelLeftOpen, Share2 } from 'lucide-react';
22
22
  import { ChatbotEnhanced, useAgents, useObjectChat, useHitlInChat, resolveDefaultAgentName, PLATFORM_DEFAULT_AGENT, agentRouteName, resolveAgentParam, isBuiltinAgentName, isBuildAgent, isAskAgent, publishHealthFromResponse, detectDraftResult, detectProposedPlan, buildProgressFromDraftReview, } from '@object-ui/plugin-chatbot';
23
23
  import { AppHeader } from '../../layout/AppHeader';
@@ -371,8 +371,15 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
371
371
  const apiBase = useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
372
372
  const env = import.meta.env ?? {};
373
373
  const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
374
- const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
374
+ const { agents, isLoading: agentsLoading, error: agentsError, refetch: refetchAgents } = useAgents({ apiBase });
375
375
  const catalogNames = useMemo(() => agents.map((a) => a.name), [agents]);
376
+ // Catalog resolved with no agent to talk to. The `/ai` route guard already
377
+ // redirects when discovery reports AI unavailable (Community Edition), so this
378
+ // is the secondary safety net: a deployment that reports AI enabled but serves
379
+ // no agent (misconfig), a transient `/agents` failure, or a `VITE_AI_BASE_URL`
380
+ // server that returns an empty list. Either way, degrade to a graceful state
381
+ // instead of the agent-less echo chat (autoResponse) that ChatPane falls into.
382
+ const noAgents = !agentsLoading && agents.length === 0;
376
383
  // Is the first path segment an agent? It is when it resolves to one (friendly
377
384
  // alias / new id / legacy id). When it doesn't, it's a legacy bare
378
385
  // `/ai/:conversationId` link (redirected below). `undefined` = catalog still
@@ -598,11 +605,27 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
598
605
  void t1;
599
606
  void t2;
600
607
  }, [conversationId]);
601
- return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsxs("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background/95 px-2 backdrop-blur sm:px-4", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 md:hidden", onClick: () => setMobileChatsOpen(true), "aria-label": t('console.ai.openChats'), "data-testid": "ai-chat-mobile-sidebar-trigger", children: _jsx(PanelLeft, { className: "h-4 w-4" }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "hidden h-8 w-8 shrink-0 md:inline-flex", onClick: toggleChatsCollapsed, "aria-label": chatsCollapsed
602
- ? t('console.ai.showChats', { defaultValue: 'Show chats' })
603
- : t('console.ai.hideChats', { defaultValue: 'Hide chats' }), title: chatsCollapsed
604
- ? t('console.ai.showChats', { defaultValue: 'Show chats' })
605
- : t('console.ai.hideChats', { defaultValue: 'Hide chats' }), "data-testid": "ai-chat-collapse-sidebar-trigger", "aria-pressed": chatsCollapsed, children: chatsCollapsed ? _jsx(PanelLeftOpen, { className: "h-4 w-4" }) : _jsx(PanelLeftClose, { className: "h-4 w-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(AppHeader, { variant: "home" }) })] }), _jsx(Sheet, { open: mobileChatsOpen, onOpenChange: setMobileChatsOpen, children: _jsxs(SheetContent, { side: "left", className: "w-[320px] p-0 sm:max-w-[360px]", "data-testid": "ai-chat-mobile-sidebar", children: [_jsxs(SheetHeader, { className: "sr-only", children: [_jsx(SheetTitle, { children: t('console.ai.chats') }), _jsx(SheetDescription, { children: t('console.ai.chatsDescription') })] }), _jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, activeAgent: activeAgent, refreshKey: refreshKey, titleHints: titleHints, className: "h-full border-r-0", onNavigate: () => setMobileChatsOpen(false) })] }) }), conversationId && (_jsx(ShareDialog, { open: shareOpen, onOpenChange: setShareOpen, objectName: "ai_conversations", recordId: conversationId, recordLabel: "this conversation", apiBase: restApiBase, publicBaseUrl: publicShareBase })), _jsxs("div", { className: "flex min-h-0 flex-1 w-full bg-muted/20", children: [!chatsCollapsed && (_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, activeAgent: activeAgent, refreshKey: refreshKey, titleHints: titleHints, className: "hidden w-72 shrink-0 border-r md:flex" })), _jsx("main", { className: "flex min-w-0 flex-1 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, onSent: handleSent, onShare: () => setShareOpen(true), onCanvasOpenChange: handleCanvasOpenChange }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }));
608
+ return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsxs("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background/95 px-2 backdrop-blur sm:px-4", children: [!noAgents && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 md:hidden", onClick: () => setMobileChatsOpen(true), "aria-label": t('console.ai.openChats'), "data-testid": "ai-chat-mobile-sidebar-trigger", children: _jsx(PanelLeft, { className: "h-4 w-4" }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "hidden h-8 w-8 shrink-0 md:inline-flex", onClick: toggleChatsCollapsed, "aria-label": chatsCollapsed
609
+ ? t('console.ai.showChats', { defaultValue: 'Show chats' })
610
+ : t('console.ai.hideChats', { defaultValue: 'Hide chats' }), title: chatsCollapsed
611
+ ? t('console.ai.showChats', { defaultValue: 'Show chats' })
612
+ : t('console.ai.hideChats', { defaultValue: 'Hide chats' }), "data-testid": "ai-chat-collapse-sidebar-trigger", "aria-pressed": chatsCollapsed, children: chatsCollapsed ? _jsx(PanelLeftOpen, { className: "h-4 w-4" }) : _jsx(PanelLeftClose, { className: "h-4 w-4" }) })] })), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(AppHeader, { variant: "home" }) })] }), noAgents ? (_jsx(AiUnavailable, { hasError: Boolean(agentsError), onRetry: refetchAgents, onHome: () => navigate('/home'), t: t })) : (_jsxs(_Fragment, { children: [_jsx(Sheet, { open: mobileChatsOpen, onOpenChange: setMobileChatsOpen, children: _jsxs(SheetContent, { side: "left", className: "w-[320px] p-0 sm:max-w-[360px]", "data-testid": "ai-chat-mobile-sidebar", children: [_jsxs(SheetHeader, { className: "sr-only", children: [_jsx(SheetTitle, { children: t('console.ai.chats') }), _jsx(SheetDescription, { children: t('console.ai.chatsDescription') })] }), _jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, activeAgent: activeAgent, refreshKey: refreshKey, titleHints: titleHints, className: "h-full border-r-0", onNavigate: () => setMobileChatsOpen(false) })] }) }), conversationId && (_jsx(ShareDialog, { open: shareOpen, onOpenChange: setShareOpen, objectName: "ai_conversations", recordId: conversationId, recordLabel: "this conversation", apiBase: restApiBase, publicBaseUrl: publicShareBase })), _jsxs("div", { className: "flex min-h-0 flex-1 w-full bg-muted/20", children: [!chatsCollapsed && (_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, activeAgent: activeAgent, refreshKey: refreshKey, titleHints: titleHints, className: "hidden w-72 shrink-0 border-r md:flex" })), _jsx("main", { className: "flex min-w-0 flex-1 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, onSent: handleSent, onShare: () => setShareOpen(true), onCanvasOpenChange: handleCanvasOpenChange }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }))] }));
613
+ }
614
+ /**
615
+ * Graceful state for `/ai` when the agent catalog resolved empty — shown
616
+ * instead of an agent-less echo chat. `hasError` distinguishes "AI not enabled
617
+ * on this deployment" (Community Edition) from "couldn't reach the AI service"
618
+ * (offline/misconfig), which also offers a retry. Either way there's a way out
619
+ * (back to home), so the route never dead-ends.
620
+ */
621
+ function AiUnavailable({ hasError, onRetry, onHome, t, }) {
622
+ return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", "data-testid": "ai-unavailable", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('console.ai.unavailableTitle', { defaultValue: 'AI assistant unavailable' }) }), _jsx(EmptyDescription, { children: hasError
623
+ ? t('console.ai.unavailableError', {
624
+ defaultValue: "Couldn't reach the AI service. It may be temporarily offline — try again, or head back home.",
625
+ })
626
+ : t('console.ai.unavailableDescription', {
627
+ defaultValue: "This deployment doesn't have an AI assistant enabled. Everything else works as usual.",
628
+ }) }), _jsxs("div", { className: "mt-6 flex flex-col items-center gap-3 sm:flex-row", children: [hasError && (_jsx(Button, { variant: "outline", onClick: onRetry, "data-testid": "ai-unavailable-retry", children: t('console.ai.unavailableRetry', { defaultValue: 'Try again' }) })), _jsx(Button, { onClick: onHome, "data-testid": "ai-unavailable-home", children: t('console.ai.unavailableHome', { defaultValue: 'Back to home' }) })] })] }) }));
606
629
  }
607
630
  function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, apiBase, conversationId, initialMessages, onSent, onShare, onCanvasOpenChange, }) {
608
631
  const { t } = useObjectTranslation();
@@ -631,10 +654,11 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
631
654
  }, [canvasOpen, onCanvasOpenChange]);
632
655
  // Draggable chat ↔ preview split (active only while the preview is open).
633
656
  const split = useResizableChatPane(canvasOpen);
634
- const handleDraftArtifacts = useCallback((artifacts) => {
657
+ const handleDraftArtifacts = useCallback((artifacts, appSegment) => {
635
658
  const app = artifacts.find((a) => a.type === 'app');
659
+ // Route the preview on the app's package id (ADR-0048), not its name.
636
660
  if (app)
637
- setCanvasApp((prev) => prev ?? { name: app.name, materialized: false });
661
+ setCanvasApp((prev) => prev ?? { name: app.name, segment: appSegment, materialized: false });
638
662
  if (canvasTimerRef.current)
639
663
  window.clearTimeout(canvasTimerRef.current);
640
664
  canvasTimerRef.current = window.setTimeout(() => setCanvasRefreshKey((k) => k + 1), 800);
@@ -644,7 +668,7 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
644
668
  // app URL — the reload that follows shows live rows in every list.
645
669
  const handleBuildMaterialized = useCallback((appName) => {
646
670
  setCanvasApp((prev) => prev && prev.name === appName && !prev.materialized
647
- ? { name: appName, materialized: true }
671
+ ? { ...prev, materialized: true } // keep the package-id segment
648
672
  : prev ?? { name: appName, materialized: true });
649
673
  }, []);
650
674
  // A different conversation is a different build session — close the pane.
@@ -721,7 +745,11 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
721
745
  return (_jsxs("div", { ref: split.containerRef, className: "relative flex min-h-0 flex-1 px-0", children: [_jsx("div", { "data-chat-column": true, className: canvasApp
722
746
  ? 'flex min-h-0 shrink-0 justify-center'
723
747
  : 'flex min-h-0 flex-1 justify-center', style: canvasApp ? { width: split.width } : undefined, children: _jsx(ChatbotEnhanced, { className: "min-h-0 flex-1 bg-background md:max-w-5xl", onUpgrade: () => window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer'), surface: "plain", maxHeight: "100%", headerSlot: headerSlot, messages: messages, placeholder: activeAgent
724
- ? t('console.ai.askAgent', { agent: activeAgentLabel })
748
+ ? agentRouteName(activeAgent) === 'ask'
749
+ // The generic "Ask {agent}…" doubles to "Ask Ask…" for the data-query
750
+ // agent whose label IS "Ask". Use its purpose-built placeholder instead.
751
+ ? t('console.ai.askAnything')
752
+ : t('console.ai.askAgent', { agent: activeAgentLabel })
725
753
  : agentsLoading
726
754
  ? t('console.ai.loadingAgents')
727
755
  : t('console.ai.askAnything'), labels: {
@@ -734,6 +762,9 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
734
762
  toolRunning: t('console.ai.toolRunning'),
735
763
  toolAwaitingApproval: t('console.ai.toolAwaitingApproval'),
736
764
  toolFailed: t('console.ai.toolFailed'),
765
+ connectionWaiting: t('console.ai.connectionWaiting', { defaultValue: 'Waiting for server…' }),
766
+ connectionStalledLabel: t('console.ai.connectionStalled', { defaultValue: 'Still working…' }),
767
+ connectionOfflineLabel: t('console.ai.connectionOffline', { defaultValue: 'Connection lost — reconnecting…' }),
737
768
  toolDetailsHidden: t('console.ai.toolDetailsHidden'),
738
769
  copy: t('console.ai.copy'),
739
770
  copied: t('console.ai.copied'),
@@ -746,7 +777,7 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
746
777
  viewTrace: t('console.ai.viewTrace'),
747
778
  }, suggestions: suggestions, onSendMessage: handleSend, onClear: clear, hideClearBar: true, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: errorSuppressed ? undefined : error, enableMarkdown: true, onToolApprove: hitl.decide, toolDecisions: hitl.decisions, toolApproveLabel: "Approve & run", toolDenyLabel: "Reject", toolDenyReason: "Operator rejected from chat",
748
779
  // Build-tree "Open app": jump straight into the app the agent just built.
749
- onOpenBuiltApp: (appName) => navigate(`/apps/${encodeURIComponent(appName)}`), openBuiltAppLabel: t('console.ai.openBuiltApp', { defaultValue: 'Open app' }),
780
+ onOpenBuiltApp: (appName, appSegment) => navigate(`/apps/${encodeURIComponent(appSegment ?? appName)}`), openBuiltAppLabel: t('console.ai.openBuiltApp', { defaultValue: 'Open app' }),
750
781
  // Live lifecycle truth for draft cards: the server's pending count per
751
782
  // package, so reloaded conversations show Published/Publish honestly.
752
783
  fetchPendingDraftCount: fetchPendingDraftCount, onPublishDrafts: async (packageId) => {
@@ -799,7 +830,7 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
799
830
  }
800
831
  }, publishDraftsLabel: t('console.ai.publishDrafts', { defaultValue: 'Publish' }), publishedLabel: t('console.ai.published', { defaultValue: 'Published' }), nextStepsLabel: t('console.ai.nextSteps', { defaultValue: "What's next" }), planTitleLabel: t('console.ai.planTitle', { defaultValue: 'Proposed plan' }), planQuestionsLabel: t('console.ai.planQuestions', { defaultValue: 'Confirm before building' }), planAssumptionsLabel: t('console.ai.planAssumptions', { defaultValue: 'Assumptions' }), planApproveHintLabel: t('console.ai.planApproveHint', {
801
832
  defaultValue: 'Reply to approve or adjust this plan.',
802
- }), planApproveLabel: t('console.ai.planApprove', { defaultValue: 'Build it' }), planAdjustLabel: t('console.ai.planAdjust', { defaultValue: 'Adjust' }), planApproveMessage: t('console.ai.planApproveMessage', {
833
+ }), planApproveLabel: t('console.ai.planApprove', { defaultValue: 'Build it' }), planAdjustLabel: t('console.ai.planAdjust', { defaultValue: 'Adjust' }), planBuiltLabel: t('console.ai.planBuilt', { defaultValue: 'Built' }), planApproveMessage: t('console.ai.planApproveMessage', {
803
834
  defaultValue: 'Looks good — build it as proposed.',
804
835
  }), planApproveDefaultsMessage: t('console.ai.planApproveDefaultsMessage', {
805
836
  defaultValue: 'Build it with your best assumptions; use sensible defaults for the open questions.',
@@ -814,10 +845,10 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
814
845
  autoPublishDrafts: getRuntimeConfig().features.autoPublishAiBuilds,
815
846
  // ADR-0037 Live Canvas: open/refresh the draft-preview pane as the
816
847
  // agent's artifacts land; Preview buttons deep-link the same route.
817
- onDraftArtifacts: handleDraftArtifacts, onPreviewDraftApp: (appName, opts) => setCanvasApp({ name: appName, materialized: opts?.materialized === true }),
848
+ onDraftArtifacts: handleDraftArtifacts, onPreviewDraftApp: (appName, opts) => setCanvasApp({ name: appName, segment: opts?.appSegment, materialized: opts?.materialized === true }),
818
849
  // ADR-0045: build materialized → canvas leaves the draft overlay for
819
850
  // the real (unlisted) app; the reload shows live seed rows.
820
- onBuildMaterialized: handleBuildMaterialized, previewDraftLabel: t('console.ai.previewDraft', { defaultValue: 'Preview' }), "data-testid": "ai-chat-panel" }) }), canvasApp ? (_jsxs(_Fragment, { children: [_jsxs("div", { role: "separator", "aria-orientation": "vertical", "aria-label": t('console.ai.resizeSplit', { defaultValue: 'Resize chat and preview' }), tabIndex: 0, onPointerDown: split.onHandlePointerDown, onKeyDown: split.onHandleKeyDown, onDoubleClick: split.reset, "data-testid": "ai-chat-split-handle", className: cn('group relative hidden w-1.5 shrink-0 cursor-col-resize touch-none select-none md:block', 'focus:outline-none'), children: [_jsx("span", { "aria-hidden": true, className: "absolute inset-y-0 -left-1.5 -right-1.5" }), _jsx("span", { "aria-hidden": true, className: cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors', 'group-hover:bg-primary/60 group-focus-visible:bg-primary', split.dragging && 'bg-primary') })] }), _jsx(LiveCanvas, { appName: canvasApp.name, materialized: canvasApp.materialized, refreshKey: canvasRefreshKey, onClose: () => setCanvasApp(null) }), split.dragging ? _jsx("div", { className: "fixed inset-0 z-50 cursor-col-resize", "data-testid": "ai-chat-split-overlay" }) : null] })) : null] }));
851
+ onBuildMaterialized: handleBuildMaterialized, previewDraftLabel: t('console.ai.previewDraft', { defaultValue: 'Preview' }), "data-testid": "ai-chat-panel" }) }), canvasApp ? (_jsxs(_Fragment, { children: [_jsxs("div", { role: "separator", "aria-orientation": "vertical", "aria-label": t('console.ai.resizeSplit', { defaultValue: 'Resize chat and preview' }), tabIndex: 0, onPointerDown: split.onHandlePointerDown, onKeyDown: split.onHandleKeyDown, onDoubleClick: split.reset, "data-testid": "ai-chat-split-handle", className: cn('group relative hidden w-1.5 shrink-0 cursor-col-resize touch-none select-none md:block', 'focus:outline-none'), children: [_jsx("span", { "aria-hidden": true, className: "absolute inset-y-0 -left-1.5 -right-1.5" }), _jsx("span", { "aria-hidden": true, className: cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors', 'group-hover:bg-primary/60 group-focus-visible:bg-primary', split.dragging && 'bg-primary') })] }), _jsx(LiveCanvas, { appName: canvasApp.name, appSegment: canvasApp.segment, materialized: canvasApp.materialized, refreshKey: canvasRefreshKey, onClose: () => setCanvasApp(null) }), split.dragging ? _jsx("div", { className: "fixed inset-0 z-50 cursor-col-resize", "data-testid": "ai-chat-split-overlay" }) : null] })) : null] }));
821
852
  }
822
853
  function dataChatSuggestions(t) {
823
854
  return [
@@ -6,8 +6,14 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  export interface LiveCanvasProps {
9
- /** The drafted app to render (its `app` metadata name). */
9
+ /** The drafted app to render (its `app` metadata name) — used for display. */
10
10
  appName: string;
11
+ /**
12
+ * ADR-0048: the app's ROUTE SEGMENT — its package id (`app.<slug>`), which is
13
+ * globally unique, unlike the display name (two AI apps can both be `library`).
14
+ * The iframe URL keys on this; falls back to `appName` when absent.
15
+ */
16
+ appSegment?: string;
11
17
  /**
12
18
  * ADR-0045: the build was materialized — the app is live (real tables and
13
19
  * seed rows) but unlisted. The canvas then renders the REAL app URL: full
@@ -19,4 +25,4 @@ export interface LiveCanvasProps {
19
25
  refreshKey: number;
20
26
  onClose: () => void;
21
27
  }
22
- export declare function LiveCanvas({ appName, materialized, refreshKey, onClose }: LiveCanvasProps): import("react").JSX.Element;
28
+ export declare function LiveCanvas({ appName, appSegment, materialized, refreshKey, onClose }: LiveCanvasProps): import("react").JSX.Element;
@@ -37,15 +37,17 @@ function canvasSrc(appName, materialized) {
37
37
  const base = `${spaBase()}/apps/${encodeURIComponent(appName)}`;
38
38
  return materialized ? base : `${base}?preview=draft`;
39
39
  }
40
- export function LiveCanvas({ appName, materialized = false, refreshKey, onClose }) {
40
+ export function LiveCanvas({ appName, appSegment, materialized = false, refreshKey, onClose }) {
41
41
  const { t } = useObjectTranslation();
42
42
  const iframeRef = useRef(null);
43
+ // Route on the package-id segment (unique), display by name (friendly).
44
+ const routeSeg = appSegment && appSegment.length ? appSegment : appName;
43
45
  // Materialized world swap: changing src on the SAME iframe element
44
46
  // navigates it in place (no white-flash remount).
45
47
  useEffect(() => {
46
48
  if (!iframeRef.current)
47
49
  return;
48
- const next = canvasSrc(appName, materialized);
50
+ const next = canvasSrc(routeSeg, materialized);
49
51
  try {
50
52
  if (iframeRef.current.getAttribute('src') !== next)
51
53
  iframeRef.current.setAttribute('src', next);
@@ -53,7 +55,7 @@ export function LiveCanvas({ appName, materialized = false, refreshKey, onClose
53
55
  catch {
54
56
  /* not ready — the mount src covers it */
55
57
  }
56
- }, [appName, materialized]);
58
+ }, [routeSeg, materialized]);
57
59
  // Refresh in place (src reload) instead of remounting the iframe — keeps
58
60
  // the pane from flashing white on every invalidation.
59
61
  useEffect(() => {
@@ -74,5 +76,5 @@ export function LiveCanvas({ appName, materialized = false, refreshKey, onClose
74
76
  : t('console.ai.liveCanvas', {
75
77
  app: appName,
76
78
  defaultValue: 'Live preview — {{app}} (draft)',
77
- }) }), _jsx(Button, { size: "sm", variant: "ghost", onClick: onClose, "data-testid": "live-canvas-close", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }), _jsx("iframe", { ref: iframeRef, title: `Draft preview: ${appName}`, src: canvasSrc(appName, materialized), className: "h-full w-full flex-1 border-0 bg-background", "data-testid": "live-canvas-frame" })] }));
79
+ }) }), _jsx(Button, { size: "sm", variant: "ghost", onClick: onClose, "data-testid": "live-canvas-close", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }), _jsx("iframe", { ref: iframeRef, title: `Draft preview: ${appName}`, src: canvasSrc(routeSeg, materialized), className: "h-full w-full flex-1 border-0 bg-background", "data-testid": "live-canvas-frame" })] }));
78
80
  }
@@ -11,20 +11,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { useEffect } from 'react';
12
12
  import { useNavigationContext } from '../../context/NavigationContext';
13
13
  import { AppHeader } from '../../layout/AppHeader';
14
- import { useDiscovery } from '@object-ui/react';
14
+ import { useAiSurfaceEnabled } from '../../hooks/useAiSurface';
15
15
  import { useObjectTranslation } from '@object-ui/i18n';
16
16
  // Lightweight FAB stub — the heavy chat chunk graph only downloads on
17
17
  // first hover/click. See ../../layout/ConsoleChatbotFab.tsx.
18
18
  import { ConsoleChatbotFab } from '../../layout/ConsoleChatbotFab';
19
19
  export function HomeLayout({ children, userId }) {
20
20
  const { setContext } = useNavigationContext();
21
- const { isAiEnabled } = useDiscovery();
22
21
  const { t } = useObjectTranslation();
23
- // Render the chatbot whenever AI is reachable. If the developer has explicitly
24
- // configured `VITE_AI_BASE_URL`, trust that opt-in even when discovery
25
- // reports AI as disabled (e.g. framework started without `--preset full`).
26
- const aiBaseUrlConfigured = Boolean(import.meta.env?.VITE_AI_BASE_URL);
27
- const showChatbot = isAiEnabled || aiBaseUrlConfigured;
22
+ // Render the chatbot only when the server serves AI (or an explicit
23
+ // `VITE_AI_BASE_URL` opt-in is set) — same runtime signal as the rest of the
24
+ // console's AI surface. See useAiSurfaceEnabled.
25
+ const { enabled: showChatbot } = useAiSurfaceEnabled();
28
26
  useEffect(() => {
29
27
  setContext('home');
30
28
  }, [setContext]);
@@ -31,15 +31,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/componen
31
31
  import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText } from 'lucide-react';
32
32
  import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
33
33
  import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts';
34
- /** Resolve the AI service base, mirroring AiChatPage/ConsoleFloatingChatbot. */
35
- function resolveAiApiBase() {
36
- const env = import.meta.env ?? {};
37
- const fromEnv = env.VITE_AI_BASE_URL;
38
- if (fromEnv)
39
- return fromEnv.replace(/\/$/, '');
40
- const serverUrl = env.VITE_SERVER_URL ?? '';
41
- return `${serverUrl.replace(/\/$/, '')}/api/v1/ai`;
42
- }
34
+ import { resolveAiApiBase } from '../../hooks/useAiSurface';
43
35
  /**
44
36
  * Which AI home CTAs to surface, driven by the live agent catalog (the single
45
37
  * source of truth) — gated PER agent, because the community edition can be in
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
12
12
  import { useAuth } from '@object-ui/auth';
13
13
  import { useObjectTranslation } from '@object-ui/i18n';
14
14
  import { Loader2 } from 'lucide-react';
15
+ import { provisionProductionEnvironment } from './provisionEnvironment';
15
16
  /** Convert a display name to a URL-friendly slug */
16
17
  function nameToSlug(name) {
17
18
  return name
@@ -81,6 +82,19 @@ export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
81
82
  setError(null);
82
83
  try {
83
84
  const org = await createOrganization({ name: name.trim(), slug: slug.trim() });
85
+ // Born-with-env: eagerly ensure the new org's production environment so
86
+ // the user lands in a ready workspace with no onboarding-wizard detour.
87
+ // `createOrganization` already switched the active org; we also pass
88
+ // `organizationId` explicitly so the target is unambiguous. Idempotent +
89
+ // best-effort: a control plane that auto-provisions the env on create
90
+ // resolves this to `alreadyProvisioned`; a genuine failure falls through
91
+ // to the onboarding gate (lazy provision on first navigation).
92
+ try {
93
+ await provisionProductionEnvironment({ organizationId: org.id });
94
+ }
95
+ catch (provisionErr) {
96
+ console.warn('[CreateWorkspace] eager env provision failed; onboarding gate will provision lazily', provisionErr);
97
+ }
84
98
  onCreated?.(org);
85
99
  }
86
100
  catch (err) {
@@ -89,7 +103,7 @@ export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
89
103
  finally {
90
104
  setIsSubmitting(false);
91
105
  }
92
- }, [name, slug, createOrganization, onCreated]);
106
+ }, [name, slug, multiOrgDisabled, t, createOrganization, onCreated]);
93
107
  return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsx(DialogContent, { className: "sm:max-w-[425px]", "data-testid": "create-workspace-dialog", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('workspace.createTitle', { defaultValue: 'Create a workspace' }) }), _jsx(DialogDescription, { children: t('workspace.createDescription', {
94
108
  defaultValue: 'A workspace is a shared space for your team to collaborate.',
95
109
  }) })] }), _jsxs("div", { className: "grid gap-4 py-4", children: [_jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "workspace-name", children: t('workspace.nameLabel', { defaultValue: 'Workspace name' }) }), _jsx(Input, { id: "workspace-name", placeholder: t('workspace.namePlaceholder', { defaultValue: 'e.g., Acme Inc' }), value: name, onChange: (e) => setName(e.target.value), autoFocus: true, "data-testid": "workspace-name-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "workspace-slug", children: t('workspace.slugLabel', { defaultValue: 'URL slug' }) }), _jsx(Input, { id: "workspace-slug", placeholder: "acme-inc", value: slug, onChange: (e) => {
@@ -12,7 +12,7 @@ import { Avatar, AvatarImage, AvatarFallback, Button, Input, Empty, EmptyTitle,
12
12
  import { Plus, Search, Loader2 } from 'lucide-react';
13
13
  import { useAuth } from '@object-ui/auth';
14
14
  import { useObjectTranslation } from '@object-ui/i18n';
15
- import { useNavigate } from 'react-router-dom';
15
+ import { useNavigate, useSearchParams } from 'react-router-dom';
16
16
  import { CreateWorkspaceDialog } from './CreateWorkspaceDialog';
17
17
  import { resolveHomeUrl } from './resolveHomeUrl';
18
18
  function getOrgInitials(name) {
@@ -26,6 +26,14 @@ function getOrgInitials(name) {
26
26
  export function OrganizationsPage() {
27
27
  const { t } = useObjectTranslation();
28
28
  const navigate = useNavigate();
29
+ // Two deliberate ways to reach this page (vs the auto-skipping post-login
30
+ // redirect): `?manage=1` (avatar menu "My Organizations") shows the picker;
31
+ // `?create=1` (avatar menu "Create workspace") additionally opens the create
32
+ // dialog directly. Both suppress the single-org auto-skip below so a
33
+ // single-org user can actually reach "New organization" / the dialog.
34
+ const [searchParams] = useSearchParams();
35
+ const manageMode = searchParams.get('manage') === '1';
36
+ const wantsCreate = searchParams.get('create') === '1';
29
37
  const { organizations, activeOrganization, isOrganizationsLoading, switchOrganization, getAuthConfig, } = useAuth();
30
38
  const [query, setQuery] = useState('');
31
39
  const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -91,16 +99,27 @@ export function OrganizationsPage() {
91
99
  return;
92
100
  if (isOrganizationsLoading)
93
101
  return;
102
+ if (manageMode || wantsCreate)
103
+ return; // came to manage/create — don't bounce
94
104
  if (orgList.length !== 1)
95
105
  return;
96
106
  autoSelectedRef.current = true;
97
107
  void handleSelect(orgList[0]);
98
108
  // eslint-disable-next-line react-hooks/exhaustive-deps
99
- }, [isOrganizationsLoading, orgList.length]);
109
+ }, [isOrganizationsLoading, orgList.length, manageMode, wantsCreate]);
110
+ // Open the create dialog when arriving via the header "Create workspace"
111
+ // entry (`?create=1`). Guarded so closing the dialog doesn't re-open it.
112
+ const createOpenedRef = useRef(false);
113
+ useEffect(() => {
114
+ if (wantsCreate && !createOpenedRef.current) {
115
+ createOpenedRef.current = true;
116
+ setIsCreateOpen(true);
117
+ }
118
+ }, [wantsCreate]);
100
119
  // Show a spinner while we're either still loading, or about to auto-redirect
101
120
  // because there's only one org. This prevents the picker from briefly
102
121
  // flashing on screen for single-org users.
103
- const willAutoSelect = !isOrganizationsLoading && orgList.length === 1;
122
+ const willAutoSelect = !manageMode && !wantsCreate && !isOrganizationsLoading && orgList.length === 1;
104
123
  if (isOrganizationsLoading || willAutoSelect) {
105
124
  return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) }));
106
125
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * provisionEnvironment
3
+ *
4
+ * Eagerly ensure a freshly created organization has its **production**
5
+ * environment so a self-service "create another workspace" lands the user in a
6
+ * ready console — no onboarding-wizard detour.
7
+ *
8
+ * ObjectStack runs a 1-production-environment-per-organization model: an org's
9
+ * FIRST environment is born as its production env (allowed on every plan,
10
+ * including free). The cloud control plane exposes this as
11
+ * `POST /api/v1/cloud/environments`, which only needs a `displayName`; the org
12
+ * is resolved from `organizationId` (preferred) → the better-auth active org →
13
+ * the actor's first membership.
14
+ *
15
+ * Idempotent + best-effort by contract:
16
+ * - Some control planes auto-provision the production env on org create (the
17
+ * `auto-default-environment` plugin). This call then races that plugin and
18
+ * the loser gets a 403 `PRODUCTION_ENV_LIMIT` / 409 — which is SUCCESS for
19
+ * us (the org is already born-with-env), not a failure.
20
+ * - On a genuine failure (5xx / network) the caller swallows the error and
21
+ * the onboarding gate provisions the env lazily on first navigation.
22
+ *
23
+ * @module
24
+ */
25
+ /** Result of ensuring the org's production environment exists. */
26
+ export interface ProvisionedEnvironment {
27
+ /** Environment id (control-plane `sys_environment` row), when this call minted it. */
28
+ id?: string;
29
+ /** Opaque system hostname, e.g. `os-<shortId>.<rootDomain>` for production. */
30
+ hostname?: string;
31
+ /**
32
+ * True when the org already had its production env (the control plane
33
+ * provisioned it on create). The born-with-env contract is still satisfied.
34
+ */
35
+ alreadyProvisioned?: boolean;
36
+ }
37
+ /**
38
+ * Ensure the production environment exists for a just-created organization.
39
+ *
40
+ * Uses {@link createAuthenticatedFetch} so the request carries the Bearer token
41
+ * and the active-org `X-Tenant-ID` header; `organizationId` is also sent in the
42
+ * body so the target org is unambiguous even before the session active-org
43
+ * switch has propagated. The env is named `Production` to match the
44
+ * born-with-env convention used by the signup org.
45
+ *
46
+ * @throws on a genuine control-plane failure (5xx / network). A 403/409
47
+ * "already has its production env" is NOT an error — it resolves to
48
+ * `{ alreadyProvisioned: true }`.
49
+ */
50
+ export declare function provisionProductionEnvironment(opts: {
51
+ organizationId: string;
52
+ displayName?: string;
53
+ }): Promise<ProvisionedEnvironment>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * provisionEnvironment
3
+ *
4
+ * Eagerly ensure a freshly created organization has its **production**
5
+ * environment so a self-service "create another workspace" lands the user in a
6
+ * ready console — no onboarding-wizard detour.
7
+ *
8
+ * ObjectStack runs a 1-production-environment-per-organization model: an org's
9
+ * FIRST environment is born as its production env (allowed on every plan,
10
+ * including free). The cloud control plane exposes this as
11
+ * `POST /api/v1/cloud/environments`, which only needs a `displayName`; the org
12
+ * is resolved from `organizationId` (preferred) → the better-auth active org →
13
+ * the actor's first membership.
14
+ *
15
+ * Idempotent + best-effort by contract:
16
+ * - Some control planes auto-provision the production env on org create (the
17
+ * `auto-default-environment` plugin). This call then races that plugin and
18
+ * the loser gets a 403 `PRODUCTION_ENV_LIMIT` / 409 — which is SUCCESS for
19
+ * us (the org is already born-with-env), not a failure.
20
+ * - On a genuine failure (5xx / network) the caller swallows the error and
21
+ * the onboarding gate provisions the env lazily on first navigation.
22
+ *
23
+ * @module
24
+ */
25
+ import { createAuthenticatedFetch } from '@object-ui/auth';
26
+ import { getCloudBase } from '../../runtime-config';
27
+ /**
28
+ * Ensure the production environment exists for a just-created organization.
29
+ *
30
+ * Uses {@link createAuthenticatedFetch} so the request carries the Bearer token
31
+ * and the active-org `X-Tenant-ID` header; `organizationId` is also sent in the
32
+ * body so the target org is unambiguous even before the session active-org
33
+ * switch has propagated. The env is named `Production` to match the
34
+ * born-with-env convention used by the signup org.
35
+ *
36
+ * @throws on a genuine control-plane failure (5xx / network). A 403/409
37
+ * "already has its production env" is NOT an error — it resolves to
38
+ * `{ alreadyProvisioned: true }`.
39
+ */
40
+ export async function provisionProductionEnvironment(opts) {
41
+ const authFetch = createAuthenticatedFetch();
42
+ const res = await authFetch(`${getCloudBase()}/api/v1/cloud/environments`, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({
46
+ displayName: opts.displayName ?? 'Production',
47
+ organizationId: opts.organizationId,
48
+ }),
49
+ });
50
+ if (!res.ok) {
51
+ // 403 PRODUCTION_ENV_LIMIT / 409 ⇒ the org already owns its (one) production
52
+ // env — born-with-env is satisfied, so this is success, not a failure.
53
+ if (res.status === 403 || res.status === 409) {
54
+ return { alreadyProvisioned: true };
55
+ }
56
+ throw new Error(`Failed to provision production environment (status ${res.status})`);
57
+ }
58
+ // The control plane wraps payloads as `{ success, data }`; tolerate both.
59
+ const body = (await res.json().catch(() => ({})));
60
+ if (body && typeof body === 'object' && 'data' in body && body.data) {
61
+ return body.data;
62
+ }
63
+ return body ?? {};
64
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * EnvironmentEntitlementDialog — a friendly upgrade / limit dialog shown instead
3
+ * of a raw red error toast when an environment-create is gated by plan or
4
+ * capacity (DEV_ENV_PLAN_LOCKED / DEV_ENV_LIMIT / PRODUCTION_ENV_LIMIT).
5
+ *
6
+ * Driven by a single {@link EntitlementDialogSpec}, opened from two places:
7
+ * • proactively, from the env-list toolbar (a free-plan org clicking
8
+ * "Add environment" — see EnvironmentListToolbar), and
9
+ * • reactively, from the action runtime's apiHandler when the create POST
10
+ * comes back with an entitlement 403 (the safety net).
11
+ *
12
+ * The CTA renders as an anchor (not an SPA navigation) so a control-plane URL
13
+ * like `/settings/billing` always lands on the real page regardless of the
14
+ * console's own router. Relative URLs resolve against the control-plane origin
15
+ * (`apiBase`); absolute / mailto URLs are used as-is.
16
+ */
17
+ import type { EntitlementDialogSpec } from './entitlements';
18
+ export interface EntitlementDialogState {
19
+ open: boolean;
20
+ spec?: EntitlementDialogSpec;
21
+ }
22
+ /** Resolve a CTA URL to a concrete href + whether it should open in a new tab. */
23
+ export declare function resolveCtaHref(url: string, apiBase: string): {
24
+ href: string;
25
+ external: boolean;
26
+ };
27
+ interface Props {
28
+ state: EntitlementDialogState;
29
+ /** Control-plane origin used to resolve relative CTA URLs. */
30
+ apiBase: string;
31
+ onOpenChange: (open: boolean) => void;
32
+ }
33
+ export declare function EnvironmentEntitlementDialog({ state, apiBase, onOpenChange }: Props): import("react").JSX.Element;
34
+ export {};