Skip to content
Closed
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
@@ -0,0 +1,149 @@
// @vitest-environment jsdom

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 { MemoryRouter } from "react-router-dom";
import type { PendingInteraction, PluginPendingInteraction } from "@bb/domain";
import type { PluginPendingInteractionProps } from "@get-bb/plugin-sdk";
import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
type PluginRegistrationSet,
} from "@/lib/plugin-slots";
import { resetAllCrashedPluginSlotsForTest } from "../../plugin/PluginSlotMount";
import { ThreadPendingInteractionBanner } from "./ThreadPendingInteractionBanner";

const mocks = vi.hoisted(() => ({
resolveMutateAsync: vi.fn(async () => ({})),
}));

vi.mock("@/hooks/mutations/thread-interaction-mutations", () => ({
useResolveThreadPendingInteraction: () => ({
mutateAsync: mocks.resolveMutateAsync,
isPending: false,
error: null,
}),
}));

vi.mock("@/lib/sdk", () => ({
sdk: { threads: { interactions: { respond: vi.fn(), cancel: vi.fn() } } },
}));

const planReview: PendingInteraction = {
id: "pint_plan",
threadId: "thr_1",
turnId: "turn_1",
providerId: "claude-code",
providerThreadId: "pt_1",
providerRequestId: "req_1",
status: "pending",
statusReason: null,
createdAt: 1,
resolvedAt: null,
resolution: null,
payload: {
kind: "approval",
reason: null,
availableDecisions: ["allow_once", "deny"],
subject: {
kind: "plan",
itemId: "plan-1",
plan: "# Migrate the picker\n\n1. Read labels from the declaration",
planFilePath: "/tmp/plans/picker.md",
},
},
};

const pluginRequest: PluginPendingInteraction = {
id: "pint_plugin",
threadId: "thr_1",
turnId: null,
origin: { kind: "plugin", pluginId: "secrets", rendererId: "secret-request" },
status: "pending",
statusReason: null,
createdAt: 1,
expiresAt: null,
resolvedAt: null,
resolution: null,
payload: { kind: "plugin", title: "Add secrets", data: { fields: ["KEY"] } },
};

function registrationSet(
overrides: Partial<PluginRegistrationSet>,
): PluginRegistrationSet {
return {
homepageSections: [],
settingsSections: [],
navPanels: [],
threadPanelActions: [],
sidebarFooterActions: [],
fileOpeners: [],
messageDirectives: [],
...overrides,
};
}

function renderBanner(interaction: PendingInteraction) {
return render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>
<ThreadPendingInteractionBanner interaction={interaction} threadId="thr_1" />
</MemoryRouter>
</QueryClientProvider>,
);
}

afterEach(() => {
cleanup();
resetPluginSlotStoreForTest();
resetAllCrashedPluginSlotsForTest();
mocks.resolveMutateAsync.mockClear();
});

