From beaf703644d31b008d9121a0aabed664f618254b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:52:39 +0800 Subject: [PATCH 1/5] fix(plugin-chatbot,app-shell): the plan card's scope chip is localized and names the target app by its label; the in-app composer names the task (objectui#11658 items 5, 2) Item 5: both extend-mode chip sites (the proposed-plan card and the live design panel) read the locale pack's `chatbot.plan.extendTarget` sentence instead of the English literal the component defaulted to, and name the target app by the label the host resolves (`resolveAppLabel`); the machine name moves to the tooltip. A host `planExtendLabel` still wins. Item 2: inside an app (the build agent scoped to a package) the composer placeholder is `console.ai.askOrChangeApp`, never "Ask {agent}". Co-authored-by: Claude --- .../app-shell/src/console/ai/AiChatPage.tsx | 20 +++- packages/i18n/src/locales/ar.ts | 2 + packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + packages/i18n/src/locales/es.ts | 2 + packages/i18n/src/locales/fr.ts | 2 + packages/i18n/src/locales/ja.ts | 2 + packages/i18n/src/locales/ko.ts | 2 + packages/i18n/src/locales/pt.ts | 2 + packages/i18n/src/locales/ru.ts | 2 + packages/i18n/src/locales/zh.ts | 2 + .../plugin-chatbot/src/ChatbotEnhanced.tsx | 57 +++++++-- .../ChatbotEnhanced.extendChip-11658.test.tsx | 110 ++++++++++++++++++ 13 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.extendChip-11658.test.tsx diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 3ca6bb71d5..9dc127f9de 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2006,6 +2006,17 @@ export function ChatPane({ return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined; }, [editPackageId, metadataApps, appLabel, t]); + // objectui#11658 — the plan card's extend-mode scope chip names the existing + // app it would add to by that app's label; the plan carries its machine name. + // An app not (yet) in metadata answers undefined and the chip shows the name. + const resolveAppLabel = useCallback( + (appName: string) => { + const app = (metadataApps ?? []).find((a) => a.name === appName); + return app ? appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }) : undefined; + }, + [metadataApps, appLabel, t], + ); + // Per-surface empty-state branding (Build = authoring, Ask = data Q&A). On the // build surface, `?package=` flips it to edit mode: "what do you want to // change in " + change-oriented starters, instead of the from-scratch @@ -2368,7 +2379,13 @@ 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') - : t('console.ai.askAgent', { agent: activeAgentLabel }) + // 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') + : t('console.ai.askAgent', { agent: activeAgentLabel }) : agentsLoading ? t('console.ai.loadingAgents') : t('console.ai.askAnything') @@ -2556,6 +2573,7 @@ export function ChatPane({ publishedLabel={t('console.ai.published', { defaultValue: 'Published' })} nextStepsLabel={t('console.ai.nextSteps', { defaultValue: "What's next" })} planTitleLabel={t('console.ai.planTitle', { defaultValue: 'Proposed plan' })} + resolveAppLabel={resolveAppLabel} planQuestionsLabel={t('console.ai.planQuestions', { defaultValue: 'Confirm before building' })} planAssumptionsLabel={t('console.ai.planAssumptions', { defaultValue: 'Assumptions' })} planDeferredLabel={t('console.ai.planDeferred', { defaultValue: 'Not yet built' })} diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index f489838e2e..cda03d4c90 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1922,6 +1922,7 @@ const ar = { liveCanvasUnlisted: "تطبيق حي — {{app}} (غير مُدرج حتى النشر)", loadingAgents: "جارٍ تحميل الوكلاء…", askAnything: "اسأل عن أي شيء…", + askOrChangeApp: "اسأل عن بياناتك، أو اطلب مني تعديل هذا التطبيق…", emptyTitle: "ابدأ محادثة", emptyDescription: "اسأل عن أي شيء — يمكن للمساعد الاطلاع على سياق تطبيقك الحالي.", switchAssistant: "تبديل المساعد", @@ -3535,6 +3536,7 @@ const ar = { countDashboards_many: "{{count}} لوحة معلومات", countDashboards_other: "{{count}} لوحة معلومات", countSeedData: "بيانات تجريبية", + extendTarget: "إضافة إلى التطبيق الحالي: {{app}}", }, build: { building: "جارٍ إنشاء {{app}}…", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a05211323a..235abc6a1e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1737,6 +1737,7 @@ const de = { liveCanvasUnlisted: "Live-App — {{app}} (nicht gelistet bis zur Veröffentlichung)", loadingAgents: "Agenten werden geladen…", askAnything: "Fragen Sie irgendetwas…", + askOrChangeApp: "Fragen Sie zu Ihren Daten oder lassen Sie mich diese App ändern…", emptyTitle: "Unterhaltung beginnen", emptyDescription: "Fragen Sie irgendetwas — der Assistent hat Zugriff auf den Kontext Ihrer aktuellen App.", switchAssistant: "Assistenten wechseln", @@ -3272,6 +3273,7 @@ const de = { countDashboards_one: "{{count}} Dashboard", countDashboards_other: "{{count}} Dashboards", countSeedData: "Beispieldaten", + extendTarget: "Wird zur bestehenden App hinzugefügt: {{app}}", }, build: { building: "{{app}} wird erstellt…", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 0d24ab5d74..a1232b337d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2218,6 +2218,7 @@ const en = { liveCanvasUnlisted: 'Live app — {{app}} (unlisted until published)', loadingAgents: 'Loading agents…', askAnything: 'Ask anything…', + askOrChangeApp: 'Ask about your data, or ask me to change this app…', emptyTitle: 'Start a conversation', emptyDescription: 'Ask anything — the assistant has access to your current app context.', switchAssistant: 'Switch assistant', @@ -3864,6 +3865,7 @@ const en = { countDashboards_one: '{{count}} dashboard', countDashboards_other: '{{count}} dashboards', countSeedData: 'sample data', + extendTarget: 'Adding to existing app: {{app}}', }, build: { building: 'Building {{app}}…', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a0c4719be6..c46e0e7576 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1783,6 +1783,7 @@ const es = { liveCanvasUnlisted: "Aplicación en vivo — {{app}} (no listada hasta su publicación)", loadingAgents: "Cargando agentes…", askAnything: "Pregunte lo que quiera…", + askOrChangeApp: "Pregunte por sus datos o pídame que cambie esta app…", 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", @@ -3336,6 +3337,7 @@ const es = { countDashboards_many: "{{count}} de paneles", countDashboards_other: "{{count}} paneles", countSeedData: "datos de ejemplo", + extendTarget: "Se añade a la app existente: {{app}}", }, build: { building: "Creando {{app}}…", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 36dd8c2c74..eefaf9b30a 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1780,6 +1780,7 @@ const fr = { liveCanvasUnlisted: "Application en direct — {{app}} (non répertoriée jusqu'à publication)", 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…", 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", @@ -3333,6 +3334,7 @@ const fr = { countDashboards_many: "{{count}} de tableaux de bord", countDashboards_other: "{{count}} tableaux de bord", countSeedData: "données d’exemple", + extendTarget: "Ajout à l’app existante : {{app}}", }, build: { building: "Création de {{app}}…", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index fbc1594935..1a08d966f6 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1739,6 +1739,7 @@ const ja = { liveCanvasUnlisted: "ライブアプリ — {{app}}(公開するまで非掲載)", loadingAgents: "エージェントを読み込んでいます…", askAnything: "何でも質問してください…", + askOrChangeApp: "データについて質問するか、このアプリの変更を依頼…", emptyTitle: "会話を始める", emptyDescription: "何でも質問してください — アシスタントは現在のアプリのコンテキストを参照できます。", switchAssistant: "アシスタントを切り替え", @@ -3274,6 +3275,7 @@ const ja = { countDashboards_one: "{{count}} 件のダッシュボード", countDashboards_other: "{{count}} 件のダッシュボード", countSeedData: "サンプルデータ", + extendTarget: "既存のアプリに追加:{{app}}", }, build: { building: "{{app}}を構築しています…", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 760f0166eb..a5b44cb425 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1737,6 +1737,7 @@ const ko = { liveCanvasUnlisted: "실시간 앱 — {{app}}(게시 전까지 비공개)", loadingAgents: "에이전트를 불러오는 중…", askAnything: "무엇이든 물어보세요…", + askOrChangeApp: "데이터에 대해 묻거나 이 앱을 바꿔 달라고 요청하세요…", emptyTitle: "대화 시작하기", emptyDescription: "무엇이든 물어보세요 — 어시스턴트는 현재 앱 컨텍스트에 접근할 수 있습니다.", switchAssistant: "어시스턴트 전환", @@ -3271,6 +3272,7 @@ const ko = { countDashboards_one: "대시보드 {{count}}개", countDashboards_other: "대시보드 {{count}}개", countSeedData: "샘플 데이터", + extendTarget: "기존 앱에 추가: {{app}}", }, build: { building: "{{app}} 빌드 중…", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 69f6fcfcda..ed7b5274e9 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1778,6 +1778,7 @@ const pt = { liveCanvasUnlisted: "Aplicativo ao vivo — {{app}} (não listado até ser publicado)", loadingAgents: "Carregando agentes…", askAnything: "Pergunte qualquer coisa…", + askOrChangeApp: "Pergunte sobre seus dados ou peça para eu alterar este app…", emptyTitle: "Iniciar uma conversa", emptyDescription: "Pergunte qualquer coisa — o assistente tem acesso ao contexto do seu aplicativo atual.", switchAssistant: "Trocar de assistente", @@ -3331,6 +3332,7 @@ const pt = { countDashboards_many: "{{count}} de painéis", countDashboards_other: "{{count}} painéis", countSeedData: "dados de exemplo", + extendTarget: "Adicionando ao app existente: {{app}}", }, build: { building: "Criando {{app}}…", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 0498036bcc..c7f78ad7a9 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1838,6 +1838,7 @@ const ru = { liveCanvasUnlisted: "Работающее приложение — {{app}} (скрыто до публикации)", loadingAgents: "Загрузка агентов…", askAnything: "Спросите о чём угодно…", + askOrChangeApp: "Спросите о данных или попросите меня изменить это приложение…", emptyTitle: "Начните диалог", emptyDescription: "Спросите о чём угодно — ассистент видит контекст вашего текущего приложения.", switchAssistant: "Сменить ассистента", @@ -3416,6 +3417,7 @@ const ru = { countDashboards_many: "{{count}} дашбордов", countDashboards_other: "{{count}} дашборда", countSeedData: "демоданные", + extendTarget: "Добавление в существующее приложение: {{app}}", }, build: { building: "Создание {{app}}…", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6ab7151c21..fe4773e3ca 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1905,6 +1905,7 @@ const zh = { liveCanvasUnlisted: '运行预览 · {{app}}(未发布 · 仅自己可见)', loadingAgents: '正在加载助手…', askAnything: '输入你的问题…', + askOrChangeApp: '问问数据,或让我改改这个应用…', emptyTitle: '开始对话', emptyDescription: '可以直接提问,助手会结合当前应用上下文提供帮助。', switchAssistant: '切换助手', @@ -3419,6 +3420,7 @@ const zh = { countDashboards_one: '{{count}} 个仪表板', countDashboards_other: '{{count}} 个仪表板', countSeedData: '示例数据', + extendTarget: '添加到现有应用:{{app}}', }, build: { building: '正在构建{{app}}…', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 4ab789d9a8..e1e1f4ba8c 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -671,9 +671,16 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes string | undefined; /** Heading above the structure-deciding questions in the plan card (default "Confirm before building"). */ planQuestionsLabel?: string; /** Heading above the agent's assumptions in the plan card (default "Assumptions"). */ @@ -1258,6 +1265,32 @@ function useMetadataCountBits(): (counts: { ); } +/** + * objectui#11658 — the extend-mode scope chip's text ("Adding to existing app: + * APP"), shared by the proposed-plan card and `BlueprintProgressPanel`. + * + * The chip names the target app by its display label, resolved by the host + * (`resolveAppLabel`), never by the machine name the plan carries — that name + * moves to the chip's tooltip. The sentence is the locale pack's + * `chatbot.plan.extendTarget`, so a zh user no longer reads the English literal + * the component used to default to. A host `planExtendLabel` still wins, in the + * `PREFIX "APP"` shape it always rendered. + */ +function useExtendChip( + override: string | undefined, + resolveAppLabel: ((appName: string) => string | undefined) | undefined, +): (targetApp: string) => string { + const { t } = useObjectTranslation(); + return React.useCallback( + (targetApp: string) => { + const app = resolveAppLabel?.(targetApp) || targetApp; + if (override) return `${override} "${app}"`; + return t('chatbot.plan.extendTarget', { app, defaultValue: 'Adding to existing app: {{app}}' }); + }, + [t, override, resolveAppLabel], + ); +} + function summarizeTools( tools: ChatToolInvocation[], /** @@ -1418,7 +1451,8 @@ const ChatbotEnhanced = React.forwardRef( builderHandoffSupersededTitle = 'A newer request is available', onOpenBuilder, onOpenRecord, - planExtendLabel = 'Adding to existing app', + planExtendLabel, + resolveAppLabel, planQuestionsLabel = 'Confirm before building', planAssumptionsLabel = 'Assumptions', planDeferredLabel = 'Not yet built', @@ -1466,6 +1500,9 @@ const ChatbotEnhanced = React.forwardRef( // `BlueprintProgressPanel` so the plan card and the live design panel can // not word the same counts differently (objectui#7254). const countBitsOf = useMetadataCountBits(); + // objectui#11658 — the extend-mode scope chip, shared with + // `BlueprintProgressPanel` so both name the target app the same way. + const extendChip = useExtendChip(planExtendLabel, resolveAppLabel); // objectui#7254 — the pack lookup for every string this component owns. // `useSafeTranslate` is provider-safe: with no I18nProvider (tests, @@ -2564,8 +2601,9 @@ const ChatbotEnhanced = React.forwardRef( - + {planExtendLabel} "{tool.proposedPlan.targetApp}" + + {extendChip(tool.proposedPlan.targetApp)} ) : null} {tool.proposedPlan.summary ? ( @@ -3184,7 +3222,7 @@ const ChatbotEnhanced = React.forwardRef( string { function BlueprintProgressPanel({ progress, designingLabel = 'Designing your app…', - extendLabel = 'Adding to existing app', + extendChip, waitingLabel = 'Waiting for server…', stalledLabel = 'Still working…', offlineLabel = 'Connection lost — reconnecting…', }: { progress: ChatBlueprintProgress; designingLabel?: string; - extendLabel?: string; + /** objectui#11658 — the localized scope-chip text for a target app (see + * `useExtendChip`); the panel owns no wording of its own for it. */ + extendChip: (targetApp: string) => string; waitingLabel?: string; stalledLabel?: string; offlineLabel?: string; @@ -4048,8 +4088,9 @@ function BlueprintProgressPanel({ - + {extendLabel} "{targetApp}" + + {extendChip(targetApp)} ) : null} {!isDone ? ( diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.extendChip-11658.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.extendChip-11658.test.tsx new file mode 100644 index 0000000000..80f67c844d --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.extendChip-11658.test.tsx @@ -0,0 +1,110 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#11658 item 5 — the extend-mode scope chip ("+ Adding to existing + * app …") is localized and names the target app by its LABEL. + * + * Measured on the 2026-10-05 cloud acceptance run: a zh-CN user's plan card read + * `+ Adding to existing app "customer_management"` — an English literal the + * component defaulted to, around the app's machine name. Both chip sites carry + * it: the proposed-plan card and the live design panel (`BlueprintProgressPanel`). + * + * The predicate pinned is the class the acceptance names: under a non-English + * locale the chip carries NO ASCII-only literal. The target label below is + * Chinese on purpose, so any Latin letter left in the zh chip is an English + * word the component (or a missing pack key) put there. The machine name stays + * reachable, on the chip's tooltip. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced'; + +const TARGET = 'customer_management'; +const TARGET_LABEL = '客户管理'; +const resolveAppLabel = (name: string) => (name === TARGET ? TARGET_LABEL : undefined); + +function renderIn(language: string, ui: React.ReactElement) { + return render( + {ui}, + ); +} + +const planMessages = (targetApp: string): ChatMessage[] => [ + { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't1', + toolName: 'propose_blueprint', + state: 'output-available', + proposedPlan: { + summary: '跟进记录', + objects: [{ name: 'follow_up', label: '跟进', fieldCount: 3 }], + counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 }, + questions: [], + assumptions: [], + targetApp, + }, + }, + ], + }, +]; + +const designingMessages = (targetApp: string): ChatMessage[] => [ + { id: 'u1', role: 'user', content: '加一张跟进表' }, + { + id: 'a1', + role: 'assistant', + content: '', + streaming: true, + toolInvocations: [{ toolCallId: 't1', toolName: 'propose_blueprint', state: 'input-available' }], + blueprintProgress: { + phase: 'designing', + targetApp, + objects: [{ name: 'follow_up', label: '跟进', fields: 3 }], + }, + }, +]; + +/** Every Latin-letter run in a string — an English word, in a zh chip. */ +const latinWords = (text: string) => text.match(/[A-Za-z]+/g) ?? []; + +afterEach(cleanup); + +describe('the extend-mode scope chip (objectui#11658 item 5)', () => { + describe.each([ + ['the proposed-plan card', 'proposed-plan-extend', planMessages], + ['the live design panel', 'blueprint-progress-extend', designingMessages], + ] as const)('%s', (_where, testId, messages) => { + it('zh: carries no ASCII-only literal and names the app by its label', () => { + renderIn('zh', ); + const chip = screen.getByTestId(testId); + expect(chip.textContent).toContain(TARGET_LABEL); + expect(latinWords(chip.textContent ?? '')).toEqual([]); + expect(chip.textContent).not.toContain('Adding to existing app'); + // The machine name is not the chip's text; it stays on the tooltip. + expect(chip.textContent).not.toContain(TARGET); + expect(chip).toHaveAttribute('title', TARGET); + }); + + it('en: reads the English sentence around the label (control)', () => { + renderIn('en', ); + const chip = screen.getByTestId(testId); + expect(chip.textContent).toContain('Adding to existing app'); + expect(chip.textContent).toContain(TARGET_LABEL); + expect(chip.textContent).not.toContain(TARGET); + }); + + it('an app the resolver does not know is shown by the name given', () => { + renderIn('zh', ); + const chip = screen.getByTestId(testId); + expect(chip.textContent).toContain('orders'); + expect(latinWords(chip.textContent ?? '')).toEqual(['orders']); + }); + }); +}); From e562695b9632ef5821ca547a1a82e49be3dbae83 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:54:48 +0800 Subject: [PATCH 2/5] test(app-shell): pin the in-app composer placeholder and the scope chip's host-side app label (objectui#11658 items 2, 5) Co-authored-by: Claude --- ...ChatPage.appContextComposer-11658.test.tsx | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.appContextComposer-11658.test.tsx 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 new file mode 100644 index 0000000000..126a7a4565 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.appContextComposer-11658.test.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11658 — what ChatPane hands the composer inside an app. + * + * Item 2: the build agent scoped to a package (`editPackageId` — the console + * 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. + * + * 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 + * console's metadata already holds; the chip half is pinned in + * `ChatbotEnhanced.extendChip-11658.test.tsx`. + * + * Same harness as `AiChatPage.planCardLocale.test.tsx`: the real pane under a + * real `I18nProvider`, with `ChatbotEnhanced` replaced by a prop recorder. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; + +let captured: Record = {}; + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + ChatbotEnhanced: (props: Record) => { + captured = props; + return null; + }, + useObjectChat: () => ({ + messages: [], + isLoading: false, + error: undefined, + sendMessage: vi.fn(), + stop: vi.fn(), + reload: vi.fn(), + clear: vi.fn(), + setMessages: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + }; +}); + +const APPS = [{ name: 'customer_management', label: '客户管理', _packageId: 'app.crm' }]; +vi.mock('../../../providers/MetadataProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useMetadata: () => ({ apps: APPS }) }; +}); +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => null }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; +import { ChatPane } from '../AiChatPage'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const AGENTS = [ + { name: 'build', label: 'Build' }, + { name: 'ask', label: 'Ask' }, +] as unknown as AgentDescriptor[]; + +function renderPane(uiLocale: string, opts: { agent: 'build' | 'ask'; editPackageId?: string }) { + cleanup(); + captured = {}; + render( + + + + + , + ); + return captured; +} + +beforeEach(() => { + window.localStorage.clear(); + // An app-scoped pane asks for that package's pending drafts; serve the probe + // from a double, never the network (objectui#6640). + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ success: true, data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('objectui#11658 item 2 — the in-app composer names the task, not the agent', () => { + it('zh: the build agent inside an app reads「问问数据,或让我改改这个应用…」', () => { + const props = renderPane('zh', { agent: 'build', editPackageId: 'app.crm' }); + expect(props.placeholder).toBe('问问数据,或让我改改这个应用…'); + expect(String(props.placeholder)).not.toContain('构建'); + }); + + it('en: the build agent inside an app reads "Ask about your data, or ask me to change this app…"', () => { + const props = renderPane('en', { agent: 'build', editPackageId: 'app.crm' }); + 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', () => { + const props = renderPane('en', { agent: 'build' }); + expect(props.placeholder).toBe('Ask Build…'); + }); + + it('control: the ask agent keeps its own placeholder, app or not', () => { + expect(renderPane('en', { agent: 'ask', editPackageId: 'app.crm' }).placeholder).toBe('Ask anything…'); + }); +}); + +describe('objectui#11658 item 5 — the host resolves the scope chip\'s app label', () => { + it('a known app resolves to its label in the UI locale; an unknown one to undefined', () => { + const props = renderPane('zh', { agent: 'build', editPackageId: 'app.crm' }); + const resolve = props.resolveAppLabel as (name: string) => string | undefined; + expect(typeof resolve).toBe('function'); + expect(resolve('customer_management')).toBe('客户管理'); + expect(resolve('no_such_app')).toBeUndefined(); + }); +}); From e1ecee6c1dd33d9d4dc6c4f39a77c86dc684e9bd Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:55:18 +0800 Subject: [PATCH 3/5] fix(app-shell): the AI usage popover shows one figure, and the usage gauge no longer reads as a loading spinner (objectui#11658 items 3, 4) Item 3: the popover shows the pool's used share as one percentage (`console.ai.usage.poolUsed`); the build / data-Q&A split is no longer drawn and its three keys (`meterBuild`, `meterAsk`, `breakdownTitle`) leave all ten packs. `useAiUsage` still reads `breakdown` strictly off the wire. Item 4: the header glyph is a closed, tone-coloured outline around a pie wedge instead of a stroked arc over a faint track, so low usage reads as a quantity, not a spinner. Co-authored-by: Claude --- .../app-shell/src/layout/AiUsageIndicator.tsx | 112 ++++++++---------- .../__tests__/AiUsageIndicator.test.tsx | 77 ++++++++---- packages/i18n/src/locales/ar.ts | 4 +- packages/i18n/src/locales/de.ts | 4 +- packages/i18n/src/locales/en.ts | 11 +- packages/i18n/src/locales/es.ts | 4 +- packages/i18n/src/locales/fr.ts | 4 +- packages/i18n/src/locales/ja.ts | 4 +- packages/i18n/src/locales/ko.ts | 4 +- packages/i18n/src/locales/pt.ts | 4 +- packages/i18n/src/locales/ru.ts | 4 +- packages/i18n/src/locales/zh.ts | 4 +- 12 files changed, 116 insertions(+), 120 deletions(-) diff --git a/packages/app-shell/src/layout/AiUsageIndicator.tsx b/packages/app-shell/src/layout/AiUsageIndicator.tsx index 6af8e84c7c..07e4dbb171 100644 --- a/packages/app-shell/src/layout/AiUsageIndicator.tsx +++ b/packages/app-shell/src/layout/AiUsageIndicator.tsx @@ -4,19 +4,20 @@ * * ADR-0057 #8 — the proactive AI usage indicator for the ChatDock header. * - * ONE small progress ring for the environment's ONE AI quota pool (objectui#8524, + * ONE small gauge for the environment's ONE AI quota pool (objectui#8524, * following the cloud single-pool ruling), so the user sees remaining AI headroom * BEFORE a send hits the 429 wall, instead of only learning the limit reactively. * Data comes from {@link useAiUsage} (the cloud `GET /api/v1/ai/usage` endpoint), * which speaks D5-SAFE fractions — this component NEVER renders a token number, - * only a ring, qualitative words and, in the popover, the pool's split as - * percentages of that same pool. + * only the gauge, qualitative words and, in the popover, the pool's used share as + * ONE percentage. * - * The split (`breakdown`: app-building vs data Q&A) answers "where did the - * allowance go". It is text inside the popover, under the pool's own row — never - * a second ring, which would imply a second budget. It appears only when at - * least one of its members is a number; when it is absent or all-null the popover - * is the pool row alone. + * objectui#11658 — the popover shows that one figure and nothing else. The pool's + * split (`breakdown`: app-building vs data Q&A) is no longer drawn: since the + * single composer, a data question asked of the build agent counts as building, + * so the split read「AI 搭建 19% · 数据问询 0%」after a data question — it cannot + * attribute a composer turn honestly. `useAiUsage` still reads `breakdown` + * strictly off the wire; nothing renders it. * * Near-full (≥ {@link NEAR_FULL}) the ring turns amber and, on click, the popover * shows "running low — resets tonight/next cycle" plus the SAME upgrade / top-up CTA @@ -28,7 +29,7 @@ import * as React from 'react'; import { cn, Button, Popover, PopoverTrigger, PopoverContent } from '@object-ui/components'; import { formatNumber, useObjectTranslation } from '@object-ui/i18n'; -import { useAiUsage, type AiMeterUsage, type AiUsageBreakdown } from '../hooks/useAiUsage.js'; +import { useAiUsage, type AiMeterUsage } from '../hooks/useAiUsage.js'; import { cloudConsoleUrl } from '../console/marketplace/marketplaceApi.js'; /** Fraction at/above which the pool is "running low" (amber + CTA). */ @@ -57,31 +58,30 @@ function statusColorClass(tone: Tone): string { return 'text-muted-foreground'; } -/** One row of the pool's split: a member that was measured (numeric). */ -interface BreakdownRow { - key: keyof AiUsageBreakdown; - fraction: number; -} - -/** The split rows to show — measured members only; empty means no split section. */ -function breakdownRows(breakdown: AiUsageBreakdown | undefined): BreakdownRow[] { - if (!breakdown) return []; - const rows: BreakdownRow[] = []; - (['build', 'dataChat'] as const).forEach((key) => { - const fraction = breakdown[key]; - if (fraction !== null) rows.push({ key, fraction }); - }); - return rows; -} - -/** A small SVG progress ring. Presentational only (aria-hidden) — the button labels it. */ -function MeterRing({ fraction, tone, size = 16 }: { fraction: number; tone: Tone; size?: number }) { - const stroke = 2; - const r = (size - stroke) / 2; +/** + * The pool gauge: a full, solid outline (the track) around a pie wedge that + * fills clockwise from 12 o'clock with the used share. Presentational only + * (aria-hidden) — the button labels it. + * + * objectui#11658 — it used to be a stroked arc over a faint 25%-opacity track, + * which at low usage is a short arc in the header: the exact silhouette of a + * loading spinner, read as「还在加载」in every acceptance screenshot. A pie + * inside a closed outline reads as a quantity at any fraction — an empty circle + * at 0, a sliver at 5%, a full disc at the cap — and never as a spinner, since + * a spinner is an OPEN arc. Both parts take the tone colour (`currentColor`), + * so the track is as visible as the fill. + */ +function MeterGauge({ fraction, tone, size = 16 }: { fraction: number; tone: Tone; size?: number }) { + const center = size / 2; + const outline = 1.5; + const trackR = (size - outline) / 2; + // The wedge is drawn as a circle whose stroke is as wide as its own diameter: + // a dash of `pct` of its circumference paints a pie sector of radius `pieR`, + // inset from the track by a gap the width of the outline. + const pieR = trackR - outline; + const r = pieR / 2; const circumference = 2 * Math.PI * r; const pct = Math.min(1, Math.max(0, fraction)); - const dash = circumference * pct; - const center = size / 2; return ( ); @@ -143,12 +152,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage if (pool.unmetered || pool.fraction === null) return null; const fraction = pool.fraction; const tone = toneFor(fraction); - const split = breakdownRows(usage.breakdown); - - const splitLabel = (key: BreakdownRow['key']): string => - key === 'build' - ? t('console.ai.usage.meterBuild', { defaultValue: 'Build' }) - : t('console.ai.usage.meterAsk', { defaultValue: 'Ask' }); + const usedPercent = formatNumber(fraction, { locale: language, style: 'percent', maximumFractionDigits: 0 }); const statusLabel = (level: Tone): string => { if (level === 'full') return t('console.ai.usage.statusFull', { defaultValue: 'Limit reached' }); @@ -215,7 +219,7 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage status: statusLabel(tone), })} > - + {tone !== 'ok' ? ( {statusLabel(tone)} @@ -228,9 +232,12 @@ export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsage {t('console.ai.usage.title', { defaultValue: 'AI usage' })}
- +
{statusLabel(tone)}
+
+ {t('console.ai.usage.poolUsed', { percent: usedPercent, defaultValue: '{{percent}} used' })} +
{reset ?
{reset}
: null} {showCta ? (
- {split.length > 0 ? ( -
-
- {t('console.ai.usage.breakdownTitle', { defaultValue: 'Used so far' })} -
-
    - {split.map((row) => ( -
  • - {splitLabel(row.key)} - - {formatNumber(row.fraction, { locale: language, style: 'percent', maximumFractionDigits: 0 })} - -
  • - ))} -
-
- ) : null} ); diff --git a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx index 87bb83e4a3..4f7630b1d5 100644 --- a/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AiUsageIndicator.test.tsx @@ -2,11 +2,11 @@ * ObjectUI * Copyright (c) 2024-present ObjectStack Inc. * - * AiUsageIndicator (ADR-0057 #8) — renders ONE ring for the ONE AI quota pool + * AiUsageIndicator (ADR-0057 #8) — renders ONE gauge for the ONE AI quota pool * (objectui#8524), hides itself when there's nothing to show (fail-soft), surfaces * a "running low" hint near the cap, offers the upgrade / top-up CTA (reusing the - * 429 deep-link), and shows the pool's read-only split as text in the popover — - * never as a second ring. Never a token #. + * 429 deep-link), and shows ONE figure in the popover — the pool's used share — + * never the build / data-Q&A split (objectui#11658). Never a token #. */ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -61,7 +61,7 @@ function setUsage(usage: AiUsageResponse | null) { }); } -/** Rings drawn inside an element — each `MeterRing` is one `svg`. */ +/** Gauges drawn inside an element — each `MeterGauge` is one `svg`. */ const ringsIn = (el: HTMLElement) => el.querySelectorAll('svg').length; describe('AiUsageIndicator', () => { @@ -118,38 +118,63 @@ describe('AiUsageIndicator', () => { expect(openMock).toHaveBeenCalledWith('https://cloud.example', '_blank', 'noopener,noreferrer'); }); - // objectui#8524 — `breakdown` answers "where did the allowance go". It is - // text under the pool's row, never a second gauge implying a second budget. - describe('the pool split (breakdown)', () => { - it('lists each measured member as a share of the pool — as text, not a ring', () => { - setUsage({ pool: pool({ fraction: 0.5 }), breakdown: { build: 0.3, dataChat: 0.2 } }); + // objectui#11658 item 3 — the popover shows ONE figure. The pool's split + // (`breakdown`) read「AI 搭建 19% · 数据问询 0%」after a data question asked + // through the single composer: it cannot attribute a composer turn, so it is + // not drawn, whatever the wire carries. + describe('the popover figure (one meter)', () => { + it("shows the pool's used share as one percentage — and no split, even when the split is measured", () => { + setUsage({ pool: pool({ fraction: 0.19 }), breakdown: { build: 0.19, dataChat: 0 } }); render(); fireEvent.click(screen.getByTestId('ai-usage-indicator')); - const split = screen.getByTestId('ai-usage-breakdown'); - expect(split).toHaveTextContent('Build'); - expect(split).toHaveTextContent('30%'); - expect(split).toHaveTextContent('Ask'); - expect(split).toHaveTextContent('20%'); - expect(ringsIn(split)).toBe(0); - expect(ringsIn(screen.getByTestId('ai-usage-popover'))).toBe(1); + const popover = screen.getByTestId('ai-usage-popover'); + expect(screen.getByTestId('ai-usage-pool-used')).toHaveTextContent('19% used'); + // Exactly one percentage in the whole popover: the pool's. + expect(popover.textContent?.match(/\d+\s?%/g)).toEqual(['19%']); + expect(screen.queryByTestId('ai-usage-breakdown')).not.toBeInTheDocument(); + expect(popover).not.toHaveTextContent('Build'); + expect(popover).not.toHaveTextContent('Ask'); + expect(ringsIn(popover)).toBe(1); }); - it('with both members null (not measured): one ring, no split', () => { - setUsage({ pool: pool({ fraction: 0.5 }), breakdown: { build: null, dataChat: null } }); + it('with no breakdown at all: the same one figure', () => { + setUsage({ pool: pool({ fraction: 0.5 }) }); render(); expect(ringsIn(screen.getByTestId('ai-usage-indicator'))).toBe(1); fireEvent.click(screen.getByTestId('ai-usage-indicator')); - expect(screen.getByTestId('ai-usage-popover')).toBeInTheDocument(); - expect(screen.queryByTestId('ai-usage-breakdown')).not.toBeInTheDocument(); + expect(screen.getByTestId('ai-usage-pool-used')).toHaveTextContent('50% used'); }); + }); - it('with no breakdown at all: one ring, no split', () => { - setUsage({ pool: pool({ fraction: 0.5 }) }); + // objectui#11658 item 4 — at low usage the old ring was a short stroked arc + // over a faint track: the silhouette of a loading spinner. The gauge is a + // CLOSED outline in the tone colour around a pie wedge, which reads as a + // quantity at any fraction. + describe('the gauge glyph (not a spinner)', () => { + const parts = (svg: SVGElement) => ({ + track: svg.querySelector('[data-gauge-part="track"]'), + fill: svg.querySelector('[data-gauge-part="fill"]'), + }); + + it.each([0, 0.05, 0.19])('at %s: a full, tone-coloured track around a pie wedge', (fraction) => { + setUsage({ pool: pool({ fraction }) }); render(); - expect(ringsIn(screen.getByTestId('ai-usage-indicator'))).toBe(1); - fireEvent.click(screen.getByTestId('ai-usage-indicator')); - expect(screen.getByTestId('ai-usage-popover')).toBeInTheDocument(); - expect(screen.queryByTestId('ai-usage-breakdown')).not.toBeInTheDocument(); + const svg = screen.getByTestId('ai-usage-indicator').querySelector('svg') as SVGElement; + const { track, fill } = parts(svg); + // The track is a closed circle: undashed, drawn in the tone colour, no + // faded-out opacity class — as visible as the fill. + expect(track).not.toBeNull(); + expect(track?.getAttribute('stroke')).toBe('currentColor'); + expect(track?.hasAttribute('stroke-dasharray')).toBe(false); + expect(track?.getAttribute('class') ?? '').not.toMatch(/\/\d+/); + // The fill is a pie: a circle whose stroke is as wide as its diameter + // (a sector of a disc), inside the track — never an arc ON the track. + const r = Number(fill?.getAttribute('r')); + expect(Number(fill?.getAttribute('stroke-width'))).toBeCloseTo(2 * r); + expect(Number(track?.getAttribute('r'))).toBeGreaterThan(2 * r); + // The wedge's share is the pool's fraction. + const [dash, gap] = (fill?.getAttribute('stroke-dasharray') ?? '').split(' ').map(Number); + expect(dash / gap).toBeCloseTo(fraction); }); }); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index cda03d4c90..fb7ed77f29 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1841,8 +1841,7 @@ const ar = { }, usage: { title: "استخدام الذكاء الاصطناعي", - meterBuild: "الإنشاء", - meterAsk: "السؤال", + poolUsed: "تم استخدام {{percent}}", statusOk: "المتبقّي وفير", statusLow: "على وشك النفاد", statusFull: "تم بلوغ الحد", @@ -1865,7 +1864,6 @@ const ar = { ctaUpgrade: "قم بالترقية للمتابعة", ctaTopUp: "أضف أرصدة للمتابعة", ariaLabel: "استخدام الذكاء الاصطناعي: {{status}}", - breakdownTitle: "المستخدَم حتى الآن", }, workspaceTitle: "مساحة عمل الذكاء الاصطناعي", workspaceSubtitle: "اسأل وافحص واستأنف المحادثات", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 235abc6a1e..9042129622 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1664,8 +1664,7 @@ const de = { }, usage: { title: "KI-Nutzung", - meterBuild: "Erstellen", - meterAsk: "Fragen", + poolUsed: "{{percent}} verbraucht", statusOk: "Reichlich übrig", statusLow: "Wird knapp", statusFull: "Limit erreicht", @@ -1680,7 +1679,6 @@ const de = { ctaUpgrade: "Upgraden, um weiterzumachen", ctaTopUp: "Credits hinzufügen, um fortzufahren", ariaLabel: "KI-Nutzung: {{status}}", - breakdownTitle: "Bisher verbraucht", }, workspaceTitle: "KI-Workspace", workspaceSubtitle: "Fragen stellen, prüfen und Unterhaltungen fortsetzen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a1232b337d..6b5ce20a05 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2128,8 +2128,11 @@ const en = { }, usage: { title: 'AI usage', - meterBuild: 'Build', - meterAsk: 'Ask', + // objectui#11658 — the popover's ONE figure: the share of the single + // AI pool already used (a formatted percentage, never a token count). + // It replaced the build / data-Q&A split, which could not attribute a + // single-composer turn honestly. + poolUsed: '{{percent}} used', statusOk: 'Plenty left', statusLow: 'Running low', statusFull: 'Limit reached', @@ -2149,10 +2152,6 @@ const en = { ctaUpgrade: 'Upgrade to keep going', ctaTopUp: 'Add credits to continue', ariaLabel: 'AI usage: {{status}}', - // objectui#8524 — heading over the pool's read-only split (`breakdown`: - // app-building vs data Q&A) in the popover. The rows reuse `meterBuild` / - // `meterAsk`; each is a share of the ONE pool, never a second budget. - breakdownTitle: 'Used so far', }, workspaceTitle: 'AI Workspace', workspaceSubtitle: 'Ask, inspect, and resume conversations', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c46e0e7576..be42b7255f 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1708,8 +1708,7 @@ const es = { }, usage: { title: "Uso de IA", - meterBuild: "Crear", - meterAsk: "Preguntar", + poolUsed: "{{percent}} usado", statusOk: "Queda bastante", statusLow: "Se está agotando", statusFull: "Límite alcanzado", @@ -1726,7 +1725,6 @@ const es = { ctaUpgrade: "Mejore el plan para continuar", ctaTopUp: "Añada créditos para continuar", ariaLabel: "Uso de IA: {{status}}", - breakdownTitle: "Usado hasta ahora", }, workspaceTitle: "Espacio de trabajo de IA", workspaceSubtitle: "Pregunte, inspeccione y retome conversaciones", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index eefaf9b30a..0d384be111 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1705,8 +1705,7 @@ const fr = { }, usage: { title: "Utilisation de l'IA", - meterBuild: "Créer", - meterAsk: "Demander", + poolUsed: "{{percent}} utilisé", statusOk: "Il en reste beaucoup", statusLow: "Bientôt épuisé", statusFull: "Limite atteinte", @@ -1723,7 +1722,6 @@ const fr = { ctaUpgrade: "Passer à l'offre supérieure pour continuer", ctaTopUp: "Ajouter des crédits pour continuer", ariaLabel: "Utilisation de l'IA : {{status}}", - breakdownTitle: "Utilisé jusqu'à présent", }, workspaceTitle: "Espace de travail IA", workspaceSubtitle: "Posez des questions, inspectez et reprenez vos conversations", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 1a08d966f6..1288e93738 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1666,8 +1666,7 @@ const ja = { }, usage: { title: "AI 使用状況", - meterBuild: "ビルド", - meterAsk: "質問", + poolUsed: "{{percent}} 使用済み", statusOk: "残量に余裕があります", statusLow: "残りわずかです", statusFull: "上限に達しました", @@ -1682,7 +1681,6 @@ const ja = { ctaUpgrade: "アップグレードして続行", ctaTopUp: "クレジットを追加して続行", ariaLabel: "AI 使用状況: {{status}}", - breakdownTitle: "これまでの使用内訳", }, workspaceTitle: "AI ワークスペース", workspaceSubtitle: "質問し、確認し、会話を再開します", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index a5b44cb425..0081a21bb1 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1664,8 +1664,7 @@ const ko = { }, usage: { title: "AI 사용량", - meterBuild: "빌드", - meterAsk: "질문", + poolUsed: "{{percent}} 사용됨", statusOk: "여유가 충분합니다", statusLow: "얼마 남지 않았습니다", statusFull: "한도에 도달했습니다", @@ -1680,7 +1679,6 @@ const ko = { ctaUpgrade: "업그레이드하고 계속하기", ctaTopUp: "크레딧을 추가하고 계속하기", ariaLabel: "AI 사용량: {{status}}", - breakdownTitle: "지금까지 사용 내역", }, workspaceTitle: "AI 워크스페이스", workspaceSubtitle: "질문하고, 살펴보고, 대화를 이어가세요", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index ed7b5274e9..58f06b7f73 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1703,8 +1703,7 @@ const pt = { }, usage: { title: "Uso de IA", - meterBuild: "Criar", - meterAsk: "Perguntar", + poolUsed: "{{percent}} usado", statusOk: "Ainda há bastante", statusLow: "Está acabando", statusFull: "Limite atingido", @@ -1721,7 +1720,6 @@ const pt = { ctaUpgrade: "Faça upgrade para continuar", ctaTopUp: "Adicione créditos para continuar", ariaLabel: "Uso de IA: {{status}}", - breakdownTitle: "Usado até agora", }, workspaceTitle: "Workspace de IA", workspaceSubtitle: "Pergunte, inspecione e retome conversas", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index c7f78ad7a9..07951d8599 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1761,8 +1761,7 @@ const ru = { }, usage: { title: "Использование ИИ", - meterBuild: "Сборка", - meterAsk: "Вопрос", + poolUsed: "Использовано {{percent}}", statusOk: "Осталось много", statusLow: "Заканчивается", statusFull: "Лимит исчерпан", @@ -1781,7 +1780,6 @@ const ru = { ctaUpgrade: "Повысьте тариф, чтобы продолжить", ctaTopUp: "Добавьте кредиты, чтобы продолжить", ariaLabel: "Использование ИИ: {{status}}", - breakdownTitle: "Использовано на данный момент", }, workspaceTitle: "Рабочее пространство ИИ", workspaceSubtitle: "Задавайте вопросы, изучайте и возвращайтесь к диалогам", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index fe4773e3ca..27b50badbd 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1832,8 +1832,7 @@ const zh = { }, usage: { title: 'AI 用量', - meterBuild: 'AI 搭建', - meterAsk: '数据问询', + poolUsed: '已用 {{percent}}', statusOk: '额度充足', statusLow: '即将用完', statusFull: '额度已用完', @@ -1848,7 +1847,6 @@ const zh = { ctaUpgrade: '升级以继续使用', ctaTopUp: '购买额度包以继续', ariaLabel: 'AI 用量:{{status}}', - breakdownTitle: '已用额度构成', }, workspaceTitle: 'AI 工作区', workspaceSubtitle: '提问、查看并继续历史对话', From 9a0e01d3277264cdbd5284a340f1b268bd38cefa Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:57:15 +0800 Subject: [PATCH 4/5] fix(app-shell): after the first AI build, the Studio workbench opens on the running app with the properties panel collapsed (objectui#11658 item 1) The built-moment transition passes `STUDIO_RUN_LANDING` router state with its navigation to the Interfaces pillar, which reads it once at mount: the canvas opens in run mode with the properties aside collapsed, and the first switch to Design re-opens the aside the landing collapsed. Every other arrival, the explicit "Design in Studio" door included, opens on Design as before. Co-authored-by: Claude --- .../app-shell/src/console/ai/AiChatPage.tsx | 7 +- ...iChatPage.builtLandsRunning-11658.test.tsx | 257 ++++++++++++++++++ ...dioDesignSurface.runLanding-11658.test.tsx | 135 +++++++++ .../studio-design/StudioDesignSurface.tsx | 30 +- .../src/views/studio-design/studioLanding.ts | 37 +++ 5 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.runLanding-11658.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/studioLanding.ts diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 9dc127f9de..e568849c1c 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -22,6 +22,7 @@ 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'; @@ -2107,7 +2108,11 @@ export function ChatPane({ // app::build cache key — the exact key the Studio dock resolves — so // the workbench's right rail resumes THIS thread (A1.b machinery). onPackageBound?.(builtPackageId); - paneNavigate(`/studio/${encodeURIComponent(builtPackageId)}/interfaces`); + // 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]); // objectui#5801 — when a turn that STAGED or PUBLISHED something finishes, diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx new file mode 100644 index 0000000000..c259417a2d --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.builtLandsRunning-11658.test.tsx @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11658 item 1 — the post-build transition lands on the RUNNING app. + * + * 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`. + * + * 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 「设计」. + * + * Harness: `AiChatPage.builtTransition.test.tsx`'s faked chat, with the Studio + * route reporting the router state it was handed. + */ + +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 { + 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[]; + const design = props.onDesignBuiltApp as ((app: string, segment?: string) => void) | undefined; + 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'; +import { isStudioRunLanding } from '../../../views/studio-design/studioLanding'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +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' }, + ], + }), + }, + }, + ], + }, +]; + +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' }, + }); + }), + ); +} + +/** The Studio route, reporting where it was sent and with what router state. */ +function StudioProbe() { + const location = useLocation(); + return ( +
+ ); +} + +function renderPage() { + render( + + + } /> + } /> + } /> + + , + ); +} + +beforeEach(() => { + chat.isLoading = false; + serverTurns = []; + window.localStorage.clear(); + window.sessionStorage.clear(); + installFetch(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +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'); + }); + + it('a LIVE build completion asks for the run-mode landing too', async () => { + serverTurns = UNBUILT_TURNS; + chat.isLoading = true; + renderPage(); + await waitFor(() => expect(screen.getByTestId('pane')).toBeInTheDocument(), { timeout: 4000 }); + 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; + }); + 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'); + }); + + it('control: the explicit "Design in Studio" door passes 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'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.runLanding-11658.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.runLanding-11658.test.tsx new file mode 100644 index 0000000000..b152762ba7 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.runLanding-11658.test.tsx @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11658 item 1 — the post-build arrival lands on the RUNNING app. + * + * After the first AI build the conversation moves into the Studio workbench + * (objectui#5799). It landed on the 「设计」 canvas with the properties aside + * open (列数 / 间距 / 自动刷新 squeezed beside the chat). The ruling: land on + * 「运行」 with the properties collapsed; 「设计」 stays one click away. + * + * The post-build navigation asks for that posture with router state + * (`STUDIO_RUN_LANDING`, see `studioLanding.ts`); the producer half is pinned in + * `AiChatPage.builtLandsRunning-11658.test.tsx`. Here, the pillar half: + * + * 1. an arrival WITH the state opens on 「运行」, design overlays absent, the + * properties aside collapsed to its rail; + * 2. one click on 「设计」 is the whole designer again — overlays AND the + * properties aside the landing collapsed; + * 3. control: an arrival without it (every other Studio entry, the explicit + * "Design in Studio" door included) opens on 「设计」 with the aside open, + * exactly as before. + * + * Same harness as `StudioDesignSurface.canvasMode.test.tsx`: the dashboard leaf, + * whose design mode is visible as `widget-click-overlay` elements. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const NAV = [{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' }]; + +const DASHBOARD = { + name: 'sales_overview', + label: 'Sales Overview', + widgets: [{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } }], +}; + +const mockClient = { + list: vi.fn(async (type: string) => (type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [])), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } }; + if (type === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD }; + return { effective: { name } }; + }), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({})), + get: vi.fn(async () => undefined), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +import { InterfacesPillar } from './StudioDesignSurface'; +import { STUDIO_RUN_LANDING } from './studioLanding'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview'; + +registerMetadataPreview('dashboard', DashboardPreview); + +afterEach(cleanup); + +const PATH = '/studio/com.acme.app/interfaces'; + +/** Render the pillar as a navigation arrives at it, with or without the landing state. */ +async function arrive(state?: unknown) { + render( + + + , + ); + // No `?surface=` on the arrival: the pillar auto-opens its first leaf, as it + // does on the real post-build landing. + await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), { timeout: 4000 }); +} + +const pressed = (name: 'Design' | 'Run') => + screen.getByRole('button', { name }).getAttribute('aria-pressed'); + +describe('the post-build landing (objectui#11658 item 1)', () => { + it('lands on Run with the properties aside collapsed', async () => { + await arrive(STUDIO_RUN_LANDING); + expect(pressed('Run')).toBe('true'); + expect(pressed('Design')).toBe('false'); + // The collapsed rail offers "Show properties"; the open aside's collapse button is absent. + expect(screen.getByRole('button', { name: 'Show properties' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Collapse properties' })).not.toBeInTheDocument(); + // Run mode is the running app: no design overlays on the dashboard. + await waitFor(() => expect(screen.getByText('Total')).toBeInTheDocument(), { timeout: 4000 }); + expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0); + }); + + it('one click on Design is the whole designer again — overlays and properties', async () => { + await arrive(STUDIO_RUN_LANDING); + fireEvent.click(screen.getByRole('button', { name: 'Design' })); + expect(pressed('Design')).toBe('true'); + expect(screen.getByRole('button', { name: 'Collapse properties' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0), { + timeout: 4000, + }); + }); + + it('a properties collapse the USER chose survives a later Design click', async () => { + await arrive(STUDIO_RUN_LANDING); + // The user takes the aside into their own hands: open it, then close it again. + fireEvent.click(screen.getByRole('button', { name: 'Show properties' })); + fireEvent.click(screen.getByRole('button', { name: 'Collapse properties' })); + fireEvent.click(screen.getByRole('button', { name: 'Design' })); + expect(screen.getByRole('button', { name: 'Show properties' })).toBeInTheDocument(); + }); + + it.each([ + ['no state', undefined], + ['an unrelated state', { from: { pathname: '/apps/acme_app' } }], + ])('control (%s): every other arrival opens on Design with the aside open', async (_label, state) => { + await arrive(state); + expect(pressed('Design')).toBe('true'); + expect(screen.getByRole('button', { name: 'Collapse properties' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show properties' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 666866869a..0bcaa48d54 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { useParams, useNavigate, Link, Navigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation, Link, Navigate } from 'react-router-dom'; import { useAdapter, SchemaRendererProvider } from '@object-ui/react'; // The ONE draft-envelope reader (objectui#8181): unwrap AND strip the // framework's read decorations in one place. This file used to carry its own @@ -118,6 +118,7 @@ import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; import { useHomePath } from '../../hooks/useHomePath.js'; import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js'; import { useSurfaceDeepLink, resolveSurfaceDeepLink, type SurfaceTarget } from './useSurfaceDeepLink.js'; +import { isStudioRunLanding } from './studioLanding.js'; import { SurfaceDeepLinkProvider, useRequestedSurface } from './surfaceDeepLinkChannel.js'; import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton, type AppNavSeed } from './skeletons.js'; import { OWD_CREATE_MODELS, OWD_DEFAULT, type OwdCreateModel } from './owd-sharing.js'; @@ -2179,7 +2180,21 @@ export function InterfacesPillar({ // canvas (and the chat dock beside it) more room — in-memory, per-mount. Only // meaningful in the aside layouts (`!showFoldedTabs`); the narrow center-tabs // layout already toggles properties as a tab, so it ignores this. - const [inspectorCollapsed, setInspectorCollapsed] = React.useState(false); + // + // objectui#11658 — an arrival that asks for the run-mode landing (the + // post-build transition, see `studioLanding.ts`) opens on 「运行」 with this + // aside collapsed: the running app, full width beside the chat. Read once, at + // mount. The collapse is the landing's, not the user's, so the first switch + // to 「设计」 hands the properties back (see `landingCollapsedRef`). + const location = useLocation(); + const [landedInRun] = React.useState(() => isStudioRunLanding(location.state)); + const [inspectorCollapsed, setInspectorCollapsedState] = React.useState(landedInRun); + const landingCollapsedRef = React.useRef(landedInRun); + // Any explicit collapse/expand by the user ends the landing's claim on it. + const setInspectorCollapsed = React.useCallback((collapsed: boolean) => { + landingCollapsedRef.current = false; + setInspectorCollapsedState(collapsed); + }, []); const canCollapseInspector = !showFoldedTabs; const hasInspectorTarget = Boolean((editNav && navSel) || selection); const prevInspectorTargetRef = React.useRef(hasInspectorTarget); @@ -2384,8 +2399,17 @@ export function InterfacesPillar({ // overlays (dashboard widget overlays, page block canvas) and the SAME // renderer serves the interactive runtime — click New, enter a record. // Selection state is retained so switching back to design keeps context. - const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design'); + const [canvasMode, setCanvasModeState] = React.useState<'design' | 'run'>(landedInRun ? 'run' : 'design'); const designing = canvasMode === 'design'; + // objectui#11658 — 「设计」 is one click from the run-mode landing: the first + // switch to it also re-opens the properties aside the landing collapsed. + const setCanvasMode = React.useCallback((mode: 'design' | 'run') => { + setCanvasModeState(mode); + if (mode === 'design' && landingCollapsedRef.current) { + landingCollapsedRef.current = false; + setInspectorCollapsedState(false); + } + }, []); // `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081), // rendered by SourcePageEditor as a code-editor + live-preview split — there // is no block tree, so `selection` never populates and the generic "click a diff --git a/packages/app-shell/src/views/studio-design/studioLanding.ts b/packages/app-shell/src/views/studio-design/studioLanding.ts new file mode 100644 index 0000000000..65f9da9bf3 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/studioLanding.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11658 — the arrival posture a navigation into the Interfaces pillar + * can ask for, carried as router `state` beside the URL. + * + * After the first AI build the conversation moves into the Studio workbench + * (objectui#5799), and it used to land on the 「设计」 canvas with the properties + * panel open — the designer, squeezed beside the chat, when the user had just + * asked for an app. ADR-0080 is preview-first, so that one arrival lands on the + * RUNNING app instead: the 「运行」 canvas mode, properties collapsed, 「设计」 one + * click away. + * + * Router state, not a URL param, on purpose: it is a one-shot arrival intent, + * not addressable state (Commandment #8). The pillar reads it once, at mount; + * the `?surface=` mirror's replace drops it right after, so a reload or a + * shared link opens the designer exactly as any other Studio entry does, and + * the explicit "Design in Studio" door (which passes no state) keeps landing on + * 「设计」. + */ + +/** The router state that asks the Interfaces pillar to open in run mode. */ +export interface StudioRunLandingState { + studioLanding: 'run'; +} + +/** Pass as `navigate(path, { state: STUDIO_RUN_LANDING })`. */ +export const STUDIO_RUN_LANDING: StudioRunLandingState = { studioLanding: 'run' }; + +/** True when a location's `state` asks for the run-mode landing. */ +export function isStudioRunLanding(state: unknown): boolean { + return ( + typeof state === 'object' && + state !== null && + (state as { studioLanding?: unknown }).studioLanding === 'run' + ); +} From 25d046b0ca0d8df45138d16060664e236602b20b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:57:41 +0800 Subject: [PATCH 5/5] chore(changeset): declare the AI build surface fixes (objectui#11658) Co-authored-by: Claude --- .changeset/11658-ai-build-surface-ux.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/11658-ai-build-surface-ux.md diff --git a/.changeset/11658-ai-build-surface-ux.md b/.changeset/11658-ai-build-surface-ux.md new file mode 100644 index 0000000000..2fe7fb47d0 --- /dev/null +++ b/.changeset/11658-ai-build-surface-ux.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-chatbot': minor +'@object-ui/i18n': minor +--- + +Five fixes on the AI build surface, from the 2026-10-05 cloud acceptance run (objectui#11658). + +- **The first AI build lands on the running app.** The built-moment transition still moves the conversation into the Studio workbench at `/studio/PKG/interfaces`. The Interfaces canvas now opens in **Run** mode with the properties panel collapsed, where it used to open in Design with the panel open. One click on **Design** brings back the design overlays and the properties panel. Every other way into Studio, the "Design in Studio" button included, still opens in Design. The transition asks for this with router state, which the pillar reads once at mount; it is not a URL param, so a reload or a shared link opens the designer. +- **The in-app composer names the task.** Inside an app, the composer placeholder reads "Ask about your data, or ask me to change this app…". This covers the console dock in a running app, the Studio dock and `/ai/build?package=`. It used to read "Ask {agent}…", which showed as「向 构建 提问…」in Chinese. The cold-start build surface and the ask agent keep the placeholders they had. New key: `console.ai.askOrChangeApp`. +- **The AI usage popover shows one figure.** The popover shows the share of the single AI pool used so far (`console.ai.usage.poolUsed`, for example "19% used"). It no longer shows the build / data-Q&A split, because that split cannot honestly say which part a single-composer turn used. The keys `console.ai.usage.meterBuild`, `meterAsk` and `breakdownTitle` are retired from all ten packs. `useAiUsage` still reads `breakdown` from the wire and validates it strictly, but nothing renders it. +- **The usage gauge no longer looks like a loading spinner.** The header glyph is now a closed outline in the tone colour with a pie wedge inside it. It used to be a stroked arc over a faint track, which at low usage had the outline of a spinner. +- **The plan card's scope chip is localized.** The extend-mode chip on the proposed-plan card and on the live design panel reads the pack's `chatbot.plan.extendTarget` sentence, where it used to show the English literal "Adding to existing app". It names the target app by its label, and the internal name moves to the chip's tooltip. `ChatbotEnhanced` gains an optional `resolveAppLabel(appName)` prop, and the console passes one over its metadata apps. A host `planExtendLabel` still takes precedence, but the prop no longer defaults to an English string.