Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/11658-ai-build-surface-ux.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 25 additions & 2 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2006,6 +2007,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 <app>" + change-oriented starters, instead of the from-scratch
Expand Down Expand Up @@ -2096,7 +2108,11 @@ export function ChatPane({
// app:<pkg>: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,
Expand Down Expand Up @@ -2368,7 +2384,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')
Expand Down Expand Up @@ -2556,6 +2578,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' })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
ChatbotEnhanced: (props: Record<string, unknown>) => {
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<Record<string, unknown>>();
return { ...actual, useMetadata: () => ({ apps: APPS }) };
});
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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(
<I18nProvider config={{ defaultLanguage: uiLocale, detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={[`/ai/${opts.agent}/conv-1`]}>
<ChatPane
agents={AGENTS}
agentsLoading={false}
agentsError={undefined}
activeAgent={opts.agent}
chatApi={`/api/v1/ai/agents/${opts.agent}/chat`}
apiBase="/api/v1/ai"
conversationId="conv-1"
editPackageId={opts.editPackageId}
initialMessages={[]}
pendingFirstMessageRef={{ current: null }}
onSent={vi.fn()}
onShare={vi.fn()}
/>
</MemoryRouter>
</I18nProvider>,
);
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();
});
});
Loading
Loading