@object-ui/app-shell 6.0.0 → 6.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d0e63f1: Migrate AI chat history from localStorage to the server-backed
8
+ `ai_conversations` / `ai_messages` REST API. The studio `AiChatPanel`,
9
+ the console `ConsoleFloatingChatbot`, and any other consumer of the new
10
+ `useChatConversation` hook (in `@object-ui/app-shell`) now resolve a
11
+ durable conversation id per signed-in user, hydrate prior messages on
12
+ mount, and rotate the conversation on reset. The previous
13
+ `objectstack:ai-chat-messages` localStorage entries are no longer read
14
+ or written.
15
+ - @object-ui/types@6.0.2
16
+ - @object-ui/core@6.0.2
17
+ - @object-ui/i18n@6.0.2
18
+ - @object-ui/react@6.0.2
19
+ - @object-ui/components@6.0.2
20
+ - @object-ui/fields@6.0.2
21
+ - @object-ui/layout@6.0.2
22
+ - @object-ui/data-objectstack@6.0.2
23
+ - @object-ui/auth@6.0.2
24
+ - @object-ui/permissions@6.0.2
25
+ - @object-ui/collaboration@6.0.2
26
+ - @object-ui/providers@6.0.2
27
+
28
+ ## 6.0.1
29
+
30
+ ### Patch Changes
31
+
32
+ - dbb9a98: cloud
33
+ - @object-ui/types@6.0.1
34
+ - @object-ui/core@6.0.1
35
+ - @object-ui/i18n@6.0.1
36
+ - @object-ui/react@6.0.1
37
+ - @object-ui/components@6.0.1
38
+ - @object-ui/fields@6.0.1
39
+ - @object-ui/layout@6.0.1
40
+ - @object-ui/data-objectstack@6.0.1
41
+ - @object-ui/auth@6.0.1
42
+ - @object-ui/permissions@6.0.1
43
+ - @object-ui/collaboration@6.0.1
44
+ - @object-ui/providers@6.0.1
45
+
3
46
  ## 6.0.0
4
47
 
5
48
  ### Major Changes
@@ -237,7 +237,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
237
237
  const expressionUser = user
238
238
  ? { name: user.name, email: user.email, role: user.role ?? 'user' }
