@object-ui/app-shell 6.1.0 → 6.2.1

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 (87) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +10 -1
  3. package/dist/console/AppContent.js +53 -2
  4. package/dist/console/ai/AiChatPage.d.ts +8 -0
  5. package/dist/console/ai/AiChatPage.js +188 -0
  6. package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
  7. package/dist/console/ai/ConversationsSidebar.js +111 -0
  8. package/dist/console/auth/LoginPage.js +19 -2
  9. package/dist/console/auth/RegisterPage.js +30 -1
  10. package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
  11. package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
  12. package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
  13. package/dist/console/marketplace/MarketplacePage.js +55 -18
  14. package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
  15. package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
  16. package/dist/console/marketplace/usePackageL10n.js +110 -0
  17. package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
  18. package/dist/console/organizations/OrganizationsPage.js +24 -3
  19. package/dist/context/FavoritesProvider.d.ts +40 -2
  20. package/dist/context/FavoritesProvider.js +201 -20
  21. package/dist/hooks/index.d.ts +1 -0
  22. package/dist/hooks/index.js +1 -0
  23. package/dist/hooks/useChatConversation.d.ts +7 -0
  24. package/dist/hooks/useChatConversation.js +37 -5
  25. package/dist/hooks/useConversationList.d.ts +25 -0
  26. package/dist/hooks/useConversationList.js +131 -0
  27. package/dist/hooks/useNavPins.d.ts +11 -4
  28. package/dist/hooks/useNavPins.js +104 -53
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +14 -0
  31. package/dist/layout/AppHeader.js +2 -2
  32. package/dist/layout/AppSidebar.js +20 -1
  33. package/dist/layout/UnifiedSidebar.js +1 -1
  34. package/dist/providers/ExpressionProvider.d.ts +11 -1
  35. package/dist/providers/ExpressionProvider.js +11 -6
  36. package/dist/services/builtinComponents.d.ts +1 -0
  37. package/dist/services/builtinComponents.js +169 -0
  38. package/dist/services/componentRegistry.d.ts +63 -0
  39. package/dist/services/componentRegistry.js +36 -0
  40. package/dist/views/ComponentNavView.d.ts +6 -0
  41. package/dist/views/ComponentNavView.js +26 -0
  42. package/dist/views/RecordDetailView.js +66 -6
  43. package/dist/views/RecordFormPage.js +15 -3
  44. package/dist/views/SearchResultsPage.js +4 -0
  45. package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +68 -0
  46. package/dist/views/metadata-admin/DesignerEditorWrapper.js +158 -0
  47. package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
  48. package/dist/views/metadata-admin/DirectoryPage.js +135 -0
  49. package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
  50. package/dist/views/metadata-admin/LayeredDiff.js +26 -0
  51. package/dist/views/metadata-admin/MetadataDetailDrawer.d.ts +13 -0
  52. package/dist/views/metadata-admin/MetadataDetailDrawer.js +52 -0
  53. package/dist/views/metadata-admin/PageShell.d.ts +34 -0
  54. package/dist/views/metadata-admin/PageShell.js +40 -0
  55. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
  56. package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
  57. package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
  58. package/dist/views/metadata-admin/QuickFind.js +152 -0
  59. package/dist/views/metadata-admin/RelatedPanel.d.ts +33 -0
  60. package/dist/views/metadata-admin/RelatedPanel.js +171 -0
  61. package/dist/views/metadata-admin/ResourceEditPage.d.ts +13 -0
  62. package/dist/views/metadata-admin/ResourceEditPage.js +302 -0
  63. package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
  64. package/dist/views/metadata-admin/ResourceHistoryPage.js +100 -0
  65. package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
  66. package/dist/views/metadata-admin/ResourceListPage.js +146 -0
  67. package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
  68. package/dist/views/metadata-admin/ResourceRouter.js +47 -0
  69. package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
  70. package/dist/views/metadata-admin/SchemaForm.js +565 -0
  71. package/dist/views/metadata-admin/anchors.d.ts +1 -0
  72. package/dist/views/metadata-admin/anchors.js +229 -0
  73. package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
  74. package/dist/views/metadata-admin/default-schemas.js +207 -0
  75. package/dist/views/metadata-admin/i18n.d.ts +33 -0
  76. package/dist/views/metadata-admin/i18n.js +303 -0
  77. package/dist/views/metadata-admin/index.d.ts +33 -0
  78. package/dist/views/metadata-admin/index.js +39 -0
  79. package/dist/views/metadata-admin/predicate.d.ts +31 -0
  80. package/dist/views/metadata-admin/predicate.js +150 -0
  81. package/dist/views/metadata-admin/registry.d.ts +232 -0
  82. package/dist/views/metadata-admin/registry.js +106 -0
  83. package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
  84. package/dist/views/metadata-admin/useMetadata.js +96 -0
  85. package/dist/views/metadata-admin/widgets.d.ts +68 -0
  86. package/dist/views/metadata-admin/widgets.js +287 -0
  87. package/package.json +27 -26
