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
56 changes: 56 additions & 0 deletions apps/app/src/components/plugin/PluginThreadChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,62 @@ describe("PluginThreadChat", () => {
);
});

it.each(["full", "compact", "timeline"] as const)(
"scopes selection actions and footer presentation to one %s instance",
async (variant) => {
const { wrapper: Wrapper } = createQueryClientTestHarness();
const ThreadChat = pluginSdkAppImplementation.ThreadChat;
const run = vi.fn();
const { rerender } = render(
<Wrapper>
<MemoryRouter>
<ThreadChat
threadId="thr_demo"
variant={variant}
experimental_showContextFooter={false}
experimental_selectionActions={[
{ id: "translate", title: "Translate", icon: "Languages", run },
]}
/>
</MemoryRouter>
</Wrapper>,
);
const calls =
variant === "timeline"
? mocks.timelinePanelProps
: mocks.embeddedChatProps;
await waitFor(() =>
expect(calls.at(-1)?.consumerSelectionActions).toEqual([
expect.objectContaining({ id: "translate", label: "Translate", run }),
]),
);
if (variant !== "timeline")
expect(calls.at(-1)?.composer).toEqual(
expect.objectContaining({
showContextFooter: false,
permissionPolicy: "snapshot",
}),
);
rerender(
<Wrapper>
<MemoryRouter>
<ThreadChat threadId="thr_demo" variant={variant} />
</MemoryRouter>
</Wrapper>,
);
await waitFor(() =>
expect(calls.at(-1)?.consumerSelectionActions).toBeUndefined(),
);
if (variant !== "timeline")
expect(calls.at(-1)?.composer).toEqual(
expect.objectContaining({
showContextFooter: true,
permissionPolicy: "snapshot",
}),
);
},
);

