@object-ui/app-shell 7.1.0 → 7.3.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 (107) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/dist/components/ManagedByBadge.js +1 -1
  3. package/dist/console/AppContent.js +9 -15
  4. package/dist/console/ConsoleShell.d.ts +16 -0
  5. package/dist/console/ConsoleShell.js +43 -2
  6. package/dist/console/ai/AiChatPage.js +64 -14
  7. package/dist/console/ai/BuildDebugDrawer.d.ts +20 -0
  8. package/dist/console/ai/BuildDebugDrawer.js +75 -0
  9. package/dist/console/ai/buildDebugApi.d.ts +94 -0
  10. package/dist/console/ai/buildDebugApi.js +16 -0
  11. package/dist/console/home/HomeLayout.js +5 -7
  12. package/dist/console/home/HomePage.js +1 -9
  13. package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
  14. package/dist/console/organizations/OrganizationsPage.js +32 -4
  15. package/dist/console/organizations/manage/OrganizationLayout.js +1 -1
  16. package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
  17. package/dist/console/organizations/provisionEnvironment.js +64 -0
  18. package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
  19. package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
  20. package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
  21. package/dist/environment/EnvironmentListToolbar.js +59 -0
  22. package/dist/environment/entitlements.d.ts +90 -0
  23. package/dist/environment/entitlements.js +91 -0
  24. package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
  25. package/dist/environment/useEnvironmentEntitlements.js +108 -0
  26. package/dist/hooks/useActionModal.js +15 -1
  27. package/dist/hooks/useAiSurface.d.ts +59 -0
  28. package/dist/hooks/useAiSurface.js +78 -0
  29. package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
  30. package/dist/hooks/useConsoleActionRuntime.js +36 -8
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.js +5 -1
  33. package/dist/layout/AppHeader.js +30 -5
  34. package/dist/layout/ConsoleFloatingChatbot.js +22 -4
  35. package/dist/layout/ConsoleLayout.js +5 -6
  36. package/dist/layout/ContextSelectors.js +0 -19
  37. package/dist/layout/WorkspaceSwitcher.d.ts +14 -0
  38. package/dist/layout/WorkspaceSwitcher.js +76 -0
  39. package/dist/preview/DraftPreviewBar.js +20 -7
  40. package/dist/providers/ExpressionProvider.js +9 -3
  41. package/dist/utils/index.d.ts +2 -2
  42. package/dist/utils/index.js +1 -1
  43. package/dist/utils/managedByEmptyState.d.ts +1 -1
  44. package/dist/utils/managedByEmptyState.js +20 -2
  45. package/dist/utils/recordFormNavigation.d.ts +60 -0
  46. package/dist/utils/recordFormNavigation.js +35 -0
  47. package/dist/utils/resolvePageVarTokens.d.ts +31 -0
  48. package/dist/utils/resolvePageVarTokens.js +72 -0
  49. package/dist/views/CreateViewDialog.js +14 -1
  50. package/dist/views/ObjectView.js +27 -13
  51. package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
  52. package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
  53. package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
  54. package/dist/views/metadata-admin/PackagesPage.js +49 -4
  55. package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
  56. package/dist/views/metadata-admin/ResourceEditPage.js +36 -4
  57. package/dist/views/metadata-admin/ResourceListPage.js +25 -10
  58. package/dist/views/metadata-admin/StudioHomePage.js +1 -5
  59. package/dist/views/metadata-admin/createBody.d.ts +26 -0
  60. package/dist/views/metadata-admin/createBody.js +30 -0
  61. package/dist/views/metadata-admin/i18n.js +20 -2
  62. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +8 -0
  63. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +17 -3
  64. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +16 -2
  65. package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
  66. package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
  67. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
  68. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
  69. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
  70. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
  71. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +15 -3
  72. package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
  73. package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
  74. package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
  75. package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
  76. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +6 -1
  77. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
  78. package/dist/views/metadata-admin/inspectors/flow-node-config.js +21 -10
  79. package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
  80. package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
  81. package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
  82. package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
  83. package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
  84. package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
  85. package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
  86. package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
  87. package/dist/views/metadata-admin/package-scope.d.ts +9 -19
  88. package/dist/views/metadata-admin/package-scope.js +11 -25
  89. package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
  90. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +22 -3
  91. package/dist/views/metadata-admin/previews/FlowCanvas.js +45 -6
  92. package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
  93. package/dist/views/metadata-admin/previews/FlowPreview.js +42 -30
  94. package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
  95. package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
  96. package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
  97. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
  98. package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
  99. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
  100. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +5 -3
  101. package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
  102. package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
  103. package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
  104. package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
  105. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +9 -0
  106. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +4 -2
  107. package/package.json +38 -38
