@object-ui/app-shell 7.1.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/dist/console/AppContent.js +9 -15
  3. package/dist/console/ConsoleShell.d.ts +16 -0
  4. package/dist/console/ConsoleShell.js +43 -2
  5. package/dist/console/ai/AiChatPage.js +36 -9
  6. package/dist/console/home/HomeLayout.js +5 -7
  7. package/dist/console/home/HomePage.js +1 -9
  8. package/dist/console/organizations/CreateWorkspaceDialog.js +15 -1
  9. package/dist/console/organizations/OrganizationsPage.js +22 -3
  10. package/dist/console/organizations/provisionEnvironment.d.ts +53 -0
  11. package/dist/console/organizations/provisionEnvironment.js +64 -0
  12. package/dist/environment/EnvironmentEntitlementDialog.d.ts +34 -0
  13. package/dist/environment/EnvironmentEntitlementDialog.js +37 -0
  14. package/dist/environment/EnvironmentListToolbar.d.ts +33 -0
  15. package/dist/environment/EnvironmentListToolbar.js +59 -0
  16. package/dist/environment/entitlements.d.ts +90 -0
  17. package/dist/environment/entitlements.js +91 -0
  18. package/dist/environment/useEnvironmentEntitlements.d.ts +32 -0
  19. package/dist/environment/useEnvironmentEntitlements.js +108 -0
  20. package/dist/hooks/useActionModal.js +15 -1
  21. package/dist/hooks/useAiSurface.d.ts +59 -0
  22. package/dist/hooks/useAiSurface.js +78 -0
  23. package/dist/hooks/useConsoleActionRuntime.d.ts +3 -0
  24. package/dist/hooks/useConsoleActionRuntime.js +36 -8
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.js +5 -1
  27. package/dist/layout/AppHeader.js +28 -4
  28. package/dist/layout/ConsoleFloatingChatbot.js +16 -2
  29. package/dist/layout/ConsoleLayout.js +5 -6
  30. package/dist/preview/DraftPreviewBar.js +20 -7
  31. package/dist/providers/ExpressionProvider.js +9 -3
  32. package/dist/utils/index.d.ts +2 -2
  33. package/dist/utils/index.js +1 -1
  34. package/dist/utils/recordFormNavigation.d.ts +60 -0
  35. package/dist/utils/recordFormNavigation.js +35 -0
  36. package/dist/utils/resolvePageVarTokens.d.ts +31 -0
  37. package/dist/utils/resolvePageVarTokens.js +72 -0
  38. package/dist/views/CreateViewDialog.js +14 -1
  39. package/dist/views/ObjectView.js +26 -12
  40. package/dist/views/metadata-admin/AssignedUsersSection.d.ts +28 -0
  41. package/dist/views/metadata-admin/AssignedUsersSection.js +151 -0
  42. package/dist/views/metadata-admin/PackagesPage.d.ts +5 -0
  43. package/dist/views/metadata-admin/PackagesPage.js +49 -4
  44. package/dist/views/metadata-admin/PermissionMatrixEditor.js +2 -1
  45. package/dist/views/metadata-admin/ResourceEditPage.js +36 -4
  46. package/dist/views/metadata-admin/ResourceListPage.js +21 -4
  47. package/dist/views/metadata-admin/createBody.d.ts +26 -0
  48. package/dist/views/metadata-admin/createBody.js +30 -0
  49. package/dist/views/metadata-admin/i18n.js +20 -0
  50. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +8 -0
  51. package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +17 -3
  52. package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +16 -2
  53. package/dist/views/metadata-admin/inspectors/FlowExprIssue.d.ts +21 -0
  54. package/dist/views/metadata-admin/inspectors/FlowExprIssue.js +13 -0
  55. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.d.ts +20 -2
  56. package/dist/views/metadata-admin/inspectors/FlowKeyValueField.js +71 -28
  57. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.d.ts +4 -1
  58. package/dist/views/metadata-admin/inspectors/FlowNodeConfigField.js +24 -9
  59. package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +15 -3
  60. package/dist/views/metadata-admin/inspectors/FlowObjectListField.d.ts +4 -1
  61. package/dist/views/metadata-admin/inspectors/FlowObjectListField.js +8 -3
  62. package/dist/views/metadata-admin/inspectors/VariableTextInput.d.ts +47 -0
  63. package/dist/views/metadata-admin/inspectors/VariableTextInput.js +95 -0
  64. package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +6 -1
  65. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +16 -1
  66. package/dist/views/metadata-admin/inspectors/flow-node-config.js +21 -10
  67. package/dist/views/metadata-admin/inspectors/flow-ref-check.d.ts +39 -0
  68. package/dist/views/metadata-admin/inspectors/flow-ref-check.js +114 -0
  69. package/dist/views/metadata-admin/inspectors/flow-scope.d.ts +109 -0
  70. package/dist/views/metadata-admin/inspectors/flow-scope.js +199 -0
  71. package/dist/views/metadata-admin/inspectors/useDatasetFields.d.ts +14 -3
  72. package/dist/views/metadata-admin/inspectors/useDatasetFields.js +0 -0
  73. package/dist/views/metadata-admin/inspectors/useFlowScope.d.ts +23 -0
  74. package/dist/views/metadata-admin/inspectors/useFlowScope.js +45 -0
  75. package/dist/views/metadata-admin/package-scope.d.ts +15 -0
  76. package/dist/views/metadata-admin/package-scope.js +16 -0
  77. package/dist/views/metadata-admin/preview-registry.d.ts +12 -0
  78. package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +22 -3
  79. package/dist/views/metadata-admin/previews/FlowCanvas.js +45 -6
  80. package/dist/views/metadata-admin/previews/FlowPreview.d.ts +1 -1
  81. package/dist/views/metadata-admin/previews/FlowPreview.js +42 -30
  82. package/dist/views/metadata-admin/previews/ObjectFormCanvas.js +9 -4
  83. package/dist/views/metadata-admin/previews/ProblemsPanel.d.ts +18 -0
  84. package/dist/views/metadata-admin/previews/ProblemsPanel.js +27 -0
  85. package/dist/views/metadata-admin/previews/ReportPreview.d.ts +9 -8
  86. package/dist/views/metadata-admin/previews/ReportPreview.js +33 -16
  87. package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
  88. package/dist/views/metadata-admin/previews/flow-canvas-parts.js +5 -3
  89. package/dist/views/metadata-admin/previews/flow-expr-problems.d.ts +19 -0
  90. package/dist/views/metadata-admin/previews/flow-expr-problems.js +97 -0
  91. package/dist/views/metadata-admin/previews/flow-problems.d.ts +84 -0
  92. package/dist/views/metadata-admin/previews/flow-problems.js +209 -0
  93. package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +9 -0
  94. package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +4 -2
  95. package/package.json +38 -38
