@object-ui/app-shell 11.3.0 → 11.5.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 (90) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/README.md +32 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +25 -3
  6. package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
  7. package/dist/console/home/CloudOnboardingNext.js +14 -4
  8. package/dist/console/home/HomePage.js +34 -7
  9. package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
  10. package/dist/console/organizations/OrganizationsPage.js +16 -7
  11. package/dist/context/CommandPaletteProvider.js +2 -1
  12. package/dist/hooks/useConsoleActionRuntime.js +32 -3
  13. package/dist/hooks/useObjectActions.js +16 -4
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/layout/AppHeader.js +13 -5
  17. package/dist/layout/AppSidebar.js +10 -4
  18. package/dist/observability/sentry.d.ts +5 -0
  19. package/dist/observability/sentry.js +6 -1
  20. package/dist/preview/DraftChangesPanel.d.ts +31 -1
  21. package/dist/preview/DraftChangesPanel.js +146 -18
  22. package/dist/urlParams.d.ts +68 -0
  23. package/dist/urlParams.js +76 -0
  24. package/dist/utils/appRoute.d.ts +15 -0
  25. package/dist/utils/appRoute.js +22 -0
  26. package/dist/utils/deriveRelatedLists.d.ts +20 -5
  27. package/dist/utils/deriveRelatedLists.js +31 -13
  28. package/dist/utils/index.d.ts +1 -1
  29. package/dist/utils/index.js +1 -1
  30. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  31. package/dist/utils/pageTabsUrlSync.js +43 -0
  32. package/dist/utils/recordFormNavigation.d.ts +40 -0
  33. package/dist/utils/recordFormNavigation.js +30 -0
  34. package/dist/utils/resolveViewId.d.ts +23 -0
  35. package/dist/utils/resolveViewId.js +37 -0
  36. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  37. package/dist/utils/warnSuppressedListNav.js +40 -0
  38. package/dist/views/InterfaceListPage.d.ts +1 -0
  39. package/dist/views/InterfaceListPage.js +7 -5
  40. package/dist/views/ObjectDataPage.d.ts +29 -0
  41. package/dist/views/ObjectDataPage.js +227 -0
  42. package/dist/views/ObjectView.js +65 -13
  43. package/dist/views/RecordDetailView.js +191 -123
  44. package/dist/views/RecordFormPage.js +7 -1
  45. package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
  46. package/dist/views/RelatedRecordActionsBridge.js +147 -0
  47. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  48. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  49. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  50. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  51. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  52. package/dist/views/metadata-admin/i18n.js +557 -6
  53. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  54. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  55. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  56. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  57. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +60 -30
  58. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  59. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  60. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  61. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  62. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  63. package/dist/views/metadata-admin/nav-selection.js +81 -0
  64. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  65. package/dist/views/metadata-admin/permission-slice.js +70 -0
  66. package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
  67. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  68. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  69. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  70. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  71. package/dist/views/studio-design/BuilderLanding.js +126 -0
  72. package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
  73. package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
  74. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  75. package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
  76. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  77. package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
  78. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  79. package/dist/views/studio-design/PackageIdInput.js +40 -0
  80. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  81. package/dist/views/studio-design/StudioDesignSurface.js +962 -145
  82. package/dist/views/studio-design/metadataError.d.ts +23 -0
  83. package/dist/views/studio-design/metadataError.js +44 -0
  84. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  85. package/dist/views/studio-design/packageSurfaces.js +34 -0
  86. package/dist/views/studio-design/packages-io.d.ts +38 -0
  87. package/dist/views/studio-design/packages-io.js +73 -0
  88. package/dist/views/studio-design/skeletons.d.ts +16 -0
  89. package/dist/views/studio-design/skeletons.js +51 -0
  90. package/package.json +42 -39
package/README.md CHANGED
@@ -152,6 +152,15 @@ for each metadata type. Every type has a pure-renderer **preview** that doubles
152
152
  as its **designer** when given `editing` + `onPatch` props — no backend round
153
153
  trip is required to edit a draft.
154
154
 
155
+ ### App → Studio reverse bridge
156
+
157
+ Inside a running app, workspace admins get a "Design in Studio" entry in the
158
+ top bar (`AppHeader`) that deep-links to the app's owning package on the Studio
159
+ design surface (`/studio/:packageId/data`). It is the reverse of the builder's
160
+ "Open app" bridge (ADR-0080): the entry only renders for admins and only when
161
+ the app has an owning package (`_packageId`), and package writability stays a
162
+ server-side concern — a read-only package opens in Studio as browse-only.
163
+
155
164
  ### Studio package scope
156
165
 
157
166
  Studio treats the selected package as the authoring scope. The package selector
@@ -164,6 +173,29 @@ enable, or disable packages; direct `/metadata/package` links redirect there.
164
173
  The Studio sidebar also flattens the root Overview group so Home and package
165
174
  navigation sit directly under the package selector.
166
175
 
