diff --git a/.changeset/12968-draft-preview-any.md b/.changeset/12968-draft-preview-any.md new file mode 100644 index 0000000000..3f278d1465 --- /dev/null +++ b/.changeset/12968-draft-preview-any.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-chatbot': minor +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(plugin-chatbot): a drafted result's Preview button appears for any draft kind, and the draft preview bar names what is pending on the surface it shows (objectui#12968) + +Clause-②: yes — `ChatbotEnhancedProps` gains the optional `onPreviewDraft`. + +**`@object-ui/plugin-chatbot`.** A drafted tool result whose items include no app now draws a +Preview button beside Publish and Review when the host passes `onPreviewDraft(items, { appSegment })`. +The click hands the host the drafted `{ type, name }` items and the owning package id, the app's route +segment. A draft set that includes an app keeps `onPreviewDraftApp`, unchanged, and a host that passes +no `onPreviewDraft` draws what it drew before. Preview publishes nothing. The confirm path previews +too: `detectReplayOutcome` lifts a drafted replay's `drafted[]` items into the exported `ReplayOutcome` +as the optional `items`, and the applied card (or, from an older cloud, the drafted badge) draws the +same Preview over them through the same `onPreviewDraft` / `onPreviewDraftApp` rule. + +**`@object-ui/app-shell`.** In draft preview (`?preview=draft`), the DraftPreviewBar's message names +how many pending drafts belong to the surface it shows: an object's list, view or record, a page, a +dashboard or a report. The count is read from the same pending-drafts list as before. The preview's +deep-link table (`preview/draftSurface.ts`, `draftPreviewPath`) maps drafted items to the route that +shows them: a page to that page, a view to the object's list on that view, a field or object to the +object's default list, anything else to the app. The console wires it: `ChatPane` passes +`onPreviewDraft`, and a field, view or page draft's Preview opens the Live Canvas at that path, in +draft preview; `LiveCanvas` takes the full preview path beside the app segment (`path`). An app +draft's Preview opens the app as before. + +**`@object-ui/i18n`.** `preview.draftBar.pendingHere`, a plural family, in all ten packs. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 02504bbffb..e81282cede 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -113,6 +113,7 @@ import { type ShortcutLabelTranslate, } from '../../chrome/advertisedShortcuts.js'; import { fetchPendingDraftCount } from '../../preview/draftStatus.js'; +import { draftPreviewPath } from '../../preview/draftSurface.js'; import { emitMetadataRefresh, publishPlanApprovalPending } from '../../assistant/assistantBus.js'; import { getRuntimeConfig, isAiStudioEnabled } from '../../runtime-config.js'; import { makerConvergedOnBuild, makerVisibleAgents } from '../../hooks/surfaceAgent.js'; @@ -1748,7 +1749,11 @@ export 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<{ name: string; segment?: string; materialized: boolean } | null>(null); + // objectui#12968 — `path`: a drafted page / view / object surface to show + // instead of the app's landing (`draftPreviewPath`, `onPreviewDraft` below). + const [canvasApp, setCanvasApp] = useState< + { name: string; segment?: string; materialized: boolean; path?: string } | null + >(null); const [canvasRefreshKey, setCanvasRefreshKey] = useState(0); // #2481 — a phone can't do the beside-chat split (chat's fixed min-width + // the preview together overflow the viewport, and the resize handle is @@ -2835,6 +2840,20 @@ export function ChatPane({ // phone open the full-screen overlay (desktop shows the split pane). if (isMobile) setMobileCanvasOpen(true); }} + // objectui#12968 (objectstack#24324 ruling A) — any other draft's + // Preview: the Live Canvas opens the affected surface (a page, the + // object's list on a view, the object's default list) in draft + // preview, at the path `draftPreviewPath` builds from the drafted + // items in the app routed at the package id (ADR-0048). + onPreviewDraft={(items, { appSegment }) => { + setCanvasApp({ + name: appSegment, + segment: appSegment, + materialized: false, + path: draftPreviewPath(items, appSegment), + }); + if (isMobile) setMobileCanvasOpen(true); + }} // ADR-0045: build materialized → canvas leaves the draft overlay for // the real (unlisted) app; the reload shows live seed rows. onBuildMaterialized={handleBuildMaterialized} @@ -2876,6 +2895,7 @@ export function ChatPane({ appName={canvasApp!.name} appSegment={canvasApp!.segment} materialized={canvasApp!.materialized} + path={canvasApp!.path} refreshKey={canvasRefreshKey} onClose={() => setCanvasApp(null)} /> @@ -2913,6 +2933,7 @@ export function ChatPane({ appName={canvasApp.name} appSegment={canvasApp.segment} materialized={canvasApp.materialized} + path={canvasApp.path} refreshKey={canvasRefreshKey} // Close returns to the chat; the pill re-offers the preview. onClose={() => setMobileCanvasOpen(false)} diff --git a/packages/app-shell/src/console/ai/LiveCanvas.test.tsx b/packages/app-shell/src/console/ai/LiveCanvas.test.tsx index 68dc1e9188..515c088870 100644 --- a/packages/app-shell/src/console/ai/LiveCanvas.test.tsx +++ b/packages/app-shell/src/console/ai/LiveCanvas.test.tsx @@ -48,3 +48,40 @@ describe('LiveCanvas — Design in Studio bridge', () => { expect(screen.getByTestId('live-canvas-close')).toBeInTheDocument(); }); }); + +describe('LiveCanvas — a drafted surface beside the app segment (objectui#12968)', () => { + it('opens the full preview path the host names, in draft preview', () => { + render( + {}} + />, + ); + expect(screen.getByTestId('live-canvas-frame')).toHaveAttribute( + 'src', + '/apps/app.crm/crm_account/view/crm_account.pipeline?preview=draft', + ); + }); + + it('CONTROL: without a path the canvas opens the app in draft preview, as before', () => { + render( {}} />); + expect(screen.getByTestId('live-canvas-frame')).toHaveAttribute('src', '/apps/app.crm?preview=draft'); + }); + + it('a materialized build shows the real app, whatever path was named', () => { + render( + {}} + />, + ); + expect(screen.getByTestId('live-canvas-frame')).toHaveAttribute('src', '/apps/app.crm'); + }); +}); diff --git a/packages/app-shell/src/console/ai/LiveCanvas.tsx b/packages/app-shell/src/console/ai/LiveCanvas.tsx index 9a8764de0c..7bbdc7f3bf 100644 --- a/packages/app-shell/src/console/ai/LiveCanvas.tsx +++ b/packages/app-shell/src/console/ai/LiveCanvas.tsx @@ -41,6 +41,13 @@ export interface LiveCanvasProps { * False (default) keeps the ADR-0037 draft-overlay preview for mutations. */ materialized?: boolean; + /** + * objectui#12968 — the full router path to preview, beside the app segment: + * a drafted page, view or object surface (`draftPreviewPath`'s answer, + * `/apps//…?preview=draft`) instead of the app's landing. Ignored + * once `materialized`, which shows the real app. + */ + path?: string; /** Bump to reload the pane (the host coalesces invalidation storms). */ refreshKey: number; onClose: () => void; @@ -56,13 +63,17 @@ function spaBase(): string { return window.location.pathname.startsWith('/_console') ? '/_console' : ''; } -/** Canvas iframe target: the REAL app once materialized, the draft overlay otherwise. */ -function canvasSrc(appName: string, materialized: boolean): string { +/** + * Canvas iframe target: the REAL app once materialized; otherwise the drafted + * surface's path when the host named one, else the app in the draft overlay. + */ +function canvasSrc(appName: string, materialized: boolean, path?: string): string { const base = `${spaBase()}/apps/${encodeURIComponent(appName)}`; - return materialized ? base : `${base}?preview=draft`; + if (materialized) return base; + return path ? `${spaBase()}${path}` : `${base}?preview=draft`; } -export function LiveCanvas({ appName, appSegment, materialized = false, refreshKey, onClose }: LiveCanvasProps) { +export function LiveCanvas({ appName, appSegment, materialized = false, path, refreshKey, onClose }: LiveCanvasProps) { const { t } = useObjectTranslation(); const navigate = useNavigate(); const iframeRef = useRef(null); @@ -72,13 +83,13 @@ export function LiveCanvas({ appName, appSegment, materialized = false, refreshK // navigates it in place (no white-flash remount). useEffect(() => { if (!iframeRef.current) return; - const next = canvasSrc(routeSeg, materialized); + const next = canvasSrc(routeSeg, materialized, path); try { if (iframeRef.current.getAttribute('src') !== next) iframeRef.current.setAttribute('src', next); } catch { /* not ready — the mount src covers it */ } - }, [routeSeg, materialized]); + }, [routeSeg, materialized, path]); // Refresh in place (src reload) instead of remounting the iframe — keeps // the pane from flashing white on every invalidation. @@ -133,7 +144,7 @@ export function LiveCanvas({ appName, appSegment, materialized = false, refreshK