Skip to content

Commit 3e1fcf5

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(chatbot): reveal Build/Ask switcher in the app FAB when AI dev is unlocked (#1921)
The floating assistant (bottom-right FAB) bound every app to a single agent and hid the agent picker unless VITE_AI_SHOW_AGENT_PICKER was set, so a user on an AI-unlocked env couldn't switch from `ask` (read-only data/query) to `build` (authoring) to keep developing inside the panel — only the full /ai page did. Auto-reveal the picker when AI development is unlocked for the viewer: the live agent catalog serves BOTH an `ask` and a `build` agent (alias-aware via isAskAgent/isBuildAgent, so legacy data_chat/metadata_assistant count) AND authoring isn't deployment-disabled (aiStudio). Pure end-user apps (only `ask`) stay clean and never see a picker, preserving the original "end users shouldn't have to choose" design. An explicit showAgentPicker prop or VITE_AI_SHOW_AGENT_PICKER still forces it on. The decision is extracted into a pure, unit-tested helper (agentPicker.ts) since ConsoleFloatingChatbot can't be unit-tested in isolation. Switching to build remounts the chat onto the build endpoint via the existing key-based remount, so propose/apply + create_object run from the panel. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 684400a commit 3e1fcf5

4 files changed

Lines changed: 193 additions & 12 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(chatbot): reveal the Build/Ask switcher in the app floating assistant when AI dev is unlocked
6+
7+
The bottom-right FAB assistant bound each app to a single agent and hid the
8+
agent picker unless `VITE_AI_SHOW_AGENT_PICKER` was set, so a user on an
9+
AI-unlocked environment could not switch from `ask` (read-only data/query) to
10+
`build` (authoring) without leaving for the full `/ai` page.
11+
12+
The picker now auto-reveals when AI development is unlocked for the viewer — the
13+
live agent catalog serves BOTH an `ask` and a `build` agent (alias-aware, so
14+
legacy `data_chat`/`metadata_assistant` count) AND authoring isn't
15+
deployment-disabled (`aiStudio`). Pure end-user apps (only `ask`) stay clean and
16+
never see a picker. An explicit `showAgentPicker` prop or
17+
`VITE_AI_SHOW_AGENT_PICKER` still forces it on.

packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { useAssistant, requestAssistantReview, emitCanvasInvalidate, type Assist
4747
import { fetchPendingDraftCount } from '../preview/draftStatus';
4848
import { getRuntimeConfig } from '../runtime-config';
4949
import { cloudPricingDeepLink } from '../console/marketplace/marketplaceApi';
50+
import { shouldShowAgentPicker } from './agentPicker';
5051

5152
/**
5253
* Display names for the two built-in platform agents (ADR-0063: `ask` / `build`,
@@ -236,10 +237,12 @@ export interface ConsoleFloatingChatbotProps {
236237
*/
237238
defaultAgent?: string;
238239
/**
239-
* Show the in-header agent switcher. Off by default: end users get the
240-
* single agent bound to their app and never have to choose. Enable for
241-
* power users / admins (or via `VITE_AI_SHOW_AGENT_PICKER`) when a
242-
* surface genuinely exposes multiple agents.
240+
* Force the in-header agent switcher on (`true`) or off (`false`),
241+
* overriding the default. When left undefined the switcher auto-reveals
242+
* only when AI development is unlocked for the viewer — the live catalog
243+
* serves BOTH an `ask` and a `build` agent and `aiStudio` isn't disabled —
244+
* so pure end-user apps (only `ask`) stay clean while builders can flip
245+
* Ask↔Build inline. `VITE_AI_SHOW_AGENT_PICKER=true` also forces it on.
243246
*/
244247
showAgentPicker?: boolean;
245248
/** Whether the floating panel should open immediately on mount. */
@@ -497,10 +500,10 @@ function ChatbotInner({
497500
},
498501
});
499502

500-
// Agent switcher — deliberately hidden by default. End users get the
501-
// single agent bound to their app (Studio → metadata_assistant, others
502-
// → data_chat) and are never asked to choose. Only surfaces when the
503-
// host explicitly opts in AND there is more than one agent to pick.
503+
// Agent switcher — Ask ↔ Build (plus any custom agents). Restrained by
504+
// design: end users bound to a single agent never see it. `showAgentPicker`
505+
// is true when AI development is unlocked (catalog serves both ask & build)
506+
// or forced on; it still needs more than one agent to be a real choice.
504507
const headerExtra =
505508
showAgentPicker && agents.length > 1 ? (
506509
<Select
@@ -738,13 +741,21 @@ export default function ConsoleFloatingChatbot({
738741
const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
739742
const env = (import.meta as any).env ?? {};
740743
const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT as string | undefined;
741-
// Power-user / admin escape hatch: force the picker on globally without
742-
// touching app metadata.
743-
const showAgentPicker =
744-
showAgentPickerProp ?? env.VITE_AI_SHOW_AGENT_PICKER === 'true';
745744

746745
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
747746

747+
// Reveal the Build/Ask switcher only when AI development is unlocked for this
748+
// viewer — the live catalog serves BOTH an `ask` and a `build` agent and
749+
// authoring isn't deployment-disabled. Pure end-user apps (only `ask`) stay
750+
// clean; builders can flip "ask about my data" ↔ "extend my app" inline. An
751+
// explicit prop or `VITE_AI_SHOW_AGENT_PICKER` still forces it. See agentPicker.
752+
const showAgentPicker = shouldShowAgentPicker({
753+
agents,
754+
showAgentPickerProp,
755+
envOptIn: env.VITE_AI_SHOW_AGENT_PICKER === 'true',
756+
aiStudioEnabled: getRuntimeConfig().features.aiStudio !== false,
757+
});
758+
748759
const [activeAgent, setActiveAgent] = React.useState<string | undefined>(undefined);
749760
React.useEffect(() => {
750761
if (!activeAgent && agents.length > 0) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { shouldShowAgentPicker, isAiDevUnlocked } from '../agentPicker';
3+
4+
const ask = { name: 'ask' };
5+
const build = { name: 'build' };
6+
const dataChat = { name: 'data_chat' }; // legacy alias of `ask`
7+
const metadataAssistant = { name: 'metadata_assistant' }; // legacy alias of `build`
8+
const custom = { name: 'sales_assistant' };
9+
10+
describe('isAiDevUnlocked', () => {
11+
it('is true only when the catalog serves BOTH an ask and a build agent', () => {
12+
expect(isAiDevUnlocked([ask, build])).toBe(true);
13+
});
14+
15+
it('is alias-aware (legacy data_chat + metadata_assistant counts)', () => {
16+
expect(isAiDevUnlocked([dataChat, metadataAssistant])).toBe(true);
17+
});
18+
19+
it('is false for a pure end-user catalog (ask only)', () => {
20+
expect(isAiDevUnlocked([ask])).toBe(false);
21+
});
22+
23+
it('is false when only a build agent is present (no ask)', () => {
24+
expect(isAiDevUnlocked([build])).toBe(false);
25+
});
26+
27+
it('is false for an empty catalog or custom-only agents', () => {
28+
expect(isAiDevUnlocked([])).toBe(false);
29+
expect(isAiDevUnlocked([custom])).toBe(false);
30+
});
31+
});
32+
33+
describe('shouldShowAgentPicker', () => {
34+
describe('auto-reveal (no explicit override)', () => {
35+
it('shows when AI development is unlocked (ask + build)', () => {
36+
expect(shouldShowAgentPicker({ agents: [ask, build] })).toBe(true);
37+
});
38+
39+
it('shows for the legacy alias catalog too', () => {
40+
expect(shouldShowAgentPicker({ agents: [dataChat, metadataAssistant] })).toBe(true);
41+
});
42+
43+
it('stays hidden for a pure end-user app (ask only)', () => {
44+
expect(shouldShowAgentPicker({ agents: [ask] })).toBe(false);
45+
});
46+
47+
it('stays hidden when only custom agents exist', () => {
48+
expect(shouldShowAgentPicker({ agents: [ask, custom] })).toBe(false);
49+
});
50+
51+
it('is suppressed when AI Studio (authoring) is deployment-disabled', () => {
52+
expect(
53+
shouldShowAgentPicker({ agents: [ask, build], aiStudioEnabled: false }),
54+
).toBe(false);
55+
});
56+
});
57+
58+
describe('env opt-in (VITE_AI_SHOW_AGENT_PICKER)', () => {
59+
it('forces the picker on even for a single-agent end-user catalog', () => {
60+
expect(shouldShowAgentPicker({ agents: [ask], envOptIn: true })).toBe(true);
61+
});
62+
});
63+
64+
describe('explicit prop wins outright', () => {
65+
it('true forces on even with an empty catalog', () => {
66+
expect(shouldShowAgentPicker({ agents: [], showAgentPickerProp: true })).toBe(true);
67+
});
68+
69+
it('false forces off even when AI development is unlocked', () => {
70+
expect(
71+
shouldShowAgentPicker({ agents: [ask, build], showAgentPickerProp: false }),
72+
).toBe(false);
73+
});
74+
75+
it('false beats the env opt-in', () => {
76+
expect(
77+
shouldShowAgentPicker({ agents: [ask], showAgentPickerProp: false, envOptIn: true }),
78+
).toBe(false);
79+
});
80+
});
81+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* agentPicker
3+
*
4+
* Pure decision logic for the floating assistant's Build/Ask switcher. Kept in
5+
* its own module (no React, no chat deps) so it can be unit-tested without
6+
* dragging in the heavy chat component graph (FloatingChatbot → streamdown →
7+
* shiki → @ai-sdk, ~20MB) that `ConsoleFloatingChatbot` pulls in.
8+
* @module
9+
*/
10+
import { isAskAgent, isBuildAgent, type AgentDescriptor } from '@object-ui/plugin-chatbot';
11+
12+
/** Minimal catalog shape the decision needs — just the agent name. */
13+
type AgentLike = Pick<AgentDescriptor, 'name'>;
14+
15+
export interface AgentPickerDecisionInput {
16+
/** Live agent catalog from `useAgents` (the single source of truth). */
17+
agents: AgentLike[];
18+
/**
19+
* Explicit host override. When defined it wins outright — `true` forces the
20+
* switcher on, `false` forces it off — regardless of catalog or env.
21+
*/
22+
showAgentPickerProp?: boolean;
23+
/**
24+
* `VITE_AI_SHOW_AGENT_PICKER === 'true'` — the power-user / admin global
25+
* escape hatch that forces the switcher on without touching app metadata.
26+
*/
27+
envOptIn?: boolean;
28+
/**
29+
* Whether AI Studio (authoring / "online development") is enabled for this
30+
* deployment. When off, the build agent must not be reachable from the panel,
31+
* so the auto-reveal is suppressed even if the catalog still serves `build`.
32+
* Mirrors `ConsoleLayout`'s `aiStudioEnabled` gate. Defaults to true.
33+
*/
34+
aiStudioEnabled?: boolean;
35+
}
36+
37+
/**
38+
* True when the live catalog exposes BOTH a data/query (`ask`) and an authoring
39+
* (`build`) agent — alias-aware via {@link isAskAgent}/{@link isBuildAgent}, so
40+
* a catalog still serving the legacy `data_chat`/`metadata_assistant` ids counts
41+
* too. This is the "AI development is unlocked for this viewer" signal, the same
42+
* `askAvailable && buildAvailable` notion HomePage uses to surface "Build with AI".
43+
*/
44+
export function isAiDevUnlocked(agents: AgentLike[]): boolean {
45+
return (
46+
agents.some((a) => isAskAgent(a.name)) && agents.some((a) => isBuildAgent(a.name))
47+
);
48+
}
49+
50+
/**
51+
* Decide whether the floating assistant should reveal its Build/Ask switcher.
52+
*
53+
* Restrained by design (the original "end users shouldn't have to choose" rule):
54+
* a pure end-user surface bound to a single `ask` agent never sees it. Precedence:
55+
* 1. `showAgentPickerProp` — explicit host override wins (`true`/`false`).
56+
* 2. `envOptIn` — `VITE_AI_SHOW_AGENT_PICKER` forces it on globally.
57+
* 3. Auto-reveal — AI development is unlocked ({@link isAiDevUnlocked}) AND
58+
* authoring isn't deployment-disabled (`aiStudioEnabled`).
59+
*
60+
* Returns the *intent* only: the render site still requires more than one agent
61+
* (`agents.length > 1`) to draw an actual choice.
62+
*/
63+
export function shouldShowAgentPicker({
64+
agents,
65+
showAgentPickerProp,
66+
envOptIn = false,
67+
aiStudioEnabled = true,
68+
}: AgentPickerDecisionInput): boolean {
69+
if (showAgentPickerProp !== undefined) return showAgentPickerProp;
70+
if (envOptIn) return true;
71+
return aiStudioEnabled && isAiDevUnlocked(agents);
72+
}

0 commit comments

Comments
 (0)