176
+ ### Access matrix (package-scoped)
177
+
178
+ The Access pillar's permission matrix follows the active package (ADR-0086 P0).
179
+ A Permission Set / Profile is a single record whose `objects` / `fields` maps
180
+ accumulate authorization rows contributed by many packages, so the matrix:
181
+
182
+ - lists **only the objects the active package declares** — the panel never
183
+ exposes the whole environment's objects; and
184
+ - saves via **slice-merge** — it re-reads the record and writes back just this
185
+ package's slice, leaving rows contributed by other packages untouched.
186
+
187
+ The left rail lists only permission sets this package owns — the metadata API
188
+ filters `permission` by the record-level `package_id` provenance server-side
189
+ (framework ADR-0086 P1), via `client.list('permission', { packageId })`, so
190
+ environment-owned platform defaults (`admin_full_access`, `member_default`, …)
191
+ are excluded by the backend. (The `?package=` list rows don't echo the
192
+ provenance columns, so a client-side filter can't do this.) Save writes a
193
+ package draft and publishes with the whole package (ADR-0086 P2). Rendering
194
+ `PermissionMatrixEditPage` without a `packageId` keeps the environment-wide
195
+ behavior (full object list, whole-record save). The scope/merge helpers
196
+ (`scopePermissionSet`, `mergePermissionSlice`) live in
197
+ `metadata-admin/permission-slice.ts`.
198
+
167
199
  ### Visual flow canvas
168
200
 
169
201
  The `flow` designer (`FlowPreview` → `FlowCanvas`) renders an automation as an
@@ -9,12 +9,13 @@ import { useEffect, useMemo } from 'react';
9
9
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@object-ui/components';
10
10
  import { useObjectTranslation } from '@object-ui/i18n';
11
11
  import { useUrlOverlay } from '../hooks/useUrlOverlay';
12
+ import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams';
12
13
  export function KeyboardShortcutsDialog() {
13
14
  const { t } = useObjectTranslation();
14
15
  // URL-addressable (?shortcuts=1) so the dialog is deep-linkable and openable
15
16
  // from the header Help menu, not only via the `?` keyboard accelerator (ADR-0054
16
17
  // C1/C2/C3).
17
- const { open, setOpen, toggleOverlay } = useUrlOverlay('shortcuts');
18
+ const { open, setOpen, toggleOverlay } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM);
18
19
  const shortcutGroups = useMemo(() => [
19
20
  {
20
21
  title: t('console.shortcuts.groups.general'),
@@ -8,13 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * AuthGuard, AdapterProvider, MetadataProvider, theme/toaster, /home, /login,
9
9
  * /organizations) is provided by `createConsole` from @object-ui/app-shell.
10
10
  */
11
- import { Routes, Route, Navigate, useNavigate, useLocation, useParams } from 'react-router-dom';
11
+ import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams } from 'react-router-dom';
12
12
  import { useState, useEffect, useCallback, useRef, lazy, Suspense, useMemo } from 'react';
13
13
  import { useAssistant } from '../assistant/assistantBus';
14
14
  import { ModalForm } from '@object-ui/plugin-form';
15
15
  import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
16
16
  import { toast } from 'sonner';
17
- import { useActionRunner, useGlobalUndo } from '@object-ui/react';
17
+ import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged } from '@object-ui/react';
18
18
  import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
19
19
  import { useAuth } from '@object-ui/auth';
20
20
  import { useMetadata } from '../providers/MetadataProvider';
@@ -23,7 +23,9 @@ import { usePreviewDrafts } from '../preview/PreviewModeContext';
23
23
  import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState';
24
24
  import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
25
25
  import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
26
- import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
26
+ import { resolveRecordFormTarget, resolveFormViewLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget } from '../utils/recordFormNavigation';
27
+ import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
28
+ import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams';
27
29
  import { matchAppBySegment } from '../utils/appRoute';
28
30
  import { resolveHref } from '@object-ui/layout';
29
31
  import { ExpressionEvaluator } from '@object-ui/core';
@@ -45,6 +47,7 @@ const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default
45
47
  const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
46
48
  const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
47
49
  const ComponentNavView = lazy(() => import('../views/ComponentNavView').then(m => ({ default: m.ComponentNavView })));
50
+ const ObjectDataPage = lazy(() => import('../views/ObjectDataPage').then(m => ({ default: m.ObjectDataPage })));
48
51
  // Metadata admin — mounted under /apps/:app/metadata. Lives at the top
49
52
  // level so URLs read like a normal nested resource (RFC-style) instead of
50
53
  // piggy-backing on the legacy ComponentRegistry fan-out.
@@ -201,30 +204,80 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
201
204
  navigate(`/apps/${seg}/component/developer/packages`, { replace: true });
202
205
  }
203
206
  }, [activeApp?.name, appName, location.pathname, navigate]);
204
- const [isDialogOpen, setIsDialogOpen] = useState(false);
205
- const [editingRecord, setEditingRecord] = useState(null);
207
+ // #2604 the create/edit overlay is URL-driven (`?form=new` / `?form=<id>`),
208
+ // not component state: the record form is a TASK overlay over the origin
209
+ // route, and putting its open-state in the URL makes browser Back close the
210
+ // overlay (returning to the intact origin) instead of abandoning the route
211
+ // with the overlay marooned on top. Same pattern as the detail drawer's
212
+ // `?recordId=…` (useUrlOverlay / ADR-0054 C3). Open pushes a history entry;
213
+ // every close strips the param with `replace` so no stale reopen-entry stays
214
+ // ahead in history.
215
+ const [searchParams, setSearchParams] = useSearchParams();
216
+ const recordFormParam = searchParams.get(RECORD_FORM_PARAM);
217
+ // #2604 D3 — child-task extension of the record-form URL contract:
218
+ // `formObject` names the object the form edits when it is NOT the route's
219
+ // object (a subtable child opened over its parent's detail); `formLink`
220
+ // ("field:id") pre-links the parent on create. Keeping the whole task in
221
+ // the URL means Back closes the overlay and a refresh reopens it still
222
+ // correctly parent-linked — no transient component state to lose.
223
+ const formObjectParam = searchParams.get(RECORD_FORM_OBJECT_PARAM);
224
+ const formLinkParam = searchParams.get(RECORD_FORM_LINK_PARAM);
225
+ const editingRecord = useMemo(() => (recordFormParam && recordFormParam !== 'new' ? { id: recordFormParam } : null), [recordFormParam]);
226
+ const formLinkValues = useMemo(() => {
227
+ if (!formLinkParam)
228
+ return undefined;
229
+ const i = formLinkParam.indexOf(':');
230
+ if (i <= 0)
231
+ return undefined;
232
+ return { [formLinkParam.slice(0, i)]: formLinkParam.slice(i + 1) };
233
+ }, [formLinkParam]);
206
234
  const [refreshKey, setRefreshKey] = useState(0);
