Skip to content
Merged
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
6 changes: 5 additions & 1 deletion apps/app/.ladle/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ function formatNetworkUrls(serverUrl) {

/** @type {import("@ladle/react").UserConfig} */
export default {
stories: ["src/**/*.stories.tsx", "../../plugins/workflows/**/*.stories.tsx"],
stories: [
"src/**/*.stories.tsx",
"../../plugins/workflows/**/*.stories.tsx",
"../../plugins/ask-user-question/*.stories.tsx",
],
defaultStory: "",
viteConfig: "./.ladle/vite.config.ts",
host: "0.0.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import { cleanup, render, screen } from "@testing-library/react";
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";
Expand Down Expand Up @@ -56,6 +57,113 @@ afterEach(() => {
});

describe("PluginPendingInteractionComposer", () => {
it("preserves drafts and pauses keyboard listeners while collapsed", () => {
const onShortcut = vi.fn();
function QuestionRenderer() {
const [answer, setAnswer] = useState("");
useEffect(() => {
window.addEventListener("keydown", onShortcut);
return () => window.removeEventListener("keydown", onShortcut);
}, []);
return (
<input
aria-label="Answer"
value={answer}
onChange={(event) => setAnswer(event.target.value)}
/>
);
}
setPluginSlotRegistrations(
"secrets",
registrations([{ id: "secret-request", component: QuestionRenderer }]),
);
renderComposer(
<PluginPendingInteractionComposer
interaction={interaction}
request={{
pluginId: "secrets",
rendererId: "secret-request",
title: interaction.payload.title,
data: interaction.payload.data,
}}
dismissal="cancel"
/>,
);
fireEvent.change(screen.getByRole("textbox", { name: "Answer" }), {
target: { value: "Keep my draft" },
});
fireEvent.keyDown(window, { key: "1" });
expect(onShortcut).toHaveBeenCalledTimes(1);
const toggle = screen.getByRole("button", { name: "Hide details" });
toggle.focus();
fireEvent.click(toggle);
expect(screen.queryByRole("textbox")).toBeNull();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Show details" }),
);
fireEvent.keyDown(window, { key: "2" });
expect(onShortcut).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Show details" }));
expect(screen.getByRole("textbox").getAttribute("value")).toBe(
"Keep my draft",
);
fireEvent.keyDown(window, { key: "3" });
expect(onShortcut).toHaveBeenCalledTimes(2);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Show details" }),
);
fireEvent.click(screen.getByRole("button", { name: "Show details" }));
expect(screen.getByRole("textbox").getAttribute("value")).toBe(
"Keep my draft",
);
});

it("opens a new interaction with a fresh form after the previous one was collapsed", () => {
function Renderer() {
const [answer, setAnswer] = useState("");
return (
<input
aria-label="Answer"
value={answer}
onChange={(event) => setAnswer(event.target.value)}
/>
);
}
setPluginSlotRegistrations(
"secrets",
registrations([{ id: "secret-request", component: Renderer }]),
);
const client = new QueryClient();
const composer = (id: string) => (
<QueryClientProvider client={client}>
<PluginPendingInteractionComposer
interaction={{ ...interaction, id }}
request={{
pluginId: "secrets",
rendererId: "secret-request",
title: interaction.payload.title,
data: interaction.payload.data,
}}
dismissal="cancel"
/>
</QueryClientProvider>
);
const view = render(composer(interaction.id));
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "Previous answer" },
});
fireEvent.click(screen.getByRole("button", { name: "Hide details" }));
view.rerender(composer("pint_new"));
expect(screen.getByRole("textbox").getAttribute("value")).toBe("");
expect(
screen
.getByRole("button", { name: "Hide details" })
.getAttribute("aria-expanded"),
).toBe("true");
});