@@ -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,9 +17,9 @@ 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';
21
- import { PanelLeft, PanelLeftClose, PanelLeftOpen, Share2 } from 'lucide-react';
22
- import { ChatbotEnhanced, useAgents, useObjectChat, useHitlInChat, resolveDefaultAgentName, PLATFORM_DEFAULT_AGENT, agentRouteName, resolveAgentParam, isBuiltinAgentName, isBuildAgent, isAskAgent, publishHealthFromResponse, detectDraftResult, detectProposedPlan, buildProgressFromDraftReview, } from '@object-ui/plugin-chatbot';
20
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Empty, EmptyTitle, EmptyDescription, cn, } from '@object-ui/components';
21
+ import { Bug, PanelLeft, PanelLeftClose, PanelLeftOpen, Share2 } from 'lucide-react';
22
+ import { ChatbotEnhanced, useAgents, useObjectChat, useAiModels, 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';
24
24
  import { fetchPendingDraftCount } from '../../preview/draftStatus';
25
25
  import { emitMetadataRefresh } from '../../assistant/assistantBus';
@@ -30,6 +30,7 @@ import { fetchConversation, sanitizeChatMessagesForCache, useChatConversation, w
30
30
  import { useReconcileOnError } from '../../hooks/useReconcileOnError';
31
31
  import { ConversationsSidebar } from './ConversationsSidebar';
32
32
  import { LiveCanvas } from './LiveCanvas';
33
+ import { BuildDebugDrawer } from './BuildDebugDrawer';
33
34
  const DEFAULT_AI_PATH = '/api/v1/ai';
34
35
  function partString(part, key) {
35
36
  const value = part[key];
@@ -363,6 +364,11 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
363
364
  // existing conversation, and the flag is stripped once the fresh id is
364
365
  // mirrored into the URL.
365
366
  const forceNewConversation = searchParams.get('new') !== null;
367
+ // ADR-0070 "Edit with AI": the package the user opened to edit (from the app
368
+ // list's per-app action). Forwarded to the build agent as `context.packageId`
369
+ // so its metadata reads scope to that app and edits bind to it from the first
370
+ // message (the agent seeds it as the conversation's active package).
371
+ const editPackageId = searchParams.get('package')?.trim() || undefined;
366
372
  const navigate = useNavigate();
367
373
  const { setContext } = useNavigationContext();
368
374
  useEffect(() => {
@@ -371,8 +377,15 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
371
377
  const apiBase = useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
372
378
  const env = import.meta.env ?? {};
373
379
  const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
374
- const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
380
+ const { agents, isLoading: agentsLoading, error: agentsError, refetch: refetchAgents } = useAgents({ apiBase });
375
381
  const catalogNames = useMemo(() => agents.map((a) => a.name), [agents]);
382
+ // Catalog resolved with no agent to talk to. The `/ai` route guard already
383
+ // redirects when discovery reports AI unavailable (Community Edition), so this
384
+ // is the secondary safety net: a deployment that reports AI enabled but serves
385
+ // no agent (misconfig), a transient `/agents` failure, or a `VITE_AI_BASE_URL`
386
+ // server that returns an empty list. Either way, degrade to a graceful state
387
+ // instead of the agent-less echo chat (autoResponse) that ChatPane falls into.
388
+ const noAgents = !agentsLoading && agents.length === 0;
376
389
  // Is the first path segment an agent? It is when it resolves to one (friendly
377
390
  // alias / new id / legacy id). When it doesn't, it's a legacy bare
378
391
  // `/ai/:conversationId` link (redirected below). `undefined` = catalog still
@@ -484,6 +497,7 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
484
497
  const [refreshKey, setRefreshKey] = useState(0);
485
498
  const [titleHints, setTitleHints] = useState({});
486
499
  const [shareOpen, setShareOpen] = useState(false);
500
+ const [debugOpen, setDebugOpen] = useState(false);
487
501
  const [mobileChatsOpen, setMobileChatsOpen] = useState(false);
488
502
  const { collapsed: chatsCollapsed, toggle: toggleChatsCollapsed, handleCanvasOpenChange, } = useCollapsibleChatsList();
489
503
  // Keyboard shortcuts (ChatGPT/Claude parity): ⌘⇧O new chat, ⌘⇧S toggle list.
@@ -598,13 +612,29 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
598
612
  void t1;
599
613
  void t2;
600
614
  }, [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'}`) })] })] }));
615
+ 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
616
+ ? t('console.ai.showChats', { defaultValue: 'Show chats' })
617
+ : t('console.ai.hideChats', { defaultValue: 'Hide chats' }), title: chatsCollapsed
618
+ ? t('console.ai.showChats', { defaultValue: 'Show chats' })
619
+ : 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 })), conversationId && isBuildAgent(activeAgent) && (_jsx(BuildDebugDrawer, { apiBase: apiBase, conversationId: conversationId, open: debugOpen, onOpenChange: setDebugOpen })), _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, editPackageId: editPackageId, initialMessages: initialMessages, onSent: handleSent, onShare: () => setShareOpen(true), onDebug: () => setDebugOpen(true), showDebug: isBuildAgent(activeAgent), onCanvasOpenChange: handleCanvasOpenChange }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }))] }));
606
620
  }