235
+ const isDialogOpen = !!recordFormParam;
236
+ // Close the record-form overlay by stripping `?form` in place. Reads the
237
+ // LIVE location (not the hook's render-time snapshot) so it stays a no-op
238
+ // when a success handler already navigated away in the same tick (the
239
+ // post-create redirect below).
240
+ const closeRecordForm = useCallback(() => {
241
+ const sp = new URLSearchParams(window.location.search);
242
+ if (!sp.has(RECORD_FORM_PARAM) && !sp.has(RECORD_FORM_OBJECT_PARAM) && !sp.has(RECORD_FORM_LINK_PARAM))
243
+ return;
244
+ sp.delete(RECORD_FORM_PARAM);
245
+ sp.delete(RECORD_FORM_OBJECT_PARAM);
246
+ sp.delete(RECORD_FORM_LINK_PARAM);
247
+ setSearchParams(sp, { replace: true });
248
+ }, [setSearchParams]);
207
249
  const { execute: executeAction, runner } = useActionRunner();
250
+ // objectui#2269 — bridge every dataSource write (create/update/delete →
251
+ // MutationEvent) onto the invalidation bus, ONCE for the whole console.
252
+ // Readers (record detail, related lists, count badges) refetch in place;
253
+ // nothing is remounted for a data refresh.
254
+ useMutationInvalidationBridge(dataSource);
208
255
  useGlobalUndo({
209
256
  dataSource: dataSource ?? undefined,
210
257
  onUndo: (op) => {
211
258
  toast.info(`Undo: ${op.description}`, { duration: 4000 });
212
259
  setRefreshKey(k => k + 1);
260
+ // Precisely-scoped invalidation — UndoableOperation carries the target
261
+ // (objectName + recordId), so detail readers refresh in place too.
262
+ if (op?.objectName)
263
+ notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
213
264
  },
214
265
  onRedo: (op) => {
215
266
  toast.info(`Redo: ${op.description}`, { duration: 3000 });
216
267
  setRefreshKey(k => k + 1);
268
+ if (op?.objectName)
269
+ notifyDataChanged({ objectName: op.objectName, recordId: op.recordId });
217
270
  },
218
271
  });
219
272
  useEffect(() => {
220
273
  runner.registerHandler('crud_success', async (action) => {
221
- setIsDialogOpen(false);
274
+ closeRecordForm();
222
275
  setRefreshKey(k => k + 1);
223
276
  toast.success(action.params?.message ?? 'Record saved successfully');
224
277
  return { success: true, reload: true };
225
278
  });
226
279
  runner.registerHandler('dialog_cancel', async () => {
227
- setIsDialogOpen(false);
280
+ closeRecordForm();
228
281
  return { success: true };
229
282
  });
230
283
  // Page-mode navigation handlers — declarative counterparts to the
@@ -274,7 +327,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
274
327
  // `flow` handler on this top-level useActionRunner — it lives on a
275
328
  // different ActionRunner instance and would never be invoked from the
276
329
  // record/list action buttons.
277
- }, [runner, navigate, appName]);
330
+ }, [runner, navigate, appName, closeRecordForm]);
278
331
  useEffect(() => {
279
332
  if (!dataSource)
280
333
  return;
@@ -296,8 +349,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
296
349
  objectNameFromPath = '';
297
350
  }
298
351
  const currentObjectDef = allObjects.find((o) => o.name === objectNameFromPath);
352
+ // The object the record-form overlay edits: the route's object by default,
353
+ // or the `formObject` child override (#2604 D3 — subtable child task opened
354
+ // over its parent's detail).
355
+ const formObjectDef = formObjectParam
356
+ ? allObjects.find((o) => o.name === formObjectParam)
357
+ : currentObjectDef;
358
+ const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;
299
359
  const handleCrudSuccess = useCallback(() => {
300
- const label = currentObjectDef ? objectLabel(currentObjectDef) : t('common.record', { defaultValue: 'Record' });
360
+ const label = formObjectDef ? objectLabel(formObjectDef) : t('common.record', { defaultValue: 'Record' });
301
361
  executeAction({
302
362
  type: 'crud_success',
303
363
  params: {
@@ -306,10 +366,45 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
306
366
  : t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }),
307
367
  },
308
368
  });
309
- }, [executeAction, editingRecord, currentObjectDef, objectLabel, t]);
369
+ }, [executeAction, editingRecord, formObjectDef, objectLabel, t]);
310
370
  const handleDialogCancel = useCallback(() => {
311
371
  executeAction({ type: 'dialog_cancel' });
312
372
  }, [executeAction]);
