diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx new file mode 100644 index 0000000000..33cf28af5f --- /dev/null +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx @@ -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 { + return { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + ...overrides, + }; +} + +function renderBanner(interaction: PendingInteraction) { + return render( + + + + + , + ); +} + +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 /", () => { + function SecretForm({ interaction }: PluginPendingInteractionProps) { + return ( +
{interaction.title}
+ ); + } + 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"); + }); +}); diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx index 15a01cf152..ca705c3841 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx @@ -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"; @@ -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"; @@ -46,7 +50,7 @@ interface ApprovalPendingInteractionBannerProps { interface UserQuestionPendingInteractionBannerProps { interaction: PendingInteraction; - payload: UserQuestionPendingInteractionPayload; + questions: readonly PendingInteractionUserQuestionQuestion[]; sourceThread?: ThreadPendingInteractionSourceThread; threadId: string; } @@ -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 `"/"` 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 ( - ); } - - if (!isApprovalPendingInteractionPayload(interaction.payload)) { - return assertNever(interaction.payload); + switch (request.kind) { + case "user_question": + return ( + + ); + case "plan_review": + return ( + + ); + 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 ( +
+ {sourceThread ? ( + + From child thread: {sourceThread.title} + + ) : null} + +
+ ); } +} +interface PlanReviewRequestBannerProps { + interaction: PendingInteraction; + request: Extract; + 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 ( - + footer={ + approval + ? approval.availableDecisions.map((decision) => ( + submitDecision(decision)} + subjectKind="plan" + /> + )) + : null + } + > +
+
+ +
+ {planFilePath ? ( +

+ {planFilePath} +

+ ) : null} +
+ ); } @@ -205,7 +325,7 @@ function ApprovalPendingInteractionBanner({ function ThreadUserQuestionPendingInteractionBanner({ interaction, - payload, + questions, sourceThread, threadId, }: UserQuestionPendingInteractionBannerProps) { @@ -218,7 +338,7 @@ function ThreadUserQuestionPendingInteractionBanner({ diff --git a/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts b/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts new file mode 100644 index 0000000000..74d802c764 --- /dev/null +++ b/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { PendingInteraction } from "@bb/domain"; +import { classifyInteractionRequest } from "./interaction-request"; + +const base = { + id: "pint_1", + threadId: "thr_1", + turnId: "turn_1", + providerId: "codex", + providerThreadId: "pt_1", + providerRequestId: "req_1", + status: "pending", + statusReason: null, + createdAt: 1, + resolvedAt: null, + resolution: null, +} as const; + +/** + * The interaction split (docs/provider-plugin-api.md §4) on the client: + * approvals are the closed policy set; requests are the open set that always + * reach the user or a plugin. Both today's wire and the target request family + * classify the same way. + */ +describe("classifyInteractionRequest", () => { + it("keeps command/file/permission/tool-use approvals in the approval family", () => { + const interaction: PendingInteraction = { + ...base, + payload: { + kind: "approval", + reason: null, + availableDecisions: ["allow_once", "deny"], + subject: { + kind: "command", + itemId: "i1", + command: "rm -rf dist", + cwd: null, + actions: [], + sessionGrant: null, + }, + }, + }; + expect(classifyInteractionRequest(interaction)).toEqual({ + family: "approval", + payload: interaction.payload, + }); + }); + + it("lifts today's plan approval subject into a plan_review request that resolves as an approval", () => { + const payload = { + kind: "approval", + reason: null, + availableDecisions: ["allow_once", "deny"], + subject: { + kind: "plan", + itemId: "plan-1", + plan: "# Plan\n\n1. Do it", + planFilePath: "/tmp/plan.md", + }, + } as const; + expect(classifyInteractionRequest({ ...base, payload })).toEqual({ + family: "request", + kind: "plan_review", + review: { + kind: "plan_review", + itemId: "plan-1", + plan: "# Plan\n\n1. Do it", + planFilePath: "/tmp/plan.md", + }, + resolvesAs: "approval", + approval: payload, + }); + }); + + it("classifies a user question and the target plan_review payload as requests", () => { + const questions = [{ id: "q1", prompt: "Which?", multiSelect: false }]; + expect( + classifyInteractionRequest({ + payload: { kind: "user_question", questions }, + }), + ).toMatchObject({ family: "request", kind: "user_question", questions }); + expect( + classifyInteractionRequest({ + payload: { + kind: "plan_review", + itemId: "plan-2", + plan: "Plan body", + planFilePath: null, + }, + }), + ).toMatchObject({ + family: "request", + kind: "plan_review", + resolvesAs: "request", + approval: null, + }); + }); + + it("routes a plugin request to its plugin by namespaced kind, from either wire shape", () => { + const fromToday = classifyInteractionRequest({ + origin: { kind: "plugin", pluginId: "secrets", rendererId: "secret-request" }, + payload: { kind: "plugin", title: "Add secrets", data: { fields: [] } }, + }); + const fromRequestFamily = classifyInteractionRequest({ + payload: { + kind: "secrets/secret-request", + title: "Add secrets", + data: { fields: [] }, + }, + }); + const expected = { + family: "request", + kind: "secrets/secret-request", + pluginId: "secrets", + name: "secret-request", + title: "Add secrets", + data: { fields: [] }, + }; + expect(fromToday).toEqual(expected); + expect(fromRequestFamily).toEqual(expected); + }); +}); diff --git a/apps/app/src/components/thread/pending-interactions/interaction-request.ts b/apps/app/src/components/thread/pending-interactions/interaction-request.ts new file mode 100644 index 0000000000..9a1f5de4d5 --- /dev/null +++ b/apps/app/src/components/thread/pending-interactions/interaction-request.ts @@ -0,0 +1,133 @@ +import { + isExtensionKind, + parseExtensionKind, + type ApprovalPendingInteractionPayload, + type ExtensionKind, + type InteractionRequestPayload, + type JsonValue, + type PendingInteraction, + type PendingInteractionUserQuestionQuestion, + type PlanReviewInteractionRequestPayload, +} from "@bb/domain"; + +/** + * The interaction split (docs/provider-plugin-api.md §4) as the client + * renders it. + * + * Approvals are the closed, policy-bearing set a permission mode may decide + * without the user: command, fileChange, toolUse, permissionGrant. Requests + * are the open set that always reaches the user or the plugin that owns + * them: `userQuestion` and `planReview` render with core renderers; a + * `"/"` request renders with the plugin through the + * `pendingInteraction` slot. + * + * Two wire shapes feed this view. Today's `PendingInteraction` still carries + * a plan review as an approval subject (`kind: "plan"`) and a plugin request + * as `kind: "plugin"` with an `origin`; the target `InteractionRequestPayload` + * carries `plan_review` and the namespaced plugin kind directly. WS5 owns the + * producers; this classifier renders either, so the UI is ready before the + * wire moves and unchanged after it does. + */ +export type InteractionRequestView = + | { + family: "approval"; + payload: ApprovalPendingInteractionPayload; + } + | { + family: "request"; + kind: "user_question"; + questions: readonly PendingInteractionUserQuestionQuestion[]; + } + | { + family: "request"; + kind: "plan_review"; + review: PlanReviewInteractionRequestPayload; + /** + * How the verdict is sent back. Today's wire resolves a plan review + * through the approval resolution (`allow_once` / `deny`) of the + * `plan` subject it rides on; the request family will carry its own. + */ + resolvesAs: "approval" | "request"; + /** The approval payload when `resolvesAs` is `"approval"`. */ + approval: ApprovalPendingInteractionPayload | null; + } + | { + family: "request"; + kind: ExtensionKind; + pluginId: string; + /** The plugin-local request name — the renderer id the plugin registered. */ + name: string; + title: string; + data: JsonValue; + }; + +/** An interaction whose payload is either wire shape. */ +export interface RequestBearingInteraction { + payload: PendingInteraction["payload"] | InteractionRequestPayload; + origin?: PendingInteraction["origin"]; +} + +export function classifyInteractionRequest( + interaction: RequestBearingInteraction, +): InteractionRequestView { + const { payload } = interaction; + switch (payload.kind) { + case "user_question": + return { + family: "request", + kind: "user_question", + questions: payload.questions, + }; + case "plan_review": + return { + family: "request", + kind: "plan_review", + review: payload, + resolvesAs: "request", + approval: null, + }; + case "plugin": { + const origin = interaction.origin; + if (origin === undefined || origin.kind !== "plugin") { + throw new Error("a plugin pending interaction carries a plugin origin"); + } + return { + family: "request", + kind: `${origin.pluginId}/${origin.rendererId}`, + pluginId: origin.pluginId, + name: origin.rendererId, + title: payload.title, + data: payload.data, + }; + } + case "approval": + if (payload.subject.kind === "plan") { + const { itemId, plan, planFilePath } = payload.subject; + return { + family: "request", + kind: "plan_review", + review: { kind: "plan_review", itemId, plan, planFilePath }, + resolvesAs: "approval", + approval: payload, + }; + } + return { family: "approval", payload }; + default: { + // The plugin member of the request family: a namespaced kind. + if (isExtensionKind(payload.kind)) { + const { pluginId, name } = parseExtensionKind(payload.kind); + return { + family: "request", + kind: payload.kind, + pluginId, + name, + title: payload.title, + data: payload.data, + }; + } + throw new Error( + `unknown interaction payload kind ${JSON.stringify(payload.kind)}`, + ); + } + } +} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 720b2c3ff9..06c0b441be 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -1610,7 +1610,9 @@ describe("ThreadDetailPromptArea", () => { screen .getAllByTestId("composer-stack-item") .map((item) => item.textContent), - ).toEqual(["Plan banner", "Goal banner", "Plugin pending interaction"]); + // The banner routes a plugin request to the plugin's slot itself + // (ThreadPendingInteractionBanner.test.tsx); the stack only orders it. + ).toEqual(["Plan banner", "Goal banner", "Pending interaction"]); }); it("selects the provider fallback model for the next turn", () => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 97a25fbac3..28d6e6c137 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -7,14 +7,14 @@ import { type RefObject, } from "react"; import { createPortal } from "react-dom"; -import { NavLink, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import type { IconName } from "@bb/shared-ui/icon"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { getFollowUpPromptPlaceholder, getCompactFollowUpPromptPlaceholder, } from "@/components/promptbox/follow-up-placeholder"; -import { isPluginPendingInteraction, PERSONAL_PROJECT_ID } from "@bb/domain"; +import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { EnvironmentStatus, PendingInteraction, @@ -34,7 +34,6 @@ import type { } from "@bb/server-contract"; import type { ChildThreadPendingAttention } from "@/hooks/queries/child-thread-pending-interactions"; import { ThreadPendingInteractionBanner } from "@/components/thread/pending-interactions/ThreadPendingInteractionBanner"; -import { PluginPendingInteractionComposer } from "@/components/plugin/PluginPendingInteractionComposer"; import { type PluginComposerHost, usePublishPluginComposerHost, @@ -1493,26 +1492,14 @@ export function ThreadDetailPromptArea({ ]); const childPendingInteractionBanners = useMemo( () => - childPendingInteractions.map((item) => - isPluginPendingInteraction(item.interaction) ? ( -
- - From child thread: {item.childTitle} - - -
- ) : ( - - ), - ), + childPendingInteractions.map((item) => ( + + )), [childPendingInteractions], ); const promptStack = useMemo( @@ -1662,11 +1649,7 @@ export function ThreadDetailPromptArea({ if (!activePendingInteraction || shouldHideComposer) { return null; } - return isPluginPendingInteraction(activePendingInteraction) ? ( - - ) : ( + return (