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
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
55 changes: 38 additions & 17 deletions apps/app/src/components/plugin/PluginSettingsSections.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import {
usePluginSlots,
type PluginSettingsSectionSlot,
Expand Down Expand Up @@ -27,28 +29,47 @@ 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) => (
<ResourceDetailConfigurationSection
<div
key={`${section.pluginId}/${section.id}/${section.generation}`}
label={section.title ?? "Plugin settings"}
id={section.id}
className="scroll-mt-4"
>
<ResourceDetailPanel surface="recessed" className="px-3 py-3">
{section.description !== undefined ? (
<p className="mb-3 text-xs leading-snug text-subtle-foreground/75">
{section.description}
</p>
) : null}
<PluginSlotMount
pluginId={section.pluginId}
slotKind="settingsSection"
slotId={section.id}
>
<section.component />
</PluginSlotMount>
</ResourceDetailPanel>
</ResourceDetailConfigurationSection>
<ResourceDetailConfigurationSection
label={section.title ?? "Plugin settings"}
>
<ResourceDetailPanel surface="recessed" className="px-3 py-3">
{section.description !== undefined ? (
<p className="mb-3 text-xs leading-snug text-subtle-foreground/75">
{section.description}
</p>
) : null}
<PluginSlotMount
pluginId={section.pluginId}
slotKind="settingsSection"
slotId={section.id}
>
<section.component />
</PluginSlotMount>
</ResourceDetailPanel>
</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
15 changes: 14 additions & 1 deletion apps/app/src/components/settings/PluginsSettingsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,17 @@ const SETTINGS_VIEW = {
values: { greeting: "hello", enabled: true, apiKey: { set: false } },
};

const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

afterEach(() => {
cleanup();
resetPluginSlotStoreForTest();
vi.unstubAllGlobals();
if (originalScrollIntoView === undefined) {
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
} else {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});

describe("PluginSettingsForm", () => {
Expand Down Expand Up @@ -330,6 +337,8 @@ describe("PluginSettingsDetail settings gating", () => {
});

it("renders a slot-only settings page", async () => {
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
function ConnectSettings() {
return <div>Custom connect settings</div>;
}
Expand Down Expand Up @@ -368,15 +377,19 @@ describe("PluginSettingsDetail settings gating", () => {

const { wrapper } = createQueryClientTestHarness();
render(
<MemoryRouter>
<MemoryRouter initialEntries={["/settings/plugins/connect#remote"]}>
<PluginSettingsDetailSection pluginId="connect" />
</MemoryRouter>,
{ wrapper },
);

expect(await screen.findByText("Remote access")).toBeDefined();
expect(screen.getByText("Custom connect settings")).toBeDefined();
expect(document.getElementById("remote")).not.toBeNull();
expect(screen.queryByText("This plugin declares no settings.")).toBeNull();
await vi.waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" }),
);
});
});

Expand Down
10 changes: 8 additions & 2 deletions apps/app/src/lib/route-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,14 @@ export function getSettingsRoutePath(section?: string): string {
: `/settings/${encodeURIComponent(section)}`;
}

export function getSettingsPluginRoutePath(pluginId: string): string {
return `/settings/plugins/${encodeURIComponent(pluginId)}`;
export function getSettingsPluginRoutePath(
pluginId: string,
sectionId?: string,
): string {
const path = `/settings/plugins/${encodeURIComponent(pluginId)}`;
return sectionId === undefined
? path
: `${path}#${encodeURIComponent(sectionId)}`;
}

export function getSettingsProviderRoutePath(providerId: string): string {
Expand Down
84 changes: 84 additions & 0 deletions apps/connect/src/ai-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { withStrictObjectSchemas } from "./ai-schema.js";

// Parity cases with the host-daemon original (codex-chatgpt-client.ts) — if
// these diverge, the daemon and gate would strictify the same bb schema
// differently and one upstream would reject it.
describe("withStrictObjectSchemas", () => {
it("marks object schemas strict and requires every property", () => {
expect(
withStrictObjectSchemas({
type: "object",
properties: {
title: { type: "string" },
count: { type: "number" },
},
}),
).toEqual({
type: "object",
properties: {
title: { type: "string" },
count: { type: "number" },
},
additionalProperties: false,
required: ["title", "count"],
});
});

it("recurses into nested objects, arrays, and union branches", () => {
expect(
withStrictObjectSchemas({
type: "object",
properties: {
items: {
type: "array",
items: { type: "object", properties: { id: { type: "string" } } },
},
},
anyOf: [{ type: "object", properties: {} }],
}),
).toEqual({
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: { id: { type: "string" } },
additionalProperties: false,
required: ["id"],
},
},
},
anyOf: [
{
type: "object",
properties: {},
additionalProperties: false,
required: [],
},
],
additionalProperties: false,
required: ["items"],
});
});

it("preserves an explicit additionalProperties value and non-object schemas", () => {
expect(
withStrictObjectSchemas({
type: "object",
properties: { a: { type: "string" } },
additionalProperties: true,
}),
).toEqual({
type: "object",
properties: { a: { type: "string" } },
additionalProperties: true,
required: ["a"],
});
expect(withStrictObjectSchemas({ type: "string" })).toEqual({
type: "string",
});
expect(withStrictObjectSchemas("keep")).toBe("keep");
});
});
Loading
Loading