373
+ // #2604 save invariant — *edit never moves you; create takes you to the
374
+ // record you made.* Edit save: the origin route is untouched (crud_success
375
+ // bumps refreshKey → origin refetches in place). Create save: land on the
376
+ // new record's detail, on ITS derived surface — a light object's detail is
377
+ // the drawer over the still-intact list; a heavy one is the detail route.
378
+ // `replace: true` swaps out the transient `?form=…` entry so Back returns
379
+ // to the pre-create origin.
380
+ const handleRecordFormSuccess = useCallback(async (saved) => {
381
+ // Child task (#2604 D3): the parent detail must stay EXACTLY as it was —
382
+ // active tab, scroll, everything. So do NOT go through crud_success;
383
+ // the child's open related lists refetch on their own via the
384
+ // invalidation bus (#2269): the dataSource write already emitted a
385
+ // MutationEvent that the bridge fans out — no manual notify needed.
386
+ if (isChildFormTask) {
387
+ const label = formObjectDef ? objectLabel(formObjectDef) : t('common.record', { defaultValue: 'Record' });
388
+ toast.success(editingRecord
389
+ ? t('form.updateSuccess', { object: label, defaultValue: `${label} updated successfully` })
390
+ : t('form.createSuccess', { object: label, defaultValue: `${label} created successfully` }));
391
+ closeRecordForm();
392
+ return;
393
+ }
394
+ handleCrudSuccess();
395
+ if (editingRecord || !currentObjectDef)
396
+ return;
397
+ const target = resolvePostCreateTarget({
398
+ objectName: currentObjectDef.name,
399
+ baseUrl: appName ? `/apps/${appName}` : (activeApp?.name ? `/apps/${activeApp.name}` : ''),
400
+ pathname: location.pathname,
401
+ search: window.location.search,
402
+ surface: deriveRecordSurface(currentObjectDef),
403
+ recordId: saved?.id ?? saved?._id,
404
+ });
405
+ if (target.kind !== 'none')
406
+ navigate(target.url, { replace: true });
407
+ }, [handleCrudSuccess, isChildFormTask, formObjectDef, editingRecord, currentObjectDef, appName, activeApp?.name, location.pathname, navigate, closeRecordForm, objectLabel, t]);
313
408
  // Track recent items on route change.
314
409
  useTrackRouteAsRecent({
315
410
  pathname: location.pathname,
@@ -330,8 +425,16 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
330
425
  navigate(target.url);
331
426
  return;
332
427
  }
333
- setEditingRecord(record);
334
- setIsDialogOpen(true);
428
+ // Open the overlay via the URL (pushes one history entry → Back closes
429
+ // the overlay, origin intact). `new` = create; a record id = edit.
430
+ const rawId = record?.id ?? record?._id;
431
+ const sp = new URLSearchParams(window.location.search);
432
+ sp.set(RECORD_FORM_PARAM, rawId != null && rawId !== '' ? String(rawId) : 'new');
433
+ // Top-level task on the route's own object — drop any stale child-task
434
+ // overrides (see the record-form URL contract above).
435
+ sp.delete(RECORD_FORM_OBJECT_PARAM);
436
+ sp.delete(RECORD_FORM_LINK_PARAM);
437
+ setSearchParams(sp);
335
438
  };