@@ -0,0 +1,108 @@
1
+ /**
2
+ * useEnvironmentEntitlements — resolve the org's environment-capacity state so
3
+ * the `sys_environment` list can present the right "create" affordance up front.
4
+ *
5
+ * Two signals, in priority order:
6
+ * 1. AUTHORITATIVE — GET /cloud/environment-entitlements (org-scoped, computed
7
+ * by the same helper the create guard uses). Gives plan + dev-create
8
+ * capability precisely, including the seat-scaled / subscription cases the
9
+ * client can't derive from rows.
10
+ * 2. FALLBACK — when that endpoint is unavailable (older control plane / error),
11
+ * derive `hasProductionEnv` from the org's env rows via the data API (which
12
+ * is org-scoped on the control plane). This keeps the critical
13
+ * "set up your production environment" path working without a backend deploy;
14
+ * free-vs-paid is left unknown, and a stray create POST is caught by the
15
+ * entitlement dialog safety net.
16
+ *
17
+ * Returns `null` when disabled (not the environment list) so callers can cheaply
18
+ * branch. Re-resolves when `refreshKey` changes (e.g. after a create).
19
+ */
20
+ import { useEffect, useState } from 'react';
21
+ import { useAuth } from '@object-ui/auth';
22
+ import { DEFAULT_UPGRADE_URL, } from './entitlements';
23
+ const TERMINAL_STATUSES = new Set(['archived', 'failed']);
24
+ /** Mirror of the server `classifyEnvironmentType`: explicit type, else default→prod. */
25
+ function classifyEnvironmentType(row) {
26
+ const t = String(row?.environment_type ?? '').trim().toLowerCase();
27
+ if (t === 'production' || t === 'prod')
28
+ return 'production';
29
+ if (t)
30
+ return 'development';
31
+ return row?.is_default === true || row?.is_default === 1 ? 'production' : 'development';
32
+ }
33
+ export function useEnvironmentEntitlements(opts) {
34
+ const { enabled, dataSource, authFetch, apiBase, refreshKey } = opts;
35
+ const { activeOrganization } = useAuth();
36
+ const orgId = activeOrganization?.id;
37
+ const [state, setState] = useState(null);
38
+ useEffect(() => {
39
+ if (!enabled) {
40
+ setState(null);
41
+ return;
42
+ }
43
+ let cancelled = false;
44
+ const fromSummary = async () => {
45
+ try {
46
+ const base = (apiBase || '').replace(/\/+$/, '');
47
+ const qs = orgId ? `?organizationId=${encodeURIComponent(orgId)}` : '';
48
+ const res = await authFetch(`${base}/api/v1/cloud/environment-entitlements${qs}`, {
49
+ method: 'GET',
50
+ credentials: 'include',
51
+ });
52
+ if (!res.ok)
53
+ return null;
54
+ const json = await res.json().catch(() => null);
55
+ const data = (json?.data ?? json);
56
+ if (!data || typeof data !== 'object')
57
+ return null;
58
+ return {
59
+ ready: true,
60
+ hasProductionEnv: data.hasProductionEnv === true,
61
+ canCreateDevelopmentEnv: data.development?.canCreate,
62
+ plan: data.plan,
63
+ upgradeUrl: data.upgradeUrl || DEFAULT_UPGRADE_URL,
64
+ contactSalesUrl: data.contactSalesUrl,
65
+ source: 'summary',
66
+ };
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ };
72
+ const fromRows = async () => {
73
+ try {
74
+ const params = { $top: 200 };
75
+ if (orgId)
76
+ params.$filter = { organization_id: orgId };
77
+ const res = await dataSource.find('sys_environment', params);
78
+ const rows = Array.isArray(res) ? res : res?.data ?? [];
79
+ const active = rows.filter((r) => !TERMINAL_STATUSES.has(String(r?.status ?? '')));
80
+ const hasProductionEnv = active.some((r) => classifyEnvironmentType(r) === 'production');
81
+ return {
82
+ ready: true,
83
+ hasProductionEnv,
84
+ canCreateDevelopmentEnv: undefined,
85
+ upgradeUrl: DEFAULT_UPGRADE_URL,
86
+ source: 'derived',
87
+ };
88
+ }
89
+ catch {
90
+ return {
91
+ ready: false,
92
+ hasProductionEnv: false,
93
+ canCreateDevelopmentEnv: undefined,
94
+ upgradeUrl: DEFAULT_UPGRADE_URL,
95
+ source: 'unknown',
96
+ };
97
+ }
98
+ };
99
+ (async () => {
100
+ const summary = await fromSummary();
101
+ const next = summary ?? (await fromRows());
102
+ if (!cancelled)
103
+ setState(next);
104
+ })();
105
+ return () => { cancelled = true; };
106
+ }, [enabled, orgId, apiBase, authFetch, dataSource, refreshKey]);
107
+ return state;
108
+ }
@@ -23,8 +23,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23
23
  */
24
24
  import { useCallback, useState } from 'react';
25
25
  import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, cn, } from '@object-ui/components';
26
- import { SchemaRenderer } from '@object-ui/react';
26
+ import { SchemaRenderer, useMetadata } from '@object-ui/react';
27
27
  import { ModalForm } from '@object-ui/plugin-form';
