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
15 changes: 15 additions & 0 deletions .changeset/12724-ai-build-progress-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': minor
'@object-ui/plugin-chatbot': minor
'@object-ui/i18n': minor
---

**The AI build conversation stays on the conversation and reads more compactly** (objectui#12724).

- **No automatic navigation after a build.** When a build finishes, the page no longer moves you into Studio. The finished build card offers two buttons: 打开应用 / Open app (primary), which opens the app on its first navigation item, and 在 Studio 中查看 / View in Studio. Both are in-app navigations. The conversation is still bound to the built app, so Studio's copilot resumes the same thread.
- **One collapsed line per phase.** A turn's tool activity reads as one line, for example "设计方案 · 4 步 · 0:48 · 读取编写规范 · 列出对象 ×3 …". Repeated calls of a tool fold into one step with a count, and a chevron expands the per-tool rows. The rows open on their own while a step is running. The elapsed time shows only for a turn this page watched; a reopened conversation shows no time.
- **The finished build card.** Its header reads 「已搭建并上线 · APP」 / "Built and live · APP" once the build is published, and "Built APP" otherwise. Below the buttons, a line lists the build's next steps as things you can say, followed by the sentence 「之后的修改先存草稿,由你在 Studio 发布」. The progress bar is gone once the build is done. A whole-app build no longer repeats its next steps as a list on the draft card.
- **The plan card.** The header names the app the plan builds and shows 等待确认 / Awaiting confirmation until you decide. Each object gets a row with one chip per field from the plan's `blueprint.objects[].fields`. The plan's views get their own rows, and the reply hint sits beside 开始搭建 / 调整方案. A plan without field rows (for example one restored from the local cache) shows the object chips with field counts, as before.
- **The composer.** Outside an app, the build agent's placeholder reads 「描述你想搭建的应用,或要改的地方…」 / "Describe the app you want to build, or what to change…" and no longer names the agent. Inside an app it still reads "Ask about your data, or ask me to change this app…". The key hint reads 「↵ 发送 · Shift+↵ 换行」.

`@object-ui/plugin-chatbot`: `ProposedPlan` gains optional `appLabel`, `views` and `objects[].fields`, and `ChatbotLabels` gains an optional `newlineHint`. `@object-ui/i18n`: new `console.ai.describeAppToBuild`, `console.ai.newlineHint`, `console.ai.viewBuiltAppInStudio`, `chatbot.phase.*`, `chatbot.plan.awaitingConfirm` / `objectsAndFields` / `views` and `chatbot.build.builtAndLive` / `nextStepsLead` / `nextStepQuote` / `draftPosture` in every pack. `console.ai.designBuiltApp` is retired.
92 changes: 34 additions & 58 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react';
import { useAdapter } from '../../providers/AdapterProvider.js';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { formatPublishFailures, type PublishFailure } from '../../views/studio-design/metadataError.js';
import { STUDIO_RUN_LANDING } from '../../views/studio-design/studioLanding.js';
import { useMetadataClient } from '../../views/metadata-admin/useMetadata.js';
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
import { resolveKeyedI18nLabel } from '../../utils/index.js';
Expand Down Expand Up @@ -2136,23 +2135,22 @@ export function ChatPane({
onPackageBound?.(boundPackageId);
}, [isBuildSurface, canBind, boundPackageId, editPackageId, isLoading, onPackageBound]);

// objectui#5799 — the built-moment transition (cloud#1609 增量一, maintainer
// form ruling: cold start keeps the full-page surface; the moment a WHOLE-APP
// build exists the conversation lives in the Studio workbench). Derived from
// the persisted draftReview envelopes (#2623 lesson: never canvasApp runtime
// state), so a REOPENED package conversation transitions too — that is the
// card's kill criterion, not an accident. The dock's 以完整页面打开 door sets
// a one-shot sessionStorage opt-out so the sanctioned way back to the full
// page is not bounced straight to Studio again. The pane remounts per
// conversation (its key carries the conversation id), so the fire-once ref
// naturally re-arms on a thread switch.
// objectui#12724 — a finished build STAYS on the conversation. The
// built-moment transition (objectui#5799) used to move the thread into the
// Studio workbench the moment a whole-app build existed, and objectui#11658
// pointed it at the running app; the maintainer's AiBuild ruling retires
// both: the result card's 打开应用 / 在 Studio 中查看 buttons are the only
// ways out, and the user picks one. What stays is the binding: the build's
// package re-keys this conversation to `app:<pkg>:build` (A1.b), the key the
// Studio dock resolves, so 在 Studio 中查看 resumes THIS thread. Derived from
// the persisted envelopes (#2623 lesson: never canvasApp runtime state), so a
// REOPENED built conversation binds too.
const builtPackageId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
for (const tool of messages[i].toolInvocations ?? []) {
// Posture-independent: read the raw envelope, not draftReview — an
// auto-publish env rewrites the build envelope to status:'published'
// and the drafted-only lift never fires there (measured live on
// staging: reopening a built conversation stayed on the full page).
// and the drafted-only lift never fires there.
const pkg = detectBuiltAppPackage(tool.result);
if (pkg) return pkg;
// objectui#10109 — an INCREMENTAL edit's draft review can list the `app`
Expand All @@ -2165,41 +2163,12 @@ export function ChatPane({
}
return undefined;
}, [messages]);
const builtTransitionFiredRef = useRef(false);
const paneNavigate = useNavigate();
useEffect(() => {
if (!isBuildSurface || isLoading || !canBind || !builtPackageId) return;
if (builtTransitionFiredRef.current) return;
builtTransitionFiredRef.current = true;
// The pane can remount several times for one arrival (pending→resolved id,
// re-key refetch), so a one-shot flag would be consumed by the first mount
// and the next would bounce anyway. The door's generic flag is converted
// into a STICKY per-conversation opt-out on first sight; later mounts of
// the same thread honor it, other threads transition normally.
let optedOut = false;
try {
const stickyKey = `objectstack:ai-full-page-opted:${conversationId ?? ''}`;
if (sessionStorage.getItem('objectstack:ai-full-page-requested') === '1') {
sessionStorage.removeItem('objectstack:ai-full-page-requested');
sessionStorage.setItem(stickyKey, '1');
optedOut = true;
} else if (sessionStorage.getItem(stickyKey) === '1') {
optedOut = true;
}
} catch {
/* storage unavailable → no opt-out */
}
if (optedOut) return;
// Bind first (idempotent): rekeyScope writes this conversation under the
// app:<pkg>:build cache key — the exact key the Studio dock resolves — so
// the workbench's right rail resumes THIS thread (A1.b machinery).
// Idempotent: the page's handler re-keys a product-only thread once and
// ignores every later call.
onPackageBound?.(builtPackageId);
// objectui#11658 — land on the RUNNING app (ADR-0080 preview-first): the
// Interfaces pillar opens on 「运行」 with its properties collapsed. The
// explicit "Design in Studio" door below passes no state and still lands
// on 「设计」.
paneNavigate(`/studio/${encodeURIComponent(builtPackageId)}/interfaces`, { state: STUDIO_RUN_LANDING });
}, [isBuildSurface, isLoading, canBind, builtPackageId, conversationId, onPackageBound, paneNavigate]);
}, [isBuildSurface, isLoading, canBind, builtPackageId, onPackageBound]);

