Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/12724-plan-card-posture.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 2 additions & 1 deletion content/docs/plugins/plugin-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3506,11 +3506,14 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
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.
<div className="flex flex-col gap-0.5" data-testid="proposed-plan-assumptions">
<span className="text-[11px] font-medium text-foreground/70">
{planAssumptionsLabel}
Expand All @@ -3519,6 +3522,11 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
{designNotes.map((a, idx) => (
<li key={idx}>{a}</li>
))}
{posture ? (
<li data-testid="proposed-plan-posture" data-posture={posture.mode}>
{posture.line}
</li>
) : null}
</ul>
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: lang, detectBrowserLanguage: false }}>
<ChatbotEnhanced messages={[turn(envelope)]} onSendMessage={vi.fn()} />
</I18nProvider>,
);

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -482,6 +482,33 @@ export const BUILD_CARDS_SCHEMA: Record<string, unknown> =
"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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const W = {
loanTitleTemplate: '{图书} · {读者} · {借出日期}',
loanTitleLine: '名称:自动生成「{图书} · {读者} · {借出日期}」,表单里不出现',
nextSteps: ['给借阅记录加续借次数', '做一个本月借出统计的仪表盘', '只允许管理员删除图书'],
postureFirstBuild: '首次搭建直接上线;之后的修改先存草稿,由你发布',
postureIntoLiveApp: '这个应用已上线,有 36 条记录在用;这次的改动先存为草稿,由你在「变更」里核对后发布',
},
en: {
app: 'Library Lending',
Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -280,6 +297,7 @@ export function planEnvelope(lang: Lang, opts: { generatedTitle?: boolean } = {}
],
line: w.sampleLine,
},
...(posture ? { posture } : {}),
},
};
}
Expand Down
13 changes: 13 additions & 0 deletions packages/plugin-chatbot/src/mapMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -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 } }
: {}),
};
}

Expand Down
Loading