Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
cloudAi: false,
newOnboarding: false,
toolsHub: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
cloudAi: false,
newOnboarding: false,
toolsHub: true,
},
Expand Down
19 changes: 11 additions & 8 deletions apps/app/src/components/plugin/PluginSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import {
Expand Down Expand Up @@ -283,14 +284,16 @@ describe("PluginSettingsDetail settings gating", () => {
});
const { wrapper } = createQueryClientTestHarness();
render(
<PluginSettingsDetail
plugin={{
...rowPlugin("running"),
id: "connect",
provenance: "builtin",
hasSettings: false,
}}
/>,
<MemoryRouter>
<PluginSettingsDetail
plugin={{
...rowPlugin("running"),
id: "connect",
provenance: "builtin",
hasSettings: false,
}}
/>
</MemoryRouter>,
{ wrapper },
);

Expand Down
52 changes: 45 additions & 7 deletions apps/app/src/components/plugin/PluginSettingsSections.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import {
usePluginSlots,
type PluginSettingsSectionSlot,
Expand All @@ -8,15 +11,32 @@ import {
ResourceDetailConfigurationSection,
} from "@bb/shared-ui/resource-list";

const CONNECT_PLUGIN_ID = "connect";
const CLOUD_AI_SECTION_ID = "cloud-ai";

function isSettingsSectionVisible(
section: PluginSettingsSectionSlot,
cloudAiEnabled: boolean,
): boolean {
return !(
section.pluginId === CONNECT_PLUGIN_ID &&
section.id === CLOUD_AI_SECTION_ID &&
!cloudAiEnabled
);
}

/**
* Plugin `settingsSection` slot mounts, rendered on that plugin's canonical
* Plugins detail page below the host-rendered declarative form.
* Each section is contained in its own per-plugin error boundary.
*/
export function PluginSettingsSections({ pluginId }: { pluginId: string }) {
const { settingsSections } = usePluginSlots();
const cloudAiEnabled = useSystemConfig().data?.experiments?.cloudAi === true;
const sections = settingsSections.filter(
(section) => section.pluginId === pluginId,
(section) =>
section.pluginId === pluginId &&
isSettingsSectionVisible(section, cloudAiEnabled),
);
if (sections.length === 0) return null;
return <PluginSettingsSectionList sections={sections} />;
Expand All @@ -27,16 +47,34 @@ function PluginSettingsSectionList({
}: {
sections: readonly PluginSettingsSectionSlot[];
}) {
const location = useLocation();

useEffect(() => {
if (location.hash.length <= 1) return;
let sectionId: string;
try {
sectionId = decodeURIComponent(location.hash.slice(1));
} catch {
return;
}
if (!sections.some((section) => section.id === sectionId)) return;
document.getElementById(sectionId)?.scrollIntoView({ block: "start" });
}, [location.hash, location.key, sections]);

return (
<div className="space-y-6" data-testid="plugin-settings-sections">
{sections.map((section) => {
const key = `${section.pluginId}/${section.id}/${section.generation}`;
return section.title === undefined ? (
<PluginSettingsSectionPanel key={key} section={section} />
) : (
<ResourceDetailConfigurationSection key={key} label={section.title}>
<PluginSettingsSectionPanel section={section} />
</ResourceDetailConfigurationSection>
return (
<div key={key} id={section.id} className="scroll-mt-4">
{section.title === undefined ? (
<PluginSettingsSectionPanel section={section} />
) : (
<ResourceDetailConfigurationSection label={section.title}>
<PluginSettingsSectionPanel section={section} />
</ResourceDetailConfigurationSection>
)}
</div>
);
})}
</div>
Expand Down
38 changes: 34 additions & 4 deletions apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ function registrationSet(
}

function LocationProbe() {
return <output aria-label="Current path">{useLocation().pathname}</output>;
const location = useLocation();
return (
<output aria-label="Current path">
{location.pathname}
{location.hash}
</output>
);
}

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

describe("PluginSidebarFooterActions", () => {
it("prefers branding.icon over the logo and contribution icon", () => {
it("uses the action icon instead of the plugin branding icon", () => {
setPluginLogoUrls(
new Map([
[
Expand Down Expand Up @@ -87,8 +93,8 @@ describe("PluginSidebarFooterActions", () => {

renderWithProviders(<PluginSidebarFooterActions />);

expect(document.querySelector('[data-icon="FileText"]')).not.toBeNull();
expect(document.querySelector('[data-icon="Smartphone"]')).toBeNull();
expect(document.querySelector('[data-icon="Smartphone"]')).not.toBeNull();
expect(document.querySelector('[data-icon="FileText"]')).toBeNull();
expect(document.querySelector("img")).toBeNull();
});

Expand Down Expand Up @@ -143,4 +149,28 @@ describe("PluginSidebarFooterActions", () => {
);
},
);

it("opens a specific plugin settings section", () => {
setPluginSlotRegistrations(
"cloud",
registrationSet({
sidebarFooterActions: [
{
id: "remote-access",
title: "Remote access",
icon: "Smartphone",
run: ({ openSettings }) =>
openSettings({ sectionId: "remote-access" }),
},
],
}),
);

renderWithProviders(<PluginSidebarFooterActions />);
fireEvent.click(screen.getByRole("button", { name: "Remote access" }));

expect(screen.getByLabelText("Current path").textContent).toBe(
"/settings/plugins/cloud#remote-access",
);
});
});
17 changes: 13 additions & 4 deletions apps/app/src/components/plugin/PluginSidebarFooterActions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useNavigate } from "react-router-dom";
import { cn } from "@bb/shared-ui/lib/utils";
import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
import { Icon } from "@bb/shared-ui/icon";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar.js";
import { PluginIcon } from "@/components/plugin/PluginIcon";
import { pluginIconName } from "@/components/plugin/PluginIcon";
import {
usePluginSlots,
type PluginSidebarFooterActionSlot,
Expand Down Expand Up @@ -63,7 +64,11 @@ function PluginSidebarFooterActionList({
});
}}
>
<PluginIcon pluginId={action.pluginId} icon={action.icon} />
<Icon
name={pluginIconName(action.icon)}
className="size-4 shrink-0"
aria-hidden="true"
/>
<span className="sr-only">{action.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
Expand All @@ -79,8 +84,12 @@ function runSidebarFooterAction({
action: PluginSidebarFooterActionSlot;
navigate: ReturnType<typeof useNavigate>;
}): void {
const openSettings = () => {
void navigate(getSettingsPluginRoutePath(action.pluginId));
const openSettings: Parameters<typeof action.run>[0]["openSettings"] = (
options,
) => {
void navigate(
getSettingsPluginRoutePath(action.pluginId, options?.sectionId),
);
};
const warn = (error: unknown) => {
console.warn(
Expand Down
77 changes: 74 additions & 3 deletions apps/app/src/components/settings/PluginsSettingsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,9 @@ describe("PluginSettingsDetail settings gating", () => {
expect(requests.some((request) => request.init?.method === "POST")).toBe(
true,
);
expect(
requests.some((request) => request.init?.method !== "POST"),
).toBe(true);
expect(requests.some((request) => request.init?.method !== "POST")).toBe(
true,
);
});

const pendingSwitch = screen.getByRole("switch", {
Expand Down Expand Up @@ -623,6 +623,77 @@ describe("PluginSettingsDetail settings gating", () => {
).toBeDefined();
expect(screen.queryByText("This plugin declares no settings.")).toBeNull();
});

it("keeps Cloud identity above experiment-gated settings sections", async () => {
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
function RemoteAccessSettings() {
return <div>Custom remote access settings</div>;
}
function AiGatewaySettings() {
return <div>Custom AI Gateway settings</div>;
}
setPluginSlotRegistrations("connect", {
homepageSections: [],
settingsSections: [
{
id: "remote-access",
title: "Remote access",
component: RemoteAccessSettings,
},
{
id: "cloud-ai",
title: "AI Gateway",
component: AiGatewaySettings,
},
],
navPanels: [],
threadPanelActions: [],
sidebarFooterActions: [],
fileOpeners: [],
messageDirectives: [],
});
const { queryClient, wrapper } = createQueryClientTestHarness();
queryClient.setQueryData(systemConfigQueryKey(), systemConfig());
render(
<MemoryRouter
initialEntries={["/settings/plugins/connect#remote-access"]}
>
<PluginSettingsDetail
plugin={{
...rowPlugin("running"),
id: "connect",
name: "Cloud",
description: "Remote access and account-backed AI.",
icon: "Cloud",
hasSettings: false,
}}
/>
</MemoryRouter>,
{ wrapper },
);

expect(screen.getByRole("heading", { name: "Cloud" })).toBeDefined();
expect(
screen.getByText("Remote access and account-backed AI."),
).toBeDefined();
expect(screen.getByRole("switch", { name: "Disable Cloud" })).toBeDefined();
expect(screen.getByText("Remote access")).toBeDefined();
expect(screen.getByText("Custom remote access settings")).toBeDefined();
expect(document.getElementById("remote-access")).not.toBeNull();
expect(screen.queryByText("AI Gateway")).toBeNull();
expect(screen.queryByText("Custom AI Gateway settings")).toBeNull();
await vi.waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" }),
);

queryClient.setQueryData(systemConfigQueryKey(), {
...systemConfig(),
experiments: { ...defaultExperiments, cloudAi: true },
});
expect(await screen.findByText("AI Gateway")).toBeDefined();
expect(screen.getByText("Custom AI Gateway settings")).toBeDefined();
});
});

describe("InstalledPluginRow", () => {
Expand Down
20 changes: 17 additions & 3 deletions apps/app/src/lib/dev-websocket-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function installWindowLocation(url: string): void {
location: {
host: location.host,
hostname: location.hostname,
port: location.port,
protocol: location.protocol,
},
});
Expand All @@ -19,6 +20,7 @@ describe("buildDevWebSocketUrl", () => {

it("connects directly to the backend for HTTP source dev", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("http://devbox.local:15802/threads/thr_1");

expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
Expand All @@ -28,6 +30,7 @@ describe("buildDevWebSocketUrl", () => {

it("uses the proxied app origin for HTTPS bb connect shares", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation(
"https://sawyer--15802.getbb.app/threads/thr_jew2ruik89",
);
Expand All @@ -37,13 +40,24 @@ describe("buildDevWebSocketUrl", () => {
);
});

it("uses the proxied app origin for the HTTP localhost Cloud gate", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("http://sawyer.localhost:39802/threads/thr_1");

expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
"ws://sawyer.localhost:39802/ws",
);
});

it("preserves terminal websocket paths on the proxied app origin", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("https://dev.example.test:15802/threads/thr_1");

expect(
buildDevWebSocketUrl({ path: "/ws/terminals/term_1" }),
).toBe("wss://dev.example.test:15802/ws/terminals/term_1");
expect(buildDevWebSocketUrl({ path: "/ws/terminals/term_1" })).toBe(
"wss://dev.example.test:15802/ws/terminals/term_1",
);
});

it("returns undefined outside the dev build", () => {
Expand Down
Loading
Loading