package/CHANGELOG.md CHANGED
@@ -1,5 +1,134 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 6.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - bc269b0: fix
8
+ - @object-ui/types@6.2.1
9
+ - @object-ui/core@6.2.1
10
+ - @object-ui/i18n@6.2.1
11
+ - @object-ui/react@6.2.1
12
+ - @object-ui/components@6.2.1
13
+ - @object-ui/fields@6.2.1
14
+ - @object-ui/layout@6.2.1
15
+ - @object-ui/data-objectstack@6.2.1
16
+ - @object-ui/auth@6.2.1
17
+ - @object-ui/permissions@6.2.1
18
+ - @object-ui/plugin-editor@6.2.1
19
+ - @object-ui/collaboration@6.2.1
20
+ - @object-ui/providers@6.2.1
21
+
22
+ ## 6.2.0
23
+
24
+ ### Minor Changes
25
+
26
+ - fe3c1d3: Metadata Admin engine — unified UI for all 27 metadata types.
27
+
28
+ A generic, schema-driven admin shell that replaces the old per-type
29
+ bespoke pages with a single registry-driven engine. Admins can now browse,
30
+ create, override, diff, and roll back every registered metadata type from
31
+ the Setup app → _All Metadata Types_.
32
+
33
+ ### New: `@object-ui/app-shell` views/metadata-admin
34
+ - **`MetadataDirectoryPage`** — auto-grouped tile directory by domain, with
35
+ free-text search, domain chips, and a _Writable only_ filter.
36
+ - **`MetadataResourceListPage` / `MetadataResourceEditPage` / `…CreatePage` / `…HistoryPage`** —
37
+ generic CRUD shell. Uses the new `/meta/types` schema field to render
38
+ SchemaForm; uses `?layers=code,overlay,effective` to power a 3-state diff
39
+ tab; uses `/references` to warn before destructive deletes.
40
+ - **`MetadataQuickFind`** — Cmd+Shift+M palette searching across types and
41
+ items.
42
+ - **`PermissionMatrixEditor`** — Salesforce-style matrix custom editor for
43
+ `type=permission`. Objects × CRUD/VAMA/lifecycle columns with cascade
44
+ rules (viewAllRecords ⟹ allowRead, etc.), expandable per-object field
45
+ R/W subtable, bulk-set (R / CRUD / All / None), filter, _only granted_
46
+ toggle, destructive-change confirmation, profile switch.
47
+ - **`DesignerEditorWrapper`** — generic load–edit–save shell that hosts any
48
+ bespoke designer (`ObjectViewConfigurator`, `DashboardEditor`,
49
+ `PageCanvasEditor`, …). Handles dirty tracking, Save / Reset / Refresh /
50
+ History buttons, and the read-only fallback when `allowOrgOverride` is
51
+ false.
52
+ - **`i18n.ts`** — bilingual (`en-US`, `zh-CN`) bundle for built-in type
53
+ labels, domain labels, and engine UI strings, with `detectLocale()` and a
54
+ `t(key)` helper.
55
+
56
+ ### New routing variant
57
+ - App nav now supports `{ type: 'component', componentRef, params? }` items.
58
+ `AppContent` resolves them through the existing `ComponentRegistry`.
59
+ - Built-in components registered: `metadata:directory`, `metadata:resource`,
60
+ `metadata:object/edit` (FieldsPage), `metadata:permission/edit`
61
+ (PermissionMatrixEditor), and lazy designer wrappers for view / dashboard
62
+ / page.
63
+
64
+ ### Plugin-designer
65
+ - Lazy-exported `ObjectManager`, `FieldDesigner`, `ObjectViewConfigurator`,
66
+ `DashboardEditor`, `PageCanvasEditor`, `MetadataObjectsPage`, and
67
+ `MetadataFieldsPage` so the engine can mount them on demand.
68
+
69
+ The temporary `/dev/meta` route is removed. Setup app navigation flows
70
+ through the new component routes.
71
+
72
+ - ca685ab: Add ChatGPT-style AI chat history surface at `/ai` and `/ai/:conversationId`.
73
+ - New `DefaultAiChatPage` with conversations sidebar (list, create, select, delete) and chat pane on the right.
74
+ - New `ConversationsSidebar` component and `useConversationList` hook for listing and managing `ai_conversations`.
75
+ - `useChatConversation` now accepts an optional `activeId` to hydrate a specific conversation (bypassing the localStorage cache), and guards against duplicate conversation creation when sibling state (e.g. selected agent / scope) changes during the same visit.
76
+ - Deleting the active conversation navigates back to `/ai` so the URL doesn't reference a stale id.
77
+ - Auto-title new conversations from the first user message (truncated to 40 chars) via `PATCH /api/v1/ai/conversations/:id`; resumed conversations are left alone.
78
+ - Manual rename in the sidebar: pencil icon opens an inline editor with optimistic update and rollback on server error.
79
+ - Client-side search input filters the sidebar by title/preview substring.
80
+
81
+ - 0335ec4: Polish the AI chat surface based on real-world dogfooding feedback.
82
+
83
+ **`@object-ui/plugin-chatbot`** — new display helpers shared by `ChatbotEnhanced`:
84
+ - `unwrapToolResult(value)` peels the MCP-style `{ type: 'text', value: '<json>' }`
85
+ envelope that backend tools emit (`@objectstack/service-ai`'s data/metadata
86
+ tools, in particular), and JSON-parses the inner payload. The result panel
87
+ now renders a structured object tree instead of a doubly-escaped wall of
88
+ `\\\"objects\\\":[…]`.
89
+ - `humanizeToolName(name)` converts snake_case / kebab-case / camelCase tool
90
+ ids into sentence case ("list_objects" → "List objects"), preserving known
91
+ acronyms (API, ID, SQL, …). Tool-call cards now show the friendly title with
92
+ the raw id as a small monospace badge for power users.
93
+ - `summarizeChatError(err)` strips the AI SDK's
94
+ `"Failed after N attempts. Last error: "` prefix and keeps the first
95
+ sentence as a headline; the full text is exposed via an optional `details`
96
+ field so the new error banner can render a "Details" disclosure plus a
97
+ prominent Retry button instead of a 300-character single-line wall.
98
+
99
+ A new `⌘⏎ to send` hint is shown in the prompt footer (hidden on narrow
100
+ screens). `ToolHeader.title` now accepts `ReactNode` (previously `string`)
101
+ so wrappers can compose richer titles.
102
+
103
+ **`@object-ui/app-shell`** — `AiChatPage`:
104
+ - Removes the fake "Hello! I'm X" assistant welcome bubble so the empty-state
105
+ suggestion chips can actually render.
106
+ - Adds per-agent default suggestion sets (`data_chat`, `metadata_assistant`)
107
+ with a generic fallback. New conversations open with three actionable
108
+ starter prompts tailored to the selected agent.
109
+ - Surfaces agent-fetch failures as an inline warning on the agent picker
110
+ instead of hijacking the welcome message.
111
+ - Placeholder text now hints at the first suggestion (e.g. `Ask Data
112
+ Assistant… (try "系统里有多少个用户?")`).
113
+
114
+ ### Patch Changes
115
+
116
+ - Updated dependencies [fe3c1d3]
117
+ - Updated dependencies [ec8dcde]
118
+ - @object-ui/data-objectstack@6.2.0
119
+ - @object-ui/react@6.2.0
120
+ - @object-ui/components@6.2.0
121
+ - @object-ui/fields@6.2.0
122
+ - @object-ui/layout@6.2.0
123
+ - @object-ui/plugin-editor@6.2.0
124
+ - @object-ui/types@6.2.0
125
+ - @object-ui/core@6.2.0
126
+ - @object-ui/i18n@6.2.0
127
+ - @object-ui/auth@6.2.0
128
+ - @object-ui/permissions@6.2.0
129
+ - @object-ui/collaboration@6.2.0
130
+ - @object-ui/providers@6.2.0
131
+
3
132
  ## 6.1.0