28
+ import { resolveFormViewLayout } from '../utils/recordFormNavigation';
28
29
  const SIZE_CLASS = {
29
30
  sm: 'sm:max-w-sm',
30
31
  default: 'sm:max-w-lg',
@@ -56,6 +57,10 @@ export function normalizeModalSchema(schema) {
56
57
  }
57
58
  export function useActionModal(dataSource) {
58
59
  const [state, setState] = useState(null);
60
+ // Object metadata — degrades to an empty list outside a MetadataProvider
61
+ // (see useMetadata). Used to resolve the object's default form view so the
62
+ // create/edit modal honors its curated sections + field selection/order.
63
+ const { objects } = useMetadata();
59
64
  const close = useCallback((r) => {
60
65
  setState((s) => {
61
66
  s?.resolve(r);
@@ -73,6 +78,14 @@ export function useActionModal(dataSource) {
73
78
  close({ success: false });
74
79
  };
75
80
  if (d.objectName && !d.content) {
81
+ // Honor the object's default FORM VIEW (curated sections + field
82
+ // selection/order + master-detail subforms) unless the action descriptor
83
+ // passed an explicit field list. Without this the modal falls back to the
84
+ // raw object schema — every field, in schema order. Mirrors the global
85
+ // New/Edit modal in AppContent so action-opened forms stay consistent.
86
+ const viewLayout = (d.fields || d.sections)
87
+ ? {}
88
+ : resolveFormViewLayout(objects.find((o) => o?.name === d.objectName));
76
89
  modalElement = (_jsx(ModalForm, { schema: {
77
90
  type: 'object-form',
78
91
  formType: 'modal',
@@ -82,6 +95,7 @@ export function useActionModal(dataSource) {
82
95
  title: d.title,
83
96
  description: d.description,
84
97
  fields: d.fields,
98
+ ...viewLayout,
85
99
  modalSize: d.size,
86
100
  open: true,
87
101
  onOpenChange,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * useAiSurfaceEnabled
3
+ *
4
+ * Single source of truth for "should the in-UI AI surface be shown — for THIS
5
+ * user, on this deployment?". The console ships under MIT and is edition- and
6
+ * seat-agnostic at build time; it decides purely at runtime from what the
7
+ * server reports — no `VITE_EDITION` flag, no tree-shake.
8
+ *
9
+ * The signal is **the agent catalog** (`GET /api/v1/ai/agents`): the surface
10
+ * shows iff that returns >= 1 agent. The catalog is the right signal because it
11
+ * is the ONLY one that is BOTH edition- AND user-aware:
12
+ *
13
+ * • The route is access-filtered server-side (ADR-0049 / ADR-0068): it returns
14
+ * only the agents the CALLER may chat. A user WITHOUT the per-user AI seat
15
+ * (the `ai_seat` permission) gets an EMPTY catalog -> the whole AI surface
16
+ * hides for them, instead of showing a button that 403s on click. The
17
+ * deployment-wide discovery `services.ai` flag CANNOT express this — it is
18
+ * identical for every user — which is exactly why we do NOT gate on it.
19
+ * • It is ALSO the honest edition signal: a Community-Edition runtime that
20
+ * ships no `@objectstack/service-ai` registers no AI service and persists no
21
+ * agents -> empty catalog -> hidden. (The old "headless service reports
22
+ * available in CE" worry is moot: empty catalog hides the surface either way.)
23
+ *
24
+ * ⚠️ Do NOT "simplify" this back to `discovery.services.ai` (isAiEnabled): that
25
+ * reintroduces the per-user gap — seat-less users would see the FAB / links and
26
+ * hit 403 on click. The per-user AI-seat gate (ADR-0068) DEPENDS on this catalog
27
+ * signal. (This reverts objectui#1992, which dropped the per-user dimension.)
28
+ *
29
+ * The `VITE_AI_BASE_URL` opt-in flows through naturally: {@link resolveAiApiBase}
30
+ * points the catalog fetch at the configured server, so an external AI server
31
+ * with reachable agents lights the surface up and an agent-less one keeps it hidden.
32
+ *
33
+ * `isLoading` is surfaced so the `/ai` route guard can wait for the catalog to
34
+ * resolve before redirecting — otherwise a stale bookmark would flash a redirect
35
+ * to home before the fetch even starts. Entry-point buttons (FAB, top-bar link,
36
+ * designer "Ask AI") ignore it: staying hidden during the brief load is the
37
+ * correct, flash-free behaviour for a control that must not appear unless AI can
38
+ * actually answer.
39
+ *
40
+ * @module
41
+ */
42
+ /**
43
+ * Resolve the AI service base URL, mirroring AiChatPage / the Home CTAs:
44
+ * an explicit `VITE_AI_BASE_URL` wins, otherwise `${VITE_SERVER_URL}/api/v1/ai`.
45
+ * Shared so every catalog fetch (route guard, layouts, Home) hits the same URL.
46
+ */
47
+ export declare function resolveAiApiBase(): string;
48
+ export interface AiSurfaceState {
49
+ /** True when the AI UI should render — the CALLER can reach >= 1 agent (access-filtered). */
50
+ enabled: boolean;
51
+ /** True until the agent catalog has resolved; route guards wait on this. */
52
+ isLoading: boolean;
53
+ }
54
+ /**
55
+ * Whether the console's AI surface (FAB, `/ai` routes, "Ask AI" affordances)
56
+ * should be shown FOR THE CURRENT USER, driven off the access-filtered agent
57
+ * catalog (empty for seat-less users -> AI hidden; ADR-0068).
58
+ */
59
+ export declare function useAiSurfaceEnabled(): AiSurfaceState;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * useAiSurfaceEnabled
3
+ *
4
+ * Single source of truth for "should the in-UI AI surface be shown — for THIS
5
+ * user, on this deployment?". The console ships under MIT and is edition- and
6
+ * seat-agnostic at build time; it decides purely at runtime from what the
7
+ * server reports — no `VITE_EDITION` flag, no tree-shake.
8
+ *
9
+ * The signal is **the agent catalog** (`GET /api/v1/ai/agents`): the surface
10
+ * shows iff that returns >= 1 agent. The catalog is the right signal because it
11
+ * is the ONLY one that is BOTH edition- AND user-aware:
12
+ *
13
+ * • The route is access-filtered server-side (ADR-0049 / ADR-0068): it returns
14
+ * only the agents the CALLER may chat. A user WITHOUT the per-user AI seat
15
+ * (the `ai_seat` permission) gets an EMPTY catalog -> the whole AI surface
16
+ * hides for them, instead of showing a button that 403s on click. The
17
+ * deployment-wide discovery `services.ai` flag CANNOT express this — it is
18
+ * identical for every user — which is exactly why we do NOT gate on it.
19
+ * • It is ALSO the honest edition signal: a Community-Edition runtime that
20
+ * ships no `@objectstack/service-ai` registers no AI service and persists no
21
+ * agents -> empty catalog -> hidden. (The old "headless service reports
22
+ * available in CE" worry is moot: empty catalog hides the surface either way.)
23
+ *
24
+ * ⚠️ Do NOT "simplify" this back to `discovery.services.ai` (isAiEnabled): that
25
+ * reintroduces the per-user gap — seat-less users would see the FAB / links and
26
+ * hit 403 on click. The per-user AI-seat gate (ADR-0068) DEPENDS on this catalog
27
+ * signal. (This reverts objectui#1992, which dropped the per-user dimension.)
28
+ *
29
+ * The `VITE_AI_BASE_URL` opt-in flows through naturally: {@link resolveAiApiBase}
30
+ * points the catalog fetch at the configured server, so an external AI server
31
+ * with reachable agents lights the surface up and an agent-less one keeps it hidden.
32
+ *
33
+ * `isLoading` is surfaced so the `/ai` route guard can wait for the catalog to
34
+ * resolve before redirecting — otherwise a stale bookmark would flash a redirect
35
+ * to home before the fetch even starts. Entry-point buttons (FAB, top-bar link,
36
+ * designer "Ask AI") ignore it: staying hidden during the brief load is the
37
+ * correct, flash-free behaviour for a control that must not appear unless AI can
38
+ * actually answer.
39
+ *
40
+ * @module
41
+ */
42
+ import { useRef } from 'react';
43
+ import { useAgents } from '@object-ui/plugin-chatbot';
44
+ /**
45
+ * Resolve the AI service base URL, mirroring AiChatPage / the Home CTAs:
46
+ * an explicit `VITE_AI_BASE_URL` wins, otherwise `${VITE_SERVER_URL}/api/v1/ai`.
47
+ * Shared so every catalog fetch (route guard, layouts, Home) hits the same URL.
48
+ */
49
+ export function resolveAiApiBase() {
50
+ const env = import.meta.env ?? {};
51
+ const fromEnv = env.VITE_AI_BASE_URL;
52
+ if (fromEnv)
53
+ return fromEnv.replace(/\/$/, '');
54
+ const serverUrl = env.VITE_SERVER_URL ?? '';
55
+ return `${serverUrl.replace(/\/$/, '')}/api/v1/ai`;
56
+ }
57
+ /**
58
+ * Whether the console's AI surface (FAB, `/ai` routes, "Ask AI" affordances)
59
+ * should be shown FOR THE CURRENT USER, driven off the access-filtered agent
60
+ * catalog (empty for seat-less users -> AI hidden; ADR-0068).
61
+ */
62
+ export function useAiSurfaceEnabled() {
63
+ const { agents, isLoading } = useAgents({ apiBase: resolveAiApiBase() });
64
+ // useAgents starts `isLoading=false` and only kicks off the fetch in an effect
65
+ // a tick later, so the first render's empty list means "not fetched yet", not
66
+ // "no agents". Latch whether a fetch has actually been in flight so the route
67
+ // guard treats that initial frame as loading (not a definitive empty → redirect).
68
+ const fetchStartedRef = useRef(false);
69
+ if (isLoading)
70
+ fetchStartedRef.current = true;
71
+ const enabled = agents.length > 0;
72
+ return {
73
+ enabled,
74
+ // Agents present → resolved/available. Otherwise we're loading until a fetch
75
+ // has both started and finished with an empty result.
76
+ isLoading: enabled ? false : isLoading || !fetchStartedRef.current,
77
+ };
78
+ }
@@ -23,6 +23,7 @@
23
23
  import React from 'react';
24
24
  import { createAuthenticatedFetch } from '@object-ui/auth';
25
25
  import type { ActionContext, ActionDef, ActionResult, ConfirmationHandler, NavigationHandler, ParamCollectionHandler, ResultDialogHandler, ToastHandler } from '@object-ui/core';
26
+ import { type EntitlementDialogSpec } from '../environment/entitlements';
26
27
  export interface ConsoleActionRuntimeOptions {
27
28
  /** Adapter for generic CRUD / execute calls. */
28
29
  dataSource: any;
@@ -45,6 +46,8 @@ export interface ConsoleActionRuntime {
45
46
  serverActionHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
46
47
  /** Authenticated fetch wrapper (Bearer + tenant + cookies). */
47
48
  authFetch: ReturnType<typeof createAuthenticatedFetch>;
49
+ /** Open the shared environment entitlement (upgrade / limit) dialog. */
50
+ openEntitlementDialog: (spec: EntitlementDialogSpec) => void;
48
51
  /** Props to spread onto `<ActionProvider>`. */
49
52
  actionProviderProps: {
50
53
  context: Record<string, any>;
@@ -33,6 +33,9 @@ import { ActionParamDialog } from '../views/ActionParamDialog';
33
33
  import { ActionResultDialog } from '../views/ActionResultDialog';
34
34
  import { FlowRunner } from '../views/FlowRunner';
35
35
  import { resolveActionParams } from '../utils/resolveActionParams';
36
+ import { EnvironmentEntitlementDialog } from '../environment/EnvironmentEntitlementDialog';
37
+ import { entitlementDialogFromError } from '../environment/entitlements';
38
+ import { resolvePageVarTokens } from '../utils/resolvePageVarTokens';
36
39
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
37
40
  export function useConsoleActionRuntime(opts) {
38
41
  const { dataSource, objects, objectName, onRefresh } = opts;
@@ -60,6 +63,9 @@ export function useConsoleActionRuntime(opts) {
60
63
  const [resultDialogState, setResultDialogState] = useState({ open: false });
61
64
  // A paused `screen`-node flow awaiting user input.
62
65
  const [screenFlow, setScreenFlow] = useState(null);
66
+ // Plan/capacity gate dialog (upgrade / limit), shared by the env-list toolbar
67
+ // (proactive) and the api-action error path below (reactive safety net).
68
+ const [entitlementDialog, setEntitlementDialog] = useState({ open: false });
63
69
  // Guards against double-firing a server action (slow SSO handoff, etc.).
64
70
  const serverActionInFlight = useRef(new Set());
65
71
  const resultDialogHandler = useCallback((spec, data) => new Promise((resolve) => {
@@ -133,7 +139,10 @@ export function useConsoleActionRuntime(opts) {
133
139
  }, [navigate]);
134
140
  // Authenticated fetch for direct backend calls. Declared before apiHandler.
135
141
  const authFetch = useMemo(() => createAuthenticatedFetch(), []);
136
- const apiHandler = useCallback(async (action) => {
142
+ const openEntitlementDialog = useCallback((spec) => {
143
+ setEntitlementDialog({ open: true, spec });
144
+ }, []);
145
+ const apiHandler = useCallback(async (action, context) => {
137
146
  try {
138
147
  const target = action.target || action.name;
139
148
  const params = action.params || {};
@@ -148,6 +157,12 @@ export function useConsoleActionRuntime(opts) {
148
157
  const rawParams = { ...params };
149
158
  const rowRecord = rawParams._rowRecord;
150
159
  delete rawParams._rowRecord;
160
+ // Resolve `{{page.<var>}}` tokens against the live page-variable snapshot
161
+ // (published into the action context by PageVariableActionBridge). This is
162
+ // what lets a pure-SDUI form submit the values its inputs wrote into page
163
+ // variables; whole-value tokens preserve type. See resolvePageVarTokens.
164
+ const pageVars = (context?.pageVariables ?? undefined);
165
+ const resolvedParams = resolvePageVarTokens(rawParams, pageVars);
151
166
  // Interpolate `{field}` tokens in the target URL from the row record.
152
167
  let resolvedTarget = targetStr;
153
168
  if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) {
@@ -160,7 +175,7 @@ export function useConsoleActionRuntime(opts) {
160
175
  const wrap = action.bodyShape && typeof action.bodyShape === 'object' && action.bodyShape.wrap
161
176
  ? action.bodyShape.wrap
162
177
  : undefined;
163
- const body = wrap ? { [wrap]: rawParams } : { ...rawParams };
178
+ const body = wrap ? { [wrap]: resolvedParams } : { ...resolvedParams };
164
179
  if (rowRecord && action.recordIdParam) {
165
180
  const rowField = action.recordIdField || 'id';
166
181
  const rowValue = rowRecord[rowField];
@@ -172,7 +187,7 @@ export function useConsoleActionRuntime(opts) {
172
187
  body.organizationId = activeOrganization.id;
173
188
  }
174
189
  if (action.bodyExtra && typeof action.bodyExtra === 'object') {
175
- Object.assign(body, action.bodyExtra);
190
+ Object.assign(body, resolvePageVarTokens(action.bodyExtra, pageVars));
176
191
  }
177
192
  const method = (action.method || 'POST').toUpperCase();
178
193
  const init = {
@@ -185,12 +200,23 @@ export function useConsoleActionRuntime(opts) {
185
200
  }
186
201
  const res = await authFetch(url, init);
187
202
  if (!res.ok) {
188
- let detail = `HTTP ${res.status}`;
203
+ let body = null;
189
204
  try {
190
- const j = await res.json();
191
- detail = j?.error || j?.message || detail;
205
+ body = await res.json();
192
206
  }
193
207
  catch { /* response body not JSON */ }
208
+ // Plan/capacity gates (e.g. creating an environment the org's plan
209
+ // doesn't include) come back as coded 403s. Surface them as a friendly
210
+ // upgrade/limit DIALOG with a CTA — never a generic red error toast.
211
+ // Returning success:false WITHOUT an `error` suppresses the runner's
212
+ // error toast (ActionRunner.handlePostExecution); the dialog owns the
213
+ // messaging.
214
+ const entitlementSpec = entitlementDialogFromError(body);
215
+ if (entitlementSpec) {
216
+ openEntitlementDialog(entitlementSpec);
217
+ return { success: false };
218
+ }
219
+ const detail = body?.error || body?.message || `HTTP ${res.status}`;
194
220
  return { success: false, error: detail };
195
221
  }
196
222
  const data = await res.json().catch(() => ({}));
@@ -251,7 +277,7 @@ export function useConsoleActionRuntime(opts) {
251
277
  catch (error) {
252
278
  return { success: false, error: error.message };
253
279
  }
254
- }, [dataSource, objApiName, authFetch, activeOrganization, refresh]);
280
+ }, [dataSource, objApiName, authFetch, activeOrganization, refresh, openEntitlementDialog]);
255
281
  // Flow action handler — POST to /api/v1/automation/{name}/trigger.
256
282
  // `context` is the shared ActionRunner context (registered handlers are
257
283
  // invoked as `handler(action, runnerContext)`).
@@ -537,7 +563,8 @@ export function useConsoleActionRuntime(opts) {
537
563
  } }), _jsx(ActionResultDialog, { state: resultDialogState, onAcknowledge: () => {
538
564
  resultDialogState.resolve?.();
539
565
  setResultDialogState({ open: false });
540
- } }), _jsx(FlowRunner, { state: screenFlow, authFetch: authFetch, baseUrl: import.meta.env.VITE_SERVER_URL || '', dataSource: dataSource, objects: objects, onClose: () => setScreenFlow(null), onComplete: () => { setScreenFlow(null); refresh(); } })] }));
566
+ } }), _jsx(FlowRunner, { state: screenFlow, authFetch: authFetch, baseUrl: import.meta.env.VITE_SERVER_URL || '', dataSource: dataSource, objects: objects, onClose: () => setScreenFlow(null), onComplete: () => { setScreenFlow(null); refresh(); } }), _jsx(EnvironmentEntitlementDialog, { state: entitlementDialog, apiBase: import.meta.env.VITE_SERVER_URL || '', onOpenChange: (open) => { if (!open)
567
+ setEntitlementDialog({ open: false }); } })] }));
541
568
  return {
542
569
  confirmHandler,
543
570
  toastHandler,
@@ -548,6 +575,7 @@ export function useConsoleActionRuntime(opts) {
548
575
  flowHandler,
549
576
  serverActionHandler,
550
577
  authFetch,
578
+ openEntitlementDialog,
551
579
  actionProviderProps,
552
580
  dialogs,
553
581
  };
package/dist/index.d.ts CHANGED
@@ -19,7 +19,9 @@ export type { AppShellProps, } from './types';
19
19
  export type { MetadataState, MetadataContextValue, MetadataTypeStatus, } from './providers/MetadataProvider';
20
20
  export type { ExpressionContextValue, } from './providers/ExpressionProvider';
21
21
  export type { RecentItem, } from './hooks/useRecentItems';
22
- export { ConsoleShell, ConnectedShell, RequireOrganization, AuthenticatedRoute, RootRedirect, SystemRedirect, LoadingFallback, } from './console/ConsoleShell';
22
+ export { ConsoleShell, ConnectedShell, RequireOrganization, RequireAiSurface, AuthenticatedRoute, RootRedirect, SystemRedirect, LoadingFallback, } from './console/ConsoleShell';
23
+ export { useAiSurfaceEnabled } from './hooks/useAiSurface';
24
+ export type { AiSurfaceState } from './hooks/useAiSurface';
23
25
  export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, ConnectionStatus, ActivityFeed, LocaleSwitcher, ModeToggle, AuthPageLayout, } from './layout';
24
26
  export type { ActivityItem } from './layout';
25
27
  export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, RouteFader, toastWithUndo, type ToastWithUndoOptions, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
package/dist/index.js CHANGED
@@ -18,7 +18,11 @@ export { getPendingRequests, isIdle, subscribeSettle, whenIdle, withSettleSignal
18
18
  export { useRecentItems } from './hooks/useRecentItems';
19
19
  // Console building blocks — compose these in your App.tsx to build the console
20
20
  // routing tree. See examples/console-starter/src/App.tsx for a minimal example.
21
- export { ConsoleShell, ConnectedShell, RequireOrganization, AuthenticatedRoute, RootRedirect, SystemRedirect, LoadingFallback, } from './console/ConsoleShell';
21
+ export { ConsoleShell, ConnectedShell, RequireOrganization, RequireAiSurface, AuthenticatedRoute, RootRedirect, SystemRedirect, LoadingFallback, } from './console/ConsoleShell';
22
+ // Runtime AI-availability signal — the single source of truth every AI entry
23
+ // point gates on (FAB, /ai routes, designer "Ask AI"). Server-pushed, no
24
+ // build-time edition flag. See ./hooks/useAiSurface.
25
+ export { useAiSurfaceEnabled } from './hooks/useAiSurface';
22
26
  // Layout chrome
23
27
  export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, ConnectionStatus, ActivityFeed, LocaleSwitcher, ModeToggle, AuthPageLayout, } from './layout';
24
28
  // Top-level chrome (dialogs, providers, error boundaries)
@@ -20,7 +20,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
20
20
  */
21
21
  import { useLocation, useParams, Link, useNavigate } from 'react-router-dom';
22
22
  import { Button, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, Avatar, AvatarImage, AvatarFallback, cn, } from '@object-ui/components';
23
- import { Search, HelpCircle, ChevronDown, Check, Lock, LogOut, Boxes, Layers, Bot, User, BookOpen, ExternalLink, Keyboard, } from 'lucide-react';
23
+ import { Search, HelpCircle, ChevronDown, Check, Lock, LogOut, Boxes, Plus, Layers, Bot, User, BookOpen, ExternalLink, Keyboard, } from 'lucide-react';
24
24
  import { useState, useEffect, useCallback, useRef } from 'react';
25
25
  import { useOffline } from '@object-ui/react';
26
26
  import { PresenceAvatars, useTenantPresence } from '@object-ui/collaboration';
@@ -39,6 +39,7 @@ import { useMobileViewSwitcher } from './MobileViewSwitcherContext';
39
39
  import { useNavigationContext } from '../context/NavigationContext';
40
40
  import { useCommandPalette } from '../context/CommandPaletteProvider';
41
41
  import { useUrlOverlay } from '../hooks/useUrlOverlay';
42
+ import { useAiSurfaceEnabled } from '../hooks/useAiSurface';
42
43
  import { getProductName } from '../runtime-config';
43
44
  import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger';
44
45
  function humanizeSlug(slug) {
@@ -64,8 +65,12 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
64
65
  // Click-reachable entry for the keyboard-shortcuts dialog (was `?`-key only).
65
66
  // Shares the `?shortcuts=1` URL param with KeyboardShortcutsDialog (C2/C3).
66
67
  const { openOverlay: openShortcuts } = useUrlOverlay('shortcuts');
67
- const { user, signOut, isAuthEnabled, organizations, activeOrganization, isOrganizationsLoading, } = useAuth();
68
+ const { user, signOut, isAuthEnabled, organizations, activeOrganization, isOrganizationsLoading, getAuthConfig, } = useAuth();
68
69
  const dataSource = useAdapter();
70
+ // Runtime AI gating: hide the top-bar AI entry point when the server serves
71
+ // no AI (Community Edition) so it can't dead-end on a chat with no agent.
72
+ // Same signal as the FAB and the `/ai` route guard.
73
+ const { enabled: aiEnabled } = useAiSurfaceEnabled();
69
74
  const { t } = useObjectTranslation();
70
75
  const { objectLabel, dashboardLabel, pageLabel, reportLabel, viewLabel, appLabel } = useObjectLabel();
71
76
  const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata();
@@ -432,6 +437,25 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
432
437
  const activeActivities = activities ?? apiActivities ?? [];
433
438
  const orgList = organizations ?? [];
434
439
  const hasOrgSection = isOrganizationsLoading || orgList.length > 0 || !!activeOrganization;
440
+ // Mirror the server's `beforeCreateOrganization` gate so the "Create
441
+ // workspace" entry only shows where multi-org self-service is enabled.
442
+ // Default to allowed until the config resolves (avoids hiding it on slow
443
+ // networks); the server still enforces.
444
+ const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
445
+ useEffect(() => {
446
+ let cancelled = false;
447
+ getAuthConfig?.()
448
+ .then((cfg) => {
449
+ if (!cancelled)
450
+ setMultiOrgDisabled(cfg?.features?.multiOrgEnabled === false);
451
+ })
452
+ .catch(() => {
453
+ /* leave default — server still enforces */
454
+ });
455
+ return () => {
456
+ cancelled = true;
457
+ };
458
+ }, [getAuthConfig]);
435
459
  // Build path segments (only used in `app` variant)
436
460
  const pathParts = location.pathname.split('/').filter(Boolean);
437
461
  const appNameFromRoute = params.appName || pathParts[1];
@@ -545,10 +569,10 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
545
569
  if (!isActive)
546
570
  mobileSwitcher.onChange(v.id);
547
571
  }, className: "gap-2", children: [v.icon ? (_jsx("span", { className: "shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4", children: v.icon })) : null, _jsx("span", { className: "flex-1 truncate", children: v.label }), v.locked ? (_jsx(Lock, { className: "h-3 w-3 shrink-0 text-muted-foreground", "aria-hidden": true })) : null, isActive ? (_jsx(Check, { className: "h-4 w-4 shrink-0 text-foreground", "aria-hidden": true })) : null] }, v.id));
548
- }) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel }))] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), t('topbar.offline', { defaultValue: 'Offline' })] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: t('topbar.usersOnline', { defaultValue: 'Users currently online' }), children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { type: "button", "data-testid": "action:command-palette:open", "aria-label": t('console.search', { defaultValue: 'Search…' }), "aria-keyshortcuts": "Meta+K Control+K", onClick: openCommandPalette, className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search…' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", "data-testid": "action:command-palette:open-mobile", onClick: openCommandPalette, "aria-label": t('console.search', { defaultValue: 'Search…' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0", asChild: true, "aria-label": t('topbar.aiAssistant', { defaultValue: 'AI Assistant' }), children: _jsx(Link, { to: "/ai", children: _jsx(Bot, { className: "h-4 w-4" }) }) }), _jsxs(DropdownMenu, { onOpenChange: (open) => { if (open)
572
+ }) })] })) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label }))) : (_jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel }))] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), t('topbar.offline', { defaultValue: 'Offline' })] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: t('topbar.usersOnline', { defaultValue: 'Users currently online' }), children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { type: "button", "data-testid": "action:command-palette:open", "aria-label": t('console.search', { defaultValue: 'Search…' }), "aria-keyshortcuts": "Meta+K Control+K", onClick: openCommandPalette, className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search…' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", "data-testid": "action:command-palette:open-mobile", onClick: openCommandPalette, "aria-label": t('console.search', { defaultValue: 'Search…' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx(InboxPopover, { notifications: notifications, unreadCount: unreadCount, pendingApprovalsCount: pendingApprovalsCount, activities: activeActivities, onMarkAllRead: markAllRead, onMarkRead: markNotificationRead }), aiEnabled && (_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0", asChild: true, "aria-label": t('topbar.aiAssistant', { defaultValue: 'AI Assistant' }), children: _jsx(Link, { to: "/ai", children: _jsx(Bot, { className: "h-4 w-4" }) }) })), _jsxs(DropdownMenu, { onOpenChange: (open) => { if (open)
549
573
  void loadHelpDocs(); }, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-56 rounded-lg", sideOffset: 4, children: [currentAppDocs.length > 0 && currentAppPackageId ? (_jsxs(DropdownMenuItem, { className: "cursor-pointer", onClick: () => navigate(currentAppDocs.length === 1
