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