Skip to content

Commit a89074a

Browse files
fix(app-shell,plugin-chatbot): AI build page preview header reads the build card's label and live state; progress items show labels (#26538)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9H8pxvmafNv5DS7U2grhcqZ)_ Fixes #26469 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:AI 构建页右侧预览的标题改用构建结果卡片同一份状态:显示应用名称(不再是机器名),卡片说已上线时去掉「未发布 · 仅自己可见」,发布后随之更新;进度列表有 label 时显示 label;卡片底部说明改为「在对话里点「发布」即可上线」。 - 为什么改:同一页面上结果卡片说「已搭建并上线」,预览标题却说「未发布」,让客户怀疑应用是否真的能用。 - 风险与回滚:只改 ui 显示逻辑,新增一个可选回调 `onBuildResult`;物化且 `appHidden: false` 的构建现在在卡片上也算已上线。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/console/ai/AiChatPage.tsx`(handleBuildResult)、`ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx`(BuildProgressPanel 的上报 effect 与 buildIsLive)、`ui/packages/app-shell/src/console/ai/LiveCanvas.tsx`(标题三分支)。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/app-shell/src/console/ai/LiveCanvas.header-26469.test.tsx packages/app-shell/src/console/ai/__tests__/AiChatPage.canvasHeader-26469.test.tsx`,预期 8 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **Preview header reads the build card (`LiveCanvas.tsx`, `AiChatPage.tsx`).** `LiveCanvas` takes `appLabel` and `live`. The header shows the label (falling back to the machine name), and when `live` is true it reads 「运行预览 · LABEL」 / "Live app — LABEL" (new key `console.ai.liveCanvasLive`), with no unlisted note whatever `materialized` says. A materialized build that is not live keeps 「(未发布 · 仅自己可见)」. `AiChatPage` keeps the card's answer per app name (`buildResults`), fed only by `onBuildResult`, and hands it to both canvas mounts (desktop split and mobile overlay). It is stored apart from `canvasApp` because the card's effect runs before its host's: on a reopened conversation the report arrives before `onBuildMaterialized` opens the canvas. Cleared with the canvas on a conversation change. - **The card hands its answer to the host (`ChatbotEnhanced.tsx`).** New optional prop `onBuildResult({ appName, label?, live })`, passed to `BuildProgressPanel`. The panel reports, from an effect, the very values its title renders: `builtApp.name`, `checklist.app.label ?? appLabel`, and `isDone && live`. It fires again whenever one of them changes, which is how the header follows a publish. No second state derivation: the header shows what the card shows. - **A listed materialized build is live (`ChatbotEnhanced.tsx` `buildIsLive`, `mapMessages.ts`).** `DraftReview` lifts the envelope's `appHidden` when it is a boolean. `buildIsLive` also holds when the draft review says `materialized: true` and `appHidden: false` (ADR-0045: publish = live AND visible). `appHidden: true` adds nothing. What else reads `buildIsLive`: only the finished card's `live` prop (its 「已搭建并上线」 vs 「已搭建」 title) and, through the panel, the new `onBuildResult` report. Nothing else in the file reads it. - **Progress items show labels (`mapMessages.ts`, `ChatbotEnhanced.tsx`).** `extractBuildProgress` keeps a non-empty string `label` on an item; `ChatBuildProgress.items` gains the optional `label`; the panel renders `label` and falls back to the `_sample`-stripped name. Until objectstack-ai/cloud#3310 adds `label` to the frames, the panel looks as before. - **Footnote (`chatbot.build.draftPosture`, every locale).** Names Publish in the chat instead of Studio: zh 「之后的修改先存草稿,在对话里点「发布」即可上线。」, en "Later changes are saved as drafts; click Publish here in the chat to make them live." Each locale quotes its own `console.ai.publishDrafts` label. - Root changeset `.changeset/26469-ai-build-canvas-header-labels.md`: `@object-ui/app-shell`, `@object-ui/plugin-chatbot`, `@object-ui/i18n`, patch. The draft tool card's 「发布 (N)」 block (shared with #26471) is untouched. ## Tests New: - `plugin-chatbot/src/__tests__/ChatbotEnhanced.buildResultHeader-26469.test.tsx` (9): a labelled item shows its label and an unlabelled one its name; the stream reader keeps `label`; a published build reports `{label, live: true}`; a drafted build reports `live: false` and reports `live: true` after a rerender into published; the checklist label wins as on the title; materialized + `appHidden: false` reads 「Built and live」 and reports live; materialized + `appHidden: true` is not live; `detectDraftResult` lifts `appHidden` only when present; the zh footnote. - `app-shell/src/console/ai/LiveCanvas.header-26469.test.tsx` (4, shipped zh pack): published → label, no 未发布; materialized not live → badge; no label → machine name; draft preview by label. - `app-shell/src/console/ai/__tests__/AiChatPage.canvasHeader-26469.test.tsx` (4): materialized + card not live → canvas props `materialized`, not live, label; card turns live → canvas turns live; a report before the canvas opens still reaches it; another app's report leaves the canvas alone. Forced test edits: - `plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx` pinned the old zh posture sentence (「之后的修改先存草稿,由你在 Studio 发布。」); it now pins the new one. - `i18n/src/__tests__/de-quote-pairing-3876.test.ts` counts the de pack's correctly paired 「„…“」 spans; the new de `draftPosture` adds one (the chat's „Veröffentlichen“ button), so its two pins move 96 → 97 with a line in each running tally, as every earlier addition did. ## Acceptance notes - The header follows the card's `live`, which is the existing result-card signal (published envelope, the package's pending-draft count reaching 0, this chat's publish signal) plus the new listed-materialized arm. When the card says live but the build was not materialized (an auto-publish environment), the iframe still opens the draft overlay (`?preview=draft`) as before; only the header text changed. With no pending drafts the overlay shows the published app. - The cloud half (frames carrying `label`, the cloud posture strings) is objectstack-ai/cloud#3310. Nothing here fakes it. ## Verification Everything at head `af7a3dae`, branched off `origin/main` `00a4593a`; no file of this PR moved on `main` since (checked against `origin/main` before opening). <details><summary>Targeted vitest (7 files, 90 tests)</summary> `cd ui && pnpm exec vitest run --maxWorkers=2` over the three new files plus `ChatbotEnhanced.buildChat-12724.test.tsx`, `mapMessages.test.ts`, `LiveCanvas.test.tsx`, `AiChatPage.previewDraft-12968.test.tsx`: `Test Files 7 passed (7)`, `Tests 90 passed (90)`, lock VERDICT command-exit 0. </details> <details><summary>Package suites</summary> - `pnpm --filter @object-ui/plugin-chatbot test --maxWorkers=2`: 85 files, 814 tests passed. - `pnpm --filter @object-ui/i18n test --maxWorkers=2`: 87 files passed; 1353 tests passed, 13 skipped. - `cd ui && pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/console/ai/`: 45 files, 245 tests passed. The whole app-shell suite (about 1,700 files) is left to CI `Test Core` / `Test UI`; this is a declared narrowing to the directory the change lives in. </details> <details><summary>Ablations (each restored, blob == HEAD, `git diff HEAD` empty)</summary> All through `scripts/ablation-replace.mjs` (anchor 1 → 0 on disk); ui vitest resolves `@object-ui/*` to `src/`, so no build sits between mutation and run. 1. `AiChatPage.tsx` `handleBuildResult` stores `live: false`: `AiChatPage.canvasHeader-26469` 2 failed / 2 passed (the publish-update and report-before-open cases). 2. `LiveCanvas.tsx` `displayName = appName` (label ignored): `LiveCanvas.header-26469` 3 failed / 1 passed. 3. `LiveCanvas.tsx` live branch forced off: `LiveCanvas.header-26469` 1 failed / 3 passed (the published case). 4. `ChatbotEnhanced.tsx` `buildIsLive` listed-materialized arm never matches: `ChatbotEnhanced.buildResultHeader-26469` 1 failed / 8 passed (`appHidden: false` reads live). </details> <details><summary>Derived gates (`dispatch-gates --commands`, 35)</summary> All 35 at `af7a3dae`, each `exit 0` (codes captured before any pipe); `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED (a derived zero). `pnpm --dir ui exec vitest run scripts/`: 178 files passed, 2 skipped; 5097 tests passed. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` Also run (outside the derived list): `ui` `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:i18n-designer-parity`, each exit 0. </details> <details><summary>Type check</summary> `pnpm --filter @object-ui/plugin-chatbot --filter @object-ui/i18n --filter @object-ui/app-shell run type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json` per package) after `turbo run build --filter='@object-ui/app-shell^...'`: lock VERDICT command-exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQw9ePD2KCzPfJGa6p)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7600209 commit a89074a

