diff --git a/.changeset/27480-iterate-or-new-question.md b/.changeset/27480-iterate-or-new-question.md new file mode 100644 index 00000000000..f7f2e4d9038 --- /dev/null +++ b/.changeset/27480-iterate-or-new-question.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-chatbot': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(plugin-chatbot): a plan that overlaps an existing app asks whether to extend it or build a new one, instead of offering Build it + +Clause-②: yes (widening) +Metadata-protocol: none + +When `propose_blueprint` finds that the plan overlaps an app the user already has, its result carries `decision: 'iterate_or_new'` and the overlapping app in `existingAppOverlap`. The chat used to draw that result as an ordinary plan card with a "Build it" button, so one click could build a duplicate of the existing app. + +- **The question.** `detectProposedPlan` lifts the decision as `ProposedPlan.iterateOrNew`, with the app's `name` and `label` from `existingAppOverlap`. The plan card keeps its rows and, in place of "Build it" / "Adjust", asks whether to keep improving that app or build a separate one. Each of the two choices sends its answer as a chat message. A result that names no object still gets the question, never the fallback "Build it" card; an overlap that names no app gets the question without a name and a reply hint. +- **The answers follow the conversation.** `ChatbotEnhanced` takes two new optional props, `planExtendExistingMessage(app)` and `planBuildNewAppMessage`. The console resolves both by the conversation's language, like the plan card's other outbound messages (`console.ai.planExtendExistingMessage` and `console.ai.planBuildNewAppMessage`, in the `en` and `zh` packs only). Neither reads as approval to the confirm gate. +- **After a reload.** Server history re-parses the stored result. The message cache now writes `decision` and `existingAppOverlap` back into its compact plan envelope, so a cache-fallback reload asks the question too. +- **Labels.** `chatbot.plan.iterateOrNew.*` (question, choices, reply hint) in all ten packs. diff --git a/content/docs/frontend/plugins/plugin-chatbot.mdx b/content/docs/frontend/plugins/plugin-chatbot.mdx index 5715fa629b3..2b8df79851a 100644 --- a/content/docs/frontend/plugins/plugin-chatbot.mdx +++ b/content/docs/frontend/plugins/plugin-chatbot.mdx @@ -496,6 +496,13 @@ results declare. The plan card (`propose_blueprint`) reads the result's dashed chip for a field the agent added on its own, the record's name field, 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). +A plan whose result says `decision: 'iterate_or_new'` overlaps an app the user +already has (`existingAppOverlap`, by `name` and `label`). Its card draws no +"Build it" button: in its place it asks whether to keep improving that app or +build a separate one, naming the app, and each choice sends its answer as a +chat message (`planExtendExistingMessage(app)` and `planBuildNewAppMessage`, +which the console picks by the conversation's language). The question survives +a reload from server history and from the console's message cache. 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/ui/packages/app-shell/src/console/ai/AiChatPage.tsx b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx index d529e41ce4e..eab93be5a28 100644 --- a/ui/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/ui/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2875,6 +2875,11 @@ export function ChatPane({ planAnswerMessage={(question, option) => outboundText('planAnswerMessage', { question, option }) } + // objectstack#27480 — the two answers of the extend-or-new question an + // `iterate_or_new` plan asks: outbound text, so the conversation's + // language picks them, like every message above. + planExtendExistingMessage={(app) => outboundText('planExtendExistingMessage', { app })} + planBuildNewAppMessage={outboundText('planBuildNewAppMessage')} // Self-use "magic moment": when the plan enables it, publish the drafted // app automatically the moment the agent finishes — no manual click; the // user refreshes and sees it live WITH data. Same governed endpoint. diff --git a/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.iterateOrNewReload-27480.test.tsx b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.iterateOrNewReload-27480.test.tsx new file mode 100644 index 00000000000..b4bc73b483a --- /dev/null +++ b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.iterateOrNewReload-27480.test.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27480 — the extend-or-new question an `iterate_or_new` plan asks + * survives a reload, on both reload paths this page has. Without that, a reload + * turns the question back into an ordinary plan card with a "Build it" button: + * the one-click duplicate the question exists to prevent, one refresh later. + * + * - server history: the stored tool result is merged back onto its call + * (`toUIMessages`) and re-parsed by the same detector the live chat uses; + * - cache fallback: the cache keeps a compact `output` rebuilt from the + * derived plan (`sanitizeChatMessagesForCache`), so the question's keys + * must be written back into it. + * + * Each path ends where the user does: the card rendered from the reloaded + * messages, asserted for the question and for the absence of "Build it". + */ +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { ChatbotEnhanced, uiMessageToChatMessage } from '@object-ui/plugin-chatbot'; +import { hydratedMessagesToChatMessages } from '../AiChatPage'; +import { sanitizeChatMessagesForCache, toUIMessages } from '../../../hooks/useChatConversation'; + +const ITERATE_OR_NEW = { + status: 'blueprint_proposed', + decision: 'iterate_or_new', + existingAppOverlap: { name: 'library', label: '图书馆' }, + summary: 'A reading-list app', + blueprint: { + objects: [{ name: 'book', label: 'Book', fields: [{ name: 'title' }, { name: 'author' }] }], + }, + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], +}; + +const LIVE = { + id: 'a1', + role: 'assistant' as const, + parts: [ + { + type: 'tool-propose_blueprint', + toolCallId: 'plan-1', + state: 'output-available' as const, + input: {}, + output: ITERATE_OR_NEW, + }, + ], +}; + +function renderCard(messages: React.ComponentProps['messages']) { + render(); +} + +function expectQuestionNotApproval() { + expect(screen.getByTestId('proposed-plan-iterate-or-new-question').textContent).toContain('图书馆'); + expect(screen.getByTestId('proposed-plan-extend-existing')).toBeInTheDocument(); + expect(screen.getByTestId('proposed-plan-build-new')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-approve')).toBeNull(); + expect(screen.queryByTestId('proposed-plan-fallback')).toBeNull(); +} + +afterEach(cleanup); + +describe('the iterate_or_new question survives a reload (objectstack#27480)', () => { + it('server history: the stored result re-parses to the question', () => { + const ui = toUIMessages([ + { + id: 'a1', + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'plan-1', toolName: 'propose_blueprint', input: {} }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'plan-1', + output: { type: 'text', value: JSON.stringify(ITERATE_OR_NEW) }, + }, + ], + }, + ]); + const messages = hydratedMessagesToChatMessages(ui); + expect(messages[0]?.toolInvocations?.[0]?.proposedPlan?.iterateOrNew).toEqual({ + app: { name: 'library', label: '图书馆' }, + }); + renderCard(messages); + expectQuestionNotApproval(); + }); + + it('cache fallback: the compact cached output keeps both keys the question is read from', () => { + const live = uiMessageToChatMessage(LIVE); + const persisted = JSON.parse(JSON.stringify(sanitizeChatMessagesForCache([live]))) as ReturnType< + typeof sanitizeChatMessagesForCache + >; + const planPart = persisted[0]?.parts.find((p) => p.type === 'tool-propose_blueprint'); + expect(planPart?.output).toMatchObject({ + status: 'blueprint_proposed', + decision: 'iterate_or_new', + existingAppOverlap: { name: 'library', label: '图书馆' }, + }); + + // Both reads a cache-fallback reload performs: this page's hydration + // mapper, and the live mapper `useObjectChat` re-runs over the same parts. + for (const messages of [hydratedMessagesToChatMessages(persisted), [uiMessageToChatMessage(persisted[0])]]) { + expect(messages[0]?.toolInvocations?.[0]?.proposedPlan?.iterateOrNew).toEqual({ + app: { name: 'library', label: '图书馆' }, + }); + cleanup(); + renderCard(messages); + expectQuestionNotApproval(); + } + }); + + it('cache fallback: a plain proposal is cached without the question keys (control)', () => { + const plain = { ...ITERATE_OR_NEW, decision: undefined, existingAppOverlap: undefined }; + const live = uiMessageToChatMessage({ ...LIVE, parts: [{ ...LIVE.parts[0], output: plain }] }); + const persisted = sanitizeChatMessagesForCache([live]); + const output = persisted[0]?.parts.find((p) => p.type === 'tool-propose_blueprint')?.output as Record< + string, + unknown + >; + expect(output.status).toBe('blueprint_proposed'); + expect('decision' in output).toBe(false); + expect('existingAppOverlap' in output).toBe(false); + renderCard(hydratedMessagesToChatMessages(persisted)); + expect(screen.getByTestId('proposed-plan-approve')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx index c7ea2062614..43deb73a967 100644 --- a/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx +++ b/ui/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx @@ -276,3 +276,35 @@ describe('#3896 — outbound text follows the CONVERSATION, in both directions', ); }); }); + +/** + * objectstack#27480 — the extend-or-new question an `iterate_or_new` plan asks + * SENDS one of two answers, so they sit on the send side of the same line: the + * conversation's language picks them, never the console's. The rows that can + * discriminate are the ones where the two disagree. + */ +describe('objectstack#27480 — the extend-or-new answers follow the CONVERSATION', () => { + function answers(props: Record) { + return { + extend: (props.planExtendExistingMessage as (app: string) => string)('图书馆'), + buildNew: props.planBuildNewAppMessage, + }; + } + + it.each(['en', 'zh', 'de'])('%s UI + Chinese thread: both answers are Chinese', (uiLocale) => { + expect(answers(renderPane(uiLocale, ZH_THREAD))).toEqual({ + extend: '在「图书馆」上继续完善,不另建应用。', + buildNew: '另建一个独立应用。', + }); + }); + + it.each(['zh', 'en', 'de'])('%s UI + English thread: both answers are English', (uiLocale) => { + const props = renderPane(uiLocale, EN_THREAD); + expect(answers(props)).toEqual({ + extend: 'Keep improving "图书馆" instead of building a new app.', + buildNew: 'Build a separate, new app.', + }); + // Nothing Chinese leaves the console for an English thread but the app's own name. + expect(CJK.test(String(props.planBuildNewAppMessage)), `outbound Chinese under ${uiLocale} UI`).toBe(false); + }); +}); diff --git a/ui/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts b/ui/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts index 2818bb061e2..c11ee3f2482 100644 --- a/ui/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts +++ b/ui/packages/app-shell/src/console/ai/__tests__/outboundAgentText.test.ts @@ -8,7 +8,7 @@ * and nothing else. There is deliberately no `I18nProvider` in this file — a UI * locale cannot even be expressed here, which is the strongest statement of the * rule the unit level can make. `AiChatPage.planCardLocale.test.tsx` then pins - * the same four strings through a real provider, where a UI locale exists and + * the same strings through a real provider, where a UI locale exists and * could still leak in. * * Gate conformance is NOT re-asserted here: the cloud `APPROVAL_RE` mirror lives @@ -29,6 +29,8 @@ const KEYS = [ 'planApproveMessage', 'planApproveDefaultsMessage', 'planAnswerMessage', + 'planExtendExistingMessage', + 'planBuildNewAppMessage', 'changesConfirmMessage', ] as const; @@ -95,4 +97,18 @@ describe('outbound agent text follows the conversation, never the UI locale (#38 expect(resolveOutboundAgentText(false, 'planAnswerMessage')).toBe(packValue(en, 'planAnswerMessage')); }); }); + + describe('planExtendExistingMessage names the app (objectstack#27480)', () => { + it('fills the app in a Chinese conversation', () => { + expect(resolveOutboundAgentText(true, 'planExtendExistingMessage', { app: '图书馆' })).toBe( + '在「图书馆」上继续完善,不另建应用。', + ); + }); + + it('fills the app in an English conversation', () => { + expect(resolveOutboundAgentText(false, 'planExtendExistingMessage', { app: 'Library' })).toBe( + 'Keep improving "Library" instead of building a new app.', + ); + }); + }); }); diff --git a/ui/packages/app-shell/src/console/ai/outboundAgentText.ts b/ui/packages/app-shell/src/console/ai/outboundAgentText.ts index 1cbed8a5505..46a13bf1985 100644 --- a/ui/packages/app-shell/src/console/ai/outboundAgentText.ts +++ b/ui/packages/app-shell/src/console/ai/outboundAgentText.ts @@ -19,7 +19,7 @@ // worse: never gated at all, so it sent the UI locale's value in both // directions. // -// Hence this module: ONE resolver, four sites, both directions, and the UI pack +// Hence this module: ONE resolver, every outbound site, both directions, and the UI pack // is never consulted. Chinese conversation -> the `zh` pack's value; anything // else -> the `en` pack's value. Reading the built-in packs directly (instead of // `t(key, { lng })`) is deliberate: an i18next lookup's answer depends on which @@ -38,6 +38,8 @@ export type OutboundAgentTextKey = | 'planApproveMessage' | 'planApproveDefaultsMessage' | 'planAnswerMessage' + | 'planExtendExistingMessage' + | 'planBuildNewAppMessage' | 'changesConfirmMessage'; /** @@ -63,6 +65,17 @@ const GATE_TEXT: Record> = { zh: '关于「{{question}}」,我选择「{{option}}」。', en: 'For "{{question}}", go with: {{option}}.', }, + // objectstack#27480 — the two answers of the extend-or-new question an + // `iterate_or_new` plan asks. Like `planAnswerMessage` they answer a question + // rather than approve a plan. + planExtendExistingMessage: { + zh: '在「{{app}}」上继续完善,不另建应用。', + en: 'Keep improving "{{app}}" instead of building a new app.', + }, + planBuildNewAppMessage: { + zh: '另建一个独立应用。', + en: 'Build a separate, new app.', + }, changesConfirmMessage: { zh: '确认修改,应用你刚才提议的改动。', en: 'Confirm — apply the change you just proposed.', @@ -106,9 +119,9 @@ export function resolveOutboundAgentText( // the boots where the console is running in Chinese. When it is not — an // English console answering a Chinese thread — this falls through to // {@link GATE_TEXT}, which `outboundAgentText.test.ts` pins BYTE-IDENTICAL to - // the `zh` pack's four values, so the text that leaves is the same either + // the `zh` pack's outbound values, so the text that leaves is the same either // way. ⛔ Do not import the `zh` pack back to close that branch: it would put - // a second 156 KB catalogue into every page load to re-derive four strings + // a second 156 KB catalogue into every page load to re-derive strings // that are already pinned equal. const template = packText(getLoadedBuiltInLocales()[lang], key) ?? GATE_TEXT[key][lang]; diff --git a/ui/packages/app-shell/src/hooks/useChatConversation.ts b/ui/packages/app-shell/src/hooks/useChatConversation.ts index 787d178a257..3a70b517262 100644 --- a/ui/packages/app-shell/src/hooks/useChatConversation.ts +++ b/ui/packages/app-shell/src/hooks/useChatConversation.ts @@ -167,6 +167,12 @@ interface CachedProposedPlan { questions: string[]; assumptions: string[]; targetApp?: string; + /** + * objectstack#27480 — the extend-or-new question a `decision: 'iterate_or_new'` + * plan asks. Kept through the cache because without it a cache-fallback + * reload turns the question back into a plan with a "Build it" button. + */ + iterateOrNew?: { app?: { name: string; label?: string } }; } interface CacheableChatToolInvocation { @@ -256,6 +262,13 @@ function proposedPlanToCachedResult(pp: CachedProposedPlan): Record ({ name: o.name, diff --git a/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index 5eca1ab5977..2d517aca68f 100644 --- a/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -116,6 +116,8 @@ const OUTBOUND_KEYS = new Set([ 'console.ai.planApproveMessage', 'console.ai.planApproveDefaultsMessage', 'console.ai.planAnswerMessage', + 'console.ai.planExtendExistingMessage', + 'console.ai.planBuildNewAppMessage', 'console.ai.changesConfirmMessage', ]); diff --git a/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx b/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx index 19031e12fa8..5d2a24bfa9f 100644 --- a/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx @@ -376,6 +376,8 @@ describe('objectui#3546 slice four — the console namespace', () => { 'planAnswerMessage', 'planApproveDefaultsMessage', 'planApproveMessage', + 'planBuildNewAppMessage', + 'planExtendExistingMessage', ]); // #3896's headline defect in one line: an outbound message read through // `t()` is read from the UI pack, which is how a zh console sent Chinese diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 745cfb1bf21..91d9628d476 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -359,7 +359,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 98 once objectstack-ai/objectstack#27176 added // `preview.changes.draftedByMcpClient`, the changes drawer's line that names // the MCP client („{{client}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(98); + // 100 once objectstack-ai/objectstack#27480 added + // `chatbot.plan.iterateOrNew.question` and `.extend`, the plan card's + // extend-or-new question and its choice that name the existing app + // („{{app}}“) — one interpolated span each, runtime data. + expect(okSpans, 'correctly paired spans').toBe(100); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -427,7 +431,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // objectstack-ai/objectstack#26469 reworded `chatbot.build.draftPosture`, // one more matched pair. 98 / 98 / 0 after objectstack-ai/objectstack#27176 // added `preview.changes.draftedByMcpClient`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 98, close: 98, rdq: 0 }); + // 100 / 100 / 0 after objectstack-ai/objectstack#27480 added + // `chatbot.plan.iterateOrNew.question` and `.extend`, one matched pair each. + expect({ open, close, rdq }).toEqual({ open: 100, close: 100, 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/ui/packages/i18n/src/__tests__/i18n.test.ts b/ui/packages/i18n/src/__tests__/i18n.test.ts index 1d082a2a69a..b9deb68bea3 100644 --- a/ui/packages/i18n/src/__tests__/i18n.test.ts +++ b/ui/packages/i18n/src/__tests__/i18n.test.ts @@ -170,6 +170,21 @@ describe('@object-ui/i18n', () => { expect(msg).not.toMatch(GATE); }); + it('the extend-or-new answers answer a question without reading as approval (objectstack#27480)', () => { + // An `iterate_or_new` plan overlaps an app that already exists, and its + // two answers tell the agent which way to go. Read as approval, either + // one would let the gate build the overlapping plan exactly as proposed — + // the one-click duplicate the question exists to prevent. + for (const lang of ['zh', 'en']) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + for (const key of ['console.ai.planExtendExistingMessage', 'console.ai.planBuildNewAppMessage']) { + const msg = i18n.t(key, { app: 'Library' }); + expect(msg, `${lang} ${key} must not fall through to its raw key`).not.toBe(key); + expect(msg, `${lang} ${key} reads as approval to the cloud APPROVAL_RE`).not.toMatch(GATE); + } + } + }); + it('the inline HITL deny reason never reads as approval, in any pack (objectui#11667)', () => { // `console.ai.toolDenyReason` is a label — it follows the UI locale, so // all ten packs define it — but it does not stay on screen: the reject diff --git a/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts b/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts index 97a48a9bbc6..aa88d9aa510 100644 --- a/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts +++ b/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts @@ -4,8 +4,9 @@ * Guard: the strings the console SENDS to the AI agent must not be translated * into arbitrary locales. * - * Most `console.ai.*` keys are labels and should be localized everywhere. Four - * are not labels — they are the message text a button transmits to the agent, + * Most `console.ai.*` keys are labels and should be localized everywhere. The + * ones in `OUTBOUND_KEYS` below are not labels — they are the message text a + * button transmits to the agent, * and the cloud confirm gate (`service-ai-studio` `confirm-gate.ts` * `APPROVAL_RE`) decides whether that text counts as approval. It recognises * Chinese and English. Nothing else. @@ -13,7 +14,7 @@ * So `AiChatPage` picks them by the language of the CONVERSATION, not the UI — * since objectui#3896 through one resolver * (`packages/app-shell/src/console/ai/outboundAgentText.ts`) that reads the two - * gate packs directly and never calls `t()` for these four keys: + * gate packs directly and never calls `t()` for these keys: * * zh conversation -> the `zh` pack's value // matches the gate * anything else -> the `en` pack's value // matches the gate @@ -46,6 +47,8 @@ const OUTBOUND_KEYS = [ 'console.ai.planApproveMessage', 'console.ai.planApproveDefaultsMessage', 'console.ai.planAnswerMessage', + 'console.ai.planExtendExistingMessage', + 'console.ai.planBuildNewAppMessage', 'console.ai.changesConfirmMessage', ] as const; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 1e575af4cdb..5602d552314 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4048,6 +4048,13 @@ const ar = { countSeedData: "بيانات تجريبية", extendTarget: "إضافة إلى التطبيق الحالي: {{app}}", awaitingConfirm: "بانتظار التأكيد", + iterateOrNew: { + question: "يتداخل هذا مع «{{app}}»، وهو تطبيق لديك بالفعل. هل تريد مواصلة تحسينه أم إنشاء تطبيق منفصل؟", + questionUnnamed: "يتداخل هذا مع تطبيق لديك بالفعل. هل تريد مواصلة تحسين ذلك التطبيق أم إنشاء تطبيق منفصل؟", + extend: "مواصلة تحسين «{{app}}»", + buildNew: "إنشاء تطبيق منفصل", + replyHint: "أجب لتحديد اختيارك.", + }, objectsAndFields: "الكائنات والحقول", views: "طرق العرض", suggestedField: "مقترح", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index e0a23f5d052..b60090a0e18 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -3747,6 +3747,13 @@ const de = { countSeedData: "Beispieldaten", extendTarget: "Wird zur bestehenden App hinzugefügt: {{app}}", awaitingConfirm: "Wartet auf Bestätigung", + iterateOrNew: { + question: "Das überschneidet sich mit „{{app}}“, einer App, die Sie bereits haben. Diese App weiter verbessern oder eine separate App erstellen?", + questionUnnamed: "Das überschneidet sich mit einer App, die Sie bereits haben. Diese App weiter verbessern oder eine separate App erstellen?", + extend: "„{{app}}“ weiter verbessern", + buildNew: "Separate App erstellen", + replyHint: "Antworten Sie, welche Option Sie wählen.", + }, objectsAndFields: "Objekte und Felder", views: "Ansichten", suggestedField: "ergänzt", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 1fe4c7407b8..0fe6b0ba262 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2512,6 +2512,8 @@ const en = { planApproveDefaultsMessage: 'Build it with your best assumptions; use sensible defaults for the open questions.', planAnswerMessage: 'For "{{question}}", go with: {{option}}.', + planExtendExistingMessage: 'Keep improving "{{app}}" instead of building a new app.', + planBuildNewAppMessage: 'Build a separate, new app.', changesTitle: 'Confirm changes', changesConfirmed: 'Confirmed', changesConfirm: 'Confirm', @@ -4508,6 +4510,13 @@ const en = { countSeedData: 'sample data', extendTarget: 'Adding to existing app: {{app}}', awaitingConfirm: 'Awaiting confirmation', + iterateOrNew: { + question: 'This overlaps "{{app}}", an app you already have. Keep improving it, or build a separate app?', + questionUnnamed: 'This overlaps an app you already have. Keep improving that app, or build a separate one?', + extend: 'Keep improving "{{app}}"', + buildNew: 'Build a separate app', + replyHint: 'Reply to say which.', + }, objectsAndFields: 'Objects and fields', views: 'Views', suggestedField: 'suggested', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 12e38350c92..aa0128c1e68 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -3823,6 +3823,13 @@ const es = { countSeedData: "datos de ejemplo", extendTarget: "Se añade a la app existente: {{app}}", awaitingConfirm: "Pendiente de confirmación", + iterateOrNew: { + question: "Esto se solapa con «{{app}}», una app que ya tiene. ¿Seguir mejorándola o crear una app independiente?", + questionUnnamed: "Esto se solapa con una app que ya tiene. ¿Seguir mejorando esa app o crear una independiente?", + extend: "Seguir mejorando «{{app}}»", + buildNew: "Crear una app independiente", + replyHint: "Responda para indicar cuál prefiere.", + }, objectsAndFields: "Objetos y campos", views: "Vistas", suggestedField: "sugerido", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 552f517f250..6c089c3dd72 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -3819,6 +3819,13 @@ const fr = { countSeedData: "données d’exemple", extendTarget: "Ajout à l’app existante : {{app}}", awaitingConfirm: "En attente de confirmation", + iterateOrNew: { + question: "Ce plan recoupe « {{app}} », une app que vous avez déjà. Continuer à l’améliorer, ou créer une app distincte ?", + questionUnnamed: "Ce plan recoupe une app que vous avez déjà. Continuer à améliorer cette app, ou en créer une distincte ?", + extend: "Continuer à améliorer « {{app}} »", + buildNew: "Créer une app distincte", + replyHint: "Répondez pour indiquer votre choix.", + }, objectsAndFields: "Objets et champs", views: "Vues", suggestedField: "suggéré", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f741aa3a704..894096f3992 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -3749,6 +3749,13 @@ const ja = { countSeedData: "サンプルデータ", extendTarget: "既存のアプリに追加:{{app}}", awaitingConfirm: "確認待ち", + iterateOrNew: { + question: "このプランは既存のアプリ「{{app}}」と重なっています。このアプリを引き続き改善しますか、それとも別のアプリを作成しますか?", + questionUnnamed: "このプランは既存のアプリと重なっています。そのアプリを引き続き改善しますか、それとも別のアプリを作成しますか?", + extend: "「{{app}}」を引き続き改善", + buildNew: "別のアプリを作成", + replyHint: "どちらにするか返信してください。", + }, objectsAndFields: "オブジェクトとフィールド", views: "ビュー", suggestedField: "追加提案", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e1102bc3870..fa643040aa8 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -3746,6 +3746,13 @@ const ko = { countSeedData: "샘플 데이터", extendTarget: "기존 앱에 추가: {{app}}", awaitingConfirm: "확인 대기 중", + iterateOrNew: { + question: "이 계획은 이미 있는 앱 “{{app}}”과(와) 겹칩니다. 이 앱을 계속 개선할까요, 아니면 별도의 앱을 만들까요?", + questionUnnamed: "이 계획은 이미 있는 앱과 겹칩니다. 그 앱을 계속 개선할까요, 아니면 별도의 앱을 만들까요?", + extend: "“{{app}}” 계속 개선", + buildNew: "별도의 앱 만들기", + replyHint: "어느 쪽인지 회신해 주세요.", + }, objectsAndFields: "개체 및 필드", views: "뷰", suggestedField: "추가 제안", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index fef9cfe5736..01c27a810dc 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -3818,6 +3818,13 @@ const pt = { countSeedData: "dados de exemplo", extendTarget: "Adicionando ao app existente: {{app}}", awaitingConfirm: "Aguardando confirmação", + iterateOrNew: { + question: "Isto se sobrepõe a “{{app}}”, um app que você já tem. Continuar melhorando esse app ou criar um app separado?", + questionUnnamed: "Isto se sobrepõe a um app que você já tem. Continuar melhorando esse app ou criar um separado?", + extend: "Continuar melhorando “{{app}}”", + buildNew: "Criar um app separado", + replyHint: "Responda indicando qual prefere.", + }, objectsAndFields: "Objetos e campos", views: "Visões", suggestedField: "sugerido", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 6b562f493c2..204a8aa5ea9 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -3908,6 +3908,13 @@ const ru = { countSeedData: "демоданные", extendTarget: "Добавление в существующее приложение: {{app}}", awaitingConfirm: "Ожидает подтверждения", + iterateOrNew: { + question: "Это пересекается с «{{app}}» — приложением, которое у вас уже есть. Продолжить улучшать его или создать отдельное приложение?", + questionUnnamed: "Это пересекается с приложением, которое у вас уже есть. Продолжить улучшать его или создать отдельное приложение?", + extend: "Продолжить улучшать «{{app}}»", + buildNew: "Создать отдельное приложение", + replyHint: "Ответьте, какой вариант выбрать.", + }, objectsAndFields: "Объекты и поля", views: "Представления", suggestedField: "предложено", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 185ea43baa2..db5f60341af 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2088,6 +2088,8 @@ const zh = { planApproveMessage: '确认,开始搭建。', planApproveDefaultsMessage: '确认搭建,未决问题按你的合理假设和默认处理。', planAnswerMessage: '关于「{{question}}」,我选择「{{option}}」。', + planExtendExistingMessage: '在「{{app}}」上继续完善,不另建应用。', + planBuildNewAppMessage: '另建一个独立应用。', changesTitle: '确认改动', changesConfirmed: '已确认', changesConfirm: '确认修改', @@ -3933,6 +3935,13 @@ const zh = { countSeedData: '示例数据', extendTarget: '添加到现有应用:{{app}}', awaitingConfirm: '等待确认', + iterateOrNew: { + question: '这个方案和你已有的应用「{{app}}」重叠。要在它上面继续完善,还是另建一个独立应用?', + questionUnnamed: '这个方案和你已有的一个应用重叠。要在那个应用上继续完善,还是另建一个独立应用?', + extend: '在「{{app}}」上继续完善', + buildNew: '另建一个独立应用', + replyHint: '回复告诉我选哪一个。', + }, objectsAndFields: '对象与字段', views: '视图', suggestedField: '补充', diff --git a/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 1eccab7a81c..eda6f473860 100644 --- a/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/ui/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -856,6 +856,19 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes string; + /** + * objectstack#27480 — builds the message the extend-or-new question's "keep + * improving" choice sends (a plan whose `proposedPlan.iterateOrNew` is set), + * given the existing app's display name. OUTBOUND text like + * `planApproveMessage`, so the console picks it by the language of the + * conversation. Default: `Keep improving "" instead of building a new app.` + */ + planExtendExistingMessage?: (app: string) => string; + /** + * objectstack#27480 — the message the extend-or-new question's "build a + * separate app" choice sends; outbound, as above (default "Build a separate, new app."). + */ + planBuildNewAppMessage?: string; /** * Labels for the granular confirm-before-change card (`changes_proposed`). * Separate from the plan card's: that one approves a whole build, this one @@ -1907,6 +1920,91 @@ function useExtendChip( ); } +/** + * objectstack#27480 — the extend-or-new question a `decision: 'iterate_or_new'` + * plan asks, drawn in place of the plan card's "Build it" gate. The plan + * overlaps an app the user already has, so one click on "Build it" could build + * a duplicate of it; the card asks instead, and each choice sends its answer + * as a chat message (`onChoose`), the same channel the user would type into. + * + * The app is named by the envelope's `label`, else by the host's + * `resolveAppLabel`, else by its machine name. An overlap that names no app + * gets the question without a name and a reply hint, not a one-click + * "keep improving" that could not say which app it means. + * + * `mode`: `choose` draws the two choices; `reply` (the host wired no message + * sending) draws the reply hint; `settled` (a build already ran after this + * question) draws the question alone. + */ +function IterateOrNewQuestion({ + iterateOrNew, + resolveAppLabel, + mode, + onChoose, + disabled, + extendMessage, + buildNewMessage, +}: { + iterateOrNew: NonNullable; + resolveAppLabel: ((appName: string) => string | undefined) | undefined; + mode: 'choose' | 'reply' | 'settled'; + onChoose: (message: string) => void; + disabled: boolean; + extendMessage: (app: string) => string; + buildNewMessage: string; +}) { + const { t } = useObjectTranslation(); + const { app } = iterateOrNew; + const appName = app ? app.label || resolveAppLabel?.(app.name) || app.name : undefined; + const question = appName + ? t('chatbot.plan.iterateOrNew.question', { + app: appName, + defaultValue: 'This overlaps "{{app}}", an app you already have. Keep improving it, or build a separate app?', + }) + : t('chatbot.plan.iterateOrNew.questionUnnamed', { + defaultValue: 'This overlaps an app you already have. Keep improving that app, or build a separate one?', + }); + const choiceClass = + 'inline-flex h-7 items-center rounded-md border bg-background px-3 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50'; + return ( +
+ + + {question} + + {mode === 'choose' && appName ? ( +
+ + +
+ ) : mode === 'settled' ? null : ( + + {t('chatbot.plan.iterateOrNew.replyHint', { defaultValue: 'Reply to say which.' })} + + )} +
+ ); +} + function summarizeTools( tools: ChatbotEnhancedToolInvocation[], /** @@ -2087,6 +2185,8 @@ const ChatbotEnhanced = React.forwardRef( planApproveMessage = 'Looks good — build it as proposed.', planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.', planAnswerMessage = (question: string, option: string) => `For "${question}", go with: ${option}.`, + planExtendExistingMessage = (app: string) => `Keep improving "${app}" instead of building a new app.`, + planBuildNewAppMessage = 'Build a separate, new app.', changesTitleLabel = 'Confirm changes', changesConfirmedLabel = 'Confirmed', changesConfirmLabel = 'Confirm', @@ -3640,8 +3740,24 @@ const ChatbotEnhanced = React.forwardRef( the input so the user types changes. Falls back to a text hint when the host hasn't wired message sending. Once this plan's build has run (issue #432) the actions collapse to a static - "Built" badge so it can't be re-triggered. */} - {onSendMessage ? ( + "Built" badge so it can't be re-triggered. + objectstack#27480 — an `iterate_or_new` plan has no such + gate: it overlaps an app that already exists, so in place + of the actions it asks whether to keep improving that app + or build a separate one. */} + {tool.proposedPlan.iterateOrNew ? ( + + ) : onSendMessage ? ( builtPlanIds.has(tool.toolCallId) ? (
[0][number]; + +const PLAIN = { + status: 'blueprint_proposed', + summary: 'A reading-list app', + blueprint: { + objects: [{ name: 'book', label: 'Book', fields: [{ name: 'title' }, { name: 'author' }] }], + }, + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], +}; + +const ITERATE_OR_NEW = { + ...PLAIN, + decision: 'iterate_or_new', + existingAppOverlap: { name: 'library', label: '图书馆' }, +}; + +function proposal(result: unknown): WireMessage[] { + return [ + { id: 'u1', role: 'user', parts: [{ type: 'text', text: 'Build me a library app' }] }, + { + id: 'a1', + role: 'assistant', + parts: [ + { + type: 'tool-propose_blueprint', + toolCallId: 'plan-1', + state: 'output-available', + output: { type: 'text', value: JSON.stringify(result) }, + }, + ], + }, + ] as WireMessage[]; +} + +const messagesOf = (wire: WireMessage[]) => uiMessagesToChatMessages(wire, { isStreaming: false }); + +afterEach(cleanup); + +describe('the iterate_or_new plan asks extend-or-new instead of offering Build it (objectstack#27480)', () => { + it('renders the question naming the existing app, and no approve button', () => { + render(); + // The card's done-when first: no approval gate of any kind. + expect(screen.queryByTestId('proposed-plan-approve')).toBeNull(); + expect(screen.queryByTestId('proposed-plan-adjust')).toBeNull(); + expect(screen.queryByTestId('proposed-plan-fallback')).toBeNull(); + expect(screen.queryByText('Build it')).toBeNull(); + expect(screen.getByTestId('proposed-plan-iterate-or-new-question').textContent).toContain('图书馆'); + // The plan's own rows stay, so the user sees what would be built either way. + expect(screen.getByTestId('proposed-plan')).toBeInTheDocument(); + }); + + it('a plain proposal is unchanged: Build it, Adjust, and no question (control)', () => { + render(); + expect(screen.getByTestId('proposed-plan-approve')).toBeInTheDocument(); + expect(screen.getByTestId('proposed-plan-adjust')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-iterate-or-new')).toBeNull(); + }); + + it('each choice sends its answer', () => { + const onSendMessage = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('proposed-plan-extend-existing')); + expect(onSendMessage).toHaveBeenLastCalledWith('Keep improving "图书馆" instead of building a new app.'); + fireEvent.click(screen.getByTestId('proposed-plan-build-new')); + expect(onSendMessage).toHaveBeenLastCalledWith('Build a separate, new app.'); + expect(onSendMessage).toHaveBeenCalledTimes(2); + }); + + it("sends the host's outbound text when the host supplies it", () => { + const onSendMessage = vi.fn(); + render( + `在「${app}」上继续完善,不另建应用。`} + planBuildNewAppMessage="另建一个独立应用。" + />, + ); + fireEvent.click(screen.getByTestId('proposed-plan-extend-existing')); + expect(onSendMessage).toHaveBeenLastCalledWith('在「图书馆」上继续完善,不另建应用。'); + fireEvent.click(screen.getByTestId('proposed-plan-build-new')); + expect(onSendMessage).toHaveBeenLastCalledWith('另建一个独立应用。'); + }); + + it('zh: the question and both choices read in Chinese and name the app', () => { + render( + + + , + ); + expect(screen.getByTestId('proposed-plan-iterate-or-new-question').textContent).toContain('「图书馆」'); + expect(screen.getByTestId('proposed-plan-extend-existing').textContent).toBe('在「图书馆」上继续完善'); + expect(screen.getByTestId('proposed-plan-build-new').textContent).toBe('另建一个独立应用'); + }); + + it('still asks, and draws no fallback Build it, when the envelope names no object', () => { + const noObjects = { ...ITERATE_OR_NEW, blueprint: { objects: [] } }; + render(); + expect(screen.getByTestId('proposed-plan-iterate-or-new')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-fallback')).toBeNull(); + expect(screen.queryByTestId('proposed-plan-approve')).toBeNull(); + }); + + it("names the app by the host's label when the envelope carries only its name", () => { + const nameOnly = { ...ITERATE_OR_NEW, existingAppOverlap: { name: 'library' } }; + render( + (name === 'library' ? 'Library' : undefined)} + />, + ); + expect(screen.getByTestId('proposed-plan-extend-existing').textContent).toBe('Keep improving "Library"'); + }); + + it('an overlap that names no app asks without a name, with a reply hint and no one-click choice', () => { + const unnamed = { ...ITERATE_OR_NEW, existingAppOverlap: {} }; + render(); + expect(screen.getByTestId('proposed-plan-iterate-or-new-question').textContent).toContain( + 'an app you already have', + ); + expect(screen.getByTestId('proposed-plan-iterate-or-new-hint')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-iterate-or-new-choices')).toBeNull(); + expect(screen.queryByTestId('proposed-plan-approve')).toBeNull(); + }); + + it('a host that wires no message sending gets the question and a reply hint', () => { + render(); + expect(screen.getByTestId('proposed-plan-iterate-or-new-hint')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-iterate-or-new-choices')).toBeNull(); + }); + + it('once a build has run after it, the question stays and its choices go', () => { + const wire: WireMessage[] = [ + ...proposal(ITERATE_OR_NEW), + { id: 'u2', role: 'user', parts: [{ type: 'text', text: 'Build a separate, new app.' }] }, + { + id: 'a2', + role: 'assistant', + parts: [ + { + type: 'tool-apply_blueprint', + toolCallId: 'build-1', + state: 'output-available', + output: { status: 'drafted', drafted: [{ type: 'object', name: 'book' }] }, + }, + ], + }, + ] as WireMessage[]; + render(); + expect(screen.getByTestId('proposed-plan-iterate-or-new-question')).toBeInTheDocument(); + expect(screen.queryByTestId('proposed-plan-iterate-or-new-choices')).toBeNull(); + expect(screen.queryByTestId('proposed-plan-iterate-or-new-hint')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-chatbot/src/__tests__/mapMessages.iterateOrNew-27480.test.ts b/ui/packages/plugin-chatbot/src/__tests__/mapMessages.iterateOrNew-27480.test.ts new file mode 100644 index 00000000000..abbcc90941e --- /dev/null +++ b/ui/packages/plugin-chatbot/src/__tests__/mapMessages.iterateOrNew-27480.test.ts @@ -0,0 +1,98 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectstack#27480 — a `propose_blueprint` result whose `decision` is + * `iterate_or_new` overlaps an app the user already has. It is lifted as a + * plan that carries the extend-or-new QUESTION (`ProposedPlan.iterateOrNew`), + * so the card can ask instead of offering "Build it". The overlapping app is + * read from `existingAppOverlap`'s own `name` / `label`. + * + * The plain proposal is the control: it lifts exactly what it did before, with + * no `iterateOrNew` key at all. + */ +import { describe, it, expect } from 'vitest'; +import { detectProposedPlan, uiMessageToChatMessage } from '../mapMessages'; + +const blueprint = { + app: { name: 'library_v2', label: '图书馆 2' }, + objects: [{ name: 'book', label: '图书', fields: [{ name: 'title' }, { name: 'isbn' }] }], +}; + +const iterateOrNew = { + status: 'blueprint_proposed', + decision: 'iterate_or_new', + existingAppOverlap: { name: 'library', label: '图书馆' }, + blueprint, + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], +}; + +const plain = { + status: 'blueprint_proposed', + blueprint, + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], +}; + +describe('detectProposedPlan — the iterate_or_new question (objectstack#27480)', () => { + it('lifts the overlapping app by name and label', () => { + const plan = detectProposedPlan(iterateOrNew); + expect(plan?.iterateOrNew).toEqual({ app: { name: 'library', label: '图书馆' } }); + // The plan's own rows still lift: the card draws them above the question. + expect(plan?.objects).toEqual([ + { name: 'book', label: '图书', fieldCount: 2, fields: [{ name: 'title' }, { name: 'isbn' }] }, + ]); + }); + + it('peels the persisted text wrapper like every other envelope', () => { + const plan = detectProposedPlan({ type: 'text', value: JSON.stringify(iterateOrNew) }); + expect(plan?.iterateOrNew).toEqual({ app: { name: 'library', label: '图书馆' } }); + }); + + it('a plain proposal is unchanged: no iterateOrNew key', () => { + const plan = detectProposedPlan(plain); + expect(plan).toBeDefined(); + expect(plan && 'iterateOrNew' in plan).toBe(false); + }); + + it('a decision other than iterate_or_new lifts nothing', () => { + const plan = detectProposedPlan({ ...plain, decision: 'new' }); + expect(plan && 'iterateOrNew' in plan).toBe(false); + }); + + it('is still a question when the envelope names no object', () => { + // Without this the detector answers undefined, and the chat draws the + // FALLBACK card for an unparsed proposal — which has a "Build it" button. + const plan = detectProposedPlan({ ...iterateOrNew, blueprint: { objects: [] } }); + expect(plan?.iterateOrNew).toEqual({ app: { name: 'library', label: '图书馆' } }); + expect(plan?.objects).toEqual([]); + }); + + it('keeps the label optional, and leaves app absent when the overlap names no app', () => { + expect( + detectProposedPlan({ ...iterateOrNew, existingAppOverlap: { name: 'library' } })?.iterateOrNew, + ).toEqual({ app: { name: 'library' } }); + expect(detectProposedPlan({ ...iterateOrNew, existingAppOverlap: { label: '图书馆' } })?.iterateOrNew).toEqual({}); + expect(detectProposedPlan({ ...iterateOrNew, existingAppOverlap: undefined })?.iterateOrNew).toEqual({}); + }); + + it('reaches the tool invocation through the live mapper', () => { + const msg = uiMessageToChatMessage({ + id: 'a1', + role: 'assistant', + parts: [ + { + type: 'tool-propose_blueprint', + toolCallId: 't1', + state: 'output-available', + input: {}, + output: iterateOrNew, + }, + ], + }); + expect(msg.toolInvocations?.[0]?.proposedPlan?.iterateOrNew).toEqual({ + app: { name: 'library', label: '图书馆' }, + }); + }); +}); diff --git a/ui/packages/plugin-chatbot/src/mapMessages.ts b/ui/packages/plugin-chatbot/src/mapMessages.ts index 7163219432a..1501c1a3046 100644 --- a/ui/packages/plugin-chatbot/src/mapMessages.ts +++ b/ui/packages/plugin-chatbot/src/mapMessages.ts @@ -330,6 +330,19 @@ export interface ProposedPlan { * reads it only when there is no `planCard`, whose views carry the same line. */ viewIntents?: ProposedPlanViewLine[]; + /** + * objectstack#27480 — the envelope's `decision: 'iterate_or_new'`: the plan + * overlaps an app that already exists (`existingAppOverlap`), so it is a + * QUESTION, not a plan to approve. The card asks whether to keep improving + * that app or build a separate one, and draws no "Build it" button. `app` is + * the overlapping app's `name` / `label`, absent when the envelope names none. + */ + iterateOrNew?: ProposedPlanIterateOrNew; +} + +/** objectstack#27480 — the extend-or-new question a `decision: 'iterate_or_new'` plan asks. */ +export interface ProposedPlanIterateOrNew { + app?: { name: string; label?: string }; } /** One view's intent line: `viewIntents[]` and `planCard.views[]` both carry it. */ @@ -448,8 +461,11 @@ export function detectProposedPlan(result: unknown): ProposedPlan | undefined { }, ]; }); + // objectstack#27480 — an `iterate_or_new` plan is a question; it is drawn + // even when it names no object, because what it asks is the whole card. + const iterateOrNew = liftIterateOrNew(obj); // No nameable objects → nothing reviewable; don't render an empty card. - if (objects.length === 0) return undefined; + if (objects.length === 0 && !iterateOrNew) return undefined; const num = (v: unknown, fallback: number): number => typeof v === 'number' && Number.isFinite(v) ? v : fallback; @@ -503,6 +519,7 @@ export function detectProposedPlan(result: unknown): ProposedPlan | undefined { ...(typeof targetApp === 'string' && targetApp ? { targetApp } : {}), ...(planCard ? { planCard } : {}), ...(viewIntents.length ? { viewIntents } : {}), + ...(iterateOrNew ? { iterateOrNew } : {}), }; } @@ -519,6 +536,20 @@ function viewLines(v: unknown[]): ProposedPlanViewLine[] { }); } +/** + * objectstack#27480 — lift the extend-or-new question off an envelope whose + * `decision` is `iterate_or_new`. The overlapping app is read from + * `existingAppOverlap`'s own `name` / `label`, the keys the card that asked + * for this names; an overlap without a name leaves `app` absent, and the card + * then asks without naming the app. Any other `decision` lifts nothing. + */ +function liftIterateOrNew(envelope: Record): ProposedPlanIterateOrNew | undefined { + if (envelope.decision !== 'iterate_or_new') return undefined; + const overlap = recordOf(envelope.existingAppOverlap); + if (!overlap || !isText(overlap.name)) return {}; + return { app: { name: overlap.name, ...(isText(overlap.label) ? { label: overlap.label } : {}) } }; +} + /** * objectui#12724 — lift the plan card's drawn keys off `planCard`. The schema * requires `objects`, `views` and `assumptions`; a value without all three is