Skip to content

Commit b09ece7

Browse files
os-zhuangclaude
andauthored
feat(adr-0045): canvas opens the real unlisted app after materialization + UnpublishedAppBar (#1664)
v1 objectui slice of ADR-0045: - mapMessages lifts `materialized` from apply_blueprint results into draftReview - ChatbotEnhanced: onBuildMaterialized fires once per materialized build (incl. conversation reload); Preview buttons pass {materialized} to the host - AiChatPage/LiveCanvas: the canvas swaps from ?preview=draft to the REAL app URL when the build materializes — the reload shows live seed rows in every list; header narrates 'Live app — unlisted until published' - ConsoleFloatingChatbot routes materialized previews to the real URL - UnpublishedAppBar (ConsoleLayout): amber banner on hidden:true apps — 'fully functional, only builders can see it' + Publish = visibility flip (one PUT /meta/app/:name with hidden:false); yields to DraftPreviewBar in preview mode plugin-chatbot 67/67, app-shell 418/418. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 63821bb commit b09ece7

7 files changed

Lines changed: 204 additions & 13 deletions

File tree

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,18 +425,28 @@ function ChatPane({
425425
// drafted app rendered as-if-published (`?preview=draft`) beside the chat.
426426
// Per-artifact signals coalesce (800 ms) into one pane refresh so a
427427
// whole-app build doesn't trigger an invalidation storm.
428-
const [canvasApp, setCanvasApp] = useState<string | null>(null);
428+
const [canvasApp, setCanvasApp] = useState<{ name: string; materialized: boolean } | null>(null);
429429
const [canvasRefreshKey, setCanvasRefreshKey] = useState(0);
430430
const canvasTimerRef = useRef<number | null>(null);
431431
useEffect(() => () => {
432432
if (canvasTimerRef.current) window.clearTimeout(canvasTimerRef.current);
433433
}, []);
434434
const handleDraftArtifacts = useCallback((artifacts: Array<{ type: string; name: string }>) => {
435435
const app = artifacts.find((a) => a.type === 'app');
436-
if (app) setCanvasApp((prev) => prev ?? app.name);
436+
if (app) setCanvasApp((prev) => prev ?? { name: app.name, materialized: false });
437437
if (canvasTimerRef.current) window.clearTimeout(canvasTimerRef.current);
438438
canvasTimerRef.current = window.setTimeout(() => setCanvasRefreshKey((k) => k + 1), 800);
439439
}, []);
440+
// ADR-0045: the build finished and was materialized (real tables + data,
441+
// app unlisted). Switch the open canvas from the draft overlay to the REAL
442+
// app URL — the reload that follows shows live rows in every list.
443+
const handleBuildMaterialized = useCallback((appName: string) => {
444+
setCanvasApp((prev) =>
445+
prev && prev.name === appName && !prev.materialized
446+
? { name: appName, materialized: true }
447+
: prev ?? { name: appName, materialized: true },
448+
);
449+
}, []);
440450
// A different conversation is a different build session — close the pane.
441451
useEffect(() => {
442452
setCanvasApp(null);
@@ -678,14 +688,19 @@ function ChatPane({
678688
// ADR-0037 Live Canvas: open/refresh the draft-preview pane as the
679689
// agent's artifacts land; Preview buttons deep-link the same route.
680690
onDraftArtifacts={handleDraftArtifacts}
681-
onPreviewDraftApp={(appName) => setCanvasApp(appName)}
691+
onPreviewDraftApp={(appName, opts) =>
692+
setCanvasApp({ name: appName, materialized: opts?.materialized === true })}
693+
// ADR-0045: build materialized → canvas leaves the draft overlay for
694+
// the real (unlisted) app; the reload shows live seed rows.
695+
onBuildMaterialized={handleBuildMaterialized}
682696
previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })}
683697
data-testid="ai-chat-panel"
684698
/>
685699
</div>
686700
{canvasApp ? (
687701
<LiveCanvas
688-
appName={canvasApp}
702+
appName={canvasApp.name}
703+
materialized={canvasApp.materialized}
689704
refreshKey={canvasRefreshKey}
690705
onClose={() => setCanvasApp(null)}
691706
/>

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import { useObjectTranslation } from '@object-ui/i18n';
2727
export interface LiveCanvasProps {
2828
/** The drafted app to render (its `app` metadata name). */
2929
appName: string;
30+
/**
31+
* ADR-0045: the build was materialized — the app is live (real tables and
32+
* seed rows) but unlisted. The canvas then renders the REAL app URL: full
33+
* data, full interaction; the in-app UnpublishedAppBar narrates the state.
34+
* False (default) keeps the ADR-0037 draft-overlay preview for mutations.
35+
*/
36+
materialized?: boolean;
3037
/** Bump to reload the pane (the host coalesces invalidation storms). */
3138
refreshKey: number;
3239
onClose: () => void;
@@ -42,9 +49,26 @@ function spaBase(): string {
4249
return window.location.pathname.startsWith('/_console') ? '/_console' : '';
4350
}
4451

45-
export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
52+
/** Canvas iframe target: the REAL app once materialized, the draft overlay otherwise. */
53+
function canvasSrc(appName: string, materialized: boolean): string {
54+
const base = `${spaBase()}/apps/${encodeURIComponent(appName)}`;
55+
return materialized ? base : `${base}?preview=draft`;
56+
}
57+
58+
export function LiveCanvas({ appName, materialized = false, refreshKey, onClose }: LiveCanvasProps) {
4659
const { t } = useObjectTranslation();
4760
const iframeRef = useRef<HTMLIFrameElement | null>(null);
61+
// Materialized world swap: changing src on the SAME iframe element
62+
// navigates it in place (no white-flash remount).
63+
useEffect(() => {
64+
if (!iframeRef.current) return;
65+
const next = canvasSrc(appName, materialized);
66+
try {
67+
if (iframeRef.current.getAttribute('src') !== next) iframeRef.current.setAttribute('src', next);
68+
} catch {
69+
/* not ready — the mount src covers it */
70+
}
71+
}, [appName, materialized]);
4872

4973
// Refresh in place (src reload) instead of remounting the iframe — keeps
5074
// the pane from flashing white on every invalidation.
@@ -63,10 +87,15 @@ export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
6387
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
6488
<Eye className="h-3.5 w-3.5" />
6589
<span className="min-w-0 flex-1 truncate">
66-
{t('console.ai.liveCanvas', {
67-
app: appName,
68-
defaultValue: 'Live preview — {{app}} (draft)',
69-
})}
90+
{materialized
91+
? t('console.ai.liveCanvasUnlisted', {
92+
app: appName,
93+
defaultValue: 'Live app — {{app}} (unlisted until published)',
94+
})
95+
: t('console.ai.liveCanvas', {
96+
app: appName,
97+
defaultValue: 'Live preview — {{app}} (draft)',
98+
})}
7099
</span>
71100
<Button size="sm" variant="ghost" onClick={onClose} data-testid="live-canvas-close">
72101
<X className="h-3.5 w-3.5" />
@@ -75,7 +104,7 @@ export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
75104
<iframe
76105
ref={iframeRef}
77106
title={`Draft preview: ${appName}`}
78-
src={`${spaBase()}/apps/${encodeURIComponent(appName)}?preview=draft`}
107+
src={canvasSrc(appName, materialized)}
79108
className="h-full w-full flex-1 border-0 bg-background"
80109
data-testid="live-canvas-frame"
81110
/>

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,14 @@ function ChatbotInner({
512512
openBuiltAppLabel={locale.openBuiltApp}
513513
// ADR-0037: see the drafted app as-if-published before Publish — same
514514
// route, draft overlay, watermark bar on top.
515-
onPreviewDraftApp={(appName) => navigate(`/apps/${encodeURIComponent(appName)}?preview=draft`)}
515+
onPreviewDraftApp={(appName, opts) =>
516+
navigate(
517+
// ADR-0045: a materialized build is a REAL (unlisted) app — open it
518+
// directly; the UnpublishedAppBar narrates. Drafts keep the overlay.
519+
opts?.materialized
520+
? `/apps/${encodeURIComponent(appName)}`
521+
: `/apps/${encodeURIComponent(appName)}?preview=draft`,
522+
)}
516523
// ADR-0037 P2.5: announce drafted artifacts so an open ?preview=draft
517524
// page (same document) drops its cache and refetches the new draft.
518525
onDraftArtifacts={(artifacts) => {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useDiscovery } from '@object-ui/react';
1717
// hover/click. See ConsoleChatbotFab.tsx.
1818
import { ConsoleChatbotFab } from './ConsoleChatbotFab';
1919
import { DraftPreviewBar } from '../preview/DraftPreviewBar';
20+
import { UnpublishedAppBar } from '../preview/UnpublishedAppBar';
2021
import { UnifiedSidebar } from './UnifiedSidebar';
2122
import { AppHeader } from './AppHeader';
2223
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext';
@@ -125,6 +126,9 @@ export function ConsoleLayout({
125126
{/* ADR-0037: unmistakable watermark while rendering the draft overlay
126127
(?preview=draft) — with one-click exit and one-click Publish. */}
127128
<DraftPreviewBar />
129+
{/* ADR-0045: materialized-but-unlisted app — real and interactive,
130+
invisible to end users until the Publish visibility flip. */}
131+
<UnpublishedAppBar />
128132
{children}
129133
</ConsoleLayoutInner>
130134

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
* ADR-0045 — the "unpublished app" banner. Renders while the CURRENT app is
11+
* materialized-but-unlisted (`app.hidden === true`): the app is fully real —
12+
* tables, data, interactions — but end users can't see it (launchers exclude
13+
* it; the REST gate strips it for non-builders). The banner narrates that
14+
* state and offers the one action that matters: Publish, which simply flips
15+
* visibility (`hidden: false`) — instant and reversible, per ADR-0045.
16+
*
17+
* Sibling of DraftPreviewBar (the ADR-0037 draft-overlay watermark): that bar
18+
* owns mutation preview (`?preview=draft`); this one owns the materialize
19+
* regime. In preview mode this bar yields — the draft bar already narrates.
20+
*/
21+
22+
import { useState } from 'react';
23+
import { useLocation, useParams } from 'react-router-dom';
24+
import { EyeOff, Rocket } from 'lucide-react';
25+
import { toast } from 'sonner';
26+
import { Button } from '@object-ui/components';
27+
import { useObjectTranslation } from '@object-ui/i18n';
28+
import { useMetadata } from '../providers/MetadataProvider';
29+
import { usePreviewDrafts } from './PreviewModeContext';
30+
31+
export function UnpublishedAppBar() {
32+
const preview = usePreviewDrafts();
33+
const { appName } = useParams();
34+
const location = useLocation();
35+
const { apps, refresh } = useMetadata();
36+
const { t } = useObjectTranslation();
37+
const [publishing, setPublishing] = useState(false);
38+
39+
// The draft-preview watermark owns the preview tree; never stack both bars.
40+
if (preview) return null;
41+
const routeApp = appName ?? location.pathname.match(/\/apps\/([^/?#]+)/)?.[1];
42+
if (!routeApp) return null;
43+
const app = (apps ?? []).find((a: any) => a?.name === routeApp);
44+
if (!app || (app as any).hidden !== true) return null;
45+
46+
const publish = async () => {
47+
setPublishing(true);
48+
try {
49+
// Publish = the ADR-0045 visibility flip: one metadata write, no
50+
// lifecycle machinery. Body is the full current app with hidden:false
51+
// (the meta save endpoint replaces the overlay row).
52+
const res = await fetch(`/api/v1/meta/app/${encodeURIComponent(routeApp)}`, {
53+
method: 'PUT',
54+
credentials: 'include',
55+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
56+
body: JSON.stringify({ ...(app as Record<string, unknown>), hidden: false }),
57+
});
58+
if (!res.ok) {
59+
const payload = await res.json().catch(() => null);
60+
throw new Error((payload as any)?.error?.message ?? (payload as any)?.error ?? `HTTP ${res.status}`);
61+
}
62+
toast.success(
63+
t('preview.unpublishedBar.published', {
64+
defaultValue: 'Published! The app is now visible to your users.',
65+
}),
66+
);
67+
refresh?.();
68+
} catch (e) {
69+
toast.error(
70+
`${t('preview.unpublishedBar.publishFailed', { defaultValue: 'Publish failed' })}: ${(e as Error).message}`,
71+
);
72+
} finally {
73+
setPublishing(false);
74+
}
75+
};
76+
77+
return (
78+
<div
79+
className="sticky top-0 z-40 flex items-center gap-3 border-b border-amber-300/70 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-200"
80+
data-testid="unpublished-app-bar"
81+
>
82+
<EyeOff className="h-4 w-4 shrink-0" />
83+
<p className="min-w-0 flex-1 truncate">
84+
{t('preview.unpublishedBar.message', {
85+
defaultValue:
86+
'Unpublished app — fully functional, but only builders can see it. Publish to make it visible to your users.',
87+
})}
88+
</p>
89+
<Button size="sm" onClick={publish} disabled={publishing} data-testid="unpublished-app-publish">
90+
<Rocket className="mr-1 h-3.5 w-3.5" />
91+
{publishing
92+
? t('preview.unpublishedBar.publishing', { defaultValue: 'Publishing…' })
93+
: t('preview.unpublishedBar.publish', { defaultValue: 'Publish' })}
94+
</Button>
95+
</div>
96+
);
97+
}

packages/plugin-chatbot/src/ChatbotEnhanced.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ export interface ChatToolInvocation {
166166
autoPublishable?: boolean;
167167
/** Count of artifacts that failed in a partial build, surfaced not hidden. */
168168
failedCount?: number;
169+
/**
170+
* ADR-0045: the build was MATERIALIZED in-turn — real tables and seed
171+
* rows exist; the app is live but `hidden` (unlisted). Preview should
172+
* open the REAL app URL, not the draft overlay.
173+
*/
174+
materialized?: boolean;
169175
};
170176
}
171177

@@ -363,8 +369,11 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
363369
* Rendered next to the build tree's Open-app action and on draft chips
364370
* whose items include an `app`. The host wires this to its router with the
365371
* preview flag (e.g. `navigate('/apps/<name>?preview=draft')`).
372+
* ADR-0045: when the build reports `materialized`, `opts.materialized` is
373+
* true and the host should open the REAL app URL (no preview flag) — the
374+
* app is live-but-unlisted, with actual tables and seed data.
366375
*/
367-
onPreviewDraftApp?: (appName: string) => void;
376+
onPreviewDraftApp?: (appName: string, opts?: { materialized?: boolean }) => void;
368377
/** Label for the preview-draft action (default "Preview"). */
369378
previewDraftLabel?: string;
370379
/**
@@ -374,6 +383,12 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
374383
* refresh the live draft-preview pane while the agent builds.
375384
*/
376385
onDraftArtifacts?: (artifacts: Array<{ type: string; name: string }>) => void;
386+
/**
387+
* ADR-0045: fires once per build whose tool result reports `materialized`
388+
* with an `app` in the draft set — the app is live (tables + seed data)
389+
* but unlisted. Hosts switch the canvas to the real app URL.
390+
*/
391+
onBuildMaterialized?: (appName: string) => void;
377392
/** Label for the publish-drafts button (default "Publish"). */
378393
publishDraftsLabel?: string;
379394
/** Label for the published-state badge that replaces the button (default "Published"). */
@@ -700,6 +715,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
700715
onPreviewDraftApp,
701716
previewDraftLabel = 'Preview',
702717
onDraftArtifacts,
718+
onBuildMaterialized,
703719
publishDraftsLabel = 'Publish',
704720
publishedLabel = 'Published',
705721
autoPublishDrafts = false,
@@ -871,6 +887,25 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
871887
if (grew) onDraftArtifacts([...artifacts.values()]);
872888
}, [messages, onDraftArtifacts]);
873889

890+
// ADR-0045: announce materialized builds (real app live, unlisted) so the
891+
// host flips its canvas from the draft overlay to the real app URL. Once
892+
// per build (keyed by toolCallId), including on conversation reload —
893+
// a reopened materialized build should still preview the real app.
894+
const materializedKeysRef = React.useRef<Set<string>>(new Set());
895+
React.useEffect(() => {
896+
if (!onBuildMaterialized) return;
897+
for (const message of messages) {
898+
for (const tool of message.toolInvocations ?? []) {
899+
const dr = tool.draftReview;
900+
if (!dr?.materialized || !tool.toolCallId) continue;
901+
const app = dr.items.find((i) => i.type === 'app');
902+
if (!app || materializedKeysRef.current.has(tool.toolCallId)) continue;
903+
materializedKeysRef.current.add(tool.toolCallId);
904+
onBuildMaterialized(app.name);
905+
}
906+
}
907+
}, [messages, onBuildMaterialized]);
908+
874909
const handleSubmit = React.useCallback(
875910
(payload: PromptInputMessage) => {
876911
const hasText = Boolean(payload.text?.trim());
@@ -1060,7 +1095,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
10601095
return app ? (
10611096
<button
10621097
type="button"
1063-
onClick={() => onPreviewDraftApp(app.name)}
1098+
onClick={() => onPreviewDraftApp(app.name, { materialized: tool.draftReview!.materialized === true })}
10641099
className="inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-xs font-medium hover:bg-muted"
10651100
data-testid="draft-preview-app"
10661101
>

packages/plugin-chatbot/src/mapMessages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function detectDraftResult(
136136
packageId?: string;
137137
autoPublishable?: boolean;
138138
failedCount?: number;
139+
materialized?: boolean;
139140
} | undefined {
140141
const obj = parseResultEnvelope(result);
141142
if (!obj || obj.status !== 'drafted') return undefined;
@@ -167,6 +168,9 @@ function detectDraftResult(
167168
...(typeof obj.packageId === 'string' && obj.packageId ? { packageId: obj.packageId } : {}),
168169
...(obj.autoPublishable === true ? { autoPublishable: true } : {}),
169170
...(failedCount > 0 ? { failedCount } : {}),
171+
// ADR-0045: the build was materialized in-turn (real tables + data, app
172+
// hidden). The canvas then previews the REAL app URL, not the draft overlay.
173+
...(obj.materialized === true ? { materialized: true } : {}),
170174
};
171175
}
172176

0 commit comments

Comments
 (0)