336
439
  const handleAppChange = (newAppName) => {
337
440
  navigate(`/apps/${newAppName}`);
@@ -395,12 +498,27 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
395
498
  // overview instead of a blank `<Navigate to="">`.
396
499
  const landing = resolveLandingRoute(activeApp, { currentUserId: user?.id ?? null });
397
500
  return landing ? _jsx(Navigate, { to: landing, replace: true }) : _jsx(StudioHomePage, {});
398
- })() }), _jsx(Route, { path: "metadata/package/*", element: _jsx(Navigate, { to: `/apps/${appName ?? activeApp.name}/component/developer/packages`, replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _jsx(Route, { path: "_diagnostics", element: _jsx(MetadataDiagnosticsPage, {}) }), _jsx(Route, { path: ":type", element: _jsx(MetadataResourceListPage, {}) }), _jsx(Route, { path: ":type/new", element: _jsx(MetadataResourceEditPage, { createMode: true }) }), _jsx(Route, { path: ":type/:name", element: _jsx(MetadataResourceEditPage, {}) }), _jsx(Route, { path: ":type/:name/history", element: _jsx(MetadataResourceHistoryPage, {}) })] }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "component/metadata/directory", element: _jsx(LegacyMetadataRedirect, { mode: "directory" }) }), _jsx(Route, { path: "component/metadata/resource/*", element: _jsx(LegacyMetadataRedirect, { mode: "resource" }) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
501
+ })() }), _jsx(Route, { path: "metadata/package/*", element: _jsx(Navigate, { to: `/apps/${appName ?? activeApp.name}/component/developer/packages`, replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _jsx(Route, { path: "_diagnostics", element: _jsx(MetadataDiagnosticsPage, {}) }), _jsx(Route, { path: ":type", element: _jsx(MetadataResourceListPage, {}) }), _jsx(Route, { path: ":type/new", element: _jsx(MetadataResourceEditPage, { createMode: true }) }), _jsx(Route, { path: ":type/:name", element: _jsx(MetadataResourceEditPage, {}) }), _jsx(Route, { path: ":type/:name/history", element: _jsx(MetadataResourceHistoryPage, {}) })] }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/data", element: _jsx(ObjectDataPage, { dataSource: dataSource, objects: allObjects }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "component/:ns/:name/*", element: _jsx(ComponentNavView, {}) }), _jsx(Route, { path: "component/metadata/directory", element: _jsx(LegacyMetadataRedirect, { mode: "directory" }) }), _jsx(Route, { path: "component/metadata/resource/*", element: _jsx(LegacyMetadataRedirect, { mode: "resource" }) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), formObjectDef && (_jsx(ModalForm, { schema: {
399
502
  type: 'object-form',
400
503
  formType: 'modal',
401
- objectName: currentObjectDef.name,
504
+ objectName: formObjectDef.name,
402
505
  mode: editingRecord ? 'edit' : 'create',
403
506
  recordId: editingRecord?.id,
507
+ // Child create task (#2604 D3): pre-link the parent from the
508
+ // `formLink` URL param (refresh-safe — the link survives).
509
+ ...(formLinkValues && !editingRecord ? { initialValues: formLinkValues } : {}),
510
+ // #2604 D1: create/edit follow the flow-surface derivation —
511
+ // field-heavy → full-screen modal (the same big canvas the
512
+ // detail page gets, with overlay return semantics); light
513
+ // objects keep the existing auto-sized modal (ModalForm
514
+ // infers from columns when modalSize is unset). Derived
515
+ // default only — anything spread later (form view layout)
516
+ // would win. Child tasks size to the CHILD object's def.
517
+ ...(deriveRecordFlowSurface(formObjectDef, isChildFormTask
518
+ ? (editingRecord ? 'child-edit' : 'child-create')
519
+ : (editingRecord ? 'edit' : 'create')).size === 'full'
520
+ ? { modalSize: 'full' }
521
+ : {}),
404
522
  // Honor the object's DEFAULT FORM VIEW: curated sections (field
405
523
  // selection + order + grouping), `contentLayout: 'tabbed'` when the
406
524
  // view is tabbed, and inline child collections (master-detail).
@@ -408,30 +526,31 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
408
526
  // win over the flat `fields` list below; otherwise this resolves to
409
527
  // {} and `fields` (every field, raw schema order) is used as before.
410
528
  // `formType` stays 'modal' (the container). (#1890 / ADR-0050.)
411
- ...resolveFormViewLayout(currentObjectDef),
529
+ ...resolveFormViewLayout(formObjectDef),
412
530
  title: editingRecord
413
- ? t('form.editTitle', { object: objectLabel(currentObjectDef) })
414
- : t('form.createTitle', { object: objectLabel(currentObjectDef) }),
531
+ ? t('form.editTitle', { object: objectLabel(formObjectDef) })
532
+ : t('form.createTitle', { object: objectLabel(formObjectDef) }),
415
533
  description: editingRecord
416
- ? t('form.editDescription', { object: objectLabel(currentObjectDef) })
417
- : t('form.createDescription', { object: objectLabel(currentObjectDef) }),
534
+ ? t('form.editDescription', { object: objectLabel(formObjectDef) })
535
+ : t('form.createDescription', { object: objectLabel(formObjectDef) }),
418
536
  open: isDialogOpen,
419
- onOpenChange: setIsDialogOpen,
537
+ onOpenChange: (open) => { if (!open)
538
+ closeRecordForm(); },
420
539
  layout: 'vertical',
421
- fields: currentObjectDef.fields
422
- ? (Array.isArray(currentObjectDef.fields)
423
- ? currentObjectDef.fields
540
+ fields: formObjectDef.fields
541
+ ? (Array.isArray(formObjectDef.fields)
542
+ ? formObjectDef.fields
424
543
  .filter((f) => {
425
544
  if (typeof f === 'string')
426
545
  return true;
427
546
  return evaluateVisibility(f.visible, expressionEvaluator);
428
547
  })
429
548
  .map((f) => typeof f === 'string' ? f : f.name)
430
- : Object.entries(currentObjectDef.fields)
549
+ : Object.entries(formObjectDef.fields)
431
550
  .filter(([_, f]) => evaluateVisibility(f.visible, expressionEvaluator))
432
551
  .map(([key]) => key))
433
552
  : [],
434
- onSuccess: handleCrudSuccess,
553
+ onSuccess: handleRecordFormSuccess,
435
554
  onCancel: handleDialogCancel,
436
555
  showSubmit: true,
437
556
  showCancel: true,
@@ -439,7 +558,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
439
558
  ? t('form.update', { defaultValue: t('common.save', { defaultValue: 'Save' }) })
440
559
  : t('form.create', { defaultValue: t('common.create', { defaultValue: 'Create' }) }),
441
560
  cancelText: t('common.cancel'),
442
- }, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
561
+ }, dataSource: dataSource }, `${formObjectDef.name}:${editingRecord?.id || 'new'}`))] })] }));
443
562
  }
444
563
  function findFirstRoute(items, ctx) {
445
564
  if (!items || items.length === 0)
@@ -14,7 +14,8 @@ import { Suspense, useEffect, useRef, useState } from 'react';
14
14
  import { Navigate, useLocation } from 'react-router-dom';
15
15
  import { AuthGuard, useAuth } from '@object-ui/auth';
16
16
  import { useObjectTranslation } from '@object-ui/i18n';
17
- import { SchemaRendererProvider } from '@object-ui/react';
17
+ import { SchemaRendererProvider, ActionProvider } from '@object-ui/react';
18
+ import { useActionModal } from '../hooks/useActionModal';
18
19
  import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
19
20
  import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
20
21
  import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
@@ -25,9 +26,30 @@ import { FavoritesProvider } from '../context/FavoritesProvider';
25
26
  import { RecentItemsProvider } from '../context/RecentItemsProvider';
26
27
  import { UserStateAdaptersProvider, useAttachUserStateAdapters, } from '../context/UserStateAdapters';
27
28
  import { ThemeProvider } from '../chrome/ThemeProvider';
29
+ import { LoadingScreen } from '../chrome/LoadingScreen';
28
30
  import { RemediationOverlay } from './RemediationOverlay';
31
+ // The console's every pre-React / pre-auth gate (Suspense fallback, adapter
32
+ // not ready, org/auth loading) renders this. It used to be a bare, unbranded
33
+ // "Loading…" line — ~8s of blank chrome before the login redirect on a cold
34
+ // /_console load (framework#2615 P3). Delegate to the branded, boot-safe
35
+ // splash (logo + product name + step list) that the rest of the shell already
36
+ // uses, so the cold load looks like the product from the first frame.
29
37
  export function LoadingFallback() {
30
- return (_jsx("div", { className: "h-screen flex items-center justify-center text-sm text-muted-foreground", children: "Loading\u2026" }));
38
+ return _jsx(LoadingScreen, {});
39
+ }
40
+ /**
41
+ * Provide a MODAL handler at the console ROOT — the same level the global
42
+ * SchemaRendererProvider (dataSource) sits — so it reaches EVERY field widget,
43
+ * including a relation field inside a create/edit form that renders in a Radix
44
+ * Dialog portal (which sits above the lower per-view ActionProviders). This is
45
+ * what lets a lookup's inline "create the referenced record" open that object's
46
+ * OWN create form and select the result. Only ADDS the modal capability where
47
+ * none existed; richer per-view ActionProviders still override it where they
48
+ * apply. Must render inside MetadataProvider (useActionModal reads useMetadata).
49
+ */
50
+ function GlobalCreateModalProvider({ dataSource, children }) {
51
+ const { modalHandler, modalElement } = useActionModal(dataSource);
52
+ return (_jsxs(ActionProvider, { onModal: modalHandler, children: [children, modalElement] }));
31
53
  }
32
54
  /**
33
55
  * ConsoleShell — top-level provider stack shared by every console route.
@@ -87,7 +109,7 @@ function ConnectedShellInner({ children }) {
87
109
  return _jsx(LoadingFallback, {});
88
110
  // Expose the adapter via SchemaRendererContext so descendant hooks like
89
111
  // useDiscovery() (used to gate the global AI chatbot) can resolve it.
90
- return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsxs(MetadataProvider, { adapter: adapter, children: [_jsx(UserStateBridge, {}), children] }, language) }));
112
+ return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsxs(MetadataProvider, { adapter: adapter, children: [_jsx(UserStateBridge, {}), _jsx(GlobalCreateModalProvider, { dataSource: adapter, children: children })] }, language) }));
91
113
  }
92
114
  /**
93
115
  * UserStateBridge — once we have an authenticated user + a connected data
@@ -4,6 +4,15 @@ interface CloudOnboardingNextProps {
4
4
  openProductionUrl?: string;
5
5
  /** SPA route to the environments list (create / open / manage). */
6
6
  environmentsRoute?: string;
7
+ /**
8
+ * Optional backend pre-warm endpoint. When set and the caller already has
9
+ * a production env, the widget fires a best-effort GET the moment it knows
10
+ * the env exists — nudging a possibly-asleep container awake WHILE the user
11
+ * reads the hint, so the later "Open Production" click lands in an
12
+ * already-waking env instead of paying the full cold-start. No-op when
13
+ * unset (older page metadata), so this is safe to ship ahead of the page.
14
+ */
15
+ warmUrl?: string;
7
16
  };
8
17
  }