607
- function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, apiBase, conversationId, initialMessages, onSent, onShare, onCanvasOpenChange, }) {
621
+ /**
622
+ * Graceful state for `/ai` when the agent catalog resolved empty — shown
623
+ * instead of an agent-less echo chat. `hasError` distinguishes "AI not enabled
624
+ * on this deployment" (Community Edition) from "couldn't reach the AI service"
625
+ * (offline/misconfig), which also offers a retry. Either way there's a way out
626
+ * (back to home), so the route never dead-ends.
627
+ */
628
+ function AiUnavailable({ hasError, onRetry, onHome, t, }) {
629
+ 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
630
+ ? t('console.ai.unavailableError', {
631
+ defaultValue: "Couldn't reach the AI service. It may be temporarily offline — try again, or head back home.",
632
+ })
633
+ : t('console.ai.unavailableDescription', {
634
+ defaultValue: "This deployment doesn't have an AI assistant enabled. Everything else works as usual.",
635
+ }) }), _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' }) })] })] }) }));
636
+ }
637
+ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, apiBase, conversationId, editPackageId, initialMessages, onSent, onShare, onDebug, showDebug, onCanvasOpenChange, }) {
608
638
  const { t } = useObjectTranslation();
609
639
  const navigate = useNavigate();
610
640
  // The agent dropdown is a LAUNCHER now (not an in-surface mode toggle): it
@@ -669,9 +699,18 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
669
699
  // ADR-0013 D2: reconcile a stream-transport failure instead of blindly
670
700
  // retrying. Shared across chat surfaces — see useReconcileOnError.
671
701
  const { errorSuppressed, handleChatError, setMessagesRef, resetSuppression } = useReconcileOnError({ chatApi, conversationId });
702
+ // ADR-0028: plan-filtered selectable AI model on the full-page Build/Ask
703
+ // surface. The footer <select> in ChatbotEnhanced renders only for 2+ models,
704
+ // so free / single-model envs see nothing. Mirrors ConsoleFloatingChatbot;
705
+ // the chosen model rides each request via useObjectChat's `model` below.
706
+ const { models: aiModels, defaultModelId } = useAiModels({ apiBase });
707
+ const [selectedModelId, setSelectedModelId] = useState(undefined);
708
+ const effectiveModelId = selectedModelId ?? defaultModelId;
672
709
  const { messages, isLoading, error, sendMessage, stop, reload, clear, setMessages, } = useObjectChat({
673
710
  api: chatApi,
674
711
  conversationId,
712
+ // ADR-0028: the user's picked model (or the env default) rides each request.
713
+ model: effectiveModelId,
675
714
  onError: handleChatError,
676
715
  body: {
677
716
  context: {
@@ -680,6 +719,9 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
680
719
  // Tell the agent the environment's publish posture so its narration
681
720
  // matches reality (an auto-published build is live, not "to publish").
682
721
  autoPublishAiBuilds: getRuntimeConfig().features.autoPublishAiBuilds,
722
+ // ADR-0070 "Edit with AI": scope the build agent to the app the user
723
+ // opened to edit. Cloud seeds it as the conversation's active package.
724
+ ...(editPackageId ? { packageId: editPackageId } : {}),
683
725
  },
684
726
  },
685
727
  initialMessages: hydrated,
@@ -718,11 +760,15 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
718
760
  sendMessage(content, files);
719
761
  onSent(content);
720
762
  }, [sendMessage, onSent]);
721
- const headerSlot = (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2 border-b border-border/50 px-4 pb-2 pt-3 sm:px-6", children: [_jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: showAgentLauncher ? (_jsxs(Select, { value: activeAgent, onValueChange: (name) => navigate(`/ai/${agentRouteName(name)}`), disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-auto min-w-0 border-0 bg-transparent px-1.5 text-xs shadow-none hover:bg-accent focus:ring-0 focus:ring-offset-0 focus-visible:ring-1 focus-visible:ring-border/80 focus-visible:ring-offset-0 sm:min-w-[160px]", "data-testid": "ai-chat-agent-picker", "aria-label": t('console.ai.switchAssistant', { defaultValue: 'Switch assistant' }), children: _jsx(SelectValue, { placeholder: t('console.ai.chooseAgent', { defaultValue: 'Choose assistant…' }) }) }), _jsx(SelectContent, { align: "start", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: localizeAgentLabel(t, agent.name, agent.label) }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[260px]", children: agent.description })) : null] }, agent.name))) })] })) : (_jsx("span", { className: "truncate text-xs font-medium text-foreground/85", children: activeAgentLabel })) }), _jsx("div", { className: "flex shrink-0 items-center gap-1", children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7 text-muted-foreground hover:text-foreground", onClick: onShare, disabled: !conversationId, "aria-label": t('console.ai.share'), "data-testid": "ai-chat-share-button", title: conversationId ? t('console.ai.shareTitle') : t('console.ai.shareDisabledTitle'), children: _jsx(Share2, { className: "h-3.5 w-3.5" }) }) }), agentsError ? (_jsx("span", { className: "basis-full text-[10px] text-amber-700 dark:text-amber-400", title: agentsError.message, children: t('console.ai.offlineDemoMode') })) : null] }));
763
+ const headerSlot = (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2 border-b border-border/50 px-4 pb-2 pt-3 sm:px-6", children: [_jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: showAgentLauncher ? (_jsxs(Select, { value: activeAgent, onValueChange: (name) => navigate(`/ai/${agentRouteName(name)}`), disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-auto min-w-0 border-0 bg-transparent px-1.5 text-xs shadow-none hover:bg-accent focus:ring-0 focus:ring-offset-0 focus-visible:ring-1 focus-visible:ring-border/80 focus-visible:ring-offset-0 sm:min-w-[160px]", "data-testid": "ai-chat-agent-picker", "aria-label": t('console.ai.switchAssistant', { defaultValue: 'Switch assistant' }), children: _jsx(SelectValue, { placeholder: t('console.ai.chooseAgent', { defaultValue: 'Choose assistant…' }) }) }), _jsx(SelectContent, { align: "start", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: localizeAgentLabel(t, agent.name, agent.label) }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[260px]", children: agent.description })) : null] }, agent.name))) })] })) : (_jsx("span", { className: "truncate text-xs font-medium text-foreground/85", children: activeAgentLabel })) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [showDebug && onDebug ? (_jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7 text-muted-foreground hover:text-foreground", onClick: onDebug, disabled: !conversationId, "aria-label": "Build Doctor", "data-testid": "ai-chat-debug-button", title: conversationId ? 'Build Doctor — what actually landed?' : 'Send a message first', children: _jsx(Bug, { className: "h-3.5 w-3.5" }) })) : null, _jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7 text-muted-foreground hover:text-foreground", onClick: onShare, disabled: !conversationId, "aria-label": t('console.ai.share'), "data-testid": "ai-chat-share-button", title: conversationId ? t('console.ai.shareTitle') : t('console.ai.shareDisabledTitle'), children: _jsx(Share2, { className: "h-3.5 w-3.5" }) })] }), agentsError ? (_jsx("span", { className: "basis-full text-[10px] text-amber-700 dark:text-amber-400", title: agentsError.message, children: t('console.ai.offlineDemoMode') })) : null] }));
722
764
  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
