Skip to content
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
17 changes: 17 additions & 0 deletions .changeset/26469-ai-build-canvas-header-labels.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions ui/packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, { label?: string; live: boolean }>>({});
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<string>(() => {
Expand Down Expand Up @@ -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' })}
Expand Down Expand Up @@ -2931,6 +2949,8 @@ export function ChatPane({
</div>
<LiveCanvas
appName={canvasApp!.name}
appLabel={canvasBuild?.label}
live={canvasBuild?.live}
appSegment={canvasApp!.segment}
materialized={canvasApp!.materialized}
path={canvasApp!.path}
Expand All @@ -2956,6 +2976,8 @@ export function ChatPane({
>
<LiveCanvas
appName={canvasApp.name}
appLabel={canvasBuild?.label}
live={canvasBuild?.live}
appSegment={canvasApp.segment}
materialized={canvasApp.materialized}
path={canvasApp.path}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LiveCanvasProps>) {
render(
<MemoryRouter>
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
<LiveCanvas
appName="home_renovation_management"
appSegment="app.u9ba"
refreshKey={0}
onClose={() => {}}
{...props}
/>
</I18nProvider>
</MemoryRouter>,
);
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('草稿预览 · 家装项目管理');
});
});
48 changes: 37 additions & 11 deletions ui/packages/app-shell/src/console/ai/LiveCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<slug>`), which is
* globally unique, unlike the display name (two AI apps can both be `library`).
Expand Down Expand Up @@ -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<HTMLIFrameElement | null>(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(() => {
Expand Down Expand Up @@ -108,15 +129,20 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
<Eye className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate">
{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)',
})}
</span>
{/* 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
Expand Down Expand Up @@ -149,7 +175,7 @@ export function LiveCanvas({ appName, appSegment, materialized = false, path, re
</div>
<iframe
ref={iframeRef}
title={`Draft preview: ${appName}`}
title={`Draft preview: ${displayName}`}
src={canvasSrc(routeSeg, materialized, path)}
className="h-full w-full flex-1 border-0 bg-background"
data-testid="live-canvas-frame"
Expand Down
Loading
Loading