9
18
  export declare function CloudOnboardingNext({ properties }: CloudOnboardingNextProps): import("react").JSX.Element;
@@ -43,7 +43,7 @@ function pick(label) {
43
43
  * `unknown` on any failure so the caller degrades gracefully rather than
44
44
  * blocking the user behind a wrong "create" CTA.
45
45
  */
46
- function useProductionEnvState() {
46
+ function useProductionEnvState(warmUrl) {
47
47
  const { activeOrganization } = useAuth();
48
48
  const orgId = activeOrganization?.id;
49
49
  const authFetch = useMemo(() => createAuthenticatedFetch(), []);
@@ -68,7 +68,17 @@ function useProductionEnvState() {
68
68
  setState({ phase: 'unknown' });
69
69
  return;
70
70
  }
71
- setState({ phase: 'ready', hasProductionEnv: data.hasProductionEnv === true });
71
+ const hasProductionEnv = data.hasProductionEnv === true;
72
+ setState({ phase: 'ready', hasProductionEnv });
73
+ // Pre-warm the prod env the instant we know it exists — fire-and-forget,
74
+ // best-effort, once per resolve. The user is now reading the hint and
75
+ // will click "Open Production" seconds later; by then the container is
76
+ // already waking. The sso-open click warms again, so a failed warm here
77
+ // costs nothing. Only when the page passes a warmUrl (else no-op).
78
+ if (hasProductionEnv && warmUrl) {
79
+ void authFetch(`${apiBase}${warmUrl}`, { method: 'GET', credentials: 'include' })
80
+ .catch(() => { });
81
+ }
72
82
  }
73
83
  catch {
74
84
  if (!cancelled)
@@ -78,7 +88,7 @@ function useProductionEnvState() {
78
88
  return () => {
79
89
  cancelled = true;
80
90
  };
81
- }, [authFetch, orgId]);
91
+ }, [authFetch, orgId, warmUrl]);
82
92
  return state;
83
93
  }
84
94
  /** Full-page nav to the backend SSO endpoint so the browser follows its 302. */
@@ -87,7 +97,7 @@ function openProduction(url) {
87
97
  }
