Skip to content

Commit 705d727

Browse files
committed
Add the Cloud plugin and AI gateway
1 parent bbf0b34 commit 705d727

77 files changed

Lines changed: 7870 additions & 196 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
2828
data: {
2929
experiments: {
3030
claudeCodeMockCliTraffic: false,
31+
cloudAi: false,
3132
newOnboarding: false,
3233
toolsHub: true,
3334
},

apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
2424
data: {
2525
experiments: {
2626
claudeCodeMockCliTraffic: false,
27+
cloudAi: false,
2728
newOnboarding: false,
2829
toolsHub: true,
2930
},

apps/app/src/components/plugin/PluginSettings.test.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @vitest-environment jsdom
22

33
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4+
import { MemoryRouter } from "react-router-dom";
45
import { afterEach, describe, expect, it, vi } from "vitest";
56
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
67
import {
@@ -283,14 +284,16 @@ describe("PluginSettingsDetail settings gating", () => {
283284
});
284285
const { wrapper } = createQueryClientTestHarness();
285286
render(
286-
<PluginSettingsDetail
287-
plugin={{
288-
...rowPlugin("running"),
289-
id: "connect",
290-
provenance: "builtin",
291-
hasSettings: false,
292-
}}
293-
/>,
287+
<MemoryRouter>
288+
<PluginSettingsDetail
289+
plugin={{
290+
...rowPlugin("running"),
291+
id: "connect",
292+
provenance: "builtin",
293+
hasSettings: false,
294+
}}
295+
/>
296+
</MemoryRouter>,
294297
{ wrapper },
295298
);
296299

apps/app/src/components/plugin/PluginSettingsSections.tsx

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { useEffect } from "react";
2+
import { useLocation } from "react-router-dom";
3+
import { useSystemConfig } from "@/hooks/queries/system-queries";
14
import {
25
usePluginSlots,
36
type PluginSettingsSectionSlot,
@@ -8,15 +11,32 @@ import {
811
ResourceDetailConfigurationSection,
912
} from "@bb/shared-ui/resource-list";
1013

14+
const CONNECT_PLUGIN_ID = "connect";
15+
const CLOUD_AI_SECTION_ID = "cloud-ai";
16+
17+
function isSettingsSectionVisible(
18+
section: PluginSettingsSectionSlot,
19+
cloudAiEnabled: boolean,
20+
): boolean {
21+
return !(
22+
section.pluginId === CONNECT_PLUGIN_ID &&
23+
section.id === CLOUD_AI_SECTION_ID &&
24+
!cloudAiEnabled
25+
);
26+
}
27+
1128
/**
1229
* Plugin `settingsSection` slot mounts, rendered on that plugin's canonical
1330
* Plugins detail page below the host-rendered declarative form.
1431
* Each section is contained in its own per-plugin error boundary.
1532
*/
1633
export function PluginSettingsSections({ pluginId }: { pluginId: string }) {
1734
const { settingsSections } = usePluginSlots();
35+
const cloudAiEnabled = useSystemConfig().data?.experiments?.cloudAi === true;
1836
const sections = settingsSections.filter(
19-
(section) => section.pluginId === pluginId,
37+
(section) =>
38+
section.pluginId === pluginId &&
39+
isSettingsSectionVisible(section, cloudAiEnabled),
2040
);
2141
if (sections.length === 0) return null;
2242
return <PluginSettingsSectionList sections={sections} />;
@@ -27,16 +47,34 @@ function PluginSettingsSectionList({
2747
}: {
2848
sections: readonly PluginSettingsSectionSlot[];
2949
}) {
50+
const location = useLocation();
51+
52+
useEffect(() => {
53+
if (location.hash.length <= 1) return;
54+
let sectionId: string;
55+
try {
56+
sectionId = decodeURIComponent(location.hash.slice(1));
57+
} catch {
58+
return;
59+
}
60+
if (!sections.some((section) => section.id === sectionId)) return;
61+
document.getElementById(sectionId)?.scrollIntoView({ block: "start" });
62+
}, [location.hash, location.key, sections]);
63+
3064
return (
3165
<div className="space-y-6" data-testid="plugin-settings-sections">
3266
{sections.map((section) => {
3367
const key = `${section.pluginId}/${section.id}/${section.generation}`;
34-
return section.title === undefined ? (
35-
<PluginSettingsSectionPanel key={key} section={section} />
36-
) : (
37-
<ResourceDetailConfigurationSection key={key} label={section.title}>
38-
<PluginSettingsSectionPanel section={section} />
39-
</ResourceDetailConfigurationSection>
68+
return (
69+
<div key={key} id={section.id} className="scroll-mt-4">
70+
{section.title === undefined ? (
71+
<PluginSettingsSectionPanel section={section} />
72+
) : (
73+
<ResourceDetailConfigurationSection label={section.title}>
74+
<PluginSettingsSectionPanel section={section} />
75+
</ResourceDetailConfigurationSection>
76+
)}
77+
</div>
4078
);
4179
})}
4280
</div>

apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ function registrationSet(
3232
}
3333

3434
function LocationProbe() {
35-
return <output aria-label="Current path">{useLocation().pathname}</output>;
35+
const location = useLocation();
36+
return (
37+
<output aria-label="Current path">
38+
{location.pathname}
39+
{location.hash}
40+
</output>
41+
);
3642
}
3743

3844
function renderWithProviders(ui: ReactNode, toolsHubEnabled = false) {
@@ -56,7 +62,7 @@ afterEach(() => {
5662
});
5763

5864
describe("PluginSidebarFooterActions", () => {
59-
it("prefers branding.icon over the logo and contribution icon", () => {
65+
it("uses the action icon instead of the plugin branding icon", () => {
6066
setPluginLogoUrls(
6167
new Map([
6268
[
@@ -87,8 +93,8 @@ describe("PluginSidebarFooterActions", () => {
8793

8894
renderWithProviders(<PluginSidebarFooterActions />);
8995

90-
expect(document.querySelector('[data-icon="FileText"]')).not.toBeNull();
91-
expect(document.querySelector('[data-icon="Smartphone"]')).toBeNull();
96+
expect(document.querySelector('[data-icon="Smartphone"]')).not.toBeNull();
97+
expect(document.querySelector('[data-icon="FileText"]')).toBeNull();
9298
expect(document.querySelector("img")).toBeNull();
9399
});
94100

@@ -143,4 +149,28 @@ describe("PluginSidebarFooterActions", () => {
143149
);
144150
},
145151
);
152+
153+
it("opens a specific plugin settings section", () => {
154+
setPluginSlotRegistrations(
155+
"cloud",
156+
registrationSet({
157+
sidebarFooterActions: [
158+
{
159+
id: "remote-access",
160+
title: "Remote access",
161+
icon: "Smartphone",
162+
run: ({ openSettings }) =>
163+
openSettings({ sectionId: "remote-access" }),
164+
},
165+
],
166+
}),
167+
);
168+
169+
renderWithProviders(<PluginSidebarFooterActions />);
170+
fireEvent.click(screen.getByRole("button", { name: "Remote access" }));
171+
172+
expect(screen.getByLabelText("Current path").textContent).toBe(
173+
"/settings/plugins/cloud#remote-access",
174+
);
175+
});
146176
});

apps/app/src/components/plugin/PluginSidebarFooterActions.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useNavigate } from "react-router-dom";
22
import { cn } from "@bb/shared-ui/lib/utils";
33
import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
4+
import { Icon } from "@bb/shared-ui/icon";
45
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar.js";
5-
import { PluginIcon } from "@/components/plugin/PluginIcon";
6+
import { pluginIconName } from "@/components/plugin/PluginIcon";
67
import {
78
usePluginSlots,
89
type PluginSidebarFooterActionSlot,
@@ -63,7 +64,11 @@ function PluginSidebarFooterActionList({
6364
});
6465
}}
6566
>
66-
<PluginIcon pluginId={action.pluginId} icon={action.icon} />
67+
<Icon
68+
name={pluginIconName(action.icon)}
69+
className="size-4 shrink-0"
70+
aria-hidden="true"
71+
/>
6772
<span className="sr-only">{action.title}</span>
6873
</SidebarMenuButton>
6974
</SidebarMenuItem>
@@ -79,8 +84,12 @@ function runSidebarFooterAction({
7984
action: PluginSidebarFooterActionSlot;
8085
navigate: ReturnType<typeof useNavigate>;
8186
}): void {
82-
const openSettings = () => {
83-
void navigate(getSettingsPluginRoutePath(action.pluginId));
87+
const openSettings: Parameters<typeof action.run>[0]["openSettings"] = (
88+
options,
89+
) => {
90+
void navigate(
91+
getSettingsPluginRoutePath(action.pluginId, options?.sectionId),
92+
);
8493
};
8594
const warn = (error: unknown) => {
8695
console.warn(

apps/app/src/components/settings/PluginsSettingsSection.test.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,9 +489,9 @@ describe("PluginSettingsDetail settings gating", () => {
489489
expect(requests.some((request) => request.init?.method === "POST")).toBe(
490490
true,
491491
);
492-
expect(
493-
requests.some((request) => request.init?.method !== "POST"),
494-
).toBe(true);
492+
expect(requests.some((request) => request.init?.method !== "POST")).toBe(
493+
true,
494+
);
495495
});
496496

497497
const pendingSwitch = screen.getByRole("switch", {
@@ -623,6 +623,77 @@ describe("PluginSettingsDetail settings gating", () => {
623623
).toBeDefined();
624624
expect(screen.queryByText("This plugin declares no settings.")).toBeNull();
625625
});
626+
627+
it("keeps Cloud identity above experiment-gated settings sections", async () => {
628+
const scrollIntoView = vi.fn();
629+
HTMLElement.prototype.scrollIntoView = scrollIntoView;
630+
function RemoteAccessSettings() {
631+
return <div>Custom remote access settings</div>;
632+
}
633+
function AiGatewaySettings() {
634+
return <div>Custom AI Gateway settings</div>;
635+
}
636+
setPluginSlotRegistrations("connect", {
637+
homepageSections: [],
638+
settingsSections: [
639+
{
640+
id: "remote-access",
641+
title: "Remote access",
642+
component: RemoteAccessSettings,
643+
},
644+
{
645+
id: "cloud-ai",
646+
title: "AI Gateway",
647+
component: AiGatewaySettings,
648+
},
649+
],
650+
navPanels: [],
651+
threadPanelActions: [],
652+
sidebarFooterActions: [],
653+
fileOpeners: [],
654+
messageDirectives: [],
655+
});
656+
const { queryClient, wrapper } = createQueryClientTestHarness();
657+
queryClient.setQueryData(systemConfigQueryKey(), systemConfig());
658+
render(
659+
<MemoryRouter
660+
initialEntries={["/settings/plugins/connect#remote-access"]}
661+
>
662+
<PluginSettingsDetail
663+
plugin={{
664+
...rowPlugin("running"),
665+
id: "connect",
666+
name: "Cloud",
667+
description: "Remote access and account-backed AI.",
668+
icon: "Cloud",
669+
hasSettings: false,
670+
}}
671+
/>
672+
</MemoryRouter>,
673+
{ wrapper },
674+
);
675+
676+
expect(screen.getByRole("heading", { name: "Cloud" })).toBeDefined();
677+
expect(
678+
screen.getByText("Remote access and account-backed AI."),
679+
).toBeDefined();
680+
expect(screen.getByRole("switch", { name: "Disable Cloud" })).toBeDefined();
681+
expect(screen.getByText("Remote access")).toBeDefined();
682+
expect(screen.getByText("Custom remote access settings")).toBeDefined();
683+
expect(document.getElementById("remote-access")).not.toBeNull();
684+
expect(screen.queryByText("AI Gateway")).toBeNull();
685+
expect(screen.queryByText("Custom AI Gateway settings")).toBeNull();
686+
await vi.waitFor(() =>
687+
expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" }),
688+
);
689+
690+
queryClient.setQueryData(systemConfigQueryKey(), {
691+
...systemConfig(),
692+
experiments: { ...defaultExperiments, cloudAi: true },
693+
});
694+
expect(await screen.findByText("AI Gateway")).toBeDefined();
695+
expect(screen.getByText("Custom AI Gateway settings")).toBeDefined();
696+
});
626697
});
627698

628699
describe("InstalledPluginRow", () => {

apps/app/src/lib/route-paths.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,14 @@ export function getSettingsRoutePath(section?: string): string {
103103
: `/settings/${encodeURIComponent(section)}`;
104104
}
105105

106-
export function getSettingsPluginRoutePath(pluginId: string): string {
107-
return `/settings/plugins/${encodeURIComponent(pluginId)}`;
106+
export function getSettingsPluginRoutePath(
107+
pluginId: string,
108+
sectionId?: string,
109+
): string {
110+
const path = `/settings/plugins/${encodeURIComponent(pluginId)}`;
111+
return sectionId === undefined
112+
? path
113+
: `${path}#${encodeURIComponent(sectionId)}`;
108114
}
109115

110116
export function getSettingsProviderRoutePath(providerId: string): string {

apps/app/src/lib/system-config-atoms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const unavailableSystemConfig: SystemConfigResponse = {
1515
keybindingOverrides: [],
1616
experiments: {
1717
claudeCodeMockCliTraffic: false,
18+
cloudAi: false,
1819
newOnboarding: false,
1920
toolsHub: false,
2021
},

apps/app/src/views/SettingsView.experiments.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ import { ExperimentsSettingsSection } from "./SettingsView";
66
afterEach(cleanup);
77

88
function renderSection(overrides?: {
9+
onCloudAiEnabledChange?: (enabled: boolean) => void;
910
onNewOnboardingEnabledChange?: (enabled: boolean) => void;
1011
onToolsHubEnabledChange?: (enabled: boolean) => void;
1112
}) {
1213
return render(
1314
<ExperimentsSettingsSection
1415
claudeCodeMockCliTrafficEnabled={false}
16+
cloudAiEnabled={false}
1517
disabled={false}
1618
newOnboardingEnabled={false}
1719
onClaudeCodeMockCliTrafficEnabledChange={vi.fn()}
20+
onCloudAiEnabledChange={overrides?.onCloudAiEnabledChange ?? vi.fn()}
1821
onNewOnboardingEnabledChange={
1922
overrides?.onNewOnboardingEnabledChange ?? vi.fn()
2023
}
@@ -25,6 +28,13 @@ function renderSection(overrides?: {
2528
}
2629

2730
describe("ExperimentsSettingsSection", () => {
31+
it("reports Cloud AI changes", () => {
32+
const onChange = vi.fn();
33+
renderSection({ onCloudAiEnabledChange: onChange });
34+
fireEvent.click(screen.getByLabelText("Cloud AI"));
35+
expect(onChange).toHaveBeenCalledWith(true);
36+
});
37+
2838
it("reports new onboarding changes", () => {
2939
const onChange = vi.fn();
3040
renderSection({ onNewOnboardingEnabledChange: onChange });

0 commit comments

Comments
 (0)