4
133
 
5
134
  ### Patch Changes
package/README.md CHANGED
@@ -241,12 +241,21 @@ The official ObjectStack adapter lives in `@object-ui/data-objectstack`
241
241
  (`createObjectStackUserStateAdapter`).
242
242
 
243
243
  ```tsx
244
- import { useFavorites, useRecentItems } from '@object-ui/app-shell';
244
+ import { useFavorites, useRecentItems, useNavPins } from '@object-ui/app-shell';
245
245
 
246
246
  const { favorites, toggleFavorite, isFavorite } = useFavorites();
247
247
  const { recentItems, addRecentItem } = useRecentItems();
248
+ // Sidebar pins live in the same store as Favorites — synced to the backend
249
+ // via the same `UserDataAdapter<FavoriteItem>` when one is attached.
250
+ const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins();
248
251
  ```
249
252
 
253
+ Nav pins and Favorites share a single `favorites` collection. `FavoriteItem`
254
+ carries optional `type: 'nav'`, `pinned`, and `navId` fields so a single
255
+ adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is
256
+ migrated on first mount and then removed. Content favorites (20) and nav
257
+ pins (20) each have an independent cap. See the guide below for details.
258
+
250
259
  See [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
251
260
  for the adapter contract, backend schema, and how to plug in your own backend.
252
261
 
@@ -39,6 +39,14 @@ const PageView = lazy(() => import('../views/PageView').then(m => ({ default: m.
39
39
  const ReportView = lazy(() => import('../views/ReportView').then(m => ({ default: m.ReportView })));
40
40
  const SearchResultsPage = lazy(() => import('../views/SearchResultsPage').then(m => ({ default: m.SearchResultsPage })));
41
41
  const RecordFormPage = lazy(() => import('../views/RecordFormPage').then(m => ({ default: m.RecordFormPage })));
42
+ const ComponentNavView = lazy(() => import('../views/ComponentNavView').then(m => ({ default: m.ComponentNavView })));
43
+ // Metadata admin — mounted under /apps/:app/metadata. Lives at the top
44
+ // level so URLs read like a normal nested resource (RFC-style) instead of
45
+ // piggy-backing on the legacy ComponentRegistry fan-out.
46
+ const MetadataDirectoryPage = lazy(() => import('../views/metadata-admin').then(m => ({ default: m.MetadataDirectoryPage })));
47
+ const MetadataResourceListPage = lazy(() => import('../views/metadata-admin').then(m => ({ default: m.MetadataResourceListPage })));
48
+ const MetadataResourceEditPage = lazy(() => import('../views/metadata-admin').then(m => ({ default: m.MetadataResourceEditPage })));
49
+ const MetadataResourceHistoryPage = lazy(() => import('../views/metadata-admin').then(m => ({ default: m.MetadataResourceHistoryPage })));
42
50
  // Designer pages — sourced from @object-ui/plugin-designer so third-party hosts
43
51
  // can opt out by not registering these routes.
44
52
  const CreateAppPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.CreateAppPage })));
@@ -52,8 +60,29 @@ const MarketplacePackagePage = lazy(() => import('./marketplace/MarketplacePacka
52
60
  const MarketplaceInstalledPage = lazy(() => import('./marketplace/MarketplaceInstalledPage').then(m => ({ default: m.MarketplaceInstalledPage })));
53
61
  export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
54
62
  const [connectionState, setConnectionState] = useState('disconnected');
55
- const { user } = useAuth();
63
+ const { user, getAuthConfig } = useAuth();
56
64
  const dataSource = useAdapter();
65
+ // Deployment-level feature flags from `/api/v1/auth/config`. Used by
66
+ // CEL predicates on metadata actions (e.g. `sys_organization`'s
67
+ // create button is hidden when `multiOrgEnabled === false`). We keep
68
+ // it empty until the fetch resolves so predicates default to "visible"
69
+ // and we don't briefly hide UI on slow networks.
70
+ const [features, setFeatures] = useState({});
71
+ useEffect(() => {
72
+ let cancelled = false;
73
+ getAuthConfig()
74
+ .then((cfg) => {
75
+ if (cancelled)
76
+ return;
77
+ setFeatures(cfg?.features ?? {});
78
+ })
79
+ .catch(() => {
80
+ /* leave empty — predicates default to visible */
81
+ });
82
+ return () => {
83
+ cancelled = true;
84
+ };
85
+ }, [getAuthConfig]);
57
86
  const navigate = useNavigate();
58
87
  const location = useLocation();
59
88
  const { appName } = useParams();
@@ -237,7 +266,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
237
266
  const expressionUser = user
238
267
  ? { name: user.name, email: user.email, role: user.role ?? 'user' }
239
268
  : { name: 'Anonymous', email: '', role: 'guest' };
240
- return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, userId: user?.id, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), replace: true }) }), _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: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _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: {
269
+ return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, features: features, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, userId: user?.id, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), replace: true }) }), _jsxs(Route, { path: "metadata", children: [_jsx(Route, { index: true, element: _jsx(MetadataDirectoryPage, {}) }), _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/page/:pageName", element: _jsx(PageDesignPage, {}) }), _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: {
241
270
  type: 'object-form',
242
271
  formType: 'modal',
243
272
  objectName: currentObjectDef.name,
@@ -356,6 +385,7 @@ const RESERVED_SECOND_SEGMENTS = new Set([
356
385
  'new', 'view', 'record', 'edit',
357
386
  'dashboard', 'report', 'page',
358
387
  'design', 'search', 'create-app', 'edit-app',
388
+ 'metadata',
359
389
  ]);
360
390
  function looksLikeRecordId(segment) {
361
391
  if (!segment)
@@ -368,6 +398,27 @@ function looksLikeRecordId(segment) {
368
398
  // Most record ids are at least 6 chars (UUID, ULID, nanoid all >=8).
369
399
  return segment.length >= 6;
370
400
  }
401
+ /**
402
+ * Translates pre-refactor metadata admin URLs
403
+ * (`/apps/:app/component/metadata/resource/:name?type=:type`,
404
+ * `/apps/:app/component/metadata/directory`) into the new REST-style
405
+ * shape (`/apps/:app/metadata/:type/:name`). Keeps bookmarks and any
406
+ * still-unmigrated link producers working.
407
+ */
408
+ function LegacyMetadataRedirect({ mode }) {
409
+ const location = useLocation();
410
+ const appBase = location.pathname.replace(/\/component\/metadata\/.*$/, '');
411
+ if (mode === 'directory') {
412
+ return _jsx(Navigate, { to: `${appBase}/metadata${location.search}${location.hash}`, replace: true });
413
+ }
414
+ const sp = new URLSearchParams(location.search);
415
+ const type = sp.get('type') ?? '';
416
+ const tail = location.pathname.match(/\/component\/metadata\/resource(\/.*)?$/)?.[1] ?? '';
417
+ const target = type
418
+ ? `${appBase}/metadata/${encodeURIComponent(type)}${tail}${location.hash}`
419
+ : `${appBase}/metadata${location.hash}`;
420
+ return _jsx(Navigate, { to: target, replace: true });
421
+ }
371
422
  /**
372
423
  * Redirects `/apps/:appName/:objectName/:recordId` shorthand to the
373
424
  * canonical `/apps/:appName/:objectName/record/:recordId` so externally
@@ -0,0 +1,8 @@
1
+ export interface AiChatPageProps {
2
+ /** Override the resolved AI service base URL. */
3
+ apiBase?: string;
4
+ /** Default agent to select on first render. */
5
+ defaultAgent?: string;
6
+ }
7
+ export declare function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp }?: AiChatPageProps): import("react/jsx-runtime").JSX.Element;
8
+ export default AiChatPage;
@@ -0,0 +1,188 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * AiChatPage — full-page ChatGPT-style AI surface.
5
+ *
6
+ * Mounted at `/ai` (new chat) and `/ai/:conversationId` (resume an existing
7
+ * conversation). Left rail lists the signed-in user's `ai_conversations`;
8
+ * right pane embeds `ChatbotEnhanced` wired to
9
+ * `POST /api/v1/ai/agents/:name/chat`.
10
+ *
11
+ * Auto-persist is handled server-side in `@objectstack/service-ai`: as long
12
+ * as the request body carries `conversationId`, the user + assistant + tool
13
+ * turns are appended to `ai_messages` automatically.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { useNavigate, useParams } from 'react-router-dom';
17
+ import { useAuth } from '@object-ui/auth';
18
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
19
+ import { ChatbotEnhanced, useAgents, useObjectChat, useHitlInChat, } from '@object-ui/plugin-chatbot';
20
+ import { AppHeader } from '../../layout/AppHeader';
21
+ import { useNavigationContext } from '../../context/NavigationContext';
22
+ import { useChatConversation } from '../../hooks/useChatConversation';
23
+ import { ConversationsSidebar } from './ConversationsSidebar';
24
+ const DEFAULT_AI_PATH = '/api/v1/ai';
25
+ function resolveApiBase(explicit) {
26
+ if (explicit)
27
+ return explicit.replace(/\/$/, '');
28
+ const env = import.meta.env ?? {};
29
+ const fromEnv = env.VITE_AI_BASE_URL;
30
+ if (fromEnv)
31
+ return fromEnv.replace(/\/$/, '');
32
+ const serverUrl = env.VITE_SERVER_URL ?? '';
33
+ return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
34
+ }
35
+ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentProp } = {}) {
36
+ const { user } = useAuth();
37
+ const userId = user?.id;
38
+ const { conversationId: urlConversationId } = useParams();
39
+ const navigate = useNavigate();
40
+ const { setContext } = useNavigationContext();
41
+ useEffect(() => {
42
+ setContext('home');
43
+ }, [setContext]);
44
+ const apiBase = useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
45
+ const env = import.meta.env ?? {};
46
+ const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
47
+ const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
48
+ const [activeAgent, setActiveAgent] = useState(undefined);
49
+ useEffect(() => {
50
+ if (!activeAgent && agents.length > 0) {
51
+ const preferred = defaultAgentProp ?? envDefaultAgent;
52
+ const match = preferred ? agents.find((a) => a.name === preferred) : undefined;
53
+ setActiveAgent((match ?? agents[0]).name);
54
+ }
55
+ }, [agents, activeAgent, defaultAgentProp, envDefaultAgent]);
56
+ const chatApi = activeAgent
57
+ ? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
58
+ : undefined;
59
+ const { conversationId, initialMessages, isLoading: convoLoading } = useChatConversation({
60
+ userId,
61
+ scope: activeAgent,
62
+ apiBase,
63
+ activeId: urlConversationId,
64
+ });
65
+ const [refreshKey, setRefreshKey] = useState(0);
66
+ // After the hook resolves a real id for a fresh `/ai` visit, mirror it into
67
+ // the URL so the sidebar's active-row + share/refresh both work.
68
+ useEffect(() => {
69
+ if (!urlConversationId && conversationId) {
70
+ navigate(`/ai/${conversationId}`, { replace: true });
71
+ }
72
+ }, [urlConversationId, conversationId, navigate]);
73
+ const titledRef = useRef(new Set());
74
+ // A resumed conversation already has history; treat it as already-titled
75
+ // so we don't clobber the original title on the next user turn.
76
+ useEffect(() => {
77
+ if (conversationId && initialMessages.length > 0) {
78
+ titledRef.current.add(conversationId);
79
+ }
80
+ }, [conversationId, initialMessages.length]);
81
+ const handleSent = useCallback((firstUserMessage) => {
82
+ // New user turn → bump sidebar list so the row's preview/timestamp refreshes.
83
+ setRefreshKey((k) => k + 1);
84
+ // Auto-title the conversation from the first user message. Server only
85
+ // tracks `title` when we PATCH it; without this every row shows the same
86
+ // "New conversation" placeholder. We mark `conversationId` as titled in a
87
+ // ref so subsequent turns don't re-rename and clobber a manual rename.
88
+ if (!firstUserMessage || !conversationId)
89
+ return;
90
+ if (titledRef.current.has(conversationId))
91
+ return;
92
+ titledRef.current.add(conversationId);
93
+ const text = firstUserMessage.trim();
94
+ if (!text)
95
+ return;
96
+ const truncated = text.length > 40 ? `${text.slice(0, 40)}…` : text;
97
+ fetch(`${apiBase}/conversations/${encodeURIComponent(conversationId)}`, {
98
+ method: 'PATCH',
99
+ credentials: 'include',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ title: truncated }),
102
+ })
103
+ .then(() => setRefreshKey((k) => k + 1))
104
+ .catch(() => {
105
+ // Leave it untitled; user can rename manually.
106
+ titledRef.current.delete(conversationId);
107
+ });
108
+ }, [apiBase, conversationId]);
109
+ return (_jsxs("div", { className: "flex h-svh w-full flex-col bg-background", "data-testid": "ai-chat-page", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsxs("div", { className: "flex flex-1 min-h-0 w-full", children: [_jsx(ConversationsSidebar, { userId: userId, apiBase: apiBase, refreshKey: refreshKey, className: "hidden w-72 shrink-0 border-r md:flex" }), _jsx("main", { className: "flex flex-1 min-w-0 flex-col", children: _jsx(ChatPane, { agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, conversationId: conversationId, initialMessages: initialMessages, hydrating: convoLoading, onSent: handleSent }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`) })] })] }));
110
+ }
111
+ function ChatPane({ agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, conversationId, initialMessages, hydrating, onSent, }) {
112
+ const activeAgentLabel = useMemo(() => {
113
+ const found = agents.find((a) => a.name === activeAgent);
114
+ return found?.label ?? activeAgent ?? 'Assistant';
115
+ }, [agents, activeAgent]);
116
+ const hydrated = useMemo(() => {
117
+ return initialMessages.map((m) => ({
118
+ id: m.id,
119
+ role: m.role,
120
+ content: m.parts.map((p) => p.text).join(''),
121
+ }));
122
+ }, [initialMessages]);
123
+ const suggestions = useMemo(() => {
124
+ if (hydrated.length > 0)
125
+ return undefined;
126
+ return buildAgentSuggestions(activeAgent, activeAgentLabel);
127
+ }, [hydrated.length, activeAgent, activeAgentLabel]);
128
+ const { messages, isLoading, error, sendMessage, stop, reload, clear, } = useObjectChat({
129
+ api: chatApi,
130
+ conversationId,
131
+ body: {
132
+ context: {
133
+ activeApp: 'AI',
134
+ agentName: activeAgent,
135
+ },
136
+ },
137
+ initialMessages: hydrated,
138
+ autoResponse: !chatApi,
139
+ autoResponseText: "Thanks for your message! I'm here to help.",
140
+ autoResponseDelay: 600,
141
+ });
142
+ const hitl = useHitlInChat({
143
+ messages: messages,
144
+ apiBase,
145
+ continueConversation: (prompt) => {
146
+ sendMessage(prompt);
147
+ },
148
+ });
149
+ const handleSend = useCallback((content, files) => {
150
+ sendMessage(content, files);
151
+ onSent(content);
152
+ }, [sendMessage, onSent]);
153
+ const headerSlot = agents.length > 0 ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2 px-4 py-2", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Agent:" }), _jsxs(Select, { value: activeAgent, onValueChange: onAgentChange, disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-[220px] text-xs", "data-testid": "ai-chat-agent-picker", children: _jsx(SelectValue, { placeholder: "Choose agent..." }) }), _jsx(SelectContent, { align: "start", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: agent.label }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[260px]", children: agent.description })) : null] }, agent.name))) })] }), hydrating ? (_jsx("span", { className: "text-[10px] text-muted-foreground", children: "Loading history\u2026" })) : null, agentsError ? (_jsx("span", { className: "text-[10px] text-amber-700 dark:text-amber-400", title: agentsError.message, children: "\u26A0 Offline demo mode \u2014 agent list unavailable" })) : null] })) : null;
154
+ return (_jsx(ChatbotEnhanced, { className: "flex-1 min-h-0 rounded-none border-0", maxHeight: "100%", headerSlot: headerSlot, messages: messages, placeholder: activeAgent
155
+ ? `Ask ${activeAgentLabel}… (try “${(suggestions?.[0]) ?? 'How can you help?'}”)`
156
+ : agentsLoading
157
+ ? 'Loading agents…'
158
+ : 'Ask anything…', suggestions: suggestions, onSendMessage: handleSend, onClear: clear, onStop: isLoading ? stop : undefined, onReload: reload, isLoading: isLoading, error: error, enableMarkdown: true, onToolApprove: hitl.decide, toolDecisions: hitl.decisions, toolApproveLabel: "Approve & run", toolDenyLabel: "Reject", toolDenyReason: "Operator rejected from chat", "data-testid": "ai-chat-panel" }));
159
+ }
160
+ const AGENT_SUGGESTIONS = {
161
+ data_chat: [
162
+ '系统里有多少个用户?列出他们的邮箱。',
163
+ '帮我列出最近创建的 5 条记录。',
164
+ '统计每个对象的记录数。',
165
+ ],
166
+ metadata_assistant: [
167
+ '系统里注册了哪些对象类型?',
168
+ 'sys_user 对象有哪些字段?',
169
+ '描述一下用户相关的对象关系。',
170
+ ],
171
+ };
172
+ const GENERIC_SUGGESTIONS = [
173
+ 'What can you help me with?',
174
+ 'List the available data objects.',
175
+ 'Summarize my recent activity.',
176
+ ];
177
+ function buildAgentSuggestions(agentName, agentLabel) {
178
+ if (agentName && AGENT_SUGGESTIONS[agentName]) {
179
+ return AGENT_SUGGESTIONS[agentName];
180
+ }
181
+ const lower = (agentName ?? agentLabel).toLowerCase();
182
+ if (lower.includes('data'))
183
+ return AGENT_SUGGESTIONS.data_chat;
184
+ if (lower.includes('metadata'))
185
+ return AGENT_SUGGESTIONS.metadata_assistant;
186
+ return GENERIC_SUGGESTIONS;
187
+ }
188
+ export default AiChatPage;
@@ -0,0 +1,7 @@
1
+ export interface ConversationsSidebarProps {
2
+ userId: string | undefined;
3
+ apiBase: string;
4
+ className?: string;
5
+ refreshKey?: number | string;
6
+ }
7
+ export declare function ConversationsSidebar({ userId, apiBase, className, refreshKey, }: ConversationsSidebarProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3
+ /**
4
+ * Left sidebar listing the signed-in user's AI conversations. Active row is
5
+ * derived from `useParams<{ conversationId }>()`; clicking a row navigates to
6
+ * `/ai/:id`, the "New chat" button navigates to `/ai`.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useNavigate, useParams } from 'react-router-dom';
10
+ import { Plus, Trash2, Pencil, MessageSquare, Search, Check, X } from 'lucide-react';
11
+ import { Button, Input, ScrollArea, Empty, EmptyTitle, EmptyDescription, cn, } from '@object-ui/components';
12
+ import { useConversationList } from '../../hooks/useConversationList';
13
+ function formatTimestamp(iso) {
14
+ if (!iso)
15
+ return '';
16
+ const d = new Date(iso);
17
+ if (Number.isNaN(d.getTime()))
18
+ return '';
19
+ const now = Date.now();
20
+ const diff = now - d.getTime();
21
+ const min = 60 * 1000;
22
+ const hour = 60 * min;
23
+ const day = 24 * hour;
24
+ if (diff < min)
25
+ return 'just now';
26
+ if (diff < hour)
27
+ return `${Math.floor(diff / min)}m ago`;
28
+ if (diff < day)
29
+ return `${Math.floor(diff / hour)}h ago`;
30
+ if (diff < 7 * day)
31
+ return `${Math.floor(diff / day)}d ago`;
32
+ return d.toLocaleDateString();
33
+ }
34
+ export function ConversationsSidebar({ userId, apiBase, className, refreshKey, }) {
35
+ const navigate = useNavigate();
36
+ const { conversationId: activeId } = useParams();
37
+ const { conversations, isLoading, error, remove, rename } = useConversationList({
38
+ userId,
39
+ apiBase,
40
+ refreshKey,
41
+ });
42
+ const [filter, setFilter] = useState('');
43
+ const [renamingId, setRenamingId] = useState(undefined);
44
+ const visible = useMemo(() => {
45
+ const q = filter.trim().toLowerCase();
46
+ if (!q)
47
+ return conversations;
48
+ return conversations.filter((c) => {
49
+ const hay = `${c.title ?? ''} ${c.preview ?? ''}`.toLowerCase();
50
+ return hay.includes(q);
51
+ });
52
+ }, [conversations, filter]);
53
+ const handleNew = useCallback(() => navigate('/ai'), [navigate]);
54
+ const handleDelete = useCallback(async (e, id) => {
55
+ e.stopPropagation();
56
+ await remove(id);
57
+ if (id === activeId)
58
+ navigate('/ai', { replace: true });
59
+ }, [remove, activeId, navigate]);
60
+ const handleRenameSubmit = useCallback(async (id, title) => {
61
+ setRenamingId(undefined);
62
+ try {
63
+ await rename(id, title);
64
+ }
65
+ catch {
66
+ // optimistic update already rolled back via refetch in the hook
67
+ }
68
+ }, [rename]);
69
+ return (_jsxs("aside", { className: cn('flex h-full min-h-0 flex-col bg-muted/30', className), "data-testid": "ai-conversations-sidebar", children: [_jsxs("div", { className: "flex shrink-0 flex-col gap-2 border-b px-3 py-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: "Chats" }), _jsxs(Button, { size: "sm", variant: "outline", onClick: handleNew, "data-testid": "ai-new-chat", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "New"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: filter, onChange: (e) => setFilter(e.target.value), placeholder: "Search chats...", className: "h-7 pl-7 text-xs", "data-testid": "ai-conversations-search" })] })] }), _jsx(ScrollArea, { className: "flex-1 min-h-0", children: isLoading && conversations.length === 0 ? (_jsx("div", { className: "px-3 py-4 text-xs text-muted-foreground", children: "Loading\u2026" })) : error ? (_jsx("div", { className: "px-3 py-4 text-xs text-destructive", children: error.message })) : conversations.length === 0 ? (_jsxs(Empty, { className: "px-3 py-8", children: [_jsx(MessageSquare, { className: "h-8 w-8 text-muted-foreground" }), _jsx(EmptyTitle, { children: "No chats yet" }), _jsx(EmptyDescription, { children: "Start a new conversation to see it here." })] })) : visible.length === 0 ? (_jsx("div", { className: "px-3 py-4 text-xs text-muted-foreground", children: "No matching chats." })) : (_jsx("ul", { className: "flex flex-col py-1", children: visible.map((c) => (_jsx(ConversationRow, { conversation: c, active: c.id === activeId, renaming: c.id === renamingId, onSelect: () => navigate(`/ai/${c.id}`), onDelete: (e) => handleDelete(e, c.id), onStartRename: () => setRenamingId(c.id), onCancelRename: () => setRenamingId(undefined), onSubmitRename: (title) => handleRenameSubmit(c.id, title) }, c.id))) })) })] }));
70
+ }
71
+ function ConversationRow({ conversation, active, renaming, onSelect, onDelete, onStartRename, onCancelRename, onSubmitRename, }) {
72
+ const title = conversation.title?.trim() || conversation.preview?.trim() || 'New conversation';
73
+ const [draft, setDraft] = useState(title);
74
+ const inputRef = useRef(null);
75
+ useEffect(() => {
76
+ if (renaming) {
77
+ setDraft(conversation.title?.trim() || conversation.preview?.trim() || '');
78
+ // Focus the input on next paint so the click that opened it doesn't blur immediately.
79
+ setTimeout(() => inputRef.current?.select(), 0);
80
+ }
81
+ }, [renaming, conversation.title, conversation.preview]);
82
+ if (renaming) {
83
+ return (_jsx("li", { children: _jsxs("div", { className: cn('flex w-full items-center gap-1 border-l-2 px-3 py-2', active ? 'border-primary bg-accent' : 'border-transparent'), "data-testid": `ai-conversation-rename-row-${conversation.id}`, children: [_jsx(Input, { ref: inputRef, value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: (e) => {
84
+ if (e.key === 'Enter') {
85
+ e.preventDefault();
86
+ onSubmitRename(draft);
87
+ }
88
+ else if (e.key === 'Escape') {
89
+ e.preventDefault();
90
+ onCancelRename();
91
+ }
92
+ }, className: "h-7 flex-1 text-sm", "data-testid": `ai-conversation-rename-input-${conversation.id}`, "aria-label": "Rename conversation" }), _jsx(Button, { size: "sm", variant: "ghost", className: "h-7 w-7 p-0", onClick: () => onSubmitRename(draft), "data-testid": `ai-conversation-rename-confirm-${conversation.id}`, "aria-label": "Save rename", children: _jsx(Check, { className: "h-3.5 w-3.5" }) }), _jsx(Button, { size: "sm", variant: "ghost", className: "h-7 w-7 p-0", onClick: onCancelRename, "aria-label": "Cancel rename", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }) }));
93
+ }
94
+ return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: onSelect, className: cn('group flex w-full flex-col gap-0.5 border-l-2 border-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50', active && 'border-primary bg-accent'), "data-testid": `ai-conversation-row-${conversation.id}`, children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("span", { className: "line-clamp-1 flex-1 font-medium", children: title }), _jsxs("div", { className: "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100", children: [_jsx("span", { role: "button", tabIndex: 0, onClick: (e) => {
95
+ e.stopPropagation();
96
+ onStartRename();
97
+ }, onKeyDown: (e) => {
98
+ if (e.key === 'Enter' || e.key === ' ') {
99
+ e.stopPropagation();
100
+ onStartRename();
101
+ }
102
+ }, className: "hover:text-primary", "data-testid": `ai-conversation-rename-${conversation.id}`, "aria-label": "Rename conversation", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }), _jsx("span", { role: "button", tabIndex: 0, onClick: (e) => {
103
+ e.stopPropagation();
104
+ onDelete(e);
105
+ }, onKeyDown: (e) => {
106
+ if (e.key === 'Enter' || e.key === ' ') {
107
+ e.stopPropagation();
108
+ onDelete(e);
109
+ }
110
+ }, className: "hover:text-destructive", "data-testid": `ai-conversation-delete-${conversation.id}`, "aria-label": "Delete conversation", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] })] }), conversation.preview && conversation.preview !== title ? (_jsx("span", { className: "line-clamp-1 text-xs text-muted-foreground", children: conversation.preview })) : null, _jsx("span", { className: "text-[10px] text-muted-foreground", children: formatTimestamp(conversation.updatedAt ?? conversation.createdAt) })] }) }));
111
+ }
@@ -2,15 +2,32 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * Login Page for ObjectStack Console
4
4
  */
5
+ import { useEffect, useState } from 'react';
5
6
  import { useNavigate, Link } from 'react-router-dom';
6
- import { LoginForm } from '@object-ui/auth';
7
+ import { LoginForm, useAuth } from '@object-ui/auth';
7
8
  import { useObjectTranslation } from '@object-ui/i18n';
8
9
  import { AuthPageLayout } from './AuthPageLayout';
9
10
  const RouterLink = ({ href, className, children }) => (_jsx(Link, { to: href, className: className, children: children }));
10
11
  export function LoginPage() {
11
12
  const navigate = useNavigate();
12
13
  const { t } = useObjectTranslation();
13
- return (_jsx(AuthPageLayout, { children: _jsx(LoginForm, { onSuccess: () => navigate('/'), registerUrl: "/register", forgotPasswordUrl: "/forgot-password", title: t('auth.login.title'), description: t('auth.login.description'), linkComponent: RouterLink, labels: {
14
+ const { getAuthConfig } = useAuth();
15
+ // Hide the "Sign up" link when the deployment has disabled
16
+ // self-service registration (env `OS_DISABLE_SIGNUP=true` or
17
+ // `emailAndPassword.disableSignUp` in objectstack.config.ts). We start
18
+ // undefined so we don't flicker the link on first paint, and pass
19
+ // `undefined` (LoginForm hides the link) once we know signup is off.
20
+ const [signUpDisabled, setSignUpDisabled] = useState(undefined);
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ getAuthConfig()
24
+ .then(cfg => { if (!cancelled)
25
+ setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true); })
26
+ .catch(() => { if (!cancelled)
27
+ setSignUpDisabled(false); });
28
+ return () => { cancelled = true; };
29
+ }, [getAuthConfig]);
30
+ return (_jsx(AuthPageLayout, { children: _jsx(LoginForm, { onSuccess: () => navigate('/'), registerUrl: signUpDisabled ? undefined : '/register', forgotPasswordUrl: "/forgot-password", title: t('auth.login.title'), description: t('auth.login.description'), linkComponent: RouterLink, labels: {
14
31
  emailLabel: t('auth.login.emailLabel'),
15
32
  emailPlaceholder: t('auth.login.emailPlaceholder'),
16
33
  passwordLabel: t('auth.login.passwordLabel'),