Skip to content

Commit c1a56d7

Browse files
os-zhuangclaude
andauthored
feat(preview): make ADR-0037 draft preview honest end-to-end — sticky mode, real empty states, live publish truth, changeset panel (#1659)
Browser-verified against the full local stack (cloud+objectos, prod-like) with drafts staged via the same path apply_blueprint uses. Four fixes and two features that together close the "AI built my app but the preview says No Apps Configured" failure chain: Fixes - MetadataProvider.extractItems now accepts the bare-array shape MetadataClient.list() returns. It only parsed the adapter SDK's `{items}` envelope, so EVERY preview-mode read collapsed to 0 items and the Live Canvas stranded on the console's generic empty state. - PreviewModeContext: preview is now STICKY. In-app navigation (landing redirect, sidebar links) drops the query string; the context value used to flicker false, swapping the metadata source mid-session, and the URL silently left the draft world. The provider now keeps the mode on, restores the flag (history replace), and only an explicit markPreviewExit() (DraftPreviewBar's Exit) leaves preview. Unit-tested. - NavigationSyncEffect is disabled inside preview: entering/leaving preview legitimately swaps the page/dashboard sets, and diffing across that swap misread draft-only items as user creations — it then WROTE app-navigation changes from inside a read-only preview (observed live: PUT /meta/app/setup 403 + "Failed to update navigation" toasts). - AppContent: in preview, a requested app that isn't in the draft overlay no longer falls back to ANOTHER app (it previewed the wrong app), and the generic "No Apps Configured" + "Create Your First App" screen never renders inside a preview — a draft-aware empty/error state (PreviewDraftEmptyState: not-ready / load-failed / retry) does. Features - Live publish truth on chat draft cards: new `fetchPendingDraftCount` prop (wired in both chat hosts via the ADR-0033 `_drafts` endpoint). Cards resolve Publish/Published from the server's CURRENT pending count — reloaded conversations stop replaying their birth snapshot, the Publish button shows the real pending count, and `verification` (ADR-0038 L1 lint) renders as a Verified / N issues chip. - DraftChangesPanel ("what will publishing change?"): the preview bar gains a Changes (N) button opening a changeset sheet — every pending draft grouped by type, classified New (adds) vs Update (overwrites live) via one published-list read per type. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0d707b6 commit c1a56d7

15 files changed

Lines changed: 799 additions & 17 deletions

packages/app-shell/src/console/AppContent.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type { ConnectionState } from '@object-ui/data-objectstack';
2020
import { useAuth } from '@object-ui/auth';
2121
import { useMetadata } from '../providers/MetadataProvider';
2222
import { useAdapter } from '../providers/AdapterProvider';
23+
import { usePreviewDrafts } from '../preview/PreviewModeContext';
24+
import { PreviewDraftEmptyState } from '../preview/PreviewDraftEmptyState';
2325
import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
2426
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
2527
import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
@@ -135,7 +137,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
135137
const navigate = useNavigate();
136138
const location = useLocation();
137139
const { appName } = useParams();
138-
const { apps, objects: allObjects, loading: metadataLoading, ensureType } = useMetadata();
140+
const { apps, objects: allObjects, loading: metadataLoading, ensureType, error: metadataError, refresh: refreshMetadata } = useMetadata();
141+
const previewDrafts = usePreviewDrafts();
139142
const { t } = useObjectTranslation();
140143
const { objectLabel } = useObjectLabel();
141144

@@ -173,6 +176,13 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
173176
launcherApps.find((a: any) => a.isDefault === true) ||
174177
launcherApps[0];
175178

179+
// ADR-0037 — a draft preview is a window onto ONE requested app. When the
180+
// overlay doesn't (yet) carry it, the default-app fallback above would
181+
// silently preview the WRONG app; treat the request as "not ready" instead
182+
// and let the preview-specific empty state below say so.
183+
const requestedAppMissing =
184+
previewDrafts && !!appName && !apps.some((a: any) => a.name === appName);
185+
176186
useEffect(() => {
177187
if (!activeApp?.name) return;
178188
const packageMetadataPath = `/apps/${activeApp.name}/metadata/package`;
@@ -344,6 +354,21 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
344354

345355
if (!dataSource || metadataLoading || !scopeMetaReady) return <LoadingScreen />;
346356

357+
// ADR-0037 — preview mode renders its OWN empty/error states and never
358+
// falls through to the generic "No Apps Configured" guard below: inside a
359+
// draft preview (the Live Canvas iframe, or a hand-opened ?preview=draft
360+
// URL) that screen both lies ("nothing has been registered") and misdirects
361+
// ("Create Your First App") about an app the AI may have just drafted.
362+
if (previewDrafts && (requestedAppMissing || !activeApp)) {
363+
return (
364+
<PreviewDraftEmptyState
365+
appName={appName}
366+
error={metadataError}
367+
onRetry={() => void refreshMetadata()}
368+
/>
369+
);
370+
}
371+
347372
const isCreateAppRoute = location.pathname.endsWith('/create-app');
348373
const isSystemRoute = location.pathname.includes('/system');
349374
// The metadata designer (Studio) must be reachable even with no active app —

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
} from '@object-ui/plugin-chatbot';
4747

4848
import { AppHeader } from '../../layout/AppHeader';
49+
import { fetchPendingDraftCount } from '../../preview/draftStatus';
4950
import { getRuntimeConfig } from '../../runtime-config';
5051
import { useNavigationContext } from '../../context/NavigationContext';
5152
import {
@@ -632,6 +633,9 @@ function ChatPane({
632633
// Build-tree "Open app": jump straight into the app the agent just built.
633634
onOpenBuiltApp={(appName) => navigate(`/apps/${encodeURIComponent(appName)}`)}
634635
openBuiltAppLabel={t('console.ai.openBuiltApp', { defaultValue: 'Open app' })}
636+
// Live lifecycle truth for draft cards: the server's pending count per
637+
// package, so reloaded conversations show Published/Publish honestly.
638+
fetchPendingDraftCount={fetchPendingDraftCount}
635639
onPublishDrafts={async (packageId) => {
636640
// Promote the conversation's staged drafts to live (ADR-0033 gate —
637641
// the human still clicks). Same call as the floating chat + PackagesPage.

packages/app-shell/src/hooks/useNavigationSync.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { NavigationItem, AppSchema } from '@object-ui/types';
1515
import { useObjectTranslation } from '@object-ui/i18n';
1616
import { useAdapter } from '../providers/AdapterProvider';
1717
import { useMetadata } from '../providers/MetadataProvider';
18+
import { usePreviewDrafts } from '../preview/PreviewModeContext';
1819

1920
// ============================================================================
2021
// Pure utility helpers (exported for testing)
@@ -513,6 +514,13 @@ export function NavigationSyncEffect(): null {
513514
const adapter = useAdapter();
514515
const adapterRef = useRef(adapter);
515516
adapterRef.current = adapter;
517+
// ADR-0037 — preview is read-only by design. Entering/leaving
518+
// `?preview=draft` swaps the entire metadata source, so the page/dashboard
519+
// sets legitimately DIFFER from the previous render; diffing across that
520+
// swap would misread draft-only (or published-only) items as user
521+
// creations/deletions and WRITE navigation changes back to the real app
522+
// metadata from inside a preview. Disabled for the whole preview session.
523+
const previewDrafts = usePreviewDrafts();
516524

517525
const {
518526
syncPageCreated,
@@ -537,6 +545,13 @@ export function NavigationSyncEffect(): null {
537545

538546
useEffect(() => {
539547
if (syncingRef.current) return;
548+
if (previewDrafts) {
549+
// Drop the baseline so leaving preview re-seeds instead of diffing the
550+
// published world against the draft world it just stopped rendering.
551+
prevPageNamesRef.current = null;
552+
prevDashNamesRef.current = null;
553+
return;
554+
}
540555

541556
const currentPageNames = new Set(
542557
(pages ?? []).map((p: any) => p.name).filter(Boolean) as string[],
@@ -616,7 +631,7 @@ export function NavigationSyncEffect(): null {
616631
return () => {
617632
cancelled = true;
618633
};
619-
}, [pages, dashboards, apps, syncPageCreated, syncDashboardCreated, syncPageDeleted, syncDashboardDeleted]);
634+
}, [pages, dashboards, apps, previewDrafts, syncPageCreated, syncDashboardCreated, syncPageDeleted, syncDashboardDeleted]);
620635

621636
return null;
622637
}

packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
type HydratedUIMessage,
4343
} from '../hooks';
4444
import { useAssistant, requestAssistantReview, emitCanvasInvalidate, type AssistantEditorContext } from '../assistant/assistantBus';
45+
import { fetchPendingDraftCount } from '../preview/draftStatus';
4546
import { getRuntimeConfig } from '../runtime-config';
4647

4748
/**
@@ -525,6 +526,9 @@ function ChatbotInner({
525526
onDraftArtifacts={(artifacts) => {
526527
for (const a of artifacts) emitCanvasInvalidate(a);
527528
}}
529+
// Live lifecycle truth for draft cards: the server's pending count per
530+
// package, so reloaded conversations show Published/Publish honestly.
531+
fetchPendingDraftCount={fetchPendingDraftCount}
528532
onPublishDrafts={async (packageId) => {
529533
// ADR-0033 — promote the conversation's staged drafts to live in one
530534
// click (the human still confirms here). Mirrors PackagesPage's
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* "What will publishing change?" — the draft changeset, answered before the
11+
* user commits. Lists every pending ADR-0033 draft grouped by metadata type,
12+
* and classifies each as NEW (no published version exists — publishing adds
13+
* it) or UPDATE (a published version exists — publishing overwrites it).
14+
* This is the review surface that turns Publish from a leap of faith into an
15+
* informed click; the per-item designer diff remains the deep-dive.
16+
*
17+
* Read-only: fetches `_drafts` + per-item `/published` probes on open, and
18+
* never writes. Publishing stays with the caller (DraftPreviewBar / chat).
19+
*/
20+
21+
import { useCallback, useEffect, useState } from 'react';
22+
import { FilePlus2, FilePen, Loader2 } from 'lucide-react';
23+
import {
24+
Badge,
25+
Sheet,
26+
SheetContent,
27+
SheetDescription,
28+
SheetHeader,
29+
SheetTitle,
30+
} from '@object-ui/components';
31+
import { useObjectTranslation } from '@object-ui/i18n';
32+
33+
export interface DraftChangeEntry {
34+
type: string;
35+
name: string;
36+
packageId: string | null;
37+
/** `new` = no published version; `update` = overwrites one; undefined = probing. */
38+
kind?: 'new' | 'update';
39+
}
40+
41+
/** Pending drafts straight from the ADR-0033 `_drafts` endpoint. */
42+
async function listPendingDrafts(): Promise<DraftChangeEntry[]> {
43+
const res = await fetch('/api/v1/meta/_drafts', {
44+
credentials: 'include',
45+
headers: { Accept: 'application/json' },
46+
cache: 'no-store',
47+
});
48+
if (!res.ok) throw new Error(`_drafts HTTP ${res.status}`);
49+
const data = (await res.json()) as
50+
| Array<Record<string, unknown>>
51+
| { drafts?: Array<Record<string, unknown>> };
52+
const list = Array.isArray(data) ? data : data?.drafts ?? [];
53+
return list
54+
.filter((d) => typeof d?.type === 'string' && typeof d?.name === 'string')
55+
.map((d) => ({
56+
type: d.type as string,
57+
name: d.name as string,
58+
packageId: typeof d.packageId === 'string' && d.packageId ? (d.packageId as string) : null,
59+
}));
60+
}
61+
62+
/**
63+
* Names that exist in the PUBLISHED world for a type — the plain (no
64+
* `preview=draft`) list. One request classifies every draft of that type:
65+
* a draft whose name is absent here is NEW; present means publish UPDATES it.
66+
* (A per-item `/published` probe would be O(drafts) requests, and the REST
67+
* tree has no such sub-route — the generic :name handler answers anything.)
68+
*/
69+
async function publishedNamesOf(type: string): Promise<Set<string>> {
70+
const res = await fetch(`/api/v1/meta/${encodeURIComponent(type)}`, {
71+
credentials: 'include',
72+
headers: { Accept: 'application/json' },
73+
cache: 'no-store',
74+
});
75+
if (!res.ok) throw new Error(`published list HTTP ${res.status}`);
76+
const data = (await res.json()) as unknown[] | { items?: unknown[] };
77+
const list = Array.isArray(data) ? data : data?.items ?? [];
78+
return new Set(
79+
(list as Array<{ name?: unknown }>)
80+
.map((it) => (typeof it?.name === 'string' ? it.name : null))
81+
.filter((n): n is string => n !== null),
82+
);
83+
}
84+
85+
export interface DraftChangesPanelProps {
86+
open: boolean;
87+
onOpenChange: (open: boolean) => void;
88+
}
89+
90+
export function DraftChangesPanel({ open, onOpenChange }: DraftChangesPanelProps) {
91+
const { t } = useObjectTranslation();
92+
const [entries, setEntries] = useState<DraftChangeEntry[] | null>(null);
93+
const [error, setError] = useState<string | null>(null);
94+
95+
const load = useCallback(async () => {
96+
setEntries(null);
97+
setError(null);
98+
try {
99+
const drafts = await listPendingDrafts();
100+
setEntries(drafts);
101+
// Classify new-vs-update per TYPE: one published-list read covers every
102+
// draft of that type. A type whose read fails stays unclassified
103+
// (rendered neutrally) rather than failing the whole panel.
104+
const types = [...new Set(drafts.map((d) => d.type))];
105+
await Promise.all(
106+
types.map(async (type) => {
107+
let published: Set<string> | null = null;
108+
try {
109+
published = await publishedNamesOf(type);
110+
} catch {
111+
return;
112+
}
113+
setEntries((prev) =>
114+
prev
115+
? prev.map((entry) =>
116+
entry.type === type
117+
? { ...entry, kind: published!.has(entry.name) ? 'update' : 'new' }
118+
: entry,
119+
)
120+
: prev,
121+
);
122+
}),
123+
);
124+
} catch (e) {
125+
setError((e as Error).message);
126+
}
127+
}, []);
128+
129+
useEffect(() => {
130+
if (open) void load();
131+
}, [open, load]);
132+
133+
const byType = new Map<string, DraftChangeEntry[]>();
134+
for (const entry of entries ?? []) {
135+
const bucket = byType.get(entry.type) ?? [];
136+
bucket.push(entry);
137+
byType.set(entry.type, bucket);
138+
}
139+
140+
return (
141+
<Sheet open={open} onOpenChange={onOpenChange}>
142+
<SheetContent side="right" className="w-[420px] sm:max-w-[420px]" data-testid="draft-changes-panel">
143+
<SheetHeader>
144+
<SheetTitle>
145+
{t('preview.changes.title', { defaultValue: 'Pending changes' })}
146+
</SheetTitle>
147+
<SheetDescription>
148+
{t('preview.changes.description', {
149+
defaultValue: 'What publishing will change. New items are added; updates overwrite the live version.',
150+
})}
151+
</SheetDescription>
152+
</SheetHeader>
153+
<div className="mt-4 flex flex-col gap-4 overflow-y-auto px-4 pb-6">
154+
{error ? (
155+
<p className="text-sm text-destructive">
156+
{t('preview.changes.loadFailed', { defaultValue: 'Could not load pending changes:' })}{' '}
157+
{error}
158+
</p>
159+
) : entries === null ? (
160+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
161+
<Loader2 className="h-4 w-4 animate-spin" />
162+
{t('preview.changes.loading', { defaultValue: 'Loading pending changes…' })}
163+
</div>
164+
) : entries.length === 0 ? (
165+
<p className="text-sm text-muted-foreground">
166+
{t('preview.changes.empty', { defaultValue: 'Nothing pending — every draft has been published.' })}
167+
</p>
168+
) : (
169+
[...byType.entries()].map(([type, items]) => (
170+
<div key={type}>
171+
<h4 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
172+
{type} · {items.length}
173+
</h4>
174+
<ul className="flex flex-col gap-1">
175+
{items.map((entry) => (
176+
<li
177+
key={`${entry.type}:${entry.name}`}
178+
className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm"
179+
>
180+
{entry.kind === 'new' ? (
181+
<FilePlus2 className="h-3.5 w-3.5 shrink-0 text-emerald-600" />
182+
) : entry.kind === 'update' ? (
183+
<FilePen className="h-3.5 w-3.5 shrink-0 text-amber-600" />
184+
) : (
185+
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
186+
)}
187+
<span className="min-w-0 flex-1 truncate font-mono text-xs">{entry.name}</span>
188+
{entry.kind ? (
189+
<Badge
190+
variant="outline"
191+
className={
192+
entry.kind === 'new'
193+
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
194+
: 'border-amber-200 bg-amber-50 text-amber-700'
195+
}
196+
>
197+
{entry.kind === 'new'
198+
? t('preview.changes.kindNew', { defaultValue: 'New' })
199+
: t('preview.changes.kindUpdate', { defaultValue: 'Update' })}
200+
</Badge>
201+
) : null}
202+
</li>
203+
))}
204+
</ul>
205+
</div>
206+
))
207+
)}
208+
</div>
209+
</SheetContent>
210+
</Sheet>
211+
);
212+
}

0 commit comments

Comments
 (0)