it("hands the permission picker to the user only when asked", async () => {
const { wrapper: Wrapper } = createQueryClientTestHarness();
const ThreadChat = pluginSdkAppImplementation.ThreadChat;
Expand Down
35 changes: 34 additions & 1 deletion apps/app/src/components/plugin/PluginThreadChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import type {
ThreadChatMessageAction,
ExperimentalThreadChatSelectionAction,
ThreadChatProps,
} from "@get-bb/plugin-sdk";
import {
Expand All @@ -17,6 +18,7 @@ import { EmbeddedThreadChat } from "@/components/thread/embedded-chat";
import {
ThreadTimelinePanelContent,
type ThreadTimelineConsumerMessageAction,
type ThreadTimelineConsumerSelectionAction,
} from "@/components/thread/timeline";
import { useThreadTimelineNavigation } from "@/components/thread/timeline/ThreadTimelineNavigationContext";
import { PluginContext } from "@/components/plugin/plugin-context";
Expand Down Expand Up @@ -50,6 +52,8 @@ export function PluginThreadChat({
className,
leadingContent,
messageActions,
experimental_selectionActions,
experimental_showContextFooter = true,
}: ThreadChatProps) {
const containerClassName = cn(
layout === "contained" ? "flex h-full min-h-0 flex-col" : "flex flex-col",
Expand All @@ -65,6 +69,8 @@ export function PluginThreadChat({
permissionPolicy={permissionPolicy}
leadingContent={leadingContent}
messageActions={messageActions}
selectionActions={experimental_selectionActions}
showContextFooter={experimental_showContextFooter}
/>
</div>
);
Expand All @@ -78,6 +84,10 @@ interface PluginThreadChatBodyProps {
permissionPolicy: "inherit" | "editable";
leadingContent: ReactNode;
messageActions: readonly ThreadChatMessageAction[] | undefined;
selectionActions:
| readonly ExperimentalThreadChatSelectionAction[]
| undefined;
showContextFooter: boolean;
}

function PluginThreadChatBody({
Expand All @@ -88,6 +98,8 @@ function PluginThreadChatBody({
permissionPolicy,
leadingContent,
messageActions,
selectionActions,
showContextFooter,
}: PluginThreadChatBodyProps) {
const threadQuery = useThread(threadId, { enabled: threadId.length > 0 });
const thread = threadQuery.data;
Expand Down Expand Up @@ -159,6 +171,21 @@ function PluginThreadChatBody({
[messageActions, pluginId],
);

const consumerSelectionActions = useMemo<
readonly ThreadTimelineConsumerSelectionAction[] | undefined
>(
() =>
selectionActions?.map((action) => ({
id: action.id,
pluginId: action.icon !== undefined ? null : pluginId,
icon: action.icon ?? null,
label: action.title,
...(action.roles !== undefined ? { roles: action.roles } : {}),
run: action.run,
})),
[selectionActions, pluginId],
);

const resolveMentionLink = useCallback<PromptMentionLinkResolver>(
(resource) => {
if (resource.kind === "thread") {
Expand Down Expand Up @@ -255,7 +282,10 @@ function PluginThreadChatBody({
}
if (thread === undefined) {
return (
<ThreadChatLoadingBody leadingContent={leadingContent} variant={variant} />
<ThreadChatLoadingBody
leadingContent={leadingContent}
variant={variant}
/>
);
}

Expand All @@ -264,6 +294,7 @@ function PluginThreadChatBody({
<ThreadTimelinePanelContent
leadingContent={leadingContent}
consumerMessageActions={consumerMessageActions}
consumerSelectionActions={consumerSelectionActions}
includePluginMessageActions={false}
onOpenLink={onOpenLink}
onOpenLocalFileLink={onOpenLocalFileLink}
Expand Down Expand Up @@ -301,6 +332,7 @@ function PluginThreadChatBody({
resolveMentionLink={resolveMentionLink}
leadingContent={leadingContent}
consumerMessageActions={consumerMessageActions}
consumerSelectionActions={consumerSelectionActions}
includePluginMessageActions={false}
onOpenLink={onOpenLink}
onOpenLocalFileLink={onOpenLocalFileLink}
Expand All @@ -318,6 +350,7 @@ function PluginThreadChatBody({
permissionPolicy:
permissionPolicy === "editable" ? "editable" : "snapshot",
environmentSummary,
showContextFooter,
pluginComposerBottomScope: { kind: "thread", threadId },
composerIdentity: `plugin-thread-chat:${threadId}`,
focusRequestKey: focusRequest,
Expand Down
27 changes: 27 additions & 0 deletions apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,33 @@ describe("FollowUpPromptBox", () => {
);
});

it.each([true, false])(
"toggles the environment/access footer (%s) without removing execution or submit",
(showContextFooter) => {
const props = createFollowUpPromptBoxProps({ kind: "ready" });
props.environmentSummary = <span>Local environment</span>;
const { container } = render(
<FollowUpPromptBox
{...props}
showContextFooter={showContextFooter}
permissionReadOnly
/>,
);
expect(
container.querySelector("[data-follow-up-composer-footer]") !== null,
).toBe(showContextFooter);
expect(screen.queryByText("Local environment") !== null).toBe(
showContextFooter,
);
expect(mocks.executionControls).toHaveBeenCalledWith(
expect.objectContaining({ disabled: false }),
);
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(props.composer?.onSubmit).toHaveBeenCalledOnce();
expect(props.permission.onChange).not.toHaveBeenCalled();
},
);

it("can lock permission without disabling execution controls", () => {
const props = createFollowUpPromptBoxProps({ kind: "ready" });

Expand Down
4 changes: 3 additions & 1 deletion apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface FollowUpPromptBoxProps {
sessionOptionsControl?: ReactNode;
executionReadOnly?: boolean;
permissionReadOnly?: boolean;
showContextFooter?: boolean;
typeahead: TypeaheadConfig;
promptActions?: readonly PromptBoxAction[];
suppressPluginComposerCustomizations?: boolean;
Expand Down Expand Up @@ -245,6 +246,7 @@ function FollowUpPromptBoxWithComposer({
sessionOptionsControl = null,
executionReadOnly,
permissionReadOnly,
showContextFooter = true,
typeahead,
promptActions,
suppressPluginComposerCustomizations,
Expand Down Expand Up @@ -815,7 +817,7 @@ function FollowUpPromptBoxWithComposer({
}
footerStart={footerStart}
/>
{!isPromptBoxCompact ? (
{showContextFooter && !isPromptBoxCompact ? (
<div
data-follow-up-composer-footer=""
className="mt-1 flex min-h-6 max-h-6 select-none max-md:mt-0 max-md:pointer-coarse:min-h-11 max-md:pointer-coarse:max-h-11 items-center justify-between gap-2 overflow-hidden pl-[15px] pr-3.5 opacity-100 transition-[max-height,min-height,margin-top,opacity] duration-[180ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ vi.mock("@/hooks/useThreadCreationOptions", () => ({
setServiceTier: vi.fn(),
reasoningLevel: "medium",
setReasoningLevel: vi.fn(),
permissionMode: "auto",
permissionMode: "full",
setPermissionMode: vi.fn(),
activeModel: { model: "gpt-5" },
modelOptions: [],
Expand Down Expand Up @@ -347,8 +347,10 @@ function buildEmbeddedChat({
threadId = "thr_child",
surfaceTone = "background",
pluginComposerBottomScope,
showContextFooter,
}: {
threadId?: string;
showContextFooter?: boolean;
surfaceTone?: "background" | "sidebar";
pluginComposerBottomScope?: PluginComposerHost["scope"];
} = {}) {
Expand All @@ -374,6 +376,7 @@ function buildEmbeddedChat({
executionResetKey: "thr_parent",
permissionPolicy: "snapshot",
environmentSummary: null,
showContextFooter,
...(pluginComposerBottomScope ? { pluginComposerBottomScope } : {}),
}}
/>
Expand Down Expand Up @@ -486,48 +489,55 @@ describe("EmbeddedThreadChat", () => {
expect(mocks.injectedTimelineProps.at(-1)).toBeUndefined();
});

it("queues the submitted draft itself while the thread runtime is active", async () => {
mocks.threadRuntimeDisplayStatus = "active";
renderEmbeddedChat();
fireEvent.change(screen.getByTestId("embedded-chat-composer"), {
target: { value: "Queue me" },
});
fireEvent.click(screen.getByText("Send"));
await vi.waitFor(() => {
expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledTimes(1);
});
expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
id: "thr_child",
input: [{ type: "text", text: "Queue me", mentions: [] }],
model: "gpt-5",
permissionMode: "auto",
}),
);
expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled();
expect(
screen.getByTestId<HTMLInputElement>("embedded-chat-composer").value,
).toBe("");
});
it.each([true, false])(
"queues with inherited permissions regardless of footer visibility (%s)",
async (showContextFooter) => {
mocks.threadRuntimeDisplayStatus = "active";
renderEmbeddedChat({ showContextFooter });
fireEvent.change(screen.getByTestId("embedded-chat-composer"), {
target: { value: "Queue me" },
});
fireEvent.click(screen.getByText("Send"));
await vi.waitFor(() => {
expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledTimes(1);
});
expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
id: "thr_child",
input: [{ type: "text", text: "Queue me", mentions: [] }],
model: "gpt-5",
permissionMode: "auto",
}),
);
expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled();
expect(
screen.getByTestId<HTMLInputElement>("embedded-chat-composer").value,
).toBe("");
},
);

it("sends directly when the thread runtime is idle", async () => {
mocks.threadRuntimeDisplayStatus = "idle";
renderEmbeddedChat();
fireEvent.change(screen.getByTestId("embedded-chat-composer"), {
target: { value: "Send me" },
});
fireEvent.click(screen.getByText("Send"));
await vi.waitFor(() => {
expect(mocks.sendThreadMessageMutateAsync).toHaveBeenCalledTimes(1);
});
expect(mocks.sendThreadMessageMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
id: "thr_child",
mode: "queue-if-active",
input: [{ type: "text", text: "Send me", mentions: [] }],
}),
);
});
it.each([true, false])(
"sends with inherited permissions regardless of footer visibility (%s)",
async (showContextFooter) => {
mocks.threadRuntimeDisplayStatus = "idle";
renderEmbeddedChat({ showContextFooter });
fireEvent.change(screen.getByTestId("embedded-chat-composer"), {
target: { value: "Send me" },
});
fireEvent.click(screen.getByText("Send"));
await vi.waitFor(() => {
expect(mocks.sendThreadMessageMutateAsync).toHaveBeenCalledTimes(1);
});
expect(mocks.sendThreadMessageMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
id: "thr_child",
mode: "queue-if-active",
permissionMode: "auto",
input: [{ type: "text", text: "Send me", mentions: [] }],
}),
);
},
);

it("steers a queued row once provisioning is ready", async () => {
mocks.threadRuntimeDisplayStatus = "provisioning";
Expand Down
Loading
Loading