diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx
index 56a90be17..cb0e9ae5d 100644
--- a/packages/app-shell/src/console/AppContent.tsx
+++ b/packages/app-shell/src/console/AppContent.tsx
@@ -346,8 +346,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
const isCreateAppRoute = location.pathname.endsWith('/create-app');
const isSystemRoute = location.pathname.includes('/system');
-
- if (!activeApp && !isCreateAppRoute && !isSystemRoute) return (
+ // The metadata designer (Studio) must be reachable even with no active app —
+ // a brand-new env where AI just drafted everything has ZERO published apps,
+ // so without this exemption the "no apps configured" guard below would block
+ // the very surface you need to REVIEW & PUBLISH those first drafts (a
+ // chicken-and-egg that stranded the AI magic-moment loop).
+ const isMetadataRoute = location.pathname.includes('/metadata');
+
+ if (!activeApp && !isCreateAppRoute && !isSystemRoute && !isMetadataRoute) return (
);
- if (!activeApp && (isCreateAppRoute || isSystemRoute)) {
+ if (!activeApp && (isCreateAppRoute || isSystemRoute || isMetadataRoute)) {
return (
}>
@@ -374,6 +380,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
} />
} />
} />
+ {/* Studio / metadata designer — reachable with no active app so a
+ fresh env can review + publish its first (AI-authored) drafts. */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{extraRoutesNoApp}
diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx
index f403b9aa1..c70127670 100644
--- a/packages/app-shell/src/console/home/HomePage.tsx
+++ b/packages/app-shell/src/console/home/HomePage.tsx
@@ -27,7 +27,9 @@ import { AppCard } from './AppCard';
import { RecentApps } from './RecentApps';
import { StarredApps } from './StarredApps';
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
-import { Plus, Settings, Sparkles, Star, Clock, ArrowDown, Store, LayoutGrid, ShieldAlert, X } from 'lucide-react';
+import { Plus, Settings, Sparkles, Star, Clock, ArrowDown, Store, LayoutGrid, ShieldAlert, X, UploadCloud } from 'lucide-react';
+import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
+import { toast } from 'sonner';
function pickGreetingKey(hour: number): string {
if (hour < 5) return 'home.greetingNight';
@@ -102,6 +104,82 @@ function StatPill({
);
}
+/**
+ * Pending-drafts banner — closes the AI magic-moment loop. After the metadata
+ * assistant drafts objects/views/apps (ADR-0033 draft-gated authoring), nothing
+ * is live until the human publishes. Without this, a user who just had AI build
+ * their whole system landed back on an empty-looking home with no trace of it
+ * and no path to publish. This surfaces the pending drafts and routes to the
+ * designer to review + publish. Disappears automatically once published
+ * (listDrafts → 0).
+ */
+function PendingDraftsBanner({ t }: { t: (key: string, opts?: any) => string }) {
+ const client = useMetadataClient();
+ const [count, setCount] = useState(0);
+ const [pkgIds, setPkgIds] = useState([]);
+ const [publishing, setPublishing] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ Promise.resolve(client.listDrafts?.({}))
+ .then((drafts) => {
+ if (cancelled || !Array.isArray(drafts)) return;
+ setCount(drafts.length);
+ setPkgIds([...new Set(drafts.map((d: any) => d.packageId).filter(Boolean) as string[])]);
+ })
+ .catch(() => { /* drafts unsupported / error → don't show */ });
+ return () => { cancelled = true; };
+ }, [client]);
+
+ // One-click publish: promote the draft package(s) directly so a brand-new
+ // user reaches "it's live" without hunting for a designer. (Pre-PMF activation
+ // > the draft-review gate; the review path can return when it matters.)
+ const publish = async () => {
+ setPublishing(true);
+ try {
+ const ids = pkgIds.length
+ ? pkgIds
+ : [...new Set((((await client.listDrafts?.({})) as any[]) || []).map((d) => d.packageId).filter(Boolean) as string[])];
+ if (ids.length === 0) throw new Error('no draft packages');
+ for (const id of ids) {
+ const res = await fetch(`/api/v1/packages/${encodeURIComponent(id)}/publish-drafts`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({}),
+ });
+ if (!res.ok) throw new Error((await res.text().catch(() => '')) || `HTTP ${res.status}`);
+ }
+ toast.success(t('home.pendingDrafts.published', { defaultValue: 'Published! Your changes are live.' }));
+ setCount(0);
+ // Surface the now-live app — reload so the populated home shows it.
+ setTimeout(() => { try { window.location.reload(); } catch { /* ignore */ } }, 700);
+ } catch (e) {
+ toast.error(`${t('home.pendingDrafts.publishFailed', { defaultValue: 'Publish failed' })}: ${(e as Error).message}`);
+ setPublishing(false);
+ }
+ };
+
+ if (count <= 0) return null;
+ return (
+
+
+
+
+
+ {t('home.pendingDrafts.message', { count, defaultValue: 'You have {{count}} unpublished change(s) — publish to make them live.' })}
+
+
+
+
+
+ );
+}
+
/**
* Dismissible nudge to set a local recovery password — shown when the user
* signed in via SSO and has no local credential yet. We no longer force this
@@ -183,6 +261,7 @@ export function HomePage() {
if (activeApps.length === 0) {
return (