Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/12968-draft-preview-any.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 22 additions & 1 deletion packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -2876,6 +2895,7 @@ export function ChatPane({
appName={canvasApp!.name}
appSegment={canvasApp!.segment}
materialized={canvasApp!.materialized}
path={canvasApp!.path}
refreshKey={canvasRefreshKey}
onClose={() => setCanvasApp(null)}
/>
Expand Down Expand Up @@ -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)}
Expand Down
37 changes: 37 additions & 0 deletions packages/app-shell/src/console/ai/LiveCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<LiveCanvas
appName="app.crm"
appSegment="app.crm"
path="/apps/app.crm/crm_account/view/crm_account.pipeline?preview=draft"
refreshKey={0}
onClose={() => {}}
/>,
);
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(<LiveCanvas appName="crm" appSegment="app.crm" refreshKey={0} onClose={() => {}} />);
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(
<LiveCanvas
appName="crm"
appSegment="app.crm"
materialized
path="/apps/app.crm/crm_account?preview=draft"
refreshKey={0}
onClose={() => {}}
/>,
);
expect(screen.getByTestId('live-canvas-frame')).toHaveAttribute('src', '/apps/app.crm');
});
});
25 changes: 18 additions & 7 deletions packages/app-shell/src/console/ai/LiveCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<segment>/…?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;
Expand All @@ -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<HTMLIFrameElement | null>(null);
Expand All @@ -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.
Expand Down Expand Up @@ -133,7 +144,7 @@ export function LiveCanvas({ appName, appSegment, materialized = false, refreshK
<iframe
ref={iframeRef}
title={`Draft preview: ${appName}`}
src={canvasSrc(routeSeg, materialized)}
src={canvasSrc(routeSeg, materialized, path)}
className="h-full w-full flex-1 border-0 bg-background"
data-testid="live-canvas-frame"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>();
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<string, unknown>) => {
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 (
<div data-testid="pane" data-has-preview-draft={String(typeof preview === 'function')}>
<button
type="button"
data-testid="preview-field"
onClick={() => preview?.([{ type: 'object', name: 'k9_task' }], { appSegment: 'app.k9' })}
/>
<button
type="button"
data-testid="preview-view"
onClick={() => preview?.([{ type: 'view', name: 'k9_task.board' }], { appSegment: 'app.k9' })}
/>
<button
type="button"
data-testid="preview-page"
onClick={() => preview?.([{ type: 'page', name: 'k9_home' }], { appSegment: 'app.k9' })}
/>
<button
type="button"
data-testid="preview-app"
onClick={() => previewApp?.('k9_app', { appSegment: 'app.k9', materialized: false })}
/>
</div>
);
},
};
});

vi.mock('@object-ui/auth', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, useAuth: () => ({ user: { id: 'u1' } }) };
});
vi.mock('../../../providers/MetadataProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, useMetadata: () => ({ apps: [] }) };
});
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, useAdapter: () => null };
});
vi.mock('../ConversationsSidebar', () => ({
ConversationsSidebar: () => <div data-testid="sidebar" />,
}));
vi.mock('../LiveCanvas', () => ({
LiveCanvas: (props: { appName: string; appSegment?: string; materialized?: boolean; path?: string }) => (
<div
data-testid="live-canvas"
data-app={props.appName}
data-segment={props.appSegment ?? ''}
data-materialized={String(props.materialized === true)}
data-path={props.path ?? ''}
/>
),
}));

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 <div data-testid="where" data-path={location.pathname} />;
}

function renderPage() {
render(
<MemoryRouter initialEntries={['/ai/build/conv-1']}>
<Routes>
<Route path="/ai/:agent/:conversationId" element={<AiChatPage />} />
<Route path="/ai/:agent" element={<AiChatPage />} />
</Routes>
<Where />
</MemoryRouter>,
);
}

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', '');
});
});
Loading
Loading