88
98
  export function CloudOnboardingNext({ properties }) {
89
99
  const navigate = useNavigate();
90
- const state = useProductionEnvState();
100
+ const state = useProductionEnvState(properties?.warmUrl);
91
101
  const openUrl = properties?.openProductionUrl || DEFAULT_OPEN_PRODUCTION_URL;
92
102
  const envsRoute = properties?.environmentsRoute || DEFAULT_ENVIRONMENTS_ROUTE;
93
103
  // Loading — a neutral skeleton sized like the button row, so the hero doesn't
@@ -28,7 +28,7 @@ import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail';
28
28
  import { useHomeInbox } from '../../hooks/useHomeInbox';
29
29
  import { appRouteSegment } from '../../utils';
30
30
  import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
31
- import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText } from 'lucide-react';
31
+ import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText, Hammer, LayoutTemplate } from 'lucide-react';
32
32
  import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
33
33
  import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts';
34
34
  import { resolveAiApiBase } from '../../hooks/useAiSurface';
@@ -135,18 +135,41 @@ function PendingDraftsBanner({ t }) {
135
135
  */
136
136
  function RecoveryPasswordReminder({ t }) {
137
137
  const navigate = useNavigate();
138
- const { hasLocalPassword } = useAuth();
138
+ const { hasLocalPassword, getAuthConfig } = useAuth();
139
139
  const [show, setShow] = useState(false);
140
140
  useEffect(() => {
141
141
  if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-dismissed') === '1')
142
142
  return;
143
+ // Don't nag on the very first landing: let a brand-new SSO user reach
144
+ // their magic moment (build their first app) before asking them to set a
145
+ // recovery password. Record the first home visit; show the reminder only
146
+ // from the next one on. (The component already intends 'not before the
147
+ // first session'; previously the code still fired on the very first screen.)
148
+ if (typeof localStorage !== 'undefined' && localStorage.getItem('os:recovery-pw-first-seen') !== '1') {
149
+ try {
150
+ localStorage.setItem('os:recovery-pw-first-seen', '1');
151
+ }
152
+ catch { /* ignore */ }
153
+ return;
154
+ }
143
155
  let cancelled = false;
144
- Promise.resolve(hasLocalPassword?.())
145
- .then((has) => { if (!cancelled && has === false)
146
- setShow(true); })
156
+ Promise.all([
157
+ Promise.resolve(hasLocalPassword?.()),
158
+ Promise.resolve(getAuthConfig?.()).catch(() => null),
159
+ ])
160
+ .then(([has, config]) => {
161
+ if (cancelled)
162
+ return;
163
+ // Skip on SSO-enforced envs: password login is disabled there, so a
164
+ // recovery password can't be used — nudging for one is misleading and
165
+ // gives a false sense of security. Same signal LoginForm uses.
166
+ const passwordUnavailable = config?.features?.ssoEnforced === true || config?.emailPassword?.enabled === false;
167
+ if (has === false && !passwordUnavailable)
168
+ setShow(true);
169
+ })
147
170
  .catch(() => { });
148
171
  return () => { cancelled = true; };
149
- }, [hasLocalPassword]);
172
+ }, [hasLocalPassword, getAuthConfig]);
150
173
  const dismiss = () => {
151
174
  try {
152
175
  localStorage.setItem('os:recovery-pw-dismissed', '1');
@@ -195,5 +218,9 @@ export function HomePage() {
195
218
  defaultValue: 'There are no applications available to you yet. Please contact your workspace administrator.',
196
219
  }) })] })) })] }));
197
220
  }