describe("ThreadPendingInteractionBanner request family", () => {
it("renders a plan review as a request with plan-verdict actions, resolved through today's approval", () => {
renderBanner(planReview);
expect(screen.getByText("Ready to code?")).toBeTruthy();
expect(screen.getByTestId("plan-review-request").textContent).toContain(
"Read labels from the declaration",
);
expect(screen.getByText("/tmp/plans/picker.md")).toBeTruthy();
// Plan verdict vocabulary, not permission vocabulary.
expect(screen.queryByRole("button", { name: "Allow once" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Approve plan" }));
expect(mocks.resolveMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thr_1",
interactionId: "pint_plan",
resolution: expect.objectContaining({ decision: "allow_once" }),
}),
);
fireEvent.click(screen.getByRole("button", { name: "Keep planning" }));
expect(mocks.resolveMutateAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
resolution: expect.objectContaining({ decision: "deny" }),
}),
);
});

it("renders a plugin request through the plugin's pendingInteraction slot, keyed by <pluginId>/<kind>", () => {
function SecretForm({ interaction }: PluginPendingInteractionProps) {
return (
<div data-testid="secret-form">{interaction.title}</div>
);
}
setPluginSlotRegistrations(
"secrets",
registrationSet({
pendingInteractions: [{ id: "secret-request", component: SecretForm }],
}),
);
renderBanner(pluginRequest);
const banner = screen.getByTestId("plugin-request-banner");
expect(banner.getAttribute("data-request-kind")).toBe(
"secrets/secret-request",
);
expect(screen.getByTestId("secret-form").textContent).toBe("Add secrets");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import {
} from "@bb/core-ui";
import { extractShellCommandFromString } from "@bb/thread-view";
import {
isApprovalPendingInteractionPayload,
isUserQuestionPendingInteractionPayload,
isPluginPendingInteraction,
type ApprovalPendingInteractionPayload,
type PendingInteraction,
type PendingInteractionApprovalDecision,
type PendingInteractionApprovalSubject,
type PendingInteractionResolution,
type UserQuestionPendingInteractionPayload,
type PendingInteractionUserQuestionQuestion,
} from "@bb/domain";
import { Button } from "@bb/shared-ui/button";
import { ExpandableLine } from "@/components/ui/expandable-line.js";
Expand All @@ -23,6 +22,11 @@ import { MarkdownPreview } from "@/components/ui/markdown-preview.js";
import { getDetailScrollMaxHeightClass } from "@/components/ui/detail-scroll-size.js";
import { UserQuestionAnswerForm } from "@/components/thread/user-questions/UserQuestionInteractionContent.js";
import { useResolveThreadPendingInteraction } from "@/hooks/mutations/thread-interaction-mutations";
import { PluginPendingInteractionComposer } from "@/components/plugin/PluginPendingInteractionComposer";
import {
classifyInteractionRequest,
type InteractionRequestView,
} from "./interaction-request";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
import { cn } from "@bb/shared-ui/lib/utils";

Expand All @@ -46,7 +50,7 @@ interface ApprovalPendingInteractionBannerProps {

interface UserQuestionPendingInteractionBannerProps {
interaction: PendingInteraction;
payload: UserQuestionPendingInteractionPayload;
questions: readonly PendingInteractionUserQuestionQuestion[];
sourceThread?: ThreadPendingInteractionSourceThread;
threadId: string;
}
Expand All @@ -70,36 +74,152 @@ interface BuildApprovalSubjectInput {
payload: ApprovalPendingInteractionPayload;
}

/**
* Renders one pending interaction by its family (docs/provider-plugin-api.md
* §4): approvals with the decision buttons a permission mode could have
* pressed; the open requests with their core renderers (`user_question`,
* `plan_review`) or, for a `"<pluginId>/<kind>"` request, the plugin's
* `pendingInteraction` slot component.
*/
export function ThreadPendingInteractionBanner({
interaction,
sourceThread,
threadId,
}: ThreadPendingInteractionBannerProps) {
if (interaction.payload.kind === "plugin") {
return null;
}
if (isUserQuestionPendingInteractionPayload(interaction.payload)) {
const request = classifyInteractionRequest(interaction);
if (request.family === "approval") {
return (
<ThreadUserQuestionPendingInteractionBanner
<ApprovalPendingInteractionBanner
interaction={interaction}
payload={interaction.payload}
payload={request.payload}
sourceThread={sourceThread}
threadId={threadId}
/>
);
}

if (!isApprovalPendingInteractionPayload(interaction.payload)) {
return assertNever(interaction.payload);
switch (request.kind) {
case "user_question":
return (
<ThreadUserQuestionPendingInteractionBanner
interaction={interaction}
questions={request.questions}
sourceThread={sourceThread}
threadId={threadId}
/>
);
case "plan_review":
return (
<PlanReviewRequestBanner
interaction={interaction}
request={request}
sourceThread={sourceThread}
threadId={threadId}
/>
);
default:
if (!isPluginPendingInteraction(interaction)) {
// The request family's plugin member is not on the wire yet (WS5);
// until then a plugin request always arrives as a plugin interaction.
return null;
}
return (
<div data-testid="plugin-request-banner" data-request-kind={request.kind}>
{sourceThread ? (
<NavLink
to={sourceThread.href}
className="mb-1 block text-xs text-muted-foreground no-underline hover:underline"
>
From child thread: {sourceThread.title}
</NavLink>
) : null}
<PluginPendingInteractionComposer interaction={interaction} />
</div>
);
}
}

interface PlanReviewRequestBannerProps {
interaction: PendingInteraction;
request: Extract<InteractionRequestView, { kind: "plan_review" }>;
sourceThread?: ThreadPendingInteractionSourceThread;
threadId: string;
}

/**
* A finished plan waiting for the user's verdict — a request, not an
* approval: no permission mode answers "ready to code?". Today's wire still
* resolves it through the `plan` approval subject's decisions, which this
* banner labels as the plan verdict they are.
*/
function PlanReviewRequestBanner({
interaction,
request,
sourceThread,
threadId,
}: PlanReviewRequestBannerProps) {
const resolvePendingInteraction = useResolveThreadPendingInteraction();
const isResolving = interaction.status === "resolving";
const submittedDecision = approvalResolutionDecision(interaction.resolution);
const mutationErrorMessage = resolvePendingInteraction.error
? getMutationErrorMessage({
error: resolvePendingInteraction.error,
fallbackMessage: "Failed to resolve plan review",
lifecycleOperation: "resolve_interaction",
})
: null;
const submitDisabled = resolvePendingInteraction.isPending || isResolving;
const approval = request.approval;
const submitDecision = (
decision: PendingInteractionApprovalDecision,
): void => {
const resolution = buildPendingInteractionApprovalResolution(
interaction,
decision,
);
void resolvePendingInteraction
.mutateAsync({ threadId, interactionId: interaction.id, resolution })
.catch(() => {});
};
const { plan, planFilePath } = request.review;
return (
<ApprovalPendingInteractionBanner
interaction={interaction}
payload={interaction.payload}
<BannerShell
title={approval?.reason ?? "Ready to code?"}
errorMessage={mutationErrorMessage}
sourceThread={sourceThread}
threadId={threadId}
/>
footer={
approval
? approval.availableDecisions.map((decision) => (
<ApprovalDecisionButton
key={decision}
decision={decision}
disabled={submitDisabled}
isLoading={isResolving && submittedDecision === decision}
onClick={() => submitDecision(decision)}
subjectKind="plan"
/>
))
: null
}
>
<div
className="overflow-hidden rounded-lg border border-border bg-card"
data-testid="plan-review-request"
>
<div
className={cn(
getDetailScrollMaxHeightClass("base"),
"overflow-auto px-3 py-2",
)}
>
<MarkdownPreview content={plan} className="text-xs" />
</div>
{planFilePath ? (
<p className="truncate border-t border-border px-3 py-2 font-mono text-xs text-muted-foreground">
{planFilePath}
</p>
) : null}
</div>
</BannerShell>
);
}

Expand Down Expand Up @@ -205,7 +325,7 @@ function ApprovalPendingInteractionBanner({

function ThreadUserQuestionPendingInteractionBanner({
interaction,
payload,
questions,
sourceThread,
threadId,
}: UserQuestionPendingInteractionBannerProps) {
Expand All @@ -218,7 +338,7 @@ function ThreadUserQuestionPendingInteractionBanner({
<UserQuestionAnswerForm
interactionId={interaction.id}
isResolving={isResolving}
questions={payload.questions}
questions={questions}
threadId={threadId}
/>
</BannerShell>
Expand Down
Loading
Loading