it("mounts only the renderer registered by the interaction's plugin", () => {
function WrongRenderer() {
return <div>wrong plugin renderer</div>;
Expand Down
120 changes: 63 additions & 57 deletions apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
PendingInteractionShell,
type PendingInteractionSourceThread,
} from "@/components/thread/pending-interactions/PendingInteractionShell";
import { useCallback, useMemo, useState } from "react";
import { Button } from "@bb/shared-ui/button";
import type { JsonValue, PendingInteraction } from "@bb/domain";
Expand All @@ -21,12 +25,14 @@ interface PluginPendingInteractionComposerProps {
>;
request: PluginPendingInteractionRequest;
dismissal: "cancel" | "stop-turn";
sourceThread?: PendingInteractionSourceThread;
}

export function PluginPendingInteractionComposer({
interaction,
request,
dismissal,
sourceThread,
}: PluginPendingInteractionComposerProps) {
const { pendingInteractions } = usePluginSlots();
const stopThread = useStopThread();
Expand Down Expand Up @@ -83,25 +89,62 @@ export function PluginPendingInteractionComposer({
const dismissLabel = dismissal === "cancel" ? "Cancel" : "Stop turn";

return (
<section className="mb-2 rounded-lg border border-border bg-surface-recessed px-4 py-3 text-xs text-muted-foreground">
<header className="mb-4 min-w-0">
<h3 className="text-pretty text-sm font-semibold text-foreground">
{request.title}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
{dismissal === "cancel" ? "Requested by " : "The agent asks through "}
<span className="capitalize">{request.pluginId}</span>
</p>
</header>
{slot ? (
<PluginSlotMount
pluginId={slot.pluginId}
slotKind="pendingInteraction"
slotId={slot.id}
crashFallback={
<PendingInteractionShell
key={interaction.id}
label={request.title}
initiallyExpanded
errorMessage={error}
sourceThread={sourceThread}
testId="plugin-interaction-shell"
>
{() => (
<>
<p className="mb-4 text-xs text-muted-foreground">
{dismissal === "cancel"
? "Requested by "
: "The agent asks through "}
<span className="capitalize">{request.pluginId}</span>
</p>
{slot ? (
<PluginSlotMount
pluginId={slot.pluginId}
slotKind="pendingInteraction"
slotId={slot.id}
crashFallback={
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
The plugin form crashed. {dismissLabel} to continue.
</p>
<Button
type="button"
variant="outline"
onClick={() => void cancel()}
disabled={submitting}
>
{dismissLabel}
</Button>
</div>
}
>
<fieldset disabled={submitting}>
<slot.component
interaction={{
id: interaction.id,
threadId: interaction.threadId,
title: request.title,
payload: request.data,
createdAt: interaction.createdAt,
expiresAt: interaction.expiresAt ?? null,
}}
submit={submit}
cancel={cancel}
/>
</fieldset>
</PluginSlotMount>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
The plugin form crashed. {dismissLabel} to continue.
The plugin form is unavailable. {dismissLabel} to continue.
</p>
<Button
type="button"
Expand All @@ -112,46 +155,9 @@ export function PluginPendingInteractionComposer({
{dismissLabel}
</Button>
</div>
}
>
<fieldset disabled={submitting}>
<slot.component
interaction={{
id: interaction.id,
threadId: interaction.threadId,
title: request.title,
payload: request.data,
createdAt: interaction.createdAt,
expiresAt: interaction.expiresAt ?? null,
}}
submit={submit}
cancel={cancel}
/>
</fieldset>
</PluginSlotMount>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
The plugin form is unavailable. {dismissLabel} to continue.
</p>
<Button
type="button"
variant="outline"
onClick={() => void cancel()}
disabled={submitting}
>
{dismissLabel}
</Button>
</div>
)}
</>
)}
{error ? (
<p
className="mt-3 rounded-md border border-surface-destructive-border bg-surface-destructive px-2 py-1 text-xs text-destructive-text"
aria-live="polite"
>
{error}
</p>
) : null}
</section>
</PendingInteractionShell>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useEffect } from "react";
import { installTestPluginRuntime } from "@get-bb/plugin-sdk/testing/app";
import { collectPluginAppRegistrations } from "@/lib/plugin-app-definition";
import { makePluginRegistrationSet } from "@/test/fixtures/plugins";
import {
setPluginSlotRegistrations,
removePluginSlotRegistrations,
} from "@/lib/plugin-slots";
import { PluginPendingInteractionComposer } from "@/components/plugin/PluginPendingInteractionComposer";
import { PendingInteractionShell } from "./PendingInteractionShell";
import { ThreadPendingInteractionBanner } from "./ThreadPendingInteractionBanner";

installTestPluginRuntime();
const { default: secretsApp } =
await import("../../../../../../plugins/secrets/app");

export default { title: "thread/Pending Interaction/Additional States" };

export function Overview() {
useEffect(() => {
setPluginSlotRegistrations(
"secrets",
makePluginRegistrationSet({
pendingInteractions:
collectPluginAppRegistrations(secretsApp).pendingInteractions,
}),
);
return () => removePluginSlotRegistrations("secrets");
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4">
<ThreadPendingInteractionBanner
threadId="thread-demo"
interaction={{
id: "plan-demo",
threadId: "thread-demo",
turnId: "turn-demo",
providerId: "codex",
providerThreadId: "provider-demo",
providerRequestId: "request-demo",
status: "pending",
statusReason: null,
createdAt: 1,
resolvedAt: null,
resolution: null,
payload: {
kind: "approval",
reason: null,
availableDecisions: ["allow_once", "deny"],
subject: {
kind: "plan",
itemId: "plan-demo",
plan: "# Share question forms\n\n1. Extract the shared form.\n2. Preserve submission adapters.\n3. Verify mobile and desktop states.",
planFilePath: "/workspace/plan.md",
},
},
}}
/>
<PluginPendingInteractionComposer
interaction={{
id: "secrets-demo",
threadId: "thread-demo",
createdAt: 1,
}}
dismissal="cancel"
request={{
pluginId: "secrets",
rendererId: "secret-request",
title: "Add credentials for the demo service",
data: {
purpose: "Connect the demo service",
destination: { kind: "dotenv", path: "/workspace/.env" },
fields: [{ name: "DEMO_API_KEY", description: "Service API key" }],
},
}}
/>
<PluginPendingInteractionComposer
interaction={{
id: "unavailable-demo",
threadId: "thread-demo",
createdAt: 1,
}}
dismissal="stop-turn"
request={{
pluginId: "unavailable-plugin",
rendererId: "unavailable",
title: "Plugin form unavailable",
data: {},
}}
/>
<PendingInteractionShell
label="Question submission failed"
initiallyExpanded
errorMessage="Could not submit your answer. Please try again once the connection is restored."
testId="error-interaction-shell"
>
{() => (
<p className="text-sm">
Your draft answer is preserved. Expand the form to review it and
retry.
</p>
)}
</PendingInteractionShell>
</div>
);
}
Loading