From 34e0ca6f605e5919b4a3c56cc18512a3f5d5c2bd Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 05:33:11 +0000
Subject: [PATCH 1/4] fix(ui): AI build page preview header reads the build
card's label and live state; progress shows labels
Claude-Session: https://claude.ai/code/session_01ADHkmQw9ePD2KCzPfJGa6p
Co-authored-by: Claude
---
.../app-shell/src/console/ai/AiChatPage.tsx | 22 +++++++++
.../app-shell/src/console/ai/LiveCanvas.tsx | 48 ++++++++++++++-----
ui/packages/i18n/src/locales/ar.ts | 3 +-
ui/packages/i18n/src/locales/de.ts | 3 +-
ui/packages/i18n/src/locales/en.ts | 3 +-
ui/packages/i18n/src/locales/es.ts | 3 +-
ui/packages/i18n/src/locales/fr.ts | 3 +-
ui/packages/i18n/src/locales/ja.ts | 3 +-
ui/packages/i18n/src/locales/ko.ts | 3 +-
ui/packages/i18n/src/locales/pt.ts | 3 +-
ui/packages/i18n/src/locales/ru.ts | 3 +-
ui/packages/i18n/src/locales/zh.ts | 3 +-
.../plugin-chatbot/src/ChatbotEnhanced.tsx | 44 +++++++++++++++--
ui/packages/plugin-chatbot/src/mapMessages.ts | 15 +++++-
14 files changed, 133 insertions(+), 26 deletions(-)
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({
`), 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/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 {
From d8812ce1d55e322a48dce3e39c4fe90f8b740eb6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 05:34:47 +0000
Subject: [PATCH 2/4] test(ui): pin the canvas header, progress labels and
listed-build live state
Claude-Session: https://claude.ai/code/session_01ADHkmQw9ePD2KCzPfJGa6p
Co-authored-by: Claude
---
.../ai/LiveCanvas.header-26469.test.tsx | 57 +++++
.../AiChatPage.canvasHeader-26469.test.tsx | 213 ++++++++++++++++++
.../ChatbotEnhanced.buildChat-12724.test.tsx | 2 +-
...tEnhanced.buildResultHeader-26469.test.tsx | 196 ++++++++++++++++
4 files changed, 467 insertions(+), 1 deletion(-)
create mode 100644 ui/packages/app-shell/src/console/ai/LiveCanvas.header-26469.test.tsx
create mode 100644 ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.canvasHeader-26469.test.tsx
create mode 100644 ui/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildResultHeader-26469.test.tsx
diff --git a/ui/packages/app-shell/src/console/ai/LiveCanvas.header-26469.test.tsx b/ui/packages/app-shell/src/console/ai/LiveCanvas.header-26469.test.tsx
new file mode 100644
index 00000000000..a919d789aab
--- /dev/null
+++ b/ui/packages/app-shell/src/console/ai/LiveCanvas.header-26469.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * objectstack#26469 — the Live Canvas header names the app by the label the
+ * chat's build card uses and says what the card says about publishing: a
+ * build the card calls live carries no 「未发布 · 仅自己可见」, a materialized
+ * build the card does not call live keeps it. Rendered through the shipped zh
+ * pack, since the wording is the point.
+ */
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { I18nProvider } from '@object-ui/i18n';
+import { LiveCanvas, type LiveCanvasProps } from './LiveCanvas';
+
+function renderZh(props: Partial) {
+ 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/__tests__/AiChatPage.canvasHeader-26469.test.tsx b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.canvasHeader-26469.test.tsx
new file mode 100644
index 00000000000..6efdae3c471
--- /dev/null
+++ b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.canvasHeader-26469.test.tsx
@@ -0,0 +1,213 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26469 — the Live Canvas header reads the chat's build card.
+ *
+ * The header named the app by its machine name and said 「未发布 · 仅自己可见」
+ * for a build the card beside it called 「已搭建并上线」, and it never
+ * changed. The console host now hands the canvas the card's own answer,
+ * reported through `onBuildResult`: the label the card names the app by and
+ * whether the card says it is live — and follows it when that changes.
+ *
+ * The chat is faked so the test drives the two callbacks the real
+ * `ChatbotEnhanced` calls (the card's report is pinned in
+ * `ChatbotEnhanced.buildResultHeader-26469.test.tsx`); the Live Canvas is
+ * faked so its props are readable. Same harness as
+ * `AiChatPage.previewDraft-12968.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 Report = (result: { appName: string; label?: string; live: boolean }) => void;
+const APP = 'home_renovation_management';
+
+vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return {
+ ...actual,
+ 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 materialized = props.onBuildMaterialized as ((app: string) => void) | undefined;
+ const report = props.onBuildResult as Report | undefined;
+ return (
+
+
+ );
+ },
+ };
+});
+vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useAgents: () => ({
+ agents: [{ name: 'metadata_assistant', label: 'Build', capabilities: ['build'] }],
+ loading: false,
+ error: undefined,
+ refetch: vi.fn(),
+ }),
+}));
+
+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; appLabel?: string; live?: boolean; materialized?: boolean }) => (
+
+ ),
+}));
+
+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();
+});
+
+async function openPane() {
+ renderPage();
+ await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 });
+}
+
+describe('AiChatPage — the canvas header follows the build card (objectstack#26469)', () => {
+ it('a materialized build the card calls built (not live) keeps the unlisted header, by label', async () => {
+ await openPane();
+ fireEvent.click(screen.getByTestId('materialize'));
+ fireEvent.click(screen.getByTestId('report-built'));
+ const canvas = await screen.findByTestId('live-canvas');
+ expect(canvas).toHaveAttribute('data-app', APP);
+ expect(canvas).toHaveAttribute('data-label', '家装项目管理');
+ expect(canvas).toHaveAttribute('data-materialized', 'true');
+ expect(canvas).toHaveAttribute('data-live', 'false');
+ });
+
+ it('the header turns live when the card does — it updates on publish', async () => {
+ await openPane();
+ fireEvent.click(screen.getByTestId('materialize'));
+ fireEvent.click(screen.getByTestId('report-built'));
+ expect(await screen.findByTestId('live-canvas')).toHaveAttribute('data-live', 'false');
+ fireEvent.click(screen.getByTestId('report-live'));
+ expect(screen.getByTestId('live-canvas')).toHaveAttribute('data-live', 'true');
+ });
+
+ it('a report that arrives before the canvas opens still reaches the header', async () => {
+ await openPane();
+ // The card's effect runs before its host's (children first), so on a
+ // reopened conversation the report precedes the materialized signal.
+ fireEvent.click(screen.getByTestId('report-live'));
+ fireEvent.click(screen.getByTestId('materialize'));
+ const canvas = await screen.findByTestId('live-canvas');
+ expect(canvas).toHaveAttribute('data-live', 'true');
+ expect(canvas).toHaveAttribute('data-label', '家装项目管理');
+ });
+
+ it("another app's report leaves this canvas alone", async () => {
+ await openPane();
+ fireEvent.click(screen.getByTestId('materialize'));
+ fireEvent.click(screen.getByTestId('report-other'));
+ const canvas = await screen.findByTestId('live-canvas');
+ expect(canvas).toHaveAttribute('data-label', '');
+ expect(canvas).toHaveAttribute('data-live', 'false');
+ });
+});
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('之后的修改先存草稿,在对话里点「发布」即可上线。');
+ });
+});
From 8bb91c2a46a46583e49b22e97326eb2dcecca43b Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 05:35:11 +0000
Subject: [PATCH 3/4] chore(changeset): AI build page header and progress
labels
Claude-Session: https://claude.ai/code/session_01ADHkmQw9ePD2KCzPfJGa6p
Co-authored-by: Claude
---
.../26469-ai-build-canvas-header-labels.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 .changeset/26469-ai-build-canvas-header-labels.md
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.
From af7a3daeee0c6ad36f6160650c24af40382a7ca5 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 06:33:24 +0000
Subject: [PATCH 4/4] test(i18n): the de pack gains one paired quote span in
draftPosture
Claude-Session: https://claude.ai/code/session_01ADHkmQw9ePD2KCzPfJGa6p
Co-authored-by: Claude
---
.../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
index a7375a20bae..3fe52c09cfa 100644
--- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
+++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
@@ -354,7 +354,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// 96 once objectstack-ai/objectstack#26172 added `form.createdNamed` and
// `.updatedNamed`, the record-save toasts that name the record
// („{{title}}“) — one interpolated span each, runtime data.
- expect(okSpans, 'correctly paired spans').toBe(96);
+ // 97 once objectstack-ai/objectstack#26469 reworded
+ // `chatbot.build.draftPosture` to name the chat's „Veröffentlichen“ button.
+ expect(okSpans, 'correctly paired spans').toBe(97);
});
it('keeps the count identity that replaces the card’s count(„) === count(“)', () => {
@@ -418,8 +420,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// more matched pair. 94 / 94 / 0 after objectui#13355 added
// `connectAgent.apiKey.confirmTitle`, one more matched pair. 96 / 96 / 0
// after objectstack-ai/objectstack#26172 added `form.createdNamed` and
- // `.updatedNamed`, one matched pair each.
- expect({ open, close, rdq }).toEqual({ open: 96, close: 96, rdq: 0 });
+ // `.updatedNamed`, one matched pair each. 97 / 97 / 0 after
+ // objectstack-ai/objectstack#26469 reworded `chatbot.build.draftPosture`,
+ // one more matched pair.
+ expect({ open, close, rdq }).toEqual({ open: 97, close: 97, rdq: 0 });
// The durable shape: every „ closed by a “, every surplus “ an English
// opener answered by a ”. Survived translating the two English values.
expect(close).toBe(open + rdq);