723
765
  ? 'flex min-h-0 shrink-0 justify-center'
724
766
  : '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
725
- ? t('console.ai.askAgent', { agent: activeAgentLabel })
767
+ ? agentRouteName(activeAgent) === 'ask'
768
+ // The generic "Ask {agent}…" doubles to "Ask Ask…" for the data-query
769
+ // agent whose label IS "Ask". Use its purpose-built placeholder instead.
770
+ ? t('console.ai.askAnything')
771
+ : t('console.ai.askAgent', { agent: activeAgentLabel })
726
772
  : agentsLoading
727
773
  ? t('console.ai.loadingAgents')
728
774
  : t('console.ai.askAnything'), labels: {
@@ -748,7 +794,11 @@ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, chatApi, ap
748
794
  stopResponse: t('console.ai.stopResponse'),
749
795
  trace: t('console.ai.trace'),
750
796
  viewTrace: t('console.ai.viewTrace'),
751
- }, 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",
797
+ },
798
+ // ADR-0028: selectable AI model — ChatbotEnhanced renders the footer
799
+ // <select> only when 2+ models are offered (free / single-model envs
800
+ // see none). The picked model flows to useObjectChat above.
801
+ models: aiModels, selectedModelId: effectiveModelId, onModelChange: setSelectedModelId, 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",
752
802
  // Build-tree "Open app": jump straight into the app the agent just built.
