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
diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.previewDraft-12968.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.previewDraft-12968.test.tsx
new file mode 100644
index 0000000000..b64038cf1c
--- /dev/null
+++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.previewDraft-12968.test.tsx
@@ -0,0 +1,205 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#12968 (objectstack#24324 ruling A, preview-first) — the console
+ * host wires the chat's any-draft Preview.
+ *
+ * `ChatPane` passes `onPreviewDraft` to the chat. A field, view or page
+ * draft's Preview opens the Live Canvas on the surface the draft changes, at
+ * the path `draftPreviewPath` builds (always `?preview=draft`); the app
+ * draft's Preview (`onPreviewDraftApp`) opens the app as before, with no path.
+ *
+ * The chat is faked so the test drives the two callbacks the real
+ * `ChatbotEnhanced` calls (its own Preview buttons are pinned in
+ * `ChatbotEnhanced.draftPreviewAny-12968.test.tsx`); the Live Canvas is faked
+ * so its props are readable. Same harness as
+ * `AiChatPage.noNavigationAfterBuild-12724.test.tsx`.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+
+type Items = Array<{ type: string; name: string }>;
+
+vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return {
+ ...actual,
+ useAgents: () => ({
+ agents: [{ name: 'metadata_assistant', label: 'Build', capabilities: ['build'] }],
+ loading: false,
+ error: undefined,
+ refetch: vi.fn(),
+ }),
+ useAiModels: () => ({ models: [], defaultModelId: undefined }),
+ useHitlInChat: () => ({ decide: vi.fn(), decisions: {} }),
+ useObjectChat: () => ({
+ messages: [],
+ isLoading: false,
+ error: undefined,
+ sendMessage: vi.fn(),
+ stop: vi.fn(),
+ reload: vi.fn(),
+ clear: vi.fn(),
+ setMessages: vi.fn(),
+ }),
+ ChatbotEnhanced: (props: Record) => {
+ const preview = props.onPreviewDraft as
+ | ((items: Items, opts: { appSegment: string }) => void)
+ | undefined;
+ const previewApp = props.onPreviewDraftApp as
+ | ((app: string, opts?: { appSegment?: string; materialized?: boolean }) => void)
+ | undefined;
+ return (
+
+
+ );
+ },
+ };
+});
+
+vi.mock('@object-ui/auth', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return { ...actual, useAuth: () => ({ user: { id: 'u1' } }) };
+});
+vi.mock('../../../providers/MetadataProvider', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return { ...actual, useMetadata: () => ({ apps: [] }) };
+});
+vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return { ...actual, useAdapter: () => null };
+});
+vi.mock('../ConversationsSidebar', () => ({
+ ConversationsSidebar: () => ,
+}));
+vi.mock('../LiveCanvas', () => ({
+ LiveCanvas: (props: { appName: string; appSegment?: string; materialized?: boolean; path?: string }) => (
+
+ ),
+}));
+
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { AiChatPage } from '../AiChatPage';
+
+window.matchMedia = ((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+})) as unknown as typeof window.matchMedia;
+
+function installFetch(): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method ?? 'GET';
+ if (/\/conversations\/conv-1$/.test(url) && method === 'GET') {
+ return new Response(JSON.stringify({ id: 'conv-1', messages: [] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ return new Response(JSON.stringify({ success: true, data: [] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }),
+ );
+}
+
+function Where() {
+ const location = useLocation();
+ return ;
+}
+
+function renderPage() {
+ render(
+
+
+ } />
+ } />
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ installFetch();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('AiChatPage — the any-draft Preview opens the drafted surface (objectui#12968)', () => {
+ it('the console host hands the chat an onPreviewDraft door', async () => {
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 });
+ expect(screen.getByTestId('pane')).toHaveAttribute('data-has-preview-draft', 'true');
+ });
+
+ it.each([
+ ['a field draft → the object default list', 'preview-field', '/apps/app.k9/k9_task?preview=draft'],
+ ['a view draft → the object list on that view', 'preview-view', '/apps/app.k9/k9_task/view/k9_task.board?preview=draft'],
+ ['a page draft → that page', 'preview-page', '/apps/app.k9/page/k9_home?preview=draft'],
+ ])('%s: the Live Canvas opens at the draftPreviewPath target, in draft preview', async (_label, button, path) => {
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 });
+ expect(screen.queryByTestId('live-canvas')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId(button));
+ const canvas = await screen.findByTestId('live-canvas', {}, { timeout: 4000 });
+ expect(canvas).toHaveAttribute('data-path', path);
+ expect(canvas).toHaveAttribute('data-segment', 'app.k9');
+ expect(canvas).toHaveAttribute('data-materialized', 'false');
+ // The canvas opens beside the chat: the conversation stays where it is.
+ expect(screen.getByTestId('where')).toHaveAttribute('data-path', '/ai/build/conv-1');
+ });
+
+ it('an app draft keeps the app preview: the canvas opens the app, with no surface path', async () => {
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 });
+ fireEvent.click(screen.getByTestId('preview-app'));
+ const canvas = await screen.findByTestId('live-canvas', {}, { timeout: 4000 });
+ expect(canvas).toHaveAttribute('data-app', 'k9_app');
+ expect(canvas).toHaveAttribute('data-segment', 'app.k9');
+ expect(canvas).toHaveAttribute('data-path', '');
+ });
+});
diff --git a/packages/app-shell/src/preview/DraftPreviewBar.tsx b/packages/app-shell/src/preview/DraftPreviewBar.tsx
index 0f4c5adac7..83a39aeedb 100644
--- a/packages/app-shell/src/preview/DraftPreviewBar.tsx
+++ b/packages/app-shell/src/preview/DraftPreviewBar.tsx
@@ -14,8 +14,8 @@
* keeps a read-only preview from ever being confused with the live app.
*/
-import { useEffect, useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useEffect, useMemo, useState } from 'react';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Eye, X, Rocket, GitCompareArrows, Sparkles } from 'lucide-react';
import { Button, cn } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
@@ -23,6 +23,7 @@ import { usePreviewDrafts, markPreviewExit, PREVIEW_QUERY_FLAG } from './Preview
import { usePendingDrafts } from './usePendingDrafts.js';
import { usePublishAllDrafts } from './usePublishAllDrafts.js';
import { DraftChangesPanel } from './DraftChangesPanel.js';
+import { draftSurfaceOf, draftsOnSurface } from './draftSurface.js';
export function DraftPreviewBar() {
const preview = usePreviewDrafts();
@@ -35,11 +36,24 @@ export function DraftPreviewBar() {
// actually promote. The shared source (objectui#5801) refreshes on the
// metadata-refresh pulse; the pathname effect keeps the old "drafts staged
// while the preview is open" trigger too.
- const { count: pendingCount, refresh: refreshPending } = usePendingDrafts({ enabled: Boolean(preview) });
+ const { count: pendingCount, entries: pendingEntries, refresh: refreshPending } = usePendingDrafts({
+ enabled: Boolean(preview),
+ });
useEffect(() => {
if (!preview) return;
void refreshPending();
}, [preview, location.pathname, refreshPending]);
+ // objectui#12968 — what is pending on the surface this preview shows: the
+ // route below `/apps/:appName` names it (an object, a page, a dashboard or a
+ // report), and the entries the count above already read are filtered to it.
+ // No surface (the app's landing, admin pages) or an unknown count → no line.
+ const appSubPath = useParams()['*'];
+ const surface = useMemo(
+ () => (appSubPath === undefined ? null : draftSurfaceOf(appSubPath)),
+ [appSubPath],
+ );
+ const pendingHere =
+ surface && typeof pendingCount === 'number' ? draftsOnSurface(pendingEntries, surface).length : 0;
if (!preview) return null;
@@ -90,10 +104,15 @@ export function DraftPreviewBar() {
? t('preview.draftBar.messageClean', {
defaultValue: 'Draft preview — no unpublished changes; everything here is already live.',
})
- : t('preview.draftBar.message', {
- defaultValue:
- 'Draft preview — you are seeing unpublished changes. Nothing here is live until you publish.',
- })}
+ : pendingHere > 0
+ ? t('preview.draftBar.pendingHere', {
+ count: pendingHere,
+ defaultValue: 'Draft preview · {{count}} unpublished changes on this page',
+ })
+ : t('preview.draftBar.message', {
+ defaultValue:
+ 'Draft preview — you are seeing unpublished changes. Nothing here is live until you publish.',
+ })}
{!noChanges && (
({ on: true }));
+vi.mock('../PreviewModeContext', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ usePreviewDrafts: () => previewState.on,
+ markPreviewExit: vi.fn(),
+}));
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (_k: string, o?: { defaultValue?: string; count?: number }) =>
+ (o?.defaultValue ?? _k).replace('{{count}}', String(o?.count ?? '')),
+ }),
+}));
+vi.mock('../usePublishAllDrafts', () => ({
+ usePublishAllDrafts: () => ({ publishAll: vi.fn(async () => ({ ok: true })), publishing: false }),
+}));
+vi.mock('../DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
+
+import { DraftPreviewBar } from '../DraftPreviewBar';
+import { draftPreviewPath, draftSurfaceOf, draftsOnSurface } from '../draftSurface';
+
+afterEach(() => {
+ previewState.on = true;
+ vi.restoreAllMocks();
+});
+
+describe('draftPreviewPath — the deep-link table (objectui#12968)', () => {
+ it.each([
+ ['a page draft → that page', [{ type: 'page', name: 'crm_home' }], '/apps/app.crm/page/crm_home?preview=draft'],
+ [
+ 'a view draft → the object list on that view',
+ [{ type: 'view', name: 'crm_account.pipeline' }],
+ '/apps/app.crm/crm_account/view/crm_account.pipeline?preview=draft',
+ ],
+ [
+ 'a field draft → the object default list',
+ [{ type: 'field', name: 'crm_account.priority' }],
+ '/apps/app.crm/crm_account?preview=draft',
+ ],
+ ['an object draft → the object default list', [{ type: 'object', name: 'crm_account' }], '/apps/app.crm/crm_account?preview=draft'],
+ ['an app draft → the app, as the Live Canvas opens it', [{ type: 'app', name: 'crm' }], '/apps/app.crm?preview=draft'],
+ ['a draft with no surface of its own → the app', [{ type: 'flow', name: 'crm_onboard' }], '/apps/app.crm?preview=draft'],
+ [
+ 'a batch → its most specific surface',
+ [
+ { type: 'object', name: 'crm_account' },
+ { type: 'view', name: 'crm_account.pipeline' },
+ ],
+ '/apps/app.crm/crm_account/view/crm_account.pipeline?preview=draft',
+ ],
+ ])('%s', (_label, items, expected) => {
+ expect(draftPreviewPath(items, 'app.crm')).toBe(expected);
+ });
+});
+
+describe('draftsOnSurface — what the bar counts (objectui#12968)', () => {
+ const entries = [
+ { type: 'object', name: 'crm_account' },
+ { type: 'view', name: 'crm_account.pipeline' },
+ { type: 'field', name: 'crm_account.priority' },
+ { type: 'object', name: 'crm_contact' },
+ { type: 'page', name: 'crm_home' },
+ { type: 'flow', name: 'crm_onboard' },
+ ];
+ it.each([
+ ['an object list', 'crm_account', ['crm_account', 'crm_account.pipeline', 'crm_account.priority']],
+ ['a view of the object', 'crm_account/view/crm_account.pipeline', ['crm_account', 'crm_account.pipeline', 'crm_account.priority']],
+ ['a record of the object', 'crm_contact/record/r1', ['crm_contact']],
+ ['a page', 'page/crm_home', ['crm_home']],
+ ])('%s', (_label, subPath, names) => {
+ const surface = draftSurfaceOf(subPath);
+ expect(surface).not.toBeNull();
+ expect(draftsOnSurface(entries, surface!).map((e) => e.name)).toEqual(names);
+ });
+ it('the app landing and admin routes show no single surface', () => {
+ expect(draftSurfaceOf('')).toBeNull();
+ expect(draftSurfaceOf('metadata/object')).toBeNull();
+ expect(draftSurfaceOf('search')).toBeNull();
+ });
+});
+
+function mockDrafts(list: unknown) {
+ global.fetch = vi.fn(async () => ({ ok: true, json: async () => list })) as unknown as typeof fetch;
+}
+
+function renderBarAt(path: string) {
+ return render(
+
+
+ } />
+
+ ,
+ );
+}
+
+const PENDING = [
+ { type: 'object', name: 'crm_account', packageId: 'app.crm' },
+ { type: 'view', name: 'crm_account.pipeline', packageId: 'app.crm' },
+ { type: 'page', name: 'crm_home', packageId: 'app.crm' },
+];
+
+describe('the DraftPreviewBar pending line (objectui#12968)', () => {
+ it('on an object list it names the drafts pending on that object', async () => {
+ mockDrafts(PENDING);
+ renderBarAt('/apps/app.crm/crm_account?preview=draft');
+ await waitFor(() =>
+ expect(screen.getByTestId('draft-preview-bar')).toHaveTextContent('Draft preview · 2 unpublished changes on this page'),
+ );
+ // The package-wide count stays on Changes, as before.
+ expect(screen.getByTestId('draft-preview-changes')).toHaveTextContent('(3)');
+ });
+
+ it('on a page it names the drafts pending on that page', async () => {
+ mockDrafts(PENDING);
+ renderBarAt('/apps/app.crm/page/crm_home?preview=draft');
+ await waitFor(() =>
+ expect(screen.getByTestId('draft-preview-bar')).toHaveTextContent('Draft preview · 1 unpublished changes on this page'),
+ );
+ });
+
+ it('CONTROL: a surface with nothing pending of its own keeps the general message', async () => {
+ mockDrafts(PENDING);
+ renderBarAt('/apps/app.crm/crm_contact?preview=draft');
+ await waitFor(() =>
+ expect(screen.getByTestId('draft-preview-bar')).toHaveTextContent(/Nothing here is live until you publish/),
+ );
+ expect(screen.getByTestId('draft-preview-bar')).not.toHaveTextContent(/on this page/);
+ });
+
+ it('CONTROL: the app landing keeps the general message', async () => {
+ mockDrafts(PENDING);
+ renderBarAt('/apps/app.crm?preview=draft');
+ await waitFor(() => expect(screen.getByTestId('draft-preview-changes')).toHaveTextContent('(3)'));
+ expect(screen.getByTestId('draft-preview-bar')).toHaveTextContent(/Nothing here is live until you publish/);
+ });
+
+ it('CONTROL: outside draft preview the bar draws nothing and reads nothing', () => {
+ previewState.on = false;
+ const fetchSpy = vi.fn();
+ global.fetch = fetchSpy as unknown as typeof fetch;
+ renderBarAt('/apps/app.crm/crm_account');
+ expect(screen.queryByTestId('draft-preview-bar')).not.toBeInTheDocument();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app-shell/src/preview/draftSurface.ts b/packages/app-shell/src/preview/draftSurface.ts
new file mode 100644
index 0000000000..e9e6ea1b4d
--- /dev/null
+++ b/packages/app-shell/src/preview/draftSurface.ts
@@ -0,0 +1,157 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#12968 (objectstack#24324 ruling A, preview-first) — the one place
+ * a draft and the console surface that shows it meet, in both directions:
+ *
+ * - {@link draftPreviewPath}: drafted items → the router path of the
+ * affected surface, in draft preview (`?preview=draft`). The chat's
+ * Preview button opens it (`ChatbotEnhanced`'s `onPreviewDraft`).
+ * - {@link draftsOnSurface}: the route the draft preview shows → the pending
+ * drafts (`usePendingDrafts`' entries) that belong to it. The
+ * `DraftPreviewBar`'s pending line counts them.
+ *
+ * Neither direction spells a route. A path is built by `@object-ui/layout`'s
+ * `resolveHref`, the console's one nav → URL mapping, from the nav entry that
+ * would open the surface; a route is read by matching it against
+ * `APP_ROUTE_METADATA_READS`, the table of every route `AppContent` declares.
+ *
+ * A view is a `