20 files changed

Lines changed: 624 additions & 30 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/plugin-chatbot': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
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
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **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.
13+
- **`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.
14+
- **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.
15+
- **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.
16+
- **Footnote.** The finished card's posture line names Publish in the chat instead of Studio, in every shipped locale.
17+
- **i18n.** New key `console.ai.liveCanvasLive` in every locale; `chatbot.build.draftPosture` reworded.

‎ui/packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,9 +1831,26 @@ export function ChatPane({
18311831
: prev ?? { name: appName, materialized: true },
18321832
);
18331833
}, []);
1834+
// The chat's build card's own answer per built app (its label and whether it
1835+
// says the app is live), keyed by the app's machine name. The canvas header
1836+
// reads it instead of deriving a publish state of its own, so the two can
1837+
// never disagree, and it follows the card when the build is published.
1838+
const [buildResults, setBuildResults] = useState<Record<string, { label?: string; live: boolean }>>({});
1839+
const handleBuildResult = useCallback(
1840+
({ appName, label, live }: { appName: string; label?: string; live: boolean }) => {
1841+
setBuildResults((prev) => {
1842+
const cur = prev[appName];
1843+
if (cur && cur.label === label && cur.live === live) return prev;
1844+
return { ...prev, [appName]: { ...(label ? { label } : {}), live } };
1845+
});
1846+
},
1847+
[],
1848+
);
1849+
const canvasBuild = canvasApp ? buildResults[canvasApp.name] : undefined;
18341850
// A different conversation is a different build session — close the pane.
18351851
useEffect(() => {
18361852
setCanvasApp(null);
1853+
setBuildResults({});
18371854
}, [conversationId]);
18381855

18391856
const activeAgentLabel = useMemo<string>(() => {
@@ -2896,6 +2913,7 @@ export function ChatPane({
28962913
// ADR-0045: build materialized → canvas leaves the draft overlay for
28972914
// the real (unlisted) app; the reload shows live seed rows.
28982915
onBuildMaterialized={handleBuildMaterialized}
2916+
onBuildResult={handleBuildResult}
28992917
// objectui#11666 — mirrored onto the assistant bus (effect above).
29002918
onPlanApprovalPendingChange={setPlanApprovalPending}
29012919
previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })}
@@ -2932,6 +2950,8 @@ export function ChatPane({
29322950
</div>
29332951
<LiveCanvas
29342952
appName={canvasApp!.name}
2953+
appLabel={canvasBuild?.label}
2954+
live={canvasBuild?.live}
29352955
appSegment={canvasApp!.segment}
29362956
materialized={canvasApp!.materialized}
29372957
path={canvasApp!.path}
@@ -2957,6 +2977,8 @@ export function ChatPane({
29572977
>
29582978
<LiveCanvas
29592979
appName={canvasApp.name}
2980+
appLabel={canvasBuild?.label}
2981+
live={canvasBuild?.live}
29602982
appSegment={canvasApp.segment}
29612983
materialized={canvasApp.materialized}
29622984
path={canvasApp.path}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#26469 — the Live Canvas header names the app by the label the
6+
* chat's build card uses and says what the card says about publishing: a
7+
* build the card calls live carries no 「未发布 · 仅自己可见」, a materialized
8+
* build the card does not call live keeps it. Rendered through the shipped zh
9+
* pack, since the wording is the point.
10+
*/
11+
import '@testing-library/jest-dom/vitest';
12+
import { describe, it, expect } from 'vitest';
13+
import { render, screen } from '@testing-library/react';
14+
import { MemoryRouter } from 'react-router-dom';
15+
import { I18nProvider } from '@object-ui/i18n';
16+
import { LiveCanvas, type LiveCanvasProps } from './LiveCanvas';
17+
18+
function renderZh(props: Partial<LiveCanvasProps>) {
19+
render(
20+
<MemoryRouter>
21+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
22+
<LiveCanvas
23+
appName="home_renovation_management"
24+
appSegment="app.u9ba"
25+
refreshKey={0}
26+
onClose={() => {}}
27+
{...props}
28+
/>
29+
</I18nProvider>
30+
</MemoryRouter>,
31+
);
32+
return screen.getByTestId('live-canvas').firstElementChild as HTMLElement;
33+
}
34+
35+
describe('LiveCanvas header — the build card is the source (objectstack#26469)', () => {
36+
it('a published build: the label, and no 未发布 badge', () => {
37+
const header = renderZh({ appLabel: '家装项目管理', materialized: true, live: true });
38+
expect(header).toHaveTextContent('运行预览 · 家装项目管理');
39+
expect(header).not.toHaveTextContent('未发布');
40+
expect(header).not.toHaveTextContent('home_renovation_management');
41+
});
42+
43+
it('a materialized build the card does not call live keeps the 未发布 badge', () => {
44+
const header = renderZh({ appLabel: '家装项目管理', materialized: true });
45+
expect(header).toHaveTextContent('运行预览 · 家装项目管理(未发布 · 仅自己可见)');
46+
});
47+
48+
it('without a label the header falls back to the machine name', () => {
49+
const header = renderZh({ materialized: true, live: true });
50+
expect(header).toHaveTextContent('运行预览 · home_renovation_management');
51+
});
52+
53+
it('a draft preview stays a draft preview, by label', () => {
54+
const header = renderZh({ appLabel: '家装项目管理' });
55+
expect(header).toHaveTextContent('草稿预览 · 家装项目管理');
56+
});
57+
});

‎ui/packages/app-shell/src/console/ai/LiveCanvas.tsx‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,19 @@ import { Button } from '@object-ui/components';
2626
import { useObjectTranslation } from '@object-ui/i18n';
2727

2828
export interface LiveCanvasProps {
29-
/** The drafted app to render (its `app` metadata name) — used for display. */
29+
/** The drafted app to render (its `app` metadata name). */
3030
appName: string;
31+
/**
32+
* The app's display label, as the chat's build card names it
33+
* (`checklist.app.label`). The header shows it in place of `appName`.
34+
*/
35+
appLabel?: string;
36+
/**
37+
* The chat's build card says the app is live (published). The header then
38+
* names the app as live and drops the unlisted note, whatever
39+
* `materialized` says — the card is the one source of the publish state.
40+
*/
41+
live?: boolean;
3142
/**
3243
* ADR-0048: the app's ROUTE SEGMENT — its package id (`app.<slug>`), which is
3344
* 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
7384
return path ? `${spaBase()}${path}` : `${base}?preview=draft`;
7485
}
7586

76-
export function LiveCanvas({ appName, appSegment, materialized = false, path, refreshKey, onClose }: LiveCanvasProps) {
87+
export function LiveCanvas({
88+
appName,
89+
appLabel,
90+
live = false,
91+
appSegment,
92+
materialized = false,
93+
path,
94+
refreshKey,
95+
onClose,
96+
}: LiveCanvasProps) {
7797
const { t } = useObjectTranslation();
7898
const navigate = useNavigate();
7999
const iframeRef = useRef<HTMLIFrameElement | null>(null);
80100
// Route on the package-id segment (unique), display by name (friendly).
81101
const routeSeg = appSegment && appSegment.length ? appSegment : appName;
102+
const displayName = appLabel || appName;
82103
// Materialized world swap: changing src on the SAME iframe element
83104
// navigates it in place (no white-flash remount).
84105
useEffect(() => {
@@ -108,15 +129,20 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
108129
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
109130
<Eye className="h-3.5 w-3.5" />
110131
<span className="min-w-0 flex-1 truncate">
111-
{materialized
112-
? t('console.ai.liveCanvasUnlisted', {
113-
app: appName,
114-
defaultValue: 'Live app — {{app}} (unlisted until published)',
132+
{live
133+
? t('console.ai.liveCanvasLive', {
134+
app: displayName,
135+
defaultValue: 'Live app — {{app}}',
115136
})
116-
: t('console.ai.liveCanvas', {
117-
app: appName,
118-
defaultValue: 'Live preview — {{app}} (draft)',
119-
})}
137+
: materialized
138+
? t('console.ai.liveCanvasUnlisted', {
139+
app: displayName,
140+
defaultValue: 'Live app — {{app}} (unlisted until published)',
141+
})
142+
: t('console.ai.liveCanvas', {
143+
app: displayName,
144+
defaultValue: 'Live preview — {{app}} (draft)',
145+
})}
120146
</span>
121147
{/* Bridge to the design surface — this pane is the RUNNING app, so the
122148
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
149175
</div>
150176
<iframe
151177
ref={iframeRef}
152-
title={`Draft preview: ${appName}`}
178+
title={`Draft preview: ${displayName}`}
153179
src={canvasSrc(routeSeg, materialized, path)}
154180
className="h-full w-full flex-1 border-0 bg-background"
155181
data-testid="live-canvas-frame"

0 commit comments

Comments
 (0)