Skip to content
Open
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 @@ -4,8 +4,9 @@ import { useEffect, useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginPendingInteraction } from "@bb/domain";
import { defaultAppSettings, type PluginPendingInteraction } from "@bb/domain";
import type { PluginPendingInteractionProps } from "@get-bb/plugin-sdk";
import { loadPluginApp } from "@get-bb/plugin-sdk/testing/app";
import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
Expand All @@ -14,10 +15,43 @@ import {
import { resetAllCrashedPluginSlotsForTest } from "./PluginSlotMount";
import { PluginPendingInteractionComposer } from "./PluginPendingInteractionComposer";
import { makePluginRegistrationSet } from "@/test/fixtures/plugins";
import { AppCommandProvider } from "@/components/commands/AppCommandProvider";

vi.mock("@/hooks/queries/system-queries", () => ({
useSystemConfig: () => ({
data: {
generalSettings: { ...defaultAppSettings },
keybindings: [1, 2, 3].map((digit) => ({
command: `question.select.${digit}`,
desktopOnly: false,
shortcut: {
key: String(digit),
mod: false,
meta: false,
control: false,
alt: false,
shift: false,
},
when: { all: ["questionOpen"], none: [] },
})),
},
}),
}));
vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null }));
const pane = vi.hoisted(() => ({ isFocused: true }));
vi.mock("@/views/thread-detail/PaneContext", () => ({
useOptionalPaneContext: () => pane,
}));

const piApp = await loadPluginApp(() =>
import("../../../../../plugins/provider-pi/app"),
);

function renderComposer(ui: React.ReactElement) {
return render(
<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>,
<QueryClientProvider client={new QueryClient()}>
<AppCommandProvider>{ui}</AppCommandProvider>
</QueryClientProvider>,
);
}

Expand Down Expand Up @@ -164,6 +198,38 @@ describe("PluginPendingInteractionComposer", () => {
).toBe("true");
});

it("selects a pi option with its displayed number key", () => {
setPluginSlotRegistrations(
"provider-pi",
registrations(piApp.pendingInteractions),
);
const data = {
requestId: "ui-1",
method: "select" as const,
options: ["Allow once", "Deny"],
};
renderComposer(
<PluginPendingInteractionComposer
interaction={interaction}
request={{
pluginId: "provider-pi",
rendererId: "extension-ui",
title: "Allow access?",
data,
}}
dismissal="cancel"
/>,
);

expect(screen.getByText("1", { selector: "kbd" })).toBeDefined();
expect(screen.getByText("2", { selector: "kbd" })).toBeDefined();
fireEvent.keyDown(window, { key: "2" });
expect(
(screen.getByRole("radio", { name: "Deny" }) as HTMLInputElement)
.checked,
).toBe(true);
});

it("mounts only the renderer registered by the interaction's plugin", () => {
function WrongRenderer() {
return <div>wrong plugin renderer</div>;
Expand Down
63 changes: 44 additions & 19 deletions plugins/provider-pi/app.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useMemo, useState, type FormEvent } from "react";
import { useMemo, useState, useEffect, type FormEvent } from "react";
import {
definePluginApp,
type PluginPendingInteractionProps,
} from "@get-bb/plugin-sdk/app";
import { Button } from "@bb/shared-ui/button";
import { useQuestionFormHost } from "@bb/shared-ui/question-form-host";
import { cn } from "@bb/shared-ui/lib/utils";
import {
PI_EXTENSION_UI_RENDERER_ID,
Expand All @@ -26,10 +27,22 @@ function ExtensionUiInteraction({
cancel,
}: PluginPendingInteractionProps) {
const request = useMemo(() => parseRequest(interaction.payload), [interaction.payload]);
const { shortcuts, registerChoiceHandler } = useQuestionFormHost();
const [text, setText] = useState(request?.prefill ?? "");
const [selected, setSelected] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

useEffect(() => {
if (busy || request?.method !== "select") return;
const options = request.options ?? [];
return registerChoiceHandler((index) => {
const option = options[index];
if (option === undefined) return false;
setSelected(option);
return true;
});
}, [busy, request, registerChoiceHandler]);

if (!request) {
return (
<div className="space-y-3 text-xs text-muted-foreground">
Expand Down Expand Up @@ -72,24 +85,36 @@ function ExtensionUiInteraction({
{request.message ? <p className="text-sm text-foreground">{request.message}</p> : null}
{request.method === "select" ? (
<fieldset className="flex flex-col gap-1.5" disabled={busy}>
{(request.options ?? []).map((option) => (
<label
key={option}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-foreground",
selected === option && "border-ring bg-surface-raised",
)}
>
<input
type="radio"
name={request.requestId}
className="size-3.5"
checked={selected === option}
onChange={() => setSelected(option)}
/>
<span>{option}</span>
</label>
))}
{(request.options ?? []).map((option, index) => {
const shortcut = shortcuts.get(String(index));
return (
<label
key={option}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-foreground",
selected === option && "border-ring bg-surface-raised",
)}
>
<input
type="radio"
name={request.requestId}
className="size-3.5"
checked={selected === option}
aria-keyshortcuts={shortcut?.ariaKeyshortcuts}
onChange={() => setSelected(option)}
/>
<span className="min-w-0 flex-1">{option}</span>
{shortcut ? (
<kbd
aria-hidden="true"
className="shrink-0 text-xs font-normal text-subtle-foreground"
>
{shortcut.label}
</kbd>
) : null}
</label>
);
})}
</fieldset>
) : null}
{request.method === "input" ? (
Expand Down
Loading