198
- return (_jsxs("div", { className: "relative min-h-full bg-background", children: [_jsx(PendingDraftsBanner, { t: t }), _jsx(RecoveryPasswordReminder, { t: t }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pt-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "mb-7 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "text-foreground", children: [greeting, displayName ? ', ' : ''] }), displayName && _jsx("span", { className: "text-primary", children: displayName }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "mt-1 text-sm sm:text-base text-muted-foreground", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }), _jsx(HomeAiActions, { askAvailable: askAvailable, buildAvailable: buildAvailable, navigate: navigate, t: t })] }), _jsx(HomeAppsStrip, { apps: activeApps, favorites: favorites, onOpen: (app) => navigate(`/apps/${appRouteSegment(app) ?? app.name}`), onBrowseMarketplace: () => navigate('/apps/setup/system/marketplace'), isAdmin: isAdmin }), _jsx("div", { className: "mb-6", children: _jsx(HomeActionCenter, { pendingApprovalsCount: pendingApprovalsCount, notifications: notifications, onOpenApprovals: () => navigate('/apps/setup/system/approvals'), onOpenNotification: (n) => navigate(n.actionUrl || '/apps/setup/sys_inbox_message?view=mine'), t: t }) }), _jsxs("div", { className: "grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]", children: [_jsx(HomeContinue, { items: recentApps, onOpen: (href) => navigate(href), t: t }), _jsx(HomeActivity, { items: activities, onViewAll: () => navigate('/apps/setup/sys_activity'), t: t })] })] }) })] }));
221
+ return (_jsxs("div", { className: "relative min-h-full bg-background", children: [_jsx(PendingDraftsBanner, { t: t }), _jsx(RecoveryPasswordReminder, { t: t }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pt-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "mb-7 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "text-foreground", children: [greeting, displayName ? ', ' : ''] }), displayName && _jsx("span", { className: "text-primary", children: displayName }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "mt-1 text-sm sm:text-base text-muted-foreground", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }), _jsx(HomeAiActions, { askAvailable: askAvailable, buildAvailable: buildAvailable, navigate: navigate, t: t })] }), isAdmin && (_jsxs("div", { className: "mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2", children: [_jsxs("button", { type: "button", onClick: () => navigate('/studio'), className: "flex items-start gap-3 rounded-xl border bg-card px-4 py-3.5 text-left transition-colors hover:border-primary/50 hover:bg-muted/40", children: [_jsx("span", { className: "mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary", children: _jsx(Hammer, { className: "h-4.5 w-4.5" }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-semibold", children: t('home.build.title', { defaultValue: 'Build an app' }) }), _jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: t('home.build.subtitle', {
222
+ defaultValue: 'Start from scratch — design objects, forms, automations and interfaces.',
223
+ }) })] })] }), _jsxs("button", { type: "button", onClick: () => navigate('/apps/setup/system/marketplace'), className: "flex items-start gap-3 rounded-xl border bg-card px-4 py-3.5 text-left transition-colors hover:border-primary/50 hover:bg-muted/40", children: [_jsx("span", { className: "mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary", children: _jsx(LayoutTemplate, { className: "h-4.5 w-4.5" }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-semibold", children: t('home.template.title', { defaultValue: 'Start with a template' }) }), _jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: t('home.template.subtitle', {
224
+ defaultValue: 'Install a template app from the marketplace and customize it.',
225
+ }) })] })] })] })), _jsx(HomeAppsStrip, { apps: activeApps, favorites: favorites, onOpen: (app) => navigate(`/apps/${appRouteSegment(app) ?? app.name}`), onBrowseMarketplace: () => navigate('/apps/setup/system/marketplace'), isAdmin: isAdmin }), _jsx("div", { className: "mb-6", children: _jsx(HomeActionCenter, { pendingApprovalsCount: pendingApprovalsCount, notifications: notifications, onOpenApprovals: () => navigate('/apps/setup/system/approvals'), onOpenNotification: (n) => navigate(n.actionUrl || '/apps/setup/sys_inbox_message?view=mine'), t: t }) }), _jsxs("div", { className: "grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]", children: [_jsx(HomeContinue, { items: recentApps, onOpen: (href) => navigate(href), t: t }), _jsx(HomeActivity, { items: activities, onViewAll: () => navigate('/apps/setup/sys_activity'), t: t })] })] }) })] }));
199
226
  }
@@ -13,15 +13,40 @@ import { useAuth } from '@object-ui/auth';
13
13
  import { useObjectTranslation } from '@object-ui/i18n';
14
14
  import { Loader2 } from 'lucide-react';
15
15
  import { provisionProductionEnvironment } from './provisionEnvironment';
16
- /** Convert a display name to a URL-friendly slug */
16
+ /**
17
+ * Convert a display name to a URL-friendly slug.
18
+ *
19
+ * The ASCII pass strips everything outside [a-z0-9 _-]. For a name written
20
+ * entirely in a non-Latin script (中文 / 日本語 / 한국어 / العربية …) that pass
21
+ * yields the empty string — and an empty slug left the "Create workspace"
22
+ * button permanently disabled (`!slug.trim()`), dead-ending the FIRST step of
23
+ * onboarding for every non-Latin-name user. Rather than block them, fall back
24
+ * to a deterministic, non-empty slug they can still edit.
25
+ *
26
+ * Deterministic (not random) on purpose: a name-derived hash means the slug
27
+ * doesn't jitter on every keystroke while typing a CJK name, and re-typing the
28
+ * same name reproduces the same slug. Uniqueness across different names comes
29
+ * from the hash; the server still enforces global slug uniqueness on submit.
30
+ */
17
31
  function nameToSlug(name) {
18
- return name
32
+ const ascii = name
19
33
  .toLowerCase()
20
34
  .replace(/[^a-z0-9\s_-]/g, '')
21
35
  .replace(/[\s_]+/g, '-')
22
36
  .replace(/-+/g, '-')
23
37
  .replace(/^-|-$/g, '')
24
38
  .slice(0, 48);
39
+ if (ascii)
40
+ return ascii;
41
+ // Empty name → empty slug (keep the button disabled; nothing to create yet).
42
+ const trimmed = name.trim();
43
+ if (!trimmed)
44
+ return '';
45
+ // Non-empty name with no ASCII-sluggable chars → deterministic fallback.
46
+ let hash = 0;
47
+ for (const ch of trimmed)
48
+ hash = (Math.imul(hash, 31) + ch.charCodeAt(0)) >>> 0;
49
+ return `workspace-${hash.toString(36).slice(0, 6)}`;
25
50
  }
26
51
  export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
27
52
  const { t } = useObjectTranslation();
@@ -90,7 +115,12 @@ export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
90
115
  // resolves this to `alreadyProvisioned`; a genuine failure falls through
91
116
  // to the onboarding gate (lazy provision on first navigation).
92
117
  try {
93
- await provisionProductionEnvironment({ organizationId: org.id });
118
+ // PR-5 (naming collapse): name the production environment after the
119
+ // workspace, so the user sees one consistent name instead of a
120
+ // workspace "Bloom Studio" whose environment is a generic "Production".
121
+ // The control plane inherits this displayName verbatim; auto-provision
122
+ // races that skip this call fall back to the org name server-side.
123
+ await provisionProductionEnvironment({ organizationId: org.id, displayName: name.trim() });
94
124
  }
95
125
  catch (provisionErr) {
96
126
  console.warn('[CreateWorkspace] eager env provision failed; onboarding gate will provision lazily', provisionErr);