From 801b93a6300c1f9f60890b764faad3b1f5f5d673 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 14:35:07 +0000
Subject: [PATCH 1/6] feat(plugin-chatbot): the drafted result's Preview button
appears for any draft kind (objectui#12968)
A drafted tool result whose items include no app now draws Preview when the
host passes the new `onPreviewDraft`; the click hands the host the drafted
items and the package id (the app's route segment). A draft set that
includes an app keeps `onPreviewDraftApp`, unchanged. Preview publishes
nothing.
Co-authored-by: Claude
Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
---
.../plugin-chatbot/src/ChatbotEnhanced.tsx | 56 +++++--
...botEnhanced.draftPreviewAny-12968.test.tsx | 152 ++++++++++++++++++
2 files changed, 198 insertions(+), 10 deletions(-)
create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.draftPreviewAny-12968.test.tsx
diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
index 286768dd2d..2e650291b5 100644
--- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
+++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
@@ -695,6 +695,22 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes void;
/** Label for the preview-draft action (default "Preview"). */
previewDraftLabel?: string;
+ /**
+ * objectui#12968 (objectstack#24324 ruling A, preview-first) — preview ANY
+ * drafted result, not only a drafted app. When provided, a drafted tool
+ * result whose items include no `app` and whose owning `packageId` is known
+ * renders a Preview button beside Publish and Review; the click hands the
+ * host the drafted `{ type, name }` items and the package id as the app's
+ * route segment (ADR-0048). The host resolves the affected surface (a page,
+ * the object's list on a view, the object's default list) and opens it in
+ * draft preview (`?preview=draft`). A draft set that includes an app keeps
+ * {@link ChatbotEnhancedProps.onPreviewDraftApp}, unchanged. Preview never
+ * publishes: publish stays the human gate (ADR-0033).
+ */
+ onPreviewDraft?: (
+ items: Array<{ type: string; name: string }>,
+ opts: { appSegment: string },
+ ) => void;
/**
* ADR-0037 Live Canvas: notifies the host whenever AI-authored draft
* artifacts land in the conversation (build-progress items + drafted
@@ -1932,6 +1948,7 @@ const ChatbotEnhanced = React.forwardRef(
getArtifactAction,
onPreviewDraftApp,
previewDraftLabel = 'Preview',
+ onPreviewDraft,
onDraftArtifacts,
onBuildMaterialized,
onPlanApprovalPendingChange,
@@ -2981,7 +2998,7 @@ const ChatbotEnhanced = React.forwardRef(
{tool.draftReview &&
tool.draftReview.items.length > 0 &&
(onReviewDraft ||
- (onPublishDrafts && tool.draftReview.packageId)) ? (
+ ((onPublishDrafts || onPreviewDraft) && tool.draftReview.packageId)) ? (
<>
{/* flex-wrap so the action buttons drop to a new line on a
narrow (mobile) row instead of forcing horizontal scroll. */}
@@ -3027,19 +3044,38 @@ const ChatbotEnhanced = React.forwardRef(
) : null}
{/* ADR-0037: drafted-app preview — see it as-if-published
- without publishing. Only when the draft set includes an
- app (the canvas previews whole apps, not single items).
- Hidden once the package has no pending drafts (live
- count when available, else this session's history). */}
- {onPreviewDraftApp && !draftIsPublished
+ without publishing. A draft set that includes an app
+ previews the app (the canvas previews whole apps).
+ objectui#12968 (objectstack#24324 ruling A): any other
+ draft previews too, through `onPreviewDraft`, which
+ opens the affected surface; it needs the package id,
+ the app's route segment. Hidden once the package has
+ no pending drafts (live count when available, else this
+ session's history). */}
+ {!draftIsPublished
? (() => {
- const app = tool.draftReview!.items.find((i) => i.type === 'app');
- return app ? (
+ const review = tool.draftReview!;
+ const app = review.items.find((i) => i.type === 'app');
+ if (app) {
+ return onPreviewDraftApp ? (
+ onPreviewDraftApp(app.name, { materialized: review.materialized === true, appSegment: review.packageId })}
+ className="inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-xs font-medium hover:bg-muted"
+ data-testid="draft-preview-app"
+ >
+
+ {previewDraftLabel}
+
+ ) : null;
+ }
+ const appSegment = review.packageId;
+ return onPreviewDraft && appSegment ? (
onPreviewDraftApp(app.name, { materialized: tool.draftReview!.materialized === true, appSegment: tool.draftReview!.packageId })}
+ onClick={() => onPreviewDraft(review.items, { appSegment })}
className="inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-xs font-medium hover:bg-muted"
- data-testid="draft-preview-app"
+ data-testid="draft-preview"
>
{previewDraftLabel}
diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.draftPreviewAny-12968.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.draftPreviewAny-12968.test.tsx
new file mode 100644
index 0000000000..8b586ef106
--- /dev/null
+++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.draftPreviewAny-12968.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * 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 drafted
+ * result's Preview button appears for ANY draft kind, not only when the draft
+ * set includes an app.
+ *
+ * The drafted results here go through the chat's own lift
+ * (`detectDraftResult`), so each fixture is the envelope an authoring tool
+ * answers, not a hand-built `draftReview`.
+ *
+ * Pinned:
+ * - a field, view or page draft draws Preview, and the click hands the host
+ * the drafted items and the package's route segment (`onPreviewDraft`);
+ * - an app draft keeps today's button and callback (`onPreviewDraftApp`),
+ * unchanged;
+ * - CONTROL: a host that passes no `onPreviewDraft` (the read-only share page,
+ * any host that never opted in) draws no Preview for a non-app draft, and a
+ * published draft draws none at all;
+ * - Preview publishes nothing: no publish callback, no request.
+ */
+import '@testing-library/jest-dom/vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { ChatbotEnhanced, type ChatbotEnhancedMessage } from '../ChatbotEnhanced';
+import { detectDraftResult } from '../mapMessages';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+/** One assistant message whose single tool part drafted `envelope`. */
+function draftedMessage(toolName: string, envelope: Record): ChatbotEnhancedMessage {
+ const draftReview = detectDraftResult(envelope);
+ if (!draftReview) throw new Error(`fixture is not a drafted envelope: ${JSON.stringify(envelope)}`);
+ return {
+ id: 'a1',
+ role: 'assistant',
+ content: '',
+ toolInvocations: [{ toolCallId: 't1', toolName, state: 'output-available', draftReview }],
+ };
+}
+
+/** `add_field` on an existing object: the draft is the OBJECT, its fields changed. */
+const FIELD_DRAFT = {
+ status: 'drafted',
+ type: 'object',
+ name: 'crm_account',
+ summary: 'Added field Priority',
+ changedKeys: ['fields'],
+ packageId: 'app.crm',
+};
+const VIEW_DRAFT = { status: 'drafted', type: 'view', name: 'crm_account.pipeline', packageId: 'app.crm' };
+const PAGE_DRAFT = { status: 'drafted', type: 'page', name: 'crm_home', packageId: 'app.crm' };
+
+describe('the drafted result Preview button (objectui#12968)', () => {
+ it.each([
+ ['a field draft on an existing object', 'add_field', FIELD_DRAFT, [{ type: 'object', name: 'crm_account' }]],
+ ['a view draft', 'create_metadata', VIEW_DRAFT, [{ type: 'view', name: 'crm_account.pipeline' }]],
+ ['a page draft', 'create_metadata', PAGE_DRAFT, [{ type: 'page', name: 'crm_home' }]],
+ ])('%s draws Preview, and the click hands the host the drafted items and route segment', (_label, toolName, envelope, items) => {
+ const onPreviewDraft = vi.fn();
+ const onPreviewDraftApp = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByTestId('draft-preview'));
+ expect(onPreviewDraft).toHaveBeenCalledTimes(1);
+ expect(onPreviewDraft).toHaveBeenCalledWith(items, { appSegment: 'app.crm' });
+ // Not the app door: a non-app draft has no app name to hand it.
+ expect(onPreviewDraftApp).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('draft-preview-app')).not.toBeInTheDocument();
+ });
+
+ it('an app draft keeps the app button and its callback, unchanged', () => {
+ const onPreviewDraft = vi.fn();
+ const onPreviewDraftApp = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.queryByTestId('draft-preview')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('draft-preview-app'));
+ expect(onPreviewDraftApp).toHaveBeenCalledWith('library', { materialized: false, appSegment: 'app.fh79' });
+ expect(onPreviewDraft).not.toHaveBeenCalled();
+ });
+
+ it('CONTROL: without onPreviewDraft a non-app draft draws no Preview, as before', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('draft-publish')).toBeInTheDocument();
+ expect(screen.queryByTestId('draft-preview')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('draft-preview-app')).not.toBeInTheDocument();
+ });
+
+ it('CONTROL: a draft without an owning package has no route segment, so no Preview', () => {
+ const { packageId: _dropped, ...noPackage } = FIELD_DRAFT;
+ render(
+ ,
+ );
+ expect(screen.queryByTestId('draft-preview')).not.toBeInTheDocument();
+ });
+
+ it('Preview publishes nothing: no publish callback, no request', () => {
+ const onPublishDrafts = vi.fn();
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'));
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByTestId('draft-preview'));
+ expect(onPublishDrafts).not.toHaveBeenCalled();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
From 7a79fd2d27649df30feabedb006b9cbff521eeeb Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 14:39:40 +0000
Subject: [PATCH 2/6] feat(app-shell): the draft preview's deep-link table, and
the bar names what is pending on the surface it shows (objectui#12968)
`preview/draftSurface.ts` maps drafted items to the router path of the
affected surface in draft preview (a page, the object's list on a view, the
object's default list, else the app), built with `@object-ui/layout`'s
`resolveHref`; and maps the route the preview shows back to the pending
drafts that belong to it, matched against `APP_ROUTE_METADATA_READS`.
The DraftPreviewBar's message names the count pending on that surface,
through `preview.draftBar.pendingHere` in all ten packs.
Co-authored-by: Claude
Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
---
.../app-shell/src/preview/DraftPreviewBar.tsx | 33 +++-
.../__tests__/draftPreview-12968.test.tsx | 168 ++++++++++++++++++
.../app-shell/src/preview/draftSurface.ts | 157 ++++++++++++++++
packages/i18n/src/locales/ar.ts | 7 +
packages/i18n/src/locales/de.ts | 3 +
packages/i18n/src/locales/en.ts | 4 +
packages/i18n/src/locales/es.ts | 4 +
packages/i18n/src/locales/fr.ts | 4 +
packages/i18n/src/locales/ja.ts | 3 +
packages/i18n/src/locales/ko.ts | 3 +
packages/i18n/src/locales/pt.ts | 4 +
packages/i18n/src/locales/ru.ts | 5 +
packages/i18n/src/locales/zh.ts | 3 +
13 files changed, 391 insertions(+), 7 deletions(-)
create mode 100644 packages/app-shell/src/preview/__tests__/draftPreview-12968.test.tsx
create mode 100644 packages/app-shell/src/preview/draftSurface.ts
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 `.` item and a field a `.` row:
+ * the spec's qualified item names, whose prefix is the object
+ * (`ViewItemNameSchema`). A field added to an existing object is drafted as the
+ * object itself.
+ */
+
+import { matchRoutes } from 'react-router-dom';
+import { resolveHref } from '@object-ui/layout';
+import type { NavigationItem } from '@object-ui/types';
+import { APP_ROUTE_METADATA_READS } from '../console/appRouteMetadataReads.js';
+import { PREVIEW_QUERY_FLAG, PREVIEW_QUERY_VALUE } from './PreviewModeContext.js';
+
+/** A drafted item, as the chat lifts it and as `_drafts` lists it. */
+export interface DraftItem {
+ type: string;
+ name: string;
+}
+
+/** The object a qualified `.` item name belongs to. */
+function objectOf(qualifiedName: string): string | undefined {
+ const dot = qualifiedName.indexOf('.');
+ return dot > 0 ? qualifiedName.slice(0, dot) : undefined;
+}
+
+/**
+ * The nav entry that opens a drafted item's surface, or `null` for an item
+ * with none of its own (an app, a flow, a permission set …), which previews
+ * as the app.
+ */
+function navEntryFor(item: DraftItem): NavigationItem | null {
+ const id = `draft-preview:${item.type}:${item.name}`;
+ switch (item.type) {
+ case 'page':
+ return { id, type: 'page', pageName: item.name };
+ case 'dashboard':
+ return { id, type: 'dashboard', dashboardName: item.name };
+ case 'report':
+ return { id, type: 'report', reportName: item.name };
+ case 'view': {
+ const objectName = objectOf(item.name);
+ return objectName ? { id, type: 'object', objectName, viewName: item.name } : null;
+ }
+ case 'field': {
+ const objectName = objectOf(item.name);
+ return objectName ? { id, type: 'object', objectName } : null;
+ }
+ case 'object':
+ return { id, type: 'object', objectName: item.name };
+ default:
+ return null;
+ }
+}
+
+/**
+ * Which drafted item a batch previews: the most specific surface first — a
+ * page, dashboard or report, then a view, then the object a field or object
+ * draft changed. A batch with none of them previews the app.
+ */
+const SURFACE_RANK: readonly string[] = ['page', 'dashboard', 'report', 'view', 'field', 'object'];
+
+/**
+ * The router path that previews `items` in the app routed at `appSegment`
+ * (its package id, ADR-0048), always in draft preview:
+ *
+ * - a page draft → that page;
+ * - a view draft → the object's list on that view;
+ * - a field or object draft → the object's default list;
+ * - an app draft, or a batch with no surface of its own → the app.
+ */
+export function draftPreviewPath(items: readonly DraftItem[], appSegment: string): string {
+ const basePath = `/apps/${appSegment}`;
+ let entry: NavigationItem | null = null;
+ let rank = SURFACE_RANK.length;
+ for (const item of items) {
+ const at = SURFACE_RANK.indexOf(item.type);
+ if (at < 0 || at >= rank) continue;
+ const candidate = navEntryFor(item);
+ if (candidate) {
+ entry = candidate;
+ rank = at;
+ }
+ }
+ const href = entry ? resolveHref(entry, basePath).href : basePath;
+ const [path, query = ''] = href.split('?');
+ const params = new URLSearchParams(query);
+ params.set(PREVIEW_QUERY_FLAG, PREVIEW_QUERY_VALUE);
+ return `${path}?${params.toString()}`;
+}
+
+/** The surface a route under `/apps/:appName` shows, as its route params name it. */
+export interface DraftSurface {
+ objectName?: string;
+ pageName?: string;
+ dashboardName?: string;
+ reportName?: string;
+}
+
+/**
+ * The surface the route at `subPath` (the path below `/apps/:appName`) shows,
+ * or `null` for a route that shows no single object, page, dashboard or report
+ * (the app's landing, metadata admin, search …).
+ */
+export function draftSurfaceOf(subPath: string): DraftSurface | null {
+ const matches = matchRoutes([...APP_ROUTE_METADATA_READS], `/${subPath.replace(/^\/+/, '')}`);
+ const params = matches ? matches[matches.length - 1].params : {};
+ const surface: DraftSurface = {};
+ if (params.objectName) surface.objectName = params.objectName;
+ if (params.pageName) surface.pageName = params.pageName;
+ if (params.dashboardName) surface.dashboardName = params.dashboardName;
+ if (params.reportName) surface.reportName = params.reportName;
+ return Object.keys(surface).length > 0 ? surface : null;
+}
+
+/** The pending drafts that change what `surface` shows. */
+export function draftsOnSurface(entries: readonly T[], surface: DraftSurface): T[] {
+ return entries.filter((entry) => {
+ switch (entry.type) {
+ case 'object':
+ return entry.name === surface.objectName;
+ case 'view':
+ case 'field':
+ return surface.objectName !== undefined && objectOf(entry.name) === surface.objectName;
+ case 'page':
+ return entry.name === surface.pageName;
+ case 'dashboard':
+ return entry.name === surface.dashboardName;
+ case 'report':
+ return entry.name === surface.reportName;
+ default:
+ return false;
+ }
+ });
+}
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 519ded9f0a..f9d0fff613 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -4352,6 +4352,13 @@ const ar = {
publishing: 'جارٍ النشر…',
exit: 'إنهاء المعاينة',
changes: 'التغييرات',
+ pendingHere: 'معاينة المسودة · {{count}} تغيير غير منشور في هذه الصفحة',
+ pendingHere_zero: 'معاينة المسودة · {{count}} تغيير غير منشور في هذه الصفحة',
+ pendingHere_one: 'معاينة المسودة · تغيير واحد غير منشور في هذه الصفحة ({{count}})',
+ pendingHere_two: 'معاينة المسودة · تغييران غير منشورين في هذه الصفحة ({{count}})',
+ pendingHere_few: 'معاينة المسودة · {{count}} تغييرات غير منشورة في هذه الصفحة',
+ pendingHere_many: 'معاينة المسودة · {{count}} تغييرًا غير منشور في هذه الصفحة',
+ pendingHere_other: 'معاينة المسودة · {{count}} تغيير غير منشور في هذه الصفحة',
},
changes: {
title: 'التغييرات المعلّقة',
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index d91ec25d49..674379d97f 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -3997,6 +3997,9 @@ const de = {
publishing: 'Wird veröffentlicht…',
exit: 'Vorschau beenden',
changes: 'Änderungen',
+ pendingHere: 'Entwurfsvorschau · {{count}} unveröffentlichte Änderungen auf dieser Seite',
+ pendingHere_one: 'Entwurfsvorschau · {{count}} unveröffentlichte Änderung auf dieser Seite',
+ pendingHere_other: 'Entwurfsvorschau · {{count}} unveröffentlichte Änderungen auf dieser Seite',
},
changes: {
title: 'Ausstehende Änderungen',
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index aa219bb65c..556687787d 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -3451,6 +3451,10 @@ const en = {
publishing: 'Publishing…',
exit: 'Exit preview',
changes: 'Changes',
+ // objectui#12968 — what is pending on the surface the preview shows.
+ pendingHere: 'Draft preview · {{count}} unpublished changes on this page',
+ pendingHere_one: 'Draft preview · {{count}} unpublished change on this page',
+ pendingHere_other: 'Draft preview · {{count}} unpublished changes on this page',
// Magic-moment hint: a freshly-built draft renders empty (dashboards at 0,
// lists "Nothing here yet") because seed/sample rows only load on publish.
// Say so plainly so an empty preview never reads as a broken build.
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index dabe9bd9cb..6f384b04dc 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -4084,6 +4084,10 @@ const es = {
publishing: 'Publicando…',
exit: 'Salir de la vista previa',
changes: 'Cambios',
+ pendingHere: 'Vista previa del borrador · {{count}} cambios sin publicar en esta página',
+ pendingHere_one: 'Vista previa del borrador · {{count}} cambio sin publicar en esta página',
+ pendingHere_many: 'Vista previa del borrador · {{count}} de cambios sin publicar en esta página',
+ pendingHere_other: 'Vista previa del borrador · {{count}} cambios sin publicar en esta página',
},
changes: {
title: 'Cambios pendientes',
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index 094948b598..1276022855 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -4081,6 +4081,10 @@ const fr = {
publishing: 'Publication…',
exit: "Quitter l'aperçu",
changes: 'Modifications',
+ pendingHere: 'Aperçu du brouillon · {{count}} modifications non publiées sur cette page',
+ pendingHere_one: 'Aperçu du brouillon · {{count}} modification non publiée sur cette page',
+ pendingHere_many: 'Aperçu du brouillon · {{count}} de modifications non publiées sur cette page',
+ pendingHere_other: 'Aperçu du brouillon · {{count}} modifications non publiées sur cette page',
},
changes: {
title: 'Modifications en attente',
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 3c5a9ff052..a96082c381 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -3999,6 +3999,9 @@ const ja = {
publishing: '公開中…',
exit: 'プレビューを終了',
changes: '変更',
+ pendingHere: '下書きプレビュー · このページの未公開の変更 {{count}} 件',
+ pendingHere_one: '下書きプレビュー · このページの未公開の変更 {{count}} 件',
+ pendingHere_other: '下書きプレビュー · このページの未公開の変更 {{count}} 件',
},
changes: {
title: '保留中の変更',
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 31fd912b39..6910169fdd 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -3996,6 +3996,9 @@ const ko = {
publishing: '게시 중…',
exit: '미리보기 종료',
changes: '변경 사항',
+ pendingHere: '초안 미리보기 · 이 페이지의 게시되지 않은 변경 {{count}}개',
+ pendingHere_one: '초안 미리보기 · 이 페이지의 게시되지 않은 변경 {{count}}개',
+ pendingHere_other: '초안 미리보기 · 이 페이지의 게시되지 않은 변경 {{count}}개',
},
changes: {
title: '대기 중인 변경 사항',
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 2a43fbb41b..5917960a79 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -4079,6 +4079,10 @@ const pt = {
publishing: 'Publicando…',
exit: 'Sair da pré-visualização',
changes: 'Alterações',
+ pendingHere: 'Pré-visualização do rascunho · {{count}} alterações não publicadas nesta página',
+ pendingHere_one: 'Pré-visualização do rascunho · {{count}} alteração não publicada nesta página',
+ pendingHere_many: 'Pré-visualização do rascunho · {{count}} de alterações não publicadas nesta página',
+ pendingHere_other: 'Pré-visualização do rascunho · {{count}} alterações não publicadas nesta página',
},
changes: {
title: 'Alterações pendentes',
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 61e8c66ae7..4337294b8a 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -4187,6 +4187,11 @@ const ru = {
publishing: 'Публикация…',
exit: 'Выйти из предпросмотра',
changes: 'Изменения',
+ pendingHere: 'Предпросмотр черновика · {{count}} неопубликованных изменений на этой странице',
+ pendingHere_one: 'Предпросмотр черновика · {{count}} неопубликованное изменение на этой странице',
+ pendingHere_few: 'Предпросмотр черновика · {{count}} неопубликованных изменения на этой странице',
+ pendingHere_many: 'Предпросмотр черновика · {{count}} неопубликованных изменений на этой странице',
+ pendingHere_other: 'Предпросмотр черновика · {{count}} неопубликованного изменения на этой странице',
},
changes: {
title: 'Ожидающие изменения',
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 4fced541eb..7f845dd776 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -2981,6 +2981,9 @@ const zh = {
publishing: '发布中…',
exit: '退出预览',
changes: '变更',
+ pendingHere: '草稿预览 · 本页 {{count}} 项未发布',
+ pendingHere_one: '草稿预览 · 本页 {{count}} 项未发布',
+ pendingHere_other: '草稿预览 · 本页 {{count}} 项未发布',
// 魔法时刻提示:刚搭好的草稿因为种子/示例数据要发布后才载入,预览里
// 仪表板显示 0、列表显示“暂无内容”。直接说明,避免把空预览误当成搭坏了。
sampleDataTitle: '示例数据将在发布后出现',
From f9a8e54cb035d6f91b28464b52ab211349e87e75 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 14:51:22 +0000
Subject: [PATCH 3/6] chore(changeset): plugin-chatbot minor, app-shell and
i18n patch for the any-draft Preview (objectui#12968)
Co-authored-by: Claude
Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
---
.changeset/12968-draft-preview-any.md | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 .changeset/12968-draft-preview-any.md
diff --git a/.changeset/12968-draft-preview-any.md b/.changeset/12968-draft-preview-any.md
new file mode 100644
index 0000000000..dda0044a38
--- /dev/null
+++ b/.changeset/12968-draft-preview-any.md
@@ -0,0 +1,24 @@
+---
+'@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.
+
+**`@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.
+
+**`@object-ui/i18n`.** `preview.draftBar.pendingHere`, a plural family, in all ten packs.
From 6c4dcfeac554d1d9b7f431b7e5de5a381a8da436 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 16:28:25 +0000
Subject: [PATCH 4/6] feat(plugin-chatbot): the confirm-gated applied card
previews what the change drafted (objectui#12968)
detectReplayOutcome lifts a drafted replay's drafted[] items into
ReplayOutcome.items (one helper shared with detectDraftResult), and the applied
card and the bare drafted badge draw Preview through the same onPreviewDraft /
onPreviewDraftApp rule the drafted result uses (draftPreviewAction).
Co-authored-by: Claude
Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
---
.../plugin-chatbot/src/ChatbotEnhanced.tsx | 96 ++++++++++---
...Enhanced.appliedCardPreview-12968.test.tsx | 126 ++++++++++++++++++
packages/plugin-chatbot/src/mapMessages.ts | 41 ++++--
3 files changed, 232 insertions(+), 31 deletions(-)
create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.appliedCardPreview-12968.test.tsx
diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
index 2e650291b5..bd84808025 100644
--- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
+++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx
@@ -704,7 +704,9 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes,
+ packageId: string | undefined,
+ materialized: boolean,
+ doors: Pick,
+): { run: () => void; app: boolean } | undefined {
+ if (items.length === 0) return undefined;
+ const app = items.find((i) => i.type === 'app');
+ if (app) {
+ const open = doors.onPreviewDraftApp;
+ return open ? { run: () => open(app.name, { materialized, appSegment: packageId }), app: true } : undefined;
+ }
+ const open = doors.onPreviewDraft;
+ return open && packageId ? { run: () => open([...items], { appSegment: packageId }), app: false } : undefined;
+}
+
/**
* objectui#12894 — the applied card of a change the copilot saved as a draft
* (cloud#2924, the StudioRailCopilot artboard): `appliedCard.title` beside a
@@ -1424,6 +1451,8 @@ function AppliedDraftCard({
card,
others,
discard,
+ onPreview,
+ previewLabel,
onReview,
onPublish,
publishLabel,
@@ -1434,6 +1463,9 @@ function AppliedDraftCard({
card: NonNullable;
others?: number;
discard?: DraftDiscardState;
+ /** objectui#12968 — preview what the change drafted (the host's `onPreviewDraft`). */
+ onPreview?: () => void;
+ previewLabel: string;
onReview?: () => void;
onPublish?: () => void;
publishLabel: string;
@@ -1517,7 +1549,7 @@ function AppliedDraftCard({
) : (
<>
- {onReview || onPublish || onDiscard ? (
+ {onReview || onPublish || onPreview || onDiscard ? (
{onReview ? (
) : null}
+ {onPreview ? (
+
+
+ {previewLabel}
+
+ ) : null}
{onDiscard ? (
(
{!draftIsPublished
? (() => {
const review = tool.draftReview!;
- const app = review.items.find((i) => i.type === 'app');
- if (app) {
- return onPreviewDraftApp ? (
- onPreviewDraftApp(app.name, { materialized: review.materialized === true, appSegment: review.packageId })}
- className="inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-xs font-medium hover:bg-muted"
- data-testid="draft-preview-app"
- >
-
- {previewDraftLabel}
-
- ) : null;
- }
- const appSegment = review.packageId;
- return onPreviewDraft && appSegment ? (
+ const preview = draftPreviewAction(review.items, review.packageId, review.materialized === true, {
+ onPreviewDraftApp,
+ onPreviewDraft,
+ });
+ return preview ? (
onPreviewDraft(review.items, { appSegment })}
+ onClick={preview.run}
className="inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-xs font-medium hover:bg-muted"
- data-testid="draft-preview"
+ data-testid={preview.app ? 'draft-preview-app' : 'draft-preview'}
>
{previewDraftLabel}
@@ -3737,11 +3770,19 @@ const ChatbotEnhanced = React.forwardRef(
// wrote them, 查看变更并发布 and 撤销.
const packageId = outcome.packageId;
const discard = discardByToolCall.get(tool.toolCallId);
+ // objectui#12968 — the items this replay drafted preview
+ // through the same door the drafted result's Preview uses.
+ const preview = draftPreviewAction(outcome.items ?? [], packageId, false, {
+ onPreviewDraftApp,
+ onPreviewDraft,
+ });
return (
onReviewDrafts(packageId) : undefined}
onPublish={
!onReviewDrafts && onPublishDrafts && packageId
@@ -3764,6 +3805,10 @@ const ChatbotEnhanced = React.forwardRef(
);
}
if (outcome?.kind === 'drafted') {
+ const preview = draftPreviewAction(outcome.items ?? [], outcome.packageId, false, {
+ onPreviewDraftApp,
+ onPreviewDraft,
+ });
return (
(
{publishDraftsLabel}
) : null}
+ {preview ? (
+
+
+ {previewDraftLabel}
+
+ ) : null}
);
}
diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.appliedCardPreview-12968.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.appliedCardPreview-12968.test.tsx
new file mode 100644
index 0000000000..19f07507b7
--- /dev/null
+++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.appliedCardPreview-12968.test.tsx
@@ -0,0 +1,126 @@
+/**
+ * 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 confirm
+ * path previews too. A change confirmed on the change card and saved as a
+ * draft answers with a `drafted` replay envelope; `detectReplayOutcome` lifts
+ * the items that replay drafted, and the applied card draws Preview through
+ * the same `onPreviewDraft` door the drafted result uses.
+ *
+ * Pinned:
+ * - the lift: a drafted replay carries its `drafted[]` items (not the
+ * package's whole `draftState.items`), and a published one carries none;
+ * - the applied card draws Preview and the click hands the host the items
+ * and the package id; the bare drafted badge (an older cloud with no
+ * applied card) does the same;
+ * - CONTROL: a host with no `onPreviewDraft` draws no Preview, and the card's
+ * other doors are unchanged.
+ *
+ * Every message goes through the real mapper from a raw tool part.
+ */
+import '@testing-library/jest-dom/vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { I18nProvider } from '@object-ui/i18n';
+import { ChatbotEnhanced } from '../ChatbotEnhanced';
+import { detectReplayOutcome, uiMessageToChatMessage } from '../mapMessages';
+import { BORROWING, PACKAGE_ID, draftedEnvelope, proposedEnvelope } from './fixtures/changePosture.fixtures';
+
+afterEach(() => {
+ cleanup();
+});
+
+const proposalTurn = (envelope: unknown) =>
+ uiMessageToChatMessage({
+ id: 'a1',
+ role: 'assistant',
+ parts: [
+ { type: 'tool-apply_edit', toolCallId: 'call_1', state: 'output-available', input: {}, output: envelope },
+ ],
+ });
+
+const replayTurn = (envelope: unknown) =>
+ uiMessageToChatMessage({
+ id: 'a2',
+ role: 'assistant',
+ parts: [
+ { type: 'tool-apply_edit', toolCallId: 'replay_t2_0', state: 'output-available', input: {}, output: envelope },
+ ],
+ });
+
+const renderApplied = (replay: unknown, props: Partial> = {}) =>
+ render(
+
+
+ ,
+ );
+
+describe('detectReplayOutcome lifts the drafted items (objectui#12968)', () => {
+ it('a drafted replay carries the items it drafted, not every pending draft of the package', () => {
+ const outcome = detectReplayOutcome('replay_t2_0', draftedEnvelope('en', { others: 2 }));
+ expect(outcome?.kind).toBe('drafted');
+ expect(outcome?.items).toEqual([{ type: 'object', name: BORROWING }]);
+ expect(outcome?.draftState?.items).toHaveLength(3);
+ });
+
+ it('a one-item stage reports its type and name, and those are the item', () => {
+ const outcome = detectReplayOutcome('replay_t2_0', {
+ status: 'drafted',
+ type: 'view',
+ name: `${BORROWING}.board`,
+ packageId: PACKAGE_ID,
+ });
+ expect(outcome?.items).toEqual([{ type: 'view', name: `${BORROWING}.board` }]);
+ });
+
+ it('a published replay carries no items', () => {
+ expect(detectReplayOutcome('replay_t2_0', { status: 'published', drafted: [{ type: 'object', name: BORROWING }] })).toEqual({
+ kind: 'published',
+ });
+ });
+});
+
+describe('the confirm-gated applied card draws Preview (objectui#12968)', () => {
+ it('the applied card draws Preview, and the click hands the host the drafted items and the package id', () => {
+ const onPreviewDraft = vi.fn();
+ const onReviewDrafts = vi.fn();
+ renderApplied(draftedEnvelope('en', { others: 2 }), { onPreviewDraft, onReviewDrafts, onDiscardDrafts: vi.fn() });
+ const preview = screen.getByTestId('applied-card-preview');
+ expect(preview).toHaveTextContent('Preview');
+ fireEvent.click(preview);
+ expect(onPreviewDraft).toHaveBeenCalledTimes(1);
+ expect(onPreviewDraft).toHaveBeenCalledWith([{ type: 'object', name: BORROWING }], { appSegment: PACKAGE_ID });
+ // Preview publishes and reviews nothing.
+ expect(onReviewDrafts).not.toHaveBeenCalled();
+ // The card's other doors are where they were.
+ expect(screen.getByTestId('applied-card-review')).toBeInTheDocument();
+ expect(screen.getByTestId('applied-card-discard')).toBeInTheDocument();
+ });
+
+ it('an older cloud (no applied card): the drafted badge draws Preview through the same door', () => {
+ const onPreviewDraft = vi.fn();
+ renderApplied(draftedEnvelope('en', { withCard: false }), { onPreviewDraft, onPublishDrafts: vi.fn() });
+ expect(screen.queryByTestId('applied-card')).not.toBeInTheDocument();
+ expect(screen.getByTestId('proposed-changes-drafted')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('proposed-changes-preview'));
+ expect(onPreviewDraft).toHaveBeenCalledWith([{ type: 'object', name: BORROWING }], { appSegment: PACKAGE_ID });
+ });
+
+ it('CONTROL: a host with no onPreviewDraft draws no Preview on either rendering', () => {
+ renderApplied(draftedEnvelope('en'), { onReviewDrafts: vi.fn(), onDiscardDrafts: vi.fn() });
+ expect(screen.getByTestId('applied-card')).toBeInTheDocument();
+ expect(screen.queryByTestId('applied-card-preview')).not.toBeInTheDocument();
+ cleanup();
+ renderApplied(draftedEnvelope('en', { withCard: false }), { onPublishDrafts: vi.fn() });
+ expect(screen.getByTestId('proposed-changes-drafted')).toBeInTheDocument();
+ expect(screen.queryByTestId('proposed-changes-preview')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts
index 1b39ebbffe..94977d0bcd 100644
--- a/packages/plugin-chatbot/src/mapMessages.ts
+++ b/packages/plugin-chatbot/src/mapMessages.ts
@@ -836,6 +836,33 @@ export interface ReplayOutcome {
draftState?: ChangeDraftState;
existingRows?: ExistingRowsLine[];
appliedCard?: AppliedCard;
+ /**
+ * objectui#12968 — on a `drafted` outcome only, the items THIS replay
+ * drafted (the envelope's `drafted[]`, lifted as {@link detectDraftResult}
+ * lifts it), so the applied card can preview them. Not `draftState.items`,
+ * which lists every pending draft of the package. Absent when the envelope
+ * names none.
+ */
+ items?: Array<{ type: string; name: string }>;
+}
+
+/**
+ * The drafted `{ type, name }` items of an authoring envelope: its `drafted[]`,
+ * or the single `type` / `name` pair a one-item stage reports.
+ */
+function draftedItemsOf(obj: Record): Array<{ type: string; name: string }> {
+ const items: Array<{ type: string; name: string }> = [];
+ if (Array.isArray(obj.drafted)) {
+ for (const d of obj.drafted) {
+ if (d && typeof d === 'object') {
+ const { type, name } = d as Record;
+ if (typeof type === 'string' && typeof name === 'string') items.push({ type, name });
+ }
+ }
+ } else if (typeof obj.type === 'string' && typeof obj.name === 'string') {
+ items.push({ type: obj.type, name: obj.name });
+ }
+ return items;
}
export function detectReplayOutcome(
@@ -873,9 +900,11 @@ export function detectReplayOutcome(
const draftState = draftStateOf(obj.draftState);
const existingRows = existingRowsOf(obj.existingRows);
const appliedCard = appliedCardOf(obj.appliedCard);
+ const items = draftedItemsOf(obj);
return {
kind: 'drafted',
...packageId,
+ ...(items.length > 0 ? { items } : {}),
...(draftState ? { draftState } : {}),
...(existingRows ? { existingRows } : {}),
...(appliedCard ? { appliedCard } : {}),
@@ -935,17 +964,7 @@ export function detectBuiltAppPackage(result: unknown): string | undefined {
export function detectDraftResult(result: unknown): DraftReview | undefined {
const obj = parseResultEnvelope(result);
if (!obj || obj.status !== 'drafted') return undefined;
- const items: Array<{ type: string; name: string }> = [];
- if (Array.isArray(obj.drafted)) {
- for (const d of obj.drafted) {
- if (d && typeof d === 'object') {
- const { type, name } = d as Record;
- if (typeof type === 'string' && typeof name === 'string') items.push({ type, name });
- }
- }
- } else if (typeof obj.type === 'string' && typeof obj.name === 'string') {
- items.push({ type: obj.type, name: obj.name });
- }
+ const items = draftedItemsOf(obj);
if (items.length === 0) return undefined;
// The owning package (when the staging tool reported it) lets the chat offer
// a one-click "publish" — POST /packages/:packageId/publish-drafts — so the
From e3e58d8e8ab49bdb832e2f93feb08fd6ff117793 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 16:28:26 +0000
Subject: [PATCH 5/6] feat(app-shell): the console wires the any-draft Preview
into the Live Canvas (objectui#12968)
ChatPane passes onPreviewDraft: a field, view or page draft's Preview opens the
Live Canvas at draftPreviewPath's target (?preview=draft). LiveCanvas accepts a
full preview path beside the app segment; the app draft path is unchanged.
Co-authored-by: Claude
Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
---
.../app-shell/src/console/ai/AiChatPage.tsx | 23 +-
.../src/console/ai/LiveCanvas.test.tsx | 37 ++++
.../app-shell/src/console/ai/LiveCanvas.tsx | 25 ++-
.../AiChatPage.previewDraft-12968.test.tsx | 205 ++++++++++++++++++
4 files changed, 282 insertions(+), 8 deletions(-)
create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.previewDraft-12968.test.tsx
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 (
+
+ preview?.([{ type: 'object', name: 'k9_task' }], { appSegment: 'app.k9' })}
+ />
+ preview?.([{ type: 'view', name: 'k9_task.board' }], { appSegment: 'app.k9' })}
+ />
+ preview?.([{ type: 'page', name: 'k9_home' }], { appSegment: 'app.k9' })}
+ />
+ previewApp?.('k9_app', { appSegment: 'app.k9', materialized: false })}
+ />
+
+ );
+ },
+ };
+});
+
+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', '');
+ });
+});
From 58d05593c87cd494658333bda25b777af3c2efe9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 18 Oct 2026 16:34:50 +0000
Subject: [PATCH 6/6] test(plugin-chatbot): the replay-outcome shape pins carry
the drafted items; changeset names the wiring (objectui#12968)
Co-authored-by: Claude
Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
---
.changeset/12968-draft-preview-any.md | 10 ++++++++--
.../__tests__/mapMessages.changePosture-12894.test.ts | 9 ++++++---
.../src/__tests__/mapMessages.replayOutcome.test.ts | 2 +-
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/.changeset/12968-draft-preview-any.md b/.changeset/12968-draft-preview-any.md
index dda0044a38..3f278d1465 100644
--- a/.changeset/12968-draft-preview-any.md
+++ b/.changeset/12968-draft-preview-any.md
@@ -12,13 +12,19 @@ Clause-②: yes — `ChatbotEnhancedProps` gains the optional `onPreviewDraft`.
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.
+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.
+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/plugin-chatbot/src/__tests__/mapMessages.changePosture-12894.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.changePosture-12894.test.ts
index dc70bc1d64..ca2f5aafdf 100644
--- a/packages/plugin-chatbot/src/__tests__/mapMessages.changePosture-12894.test.ts
+++ b/packages/plugin-chatbot/src/__tests__/mapMessages.changePosture-12894.test.ts
@@ -104,6 +104,8 @@ describe('objectui#12894 — detectReplayOutcome keeps the applied card of a cha
expect(out).toEqual({
kind: 'drafted',
packageId: PACKAGE_ID,
+ // objectui#12968 — the items this replay drafted, for the card's Preview.
+ items: [{ type: 'object', name: BORROWING }],
draftState: {
pending: 2,
items: [
@@ -117,9 +119,10 @@ describe('objectui#12894 — detectReplayOutcome keeps the applied card of a cha
});
});
- it('an older cloud drafted replay lifts exactly what it lifted before', () => {
+ it('an older cloud drafted replay lifts none of the cloud#2924 keys', () => {
const out = detectReplayOutcome('replay_turn_0', draftedEnvelope('en', { withCard: false }));
- expect(out).toEqual({ kind: 'drafted', packageId: PACKAGE_ID });
+ // `items` is objectui#12968's lift of `drafted[]`, which every cloud sends.
+ expect(out).toEqual({ kind: 'drafted', packageId: PACKAGE_ID, items: [{ type: 'object', name: BORROWING }] });
});
it('each key degrades on its own: a card without a draft state keeps the card', () => {
@@ -137,7 +140,7 @@ describe('objectui#12894 — detectReplayOutcome keeps the applied card of a cha
appliedCard: { lines: ['已有 12 条记录的续借次数为空'] },
existingRows: [{ object: BORROWING, field: 'renewal_count', rows: 12 }, 'x'],
});
- expect(out).toEqual({ kind: 'drafted', packageId: PACKAGE_ID });
+ expect(out).toEqual({ kind: 'drafted', packageId: PACKAGE_ID, items: [{ type: 'object', name: BORROWING }] });
});
it('a card keeps only its string lines; a draft state only its well-formed items', () => {
diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.replayOutcome.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.replayOutcome.test.ts
index e12892878d..7d5784f9b1 100644
--- a/packages/plugin-chatbot/src/__tests__/mapMessages.replayOutcome.test.ts
+++ b/packages/plugin-chatbot/src/__tests__/mapMessages.replayOutcome.test.ts
@@ -37,7 +37,7 @@ describe('detectReplayOutcome (objectui#5695)', () => {
'replay_t1_0',
JSON.stringify({ status: 'drafted', drafted: [{ type: 'view', name: 'v' }], packageId: 'app.k9qk' }),
),
- ).toEqual({ kind: 'drafted', packageId: 'app.k9qk' });
+ ).toEqual({ kind: 'drafted', packageId: 'app.k9qk', items: [{ type: 'view', name: 'v' }] });
});
it('lifts a failed publish with the machine verdict and the FIRST line of publishError', () => {