239
239
  : { 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, 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: {
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: {
241
241
  type: 'object-form',
242
242
  formType: 'modal',
243
243
  objectName: currentObjectDef.name,
@@ -10,6 +10,11 @@
10
10
  import React from 'react';
11
11
  interface HomeLayoutProps {
12
12
  children: React.ReactNode;
13
+ /**
14
+ * Signed-in user id. Forwarded to the floating chatbot so it can hydrate
15
+ * server-backed conversation history.
16
+ */
17
+ userId?: string;
13
18
  }
14
- export declare function HomeLayout({ children }: HomeLayoutProps): import("react/jsx-runtime").JSX.Element;
19
+ export declare function HomeLayout({ children, userId }: HomeLayoutProps): import("react/jsx-runtime").JSX.Element;
15
20
  export {};
@@ -15,7 +15,7 @@ import { useDiscovery } from '@object-ui/react';
15
15
  // Lightweight FAB stub — the heavy chat chunk graph only downloads on
16
16
  // first hover/click. See ../../layout/ConsoleChatbotFab.tsx.
17
17
  import { ConsoleChatbotFab } from '../../layout/ConsoleChatbotFab';
18
- export function HomeLayout({ children }) {
18
+ export function HomeLayout({ children, userId }) {
19
19
  const { setContext } = useNavigationContext();
20
20
  const { isAiEnabled } = useDiscovery();
21
21
  // Render the chatbot whenever AI is reachable. If the developer has explicitly
@@ -26,5 +26,5 @@ export function HomeLayout({ children }) {
26
26
  useEffect(() => {
27
27
  setContext('home');
28
28
  }, [setContext]);
29
- return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto pb-20 sm:pb-0", children: children }), showChatbot && _jsx(ConsoleChatbotFab, { appLabel: "Workspace", objects: [] })] }));
29
+ return (_jsxs("div", { className: "flex min-h-svh w-full flex-col bg-background", "data-testid": "home-layout", children: [_jsx("header", { className: "sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4", children: _jsx(AppHeader, { variant: "home" }) }), _jsx("main", { className: "flex-1 min-w-0 overflow-auto pb-20 sm:pb-0", children: children }), showChatbot && _jsx(ConsoleChatbotFab, { appLabel: "Workspace", objects: [], userId: userId })] }));
30
30
  }
@@ -7,11 +7,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  */
8
8
  import { useEffect, useState } from 'react';
9
9
  import { useNavigate, useParams } from 'react-router-dom';
10
- import { Button, Badge, Card, CardContent, CardHeader, CardTitle, Skeleton, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Label, Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
11
- import { ArrowLeft, ExternalLink, Download, AlertCircle, Package, Trash2 } from 'lucide-react';
10
+ import { Button, Badge, Card, CardContent, CardHeader, CardTitle, Skeleton, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Label, Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from '@object-ui/components';
11
+ import { ArrowLeft, ExternalLink, Download, AlertCircle, Package, Trash2, MoreHorizontal, CheckCircle2 } from 'lucide-react';
12
12
  import { PackageIcon } from './PackageIcon';
13
13
  import { MarkdownText } from './MarkdownText';
14
14
  import { getMarketplacePackage, installPackage, installLocal, uninstallLocal, listLocalInstalls, listCloudEnvironments, listInstallableOrgIds, cloudInstallDeepLink, } from './marketplaceApi';
15
+ import { getRuntimeConfig } from '../../runtime-config';
15
16
  export function MarketplacePackagePage() {
16
17
  const navigate = useNavigate();
17
18
  const { packageId, appName } = useParams();
@@ -32,6 +33,8 @@ export function MarketplacePackagePage() {
32
33
  const [installingLocal, setInstallingLocal] = useState(false);
33
34
  const [localResult, setLocalResult] = useState(null);
34
35
  useEffect(() => {
36
+ if (!getRuntimeConfig().features.installLocal)
37
+ return;
35
38
  let cancelled = false;
36
39
  (async () => {
37
40
  const items = await listLocalInstalls();
@@ -183,18 +186,29 @@ export function MarketplacePackagePage() {
183
186
  }
184
187
  };
185
188
  if (loading) {
186
- return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6", children: [_jsx(Skeleton, { className: "h-8 w-32" }), _jsx(Skeleton, { className: "h-16 w-full" }), _jsx(Skeleton, { className: "h-64 w-full" })] }));
189
+ return (_jsxs("div", { className: "mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6", children: [_jsx(Skeleton, { className: "h-8 w-32" }), _jsx(Skeleton, { className: "h-16 w-full" }), _jsx(Skeleton, { className: "h-64 w-full" })] }));
187
190
  }
188
191
  if (error || !data) {
189
- return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Back to marketplace"] }), _jsxs("div", { className: "flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 text-destructive", "aria-hidden": "true" }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-destructive", children: "Failed to load package" }), _jsx("div", { className: "text-muted-foreground mt-1", children: error ?? 'Not found.' })] })] })] }));
192
+ return (_jsxs("div", { className: "mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Back to marketplace"] }), _jsxs("div", { className: "flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 text-destructive", "aria-hidden": "true" }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-destructive", children: "Failed to load package" }), _jsx("div", { className: "text-muted-foreground mt-1", children: error ?? 'Not found.' })] })] })] }));
190
193
  }
191
194
  const pkg = data.package;
192
195
  const latestVersion = pkg.latest_version?.version ?? data.versions[0]?.version ?? null;
193
196
  const localInstall = localInstalls.find((i) => i.manifestId === pkg.manifest_id) ?? null;
194
- return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6 max-w-5xl", children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "self-start", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Back to marketplace"] }), _jsxs("div", { className: "flex items-start gap-5 flex-wrap rounded-2xl border bg-gradient-to-br from-primary/5 via-background to-background p-6", children: [_jsx(PackageIcon, { iconUrl: pkg.icon_url, displayName: pkg.display_name, manifestId: pkg.manifest_id, className: "h-20 w-20 rounded-2xl shadow-sm ring-1 ring-border", initialClassName: "text-3xl font-bold" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-3xl font-bold tracking-tight truncate", children: pkg.display_name || pkg.manifest_id }), _jsxs("div", { className: "text-sm text-muted-foreground mt-2 flex flex-wrap items-center gap-2", children: [_jsx("code", { className: "font-mono text-xs px-1.5 py-0.5 rounded bg-muted", children: pkg.manifest_id }), latestVersion && (_jsxs(Badge, { variant: "outline", children: ["v", latestVersion] })), pkg.publisher && pkg.publisher !== 'private' && (_jsx(Badge, { variant: pkg.publisher === 'objectstack' ? 'default' : 'secondary', children: pkg.publisher })), pkg.category && _jsx(Badge, { variant: "outline", children: pkg.category }), pkg.license && _jsx(Badge, { variant: "outline", className: "font-normal", children: pkg.license }), localInstall && (_jsxs(Badge, { variant: "default", className: "bg-green-600 hover:bg-green-600", children: ["Installed \u00B7 v", localInstall.version] }))] }), pkg.description && (_jsx("p", { className: "text-base text-foreground/80 mt-4 max-w-2xl leading-relaxed", children: pkg.description }))] }), _jsxs("div", { className: "flex flex-col gap-2 shrink-0 min-w-[14rem]", children: [_jsxs(Button, { onClick: doInstallLocal, disabled: !latestVersion || installingLocal, size: "lg", children: [_jsx(Download, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), installingLocal
195
- ? 'Working…'
196
- : localInstall
197
- ? `Reinstall to this runtime`
198
- : 'Install to this runtime'] }), localInstall && (_jsxs(Button, { variant: "outline", onClick: doUninstallLocal, disabled: installingLocal, children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Uninstall from this runtime"] })), _jsxs(Button, { variant: "ghost", onClick: openInstall, disabled: !latestVersion, size: "sm", children: [_jsx(Download, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Install to cloud environment\u2026"] }), pkg.homepage_url && (_jsx("a", { href: pkg.homepage_url, target: "_blank", rel: "noopener noreferrer", children: _jsxs(Button, { variant: "ghost", size: "sm", className: "w-full", children: [_jsx(ExternalLink, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Homepage"] }) })), localResult && (_jsx("div", { className: `rounded-md border p-2 text-xs whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`, children: localResult.message }))] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-3", children: [_jsx("div", { className: "lg:col-span-2 space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "About" }) }), _jsx(CardContent, { children: pkg.readme ? (_jsx(MarkdownText, { source: pkg.readme })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "No readme provided." })) })] }) }), _jsx("div", { className: "space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Versions" }) }), _jsx(CardContent, { children: data.versions.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No approved versions." })) : (_jsx("ul", { className: "space-y-2", children: data.versions.map((v) => (_jsxs("li", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Package, { className: "h-3.5 w-3.5 text-muted-foreground", "aria-hidden": "true" }), _jsxs("code", { className: "font-mono", children: ["v", v.version] }), v.is_prerelease && _jsx(Badge, { variant: "outline", className: "text-xs", children: "pre" })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: v.published_at ? new Date(v.published_at).toLocaleDateString() : '—' })] }, v.id))) })) })] }) })] }), _jsx(Dialog, { open: installOpen, onOpenChange: (o) => { setInstallOpen(o); if (!o)
197
+ const supportsLocal = getRuntimeConfig().features.installLocal;
198
+ const primaryDisabled = !latestVersion || installingLocal || installing;
199
+ const primaryAction = supportsLocal
200
+ ? {
201
+ label: installingLocal
202
+ ? 'Working…'
203
+ : localInstall
204
+ ? 'Reinstall'
205
+ : 'Install',
206
+ onClick: doInstallLocal,
207
+ }
208
+ : {
209
+ label: installing ? 'Installing…' : 'Install to cloud…',
210
+ onClick: openInstall,
211
+ };
212
+ return (_jsxs("div", { className: "mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "self-start -ml-2 text-muted-foreground hover:text-foreground", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Back to marketplace"] }), _jsxs("div", { className: "flex items-start gap-5 flex-wrap sm:flex-nowrap rounded-2xl border bg-gradient-to-br from-primary/5 via-background to-background p-6 sm:p-8", children: [_jsx(PackageIcon, { iconUrl: pkg.icon_url, displayName: pkg.display_name, manifestId: pkg.manifest_id, className: "h-20 w-20 rounded-2xl shadow-sm ring-1 ring-border shrink-0", initialClassName: "text-3xl font-bold" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight truncate", children: pkg.display_name || pkg.manifest_id }), pkg.homepage_url && (_jsxs("a", { href: pkg.homepage_url, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors", title: "Homepage", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5", "aria-hidden": "true" }), _jsx("span", { className: "hidden sm:inline", children: "Homepage" })] }))] }), _jsxs("div", { className: "text-sm text-muted-foreground mt-2 flex flex-wrap items-center gap-1.5", children: [_jsx("code", { className: "font-mono text-xs px-1.5 py-0.5 rounded bg-muted", children: pkg.manifest_id }), latestVersion && _jsxs(Badge, { variant: "outline", children: ["v", latestVersion] }), pkg.publisher && pkg.publisher !== 'private' && (_jsx(Badge, { variant: pkg.publisher === 'objectstack' ? 'default' : 'secondary', children: pkg.publisher })), pkg.category && _jsx(Badge, { variant: "outline", children: pkg.category }), pkg.license && _jsx(Badge, { variant: "outline", className: "font-normal", children: pkg.license }), localInstall && (_jsxs(Badge, { variant: "default", className: "bg-green-600 hover:bg-green-600 gap-1", children: [_jsx(CheckCircle2, { className: "h-3 w-3", "aria-hidden": "true" }), "Installed \u00B7 v", localInstall.version] }))] }), pkg.description && (_jsx("p", { className: "text-sm sm:text-base text-foreground/80 mt-3 max-w-2xl leading-relaxed", children: pkg.description }))] }), _jsxs("div", { className: "flex items-center gap-2 shrink-0 self-start", children: [_jsxs(Button, { onClick: primaryAction.onClick, disabled: primaryDisabled, size: "lg", className: "min-w-[8rem]", children: [_jsx(Download, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), primaryAction.label] }), localInstall && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "lg", className: "px-2.5", "aria-label": "More install options", children: _jsx(MoreHorizontal, { className: "h-4 w-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-56", children: _jsxs(DropdownMenuItem, { onSelect: doUninstallLocal, disabled: installingLocal, className: "text-destructive focus:text-destructive", children: [_jsx(Trash2, { className: "h-4 w-4 mr-2", "aria-hidden": "true" }), "Uninstall from this runtime"] }) })] }))] })] }), localResult && (_jsxs("div", { role: "status", className: `flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`, children: [localResult.ok ? _jsx(CheckCircle2, { className: "h-4 w-4 mt-0.5 shrink-0", "aria-hidden": "true" }) : _jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 shrink-0", "aria-hidden": "true" }), _jsx("div", { className: "flex-1", children: localResult.message }), _jsx("button", { type: "button", className: "text-xs underline opacity-60 hover:opacity-100", onClick: () => setLocalResult(null), children: "Dismiss" })] })), _jsxs("div", { className: "grid gap-4 lg:grid-cols-3", children: [_jsx("div", { className: "lg:col-span-2 space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "About" }) }), _jsx(CardContent, { children: pkg.readme ? (_jsx(MarkdownText, { source: pkg.readme })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "No readme provided." })) })] }) }), _jsx("div", { className: "space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Versions" }) }), _jsx(CardContent, { children: data.versions.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No approved versions." })) : (_jsx("ul", { className: "space-y-2", children: data.versions.map((v) => (_jsxs("li", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Package, { className: "h-3.5 w-3.5 text-muted-foreground", "aria-hidden": "true" }), _jsxs("code", { className: "font-mono", children: ["v", v.version] }), v.is_prerelease && _jsx(Badge, { variant: "outline", className: "text-xs", children: "pre" })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: v.published_at ? new Date(v.published_at).toLocaleDateString() : '—' })] }, v.id))) })) })] }) })] }), _jsx(Dialog, { open: installOpen, onOpenChange: (o) => { setInstallOpen(o); if (!o)
199
213
  setInstallResult(null); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { children: ["Install ", pkg.display_name || pkg.manifest_id] }), _jsx(DialogDescription, { children: "Choose an environment to install this app into. You need to be signed into ObjectStack Cloud." })] }), envsLoading ? (_jsx(Skeleton, { className: "h-10 w-full" })) : envsError ? (_jsxs("div", { className: "rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-sm space-y-2", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 text-amber-600", "aria-hidden": "true" }), _jsx("div", { className: "flex-1", children: envsError })] }), _jsx("a", { href: cloudInstallDeepLink(pkg.id), target: "_blank", rel: "noopener noreferrer", children: _jsxs(Button, { variant: "outline", size: "sm", className: "w-full", children: [_jsx(ExternalLink, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Open on cloud"] }) })] })) : envs.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No environments found in your active organization." })) : (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "env-select", children: "Environment" }), _jsxs(Select, { value: selectedEnv, onValueChange: setSelectedEnv, children: [_jsx(SelectTrigger, { id: "env-select", children: _jsx(SelectValue, { placeholder: "Pick an environment" }) }), _jsx(SelectContent, { children: envs.map((e) => (_jsxs(SelectItem, { value: e.id, children: [e.display_name || e.hostname || e.id, e.plan && _jsxs("span", { className: "text-muted-foreground", children: [" \u00B7 ", e.plan] })] }, e.id))) })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "seed", checked: seedSampleData, onCheckedChange: (c) => setSeedSampleData(c === true) }), _jsx(Label, { htmlFor: "seed", className: "text-sm font-normal cursor-pointer", children: "Include sample data" })] })] })), installResult && (_jsx("div", { className: `rounded-md border p-3 text-sm ${installResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700' : 'border-destructive/30 bg-destructive/5 text-destructive'}`, children: installResult.message })), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setInstallOpen(false), children: "Close" }), !envsError && (_jsx(Button, { onClick: doInstall, disabled: !selectedEnv || installing || installResult?.ok === true, children: installing ? 'Installing…' : 'Install' }))] })] }) })] }));