// objectui#5801 — when a turn that STAGED or PUBLISHED something finishes,
// announce it on the bus so every pending-drafts surface (Studio topbar,
Expand Down Expand Up @@ -2495,12 +2464,16 @@ export function ChatPane({
// The generic "Ask {agent}…" doubles to "Ask Ask…" for the data-query
// agent whose label IS "Ask". Use its purpose-built placeholder instead.
? t('console.ai.askAnything')
// objectui#11658 — inside an app (the build agent scoped to a
// package: the console dock in a running app, the Studio dock,
// `/ai/build?package=`) the composer names what the user can do
// there, never the agent: "Ask Build…" read as「向 构建 提问…」.
: editing
? t('console.ai.askOrChangeApp')
// The build agent's composer names what the user can do, never
// the agent ("Ask Build…" read as「向 构建 提问…」). Inside an app
// (the build agent scoped to a package: the console dock in a
// running app, the Studio dock, `/ai/build?package=`) that is
// asking or changing the app (objectui#11658); outside one it is
// describing the app to build (objectui#12724).
: isBuildSurface
? editing
? t('console.ai.askOrChangeApp')
: t('console.ai.describeAppToBuild')
: t('console.ai.askAgent', { agent: activeAgentLabel })
: agentsLoading
? t('console.ai.loadingAgents')
Expand All @@ -2511,6 +2484,8 @@ export function ChatPane({
emptyDescription: emptyState.description,
clear: t('console.ai.clearConversation'),
sendHint: t('console.ai.sendHint'),
// objectui#12724 — the footer reads「↵ 发送 · Shift+↵ 换行」.
newlineHint: t('console.ai.newlineHint'),
agentActivity: t('console.ai.agentActivity'),
toolCompleted: t('console.ai.toolCompleted'),
toolRunning: t('console.ai.toolRunning'),
Expand Down Expand Up @@ -2581,21 +2556,22 @@ export function ChatPane({
// free text in any language into the same field) and by the model on
// its next turn. No code parses it, so it follows the UI locale.
toolDenyReason={t('console.ai.toolDenyReason')}
// Build-tree "Open app": jump straight into the app the agent just built.
// objectui#12724 — the finished build card's PRIMARY action: an in-app
// navigation into the app the agent just built, which lands on its
// first navigation item (`/apps/<app>`'s declared landing).
onOpenBuiltApp={(appName, appSegment) =>
navigate(`/apps/${encodeURIComponent(appSegment ?? appName)}`)}
openBuiltAppLabel={t('console.ai.openBuiltApp', { defaultValue: 'Open app' })}
// ADR-0080 D5 cold-start handoff — the PRIMARY action on a finished
// build: Studio is the built app's iteration home (direct edit + the
// same copilot conversation in the dock), so the flow's natural end
// is "step into Studio", not "leave for the published front-end".
// The package id is the canvas segment (one package = one app).
// The secondary door (在 Studio 中查看): Studio is the built app's
// iteration home (direct edit + the same copilot conversation in the
// dock, which the build's binding resumes). The package id is the
// canvas segment (one package = one app).
onDesignBuiltApp={(_appName, appSegment) => {
const pkg = appSegment ?? canvasApp?.segment;
if (!pkg) return;
navigate(`/studio/${encodeURIComponent(pkg)}/interfaces`);
}}
designBuiltAppLabel={t('console.ai.designBuiltApp', { defaultValue: 'Design in Studio' })}
designBuiltAppLabel={t('console.ai.viewBuiltAppInStudio', { defaultValue: 'View in Studio' })}
// Artifact deep links: every artifact the agent built gets a one-click
// path to where it can be edited BY HAND (object → Data, flow →
// Automations, dashboard/page/view/app → Interfaces). Returns null for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
* dock in a running app, the Studio dock, `/ai/build?package=`) used the
* generic "Ask {agent}…" placeholder, which a zh-CN user read as「向 构建 提问…」:
* the agent's name, not the task. Inside an app the placeholder names what the
* user can do there. The cold-start build surface (no package) and the ask
* agent keep the placeholders they had — they are the controls.
* user can do there. objectui#12724 extends that to the cold-start build
* surface (no package): it reads「描述你想搭建的应用,或要改的地方…」, and the
* composer footer gains the newline half of its key hint. The ask agent and
* an agent that is neither build nor ask keep their placeholders — they are
* the controls.
*
* Item 5 (host half): the plan card's extend-mode chip names the existing app
* by its label. ChatPane hands ChatbotEnhanced a resolver over the apps the
Expand Down Expand Up @@ -77,9 +80,10 @@ window.matchMedia = ((query: string) => ({
const AGENTS = [
{ name: 'build', label: 'Build' },
{ name: 'ask', label: 'Ask' },
{ name: 'analyst', label: 'Analyst' },
] as unknown as AgentDescriptor[];

function renderPane(uiLocale: string, opts: { agent: 'build' | 'ask'; editPackageId?: string }) {
function renderPane(uiLocale: string, opts: { agent: 'build' | 'ask' | 'analyst'; editPackageId?: string }) {
cleanup();
captured = {};
render(
Expand Down Expand Up @@ -136,9 +140,29 @@ describe('objectui#11658 item 2 — the in-app composer names the task, not the
expect(props.placeholder).toBe('Ask about your data, or ask me to change this app…');
});

it('control: the cold-start build surface (no app) keeps its placeholder', () => {
// objectui#12724 retired the old control here ("the cold-start build surface
// keeps 'Ask Build…'"): outside an app the build agent's composer names the
// task too. The third agent below is the control that keeps the template.
it('objectui#12724 zh: the build agent outside an app reads「描述你想搭建的应用,或要改的地方…」', () => {
const props = renderPane('zh', { agent: 'build' });
expect(props.placeholder).toBe('描述你想搭建的应用,或要改的地方…');
expect(String(props.placeholder)).not.toContain('构建');
});

it('objectui#12724 en: the build agent outside an app reads "Describe the app you want to build, or what to change…"', () => {
const props = renderPane('en', { agent: 'build' });
expect(props.placeholder).toBe('Ask Build…');
expect(props.placeholder).toBe('Describe the app you want to build, or what to change…');
expect(String(props.placeholder)).not.toContain('Build…');
});

it('control: an agent that is neither build nor ask keeps the "Ask {agent}…" template', () => {
expect(renderPane('en', { agent: 'analyst' }).placeholder).toBe('Ask Analyst…');
});

it('objectui#12724: the composer footer reads「↵ 发送 · Shift+↵ 换行」', () => {
const labels = renderPane('zh', { agent: 'build' }).labels as Record<string, unknown>;
expect(labels.sendHint).toBe('发送');
expect(labels.newlineHint).toBe('换行');
});

it('control: the ask agent keeps its own placeholder, app or not', () => {
Expand Down
Loading
Loading