753
803
  onOpenBuiltApp: (appName, appSegment) => navigate(`/apps/${encodeURIComponent(appSegment ?? appName)}`), openBuiltAppLabel: t('console.ai.openBuiltApp', { defaultValue: 'Open app' }),
754
804
  // Live lifecycle truth for draft cards: the server's pending count per
@@ -0,0 +1,20 @@
1
+ /**
2
+ * BuildDebugDrawer — self-serve "what actually landed?" panel for a build
3
+ * conversation. Opens a right-side sheet, calls the admin build-debug endpoint
4
+ * (see buildDebugApi.ts), and renders the reconciliation: agent-CLAIMED vs LIVE
5
+ * `sys_metadata`. The headline is the verdict + the two failure modes the chat
6
+ * can't show — PROPOSED-BUT-ORPHANED (a confirm card no turn applied) and
7
+ * CLAIMED-BUT-MISSING (said applied, isn't live). Read-only; no DB credentials.
8
+ *
9
+ * Distinct from `useReconcileOnError` (ADR-0013 D2 stream-failure recovery) —
10
+ * this reconciles the BUILD against live metadata, not a transport drop.
11
+ */
12
+ import React from 'react';
13
+ interface BuildDebugDrawerProps {
14
+ apiBase: string;
15
+ conversationId?: string;
16
+ open: boolean;
17
+ onOpenChange: (open: boolean) => void;
18
+ }
19
+ export declare function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange }: BuildDebugDrawerProps): React.JSX.Element;
20
+ export {};
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * BuildDebugDrawer — self-serve "what actually landed?" panel for a build
5
+ * conversation. Opens a right-side sheet, calls the admin build-debug endpoint
6
+ * (see buildDebugApi.ts), and renders the reconciliation: agent-CLAIMED vs LIVE
7
+ * `sys_metadata`. The headline is the verdict + the two failure modes the chat
8
+ * can't show — PROPOSED-BUT-ORPHANED (a confirm card no turn applied) and
9
+ * CLAIMED-BUT-MISSING (said applied, isn't live). Read-only; no DB credentials.
10
+ *
11
+ * Distinct from `useReconcileOnError` (ADR-0013 D2 stream-failure recovery) —
12
+ * this reconciles the BUILD against live metadata, not a transport drop.
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from '@object-ui/components';
16
+ import { Bug, CheckCircle2, AlertTriangle, XCircle, Loader2, CircleSlash } from 'lucide-react';
17
+ import { fetchBuildDebug } from './buildDebugApi';
18
+ export function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange }) {
19
+ const [report, setReport] = useState(null);
20
+ const [loading, setLoading] = useState(false);
21
+ const [error, setError] = useState(null);
22
+ useEffect(() => {
23
+ if (!open || !conversationId)
24
+ return;
25
+ let cancelled = false;
26
+ setLoading(true);
27
+ setError(null);
28
+ setReport(null);
29
+ fetchBuildDebug(apiBase, conversationId)
30
+ .then((r) => {
31
+ if (cancelled)
32
+ return;
33
+ if (!r)
34
+ setError('Not available — the conversation was not found or you are not authorized.');
35
+ else
36
+ setReport(r);
37
+ })
38
+ .catch((e) => {
39
+ if (!cancelled)
40
+ setError(e instanceof Error ? e.message : String(e));
41
+ })
42
+ .finally(() => {
43
+ if (!cancelled)
44
+ setLoading(false);
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ }, [open, conversationId, apiBase]);
50
+ const rec = report?.reconciliation;
51
+ const problems = rec ? rec.orphaned.length + rec.missing.length + rec.errors.length : 0;
52
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", className: "w-full overflow-y-auto sm:max-w-xl", children: [_jsxs(SheetHeader, { children: [_jsxs(SheetTitle, { className: "flex items-center gap-2", children: [_jsx(Bug, { className: "h-4 w-4" }), " Build Doctor"] }), _jsx(SheetDescription, { children: "What the agent claimed vs what is actually live. Read-only diagnostic." })] }), _jsxs("div", { className: "mt-4 space-y-4 text-sm", children: [loading && (_jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " Reconciling\u2026"] })), error && !loading && (_jsx("div", { className: "rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive", children: error })), report && !loading && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground", children: [_jsx("div", { className: "font-medium text-foreground", children: report.title ?? '(untitled)' }), _jsxs("div", { className: "mt-1", children: [report.summary.userTurns, " turn(s) \u00B7 ", report.summary.messages, " msgs \u00B7", ' ', report.summary.totalTokens.toLocaleString(), " tok \u00B7", ' ', (report.summary.llmMs / 1000).toFixed(1), "s LLM", report.summary.models.length ? ` · ${report.summary.models.join(', ')}` : ''] })] }), rec && (_jsxs("div", { className: rec.ok
53
+ ? 'flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 p-3 text-emerald-700 dark:text-emerald-400'
54
+ : 'flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive', children: [rec.ok ? _jsx(CheckCircle2, { className: "h-4 w-4" }) : _jsx(AlertTriangle, { className: "h-4 w-4" }), _jsx("span", { className: "font-medium", children: rec.ok
55
+ ? `All ${rec.liveCount} attempted change(s) are live — nothing evaporated.`
56
+ : `${problems} discrepancy(ies) — what the chat said doesn't match what's live.` })] })), rec && rec.orphaned.length > 0 && (_jsx(FindingSection, { icon: _jsx(CircleSlash, { className: "h-4 w-4 text-destructive" }), title: "Proposed but never applied", hint: "A confirm card the agent proposed but no later turn applied \u2014 the change silently evaporated.", findings: rec.orphaned, tone: "destructive" })), rec && rec.missing.length > 0 && (_jsx(FindingSection, { icon: _jsx(AlertTriangle, { className: "h-4 w-4 text-amber-600" }), title: "Claimed but missing", hint: "A tool result said it was applied, but the artifact isn't live in sys_metadata.", findings: rec.missing, tone: "amber" })), rec && rec.errors.length > 0 && (_jsx(FindingSection, { icon: _jsx(XCircle, { className: "h-4 w-4 text-destructive" }), title: "Tool errors", hint: "Tool calls that returned an error during the build.", findings: rec.errors, tone: "destructive" })), report.verify && (_jsxs("div", { className: "rounded-md border p-3 text-xs", children: [_jsx("div", { className: "font-medium text-foreground", children: "Build check (verify_build)" }), _jsxs("div", { className: "mt-1 text-muted-foreground", children: ["Your app:", ' ', report.verify.userIssues.length === 0 ? (_jsx("span", { className: "text-emerald-600 dark:text-emerald-400", children: "0 issues" })) : (_jsxs("span", { className: "text-destructive", children: [report.verify.userIssues.length, " issue(s)"] })), report.verify.platformNoise > 0
57
+ ? ` · ${report.verify.platformNoise} platform sys_* finding(s) hidden`
58
+ : ''] }), report.verify.userIssues.map((is, i) => (_jsxs("div", { className: "mt-1 text-destructive", children: ["[", is.severity, "] ", is.code, " ", is.artifact ? `${is.artifact.type}:${is.artifact.name}` : ''] }, i)))] })), report.pendingActions.length > 0 && (_jsxs("div", { className: "rounded-md border p-3 text-xs", children: [_jsx("div", { className: "font-medium text-foreground", children: "Pending actions" }), report.pendingActions.map((p, i) => (_jsxs("div", { className: "mt-1 text-muted-foreground", children: [p.tool ?? '?', " \u00B7 ", p.object ?? '-', " \u00B7 ", _jsx("span", { className: "font-mono", children: p.status ?? '-' })] }, i)))] })), _jsxs("details", { className: "rounded-md border p-3 text-xs", children: [_jsxs("summary", { className: "cursor-pointer font-medium text-foreground", children: ["Timeline (", report.timeline.length, ")"] }), _jsx("div", { className: "mt-2 space-y-1 font-mono text-[11px] leading-relaxed", children: report.timeline.map((e, i) => (_jsx(TimelineRow, { entry: e }, i))) })] })] }))] })] }) }));
59
+ }
60
+ function FindingSection({ icon, title, hint, findings, tone, }) {
61
+ const border = tone === 'destructive' ? 'border-destructive/30' : 'border-amber-500/30';
62
+ return (_jsxs("div", { className: `rounded-md border ${border} p-3`, children: [_jsxs("div", { className: "flex items-center gap-2 font-medium text-foreground", children: [icon, " ", title, " (", findings.length, ")"] }), _jsx("div", { className: "mt-1 text-xs text-muted-foreground", children: hint }), _jsx("div", { className: "mt-2 space-y-1", children: findings.map((f, i) => (_jsxs("div", { className: "font-mono text-xs", children: [f.t ? `${f.t} · ` : '', f.tool, " \u2192 ", f.artifact.type, ":", f.artifact.name, _jsxs("span", { className: "text-muted-foreground", children: [" (", f.status, ")"] })] }, i))) })] }));
63
+ }
64
+ function TimelineRow({ entry }) {
65
+ if (entry.kind === 'user') {
66
+ return (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: entry.t }), " \uD83D\uDC64 ", entry.text] }));
67
+ }
68
+ if (entry.kind === 'assistant-text') {
69
+ return (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: entry.t }), " \uD83E\uDD16 ", entry.text] }));
70
+ }
71
+ if (entry.kind === 'assistant-calls') {
72
+ return (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: entry.t }), " \uD83E\uDD16 \u2192", ' ', entry.calls.map((c) => c.name).join(', ')] }));
73
+ }
74
+ return (_jsxs("div", { className: entry.isError ? 'text-destructive' : '', children: [_jsx("span", { className: "text-muted-foreground", children: entry.t }), " \u21B3 ", entry.name, entry.status ? ` (${entry.status})` : ''] }));
75
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Client for the admin build-debug endpoint
3
+ * (`GET /api/v1/ai/conversations/:id/debug`, service-ai).
4
+ *
5
+ * The chat renders the build agent's self-report, never what actually landed.
6
+ * This fetches the server-side reconciliation — agent-CLAIMED vs LIVE
7
+ * `sys_metadata` — so a signed-in admin can diagnose a build that "went wrong"
8
+ * from the browser, with no DB credentials. Mirrors `fetchConversation`'s
9
+ * same-origin cookie auth.
10
+ */
11
+ export interface ArtifactRef {
12
+ type: string;
13
+ name: string;
14
+ }
15
+ export interface MutationFinding {
16
+ t?: string;
17
+ tool: string;
18
+ status: string;
19
+ artifact: ArtifactRef;
20
+ }
21
+ export interface VerifyIssue {
22
+ severity: string;
23
+ code: string;
24
+ artifact?: ArtifactRef;
25
+ }
26
+ export interface VerifySummary {
27
+ status: string;
28
+ errors: number;
29
+ warnings: number;
30
+ userIssues: VerifyIssue[];
31
+ platformNoise: number;
32
+ }
33
+ export type TimelineEntry = {
34
+ t: string;
35
+ kind: 'user';
36
+ text: string;
37
+ } | {
38
+ t: string;
39
+ kind: 'assistant-text';
40
+ text: string;
41
+ model?: string;
42
+ tokens?: number;
43
+ ms?: number;
44
+ } | {
45
+ t: string;
46
+ kind: 'assistant-calls';
47
+ calls: Array<{
48
+ name: string;
49
+ args: unknown;
50
+ }>;
51
+ model?: string;
52
+ tokens?: number;
53
+ ms?: number;
54
+ } | {
55
+ t: string;
56
+ kind: 'tool-result';
57
+ name: string;
58
+ isError: boolean;
59
+ status?: string;
60
+ preview: string;
61
+ };
62
+ export interface BuildDebugReport {
63
+ conversationId: string;
64
+ title: string | null;
65
+ summary: {
66
+ models: string[];
67
+ userTurns: number;
68
+ messages: number;
69
+ totalTokens: number;
70
+ llmMs: number;
71
+ };
72
+ reconciliation: {
73
+ orphaned: MutationFinding[];
74
+ missing: MutationFinding[];
75
+ errors: MutationFinding[];
76
+ liveCount: number;
77
+ ok: boolean;
78
+ };
79
+ verify: VerifySummary | null;
80
+ timeline: TimelineEntry[];
81
+ pendingActions: Array<{
82
+ tool: string | null;
83
+ object: string | null;
84
+ status: string | null;
85
+ error: string | null;
86
+ createdAt: string | null;
87
+ }>;
88
+ }
89
+ /**
90
+ * Fetch the reconciliation report for a build conversation. Returns null on
91
+ * 403/404 (not authorized / unknown conversation) so the caller can show an
92
+ * empty state instead of throwing.
93
+ */
94
+ export declare function fetchBuildDebug(apiBase: string, conversationId: string): Promise<BuildDebugReport | null>;
@@ -0,0 +1,16 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Fetch the reconciliation report for a build conversation. Returns null on
4
+ * 403/404 (not authorized / unknown conversation) so the caller can show an
5
+ * empty state instead of throwing.
6
+ */
7
+ export async function fetchBuildDebug(apiBase, conversationId) {
8
+ const res = await fetch(`${apiBase}/conversations/${encodeURIComponent(conversationId)}/debug`, {
9
+ credentials: 'include',
10
+ });
11
+ if (res.status === 404 || res.status === 403 || res.status === 401)
12
+ return null;
13
+ if (!res.ok)
14
+ throw new Error(`GET build debug failed: ${res.status}`);
15
+ return (await res.json());
16
+ }
@@ -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,36 @@ export function OrganizationsPage() {
91
99
  return;
92
100
  if (isOrganizationsLoading)
93
101
  return;
102
+ if (wantsCreate)
103
+ return; // came to create — don't bounce
94
104
  if (orgList.length !== 1)
95
105
  return;
96
106
  autoSelectedRef.current = true;
97
- void handleSelect(orgList[0]);
107
+ // Single-org users have no real choice to make. In manage mode (`?manage=1`,
108
+ // used by the Cloud app "Members" nav and the avatar "My Organizations"
109
+ // entry), skip the pointless one-item picker and deep-link straight to that
110
+ // org's member management; otherwise switch into the org and land on home.
111
+ if (manageMode) {
112
+ navigate(`/organizations/${orgList[0].slug}/members`, { replace: true });
113
+ }
114
+ else {
115
+ void handleSelect(orgList[0]);
116
+ }
98
117
  // eslint-disable-next-line react-hooks/exhaustive-deps
99
- }, [isOrganizationsLoading, orgList.length]);
118
+ }, [isOrganizationsLoading, orgList.length, manageMode, wantsCreate]);
119
+ // Open the create dialog when arriving via the header "Create workspace"
120
+ // entry (`?create=1`). Guarded so closing the dialog doesn't re-open it.
121
+ const createOpenedRef = useRef(false);
122
+ useEffect(() => {
123
+ if (wantsCreate && !createOpenedRef.current) {
124
+ createOpenedRef.current = true;
125
+ setIsCreateOpen(true);
126
+ }
127
+ }, [wantsCreate]);
100
128
  // Show a spinner while we're either still loading, or about to auto-redirect