550
574
  ? `/apps/${currentAppPackageId}/docs/${currentAppDocs[0].name}`
551
- : `/apps/${currentAppPackageId}/docs`), children: [_jsx(BookOpen, { className: "mr-2 h-4 w-4" }), t('help.appDocs', { defaultValue: "This app's docs" })] })) : null, _jsxs(DropdownMenuItem, { className: "cursor-pointer", onClick: () => navigate('/docs'), children: [_jsx(Layers, { className: "mr-2 h-4 w-4" }), t('help.allDocs', { defaultValue: 'All documentation' })] }), isApp ? (_jsxs(DropdownMenuItem, { className: "cursor-pointer", "data-testid": "action:keyboard-shortcuts:open", onClick: openShortcuts, children: [_jsx(Keyboard, { className: "mr-2 h-4 w-4" }), t('help.keyboardShortcuts', { defaultValue: 'Keyboard shortcuts' })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { asChild: true, className: "cursor-pointer", children: _jsxs("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: [_jsx(ExternalLink, { className: "mr-2 h-4 w-4" }), t('help.onlineDocs', { defaultValue: 'Online documentation' })] }) })] })] })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/account/component/account/profile_card'), className: "cursor-pointer", children: [_jsx(User, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), (metadataApps || [])
575
+ : `/apps/${currentAppPackageId}/docs`), children: [_jsx(BookOpen, { className: "mr-2 h-4 w-4" }), t('help.appDocs', { defaultValue: "This app's docs" })] })) : null, _jsxs(DropdownMenuItem, { className: "cursor-pointer", onClick: () => navigate('/docs'), children: [_jsx(Layers, { className: "mr-2 h-4 w-4" }), t('help.allDocs', { defaultValue: 'All documentation' })] }), isApp ? (_jsxs(DropdownMenuItem, { className: "cursor-pointer", "data-testid": "action:keyboard-shortcuts:open", onClick: openShortcuts, children: [_jsx(Keyboard, { className: "mr-2 h-4 w-4" }), t('help.keyboardShortcuts', { defaultValue: 'Keyboard shortcuts' })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { asChild: true, className: "cursor-pointer", children: _jsxs("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: [_jsx(ExternalLink, { className: "mr-2 h-4 w-4" }), t('help.onlineDocs', { defaultValue: 'Online documentation' })] }) })] })] })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [" ", _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/account/component/account/profile_card'), className: "cursor-pointer", children: [_jsx(User, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations?manage=1'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), hasOrgSection && !multiOrgDisabled && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations?create=1'), className: "cursor-pointer", "data-testid": "header-create-workspace", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.create', { defaultValue: 'Create workspace' })] })), (metadataApps || [])
552
576
  .filter((a) => a.active !== false && a.hidden === true && a.name !== 'account')
553
577
  .map((app) => {
554
578
  const AppIcon = getIcon(app.icon);
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  */
14
14
  import React from 'react';
15
15
  import { useReconcileOnError } from '../hooks/useReconcileOnError';
16
- import { FloatingChatbot, useObjectChat, useAgents, useHitlInChat, resolveDefaultAgentName, uiMessagesToChatMessages, publishHealthFromResponse, agentRouteName, } from '@object-ui/plugin-chatbot';
16
+ import { FloatingChatbot, useObjectChat, useAgents, useAiModels, useHitlInChat, resolveDefaultAgentName, uiMessagesToChatMessages, publishHealthFromResponse, agentRouteName, } from '@object-ui/plugin-chatbot';
17
17
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, } from '@object-ui/components';
18
18
  import { Share2, SquarePen } from 'lucide-react';
19
19
  import { useObjectTranslation } from '@object-ui/i18n';
@@ -257,6 +257,14 @@ function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activ
257
257
  // the agent context so "add a priority field" acts on the open object,
258
258
  // and drives context-aware starter suggestions.
259
259
  const { editor } = useAssistant();
260
+ // ADR-0028: the plan-filtered AI-model allowlist this env offers in the
261
+ // picker (free / single-model envs return one entry → the footer picker
262
+ // hides itself). The selected id is sent with each turn; the backend
263
+ // validates it against the same allowlist and weights the quota by the
264
+ // chosen model's cost_weight.
265
+ const { models: aiModels, defaultModelId } = useAiModels({ apiBase });
266
+ const [selectedModelId, setSelectedModelId] = React.useState(undefined);
267
+ const effectiveModelId = selectedModelId ?? defaultModelId;
260
268
  // Replay persisted history when present.
261
269
  const hydratedHistory = React.useMemo(() => {
262
270
  if (!persistedMessages || persistedMessages.length === 0)
@@ -277,6 +285,9 @@ function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activ
277
285
  const { messages, isLoading, error, sendMessage, stop, reload, clear, setMessages, } = useObjectChat({
278
286
  api: chatApi,
279
287
  conversationId,
288
+ // ADR-0028: the user's picked model (or the env default) — sent in each
289
+ // request body; the agent route validates it + routes the turn to it.
290
+ model: effectiveModelId,
280
291
  onError: handleChatError,
281
292
  body: {
282
293
  context: {
@@ -348,7 +359,10 @@ function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activ
348
359
  panelHeight: 560,
349
360
  title: locale.title,
350
361
  triggerSize: 56,
351
- }, headerExtra: headerExtra, headerActions: headerActions, messages: messages, labels: locale.labels, showAvatars: true, hideClearBar: true, assistantAvatarFallback: locale.agentLabel, suggestions: messages.length === 0 ? (editorSuggestions ?? locale.suggestions) : undefined, placeholder: activeAgent
362
+ }, headerExtra: headerExtra, headerActions: headerActions, messages: messages, labels: locale.labels,
363
+ // ADR-0028: model picker — ChatbotEnhanced renders the footer <select>
364
+ // only when 2+ models are offered, so free / single-model envs see none.
365
+ models: aiModels, selectedModelId: effectiveModelId, onModelChange: setSelectedModelId, showAvatars: true, hideClearBar: true, assistantAvatarFallback: locale.agentLabel, suggestions: messages.length === 0 ? (editorSuggestions ?? locale.suggestions) : undefined, placeholder: activeAgent
352
366
  ? locale.placeholder
353
367
  : agentsLoading
354
368
  ? locale.loadingPlaceholder
@@ -10,7 +10,6 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
10
10
  */
11
11
  import { useEffect } from 'react';
12
12
  import { AppShell } from '@object-ui/layout';
13
- import { useDiscovery } from '@object-ui/react';
14
13
  import { isBuildAgent } from '@object-ui/plugin-chatbot';
15
14
  // Lightweight FAB stub — the heavy chat chunk graph (plugin-chatbot,
16
15
  // shiki, streamdown, mermaid, @ai-sdk, ~20MB) only downloads on first
@@ -22,6 +21,7 @@ import { UnifiedSidebar } from './UnifiedSidebar';
22
21
  import { AppHeader } from './AppHeader';
23
22
  import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext';
24
23
  import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
24
+ import { useAiSurfaceEnabled } from '../hooks/useAiSurface';
25
25
  import { useNavigationContext } from '../context/NavigationContext';
26
26
  import { CommandPaletteProvider } from '../context/CommandPaletteProvider';
27
27
  import { resolveI18nLabel } from '../utils';
@@ -35,11 +35,10 @@ function ConsoleLayoutInner({ children }) {
35
35
  // (moved to ./ConsoleFloatingChatbot.tsx for code-splitting)
36
36
  export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange, objects, connectionState, userId, }) {
37
37
  const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
38
- const { isAiEnabled } = useDiscovery();
39
- // Trust an explicit `VITE_AI_BASE_URL` opt-in even when discovery reports
40
- // AI as disabled (e.g. framework started without `--preset full`).
41
- const aiBaseUrlConfigured = Boolean(import.meta.env?.VITE_AI_BASE_URL);
42
- const showChatbot = isAiEnabled || aiBaseUrlConfigured;
38
+ // Runtime, server-pushed AI gating (shared with the `/ai` route guard and the
39
+ // top-bar AI link): show the chatbot only when the server actually serves AI,
40
+ // or when an explicit `VITE_AI_BASE_URL` opt-in points at an external server.
41
+ const { enabled: showChatbot } = useAiSurfaceEnabled();
43
42
  // AI Studio (AI-driven metadata authoring / "online development") can be
44
43
  // turned off per deployment. When off, suppress the metadata-authoring
45
44
  // assistant so the chatbot falls back to the generic data assistant — the