diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 7063fecfc..6a6e265b0 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -425,7 +425,7 @@ function ChatPane({ // drafted app rendered as-if-published (`?preview=draft`) beside the chat. // Per-artifact signals coalesce (800 ms) into one pane refresh so a // whole-app build doesn't trigger an invalidation storm. - const [canvasApp, setCanvasApp] = useState(null); + const [canvasApp, setCanvasApp] = useState<{ name: string; materialized: boolean } | null>(null); const [canvasRefreshKey, setCanvasRefreshKey] = useState(0); const canvasTimerRef = useRef(null); useEffect(() => () => { @@ -433,10 +433,20 @@ function ChatPane({ }, []); const handleDraftArtifacts = useCallback((artifacts: Array<{ type: string; name: string }>) => { const app = artifacts.find((a) => a.type === 'app'); - if (app) setCanvasApp((prev) => prev ?? app.name); + if (app) setCanvasApp((prev) => prev ?? { name: app.name, materialized: false }); if (canvasTimerRef.current) window.clearTimeout(canvasTimerRef.current); canvasTimerRef.current = window.setTimeout(() => setCanvasRefreshKey((k) => k + 1), 800); }, []); + // ADR-0045: the build finished and was materialized (real tables + data, + // app unlisted). Switch the open canvas from the draft overlay to the REAL + // app URL — the reload that follows shows live rows in every list. + const handleBuildMaterialized = useCallback((appName: string) => { + setCanvasApp((prev) => + prev && prev.name === appName && !prev.materialized + ? { name: appName, materialized: true } + : prev ?? { name: appName, materialized: true }, + ); + }, []); // A different conversation is a different build session — close the pane. useEffect(() => { setCanvasApp(null); @@ -678,14 +688,19 @@ function ChatPane({ // ADR-0037 Live Canvas: open/refresh the draft-preview pane as the // agent's artifacts land; Preview buttons deep-link the same route. onDraftArtifacts={handleDraftArtifacts} - onPreviewDraftApp={(appName) => setCanvasApp(appName)} + onPreviewDraftApp={(appName, opts) => + setCanvasApp({ name: appName, materialized: opts?.materialized === true })} + // ADR-0045: build materialized → canvas leaves the draft overlay for + // the real (unlisted) app; the reload shows live seed rows. + onBuildMaterialized={handleBuildMaterialized} previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })} data-testid="ai-chat-panel" /> {canvasApp ? ( setCanvasApp(null)} /> diff --git a/packages/app-shell/src/console/ai/LiveCanvas.tsx b/packages/app-shell/src/console/ai/LiveCanvas.tsx index fe4eaab04..e2a3c3909 100644 --- a/packages/app-shell/src/console/ai/LiveCanvas.tsx +++ b/packages/app-shell/src/console/ai/LiveCanvas.tsx @@ -27,6 +27,13 @@ import { useObjectTranslation } from '@object-ui/i18n'; export interface LiveCanvasProps { /** The drafted app to render (its `app` metadata name). */ appName: string; + /** + * ADR-0045: the build was materialized — the app is live (real tables and + * seed rows) but unlisted. The canvas then renders the REAL app URL: full + * data, full interaction; the in-app UnpublishedAppBar narrates the state. + * False (default) keeps the ADR-0037 draft-overlay preview for mutations. + */ + materialized?: boolean; /** Bump to reload the pane (the host coalesces invalidation storms). */ refreshKey: number; onClose: () => void; @@ -42,9 +49,26 @@ function spaBase(): string { return window.location.pathname.startsWith('/_console') ? '/_console' : ''; } -export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) { +/** Canvas iframe target: the REAL app once materialized, the draft overlay otherwise. */ +function canvasSrc(appName: string, materialized: boolean): string { + const base = `${spaBase()}/apps/${encodeURIComponent(appName)}`; + return materialized ? base : `${base}?preview=draft`; +} + +export function LiveCanvas({ appName, materialized = false, refreshKey, onClose }: LiveCanvasProps) { const { t } = useObjectTranslation(); const iframeRef = useRef(null); + // Materialized world swap: changing src on the SAME iframe element + // navigates it in place (no white-flash remount). + useEffect(() => { + if (!iframeRef.current) return; + const next = canvasSrc(appName, materialized); + try { + if (iframeRef.current.getAttribute('src') !== next) iframeRef.current.setAttribute('src', next); + } catch { + /* not ready — the mount src covers it */ + } + }, [appName, materialized]); // Refresh in place (src reload) instead of remounting the iframe — keeps // the pane from flashing white on every invalidation. @@ -63,10 +87,15 @@ export function LiveCanvas({ appName, refreshKey, onClose }: LiveCanvasProps) {
- {t('console.ai.liveCanvas', { - app: appName, - defaultValue: 'Live preview — {{app}} (draft)', - })} + {materialized + ? t('console.ai.liveCanvasUnlisted', { + app: appName, + defaultValue: 'Live app — {{app}} (unlisted until published)', + }) + : t('console.ai.liveCanvas', { + app: appName, + defaultValue: 'Live preview — {{app}} (draft)', + })}