diff --git a/.changeset/26469-ai-build-canvas-header-labels.md b/.changeset/26469-ai-build-canvas-header-labels.md
new file mode 100644
index 00000000000..7a9d86a6ac5
--- /dev/null
+++ b/.changeset/26469-ai-build-canvas-header-labels.md
@@ -0,0 +1,17 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/plugin-chatbot': patch
+'@object-ui/i18n': patch
+---
+
+fix(app-shell,plugin-chatbot,i18n): the AI build page's preview header names the app by its label and says what the build card says about publishing; the progress panel shows labels
+
+Clause-②: no
+Metadata-protocol: none
+
+- **Preview header.** The Live Canvas header named the app by its machine name and said "unlisted until published" (「未发布 · 仅自己可见」) for any materialized build, even one the build card beside it called live, and never changed. It now reads the build card's own answer: the label the card names the app by (`checklist.app.label`, else the build's label) and whether the card says the app is live. A live build reads "Live app — LABEL" (「运行预览 · LABEL」), and the header follows the card when the build is published. A materialized build the card does not call live keeps the unlisted note.
+- **`ChatbotEnhanced`.** New optional prop `onBuildResult({ appName, label, live })`, fired by a build card when it has an app and again when its label or live state changes. Additive; hosts that do not pass it see no change.
+- **Listed materialized builds are live.** A build envelope that says `materialized: true` and `appHidden: false` (the app is already listed, ADR-0045 publish = live and visible) now reads "Built and live" on the card. The draft review carries `appHidden` when the envelope says it.
+- **Progress items.** `ChatBuildProgress.items` entries gain an optional `label`; the panel shows it in place of the machine name, and falls back to the name when a frame carries none.
+- **Footnote.** The finished card's posture line names Publish in the chat instead of Studio, in every shipped locale.
+- **i18n.** New key `console.ai.liveCanvasLive` in every locale; `chatbot.build.draftPosture` reworded.
diff --git a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx
index a0d8fd74206..829d1e06215 100644
--- a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx
+++ b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx
@@ -1831,9 +1831,26 @@ export function ChatPane({
: prev ?? { name: appName, materialized: true },
);
}, []);
+ // The chat's build card's own answer per built app (its label and whether it
+ // says the app is live), keyed by the app's machine name. The canvas header
+ // reads it instead of deriving a publish state of its own, so the two can
+ // never disagree, and it follows the card when the build is published.
+ const [buildResults, setBuildResults] = useState>({});
+ const handleBuildResult = useCallback(
+ ({ appName, label, live }: { appName: string; label?: string; live: boolean }) => {
+ setBuildResults((prev) => {
+ const cur = prev[appName];
+ if (cur && cur.label === label && cur.live === live) return prev;
+ return { ...prev, [appName]: { ...(label ? { label } : {}), live } };
+ });
+ },
+ [],
+ );
+ const canvasBuild = canvasApp ? buildResults[canvasApp.name] : undefined;
// A different conversation is a different build session — close the pane.
useEffect(() => {
setCanvasApp(null);
+ setBuildResults({});
}, [conversationId]);
const activeAgentLabel = useMemo(() => {
@@ -2895,6 +2912,7 @@ export function ChatPane({
// ADR-0045: build materialized → canvas leaves the draft overlay for
// the real (unlisted) app; the reload shows live seed rows.
onBuildMaterialized={handleBuildMaterialized}
+ onBuildResult={handleBuildResult}
// objectui#11666 — mirrored onto the assistant bus (effect above).
onPlanApprovalPendingChange={setPlanApprovalPending}
previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })}
@@ -2931,6 +2949,8 @@ export function ChatPane({
) {
+ render(
+
+
+ {}}
+ {...props}
+ />
+
+ ,
+ );
+ return screen.getByTestId('live-canvas').firstElementChild as HTMLElement;
+}
+
+describe('LiveCanvas header — the build card is the source (objectstack#26469)', () => {
+ it('a published build: the label, and no 未发布 badge', () => {
+ const header = renderZh({ appLabel: '家装项目管理', materialized: true, live: true });
+ expect(header).toHaveTextContent('运行预览 · 家装项目管理');
+ expect(header).not.toHaveTextContent('未发布');
+ expect(header).not.toHaveTextContent('home_renovation_management');
+ });
+
+ it('a materialized build the card does not call live keeps the 未发布 badge', () => {
+ const header = renderZh({ appLabel: '家装项目管理', materialized: true });
+ expect(header).toHaveTextContent('运行预览 · 家装项目管理(未发布 · 仅自己可见)');
+ });
+
+ it('without a label the header falls back to the machine name', () => {
+ const header = renderZh({ materialized: true, live: true });
+ expect(header).toHaveTextContent('运行预览 · home_renovation_management');
+ });
+
+ it('a draft preview stays a draft preview, by label', () => {
+ const header = renderZh({ appLabel: '家装项目管理' });
+ expect(header).toHaveTextContent('草稿预览 · 家装项目管理');
+ });
+});
diff --git a/ui/packages/app-shell/src/console/ai/LiveCanvas.tsx b/ui/packages/app-shell/src/console/ai/LiveCanvas.tsx
index a74bbf62556..9817608677b 100644
--- a/ui/packages/app-shell/src/console/ai/LiveCanvas.tsx
+++ b/ui/packages/app-shell/src/console/ai/LiveCanvas.tsx
@@ -26,8 +26,19 @@ import { Button } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
export interface LiveCanvasProps {
- /** The drafted app to render (its `app` metadata name) — used for display. */
+ /** The drafted app to render (its `app` metadata name). */
appName: string;
+ /**
+ * The app's display label, as the chat's build card names it
+ * (`checklist.app.label`). The header shows it in place of `appName`.
+ */
+ appLabel?: string;
+ /**
+ * The chat's build card says the app is live (published). The header then
+ * names the app as live and drops the unlisted note, whatever
+ * `materialized` says — the card is the one source of the publish state.
+ */
+ live?: boolean;
/**
* ADR-0048: the app's ROUTE SEGMENT — its package id (`app.`), which is
* globally unique, unlike the display name (two AI apps can both be `library`).
@@ -73,12 +84,22 @@ function canvasSrc(appName: string, materialized: boolean, path?: string): strin
return path ? `${spaBase()}${path}` : `${base}?preview=draft`;
}
-export function LiveCanvas({ appName, appSegment, materialized = false, path, refreshKey, onClose }: LiveCanvasProps) {
+export function LiveCanvas({
+ appName,
+ appLabel,
+ live = false,
+ appSegment,
+ materialized = false,
+ path,
+ refreshKey,
+ onClose,
+}: LiveCanvasProps) {
const { t } = useObjectTranslation();
const navigate = useNavigate();
const iframeRef = useRef(null);
// Route on the package-id segment (unique), display by name (friendly).
const routeSeg = appSegment && appSegment.length ? appSegment : appName;
+ const displayName = appLabel || appName;
// Materialized world swap: changing src on the SAME iframe element
// navigates it in place (no white-flash remount).
useEffect(() => {
@@ -108,15 +129,20 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
- {materialized
- ? t('console.ai.liveCanvasUnlisted', {
- app: appName,
- defaultValue: 'Live app — {{app}} (unlisted until published)',
+ {live
+ ? t('console.ai.liveCanvasLive', {
+ app: displayName,
+ defaultValue: 'Live app — {{app}}',
})
- : t('console.ai.liveCanvas', {
- app: appName,
- defaultValue: 'Live preview — {{app}} (draft)',
- })}
+ : materialized
+ ? t('console.ai.liveCanvasUnlisted', {
+ app: displayName,
+ defaultValue: 'Live app — {{app}} (unlisted until published)',
+ })
+ : t('console.ai.liveCanvas', {
+ app: displayName,
+ defaultValue: 'Live preview — {{app}} (draft)',
+ })}
{/* Bridge to the design surface — this pane is the RUNNING app, so the
builder needs an explicit way to jump into the Studio designer to
@@ -149,7 +175,7 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
diff --git a/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx
index 9b42c4ca23f..1a652280267 100644
--- a/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx
+++ b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx
@@ -207,7 +207,7 @@ describe('objectui#12724 — the finished build card', () => {
// The published envelope lifts no draft review; the package is its own.
expect(onDesignBuiltApp).toHaveBeenCalledWith('library_app', 'app.hcpl');
expect(screen.getByTestId('build-result-posture')).toHaveTextContent(
- '之后的修改先存草稿,由你在 Studio 发布。',
+ '之后的修改先存草稿,在对话里点「发布」即可上线。',
);
});
diff --git a/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildResultHeader-26469.test.tsx b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildResultHeader-26469.test.tsx
new file mode 100644
index 00000000000..934daa1e8db
--- /dev/null
+++ b/ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildResultHeader-26469.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * objectstack#26469 — the AI build page's progress panel and the build card's
+ * answer the Live Canvas header reads.
+ *
+ * 1. Progress items show the frame's `label` when it carries one and fall
+ * back to the machine name without it (the cloud half that adds `label`
+ * may land later; the panel must read right before and after).
+ * 2. `onBuildResult` hands the host the card's own answer — the app, the
+ * label its title names and whether its title says live — and fires again
+ * when that answer changes (the build is published).
+ * 3. A materialized build whose envelope says `appHidden: false` is listed,
+ * i.e. published, so the card reads live; `appHidden: true` does not.
+ * 4. The posture footnote names Publish in the chat, not Studio.
+ */
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { I18nProvider } from '@object-ui/i18n';
+import { ChatbotEnhanced, type ChatbotEnhancedMessage } from '../ChatbotEnhanced';
+import { detectDraftResult, uiMessageToChatMessage } from '../mapMessages';
+
+function inLocale(lang: 'zh' | 'en', ui: React.ReactElement) {
+ return (
+ {ui}
+ );
+}
+
+const building = (items: Array<{ type: string; name: string; label?: string }>): ChatbotEnhancedMessage => ({
+ id: 'building-1',
+ role: 'assistant',
+ content: '',
+ streaming: true,
+ buildProgress: { phase: 'structure', appLabel: '家装项目管理', items, done: items.length, total: 8 },
+});
+
+const items = [
+ { type: 'object', name: 'u9ba_customer' },
+ { type: 'app', name: 'home_renovation_management' },
+];
+
+/** A finished whole-app build; the envelope varies per case. */
+const built = (envelope: Record): ChatbotEnhancedMessage => {
+ const result = { packageId: 'app.u9ba', drafted: items, ...envelope };
+ const draftReview = detectDraftResult(result);
+ return {
+ id: 'built-1',
+ role: 'assistant',
+ content: '',
+ buildProgress: { phase: 'done', appLabel: '家装项目管理', items, done: 2, total: 2 },
+ toolInvocations: [
+ {
+ toolCallId: 'apply-1',
+ toolName: 'apply_blueprint',
+ state: 'output-available',
+ result,
+ ...(draftReview ? { draftReview } : {}),
+ },
+ ],
+ };
+};
+
+describe('objectstack#26469 — progress items show labels', () => {
+ it('a frame item with a label shows the label; one without falls back to the machine name', () => {
+ render(
+ ,
+ );
+ const panel = screen.getByTestId('build-progress');
+ expect(panel).toHaveTextContent('客户');
+ expect(panel).not.toHaveTextContent('u9ba_customer');
+ expect(panel).toHaveTextContent('u9ba_project.project_list');
+ });
+
+ it('the stream reader keeps an item label and leaves it off when the frame has none', () => {
+ const out = uiMessageToChatMessage({
+ id: 'm1',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'data-build-progress',
+ id: 'build-progress',
+ data: {
+ phase: 'structure',
+ items: [
+ { type: 'object', name: 'u9ba_customer', label: '客户' },
+ { type: 'object', name: 'u9ba_project' },
+ ],
+ },
+ },
+ ],
+ });
+ expect(out.buildProgress?.items).toEqual([
+ { type: 'object', name: 'u9ba_customer', label: '客户' },
+ { type: 'object', name: 'u9ba_project' },
+ ]);
+ });
+});
+
+describe('objectstack#26469 — the card hands its answer to the host', () => {
+ it('a published build reports its label and live', () => {
+ const onBuildResult = vi.fn();
+ render();
+ expect(onBuildResult).toHaveBeenLastCalledWith({
+ appName: 'home_renovation_management',
+ label: '家装项目管理',
+ live: true,
+ });
+ });
+
+ it('an unpublished build reports not live, and reports again once it is published', () => {
+ const onBuildResult = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ expect(onBuildResult).toHaveBeenLastCalledWith({
+ appName: 'home_renovation_management',
+ label: '家装项目管理',
+ live: false,
+ });
+ rerender();
+ expect(onBuildResult).toHaveBeenLastCalledWith({
+ appName: 'home_renovation_management',
+ label: '家装项目管理',
+ live: true,
+ });
+ });
+
+ it('the checklist label wins over the stream label, as on the card title', () => {
+ const onBuildResult = vi.fn();
+ const message = built({
+ status: 'published',
+ checklist: {
+ status: 'passed',
+ passed: 1,
+ total: 1,
+ app: { name: 'home_renovation_management', label: '家装项目' },
+ lines: [{ kind: 'object', status: 'passed', line: '对象 1 个' }],
+ },
+ });
+ render();
+ expect(screen.getByTestId('build-progress-title')).toHaveTextContent(/· 家装项目$/);
+ expect(onBuildResult).toHaveBeenLastCalledWith({
+ appName: 'home_renovation_management',
+ label: '家装项目',
+ live: true,
+ });
+ });
+});
+
+describe('objectstack#26469 — a listed materialized build is live', () => {
+ it('materialized with appHidden: false reads live on the card and in the report', () => {
+ const onBuildResult = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByTestId('build-progress-title')).toHaveTextContent(/^Built and live · 家装项目管理$/);
+ expect(onBuildResult).toHaveBeenLastCalledWith(expect.objectContaining({ live: true }));
+ });
+
+ it('materialized with appHidden: true is not live', () => {
+ const onBuildResult = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByTestId('build-progress-title')).toHaveTextContent(/^Built 家装项目管理$/);
+ expect(onBuildResult).toHaveBeenLastCalledWith(expect.objectContaining({ live: false }));
+ });
+
+ it('the draft review lifts appHidden only when the envelope says it', () => {
+ expect(detectDraftResult({ status: 'drafted', drafted: items, appHidden: false })?.appHidden).toBe(false);
+ expect(detectDraftResult({ status: 'drafted', drafted: items })).not.toHaveProperty('appHidden');
+ });
+});
+
+describe('objectstack#26469 — the posture footnote names Publish in the chat', () => {
+ it('zh', () => {
+ render(inLocale('zh', ));
+ expect(screen.getByTestId('build-result-posture')).toHaveTextContent('之后的修改先存草稿,在对话里点「发布」即可上线。');
+ });
+});
diff --git a/ui/packages/plugin-chatbot/src/mapMessages.ts b/ui/packages/plugin-chatbot/src/mapMessages.ts
index cbc1b70fbe0..6b3e8f8eb07 100644
--- a/ui/packages/plugin-chatbot/src/mapMessages.ts
+++ b/ui/packages/plugin-chatbot/src/mapMessages.ts
@@ -222,6 +222,12 @@ export interface DraftReview {
* open the REAL app URL, not the draft overlay.
*/
materialized?: boolean;
+ /**
+ * The build envelope's `appHidden`, beside `materialized`: `false` says
+ * the materialized app is already listed, i.e. published (ADR-0045
+ * publish = live AND visible). Absent when the envelope does not say.
+ */
+ appHidden?: boolean;
/**
* ADR-0038 L1 graph-lint verdict for the staged build. Rendered as a
* verified/issues chip so "drafted" and "verified" read as the two
@@ -1095,6 +1101,7 @@ function draftReviewOf(obj: Record): DraftReview | undefined {
// ADR-0045: the build was materialized in-turn (real tables + data, app
// hidden). The canvas then previews the REAL app URL, not the draft overlay.
...(obj.materialized === true ? { materialized: true } : {}),
+ ...(typeof obj.appHidden === 'boolean' ? { appHidden: obj.appHidden } : {}),
...(verification ? { verification } : {}),
...(issues.length ? { issues } : {}),
// objectui#10109 — the producer's incremental-edit marker rides the draft
@@ -1340,7 +1347,13 @@ function extractBuildProgress(parts: AnyPart[]): ChatBuildProgress | undefined {
const items = Array.isArray(d.items)
? (d.items as Array>)
.filter((i) => typeof i?.type === 'string' && typeof i?.name === 'string')
- .map((i) => ({ type: i.type as string, name: i.name as string }))
+ .map((i) => ({
+ type: i.type as string,
+ name: i.name as string,
+ // The item's display label, when the frame carries one; the panel
+ // falls back to the machine name without it.
+ ...(typeof i.label === 'string' && i.label ? { label: i.label } : {}),
+ }))
: [];
const phase = readBuildPhase(d.phase);
return {