From 59f839672db71b1e135c9674fcbc9bffdb743345 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:31:55 +0000 Subject: [PATCH 1/3] feat(ai-build): one line per phase, a result card with its two doors, no navigation after a build Part of objectui#12724, the halves that need no new payload. - AiChatPage: a finished build no longer navigates to Studio (the objectui#5799 transition and its objectui#11658 run landing retire); the A1.b binding stays, so the Studio dock resumes the thread. The build agent's placeholder outside an app describes the task, and the composer footer adds the newline hint. - ChatbotEnhanced: a turn's tool activity collapses to one line per phase with folded repeats, the step count and the measured elapsed time; the chevron expands the rows. The finished build card says built-and-live only on a publish signal, puts Open app first and View in Studio second, and closes with the next-steps line and the draft posture sentence. The plan card names the app, draws one chip per declared field and one row per declared view, and degrades to the object chips without field rows. - mapMessages: ProposedPlan lifts the spec-declared field and view names and labels and the app label. - i18n: the new keys in every pack; console.ai.designBuiltApp retires. Claude-Session: https://claude.ai/code/session_01XfuGMLXaKo5XaZhNvUTbDL Co-authored-by: Claude --- .changeset/12724-ai-build-progress-result.md | 15 + .../app-shell/src/console/ai/AiChatPage.tsx | 92 ++-- ...ChatPage.appContextComposer-11658.test.tsx | 34 +- .../AiChatPage.builtTransition.test.tsx | 374 --------------- ...age.noNavigationAfterBuild-12724.test.tsx} | 165 +++++-- .../__tests__/de-quote-pairing-3876.test.ts | 10 +- .../perm-home-namespace-3546.test.tsx | 12 +- .../untranslated-identity-4376.test.ts | 1 + packages/i18n/src/locales/ar.ts | 24 +- packages/i18n/src/locales/de.ts | 20 +- packages/i18n/src/locales/en.ts | 20 +- packages/i18n/src/locales/es.ts | 21 +- packages/i18n/src/locales/fr.ts | 21 +- packages/i18n/src/locales/ja.ts | 20 +- packages/i18n/src/locales/ko.ts | 20 +- packages/i18n/src/locales/pt.ts | 21 +- packages/i18n/src/locales/ru.ts | 22 +- packages/i18n/src/locales/zh.ts | 20 +- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 451 +++++++++++++++--- .../ChatbotEnhanced.buildChat-12724.test.tsx | 297 ++++++++++++ .../src/__tests__/ChatbotEnhanced.test.tsx | 13 +- .../src/__tests__/mapMessages.test.ts | 64 ++- packages/plugin-chatbot/src/mapMessages.ts | 48 +- 23 files changed, 1220 insertions(+), 565 deletions(-) create mode 100644 .changeset/12724-ai-build-progress-result.md delete mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.builtTransition.test.tsx rename packages/app-shell/src/console/ai/__tests__/{AiChatPage.builtLandsRunning-11658.test.tsx => AiChatPage.noNavigationAfterBuild-12724.test.tsx} (50%) create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx diff --git a/.changeset/12724-ai-build-progress-result.md b/.changeset/12724-ai-build-progress-result.md new file mode 100644 index 0000000000..44de50d9a9 --- /dev/null +++ b/.changeset/12724-ai-build-progress-result.md @@ -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. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 8a2881d96c..45a4bfe4ce 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -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'; @@ -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::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` @@ -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::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, @@ -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') @@ -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'), @@ -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/`'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 diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.appContextComposer-11658.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.appContextComposer-11658.test.tsx index 126a7a4565..8423a08e4a 100644 --- a/packages/app-shell/src/console/ai/__tests__/AiChatPage.appContextComposer-11658.test.tsx +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.appContextComposer-11658.test.tsx @@ -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 @@ -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( @@ -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; + expect(labels.sendHint).toBe('发送'); + expect(labels.newlineHint).toBe('换行'); }); it('control: the ask agent keeps its own placeholder, app or not', () => { diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtTransition.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtTransition.test.tsx deleted file mode 100644 index 43d544806f..0000000000 --- a/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtTransition.test.tsx +++ /dev/null @@ -1,374 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * objectui#5799 — the built-moment transition (cloud#1609 增量一). - * - * The maintainer's form ruling: cold start keeps the full-page build surface; - * the moment a WHOLE-APP build exists, the conversation lives in the Studio - * workbench. The pins: - * - * 1. REOPENING a conversation that already built an app lands in - * `/studio//interfaces` — the kill criterion ("no path stays on the - * full-page chat after the build"), driven from the persisted draftReview - * envelope (#2623 lesson: never runtime canvas state). - * 2. A LIVE build completion transitions when the turn settles. - * 3. The Studio dock's 以完整页面打开 door is the ONE sanctioned way back: - * its one-shot sessionStorage opt-out keeps the arrival on the full page - * instead of bouncing straight back to Studio. - * 4. objectui#10109 — an INCREMENTAL edit is not a whole-app build. An - * `apply_edit` envelope says `kind: 'edit'` and may list the `app` it - * re-staged (an `add_object` op merges the nav); neither reopening nor a - * live completion of such an edit moves the conversation to Studio, in - * either posture. The `apply_blueprint` pins above are the controls. - * - * Same faked-chat harness as AiChatPage.buildHistorySurvives.test.tsx: the - * hook instance owns the thread; hydration (the REAL AiChatPage path) derives - * draftReview from the persisted tool envelope. - */ - -import '@testing-library/jest-dom/vitest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import React from 'react'; - -interface FakeMsg { - id: string; - role: string; - content: string; - toolInvocations?: unknown[]; -} - -const chat = { - isLoading: false, - append: undefined as ((m: FakeMsg) => void) | undefined, -}; - -vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { - const actual = await importOriginal>(); - const React2 = await import('react'); - return { - ...actual, - useAgents: () => ({ - agents: [{ name: 'metadata_assistant', label: 'Build', capabilities: ['build'] }], - loading: false, - error: undefined, - refetch: vi.fn(), - }), - useAiModels: () => ({ models: [], defaultModelId: undefined }), - useHitlInChat: () => ({ decide: vi.fn(), decisions: {} }), - useObjectChat: (opts: { initialMessages?: FakeMsg[] }) => { - const [messages, setMessages] = React2.useState( - () => (opts.initialMessages ?? []) as FakeMsg[], - ); - chat.append = (m: FakeMsg) => setMessages((prev) => [...prev, m]); - return { - messages, - isLoading: chat.isLoading, - error: undefined, - sendMessage: vi.fn(), - stop: vi.fn(), - reload: vi.fn(), - clear: vi.fn(), - setMessages: vi.fn(), - }; - }, - ChatbotEnhanced: (props: Record) => { - const msgs = (props.messages ?? []) as FakeMsg[]; - return
{msgs.map((m) => m.id).join(',')}
; - }, - }; -}); - -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: () =>
, -})); - -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; - -/** A persisted thread whose build ALREADY finished: the tool row carries the - * whole-app draft envelope (app item + packageId). */ -const BUILT_TURNS = [ - { id: 'r1', role: 'user', content: [{ type: 'text', text: 'build me a CRM' }] }, - { - id: 'r2', - role: 'assistant', - content: [ - { type: 'text', text: 'Built it.' }, - { type: 'tool-call', toolCallId: 't1', toolName: 'apply_blueprint' }, - ], - }, - { - id: 'r3', - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 't1', - toolName: 'apply_blueprint', - output: { - type: 'text', - value: JSON.stringify({ - status: 'drafted', - packageId: 'app.k9', - drafted: [ - { type: 'app', name: 'k9_app' }, - { type: 'object', name: 'k9_task' }, - ], - }), - }, - }, - ], - }, -]; - -/** objectui#10109 — a persisted thread whose only authoring turn is an - * INCREMENTAL edit: the `apply_edit` envelope says `kind: 'edit'` and lists the - * `app` artifact its `add_object` op re-staged. The envelope shape is the - * producer's declaration as recorded on the card; the producer lives outside - * this repository. */ -const editTurns = (status: 'drafted' | 'published') => [ - { id: 'r1', role: 'user', content: [{ type: 'text', text: 'add an invoice object' }] }, - { - id: 'r2', - role: 'assistant', - content: [ - { type: 'text', text: 'Added it.' }, - { type: 'tool-call', toolCallId: 't1', toolName: 'apply_edit' }, - ], - }, - { - id: 'r3', - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 't1', - toolName: 'apply_edit', - output: { - type: 'text', - value: JSON.stringify({ - status, - kind: 'edit', - packageId: 'app.k9', - drafted: [ - { type: 'object', name: 'k9_invoice' }, - { type: 'app', name: 'k9_app' }, - ], - }), - }, - }, - ], - }, -]; - -const UNBUILT_TURNS = [ - { id: 'r1', role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { id: 'r2', role: 'assistant', content: [{ type: 'text', text: 'hi — what shall we build?' }] }, -]; - -let serverTurns: unknown[] = []; - -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: serverTurns }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response(JSON.stringify({ success: true, data: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }), - ); -} - -function renderPage() { - render( - - - } /> - } /> - } - /> - - , - ); -} - -beforeEach(() => { - chat.isLoading = false; - serverTurns = []; - window.localStorage.clear(); - window.sessionStorage.clear(); - installFetch(); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe('AiChatPage — built-moment transition (objectui#5799)', () => { - it('reopening a conversation that already built an app lands in the Studio workbench', async () => { - serverTurns = BUILT_TURNS; - renderPage(); - await waitFor(() => expect(screen.getByTestId('studio-page')).toBeInTheDocument(), { - timeout: 4000, - }); - }); - - it("an AUTO-PUBLISH environment's published envelope transitions too (the staging posture)", async () => { - serverTurns = JSON.parse( - JSON.stringify(BUILT_TURNS).replace('"status":"drafted"', '"status":"published"'), - ); - renderPage(); - await waitFor(() => expect(screen.getByTestId('studio-page')).toBeInTheDocument(), { - timeout: 4000, - }); - }); - - it('a conversation with no whole-app build stays on the full page', async () => { - serverTurns = UNBUILT_TURNS; - renderPage(); - await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); - await new Promise((r) => setTimeout(r, 300)); - expect(screen.queryByTestId('studio-page')).not.toBeInTheDocument(); - }); - - it('a LIVE build completion transitions when the turn settles', async () => { - serverTurns = UNBUILT_TURNS; - chat.isLoading = true; - renderPage(); - await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); - expect(screen.queryByTestId('studio-page')).not.toBeInTheDocument(); - - // The build turn lands (envelope on the invocation, as the live mapper - // produces it) and the stream settles. - act(() => { - chat.append?.({ - id: 'live-1', - role: 'assistant', - content: 'built', - toolInvocations: [ - { - toolCallId: 't9', - toolName: 'apply_blueprint', - state: 'output-available', - draftReview: { - packageId: 'app.k9', - items: [ - { type: 'app', name: 'k9_app' }, - { type: 'object', name: 'k9_task' }, - ], - }, - }, - ], - }); - chat.isLoading = false; - }); - // one more render so the faked hook reports the settled isLoading - act(() => { - chat.append?.({ id: 'live-2', role: 'assistant', content: 'done' }); - }); - await waitFor(() => expect(screen.getByTestId('studio-page')).toBeInTheDocument(), { - timeout: 4000, - }); - }); - - it("the dock's full-page door opts out ONCE — no bounce straight back to Studio", async () => { - serverTurns = BUILT_TURNS; - window.sessionStorage.setItem('objectstack:ai-full-page-requested', '1'); - renderPage(); - await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); - await new Promise((r) => setTimeout(r, 300)); - expect(screen.queryByTestId('studio-page')).not.toBeInTheDocument(); - // consumed: the NEXT built conversation transitions normally - expect(window.sessionStorage.getItem('objectstack:ai-full-page-requested')).toBeNull(); - }); - - it.each(['drafted', 'published'] as const)( - 'objectui#10109 — reopening a conversation whose only turn is an apply_edit (%s posture) stays on the full page', - async (status) => { - serverTurns = editTurns(status); - renderPage(); - await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); - await new Promise((r) => setTimeout(r, 300)); - expect(screen.queryByTestId('studio-page')).not.toBeInTheDocument(); - }, - ); - - it('objectui#10109 — a LIVE apply_edit completion does not transition', async () => { - serverTurns = UNBUILT_TURNS; - chat.isLoading = true; - renderPage(); - await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); - - // The edit turn lands with the draft review the live mapper lifts from the - // envelope — it lists the re-staged `app`, and says `kind: 'edit'`. - act(() => { - chat.append?.({ - id: 'live-1', - role: 'assistant', - content: 'added', - toolInvocations: [ - { - toolCallId: 't9', - toolName: 'apply_edit', - state: 'output-available', - draftReview: { - kind: 'edit', - packageId: 'app.k9', - items: [ - { type: 'object', name: 'k9_invoice' }, - { type: 'app', name: 'k9_app' }, - ], - }, - }, - ], - }); - chat.isLoading = false; - }); - act(() => { - chat.append?.({ id: 'live-2', role: 'assistant', content: 'done' }); - }); - await new Promise((r) => setTimeout(r, 300)); - expect(screen.queryByTestId('studio-page')).not.toBeInTheDocument(); - // Not vacuous: the settled turn (the one that re-arms the transition) did render. - expect(screen.getByTestId('pane')).toHaveTextContent('live-2'); - }); -}); diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.noNavigationAfterBuild-12724.test.tsx similarity index 50% rename from packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx rename to packages/app-shell/src/console/ai/__tests__/AiChatPage.noNavigationAfterBuild-12724.test.tsx index c259417a2d..ea62ac593c 100644 --- a/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.noNavigationAfterBuild-12724.test.tsx @@ -1,26 +1,33 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * objectui#11658 item 1 — the post-build transition lands on the RUNNING app. + * objectui#12724 — a finished build STAYS on the conversation. * - * The built-moment transition (objectui#5799) moves the conversation into the - * Studio workbench at `/studio/PKG/interfaces`. That navigation is where the - * landing is decided: nothing from the cloud names the destination — the build - * envelope only supplies the package id. It now asks the Interfaces pillar for - * the run-mode landing (`STUDIO_RUN_LANDING` router state); the pillar half is - * pinned in `StudioDesignSurface.runLanding-11658.test.tsx`. + * The built-moment transition (objectui#5799) moved a conversation 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 user stays on the conversation and picks a door on the result card. This + * file replaces the two that pinned the transition + * (`AiChatPage.builtTransition.test.tsx`, `AiChatPage.builtLandsRunning-11658.test.tsx`). * - * Control: the explicit "Design in Studio" door on a finished build is a request - * to DESIGN, so it passes no landing state and keeps opening on 「设计」. + * The pins: + * 1. Neither a REOPENED built conversation (either posture) nor a LIVE build + * completion navigates: the page stays on `/ai/build/conv-1`, the only + * router call is the A1.b binding's in-place `?package=` replace on the + * same conversation, and `location.assign` is never called. + * 2. The binding still happens (the Studio dock resumes THIS thread through + * it), so pin 1 is not vacuous. + * 3. The two doors are SPA navigations the user chooses: 打开应用 to + * `/apps/APP` (the app's first navigation item), 在 Studio 中查看 to + * `/studio/PKG/interfaces` with no landing state. * - * Harness: `AiChatPage.builtTransition.test.tsx`'s faked chat, with the Studio - * route reporting the router state it was handed. + * Same faked-chat harness the retired files used: the hook instance owns the + * thread; hydration (the REAL AiChatPage path) derives the envelopes. */ import '@testing-library/jest-dom/vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import React from 'react'; interface FakeMsg { @@ -35,6 +42,28 @@ const chat = { append: undefined as ((m: FakeMsg) => void) | undefined, }; +/** Every call any `useNavigate()` result received, in order. */ +const navigateCalls: unknown[][] = []; + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + const React2 = await import('react'); + return { + ...actual, + useNavigate: () => { + const navigate = actual.useNavigate(); + return React2.useMemo( + () => + ((...args: unknown[]) => { + navigateCalls.push(args); + return (navigate as (...a: unknown[]) => unknown)(...args); + }) as typeof navigate, + [navigate], + ); + }, + }; +}); + vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { const actual = await importOriginal>(); const React2 = await import('react'); @@ -49,7 +78,9 @@ vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { useAiModels: () => ({ models: [], defaultModelId: undefined }), useHitlInChat: () => ({ decide: vi.fn(), decisions: {} }), useObjectChat: (opts: { initialMessages?: FakeMsg[] }) => { - const [messages, setMessages] = React2.useState(() => (opts.initialMessages ?? []) as FakeMsg[]); + const [messages, setMessages] = React2.useState( + () => (opts.initialMessages ?? []) as FakeMsg[], + ); chat.append = (m: FakeMsg) => setMessages((prev) => [...prev, m]); return { messages, @@ -64,11 +95,13 @@ vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { }, ChatbotEnhanced: (props: Record) => { const msgs = (props.messages ?? []) as FakeMsg[]; + const open = props.onOpenBuiltApp as ((app: string, segment?: string) => void) | undefined; const design = props.onDesignBuiltApp as ((app: string, segment?: string) => void) | undefined; return (
{msgs.map((m) => m.id).join(',')} -
); }, @@ -87,11 +120,15 @@ vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, useAdapter: () => null }; }); -vi.mock('../ConversationsSidebar', () => ({ ConversationsSidebar: () =>
})); -vi.mock('../LiveCanvas', () => ({ LiveCanvas: () =>
})); +vi.mock('../ConversationsSidebar', () => ({ + ConversationsSidebar: () =>
, +})); +vi.mock('../LiveCanvas', () => ({ + LiveCanvas: () =>
, +})); +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { AiChatPage } from '../AiChatPage'; -import { isStudioRunLanding } from '../../../views/studio-design/studioLanding'; window.matchMedia = ((query: string) => ({ matches: false, @@ -104,7 +141,8 @@ window.matchMedia = ((query: string) => ({ dispatchEvent: () => false, })) as unknown as typeof window.matchMedia; -const BUILT_TURNS = [ +/** A persisted thread whose build ALREADY finished (whole-app draft envelope). */ +const builtTurns = (status: 'drafted' | 'published') => [ { id: 'r1', role: 'user', content: [{ type: 'text', text: 'build me a CRM' }] }, { id: 'r2', @@ -125,7 +163,7 @@ const BUILT_TURNS = [ output: { type: 'text', value: JSON.stringify({ - status: 'drafted', + status, packageId: 'app.k9', drafted: [ { type: 'app', name: 'k9_app' }, @@ -165,14 +203,15 @@ function installFetch(): void { ); } -/** The Studio route, reporting where it was sent and with what router state. */ -function StudioProbe() { +/** Where the router is, and with what state. */ +function Where() { const location = useLocation(); return (
); } @@ -183,35 +222,64 @@ function renderPage() { } /> } /> - } /> + } /> + } /> + , ); } +let assignSpy: ReturnType; + beforeEach(() => { chat.isLoading = false; serverTurns = []; + navigateCalls.length = 0; window.localStorage.clear(); window.sessionStorage.clear(); installFetch(); + // `location.assign` is the full navigation a page reload into Studio would take. + assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => {}); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); + assignSpy.mockRestore(); }); -describe('AiChatPage — the post-build landing is the running app (objectui#11658 item 1)', () => { - it('reopening a built conversation lands on the Interfaces pillar with the run-mode landing', async () => { - serverTurns = BUILT_TURNS; - renderPage(); - const studio = await screen.findByTestId('studio-page', {}, { timeout: 4000 }); - expect(studio).toHaveAttribute('data-path', '/studio/app.k9/interfaces'); - expect(studio).toHaveAttribute('data-run-landing', 'true'); - }); +/** The ONLY router calls a finished build may make: the binding's in-place replace. */ +function expectOnlyTheBinding(): void { + for (const [to, opts] of navigateCalls) { + expect(to).toBe('/ai/build/conv-1?package=app.k9'); + expect(opts).toEqual({ replace: true }); + } + expect(screen.getByTestId('where')).toHaveAttribute('data-path', '/ai/build/conv-1'); + expect(screen.queryByTestId('studio-page')).not.toBeInTheDocument(); + expect(screen.queryByTestId('app-page')).not.toBeInTheDocument(); + expect(assignSpy).not.toHaveBeenCalled(); +} + +describe('AiChatPage — no automatic navigation after a build (objectui#12724)', () => { + it.each(['drafted', 'published'] as const)( + 'reopening a conversation that built an app (%s posture) stays on the conversation', + async (status) => { + serverTurns = builtTurns(status); + renderPage(); + await waitFor(() => expect(screen.getByTestId('pane')).toHaveTextContent('r'), { timeout: 4000 }); + // The binding lands (not vacuous: the build WAS seen) … + await waitFor( + () => expect(screen.getByTestId('where')).toHaveAttribute('data-search', '?package=app.k9'), + { timeout: 4000 }, + ); + await new Promise((r) => setTimeout(r, 300)); + // … and nothing else moved. + expectOnlyTheBinding(); + }, + ); - it('a LIVE build completion asks for the run-mode landing too', async () => { + it('a LIVE build completion stays on the conversation', async () => { serverTurns = UNBUILT_TURNS; chat.isLoading = true; renderPage(); @@ -238,20 +306,37 @@ describe('AiChatPage — the post-build landing is the running app (objectui#116 }); chat.isLoading = false; }); + // one more render so the faked hook reports the settled isLoading act(() => { chat.append?.({ id: 'live-2', role: 'assistant', content: 'done' }); }); - const studio = await screen.findByTestId('studio-page', {}, { timeout: 4000 }); - expect(studio).toHaveAttribute('data-run-landing', 'true'); + await waitFor( + () => expect(screen.getByTestId('where')).toHaveAttribute('data-search', '?package=app.k9'), + { timeout: 4000 }, + ); + await new Promise((r) => setTimeout(r, 300)); + expect(screen.getByTestId('pane')).toHaveTextContent('live-2'); + expectOnlyTheBinding(); + }); + + it('打开应用 is an SPA navigation to the built app (its first navigation item)', async () => { + serverTurns = UNBUILT_TURNS; + renderPage(); + await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); + fireEvent.click(screen.getByTestId('open-door')); + expect(await screen.findByTestId('app-page', {}, { timeout: 4000 })).toBeInTheDocument(); + expect(screen.getByTestId('where')).toHaveAttribute('data-path', '/apps/k9_app'); + expect(assignSpy).not.toHaveBeenCalled(); }); - it('control: the explicit "Design in Studio" door passes no landing state', async () => { + it('在 Studio 中查看 is an SPA navigation to the Interfaces pillar with no landing state', async () => { serverTurns = UNBUILT_TURNS; renderPage(); await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); - fireEvent.click(screen.getByTestId('design-door')); - const studio = await screen.findByTestId('studio-page', {}, { timeout: 4000 }); - expect(studio).toHaveAttribute('data-path', '/studio/app.k9/interfaces'); - expect(studio).toHaveAttribute('data-run-landing', 'false'); + fireEvent.click(screen.getByTestId('studio-door')); + expect(await screen.findByTestId('studio-page', {}, { timeout: 4000 })).toBeInTheDocument(); + expect(screen.getByTestId('where')).toHaveAttribute('data-path', '/studio/app.k9/interfaces'); + expect(screen.getByTestId('where')).toHaveAttribute('data-state', 'null'); + expect(assignSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 5863d758a6..d8698223b4 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -337,7 +337,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 87 once objectui#12622 added `fields.datePicker.invalid`, the record // form date picker's notice for typed text that names no date, which // quotes that text („{{value}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(87); + // 88 once objectui#12724 added `chatbot.build.nextStepQuote`, the finished + // build's quote around each next step („{{step}}“) — one interpolated span, + // runtime data. + expect(okSpans, 'correctly paired spans').toBe(88); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -389,8 +392,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `.unmanagedIgnored`, one matched pair each. 86 / 86 / 0 after // objectui#12504 retired `console.objectView.viewConfigPermissionDenied`, // one matched pair fewer. 87 / 87 / 0 after objectui#12622 added - // `fields.datePicker.invalid`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 87, close: 87, rdq: 0 }); + // `fields.datePicker.invalid`, one more matched pair. 88 / 88 / 0 after + // objectui#12724 added `chatbot.build.nextStepQuote`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 88, close: 88, 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); diff --git a/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx b/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx index c218502c24..9785dbfa8f 100644 --- a/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx @@ -450,14 +450,10 @@ describe('objectui#3546 slice six — the perm and home namespaces', () => { `${at(builtInLocales[lang], 'topbar.designInStudio')} →`, ); } - // NOT reused, deliberately: `console.ai.designBuiltApp` is also "Design in - // Studio" in `en`, but zh/de/ru already render it differently on purpose - // ("进入 Studio 继续完善" — keep refining what the agent just built). The - // neighbour taken is the plain navigation label, which is what this link is. - expect(at(builtInLocales.en, 'console.ai.designBuiltApp')).toBe(at(builtInLocales.en, 'topbar.designInStudio')); - expect(at(builtInLocales.zh, 'console.ai.designBuiltApp')).not.toBe( - at(builtInLocales.zh, 'topbar.designInStudio'), - ); + // `console.ai.designBuiltApp`, once the near-miss this paragraph ruled out, + // retired with objectui#12724 (the build card's Studio door now reads + // `console.ai.viewBuiltAppInStudio`), so there is no second rendering left + // to rule out. }); it('the count-plus-unit wording follows each pack own neighbours', () => { diff --git a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts index b7af78b8d7..c0b7bc82b3 100644 --- a/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts +++ b/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts @@ -145,6 +145,7 @@ const LEGITIMATE_IDENTITIES: Record = { 'marketplace.versionBadge': 'v{{version}} — version prefix plus the hole.', 'appManagement.toast.bulkFailureEntry': '{{name}} ({{reason}}) — holes and parentheses only.', 'collaboration.userStatusTitle': '{{name}} ({{status}}) — holes and parentheses only.', + 'chatbot.build.nextStepQuote': '“{{step}}” — a hole in quote marks; ko quotes with the same curly marks as en (objectui#12724).', }; /** Every string leaf of a pack, as `[dotted.path, value]`. */ diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c51ffa5b20..9ba62aefdf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1986,6 +1986,7 @@ const ar = { loadingAgents: "جارٍ تحميل الوكلاء…", askAnything: "اسأل عن أي شيء…", askOrChangeApp: "اسأل عن بياناتك، أو اطلب مني تعديل هذا التطبيق…", + describeAppToBuild: "صِف التطبيق الذي تريد إنشاءه، أو ما تريد تغييره…", emptyTitle: "ابدأ محادثة", emptyDescription: "اسأل عن أي شيء — يمكن للمساعد الاطلاع على سياق تطبيقك الحالي.", switchAssistant: "تبديل المساعد", @@ -2007,6 +2008,7 @@ const ar = { }, clearConversation: "مسح", sendHint: "للإرسال", + newlineHint: "لسطر جديد", agentActivity: "نشاط الوكيل", toolCompleted: "مكتمل", toolRunning: "قيد التنفيذ", @@ -2031,7 +2033,7 @@ const ar = { publishOk: "تم النشر — الكائنات فعّالة الآن.", seedWarn: "تم النشر، لكن تعذّر تحميل بعض البيانات التجريبية.", openBuiltApp: "فتح التطبيق", - designBuiltApp: "التصميم في Studio", + viewBuiltAppInStudio: "العرض في Studio", previewDraft: "معاينة", previewApp: "معاينة التطبيق", resizeSplit: "تغيير حجم المحادثة والمعاينة", @@ -3652,6 +3654,19 @@ const ar = { denied: "مرفوض", failed: "فشل", }, + phase: { + build: "الإنشاء", + design: "تصميم الخطة", + stepCount: "{{count}} خطوات", + stepCount_zero: "{{count}} خطوة", + stepCount_one: "{{count}} خطوة", + stepCount_two: "خطوتان ({{count}})", + stepCount_few: "{{count}} خطوات", + stepCount_many: "{{count}} خطوة", + stepCount_other: "{{count}} خطوة", + expand: "إظهار الخطوات", + collapse: "إخفاء الخطوات", + }, plan: { countObjects: "{{count}} كائنات", countObjects_zero: "{{count}} كائن", @@ -3676,10 +3691,14 @@ const ar = { countDashboards_other: "{{count}} لوحة معلومات", countSeedData: "بيانات تجريبية", extendTarget: "إضافة إلى التطبيق الحالي: {{app}}", + awaitingConfirm: "بانتظار التأكيد", + objectsAndFields: "الكائنات والحقول", + views: "طرق العرض", }, build: { building: "جارٍ إنشاء {{app}}…", built: "تم إنشاء {{app}}", + builtAndLive: "تم الإنشاء والنشر · {{app}}", appFallback: "تطبيقك", addingSampleData: "جارٍ إضافة بيانات تجريبية", group: { @@ -3694,6 +3713,9 @@ const ar = { verifyStep: "الخطوة {{n}}", verified: "تم التحقق من التغيير", unknownPhase: "مرحلة إنشاء غير معروفة", + nextStepsLead: "بعد ذلك يمكنك أن تقول: {{steps}}", + nextStepQuote: "«{{step}}»", + draftPosture: "تُحفظ التغييرات اللاحقة كمسودات، وأنت من ينشرها في Studio.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b2691289a3..bceb9a9545 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1793,6 +1793,7 @@ const de = { loadingAgents: "Agenten werden geladen…", askAnything: "Fragen Sie irgendetwas…", askOrChangeApp: "Fragen Sie zu Ihren Daten oder lassen Sie mich diese App ändern…", + describeAppToBuild: "Beschreiben Sie die App, die Sie erstellen möchten, oder was geändert werden soll…", emptyTitle: "Unterhaltung beginnen", emptyDescription: "Fragen Sie irgendetwas — der Assistent hat Zugriff auf den Kontext Ihrer aktuellen App.", switchAssistant: "Assistenten wechseln", @@ -1814,6 +1815,7 @@ const de = { }, clearConversation: "Leeren", sendHint: "zum Senden", + newlineHint: "für eine neue Zeile", agentActivity: "Agentenaktivität", toolCompleted: "Abgeschlossen", toolRunning: "Läuft", @@ -1838,7 +1840,7 @@ const de = { publishOk: "Veröffentlicht — die Objekte sind jetzt live.", seedWarn: "Veröffentlicht, aber einige Beispieldaten konnten nicht geladen werden.", openBuiltApp: "App öffnen", - designBuiltApp: "In Studio gestalten", + viewBuiltAppInStudio: "In Studio ansehen", previewDraft: "Vorschau", previewApp: "App-Vorschau", resizeSplit: "Chat und Vorschau anpassen", @@ -3385,6 +3387,15 @@ const de = { denied: "Abgelehnt", failed: "Fehlgeschlagen", }, + phase: { + build: "Erstellen", + design: "Plan entwerfen", + stepCount: "{{count}} Schritte", + stepCount_one: "{{count}} Schritt", + stepCount_other: "{{count}} Schritte", + expand: "Schritte anzeigen", + collapse: "Schritte ausblenden", + }, plan: { countObjects: "{{count}} Objekte", countObjects_one: "{{count}} Objekt", @@ -3397,10 +3408,14 @@ const de = { countDashboards_other: "{{count}} Dashboards", countSeedData: "Beispieldaten", extendTarget: "Wird zur bestehenden App hinzugefügt: {{app}}", + awaitingConfirm: "Wartet auf Bestätigung", + objectsAndFields: "Objekte und Felder", + views: "Ansichten", }, build: { building: "{{app}} wird erstellt…", built: "{{app}} wurde erstellt", + builtAndLive: "Erstellt und live · {{app}}", appFallback: "Ihre App", addingSampleData: "Beispieldaten werden hinzugefügt", group: { @@ -3415,6 +3430,9 @@ const de = { verifyStep: "Schritt {{n}}", verified: "Änderung geprüft", unknownPhase: "Unbekannte Build-Phase", + nextStepsLead: "Als Nächstes können Sie sagen: {{steps}}", + nextStepQuote: "„{{step}}“", + draftPosture: "Spätere Änderungen werden als Entwurf gespeichert, und Sie veröffentlichen sie in Studio.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f949860b95..2175c03b2e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2312,6 +2312,7 @@ const en = { loadingAgents: 'Loading agents…', askAnything: 'Ask anything…', askOrChangeApp: 'Ask about your data, or ask me to change this app…', + describeAppToBuild: 'Describe the app you want to build, or what to change…', emptyTitle: 'Start a conversation', emptyDescription: 'Ask anything — the assistant has access to your current app context.', switchAssistant: 'Switch assistant', @@ -2359,6 +2360,7 @@ const en = { }, clearConversation: 'Clear', sendHint: 'to send', + newlineHint: 'for a new line', agentActivity: 'Agent activity', toolCompleted: 'Completed', toolRunning: 'Running', @@ -2389,7 +2391,7 @@ const en = { publishOk: 'Published — objects are now live.', seedWarn: 'Published, but some sample data failed to load.', openBuiltApp: 'Open app', - designBuiltApp: 'Design in Studio', + viewBuiltAppInStudio: 'View in Studio', previewDraft: 'Preview', previewApp: 'Preview app', resizeSplit: 'Resize chat and preview', @@ -4053,6 +4055,15 @@ const en = { denied: 'Denied', failed: 'Failed', }, + phase: { + build: 'Build', + design: 'Design the plan', + stepCount: '{{count}} steps', + stepCount_one: '{{count}} step', + stepCount_other: '{{count}} steps', + expand: 'Show steps', + collapse: 'Hide steps', + }, plan: { countObjects: '{{count}} objects', countObjects_one: '{{count}} object', @@ -4065,10 +4076,14 @@ const en = { countDashboards_other: '{{count}} dashboards', countSeedData: 'sample data', extendTarget: 'Adding to existing app: {{app}}', + awaitingConfirm: 'Awaiting confirmation', + objectsAndFields: 'Objects and fields', + views: 'Views', }, build: { building: 'Building {{app}}…', built: 'Built {{app}}', + builtAndLive: 'Built and live · {{app}}', appFallback: 'your app', addingSampleData: 'adding sample data', group: { @@ -4083,6 +4098,9 @@ const en = { verifyStep: 'step {{n}}', verified: 'Checked the change', unknownPhase: 'Unknown build phase', + nextStepsLead: 'Next, you can say: {{steps}}', + nextStepQuote: '“{{step}}”', + draftPosture: 'Later changes are saved as drafts, and you publish them in Studio.', }, }, chatbotError: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 1e8b62e17c..3ad2ebead2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1841,6 +1841,7 @@ const es = { loadingAgents: "Cargando agentes…", askAnything: "Pregunte lo que quiera…", askOrChangeApp: "Pregunte por sus datos o pídame que cambie esta app…", + describeAppToBuild: "Describa la app que quiere crear o lo que hay que cambiar…", emptyTitle: "Iniciar una conversación", emptyDescription: "Pregunte lo que quiera — el asistente tiene acceso al contexto de su aplicación actual.", switchAssistant: "Cambiar de asistente", @@ -1862,6 +1863,7 @@ const es = { }, clearConversation: "Borrar", sendHint: "para enviar", + newlineHint: "para una nueva línea", agentActivity: "Actividad del agente", toolCompleted: "Completado", toolRunning: "En ejecución", @@ -1886,7 +1888,7 @@ const es = { publishOk: "Publicado — los objetos ya están activos.", seedWarn: "Publicado, pero no se pudieron cargar algunos datos de ejemplo.", openBuiltApp: "Abrir la aplicación", - designBuiltApp: "Diseñar en Studio", + viewBuiltAppInStudio: "Ver en Studio", previewDraft: "Vista previa", previewApp: "Vista previa de la aplicación", resizeSplit: "Redimensionar el chat y la vista previa", @@ -3450,6 +3452,16 @@ const es = { denied: "Denegado", failed: "Fallido", }, + phase: { + build: "Creación", + design: "Diseño del plan", + stepCount: "{{count}} pasos", + stepCount_one: "{{count}} paso", + stepCount_many: "{{count}} de pasos", + stepCount_other: "{{count}} pasos", + expand: "Mostrar pasos", + collapse: "Ocultar pasos", + }, plan: { countObjects: "{{count}} objetos", countObjects_one: "{{count}} objeto", @@ -3465,10 +3477,14 @@ const es = { countDashboards_other: "{{count}} paneles", countSeedData: "datos de ejemplo", extendTarget: "Se añade a la app existente: {{app}}", + awaitingConfirm: "Pendiente de confirmación", + objectsAndFields: "Objetos y campos", + views: "Vistas", }, build: { building: "Creando {{app}}…", built: "Creación de {{app}} completada", + builtAndLive: "Creada y publicada · {{app}}", appFallback: "su aplicación", addingSampleData: "añadiendo datos de ejemplo", group: { @@ -3483,6 +3499,9 @@ const es = { verifyStep: "paso {{n}}", verified: "Cambio comprobado", unknownPhase: "Fase de creación desconocida", + nextStepsLead: "A continuación puede decir: {{steps}}", + nextStepQuote: "«{{step}}»", + draftPosture: "Los cambios posteriores se guardan como borrador y usted los publica en Studio.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index e1df50522b..4a7a9b181f 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1838,6 +1838,7 @@ const fr = { loadingAgents: "Chargement des agents…", askAnything: "Posez n'importe quelle question…", askOrChangeApp: "Posez une question sur vos données ou demandez-moi de modifier cette app…", + describeAppToBuild: "Décrivez l’app que vous voulez créer, ou ce qu’il faut modifier…", emptyTitle: "Démarrer une conversation", emptyDescription: "Posez n'importe quelle question — l'assistant a accès au contexte de votre application actuelle.", switchAssistant: "Changer d'assistant", @@ -1859,6 +1860,7 @@ const fr = { }, clearConversation: "Effacer", sendHint: "pour envoyer", + newlineHint: "pour un retour à la ligne", agentActivity: "Activité de l'agent", toolCompleted: "Terminé", toolRunning: "En cours", @@ -1883,7 +1885,7 @@ const fr = { publishOk: "Publié — les objets sont maintenant actifs.", seedWarn: "Publié, mais certaines données d'exemple n'ont pas pu être chargées.", openBuiltApp: "Ouvrir l'application", - designBuiltApp: "Concevoir dans Studio", + viewBuiltAppInStudio: "Voir dans Studio", previewDraft: "Aperçu", previewApp: "Aperçu de l'application", resizeSplit: "Redimensionner la discussion et l'aperçu", @@ -3447,6 +3449,16 @@ const fr = { denied: "Refusé", failed: "Échec", }, + phase: { + build: "Création", + design: "Conception du plan", + stepCount: "{{count}} étapes", + stepCount_one: "{{count}} étape", + stepCount_many: "{{count}} d’étapes", + stepCount_other: "{{count}} étapes", + expand: "Afficher les étapes", + collapse: "Masquer les étapes", + }, plan: { countObjects: "{{count}} objets", countObjects_one: "{{count}} objet", @@ -3462,10 +3474,14 @@ const fr = { countDashboards_other: "{{count}} tableaux de bord", countSeedData: "données d’exemple", extendTarget: "Ajout à l’app existante : {{app}}", + awaitingConfirm: "En attente de confirmation", + objectsAndFields: "Objets et champs", + views: "Vues", }, build: { building: "Création de {{app}}…", built: "Création de {{app}} terminée", + builtAndLive: "Créée et en ligne · {{app}}", appFallback: "votre application", addingSampleData: "ajout de données d’exemple", group: { @@ -3480,6 +3496,9 @@ const fr = { verifyStep: "étape {{n}}", verified: "Modification vérifiée", unknownPhase: "Phase de création inconnue", + nextStepsLead: "Ensuite, vous pouvez dire : {{steps}}", + nextStepQuote: "« {{step}} »", + draftPosture: "Les modifications suivantes sont enregistrées comme brouillons, et vous les publiez dans Studio.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index dbbb208824..de12ed7767 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1795,6 +1795,7 @@ const ja = { loadingAgents: "エージェントを読み込んでいます…", askAnything: "何でも質問してください…", askOrChangeApp: "データについて質問するか、このアプリの変更を依頼…", + describeAppToBuild: "作りたいアプリ、または変更したい点を入力…", emptyTitle: "会話を始める", emptyDescription: "何でも質問してください — アシスタントは現在のアプリのコンテキストを参照できます。", switchAssistant: "アシスタントを切り替え", @@ -1816,6 +1817,7 @@ const ja = { }, clearConversation: "クリア", sendHint: "で送信", + newlineHint: "で改行", agentActivity: "エージェントの動作", toolCompleted: "完了", toolRunning: "実行中", @@ -1840,7 +1842,7 @@ const ja = { publishOk: "公開しました — オブジェクトが有効になりました。", seedWarn: "公開しましたが、一部のサンプルデータを読み込めませんでした。", openBuiltApp: "アプリを開く", - designBuiltApp: "Studio でデザイン", + viewBuiltAppInStudio: "Studio で表示", previewDraft: "プレビュー", previewApp: "アプリをプレビュー", resizeSplit: "チャットとプレビューのサイズを変更", @@ -3387,6 +3389,15 @@ const ja = { denied: "拒否", failed: "失敗", }, + phase: { + build: "構築", + design: "プランの設計", + stepCount: "{{count}} ステップ", + stepCount_one: "{{count}} ステップ", + stepCount_other: "{{count}} ステップ", + expand: "ステップを表示", + collapse: "ステップを隠す", + }, plan: { countObjects: "{{count}} 件のオブジェクト", countObjects_one: "{{count}} 件のオブジェクト", @@ -3399,10 +3410,14 @@ const ja = { countDashboards_other: "{{count}} 件のダッシュボード", countSeedData: "サンプルデータ", extendTarget: "既存のアプリに追加:{{app}}", + awaitingConfirm: "確認待ち", + objectsAndFields: "オブジェクトとフィールド", + views: "ビュー", }, build: { building: "{{app}}を構築しています…", built: "{{app}}を構築しました", + builtAndLive: "構築して公開済み · {{app}}", appFallback: "アプリ", addingSampleData: "サンプルデータを追加中", group: { @@ -3417,6 +3432,9 @@ const ja = { verifyStep: "ステップ {{n}}", verified: "変更を確認しました", unknownPhase: "不明なビルドフェーズ", + nextStepsLead: "次はこう話しかけられます:{{steps}}", + nextStepQuote: "「{{step}}」", + draftPosture: "以降の変更は下書きとして保存され、Studio で公開します。", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 205c463a45..41a24be832 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1793,6 +1793,7 @@ const ko = { loadingAgents: "에이전트를 불러오는 중…", askAnything: "무엇이든 물어보세요…", askOrChangeApp: "데이터에 대해 묻거나 이 앱을 바꿔 달라고 요청하세요…", + describeAppToBuild: "만들고 싶은 앱이나 바꿀 부분을 설명하세요…", emptyTitle: "대화 시작하기", emptyDescription: "무엇이든 물어보세요 — 어시스턴트는 현재 앱 컨텍스트에 접근할 수 있습니다.", switchAssistant: "어시스턴트 전환", @@ -1814,6 +1815,7 @@ const ko = { }, clearConversation: "지우기", sendHint: "눌러 전송", + newlineHint: "눌러 줄바꿈", agentActivity: "에이전트 활동", toolCompleted: "완료됨", toolRunning: "실행 중", @@ -1838,7 +1840,7 @@ const ko = { publishOk: "게시했습니다 — 객체가 이제 활성화되었습니다.", seedWarn: "게시했지만 일부 샘플 데이터를 불러오지 못했습니다.", openBuiltApp: "앱 열기", - designBuiltApp: "Studio에서 디자인", + viewBuiltAppInStudio: "Studio에서 보기", previewDraft: "미리보기", previewApp: "앱 미리보기", resizeSplit: "채팅과 미리보기 크기 조정", @@ -3384,6 +3386,15 @@ const ko = { denied: "거부됨", failed: "실패", }, + phase: { + build: "빌드", + design: "계획 설계", + stepCount: "{{count}}단계", + stepCount_one: "{{count}}단계", + stepCount_other: "{{count}}단계", + expand: "단계 보기", + collapse: "단계 숨기기", + }, plan: { countObjects: "오브젝트 {{count}}개", countObjects_one: "오브젝트 {{count}}개", @@ -3396,10 +3407,14 @@ const ko = { countDashboards_other: "대시보드 {{count}}개", countSeedData: "샘플 데이터", extendTarget: "기존 앱에 추가: {{app}}", + awaitingConfirm: "확인 대기 중", + objectsAndFields: "개체 및 필드", + views: "뷰", }, build: { building: "{{app}} 빌드 중…", built: "{{app}} 빌드 완료", + builtAndLive: "빌드 및 게시 완료 · {{app}}", appFallback: "앱", addingSampleData: "샘플 데이터 추가 중", group: { @@ -3414,6 +3429,9 @@ const ko = { verifyStep: "{{n}}단계", verified: "변경 사항 확인 완료", unknownPhase: "알 수 없는 빌드 단계", + nextStepsLead: "다음으로 이렇게 말할 수 있습니다: {{steps}}", + nextStepQuote: "“{{step}}”", + draftPosture: "이후 변경 사항은 초안으로 저장되며, Studio에서 직접 게시합니다.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9fc97583e9..c7d7d75637 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1836,6 +1836,7 @@ const pt = { loadingAgents: "Carregando agentes…", askAnything: "Pergunte qualquer coisa…", askOrChangeApp: "Pergunte sobre seus dados ou peça para eu alterar este app…", + describeAppToBuild: "Descreva o app que você quer criar ou o que mudar…", emptyTitle: "Iniciar uma conversa", emptyDescription: "Pergunte qualquer coisa — o assistente tem acesso ao contexto do seu aplicativo atual.", switchAssistant: "Trocar de assistente", @@ -1857,6 +1858,7 @@ const pt = { }, clearConversation: "Limpar", sendHint: "para enviar", + newlineHint: "para uma nova linha", agentActivity: "Atividade do agente", toolCompleted: "Concluído", toolRunning: "Em execução", @@ -1881,7 +1883,7 @@ const pt = { publishOk: "Publicado — os objetos já estão ativos.", seedWarn: "Publicado, mas alguns dados de exemplo não puderam ser carregados.", openBuiltApp: "Abrir o aplicativo", - designBuiltApp: "Projetar no Studio", + viewBuiltAppInStudio: "Ver no Studio", previewDraft: "Pré-visualizar", previewApp: "Pré-visualizar o aplicativo", resizeSplit: "Redimensionar a conversa e a pré-visualização", @@ -3445,6 +3447,16 @@ const pt = { denied: "Negado", failed: "Falhou", }, + phase: { + build: "Criação", + design: "Desenho do plano", + stepCount: "{{count}} etapas", + stepCount_one: "{{count}} etapa", + stepCount_many: "{{count}} de etapas", + stepCount_other: "{{count}} etapas", + expand: "Mostrar etapas", + collapse: "Ocultar etapas", + }, plan: { countObjects: "{{count}} objetos", countObjects_one: "{{count}} objeto", @@ -3460,10 +3472,14 @@ const pt = { countDashboards_other: "{{count}} painéis", countSeedData: "dados de exemplo", extendTarget: "Adicionando ao app existente: {{app}}", + awaitingConfirm: "Aguardando confirmação", + objectsAndFields: "Objetos e campos", + views: "Visões", }, build: { building: "Criando {{app}}…", built: "Criação de {{app}} concluída", + builtAndLive: "Criado e publicado · {{app}}", appFallback: "seu aplicativo", addingSampleData: "adicionando dados de exemplo", group: { @@ -3478,6 +3494,9 @@ const pt = { verifyStep: "etapa {{n}}", verified: "Alteração verificada", unknownPhase: "Fase de criação desconhecida", + nextStepsLead: "Em seguida, você pode dizer: {{steps}}", + nextStepQuote: "“{{step}}”", + draftPosture: "As alterações seguintes ficam salvas como rascunho, e você as publica no Studio.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8db9cae06c..ae39a75525 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1898,6 +1898,7 @@ const ru = { loadingAgents: "Загрузка агентов…", askAnything: "Спросите о чём угодно…", askOrChangeApp: "Спросите о данных или попросите меня изменить это приложение…", + describeAppToBuild: "Опишите приложение, которое хотите создать, или что изменить…", emptyTitle: "Начните диалог", emptyDescription: "Спросите о чём угодно — ассистент видит контекст вашего текущего приложения.", switchAssistant: "Сменить ассистента", @@ -1919,6 +1920,7 @@ const ru = { }, clearConversation: "Очистить", sendHint: "чтобы отправить", + newlineHint: "для новой строки", agentActivity: "Активность агента", toolCompleted: "Завершено", toolRunning: "Выполняется", @@ -1943,7 +1945,7 @@ const ru = { publishOk: "Опубликовано — объекты активны.", seedWarn: "Опубликовано, но часть примеров данных загрузить не удалось.", openBuiltApp: "Открыть приложение", - designBuiltApp: "Открыть в Studio", + viewBuiltAppInStudio: "Открыть в Studio", previewDraft: "Предпросмотр", previewApp: "Предпросмотр приложения", resizeSplit: "Изменить размер чата и предпросмотра", @@ -3531,6 +3533,17 @@ const ru = { denied: "Отклонено", failed: "Ошибка выполнения", }, + phase: { + build: "Сборка", + design: "Проектирование плана", + stepCount: "шагов: {{count}}", + stepCount_one: "{{count}} шаг", + stepCount_few: "{{count}} шага", + stepCount_many: "{{count}} шагов", + stepCount_other: "{{count}} шага", + expand: "Показать шаги", + collapse: "Скрыть шаги", + }, plan: { countObjects: "объектов: {{count}}", countObjects_one: "{{count}} объект", @@ -3549,10 +3562,14 @@ const ru = { countDashboards_other: "{{count}} дашборда", countSeedData: "демоданные", extendTarget: "Добавление в существующее приложение: {{app}}", + awaitingConfirm: "Ожидает подтверждения", + objectsAndFields: "Объекты и поля", + views: "Представления", }, build: { building: "Создание {{app}}…", built: "Создание {{app}} завершено", + builtAndLive: "Создано и опубликовано · {{app}}", appFallback: "вашего приложения", addingSampleData: "добавление демоданных", group: { @@ -3567,6 +3584,9 @@ const ru = { verifyStep: "шаг {{n}}", verified: "Изменение проверено", unknownPhase: "Неизвестный этап сборки", + nextStepsLead: "Дальше можно сказать: {{steps}}", + nextStepQuote: "«{{step}}»", + draftPosture: "Дальнейшие изменения сохраняются как черновики, а публикуете их вы в Studio.", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index ecc74b6380..d83c32245a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1950,6 +1950,7 @@ const zh = { loadingAgents: '正在加载助手…', askAnything: '输入你的问题…', askOrChangeApp: '问问数据,或让我改改这个应用…', + describeAppToBuild: '描述你想搭建的应用,或要改的地方…', emptyTitle: '开始对话', emptyDescription: '可以直接提问,助手会结合当前应用上下文提供帮助。', switchAssistant: '切换助手', @@ -1971,6 +1972,7 @@ const zh = { }, clearConversation: '清空', sendHint: '发送', + newlineHint: '换行', agentActivity: '执行过程', toolCompleted: '已完成', toolRunning: '运行中', @@ -1997,7 +1999,7 @@ const zh = { publishOk: '已发布,对象已生效。', seedWarn: '已发布,但部分示例数据未能载入。', openBuiltApp: '打开应用', - designBuiltApp: '进入 Studio 继续完善', + viewBuiltAppInStudio: '在 Studio 中查看', previewDraft: '预览', previewApp: '预览应用', resizeSplit: '调整对话与预览的宽度', @@ -3524,6 +3526,15 @@ const zh = { denied: '已拒绝', failed: '失败', }, + phase: { + build: '搭建', + design: '设计方案', + stepCount: '{{count}} 步', + stepCount_one: '{{count}} 步', + stepCount_other: '{{count}} 步', + expand: '展开步骤', + collapse: '收起步骤', + }, plan: { countObjects: '{{count}} 个对象', countObjects_one: '{{count}} 个对象', @@ -3536,10 +3547,14 @@ const zh = { countDashboards_other: '{{count}} 个仪表板', countSeedData: '示例数据', extendTarget: '添加到现有应用:{{app}}', + awaitingConfirm: '等待确认', + objectsAndFields: '对象与字段', + views: '视图', }, build: { building: '正在构建{{app}}…', built: '已构建{{app}}', + builtAndLive: '已搭建并上线 · {{app}}', appFallback: '你的应用', addingSampleData: '正在添加示例数据', group: { @@ -3554,6 +3569,9 @@ const zh = { verifyStep: '第 {{n}} 步', verified: '已检查改动', unknownPhase: '未知的构建阶段', + nextStepsLead: '接下来可以说:{{steps}}', + nextStepQuote: '「{{step}}」', + draftPosture: '之后的修改先存草稿,由你在 Studio 发布。', }, }, chatbotError: { diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 85d70df015..698400134e 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; import { useObjectTranslation, useSafeTranslate } from '@object-ui/i18n'; -import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; +import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, ChevronDown, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; import type { ChatStatus } from 'ai'; import { humanizeToolName, @@ -82,7 +82,14 @@ import { SourcesContent, Source, } from './elements/sources'; -import { parseResultEnvelope, detectAuthoringVerdict, type DraftReview } from './mapMessages'; +import { + parseResultEnvelope, + detectAuthoringVerdict, + detectBuiltAppPackage, + buildProgressFromDraftReview, + type DraftReview, + type ProposedPlan, +} from './mapMessages'; /** * One chat message as `` RENDERS it — the runtime contract of @@ -313,16 +320,12 @@ export interface ChatbotEnhancedToolInvocation { * objects the build will create, the agent's assumptions, and any * structure-deciding questions — and the user can approve or adjust before * `apply_blueprint` runs. Nothing is created yet; this is the confirm gate. + * + * The shape is `mapMessages.ts`'s `ProposedPlan`, referenced rather than + * repeated, for the reason `draftReview` above gives (objectui#12724 added + * the field, view and app-label rows to it). */ - proposedPlan?: { - summary?: string; - objects: Array<{ name: string; label?: string; fieldCount: number }>; - counts: { objects: number; views: number; dashboards: number; seedData: number }; - questions: string[]; - questionChoices?: Array<{ text: string; options: string[] }>; - assumptions: string[]; - targetApp?: string; - }; + proposedPlan?: ProposedPlan; /** Granular metadata-change preview (confirm-before-change). Set only * when a mutating tool returned status:'changes_proposed' — i.e. it was * NOT yet approved this turn, so nothing changed. Rendered as a 确认修改 card. */ @@ -388,6 +391,11 @@ export interface ChatbotLabels { clear?: string; /** Trailing hint next to the send button (e.g. "to send"). */ sendHint?: string; + /** + * objectui#12724 — the newline half of the composer's key hint, after + * "Shift+↵" («↵ 发送 · Shift+↵ 换行»). Omitted, the hint names sending only. + */ + newlineHint?: string; /** Compact agent activity heading shown in summary mode. */ agentActivity?: string; /** Status label for completed tool work. */ @@ -1609,6 +1617,10 @@ const ChatbotEnhanced = React.forwardRef( // standalone hosts) each call returns the English fallback passed beside // it, so nothing below can render a raw key. const tt = useSafeTranslate(); + // objectui#12724 — the plan card's section heads and its 等待确认 chip. + const planAwaitingLabel = tt('chatbot.plan.awaitingConfirm', 'Awaiting confirmation'); + const planObjectsLabel = tt('chatbot.plan.objectsAndFields', 'Objects and fields'); + const planViewsLabel = tt('chatbot.plan.views', 'Views'); // Resolve localizable strings once. Precedence is host `labels` prop -> // locale pack -> the English default this component always shipped, so a @@ -1622,6 +1634,7 @@ const ChatbotEnhanced = React.forwardRef( 'Ask anything — the assistant has access to your current app context.', clear: labels?.clear ?? 'Clear', sendHint: labels?.sendHint ?? 'to send', + newlineHint: labels?.newlineHint, agentActivity: labels?.agentActivity ?? tt('chatbot.toolState.agentActivity', 'Agent activity'), toolCompleted: labels?.toolCompleted ?? tt('chatbot.toolState.completed', 'Completed'), toolRunning: labels?.toolRunning ?? tt('chatbot.toolState.running', 'Running'), @@ -2639,7 +2652,12 @@ const ChatbotEnhanced = React.forwardRef( (make the data yours, refine, automate, publish) so a finished build isn't a dead end. Backend supplies these as `nextSteps` on the apply_blueprint result; lifecycle-neutral. */} - {tool.draftReview.nextSteps && tool.draftReview.nextSteps.length > 0 ? ( + {/* objectui#12724 — a whole-app build says its next steps on + the finished build card instead (「接下来可以说」), so + only an incremental edit keeps the list here. */} + {tool.draftReview.nextSteps && + tool.draftReview.nextSteps.length > 0 && + !buildProgressFromDraftReview(tool.draftReview) ? (
( className="flex flex-col gap-2 border-t bg-muted/20 px-3 py-2.5" data-testid="proposed-plan" > - - - {planTitleLabel} - + {/* objectui#12724 — the header names the app the plan builds + (`blueprint.app.label`) and, until the user decides, says + the plan waits for them. */} +
+ + + + {tool.proposedPlan.appLabel + ? `${planTitleLabel} · ${tool.proposedPlan.appLabel}` + : planTitleLabel} + + + {onSendMessage && + !builtPlanIds.has(tool.toolCallId) && + !approvedPlanIds.has(tool.toolCallId) ? ( + + {planAwaitingLabel} + + ) : null} +
+ {(() => { + const bits = countBitsOf(tool.proposedPlan!.counts); + return bits.length ? ( + {bits.join(' · ')} + ) : null; + })()} {tool.proposedPlan.targetApp ? ( ( {tool.proposedPlan.summary ? (

{tool.proposedPlan.summary}

) : null} - {tool.proposedPlan.objects.length > 0 ? ( -
+ {tool.proposedPlan.objects.some((o) => o.fields) ? ( + // objectui#12724 — one row per object with a chip per + // declared field (`blueprint.objects[].fields`). +
+ + {planObjectsLabel} + + {tool.proposedPlan.objects.map((o) => ( +
+ + {o.label || o.name} + +
+ {o.fields ? ( + o.fields.map((f) => ( + + {f.label || f.name} + + )) + ) : o.fieldCount > 0 ? ( + · {o.fieldCount} + ) : null} +
+
+ ))} +
+ ) : tool.proposedPlan.objects.length > 0 ? ( +
{tool.proposedPlan.objects.map((o) => ( ( ))}
) : null} - {(() => { - const bits = countBitsOf(tool.proposedPlan!.counts); - return bits.length ? ( - {bits.join(' · ')} - ) : null; - })()} + {tool.proposedPlan.views && tool.proposedPlan.views.length > 0 ? ( + // objectui#12724 — the views the build creates (`blueprint.views`). +
+ + {planViewsLabel} + + {tool.proposedPlan.views.map((v) => ( +
+ · + {v.label || v.name} +
+ ))} +
+ ) : null} {/* Assumptions split into ordinary design notes vs. business rules the build is explicitly DEFERRING ("待补 / Not yet built"). The deferred set gets its own labelled, tinted @@ -2918,6 +3015,13 @@ const ChatbotEnhanced = React.forwardRef( > {planAdjustLabel} + {/* objectui#12724 — the reply door beside the buttons. */} + + {planApproveHintLabel} +
) ) : ( @@ -3328,6 +3432,33 @@ const ChatbotEnhanced = React.forwardRef( // nothing the row above it does not, and a reloaded multi-step // build printed it under every step. The note stays for a // placeholder turn with nothing else on screen (#772). + // objectui#12724 — the whole-app build's draft tool (its + // envelope names the package and the `nextSteps`), and whether + // the build is live: the envelope itself says `published` (an + // auto-publish environment), or this chat published the draft. + const builtAppDraft = tools.find( + (tool) => + tool.draftReview?.packageId && + tool.draftReview.kind !== 'edit' && + tool.draftReview.items?.some((i) => i.type === 'app'), + ); + // An auto-publish environment's envelope says `published` and + // lifts no draft review, so its package is read off the + // envelope itself (the reader the page's binding uses). + const publishedBuild = tools.find( + (tool) => + parseResultEnvelope(tool.result)?.status === 'published' && + Boolean(detectBuiltAppPackage(tool.result)), + ); + const builtPackageId = + builtAppDraft?.draftReview?.packageId ?? + (publishedBuild ? detectBuiltAppPackage(publishedBuild.result) : undefined); + const buildIsLive = + publishedBuild !== undefined || + (builtAppDraft !== undefined && + (builtPackageId !== undefined && pendingByPackage.has(builtPackageId) + ? pendingByPackage.get(builtPackageId) === 0 + : publishedToolCalls.has(builtAppDraft.toolCallId))); const showsActivity = summaryTools.length > 0 || detailedTools.length > 0 || @@ -3376,12 +3507,9 @@ const ChatbotEnhanced = React.forwardRef( // envelope — present on reloaded conversations too, // so the Studio CTA / artifact links never depend on // live-canvas runtime state. - appSegment={ - message.toolInvocations - ?.map((tool) => tool.draftReview) - .find((dr) => dr?.packageId && dr.items?.some((i) => i.type === 'app')) - ?.packageId - } + appSegment={builtPackageId} + live={buildIsLive} + nextSteps={builtAppDraft?.draftReview?.nextSteps} onOpenBuiltApp={onOpenBuiltApp} openBuiltAppLabel={openBuiltAppLabel} onDesignBuiltApp={onDesignBuiltApp} @@ -3404,8 +3532,12 @@ const ChatbotEnhanced = React.forwardRef( ) : null} {!isUser && processVisibility === 'summary' && summaryTools.length > 0 ? ( - ) : null} @@ -3651,6 +3783,16 @@ const ChatbotEnhanced = React.forwardRef( > {L.sendHint} + {L.newlineHint ? ( + <> + · + Shift+ + + + {L.newlineHint} + + + ) : null}
void; openBuiltAppLabel?: string; onDesignBuiltApp?: (appName: string, appSegment?: string) => void; @@ -4354,9 +4506,11 @@ function BuildProgressPanel({ ) : ( )} - + {isDone - ? t('chatbot.build.built', { app: appName, defaultValue: 'Built {{app}}' }) + ? live + ? t('chatbot.build.builtAndLive', { app: appName, defaultValue: 'Built and live · {{app}}' }) + : t('chatbot.build.built', { app: appName, defaultValue: 'Built {{app}}' }) : isUnknown ? unknownPhase : t('chatbot.build.building', { app: appName, defaultValue: 'Building {{app}}…' })} @@ -4378,12 +4532,15 @@ function BuildProgressPanel({ ) : null}
-
-
-
+ {/* objectui#12724 — a finished build is a result card, not a progress bar. */} + {!isDone ? ( +
+
+
+ ) : null}
    {orderedTypes.map((type) => { const entries = groups.get(type)!; @@ -4450,36 +4607,37 @@ function BuildProgressPanel({
) : null} {isDone && builtApp && (onDesignBuiltApp || onOpenBuiltApp || onPreviewDraftApp) ? ( -
- {/* ADR-0080 D5 cold-start handoff: Studio is the built app's - iteration home, so it takes the PRIMARY slot; "Open app" (the - published front-end) demotes to a secondary. Hosts that don't - pass onDesignBuiltApp keep the old Open-app-primary layout. */} - {onDesignBuiltApp ? ( +
+ {/* objectui#12724 (the AiBuild artboard, ruled by the maintainer): + the finished card's PRIMARY action opens the built app (its + first navigation item); Studio is the secondary door. Both are + in-app navigations the user chooses — nothing navigates on its + own when a build lands. A host without onOpenBuiltApp puts + Studio in the primary slot. */} + {onOpenBuiltApp ? ( ) : null} - {onOpenBuiltApp ? ( + {onDesignBuiltApp ? ( ) : null} {/* ADR-0037: see the drafted app as-if-published, before Publish. */} @@ -4496,6 +4654,189 @@ function BuildProgressPanel({ ) : null}
) : null} + {isDone && builtApp ? ( + // objectui#12724 — what to say next (the envelope's `nextSteps`) and + // the posture of every later change, under the finished build. +

+ {nextSteps && nextSteps.length > 0 ? ( + + {t('chatbot.build.nextStepsLead', { + steps: nextSteps + .map((step) => t('chatbot.build.nextStepQuote', { step, defaultValue: '“{{step}}”' })) + .join(' '), + defaultValue: 'Next, you can say: {{steps}}', + })}{' '} + + ) : null} + + {t('chatbot.build.draftPosture', { + defaultValue: 'Later changes are saved as drafts, and you publish them in Studio.', + })} + +

+ ) : null} +
+ ); +} + +/** + * objectui#12724 — one step of a phase's collapsed line: a tool, folded over + * every call of it in the turn, in the order the turn first called it. + */ +interface ToolPhaseStep { + title: string; + count: number; +} + +/** Fold a turn's tool calls into one step per tool, in first-call order. */ +function foldToolPhaseSteps( + tools: ChatbotEnhancedToolInvocation[], + translateToolTitle?: ToolTitleTranslator, +): ToolPhaseStep[] { + const steps = new Map(); + for (const tool of tools) { + const step = steps.get(tool.toolName); + if (step) step.count += 1; + else + steps.set(tool.toolName, { + title: humanizeToolName(tool.toolName, translateToolTitle) || tool.toolName, + count: 1, + }); + } + return [...steps.values()]; +} + +/** + * Wall-clock span of each phase, keyed by its message id. Measured here, while + * the turn streams, because no producer persists a duration: a turn reopened + * from history has no entry, and its line simply omits the time. Module-scoped + * so a pane remount mid-turn keeps the clock it started. + */ +const phaseClock = new Map(); + +function usePhaseElapsed(key: string, running: boolean): number | undefined { + // The clock is read in effects only (render stays pure); `now` is the last + // reading, refreshed once a second while the phase runs. + const [now, setNow] = React.useState(undefined); + React.useEffect(() => { + if (!running) { + const entry = phaseClock.get(key); + if (entry && entry.end === undefined) entry.end = Date.now(); + setNow(Date.now()); + return undefined; + } + const entry = phaseClock.get(key); + if (!entry) phaseClock.set(key, { start: Date.now() }); + else entry.end = undefined; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [key, running]); + const entry = phaseClock.get(key); + if (!entry || now === undefined) return undefined; + return Math.max(0, Math.floor(((entry.end ?? now) - entry.start) / 1000)); +} + +/** + * objectui#12724 — a turn's tool activity as ONE collapsed line per phase + * (「设计方案 · 4 步 · 0:48 · 读取编写规范 · 列出对象 ×3 …」), with a chevron + * that expands the per-tool rows (`ToolActivitySummary`). Repeated calls of a + * tool fold into one step with a count. The phase is named for the build tool + * the turn called (`propose_blueprint` designs the plan, `apply_blueprint` + * builds); any other turn's line opens on its step count. The rows open on their + * own while a step runs, waits or failed, so a live timer or an error is never + * hidden behind the chevron; the user's own toggle wins after that. + */ +function ToolPhaseSummary({ + phaseKey, + streaming, + phaseTools, + summaryTools, + translateToolTitle, + labels, +}: { + phaseKey: string; + streaming: boolean; + /** Every tool the turn called — the phase's name, steps and state. */ + phaseTools: ChatbotEnhancedToolInvocation[]; + /** The tools the summary strip shows — the expanded rows. */ + summaryTools: ChatbotEnhancedToolInvocation[]; + translateToolTitle?: ToolTitleTranslator; + labels: ToolActivitySummaryLabels; +}) { + const { t } = useObjectTranslation(); + // The line speaks for the whole turn — every tool it called, including the + // ones that render their own card below (the plan, a draft, an approval) — + // while the expanded rows stay the summary strip they always were. A turn + // whose every tool has its own card draws no line (the host gates on the + // summary strip), so a lone card is never named twice. + const groups = summarizeTools(summaryTools, translateToolTitle); + const steps = foldToolPhaseSteps(phaseTools, translateToolTitle); + const turnStates = summarizeTools(phaseTools, translateToolTitle); + const has = (state: ToolSummaryState) => turnStates.some((g) => g.state === state); + const lineState: ToolSummaryState = has('failed') + ? 'failed' + : has('awaiting') + ? 'awaiting' + : has('running') + ? 'running' + : 'completed'; + const [toggled, setToggled] = React.useState(null); + const expanded = toggled ?? lineState !== 'completed'; + const elapsed = usePhaseElapsed(phaseKey, streaming || lineState === 'running'); + const names = phaseTools.map((tool) => tool.toolName); + const phaseLabel = names.includes('apply_blueprint') + ? t('chatbot.phase.build', { defaultValue: 'Build' }) + : names.includes('propose_blueprint') + ? t('chatbot.phase.design', { defaultValue: 'Design the plan' }) + : undefined; + const headline = [ + ...(phaseLabel ? [phaseLabel] : []), + t('chatbot.phase.stepCount', { count: steps.length, defaultValue: '{{count}} steps' }), + ...(elapsed !== undefined ? [formatElapsed(elapsed)] : []), + ].join(' · '); + const stepText = steps + .map((step) => (step.count > 1 ? `${step.title} ×${step.count}` : step.title)) + .join(' · '); + return ( +
+
+ + {!expanded ? {getToolSummaryStatus(lineState, labels)} : null} + + {headline} + + + {stepText} + + {groups.length > 0 ? ( + + ) : null} +
+ {expanded && groups.length > 0 ? ( +
+ +
+ ) : null}
); } diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx new file mode 100644 index 0000000000..5490e06630 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx @@ -0,0 +1,297 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12724 — the build conversation per the AiBuild artboard, the + * halves that need no new payload: + * + * 1. Progress: ONE collapsed line per phase (「设计方案 · 4 步 · 0:48 · 读取编写 + * 规范 · 列出对象 ×3 …」), repeated calls folded into one step with a + * count, and a chevron that expands the per-tool rows. + * 2. The finished build card: the 「已搭建并上线 · APP」 header when the build + * is live, 打开应用 (primary) and 在 Studio 中查看, the 「接下来可以说」 line + * from the envelope's `nextSteps`, and the draft-posture sentence. + * 3. The plan card: field chips from `blueprint.objects[].fields`, view rows, + * the app name in the header — and today's object chips when the plan + * carries no field rows. + * + * Rendered through a real `I18nProvider` where the wording is the point, so + * the zh strings are the shipped pack's, not this file's. + */ +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ChatbotEnhanced, type ChatbotEnhancedMessage } from '../ChatbotEnhanced'; +import { detectProposedPlan } from '../mapMessages'; + +function inLocale(lang: 'zh' | 'en', ui: React.ReactElement) { + return ( + {ui} + ); +} + +const done = (toolCallId: string, toolName: string) => ({ + toolCallId, + toolName, + state: 'output-available' as const, + result: { ok: true }, +}); + +/** The design turn the artboard draws: rules, objects ×3, todos ×2, then the plan. */ +const designTurn = (streaming: boolean): ChatbotEnhancedMessage => ({ + id: 'design-1', + role: 'assistant', + content: '', + streaming, + toolInvocations: [ + done('a', 'get_authoring_rules'), + done('b', 'list_objects'), + done('c', 'list_objects'), + done('d', 'todo_write'), + done('e', 'list_objects'), + done('f', 'todo_write'), + done('g', 'propose_blueprint'), + ], +}); + +describe('objectui#12724 — the progress collapses to one line per phase', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('zh: one line with the phase, its folded step count, the elapsed time and the folded steps', () => { + const { rerender } = render(inLocale('zh', )); + act(() => { + vi.advanceTimersByTime(48_000); + }); + rerender(inLocale('zh', )); + expect(screen.getAllByTestId('tool-phase')).toHaveLength(1); + expect(screen.getByTestId('tool-phase-headline')).toHaveTextContent(/^设计方案 · 4 步 · 0:48$/); + expect(screen.getByTestId('tool-phase-steps')).toHaveTextContent( + /^读取编写规范 · 列出对象 ×3 · 记录待办 ×2 · 设计应用方案$/, + ); + }); + + it('a settled phase is collapsed; the chevron expands the steps and collapses them again', () => { + render(); + const toggle = screen.getByTestId('tool-phase-toggle'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByTestId('tool-phase-steps-expanded')).not.toBeInTheDocument(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + const rows = screen.getByTestId('tool-phase-steps-expanded'); + expect(within(rows).getByText('List objects')).toBeInTheDocument(); + expect(within(rows).getByText('x3')).toBeInTheDocument(); + fireEvent.click(toggle); + expect(screen.queryByTestId('tool-phase-steps-expanded')).not.toBeInTheDocument(); + }); + + it('a reopened phase (never measured here) omits the time instead of inventing one', () => { + render(); + expect(screen.getByTestId('tool-phase-headline')).toHaveTextContent(/^Design the plan · 4 steps$/); + }); + + it('a running step keeps the rows open, so its live timer is not hidden', () => { + const { container } = render( + , + ); + expect(screen.getByTestId('tool-phase-headline')).toHaveTextContent(/^Build · 2 steps/); + expect(screen.getByTestId('tool-phase-toggle')).toHaveAttribute('aria-expanded', 'true'); + expect(container.querySelector('[data-tool-running-timer]')).not.toBeNull(); + }); +}); + +const builtMessage = (opts: { published?: boolean; nextSteps?: string[] }): ChatbotEnhancedMessage => ({ + id: 'built-1', + role: 'assistant', + content: '', + buildProgress: { + phase: 'done', + appLabel: '图书借阅管理', + items: [ + { type: 'object', name: 'book' }, + { type: 'app', name: 'library_app' }, + ], + done: 2, + total: 2, + }, + toolInvocations: [ + { + toolCallId: 'apply-1', + toolName: 'apply_blueprint', + state: 'output-available', + result: { + status: opts.published ? 'published' : 'drafted', + packageId: 'app.hcpl', + drafted: [ + { type: 'object', name: 'book' }, + { type: 'app', name: 'library_app' }, + ], + }, + ...(opts.published + ? {} + : { + draftReview: { + packageId: 'app.hcpl', + items: [ + { type: 'object', name: 'book' }, + { type: 'app', name: 'library_app' }, + ], + ...(opts.nextSteps ? { nextSteps: opts.nextSteps } : {}), + }, + }), + }, + ], +}); + +describe('objectui#12724 — the finished build card', () => { + it('zh: a live build reads「已搭建并上线 · APP」, with both doors, the next-steps line and the posture', () => { + const onOpenBuiltApp = vi.fn(); + const onDesignBuiltApp = vi.fn(); + render( + inLocale( + 'zh', + , + ), + ); + expect(screen.getByTestId('build-progress-title')).toHaveTextContent('已搭建并上线 · 图书借阅管理'); + const open = screen.getByTestId('build-progress-open-app'); + const studio = screen.getByTestId('build-progress-design-app'); + expect(open).toHaveTextContent('打开应用'); + expect(open.className).toContain('bg-primary'); + expect(studio).toHaveTextContent('在 Studio 中查看'); + fireEvent.click(open); + expect(onOpenBuiltApp).toHaveBeenCalledWith('library_app'); + fireEvent.click(studio); + // 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 发布。', + ); + }); + + it('zh: the next-steps line quotes each of the envelope\'s `nextSteps`', () => { + render( + inLocale( + 'zh', + , + ), + ); + expect(screen.getByTestId('build-result-next-steps')).toHaveTextContent( + '接下来可以说:「给借阅记录加续借次数」 「只允许管理员删除图书」', + ); + // Said once: the draft card below no longer repeats them as a list. + expect(screen.queryByTestId('draft-next-steps')).not.toBeInTheDocument(); + }); + + it('a build with no publish signal only says it was built — never "live"', () => { + render(); + expect(screen.getByTestId('build-progress-title')).toHaveTextContent(/^Built 图书借阅管理$/); + expect(screen.queryByTestId('build-result-next-steps')).not.toBeInTheDocument(); + expect(screen.getByTestId('build-result-posture')).toBeInTheDocument(); + }); +}); + +/** A plan envelope shaped as the spec's `SolutionBlueprintSchema` declares it. */ +const planEnvelope = { + status: 'blueprint_proposed', + summary: '独立的图书借阅管理应用', + counts: { objects: 2, views: 1, dashboards: 0, seedData: 0 }, + blueprint: { + app: { name: 'library_app', label: '图书借阅管理' }, + objects: [ + { + name: 'book', + label: '图书', + fields: [ + { name: 'title', label: '书名', type: 'text' }, + { name: 'isbn', label: 'ISBN', type: 'text' }, + ], + }, + { name: 'reader', label: '读者', fields: [{ name: 'full_name', label: '姓名', type: 'text' }] }, + ], + views: [{ object: 'book', name: 'loan_board', label: '借阅看板', type: 'kanban', groupBy: 'status' }], + assumptions: ['只建这三部分'], + }, +}; + +const planMessage = (envelope: unknown): ChatbotEnhancedMessage => ({ + id: 'plan-1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 'p1', + toolName: 'propose_blueprint', + state: 'output-available', + result: envelope, + proposedPlan: detectProposedPlan(envelope), + }, + ], +}); + +describe('objectui#12724 — the plan card from what the envelope declares', () => { + it('zh: the header names the app, each object row draws a chip per field, views get rows', () => { + render( + inLocale( + 'zh', + , + ), + ); + expect(screen.getByTestId('proposed-plan-title')).toHaveTextContent(/^方案 · 图书借阅管理$/); + expect(screen.getByTestId('proposed-plan-awaiting')).toHaveTextContent('等待确认'); + const book = screen.getByTestId('proposed-plan-object-book'); + expect(within(book).getAllByTestId('proposed-plan-field').map((c) => c.textContent)).toEqual([ + '书名', + 'ISBN', + ]); + const reader = screen.getByTestId('proposed-plan-object-reader'); + expect(within(reader).getAllByTestId('proposed-plan-field').map((c) => c.textContent)).toEqual(['姓名']); + expect(screen.getAllByTestId('proposed-plan-view').map((v) => v.textContent)).toEqual(['·借阅看板']); + expect(screen.getByTestId('proposed-plan-reply-hint')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-object-chips')).not.toBeInTheDocument(); + }); + + it('degrades to today\'s object chips when the plan carries no field rows (the cache copy)', () => { + const countOnly = { + status: 'blueprint_proposed', + blueprint: { objects: [{ name: 'book', label: '图书', fields: [null, null, null, null] }] }, + }; + render(); + const chips = screen.getByTestId('proposed-plan-object-chips'); + expect(chips).toHaveTextContent('图书· 4'); + expect(screen.queryByTestId('proposed-plan-objects')).not.toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-field')).not.toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-views')).not.toBeInTheDocument(); + expect(screen.getByTestId('proposed-plan-title')).toHaveTextContent(/^Proposed plan$/); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx index 79d668d26a..f9c718024e 100644 --- a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx @@ -1035,9 +1035,10 @@ describe('ChatbotEnhanced — streaming build preview (live build tree)', () => expect(onOpenBuiltApp).toHaveBeenCalledWith('crm'); }); - // ADR-0080 D5 cold-start handoff — Studio is the built app's iteration home, - // so it takes the primary slot and "Open app" demotes to a secondary. - it('renders "Design in Studio" as the PRIMARY action and demotes Open app', () => { + // objectui#12724 (the AiBuild artboard) — the finished card's PRIMARY action + // opens the built app; Studio is the secondary door. Replaces the ADR-0080 D5 + // pin that put Studio in the primary slot. + it('renders "Open app" as the PRIMARY action and Studio as the secondary door (objectui#12724)', () => { const onDesignBuiltApp = vi.fn(); const onOpenBuiltApp = vi.fn(); const doneWithApp: ChatbotEnhancedMessage = { @@ -1054,9 +1055,11 @@ describe('ChatbotEnhanced — streaming build preview (live build tree)', () => onOpenBuiltApp={onOpenBuiltApp} />, ); + const open = screen.getByTestId('build-progress-open-app'); const design = screen.getByTestId('build-progress-design-app'); - expect(design.className).toContain('bg-primary'); - expect(screen.getByTestId('build-progress-open-app').className).toContain('border'); + expect(open.className).toContain('bg-primary'); + expect(design.className).toContain('border'); + expect(design.className).not.toContain('bg-primary'); fireEvent.click(design); expect(onDesignBuiltApp).toHaveBeenCalledWith('crm', undefined); }); diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts index 145ed835fb..6a38291474 100644 --- a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts +++ b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts @@ -506,9 +506,15 @@ describe('proposedPlan detection (propose_blueprint)', () => { expect(plan).toEqual({ summary: 'A lightweight applicant tracker', objects: [ - { name: 'candidate', label: 'Candidate', fieldCount: 2 }, - { name: 'job', label: 'Job', fieldCount: 1 }, + { + name: 'candidate', + label: 'Candidate', + fieldCount: 2, + fields: [{ name: 'full_name' }, { name: 'stage' }], + }, + { name: 'job', label: 'Job', fieldCount: 1, fields: [{ name: 'title' }] }, ], + views: [{ name: 'pipeline' }, { name: 'all' }, { name: 'archived' }], counts: { objects: 2, views: 3, dashboards: 1, seedData: 2 }, questions: ['Track interviews as a separate object, or as a stage field?'], assumptions: ['One pipeline for all roles', 'No external job-board sync'], @@ -530,8 +536,13 @@ describe('proposedPlan detection (propose_blueprint)', () => { ], }); expect(msg.toolInvocations?.[0]?.proposedPlan?.objects).toEqual([ - { name: 'candidate', label: 'Candidate', fieldCount: 2 }, - { name: 'job', label: 'Job', fieldCount: 1 }, + { + name: 'candidate', + label: 'Candidate', + fieldCount: 2, + fields: [{ name: 'full_name' }, { name: 'stage' }], + }, + { name: 'job', label: 'Job', fieldCount: 1, fields: [{ name: 'title' }] }, ]); }); @@ -548,7 +559,7 @@ describe('proposedPlan detection (propose_blueprint)', () => { }, }); expect(plan?.objects).toEqual([ - { name: 'task', fieldCount: 1 }, + { name: 'task', fieldCount: 1, fields: [{ name: 'title' }] }, { name: 'note', fieldCount: 0 }, ]); expect(plan?.counts).toEqual({ objects: 2, views: 1, dashboards: 0, seedData: 0 }); @@ -556,6 +567,49 @@ describe('proposedPlan detection (propose_blueprint)', () => { expect(plan?.assumptions).toEqual([]); }); + // objectui#12724 — the plan card v2 rows, from what the spec's + // `SolutionBlueprintSchema` declares and nothing else: each field's and + // view's `name` + `label`, and `app.label`. + it('lifts field and view names + labels and the app label (objectui#12724)', () => { + const plan = detectProposedPlan({ + status: 'blueprint_proposed', + blueprint: { + app: { name: 'library_app', label: '图书借阅管理' }, + objects: [ + { + name: 'book', + label: '图书', + fields: [ + { name: 'title', label: '书名', type: 'text' }, + { name: 'isbn', type: 'text' }, + { label: 'no name → dropped' }, + null, + ], + }, + ], + views: [{ name: 'loan_board', label: '借阅看板', type: 'kanban', groupBy: 'status' }, { type: 'list' }], + }, + }); + expect(plan?.appLabel).toBe('图书借阅管理'); + expect(plan?.objects[0]).toEqual({ + name: 'book', + label: '图书', + fieldCount: 4, + fields: [{ name: 'title', label: '书名' }, { name: 'isbn' }], + }); + expect(plan?.views).toEqual([{ name: 'loan_board', label: '借阅看板' }]); + }); + + it('a count-only fields array (the cache-fallback copy) lifts no field rows (objectui#12724)', () => { + const plan = detectProposedPlan({ + status: 'blueprint_proposed', + blueprint: { objects: [{ name: 'book', fields: [null, null, null] }] }, + }); + expect(plan?.objects).toEqual([{ name: 'book', fieldCount: 3 }]); + expect(plan?.views).toBeUndefined(); + expect(plan?.appLabel).toBeUndefined(); + }); + it('surfaces the extend-mode targetApp', () => { const plan = detectProposedPlan({ status: 'blueprint_proposed', diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index 3240e1eb2b..709c7829e7 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -268,8 +268,32 @@ export interface DraftReview { */ export interface ProposedPlan { summary?: string; - /** Objects (tables) the build will create — machine name, label, field count. */ - objects: Array<{ name: string; label?: string; fieldCount: number }>; + /** + * objectui#12724 — the app's display label (`blueprint.app.label`, the + * spec's `BlueprintAppSchema`), for the card's 「方案 · APP」 header. Absent + * when the blueprint names no app label. + */ + appLabel?: string; + /** + * Objects (tables) the build will create — machine name, label, field count. + * objectui#12724 — `fields` lifts the name and label of each declared field + * (`blueprint.objects[].fields`, the spec's `BlueprintFieldSchema`) so the + * card can draw one chip per field. Absent when no entry carries a name (a + * cache-fallback reload keeps only the count), and the card then draws the + * object chip with its count, as before. + */ + objects: Array<{ + name: string; + label?: string; + fieldCount: number; + fields?: Array<{ name: string; label?: string }>; + }>; + /** + * objectui#12724 — the views the build will create (`blueprint.views`, the + * spec's `BlueprintViewSchema`), by name and label. Absent when the + * blueprint declares none. + */ + views?: Array<{ name: string; label?: string }>; /** Totals for a compact "N objects · N views · …" line. */ counts: { objects: number; views: number; dashboards: number; seedData: number }; /** ≤2 structure-deciding questions to confirm before building (may be empty). */ @@ -294,6 +318,19 @@ function nonEmptyStrings(v: unknown): string[] { : []; } +/** + * The `{ name, label? }` of each entry of a blueprint array that carries a + * name (the spec's field and view rows both declare these two keys). Entries + * without a string name are dropped, so a count-only cache copy yields none. + */ +function namedEntries(v: unknown): Array<{ name: string; label?: string }> { + return (Array.isArray(v) ? v : []).flatMap((e) => { + const r = e as { name?: unknown; label?: unknown } | null; + if (typeof r?.name !== 'string' || !r.name) return []; + return [{ name: r.name, ...(typeof r.label === 'string' && r.label ? { label: r.label } : {}) }]; + }); +} + export function detectProposedPlan(result: unknown): ProposedPlan | undefined { const obj = parseResultEnvelope(result); if (!obj || obj.status !== 'blueprint_proposed') return undefined; @@ -306,11 +343,13 @@ export function detectProposedPlan(result: unknown): ProposedPlan | undefined { const objects = (Array.isArray(bp.objects) ? bp.objects : []).flatMap((o) => { const r = o as { name?: unknown; label?: unknown; fields?: unknown }; if (typeof r?.name !== 'string' || !r.name) return []; + const fields = namedEntries(r.fields); return [ { name: r.name, ...(typeof r.label === 'string' && r.label ? { label: r.label } : {}), fieldCount: Array.isArray(r.fields) ? r.fields.length : 0, + ...(fields.length ? { fields } : {}), }, ]; }); @@ -350,10 +389,15 @@ export function detectProposedPlan(result: unknown): ProposedPlan | undefined { ? bp.summary : undefined; const targetApp = (obj as { targetApp?: unknown }).targetApp; + const rawApp = bp.app && typeof bp.app === 'object' ? (bp.app as { label?: unknown }) : undefined; + const appLabel = typeof rawApp?.label === 'string' && rawApp.label ? rawApp.label : undefined; + const views = namedEntries(bp.views); return { ...(summary ? { summary } : {}), + ...(appLabel ? { appLabel } : {}), objects, + ...(views.length ? { views } : {}), counts, questions: questions.length ? questions : nonEmptyStrings(bp.questions), ...(questionChoices.length ? { questionChoices } : {}), From 781fc103c56574d01a2498ecbc52bb910bcf73b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:40:42 +0000 Subject: [PATCH 2/3] fix(ai-build): the phase line leads its turn and a waiting plan does not hold it open The line now sits above the design / build panel it summarizes, and its state is the summary strip's: a plan awaiting confirmation already says so on its own card, so the line stays collapsed with a tick. Claude-Session: https://claude.ai/code/session_01XfuGMLXaKo5XaZhNvUTbDL Co-authored-by: Claude --- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 37 ++++++++++--------- .../ChatbotEnhanced.buildChat-12724.test.tsx | 17 +++++++++ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 698400134e..55343e519c 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -3490,6 +3490,18 @@ const ChatbotEnhanced = React.forwardRef( )} > + {/* objectui#12724 — the phase line leads the turn, above + the design / build panel it summarizes. */} + {!isUser && processVisibility === 'summary' && summaryTools.length > 0 ? ( + + ) : null} {blueprintProgress ? ( ( {reasoning} ) : null} - {!isUser && processVisibility === 'summary' && summaryTools.length > 0 ? ( - - ) : null} {!isUser && detailedTools.length > 0 ? detailedTools.map(renderToolDetail) : null} @@ -4768,15 +4770,16 @@ function ToolPhaseSummary({ labels: ToolActivitySummaryLabels; }) { const { t } = useObjectTranslation(); - // The line speaks for the whole turn — every tool it called, including the - // ones that render their own card below (the plan, a draft, an approval) — - // while the expanded rows stay the summary strip they always were. A turn - // whose every tool has its own card draws no line (the host gates on the - // summary strip), so a lone card is never named twice. + // The line's steps speak for the whole turn — every tool it called, + // including the ones that render their own card below (the plan, a draft, an + // approval) — while its state and the expanded rows are the summary strip's: + // a step that waits on the user already says so on its own card (the plan's + // 等待确认), so it does not hold the line open. A turn whose every tool has + // its own card draws no line (the host gates on the summary strip), so a lone + // card is never named twice. const groups = summarizeTools(summaryTools, translateToolTitle); const steps = foldToolPhaseSteps(phaseTools, translateToolTitle); - const turnStates = summarizeTools(phaseTools, translateToolTitle); - const has = (state: ToolSummaryState) => turnStates.some((g) => g.state === state); + const has = (state: ToolSummaryState) => groups.some((g) => g.state === state); const lineState: ToolSummaryState = has('failed') ? 'failed' : has('awaiting') diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx index 5490e06630..6d0435f740 100644 --- a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.buildChat-12724.test.tsx @@ -90,6 +90,23 @@ describe('objectui#12724 — the progress collapses to one line per phase', () = expect(screen.queryByTestId('tool-phase-steps-expanded')).not.toBeInTheDocument(); }); + it('a plan that waits on the user does not hold the line open — its own card says so', () => { + const turn = designTurn(false); + const plan = { + status: 'blueprint_proposed', + blueprint: { objects: [{ name: 'book', label: 'Book', fields: [{ name: 'title' }] }] }, + }; + turn.toolInvocations = turn.toolInvocations!.map((tool) => + tool.toolName === 'propose_blueprint' + ? { ...tool, result: plan, proposedPlan: detectProposedPlan(plan) } + : tool, + ); + render(); + expect(screen.getByTestId('tool-phase-toggle')).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('tool-phase-headline')).toHaveTextContent(/^Design the plan · 4 steps/); + expect(screen.getByTestId('proposed-plan-awaiting')).toBeInTheDocument(); + }); + it('a reopened phase (never measured here) omits the time instead of inventing one', () => { render(); expect(screen.getByTestId('tool-phase-headline')).toHaveTextContent(/^Design the plan · 4 steps$/); From 38ceb1ea04352c57136adbca19b91c5086cab1e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:53:40 +0000 Subject: [PATCH 3/3] test(ai-build): the live plan baseline lifts field names; the cache copy still keeps the count Claude-Session: https://claude.ai/code/session_01XfuGMLXaKo5XaZhNvUTbDL Co-authored-by: Claude --- .../src/hooks/__tests__/useChatConversation.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx b/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx index 93aca68856..8c07202636 100644 --- a/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useChatConversation.test.tsx @@ -477,9 +477,17 @@ describe('sanitizeChatMessagesForCache — affordance cards survive a cache roun // 1. Live render derives the cards from the raw tool output (baseline). const live = uiMessageToChatMessage(liveUiMessage); expect(live.toolInvocations?.[0]?.draftReview?.items).toEqual(draftedOutput.drafted); + // objectui#12724 — the live plan lifts each field's name for the card's + // field chips; the cache copy below keeps only the count, and the card + // degrades to the object chips there. expect(live.toolInvocations?.[1]?.proposedPlan?.objects).toEqual([ - { name: 'candidate', label: 'Candidate', fieldCount: 2 }, - { name: 'job', fieldCount: 1 }, + { + name: 'candidate', + label: 'Candidate', + fieldCount: 2, + fields: [{ name: 'full_name' }, { name: 'stage' }], + }, + { name: 'job', fieldCount: 1, fields: [{ name: 'title' }] }, ]); // 2. Cache it, then round-trip through JSON exactly like localStorage does.