101
129
  // because there's only one org. This prevents the picker from briefly
102
130
  // flashing on screen for single-org users.
103
- const willAutoSelect = !isOrganizationsLoading && orgList.length === 1;
131
+ const willAutoSelect = !wantsCreate && !isOrganizationsLoading && orgList.length === 1;
104
132
  if (isOrganizationsLoading || willAutoSelect) {
105
133
  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
134
  }
@@ -45,7 +45,7 @@ export function OrganizationLayout() {
45
45
  { to: 'invitations', label: t('organization.tabs.invitations', { defaultValue: 'Invitations' }) },
46
46
  { to: 'settings', label: t('organization.tabs.settings', { defaultValue: 'Settings' }) },
47
47
  ];
48
- return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "organization-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "orgs" }) }), _jsx("div", { className: "border-b", children: _jsxs("div", { className: "mx-auto w-full max-w-5xl px-4 sm:px-6 pt-6", children: [_jsxs("button", { onClick: () => navigate('/organizations'), className: "mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground", children: [_jsx(ArrowLeft, { className: "h-3.5 w-3.5" }), t('organization.backToList', { defaultValue: 'Back to organizations' })] }), _jsx("div", { className: "flex items-center justify-between gap-4", children: _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "truncate text-2xl font-bold tracking-tight", children: org.name }), _jsx("p", { className: "text-xs text-muted-foreground", children: org.slug })] }) }), _jsx("nav", { className: "mt-4 flex gap-1 -mb-px", children: tabs.map((tab) => (_jsx(NavLink, { to: tab.to, end: true, className: ({ isActive }) => `border-b-2 px-3 py-2 text-sm font-medium transition-colors ${isActive
48
+ return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "organization-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "orgs" }) }), _jsx("div", { className: "border-b", children: _jsxs("div", { className: "mx-auto w-full max-w-5xl px-4 sm:px-6 pt-6", children: [(organizations ?? []).length > 1 && (_jsxs("button", { onClick: () => navigate('/organizations'), className: "mb-3 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground", children: [_jsx(ArrowLeft, { className: "h-3.5 w-3.5" }), t('organization.backToList', { defaultValue: 'Back to organizations' })] })), _jsx("div", { className: "flex items-center justify-between gap-4", children: _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "truncate text-2xl font-bold tracking-tight", children: org.name }), _jsx("p", { className: "text-xs text-muted-foreground", children: org.slug })] }) }), _jsx("nav", { className: "mt-4 flex gap-1 -mb-px", children: tabs.map((tab) => (_jsx(NavLink, { to: tab.to, end: true, className: ({ isActive }) => `border-b-2 px-3 py-2 text-sm font-medium transition-colors ${isActive
49
49
  ? 'border-primary text-foreground'
50
50
  : 'border-transparent text-muted-foreground hover:text-foreground'}`, children: tab.label }, tab.to))) })] }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto", children: _jsx("div", { className: "mx-auto w-full max-w-5xl px-4 sm:px-6 py-6", children: _jsx(Outlet, { context: { org } }) }) })] }));
51
51
  }
@@ -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>;