From 1e7b88176a3d6e36a712f4c10d07f2229a108862 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 08:58:44 +0000 Subject: [PATCH] feat(plugin-chatbot): the plan card says how the build will land (objectui#12724) cloud#2924 ruling A declared `planCard.posture` on propose_blueprint's envelope (cloud PR #3111). The plan card now draws its `line` as the last item of the assumptions list, where the AiBuild artboard draws it, and keeps its earlier rows when the key is absent. The vendored build-cards schema moves to cloud f0ecef3a, whose only change is that key; the fixtures gain a first-build and a draft posture and the contract test checks both against it. Part of #12724 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01R3ARF6ZEyBWW8EBT9we3eX --- .changeset/12724-plan-card-posture.md | 8 ++ content/docs/plugins/plugin-chatbot.mdx | 3 +- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 12 ++- ...botEnhanced.planCardPosture-12724.test.tsx | 83 +++++++++++++++++++ .../buildCards.contract-12724.test.ts | 5 ++ .../__tests__/fixtures/build-cards.schema.ts | 29 ++++++- .../__tests__/fixtures/buildCards.fixtures.ts | 20 ++++- packages/plugin-chatbot/src/mapMessages.ts | 13 +++ 8 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 .changeset/12724-plan-card-posture.md create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planCardPosture-12724.test.tsx diff --git a/.changeset/12724-plan-card-posture.md b/.changeset/12724-plan-card-posture.md new file mode 100644 index 0000000000..7541f743df --- /dev/null +++ b/.changeset/12724-plan-card-posture.md @@ -0,0 +1,8 @@ +--- +"@object-ui/plugin-chatbot": minor +--- + +Clause-②: yes +Metadata-protocol: none + +The AI build chat's plan card now says how the build will land. When `propose_blueprint`'s `planCard` carries `posture` (objectstack-ai/cloud#2924 ruling A), its `line` (e.g. "This first build goes live at once; later changes are saved as drafts for you to publish") is drawn as the last item of the assumptions list. The exported `ProposedPlanCard` type gains the optional `posture: { mode: 'publish' | 'draft'; line: string }`. A plan card without the key draws as before (objectui#12724). diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 6a32b4a77f..b2dc8e7b80 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -410,7 +410,8 @@ The AI build conversation's two cards draw only what the build agent's tool results declare. The plan card (`propose_blueprint`) reads the result's `planCard`: a chip per field with the format its sample data is held to, a dashed chip for a field the agent added on its own, the record's name field, -one line per view, the assumptions, and the sample data the build will stage. +one line per view, the assumptions, the sample data the build will stage, and +how the build will land (`posture`: live at once, or saved as a draft). The result card (`apply_blueprint`, then a later `verify_build` in the same turn) reads `checklist`: a passed/total chip, the verified lines in two columns, and the app and package that "Open app" and "View in Studio" open. diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 8ec8590a5c..e2d48d11f0 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -3506,11 +3506,14 @@ const ChatbotEnhanced = React.forwardRef( const { designNotes, deferred } = classifyAssumptions( tool.proposedPlan!.assumptions, ); + const posture = tool.proposedPlan!.planCard?.posture; return ( <> - {designNotes.length > 0 ? ( + {designNotes.length > 0 || posture ? ( // objectui#12724 — a list, one item per assumption - // (`planCard.assumptions`, else `blueprint.assumptions`). + // (`planCard.assumptions`, else `blueprint.assumptions`), + // then how the build will land (`planCard.posture.line`, + // cloud#2924) as the last item, where the artboard draws it.
{planAssumptionsLabel} @@ -3519,6 +3522,11 @@ const ChatbotEnhanced = React.forwardRef( {designNotes.map((a, idx) => (
  • {a}
  • ))} + {posture ? ( +
  • + {posture.line} +
  • + ) : null}
    ) : null} diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planCardPosture-12724.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planCardPosture-12724.test.tsx new file mode 100644 index 0000000000..22e11ba981 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planCardPosture-12724.test.tsx @@ -0,0 +1,83 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#12724 — the plan card says how the build will land (the AiBuild + * artboard's last 假设 item, 「首次搭建直接上线;之后的修改先存草稿,由你发布」) from + * the `planCard.posture` key objectstack-ai/cloud declares (cloud#2924 ruling A; + * `build-cards.schema.json`, vendored as `fixtures/build-cards.schema.ts`). A plan + * card without the key says nothing about it. + * + * Each message goes through the real mapper (`uiMessageToChatMessage`) from a + * raw tool part, so the lift is under test, not a hand-built invocation. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ChatbotEnhanced } from '../ChatbotEnhanced'; +import { uiMessageToChatMessage } from '../mapMessages'; +import { planEnvelope } from './fixtures/buildCards.fixtures'; + +const turn = (output: unknown) => + uiMessageToChatMessage({ + id: 'plan', + role: 'assistant', + parts: [{ type: 'tool-propose_blueprint', toolCallId: 'plan-0', state: 'output-available', input: {}, output }], + }); + +const renderPlan = (envelope: unknown, lang: 'zh' | 'en' = 'zh') => + render( + + + , + ); + +const assumptionItems = () => + within(screen.getByTestId('proposed-plan-assumptions')) + .getAllByRole('listitem') + .map((li) => li.textContent); + +describe('objectui#12724 — the plan card draws `planCard.posture.line`', () => { + it('zh: a first build`s line is the last assumption item', () => { + renderPlan(planEnvelope('zh', { posture: 'firstBuild' })); + expect(assumptionItems()).toEqual([ + '只建这三部分,不建仪表盘、预约、罚款;补充的字段(分类)可以去掉。', + '归还后状态置为已还;逾期由应还日期已过且未归还判定。', + '首次搭建直接上线;之后的修改先存草稿,由你发布', + ]); + expect(screen.getByTestId('proposed-plan-posture')).toHaveAttribute('data-posture', 'publish'); + }); + + it('en: the same line in English', () => { + renderPlan(planEnvelope('en', { posture: 'firstBuild' }), 'en'); + expect(screen.getByTestId('proposed-plan-posture')).toHaveTextContent( + /^This first build goes live at once; later changes are saved as drafts for you to publish$/, + ); + }); + + it('a build into a live app draws the producer`s draft line, records included', () => { + renderPlan(planEnvelope('zh', { posture: 'draft' })); + const line = screen.getByTestId('proposed-plan-posture'); + expect(line).toHaveTextContent(/^这个应用已上线,有 36 条记录在用;这次的改动先存为草稿,由你在「变更」里核对后发布$/); + expect(line).toHaveAttribute('data-posture', 'draft'); + }); + + it('a plan card with no assumptions still draws the section for the posture line alone', () => { + const plan = planEnvelope('zh', { posture: 'firstBuild' }); + renderPlan({ ...plan, planCard: { ...plan.planCard, assumptions: [] } }); + expect(assumptionItems()).toEqual(['首次搭建直接上线;之后的修改先存草稿,由你发布']); + }); + + it('CONTROL: a plan card without `posture` draws only the assumptions', () => { + renderPlan(planEnvelope('zh')); + expect(screen.queryByTestId('proposed-plan-posture')).not.toBeInTheDocument(); + expect(assumptionItems()).toHaveLength(2); + }); + + it('CONTROL: a posture whose mode the schema does not declare is not drawn', () => { + const plan = planEnvelope('zh', { posture: 'firstBuild' }); + renderPlan({ ...plan, planCard: { ...plan.planCard, posture: { ...plan.planCard.posture, mode: 'live' } } }); + expect(screen.queryByTestId('proposed-plan-posture')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/buildCards.contract-12724.test.ts b/packages/plugin-chatbot/src/__tests__/buildCards.contract-12724.test.ts index 5d1eb5b873..ab99d529af 100644 --- a/packages/plugin-chatbot/src/__tests__/buildCards.contract-12724.test.ts +++ b/packages/plugin-chatbot/src/__tests__/buildCards.contract-12724.test.ts @@ -115,6 +115,11 @@ describe('the build-card fixtures are payloads the producer declares (objectui#1 expect(violations(PLAN, planEnvelope(lang, { generatedTitle: true }))).toEqual([]); }); + it.each(['zh', 'en'] as const)('%s: the envelopes with a publish posture satisfy ProposeBlueprintCardPayload (cloud#2924)', (lang) => { + expect(violations(PLAN, planEnvelope(lang, { posture: 'firstBuild' }))).toEqual([]); + expect(violations(PLAN, planEnvelope(lang, { posture: 'draft' }))).toEqual([]); + }); + it.each(['zh', 'en'] as const)('%s: the apply_blueprint envelopes satisfy BuildResultCardPayload', (lang) => { expect(violations(RESULT, applyEnvelope(lang))).toEqual([]); expect(violations(RESULT, applyEnvelope(lang, { materialized: true }))).toEqual([]); diff --git a/packages/plugin-chatbot/src/__tests__/fixtures/build-cards.schema.ts b/packages/plugin-chatbot/src/__tests__/fixtures/build-cards.schema.ts index 8608e199c1..3724fa0e30 100644 --- a/packages/plugin-chatbot/src/__tests__/fixtures/build-cards.schema.ts +++ b/packages/plugin-chatbot/src/__tests__/fixtures/build-cards.schema.ts @@ -1,4 +1,4 @@ -// Vendored from objectstack-ai/cloud packages/service-ai-studio/schema/build-cards.schema.json at f7ab9cc8 (cloud PR #3047); the object below is that file verbatim. +// Vendored from objectstack-ai/cloud packages/service-ai-studio/schema/build-cards.schema.json at f0ecef3a (cloud PR #3111); the object below is that file verbatim. // // objectui#12724 — the producer's contract for the two build cards the chat draws: // `ProposeBlueprintCardPayload` (`propose_blueprint`'s `planCard`, beside the @@ -482,6 +482,33 @@ export const BUILD_CARDS_SCHEMA: Record = "line" ], "additionalProperties": false + }, + "posture": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "publish", + "draft" + ] + }, + "liveRecords": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "line": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "mode", + "liveRecords", + "line" + ], + "additionalProperties": false } }, "required": [ diff --git a/packages/plugin-chatbot/src/__tests__/fixtures/buildCards.fixtures.ts b/packages/plugin-chatbot/src/__tests__/fixtures/buildCards.fixtures.ts index f9d53f0e82..790e29dc78 100644 --- a/packages/plugin-chatbot/src/__tests__/fixtures/buildCards.fixtures.ts +++ b/packages/plugin-chatbot/src/__tests__/fixtures/buildCards.fixtures.ts @@ -59,6 +59,8 @@ const W = { loanTitleTemplate: '{图书} · {读者} · {借出日期}', loanTitleLine: '名称:自动生成「{图书} · {读者} · {借出日期}」,表单里不出现', nextSteps: ['给借阅记录加续借次数', '做一个本月借出统计的仪表盘', '只允许管理员删除图书'], + postureFirstBuild: '首次搭建直接上线;之后的修改先存草稿,由你发布', + postureIntoLiveApp: '这个应用已上线,有 36 条记录在用;这次的改动先存为草稿,由你在「变更」里核对后发布', }, en: { app: 'Library Lending', @@ -104,6 +106,9 @@ const W = { loanTitleTemplate: '{Book} · {Reader} · {Loan date}', loanTitleLine: 'Name: generated as “{Book} · {Reader} · {Loan date}”, not on the form', nextSteps: ['Add a renewal count to loans', 'Make a dashboard of this month’s loans', 'Only admins may delete books'], + postureFirstBuild: 'This first build goes live at once; later changes are saved as drafts for you to publish', + postureIntoLiveApp: + 'This app is live with 36 records in use; this change is saved as a draft for you to review in Changes and publish', }, } as const; @@ -113,9 +118,21 @@ const W = { * from its two lookups and its date, as cloud#2925's builder writes it: a formula * `nameField` on the blueprint, and on the plan card `nameField.generated` with * the title field left out of the loan's chips (its line stands for it). + * `posture` (cloud#2924 ruling A) adds `planCard.posture` as `planCardOf` + * writes it: `'firstBuild'` for a new app's first build, `'draft'` for a build + * into an app live with 36 records. */ -export function planEnvelope(lang: Lang, opts: { generatedTitle?: boolean } = {}) { +export function planEnvelope( + lang: Lang, + opts: { generatedTitle?: boolean; posture?: 'firstBuild' | 'draft' } = {}, +) { const w = W[lang]; + const posture = + opts.posture === 'firstBuild' + ? { mode: 'publish' as const, liveRecords: 0, line: w.postureFirstBuild } + : opts.posture === 'draft' + ? { mode: 'draft' as const, liveRecords: 36, line: w.postureIntoLiveApp } + : undefined; const loanTitleField = { name: 'title', label: w.loanTitle, @@ -280,6 +297,7 @@ export function planEnvelope(lang: Lang, opts: { generatedTitle?: boolean } = {} ], line: w.sampleLine, }, + ...(posture ? { posture } : {}), }, }; } diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index 199fb05ce3..c3d4b84462 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -372,6 +372,15 @@ export interface ProposedPlanCard { assumptions: string[]; /** The sample data the build stages, as one line. Absent when it stages none. */ sampleData?: { line: string }; + /** + * cloud#2924 ruling A — how the build will land once approved: `publish` (a + * new app's first build, or an app with no records yet) or `draft` (an app + * live with records), and the card's `line` (e.g. 「首次搭建直接上线;之后的修改 + * 先存草稿,由你发布」). Absent where the build does not publish in the turn, + * and the card then says nothing about it. The producer's `liveRecords` is + * already in the line, so it is not lifted. + */ + posture?: { mode: 'publish' | 'draft'; line: string }; } /** @@ -552,11 +561,15 @@ function liftPlanCard(raw: unknown): ProposedPlanCard | undefined { ]; }); const sampleData = recordOf(card.sampleData); + const posture = recordOf(card.posture); return { objects, views: viewLines(card.views), assumptions: nonEmptyStrings(card.assumptions), ...(sampleData && isText(sampleData.line) ? { sampleData: { line: sampleData.line } } : {}), + ...(posture && (posture.mode === 'publish' || posture.mode === 'draft') && isText(posture.line) + ? { posture: { mode: posture.mode, line: posture.line } } + : {}), }; }