200
214
  }
@@ -57,6 +57,20 @@ export declare function listMarketplacePackages(params?: {
57
57
  offset?: number;
58
58
  }): Promise<MarketplaceListResponse>;
59
59
  export declare function getMarketplacePackage(id: string): Promise<MarketplaceDetailResponse>;
60
+ /**
61
+ * Install a package into an environment by calling the cloud's
62
+ * `install_package` action **directly** (not via the proxy). Requires the
63
+ * caller's browser to have a valid cloud session cookie — typically
64
+ * because the user has signed into cloud at least once and the cookie
65
+ * domain covers this origin. When that's not the case the call fails
66
+ * with 401; the UI can then surface a "Sign in on cloud" link.
67
+ *
68
+ * `cloudBaseUrl` is supplied by the server at boot via
69
+ * `/api/v1/runtime/config` (see `runtime-config.ts`); we read it through
70
+ * `getCloudBase()` rather than hardcoding `VITE_CLOUD_URL` or sniffing
71
+ * `window.location.hostname`. When the runtime *is* the cloud,
72
+ * `getCloudBase()` returns `''` and we fall back to same-origin.
73
+ */
60
74
  export interface InstallResponse {
61
75
  installation?: {
62
76
  id: string;
@@ -12,6 +12,7 @@
12
12
  * cloud/packages/service-cloud/src/routes/marketplace.ts
13
13
  * framework/packages/runtime/src/cloud/marketplace-proxy-plugin.ts
14
14
  */
15
+ import { getCloudBase } from '../../runtime-config';
15
16
  const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
16
17
  const API_BASE = `${SERVER_URL}/api/v1/marketplace`;
17
18
  async function call(path, init) {
@@ -52,21 +53,8 @@ export async function listMarketplacePackages(params = {}) {
52
53
  export async function getMarketplacePackage(id) {
53
54
  return call(`/packages/${encodeURIComponent(id)}`);
54
55
  }
55
- /**
56
- * Install a package into an environment by calling the cloud's
57
- * `install_package` action **directly** (not via the proxy). Requires the
58
- * caller's browser to have a valid cloud session cookie — typically
59
- * because the user has signed into cloud at least once and the cookie
60
- * domain covers this origin. When that's not the case the call fails
61
- * with 401; the UI can then surface a "Sign in on cloud" link.
62
- *
63
- * `cloudBaseUrl` is read from `VITE_CLOUD_URL` if set; otherwise we fall
64
- * back to relative (assumes same-origin, which is true for cloud-hosted
65
- * consoles but generally not for tenant runtimes).
66
- */
67
- const CLOUD_BASE = (import.meta.env.VITE_CLOUD_URL || '').replace(/\/$/, '');
68
56
  export async function installPackage(input) {
69
- const base = CLOUD_BASE || SERVER_URL;
57
+ const base = getCloudBase() || SERVER_URL;
70
58
  const res = await fetch(`${base}/api/v1/actions/sys_package/install_package`, {
71
59
  method: 'POST',
72
60
  credentials: 'include',
@@ -95,7 +83,7 @@ export async function installPackage(input) {
95
83
  return (payload?.data ?? payload);
96
84
  }
97
85
  export async function listCloudEnvironments() {
98
- const base = CLOUD_BASE || SERVER_URL;
86
+ const base = getCloudBase() || SERVER_URL;
99
87
  const res = await fetch(`${base}/api/v1/data/sys_environment?limit=200`, {
100
88
  credentials: 'include',
101
89
  headers: { 'Accept': 'application/json' },
@@ -119,7 +107,7 @@ export async function listCloudEnvironments() {
119
107
  * can render a clean "no installable environments" state.
120
108
  */
121
109
  export async function listInstallableOrgIds() {
122
- const base = CLOUD_BASE || SERVER_URL;
110
+ const base = getCloudBase() || SERVER_URL;
123
111
  // sys_member rows are scoped to the caller; better-auth-managed table.
124
112
  const url = `${base}/api/v1/data/sys_member?limit=200`;
125
113
  let payload = null;
@@ -149,7 +137,7 @@ export async function listInstallableOrgIds() {
149
137
  return ids;
150
138
  }
151
139
  export function cloudInstallDeepLink(packageId) {
152
- const base = CLOUD_BASE || 'https://cloud.objectos.app';
140
+ const base = getCloudBase() || 'https://cloud.objectos.app';
153
141
  return `${base}/apps/cloud-control/sys_package/${encodeURIComponent(packageId)}`;
154
142
  }
155
143
  export async function installLocal(input) {
@@ -7,3 +7,4 @@ export { useRecentItems, type RecentItem } from './useRecentItems';
7
7
  export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals';
8
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
9
9
  export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
10
+ export { useChatConversation, type HydratedUIMessage, type UseChatConversationOptions, type UseChatConversationReturn, } from './useChatConversation';
@@ -7,3 +7,4 @@ export { useRecentItems } from './useRecentItems';
7
7
  export { useRecordApprovals } from './useRecordApprovals';
8
8
  export { useResponsiveSidebar } from './useResponsiveSidebar';
9
9
  export { useTrackRouteAsRecent } from './useTrackRouteAsRecent';
10
+ export { useChatConversation, } from './useChatConversation';
@@ -0,0 +1,31 @@
1
+ /** Minimal UIMessage shape compatible with `@ai-sdk/react`'s `useChat`. */
2
+ export interface HydratedUIMessage {
3
+ id: string;
4
+ role: 'user' | 'assistant' | 'system';
5
+ parts: Array<{
6
+ type: 'text';
7
+ text: string;
8
+ }>;
9
+ }
10
+ export interface UseChatConversationOptions {
11
+ /** Authenticated user id; hook is inert until this is defined. */
12
+ userId: string | undefined;
13
+ /**
14
+ * Optional scope (e.g. agent name) for keying separate conversations under
15
+ * the same user.
16
+ */
17
+ scope?: string;
18
+ /**
19
+ * Base URL of the AI service (no trailing slash). Hook calls
20
+ * `${apiBase}/conversations[/...]`. Required.
21
+ */
22
+ apiBase: string;
23
+ }
24
+ export interface UseChatConversationReturn {
25
+ conversationId: string | undefined;
26
+ initialMessages: HydratedUIMessage[];
27
+ isLoading: boolean;
28
+ /** Delete the current conversation + start a fresh one. */
29
+ reset: () => Promise<void>;
30
+ }
31
+ export declare function useChatConversation(options: UseChatConversationOptions): UseChatConversationReturn;
@@ -0,0 +1,188 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ /**
3
+ * Server-backed AI chat conversation lifecycle.
4
+ *
5
+ * Binds a chat UI to an `ai_conversations` row owned by the signed-in user.
6
+ * On mount it tries the cached id (per user, optionally per scope); falls
7
+ * back to creating a fresh conversation when the cached one is gone
8
+ * (404/403).
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from 'react';
11
+ const CACHE_PREFIX = 'objectstack:ai-chat-conversation-id';
12
+ function cacheKey(userId, scope) {
13
+ return scope ? `${CACHE_PREFIX}:${userId}:${scope}` : `${CACHE_PREFIX}:${userId}`;
14
+ }
15
+ function readCache(key) {
16
+ try {
17
+ return localStorage.getItem(key) ?? undefined;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ function writeCache(key, value) {
24
+ try {
25
+ if (value)
26
+ localStorage.setItem(key, value);
27
+ else
28
+ localStorage.removeItem(key);
29
+ }
30
+ catch {
31
+ /* ignore — private mode, quota, etc. */
32
+ }
33
+ }
34
+ function contentToText(content) {
35
+ if (typeof content === 'string')
36
+ return content;
37
+ if (Array.isArray(content)) {
38
+ return content
39
+ .map((part) => {
40
+ if (typeof part === 'string')
41
+ return part;
42
+ if (part &&
43
+ typeof part === 'object' &&
44
+ 'text' in part &&
45
+ typeof part.text === 'string') {
46
+ return part.text;
47
+ }
48
+ return '';
49
+ })
50
+ .join('');
51
+ }
52
+ return '';
53
+ }
54
+ function toUIMessages(rows) {
55
+ if (!rows)
56
+ return [];
57
+ const out = [];
58
+ rows.forEach((row, idx) => {
59
+ const role = row.role;
60
+ if (role !== 'user' && role !== 'assistant' && role !== 'system')
61
+ return;
62
+ const text = contentToText(row.content);
63
+ if (!text)
64
+ return;
65
+ out.push({
66
+ id: row.id ?? `msg-${idx}`,
67
+ role,
68
+ parts: [{ type: 'text', text }],
69
+ });
70
+ });
71
+ return out;
72
+ }
73
+ async function fetchConversation(apiBase, id) {
74
+ const res = await fetch(`${apiBase}/conversations/${encodeURIComponent(id)}`, {
75
+ credentials: 'include',
76
+ });
77
+ if (res.status === 404 || res.status === 403)
78
+ return null;
79
+ if (!res.ok)
80
+ throw new Error(`GET conversation failed: ${res.status}`);
81
+ return (await res.json());
82
+ }
83
+ async function createConversation(apiBase) {
84
+ const res = await fetch(`${apiBase}/conversations`, {
85
+ method: 'POST',
86
+ credentials: 'include',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: '{}',
89
+ });
90
+ if (!res.ok)
91
+ throw new Error(`POST conversation failed: ${res.status}`);
92
+ return (await res.json());
93
+ }
94
+ async function deleteConversation(apiBase, id) {
95
+ await fetch(`${apiBase}/conversations/${encodeURIComponent(id)}`, {
96
+ method: 'DELETE',
97
+ credentials: 'include',
98
+ }).catch(() => {
99
+ /* best-effort */
100
+ });
101
+ }
102
+ export function useChatConversation(options) {
103
+ const { userId, scope, apiBase } = options;
104
+ const [conversationId, setConversationId] = useState(undefined);
105
+ const [initialMessages, setInitialMessages] = useState([]);
106
+ const [isLoading, setIsLoading] = useState(Boolean(userId));
107
+ const mountedRef = useRef(true);
108
+ useEffect(() => {
109
+ mountedRef.current = true;
110
+ return () => {
111
+ mountedRef.current = false;
112
+ };
113
+ }, []);
114
+ useEffect(() => {
115
+ if (!userId) {
116
+ setConversationId(undefined);
117
+ setInitialMessages([]);
118
+ setIsLoading(false);
119
+ return;
120
+ }
121
+ let cancelled = false;
122
+ const key = cacheKey(userId, scope);
123
+ setIsLoading(true);
124
+ (async () => {
125
+ try {
126
+ const cached = readCache(key);
127
+ if (cached) {
128
+ const existing = await fetchConversation(apiBase, cached);
129
+ if (cancelled)
130
+ return;
131
+ if (existing) {
132
+ setConversationId(existing.id);
133
+ setInitialMessages(toUIMessages(existing.messages));
134
+ return;
135
+ }
136
+ writeCache(key, undefined);
137
+ }
138
+ const fresh = await createConversation(apiBase);
139
+ if (cancelled)
140
+ return;
141
+ writeCache(key, fresh.id);
142
+ setConversationId(fresh.id);
143
+ setInitialMessages(toUIMessages(fresh.messages));
144
+ }
145
+ catch {
146
+ if (!cancelled) {
147
+ setConversationId(undefined);
148
+ setInitialMessages([]);
149
+ }
150
+ }
151
+ finally {
152
+ if (!cancelled)
153
+ setIsLoading(false);
154
+ }
155
+ })();
156
+ return () => {
157
+ cancelled = true;
158
+ };
159
+ }, [userId, scope, apiBase]);
160
+ const reset = useCallback(async () => {
161
+ if (!userId)
162
+ return;
163
+ const key = cacheKey(userId, scope);
164
+ setIsLoading(true);
165
+ try {
166
+ if (conversationId)
167
+ await deleteConversation(apiBase, conversationId);
168
+ writeCache(key, undefined);
169
+ const fresh = await createConversation(apiBase);
170
+ writeCache(key, fresh.id);
171
+ if (!mountedRef.current)
172
+ return;
173
+ setConversationId(fresh.id);
174
+ setInitialMessages([]);
175
+ }
176
+ catch {
177
+ if (mountedRef.current) {
178
+ setConversationId(undefined);
179
+ setInitialMessages([]);
180
+ }
181
+ }
182
+ finally {
183
+ if (mountedRef.current)
184
+ setIsLoading(false);
185
+ }
186
+ }, [conversationId, userId, scope, apiBase]);
187
+ return { conversationId, initialMessages, isLoading, reset };
188
+ }
package/dist/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, Conn
19
19
  export type { ActivityItem } from './layout';
20
20
  export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, RouteFader, toastWithUndo, type ToastWithUndoOptions, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
21
21
  export { initSentry, captureError, setSentryUser, getSentry } from './observability';
22
+ export { initRuntimeConfig, getRuntimeConfig, getCloudBase, isRuntimeConfigInitialised, resetRuntimeConfigForTesting, } from './runtime-config';
23
+ export type { RuntimeConfig, RuntimeFeatures } from './runtime-config';
22
24
  export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
23
25
  export type { RecordFormPageProps } from './views';
24
26
  export { useFavorites, useMetadataService, useNavPins, useNavigationSync, NavigationSyncEffect, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, generateNavId, useResponsiveSidebar, } from './hooks';
package/dist/index.js CHANGED
@@ -22,6 +22,10 @@ export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, Conn
22
22
  export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, RouteFader, toastWithUndo, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
23
23
  // Observability — Sentry integration, opt-in via VITE_SENTRY_DSN
24
24
  export { initSentry, captureError, setSentryUser, getSentry } from './observability';
25
+ // Runtime configuration pushed by the server at boot. Consumers fetch
26
+ // `/api/v1/runtime/config` via `initRuntimeConfig()` before first render
27
+ // and read upstream cloud URL + capability flags from `getRuntimeConfig()`.
28
+ export { initRuntimeConfig, getRuntimeConfig, getCloudBase, isRuntimeConfigInitialised, resetRuntimeConfigForTesting, } from './runtime-config';
25
29
  // Standard inner-SPA views
26
30
  export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
27
31
  // Hooks
@@ -14,6 +14,12 @@ export interface ConsoleFloatingChatbotProps {
14
14
  defaultAgent?: string;
15
15
  /** Whether the floating panel should open immediately on mount. */
16
16
  defaultOpen?: boolean;
17
+ /**
18
+ * Authenticated user id. When provided, the chat hydrates from (and writes
19
+ * to) a server-backed `ai_conversations` row keyed by `userId` + agent.
20
+ * Inert until defined — the floating panel still works in local-only mode.
21
+ */
22
+ userId?: string;
17
23
  }
18
- export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, defaultOpen, }: ConsoleFloatingChatbotProps): import("react/jsx-runtime").JSX.Element;
24
+ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, defaultOpen, userId, }: ConsoleFloatingChatbotProps): import("react/jsx-runtime").JSX.Element;
19
25
  export {};
@@ -14,6 +14,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  import React from 'react';
15
15
  import { FloatingChatbot, useObjectChat, useAgents, useHitlInChat, } from '@object-ui/plugin-chatbot';
16
16
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
17
+ import { useChatConversation } from '../hooks';
17
18
  const DEFAULT_AI_PATH = '/api/v1/ai';
18
19
  function resolveApiBase(explicit) {
19
20
  if (explicit)
@@ -25,7 +26,7 @@ function resolveApiBase(explicit) {
25
26
  const serverUrl = env.VITE_SERVER_URL ?? '';
26
27
  return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
27
28
  }
28
- function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, defaultOpen = false, }) {
29
+ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, activeAgent, onAgentChange, chatApi, apiBase, defaultOpen = false, conversationId, }) {
29
30
  const objectNames = objects.map((o) => o.label || o.name).join(', ');
30
31
  const activeAgentLabel = React.useMemo(() => {
31
32
  const found = agents.find((a) => a.name === activeAgent);
@@ -36,7 +37,7 @@ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, a
36
37
  : `Hello! I'm your **${appLabel}** assistant. ${agentsError ? '(Backend unreachable — running in offline demo mode.)' : ''}`;
37
38
  const { messages, isLoading, error, sendMessage, stop, reload, clear, } = useObjectChat({
38
39
  api: chatApi,
39
- conversationId: activeAgent ? `${appLabel}:${activeAgent}` : undefined,
40
+ conversationId,
40
41
  body: {
41
42
  context: {
42
43
  activeApp: appLabel,
@@ -85,7 +86,7 @@ function ChatbotInner({ appLabel, objects, agents, agentsLoading, agentsError, a
85
86
  ? 'Loading agents...'
86
87
  : 'Ask anything...', onSendMessage: (content) => sendMessage(content), 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" }));
87
88
  }
88
- export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, defaultOpen = false, }) {
89
+ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: apiBaseProp, defaultAgent: defaultAgentProp, defaultOpen = false, userId, }) {
89
90
  const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
90
91
  const env = import.meta.env ?? {};
91
92
  const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT;
@@ -101,8 +102,16 @@ export default function ConsoleFloatingChatbot({ appLabel, objects, apiBase: api
101
102
  const chatApi = activeAgent
102
103
  ? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
103
104
  : undefined;
104
- // `key` forces a clean remount whenever the active agent (and therefore the
105
- // chat API URL) changes required because `useObjectChat` locks its mode
106
- // (api vs local) on first render.
107
- return (_jsx(ChatbotInner, { appLabel: appLabel, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, defaultOpen: defaultOpen }, chatApi ?? 'local'));
105
+ // Server-backed conversation. Scoped by agent so each agent gets its own
106
+ // persistent history. Hook is inert until `userId` is provided; without it
107
+ // the FAB continues to work in local-only mode (no persistence).
108
+ const { conversationId } = useChatConversation({
109
+ userId,
110
+ scope: activeAgent,
111
+ apiBase,
112
+ });
113
+ // `key` forces a clean remount whenever the chat endpoint OR the resolved
114
+ // conversation id changes — required because `useObjectChat` locks its mode
115
+ // (api vs local) and its `conversationId` on first render.
116
+ return (_jsx(ChatbotInner, { appLabel: appLabel, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, chatApi: chatApi, apiBase: apiBase, defaultOpen: defaultOpen, conversationId: conversationId }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`));
108
117
  }
@@ -16,7 +16,12 @@ interface ConsoleLayoutProps {
16
16
  onAppChange: (name: string) => void;
17
17
  objects: any[];
18
18
  connectionState?: ConnectionState;
19
+ /**
20
+ * Signed-in user id. Forwarded to the floating chatbot so it can hydrate
21
+ * server-backed conversation history. Omit for unauthenticated/local-only.
22
+ */
23
+ userId?: string;
19
24
  }
20
25
  /** Floating chatbot wired with useObjectChat for demo auto-response */
21
- export declare function ConsoleLayout({ children, activeAppName, activeApp, onAppChange, objects, connectionState }: ConsoleLayoutProps): import("react/jsx-runtime").JSX.Element;
26
+ export declare function ConsoleLayout({ children, activeAppName, activeApp, onAppChange, objects, connectionState, userId, }: ConsoleLayoutProps): import("react/jsx-runtime").JSX.Element;
22
27
  export {};
@@ -28,7 +28,7 @@ function ConsoleLayoutInner({ children }) {
28
28
  }
29
29
  /** Floating chatbot wired with useObjectChat for demo auto-response */
30
30
  // (moved to ./ConsoleFloatingChatbot.tsx for code-splitting)
31
- export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange, objects, connectionState }) {
31
+ export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange, objects, connectionState, userId, }) {
32
32
  const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
33
33
  const { isAiEnabled } = useDiscovery();
34
34
  // Trust an explicit `VITE_AI_BASE_URL` opt-in even when discovery reports
@@ -51,5 +51,5 @@ export function ConsoleLayout({ children, activeAppName, activeApp, onAppChange,
51
51
  ? `${resolveI18nLabel(activeApp.label)} — ObjectStack Console`
52
52
  : undefined,
53
53
  }
54
- : undefined, children: [_jsx(ConsoleLayoutInner, { children: children }), showChatbot && (_jsx(ConsoleChatbotFab, { appLabel: appLabel, objects: objects }))] }) }));
54
+ : undefined, children: [_jsx(ConsoleLayoutInner, { children: children }), showChatbot && (_jsx(ConsoleChatbotFab, { appLabel: appLabel, objects: objects, userId: userId }))] }) }));
55
55
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Runtime configuration pushed by the server at boot.
3
+ *
4
+ * The SPA fetches `GET /api/v1/runtime/config` once before first paint
5
+ * (`initRuntimeConfig()`) and exposes the response through a module-level
6
+ * singleton (`config` + `getRuntimeConfig()`). Modules that need to know
7
+ * about the upstream cloud URL or capability flags read from here —
8
+ * NEVER from `window.location.hostname` or Vite-time env vars, since
9
+ * those don't reflect the runtime the SPA is actually attached to (e.g.
10
+ * a tenant ObjectOS runtime pointing at a separate cloud control plane).
11
+ *
12
+ * Mirrors `apps/studio/src/lib/config.ts` but lives in app-shell because
13
+ * the Console SPA in `apps/console` consumes app-shell code.
14
+ *
15
+ * Server-side: see
16
+ * cloud/packages/service-cloud/src/multi-environment-plugins.ts
17
+ * cloud/packages/service-cloud/src/single-environment-plugin.ts
18
+ * framework/packages/runtime/src/cloud/runtime-config-plugin.ts
19
+ */
20
+ export interface RuntimeFeatures {
21
+ /** "Install to this runtime" button is meaningful on this runtime. */
22
+ installLocal: boolean;
23
+ /** `/api/v1/marketplace/*` is reachable from this runtime. */
24
+ marketplace: boolean;
25
+ }
26
+ export interface RuntimeConfig {
27
+ /**
28
+ * Upstream cloud base URL — the SPA dispatches install + env listing
29
+ * directly against this origin. Empty string ⇒ same-origin (i.e. the
30
+ * runtime we're attached to *is* the cloud).
31
+ */
32
+ cloudUrl: string;
33
+ /** Single-environment runtime (CLI `os serve`, etc.). */
34
+ singleEnvironment: boolean;
35
+ defaultOrgId?: string | null;
36
+ defaultEnvironmentId?: string | null;
37
+ features: RuntimeFeatures;
38
+ }
39
+ /**
40
+ * Fetch the server-pushed runtime config and merge it into the singleton.
41
+ * Must be awaited before first render so consumers see definitive values
42
+ * on first paint. Safe to call more than once (subsequent calls re-fetch
43
+ * and re-merge).
44
+ *
45
+ * `baseUrl` lets callers in dev (Vite proxy) override the fetch origin.
46
+ * In production both Console SPA and tenant runtime share an origin so
47
+ * the default (relative `/api/v1/...`) works.
48
+ */
49
+ export declare function initRuntimeConfig(baseUrl?: string): Promise<void>;
50
+ /** Read-only accessor. Returns the current snapshot. */
51
+ export declare function getRuntimeConfig(): RuntimeConfig;
52
+ /** Whether `initRuntimeConfig()` has run at least once. */
53
+ export declare function isRuntimeConfigInitialised(): boolean;
54
+ /**
55
+ * Resolve the upstream cloud base URL the SPA should target. When the
56
+ * runtime says it *is* the cloud (`cloudUrl: ''`) the SPA stays on the
57
+ * current origin. Otherwise this returns the server-supplied URL with no
58
+ * trailing slash.
59
+ */
60
+ export declare function getCloudBase(): string;
61
+ /** Test/dev helper. */
62
+ export declare function resetRuntimeConfigForTesting(): void;
@@ -0,0 +1,86 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+ const defaults = {
3
+ cloudUrl: '',
4
+ singleEnvironment: false,
5
+ defaultOrgId: null,
6
+ defaultEnvironmentId: null,
7
+ features: { installLocal: false, marketplace: true },
8
+ };
9
+ let current = { ...defaults };
10
+ let initialised = false;
11
+ /** Apply a partial update over the singleton. */
12
+ function applyUpdate(patch) {
13
+ current = {
14
+ ...current,
15
+ ...patch,
16
+ features: {
17
+ ...current.features,
18
+ ...(patch.features ?? {}),
19
+ },
20
+ };
21
+ }
22
+ /**
23
+ * Fetch the server-pushed runtime config and merge it into the singleton.
24
+ * Must be awaited before first render so consumers see definitive values
25
+ * on first paint. Safe to call more than once (subsequent calls re-fetch
26
+ * and re-merge).
27
+ *
28
+ * `baseUrl` lets callers in dev (Vite proxy) override the fetch origin.
29
+ * In production both Console SPA and tenant runtime share an origin so
30
+ * the default (relative `/api/v1/...`) works.
31
+ */
32
+ export async function initRuntimeConfig(baseUrl = '') {
33
+ const base = (baseUrl || '').replace(/\/+$/, '');
34
+ try {
35
+ const res = await fetch(`${base}/api/v1/runtime/config`, {
36
+ credentials: 'include',
37
+ headers: { Accept: 'application/json' },
38
+ });
39
+ if (!res.ok)
40
+ return;
41
+ const body = (await res.json());
42
+ if (!body || typeof body !== 'object')
43
+ return;
44
+ applyUpdate({
45
+ cloudUrl: typeof body.cloudUrl === 'string' ? body.cloudUrl.replace(/\/+$/, '') : current.cloudUrl,
46
+ singleEnvironment: !!body.singleEnvironment,
47
+ defaultOrgId: body.defaultOrgId ?? current.defaultOrgId ?? null,
48
+ defaultEnvironmentId: body.defaultEnvironmentId ?? current.defaultEnvironmentId ?? null,
49
+ features: body.features
50
+ ? {
51
+ installLocal: !!body.features.installLocal,
52
+ marketplace: body.features.marketplace !== false,
53
+ }
54
+ : current.features,
55
+ });
56
+ }
57
+ catch {
58
+ // Endpoint missing or network failure ⇒ keep defaults. Older runtimes
59
+ // pre-dating this endpoint simply behave as before.
60
+ }
61
+ finally {
62
+ initialised = true;
63
+ }
64
+ }
65
+ /** Read-only accessor. Returns the current snapshot. */
66
+ export function getRuntimeConfig() {
67
+ return current;
68
+ }
69
+ /** Whether `initRuntimeConfig()` has run at least once. */
70
+ export function isRuntimeConfigInitialised() {
71
+ return initialised;
72
+ }
73
+ /**
74
+ * Resolve the upstream cloud base URL the SPA should target. When the
75
+ * runtime says it *is* the cloud (`cloudUrl: ''`) the SPA stays on the
76
+ * current origin. Otherwise this returns the server-supplied URL with no
77
+ * trailing slash.
78
+ */
79
+ export function getCloudBase() {
80
+ return current.cloudUrl ?? '';
81
+ }
82
+ /** Test/dev helper. */
83
+ export function resetRuntimeConfigForTesting() {
84
+ current = { ...defaults, features: { ...defaults.features } };
85
+ initialised = false;
86
+ }
@@ -386,7 +386,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
386
386
  const shouldRefresh = action.refreshAfter !== false;
387
387
  if (shouldRefresh)
388
388
  setActionRefreshKey(k => k + 1);
389
- return { success: true, data: json?.data, reload: shouldRefresh };
389
+ const result = json?.data;
390
+ return { success: true, data: result, reload: shouldRefresh };
390
391
  }
391
392
  catch (error) {
392
393
  return { success: false, error: error.message };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -28,35 +28,35 @@
28
28
  "@sentry/react": "^8.55.2",
29
29
  "lucide-react": "^1.16.0",
30
30
  "sonner": "^2.0.7",
31
- "@object-ui/auth": "6.0.0",
32
- "@object-ui/collaboration": "6.0.0",
33
- "@object-ui/components": "6.0.0",
34
- "@object-ui/core": "6.0.0",
35
- "@object-ui/data-objectstack": "6.0.0",
36
- "@object-ui/fields": "6.0.0",
37
- "@object-ui/i18n": "6.0.0",
38
- "@object-ui/layout": "6.0.0",
39
- "@object-ui/permissions": "6.0.0",
40
- "@object-ui/providers": "6.0.0",
41
- "@object-ui/react": "6.0.0",
42
- "@object-ui/types": "6.0.0"
31
+ "@object-ui/auth": "6.0.2",
32
+ "@object-ui/collaboration": "6.0.2",
33
+ "@object-ui/components": "6.0.2",
34
+ "@object-ui/core": "6.0.2",
35
+ "@object-ui/data-objectstack": "6.0.2",
36
+ "@object-ui/fields": "6.0.2",
37
+ "@object-ui/i18n": "6.0.2",
38
+ "@object-ui/layout": "6.0.2",
39
+ "@object-ui/permissions": "6.0.2",
40
+ "@object-ui/providers": "6.0.2",
41
+ "@object-ui/react": "6.0.2",
42
+ "@object-ui/types": "6.0.2"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "react": "^18.0.0 || ^19.0.0",
46
46
  "react-dom": "^18.0.0 || ^19.0.0",
47
47
  "react-router-dom": "^6.0.0 || ^7.0.0",
48
- "@object-ui/plugin-calendar": "^6.0.0",
49
- "@object-ui/plugin-charts": "^6.0.0",
50
- "@object-ui/plugin-chatbot": "^6.0.0",
51
- "@object-ui/plugin-dashboard": "^6.0.0",
52
- "@object-ui/plugin-designer": "^6.0.0",
53
- "@object-ui/plugin-detail": "^6.0.0",
54
- "@object-ui/plugin-form": "^6.0.0",
55
- "@object-ui/plugin-grid": "^6.0.0",
56
- "@object-ui/plugin-kanban": "^6.0.0",
57
- "@object-ui/plugin-list": "^6.0.0",
58
- "@object-ui/plugin-report": "^6.0.0",
59
- "@object-ui/plugin-view": "^6.0.0"
48
+ "@object-ui/plugin-calendar": "^6.0.2",
49
+ "@object-ui/plugin-charts": "^6.0.2",
50
+ "@object-ui/plugin-chatbot": "^6.0.2",
51
+ "@object-ui/plugin-dashboard": "^6.0.2",
52
+ "@object-ui/plugin-designer": "^6.0.2",
53
+ "@object-ui/plugin-detail": "^6.0.2",
54
+ "@object-ui/plugin-form": "^6.0.2",
55
+ "@object-ui/plugin-grid": "^6.0.2",
56
+ "@object-ui/plugin-kanban": "^6.0.2",
57
+ "@object-ui/plugin-list": "^6.0.2",
58
+ "@object-ui/plugin-report": "^6.0.2",
59
+ "@object-ui/plugin-view": "^6.0.2"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^25.9.0",