@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.
- package/CHANGELOG.md +560 -0
- package/dist/console/AppContent.js +23 -17
- package/dist/console/ConsoleShell.d.ts +16 -0
- package/dist/console/ConsoleShell.js +43 -2
- package/dist/console/ai/AiChatPage.js +47 -16
- package/dist/console/ai/LiveCanvas.d.ts +8 -2
- package/dist/console/ai/LiveCanvas.js +6 -4
- package/dist/console/home/HomeLayout.js +5 -7
- package/dist/console/home/HomePage.js +1 -9
- package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
- package/dist/console/organizations/OrganizationsPage.js +22 -3
- package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
- package/dist/console/organizations/provisionEnvironment.js +64 -0
- package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
- package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
- package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
- package/dist/environment/EnvironmentListToolbar.js +59 -0
- package/dist/environment/entitlements.d.ts +90 -0
- package/dist/environment/entitlements.js +91 -0
- package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
- package/dist/environment/useEnvironmentEntitlements.js +108 -0
- package/dist/hooks/useActionModal.js +15 -1
- package/dist/hooks/useAiSurface.d.ts +59 -0
- package/dist/hooks/useAiSurface.js +78 -0
- package/dist/hooks/useChatConversation.d.ts +30 -0
- package/dist/hooks/useChatConversation.js +63 -0
- package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
- package/dist/hooks/useConsoleActionRuntime.js +42 -10
- package/dist/index.d.ts +5 -2
- package/dist/index.js +10 -2
- package/dist/layout/AppHeader.js +28 -4
- package/dist/layout/ConsoleFloatingChatbot.d.ts +6 -4
- package/dist/layout/ConsoleFloatingChatbot.js +41 -10
- package/dist/layout/ConsoleLayout.js +5 -6
- package/dist/layout/ContextSelectors.js +59 -35
- package/dist/layout/agentPicker.d.ts +56 -0
- package/dist/layout/agentPicker.js +40 -0
- package/dist/preview/CommitTimeline.d.ts +15 -0
- package/dist/preview/CommitTimeline.js +82 -0
- package/dist/preview/DraftPreviewBar.js +20 -7
- package/dist/preview/UnpublishedAppBar.js +11 -7
- package/dist/preview/commitHistory.d.ts +28 -0
- package/dist/preview/commitHistory.js +48 -0
- package/dist/providers/ExpressionProvider.js +9 -3
- package/dist/providers/MetadataProvider.js +9 -0
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js +1 -1
- package/dist/utils/recordFormNavigation.d.ts +60 -0
- package/dist/utils/recordFormNavigation.js +35 -0
- package/dist/utils/resolvePageVarTokens.d.ts +31 -0
- package/dist/utils/resolvePageVarTokens.js +72 -0
- package/dist/views/CreateViewDialog.js +14 -1
- package/dist/views/FlowRunner.d.ts +2 -30
- package/dist/views/FlowRunner.js +18 -50
- package/dist/views/ObjectView.js +26 -12
- package/dist/views/ScreenView.d.ts +70 -0
- package/dist/views/ScreenView.js +73 -0
- package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
- package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
- package/dist/views/metadata-admin/DirectoryPage.js +2 -14
- package/dist/views/metadata-admin/JsonSourceEditor.d.ts +3 -1
- package/dist/views/metadata-admin/JsonSourceEditor.js +21 -3
- package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
- package/dist/views/metadata-admin/PackagesPage.js +58 -5
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
- package/dist/views/metadata-admin/ResourceEditPage.js +83 -24
- package/dist/views/metadata-admin/ResourceListPage.js +28 -19
- package/dist/views/metadata-admin/StudioHomePage.js +6 -14
- package/dist/views/metadata-admin/anchors.js +20 -2
- package/dist/views/metadata-admin/createBody.d.ts +26 -0
- package/dist/views/metadata-admin/createBody.js +30 -0
- package/dist/views/metadata-admin/i18n.js +108 -2
- package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +10 -2
- package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +136 -8
- package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +99 -4
- package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
- package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
- package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
- package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
- package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
- package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
- package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +81 -4
- package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
- package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
- package/dist/views/metadata-admin/inspectors/ObjectDefaultInspector.js +5 -4
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +47 -12
- package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.d.ts +1 -1
- package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.js +60 -2
- package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
- package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
- package/dist/views/metadata-admin/inspectors/_shared.d.ts +5 -1
- package/dist/views/metadata-admin/inspectors/_shared.js +2 -2
- package/dist/views/metadata-admin/inspectors/datasetFilterCondition.d.ts +24 -0
- package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +102 -0
- package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +67 -11
- package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
- package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
- package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
- package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
- package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
- package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
- package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
- package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
- package/dist/views/metadata-admin/issuePath.d.ts +22 -0
- package/dist/views/metadata-admin/issuePath.js +65 -0
- package/dist/views/metadata-admin/package-scope.d.ts +41 -0
- package/dist/views/metadata-admin/package-scope.js +59 -0
- package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
- package/dist/views/metadata-admin/previews/DatasetPreview.js +21 -5
- package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +26 -1
- package/dist/views/metadata-admin/previews/FlowCanvas.js +143 -16
- package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
- package/dist/views/metadata-admin/previews/FlowPreview.js +47 -7
- package/dist/views/metadata-admin/previews/FlowSimulatorPanel.js +37 -3
- package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
- package/dist/views/metadata-admin/previews/PagePreview.js +112 -3
- package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
- package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
- package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
- package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
- package/dist/views/metadata-admin/previews/ScreenPreview.d.ts +38 -0
- package/dist/views/metadata-admin/previews/ScreenPreview.js +61 -0
- package/dist/views/metadata-admin/previews/flow-canvas-layout.d.ts +14 -0
- package/dist/views/metadata-admin/previews/flow-canvas-layout.js +37 -0
- package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +17 -1
- package/dist/views/metadata-admin/previews/flow-canvas-parts.js +23 -6
- package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
- package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
- package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
- package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
- package/dist/views/metadata-admin/previews/object-fields-io.d.ts +21 -0
- package/dist/views/metadata-admin/previews/object-fields-io.js +37 -2
- package/dist/views/metadata-admin/previews/screen-spec.d.ts +43 -0
- package/dist/views/metadata-admin/previews/screen-spec.js +108 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +20 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.d.ts +7 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +76 -2
- package/dist/views/metadata-admin/previews/simulator/flow-simulator.d.ts +32 -3
- package/dist/views/metadata-admin/previews/simulator/flow-simulator.js +119 -9
- 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,
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
? {
|
|
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
|
-
?
|
|
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(
|
|
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
|
-
}, [
|
|
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(
|
|
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 {
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
const
|
|
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
|
-
|
|
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 {};
|